c6be191699
## 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 <[email protected]>
Co-Authored-By: Happy <[email protected]>
332 lines
14 KiB
JavaScript
332 lines
14 KiB
JavaScript
/**
|
||
* Watchlist UI functions
|
||
*/
|
||
|
||
/**
|
||
* Display watchlist items
|
||
*/
|
||
async function displayWatchlist(status = null) {
|
||
const container = document.getElementById('watchlistContainer');
|
||
if (!container) return;
|
||
|
||
try {
|
||
container.innerHTML = '<div class="loading">Chargement de la watchlist...</div>';
|
||
|
||
const items = await getWatchlist(status);
|
||
const stats = await getWatchlistStats();
|
||
|
||
if (items.length === 0) {
|
||
container.innerHTML = `
|
||
<div class="empty-watchlist">
|
||
<div style="text-align: center; padding: 60px 20px;">
|
||
<svg style="width:80px;height:80px;margin:0 auto 20px;opacity:0.3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M15 17h5l-1.405-1.405A2.032 2.032 0 018.138 7.702 10.78 1.478 1.482-1.478-10.78-1.478 1.478-8.138 1.478-1.478 1.478-1.478-8.138 1.478-1.478 1.478-8.138 1.478z"></path>
|
||
</svg>
|
||
<h3 style="color: #666; margin-bottom: 10px;">Aucun anime dans votre watchlist</h3>
|
||
<p style="color: #999;">Ajoutez des animes depuis la recherche pour commencer le suivi automatique</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
// Render stats
|
||
let statsHtml = '';
|
||
if (stats && stats.total > 0) {
|
||
statsHtml = `
|
||
<div class="watchlist-stats" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px; margin-bottom: 30px;">
|
||
<div class="stat-card" style="background: rgba(0, 217, 255, 0.1); padding: 15px; border-radius: 10px; text-align: center;">
|
||
<div class="stat-value" style="font-size: 32px; font-weight: bold; color: #00d9ff;">${stats.total}</div>
|
||
<div class="stat-label" style="font-size: 12px; color: #999; text-transform: uppercase; margin-top: 5px;">Total</div>
|
||
</div>
|
||
<div class="stat-card" style="background: rgba(76, 175, 80, 0.1); padding: 15px; border-radius: 10px; text-align: center;">
|
||
<div class="stat-value" style="font-size: 32px; font-weight: bold; color: #4caf50;">${stats.active}</div>
|
||
<div class="stat-label" style="font-size: 12px; color: #999; text-transform: uppercase; margin-top: 5px;">Actifs</div>
|
||
</div>
|
||
<div class="stat-card" style="background: rgba(255, 152, 0, 0.1); padding: 15px; border-radius: 10px; text-align: center;">
|
||
<div class="stat-value" style="font-size: 32px; font-weight: bold; color: #ff9800;">${stats.paused}</div>
|
||
<div class="stat-label" style="font-size: 12px; color: #999; text-transform: uppercase; margin-top: 5px;">En pause</div>
|
||
</div>
|
||
<div class="stat-card" style="background: rgba(158, 158, 158, 0.1); padding: 15px; border-radius: 10px; text-align: center;">
|
||
<div class="stat-value" style="font-size: 32px; font-weight: bold; color: #9e9e9e;">${stats.completed}</div>
|
||
<div class="stat-label" style="font-size: 12px; color: #999; text-transform: uppercase; margin-top: 5px;">Terminés</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Render items
|
||
let itemsHtml = '';
|
||
items.forEach(item => {
|
||
const statusIcon = getStatusIcon(item.status);
|
||
const statusBadge = getStatusBadge(item.status);
|
||
const lastEpInfo = item.last_episode_downloaded > 0
|
||
? `<span style="color: #999;">Dernier épisode: ${item.last_episode_downloaded}</span>`
|
||
: '';
|
||
|
||
itemsHtml += `
|
||
<div class="watchlist-item" id="watchlist-${item.id}" style="background: rgba(255,255,255,0.05); border-radius: 12px; padding: 20px; margin-bottom: 15px; border: 1px solid rgba(255,255,255,0.1);">
|
||
<div style="display: flex; justify-content: space-between; align-items: start; gap: 20px;">
|
||
<div style="flex: 1;">
|
||
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
|
||
<h3 style="color: #fff; margin: 0; font-size: 18px;">${escapeHtml(item.anime_title)}</h3>
|
||
${statusBadge}
|
||
</div>
|
||
|
||
<div style="font-size: 13px; color: #999; margin-bottom: 8px;">
|
||
${statusIcon} ${item.provider_id} • ${item.lang.toUpperCase()}
|
||
</div>
|
||
|
||
${lastEpInfo ? `
|
||
<div style="font-size: 12px; color: #999; margin-bottom: 8px;">
|
||
${lastEpInfo}
|
||
</div>
|
||
` : ''}
|
||
|
||
${item.last_checked ? `
|
||
<div style="font-size: 12px; color: #666;">
|
||
Dernière vérification: ${new Date(item.last_checked).toLocaleString('fr-FR')}
|
||
</div>
|
||
` : '<div style="font-size: 12px; color: #666;">Jamais vérifié</div>'}
|
||
</div>
|
||
|
||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||
${item.status === 'active' && item.auto_download ? `
|
||
<button class="btn-secondary btn-small" onclick="handlePauseWatchlist('${item.id}')" style="padding: 6px 12px; font-size: 12px;" title="Mettre en pause">
|
||
⏸️ Pause
|
||
</button>
|
||
` : item.status === 'paused' ? `
|
||
<button class="btn-primary btn-small" onclick="handleResumeWatchlist('${item.id}')" style="padding: 6px 12px; font-size: 12px;" title="Reprendre">
|
||
▶️ Reprendre
|
||
</button>
|
||
` : ''}
|
||
|
||
<button class="btn-secondary btn-small" onclick="handleCheckItem('${item.id}')" style="padding: 6px 12px; font-size: 12px;" title="Vérifier maintenant">
|
||
🔍 Vérifier
|
||
</button>
|
||
|
||
<button class="btn-secondary btn-small" onclick="handleDeleteWatchlist('${item.id}')" style="padding: 6px 12px; font-size: 12px; background: rgba(244, 67, 54, 0.2); border-color: rgba(244, 67, 54, 0.5);" title="Supprimer">
|
||
🗑️
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
${item.synopsis ? `
|
||
<details style="margin-top: 15px;">
|
||
<summary style="cursor: pointer; color: #999; font-size: 13px; padding: 5px 0;">📖 Synopsis</summary>
|
||
<p style="color: #ccc; font-size: 13px; line-height: 1.5; margin-top: 10px;">${escapeHtml(item.synopsis)}</p>
|
||
</details>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
container.innerHTML = statsHtml + itemsHtml;
|
||
|
||
} catch (error) {
|
||
console.error('Error loading watchlist:', error);
|
||
container.innerHTML = `
|
||
<div class="error-message" style="text-align: center; padding: 40px; color: #f44;">
|
||
❌ Erreur lors du chargement: ${error.message}
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get status icon
|
||
*/
|
||
function getStatusIcon(status) {
|
||
const icons = {
|
||
'active': '✅',
|
||
'paused': '⏸️',
|
||
'completed': '✨',
|
||
'archived': '📦'
|
||
};
|
||
return icons[status] || '📌';
|
||
}
|
||
|
||
/**
|
||
* Get status badge
|
||
*/
|
||
function getStatusBadge(status) {
|
||
const badges = {
|
||
'active': '<span style="background: rgba(76, 175, 80, 0.2); color: #4caf50; padding: 3px 10px; border-radius: 12px; font-size: 11px; text-transform: uppercase;">Actif</span>',
|
||
'paused': '<span style="background: rgba(255, 152, 0, 0.2); color: #ff9800; padding: 3px 10px; border-radius: 12px; font-size: 11px; text-transform: uppercase;">En pause</span>',
|
||
'completed': '<span style="background: rgba(158, 158, 158, 0.2); color: #9e9e9e; padding: 3px 10px; border-radius: 12px; font-size: 11px; text-transform: uppercase;">Terminé</span>',
|
||
'archived': '<span style="background: rgba(33, 150, 243, 0.2); color: #2196f3; padding: 3px 10px; border-radius: 12px; font-size: 11px; text-transform: uppercase;">Archivé</span>'
|
||
};
|
||
return badges[status] || '';
|
||
}
|
||
|
||
/**
|
||
* Add anime to watchlist from search results
|
||
*/
|
||
async function handleAddToWatchlist(animeUrl, providerId) {
|
||
try {
|
||
// Get anime details from the DOM or API
|
||
const response = await fetch(`${API_BASE}/anime/metadata?url=${encodeURIComponent(animeUrl)}`);
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch anime details');
|
||
}
|
||
|
||
const metadata = await response.json();
|
||
|
||
const itemData = {
|
||
anime_title: metadata.title || 'Unknown Anime',
|
||
anime_url: animeUrl,
|
||
provider_id: providerId,
|
||
lang: 'vostfr',
|
||
auto_download: true,
|
||
quality_preference: 'auto',
|
||
poster_image: metadata.poster_image || null,
|
||
cover_image: metadata.cover_image || null,
|
||
synopsis: metadata.synopsis || null,
|
||
genres: metadata.genres || []
|
||
};
|
||
|
||
const result = await addToWatchlist(itemData);
|
||
|
||
alert(`✅ "${result.anime_title}" a été ajouté à votre watchlist!`);
|
||
|
||
// Update button to show it's already in watchlist
|
||
updateAddButton(animeUrl, true);
|
||
|
||
} catch (error) {
|
||
console.error('Error adding to watchlist:', error);
|
||
alert(`❌ Erreur: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update add button state
|
||
*/
|
||
function updateAddButton(animeUrl, isInWatchlist) {
|
||
// Find all buttons for this anime
|
||
const buttons = document.querySelectorAll(`[data-watchlist-url="${encodeURIComponent(animeUrl)}"]`);
|
||
|
||
buttons.forEach(button => {
|
||
if (isInWatchlist) {
|
||
button.innerHTML = '✓ Suivi';
|
||
button.disabled = true;
|
||
button.style.opacity = '0.6';
|
||
} else {
|
||
button.innerHTML = '+ Suivre';
|
||
button.disabled = false;
|
||
button.style.opacity = '1';
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Pause watchlist item
|
||
*/
|
||
async function handlePauseWatchlist(itemId) {
|
||
try {
|
||
await pauseWatchlistItem(itemId);
|
||
await displayWatchlist();
|
||
alert('✅ Anime mis en pause');
|
||
} catch (error) {
|
||
console.error('Error pausing item:', error);
|
||
alert(`❌ Erreur: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resume watchlist item
|
||
*/
|
||
async function handleResumeWatchlist(itemId) {
|
||
try {
|
||
await resumeWatchlistItem(itemId);
|
||
await displayWatchlist();
|
||
alert('✅ Anime réactivé');
|
||
} catch (error) {
|
||
console.error('Error resuming item:', error);
|
||
alert(`❌ Erreur: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check specific item
|
||
*/
|
||
async function handleCheckItem(itemId) {
|
||
const button = event.target;
|
||
const originalText = button.innerHTML;
|
||
|
||
try {
|
||
button.disabled = true;
|
||
button.innerHTML = '⏳...';
|
||
|
||
const result = await checkWatchlistItem(itemId);
|
||
|
||
if (result.new_episodes_found > 0) {
|
||
alert(`🎉 ${result.new_episodes_found} nouveau(x) épisode(s) trouvé(s)!\n\n${result.episodes_downloaded.length} téléchargé(s)`);
|
||
} else {
|
||
alert('ℹ️ Aucun nouvel épisode trouvé');
|
||
}
|
||
|
||
await displayWatchlist();
|
||
|
||
} catch (error) {
|
||
console.error('Error checking item:', error);
|
||
alert(`❌ Erreur: ${error.message}`);
|
||
} finally {
|
||
button.disabled = false;
|
||
button.innerHTML = originalText;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Delete watchlist item
|
||
*/
|
||
async function handleDeleteWatchlist(itemId) {
|
||
if (!confirm('⚠️ Êtes-vous sûr de vouloir supprimer cet anime de votre watchlist ?')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await deleteFromWatchlist(itemId);
|
||
await displayWatchlist();
|
||
alert('✅ Anime supprimé de la watchlist');
|
||
} catch (error) {
|
||
console.error('Error deleting item:', error);
|
||
alert(`❌ Erreur: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check all items
|
||
*/
|
||
async function handleCheckAll() {
|
||
const button = event.target;
|
||
const originalText = button.innerHTML;
|
||
|
||
try {
|
||
button.disabled = true;
|
||
button.innerHTML = '⏳ Vérification...';
|
||
|
||
const result = await checkAllWatchlistItems();
|
||
|
||
alert(`✅ Vérification terminée!\n\n${result.checked} animes vérifiés\n${result.total_new_episodes} nouveaux épisodes trouvés\n${result.total_downloaded} téléchargés`);
|
||
|
||
await displayWatchlist();
|
||
|
||
} catch (error) {
|
||
console.error('Error checking all:', error);
|
||
alert(`❌ Erreur: ${error.message}`);
|
||
} finally {
|
||
button.disabled = false;
|
||
button.innerHTML = originalText;
|
||
}
|
||
}
|
||
|
||
// Make functions available globally
|
||
window.displayWatchlist = displayWatchlist;
|
||
window.handleAddToWatchlist = handleAddToWatchlist;
|
||
window.handlePauseWatchlist = handlePauseWatchlist;
|
||
window.handleResumeWatchlist = handleResumeWatchlist;
|
||
window.handleCheckItem = handleCheckItem;
|
||
window.handleDeleteWatchlist = handleDeleteWatchlist;
|
||
window.handleCheckAll = handleCheckAll;
|