feat: Complete watchlist & auto-download system with UI
## Backend Implementation (100% Complete)
### Core Components
- **WatchlistManager**: JSON-based storage with full CRUD operations
- User-scoped data access for multi-tenant support
- Statistics and query functions
- Settings management with persistence
- **EpisodeChecker**: Automatic new episode detection
- Checks for new episodes using existing downloaders
- Automatic download with error handling
- Manual and scheduled check support
- Lazy initialization to avoid circular imports
- **AutoDownloadScheduler**: APScheduler-based periodic checking
- Configurable intervals (1-168 hours)
- Start/stop/restart controls
- Next run time tracking
### API Endpoints (15 endpoints)
- POST /api/watchlist - Add anime to watchlist
- GET /api/watchlist - Get user's watchlist (with status filter)
- GET /api/watchlist/{id} - Get specific item
- PUT /api/watchlist/{id} - Update item
- DELETE /api/watchlist/{id} - Delete item
- POST /api/watchlist/{id}/check - Check for new episodes
- POST /api/watchlist/{id}/pause - Pause tracking
- POST /api/watchlist/{id}/resume - Resume tracking
- GET /api/watchlist/settings - Get settings
- PUT /api/watchlist/settings - Update settings
- GET /api/watchlist/stats - Get statistics
- POST /api/watchlist/check-all - Check all items
- GET /api/watchlist/scheduler/status - Scheduler status
- POST /api/watchlist/scheduler/start - Start scheduler
- POST /api/watchlist/scheduler/stop - Stop scheduler
### Bug Fixes
- Fixed WatchlistManager.update() to accept both dict and WatchlistItemUpdate
- Added asyncio import to AutoDownloadScheduler for event loop detection
- Improved scheduler start() with better error handling
## Frontend Implementation (100% Complete)
### UI Components
- **Watchlist Page** (/watchlist)
- Scheduler status panel with start/stop/check all buttons
- Filter tabs (all/active/paused/completed)
- Statistics display with color-coded cards
- Watchlist items with pause/resume/delete controls
- Auto-refresh every 30 seconds
- Authentication check
- **Settings Modal**
- Check interval configuration (1-168h)
- Auto-download toggle
- Max concurrent downloads slider
- Notifications toggle
- Live settings update with scheduler restart
- **"Suivre" Button**
- Added to anime search result cards
- Purple gradient with heart icon
- Quick-add to watchlist functionality
- State tracking (disabled when already in watchlist)
### JavaScript Files
- **static/js/watchlist.js**: API client functions
- All watchlist API calls with token auth
- Error handling and response parsing
- **static/js/watchlist-ui.js**: UI functions
- Display watchlist with stats
- Handle add/pause/resume/delete
- Filter by status
- Settings modal management
- **static/js/tabs.js**: Watchlist tab handler
- Redirects to /watchlist page
## Testing
### Test Suite (test_watchlist_simple.py)
All tests passing (3/3):
1. **Watchlist Manager Tests** ✅
- Create/read/update/delete operations
- User-scoped queries
- Statistics generation
- Check time updates
2. **Settings Tests** ✅
- Get current settings
- Update settings with validation
- Reset to defaults
3. **Scheduler Tests** ✅
- Start/stop/restart controls
- Running status verification
- Next run time tracking
### Dependencies
- APScheduler 3.11.0 installed in virtual environment
- tzlocal 5.3.1 (APScheduler dependency)
## Documentation
- docs/WATCHLIST_AUTO_DOWNLOAD.md: Complete system documentation
- API endpoints with examples
- Architecture overview
- Usage examples
- Troubleshooting guide
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Watchlist management and auto-download UI
|
||||
*/
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
/**
|
||||
* Get user's watchlist
|
||||
*/
|
||||
async function getWatchlist(status = null) {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
let url = `${API_BASE}/watchlist`;
|
||||
if (status) {
|
||||
url += `?status=${status}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch watchlist');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add anime to watchlist
|
||||
*/
|
||||
async function addToWatchlist(animeData) {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(animeData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to add to watchlist');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update watchlist item
|
||||
*/
|
||||
async function updateWatchlistItem(itemId, updateData) {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist/${itemId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update watchlist item');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete from watchlist
|
||||
*/
|
||||
async function deleteFromWatchlist(itemId) {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete from watchlist');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause watchlist item
|
||||
*/
|
||||
async function pauseWatchlistItem(itemId) {
|
||||
return await updateWatchlistItem(itemId, { status: 'paused' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume watchlist item
|
||||
*/
|
||||
async function resumeWatchlistItem(itemId) {
|
||||
return await updateWatchlistItem(itemId, { status: 'active' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check specific anime for new episodes
|
||||
*/
|
||||
async function checkWatchlistItem(itemId) {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist/${itemId}/check`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to check for new episodes');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all watchlist items
|
||||
*/
|
||||
async function checkAllWatchlistItems() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist/check-all`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to check all items');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get watchlist settings
|
||||
*/
|
||||
async function getWatchlistSettings() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist/settings`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch settings');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update watchlist settings
|
||||
*/
|
||||
async function updateWatchlistSettings(settings) {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist/settings`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update settings');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get watchlist statistics
|
||||
*/
|
||||
async function getWatchlistStats() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist/stats`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch statistics');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduler status
|
||||
*/
|
||||
async function getSchedulerStatus() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist/scheduler/status`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch scheduler status');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start scheduler
|
||||
*/
|
||||
async function startScheduler() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist/scheduler/start`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to start scheduler');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop scheduler
|
||||
*/
|
||||
async function stopScheduler() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist/scheduler/stop`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to stop scheduler');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// Make functions available globally
|
||||
window.getWatchlist = getWatchlist;
|
||||
window.addToWatchlist = addToWatchlist;
|
||||
window.updateWatchlistItem = updateWatchlistItem;
|
||||
window.deleteFromWatchlist = deleteFromWatchlist;
|
||||
window.pauseWatchlistItem = pauseWatchlistItem;
|
||||
window.resumeWatchlistItem = resumeWatchlistItem;
|
||||
window.checkWatchlistItem = checkWatchlistItem;
|
||||
window.checkAllWatchlistItems = checkAllWatchlistItems;
|
||||
window.getWatchlistSettings = getWatchlistSettings;
|
||||
window.updateWatchlistSettings = updateWatchlistSettings;
|
||||
window.getWatchlistStats = getWatchlistStats;
|
||||
window.getSchedulerStatus = getSchedulerStatus;
|
||||
window.startScheduler = startScheduler;
|
||||
window.stopScheduler = stopScheduler;
|
||||
Reference in New Issue
Block a user