Files
ohm_streaming/static/js/watchlist-ui.js
T
root c6be191699 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 <[email protected]>
Co-Authored-By: Happy <[email protected]>
2026-01-29 21:56:39 +00:00

332 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;