feat: Complete watchlist & auto-download system with UI

Implement comprehensive watchlist system with automatic episode detection
and downloading. Features include per-user watchlists, scheduler-based
periodic checks, and a modern web UI.

**Backend Components:**
- WatchlistManager: JSON-based storage with multi-tenant support
- EpisodeChecker: Detects and downloads new episodes automatically
- AutoDownloadScheduler: APScheduler-based periodic task execution
- Complete REST API for CRUD operations and scheduler control

**Frontend Components:**
- Modern watchlist page with dark theme and animations
- Real-time status updates and progress tracking
- Scheduler controls with next-run display
- Add anime directly from search results

**Models & Configuration:**
- WatchlistItem with status, quality, and auto-download settings
- WatchlistSettings for global configuration
- Per-user statistics and provider tracking

**API Endpoints:**
- GET/POST /api/watchlist - List and add items
- PUT/DELETE /api/watchlist/{id} - Update and delete
- POST /api/watchlist/{id}/check - Manual check trigger
- POST /api/watchlist/check-all - Check all due items
- GET/PUT /api/watchlist/settings - Global settings
- GET /api/watchlist/stats - Statistics
- GET/POST /api/watchlist/scheduler/* - Scheduler control

**Configuration Files:**
- config/watchlist.json - User watchlist data
- config/watchlist_settings.json - Global settings

Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2026-02-24 09:13:22 +00:00
parent c6be191699
commit da5403a307
17 changed files with 1733 additions and 259 deletions
+4 -2
View File
@@ -241,22 +241,24 @@
.anime-card-actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 15px;
}
.anime-card-actions select {
flex: 1;
width: 100%;
padding: 8px;
border: 2px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
background: rgba(0, 0, 0, 0.3);
color: #fff;
font-size: 13px;
box-sizing: border-box;
}
.anime-card-actions button {
flex: 1;
width: 100%;
padding: 8px 12px;
font-size: 12px;
}
+21 -20
View File
@@ -62,26 +62,27 @@ async function searchAnimeDetails(query, malId = null) {
const providersData = await getProvidersInfo();
// Build results HTML
streamingHtml = `
<div class="streaming-results-header">
const streamingParts = [
`<div class="streaming-results-header">
<h3>🎬 Résultats de streaming</h3>
</div>
<div class="search-results" style="margin-top: 20px;">
`;
<div class="search-results" style="margin-top: 20px;">`
];
// Display results from each provider
// Display results from each provider - render all cards in parallel
for (const [providerId, results] of Object.entries(streamingData.value.results)) {
if (results && results.length > 0) {
const provider = providersData.anime_providers[providerId];
results.forEach((anime) => {
// Use the same renderAnimeCard function from anime.js for consistency
streamingHtml += renderAnimeCard(anime, providerId, provider, 'vostfr');
});
// Render all cards for this provider
const cardPromises = results.map((anime) => renderAnimeCard(anime, providerId, provider, 'vostfr'));
const cards = await Promise.all(cardPromises);
streamingParts.push(...cards);
}
}
streamingHtml += '</div>';
streamingParts.push('</div>');
streamingHtml = streamingParts.join('');
}
// Display results
@@ -149,12 +150,12 @@ async function getProviderSearchResults(query) {
}
// Build results HTML
let html = `
<div class="streaming-results-header">
const htmlParts = [
`<div class="streaming-results-header">
<h3>🎬 Résultats de streaming</h3>
</div>
<div class="search-results" style="margin-top: 20px;">
`;
<div class="search-results" style="margin-top: 20px;">`
];
// Display results from each provider
for (const [providerId, results] of Object.entries(data.results)) {
@@ -162,16 +163,16 @@ async function getProviderSearchResults(query) {
const providersData = await getProvidersInfo();
const provider = providersData.anime_providers[providerId];
results.forEach((anime, index) => {
// Use the same renderAnimeCard function from anime.js for consistency
html += renderAnimeCard(anime, providerId, provider, 'vostfr');
});
// Render all cards for this provider in parallel
const cardPromises = results.map((anime) => renderAnimeCard(anime, providerId, provider, 'vostfr'));
const cards = await Promise.all(cardPromises);
htmlParts.push(...cards);
}
}
html += '</div>';
htmlParts.push('</div>');
return html;
return htmlParts.join('');
} catch (error) {
console.error('Error getting provider search results:', error);
+208 -4
View File
@@ -62,7 +62,7 @@ async function renderAnimeCard(anime, providerId, providerInfo, lang) {
const supportsSeasons = await providerSupportsSeasons(providerId, anime.url);
const seasonSelectHtml = supportsSeasons ? `
<select id="seasons-${providerId}-${encodeURIComponent(anime.url)}" onchange="handleSeasonChange('${providerId}', '${encodeURIComponent(anime.url)}', '${lang}')" style="margin-bottom: 8px;">
<select id="seasons-${providerId}-${encodeURIComponent(anime.url)}" onchange="handleSeasonChange('${providerId}', '${encodeURIComponent(anime.url)}', '${lang}')" style="margin-bottom: 8px; width: 100%; padding: 8px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; color: #fff; font-size: 14px;">
<option value="">Chargement des saisons...</option>
</select>
` : '';
@@ -76,8 +76,10 @@ async function renderAnimeCard(anime, providerId, providerInfo, lang) {
${metadataHtml}
<div class="anime-card-actions">
${seasonSelectHtml}
<select id="episodes-${providerId}-${encodeURIComponent(anime.url)}">
<option value="">${supportsSeasons ? 'Sélectionner une saison d\'abord' : 'Charger les épisodes...'}</option>
<select id="episodes-${providerId}-${encodeURIComponent(anime.url)}"
onclick="${!supportsSeasons ? `loadEpisodesForAnime('${providerId}', '${encodeURIComponent(anime.url)}', '${lang}')` : ''}"
onchange="${!supportsSeasons ? `loadEpisodesForAnime('${providerId}', '${encodeURIComponent(anime.url)}', '${lang}')` : ''}">
<option value="">${supportsSeasons ? 'Sélectionner une saison d\'abord' : 'Cliquez pour charger les épisodes...'}</option>
</select>
</div>
<div class="anime-card-actions" id="actions-${providerId}-${encodeURIComponent(anime.url)}" style="display:none;">
@@ -152,15 +154,21 @@ async function loadSeasonsForAnime(providerId, encodedUrl) {
const seasonSelectId = `seasons-${providerId}-${encodedUrl}`;
const seasonSelectElement = document.getElementById(seasonSelectId);
if (!seasonSelectElement) return;
if (!seasonSelectElement) {
console.log('Season select element not found:', seasonSelectId);
return;
}
// Check if provider supports seasons
const supportsSeasons = await providerSupportsSeasons(providerId, url);
if (!supportsSeasons) {
console.log('Provider does not support seasons:', providerId);
seasonSelectElement.style.display = 'none';
return;
}
console.log('Loading seasons for:', url, 'Element:', seasonSelectId);
// Mark as loading to prevent duplicate requests
if (seasonSelectElement.dataset.loading === 'true') {
console.log('Season loading already in progress, skipping...');
@@ -196,8 +204,10 @@ async function loadSeasonsForAnime(providerId, encodedUrl) {
});
console.log(`Loaded ${data.seasons.length} seasons`);
seasonSelectElement.style.display = 'block';
} else {
// No seasons found, hide season selector and load episodes directly
console.log('No seasons found, hiding selector');
seasonSelectElement.style.display = 'none';
loadEpisodesForAnime(providerId, encodedUrl, 'vostfr');
}
@@ -378,6 +388,195 @@ async function handleDownloadSeason(encodedUrl, lang) {
}
}
/**
* Load all seasons and episodes and display them
*/
async function loadAllSeasonsAndEpisodes(providerId, encodedUrl, lang) {
const url = decodeURIComponent(encodedUrl);
const cardId = `anime-${providerId}-${encodedUrl}`;
const card = document.getElementById(cardId);
if (!card) {
console.error('Card not found:', cardId);
return;
}
// Remove existing all-seasons container if present
const existingContainer = document.getElementById(`all-seasons-${providerId}-${encodedUrl}`);
if (existingContainer) {
existingContainer.remove();
return;
}
// Create container for all seasons
const container = document.createElement('div');
container.id = `all-seasons-${providerId}-${encodedUrl}`;
container.style.cssText = 'margin-top: 16px;';
try {
// Fetch all seasons
const response = await fetch(`${API_BASE}/anime/seasons?url=${encodeURIComponent(url)}`);
if (!response.ok) {
throw new Error('Failed to fetch seasons');
}
const data = await response.json();
if (!data.seasons || data.seasons.length === 0) {
container.innerHTML = '<div style="padding: 10px; color: #888;">Aucune saison disponible</div>';
card.appendChild(container);
return;
}
// Create HTML for all seasons
let html = '<div style="margin-bottom: 12px;"><strong>Toutes les saisons</strong></div>';
for (const season of data.seasons) {
const seasonId = `season-${encodeURIComponent(season.url)}`;
html += `
<div class="season-block" style="margin-bottom: 12px; padding: 12px; background: rgba(255, 255, 255, 0.05); border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.1);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div style="font-weight: 600; color: #00d9ff;">${escapeHtml(season.title)}</div>
<div style="font-size: 12px; color: #888;">${season.episode_count || '?'} épisodes</div>
</div>
<div id="${seasonId}-episodes" style="display: none;">
<select class="episode-select" data-season-url="${escapeHtml(season.url)}" style="width: 100%; margin-bottom: 8px;">
<option value="">Cliquez pour charger les épisodes...</option>
</select>
<div class="season-actions" style="display: none; gap: 8px;">
<button class="btn-primary btn-small" onclick="downloadSeasonEpisode('${encodeURIComponent(season.url)}', '${providerId}', '${lang}')">
📥 Télécharger
</button>
<button class="btn-secondary btn-small" onclick="downloadEntireSeason('${encodeURIComponent(season.url)}', '${lang}')" style="background: linear-gradient(45deg, #ff6b6b, #ffa500);">
📦 Saison complète
</button>
</div>
</div>
<button class="btn-secondary btn-small" onclick="toggleSeasonEpisodes('${seasonId}')" style="width: 100%;">
▼ Afficher les épisodes
</button>
</div>
`;
}
container.innerHTML = html;
card.appendChild(container);
} catch (error) {
console.error('Error loading all seasons:', error);
container.innerHTML = '<div style="padding: 10px; color: #ff6b6b;">Erreur de chargement des saisons</div>';
card.appendChild(container);
}
}
/**
* Toggle season episodes visibility
*/
function toggleSeasonEpisodes(seasonId) {
const episodesDiv = document.getElementById(`${seasonId}-episodes`);
const button = episodesDiv.parentElement.querySelector('button[onclick^="toggleSeasonEpisodes"]');
if (episodesDiv.style.display === 'none') {
episodesDiv.style.display = 'block';
button.textContent = '▲ Masquer les épisodes';
// Load episodes if not already loaded
const select = episodesDiv.querySelector('.episode-select');
if (select && select.options.length <= 1) {
const seasonUrl = select.dataset.seasonUrl;
loadSeasonEpisodes(seasonUrl, select);
}
} else {
episodesDiv.style.display = 'none';
button.textContent = '▼ Afficher les épisodes';
}
}
/**
* Load episodes for a specific season
*/
async function loadSeasonEpisodes(seasonUrl, selectElement) {
try {
selectElement.innerHTML = '<option value="">Chargement...</option>';
selectElement.disabled = true;
const data = await loadEpisodes(decodeURIComponent(seasonUrl), 'vostfr');
if (data.episodes && data.episodes.length > 0) {
selectElement.innerHTML = '<option value="">Sélectionner un épisode</option>';
data.episodes.forEach(ep => {
const option = document.createElement('option');
option.value = ep.url;
option.textContent = `Épisode ${ep.episode}`;
selectElement.appendChild(option);
});
selectElement.disabled = false;
// Show action buttons
const actionsDiv = selectElement.parentElement.querySelector('.season-actions');
if (actionsDiv) {
actionsDiv.style.display = 'flex';
}
} else {
selectElement.innerHTML = '<option value="">Aucun épisode disponible</option>';
}
} catch (error) {
console.error('Error loading episodes:', error);
selectElement.innerHTML = '<option value="">Erreur de chargement</option>';
}
}
/**
* Download selected episode from season
*/
async function downloadSeasonEpisode(encodedSeasonUrl, providerId, lang) {
const seasonUrl = decodeURIComponent(encodedSeasonUrl);
const selectElement = document.querySelector(`[data-season-url="${seasonUrl}"]`);
if (!selectElement) {
console.error('Select element not found');
return;
}
const episodeUrl = selectElement.value;
if (!episodeUrl) {
alert('Veuillez sélectionner un épisode');
return;
}
try {
await downloadEpisode(episodeUrl);
loadDownloads();
alert('Téléchargement démarré!');
selectElement.value = '';
} catch (error) {
console.error('Download error:', error);
alert('Erreur lors du démarrage du téléchargement');
}
}
/**
* Download entire season
*/
async function downloadEntireSeason(encodedSeasonUrl, lang) {
const seasonUrl = decodeURIComponent(encodedSeasonUrl);
if (!confirm(`⚠️ Attention: Vous allez télécharger toute cette saison. Cela peut prendre du temps et utiliser beaucoup d'espace disque.\n\nVoulez-vous continuer ?`)) {
return;
}
try {
const data = await downloadSeason(seasonUrl, lang);
loadDownloads();
alert(`${data.message}\n\n${data.total_episodes} épisodes ont été ajoutés à la file de téléchargement!`);
} catch (error) {
console.error('Season download error:', error);
alert('Erreur lors du démarrage du téléchargement de la saison');
}
}
/**
* Handle search form submission
*/
@@ -434,3 +633,8 @@ window.handleDownloadEpisode = handleDownloadEpisode;
window.handleDownloadSeason = handleDownloadSeason;
window.handleSearch = handleSearch;
window.handleDirectDownload = handleDirectDownload;
window.loadAllSeasonsAndEpisodes = loadAllSeasonsAndEpisodes;
window.toggleSeasonEpisodes = toggleSeasonEpisodes;
window.loadSeasonEpisodes = loadSeasonEpisodes;
window.downloadSeasonEpisode = downloadSeasonEpisode;
window.downloadEntireSeason = downloadEntireSeason;
+1 -1
View File
@@ -33,7 +33,7 @@ async function providerSupportsSeasons(providerId, url = null) {
return provider.supports_seasons;
}
// Otherwise, check by provider ID (known season-supporting providers)
return ['animesama', 'frenchmanga'].includes(providerId);
return ['anime-sama', 'anime-ultime', 'french-manga'].includes(providerId);
}
// Fallback: check URL if provided
+13 -14
View File
@@ -1,14 +1,13 @@
/**
* Watchlist management and auto-download UI
* Note: API_BASE is defined in api.js (loaded before this file)
*/
const API_BASE = '/api';
/**
* Get user's watchlist
*/
async function getWatchlist(status = null) {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -35,7 +34,7 @@ async function getWatchlist(status = null) {
* Add anime to watchlist
*/
async function addToWatchlist(animeData) {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -61,7 +60,7 @@ async function addToWatchlist(animeData) {
* Update watchlist item
*/
async function updateWatchlistItem(itemId, updateData) {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -86,7 +85,7 @@ async function updateWatchlistItem(itemId, updateData) {
* Delete from watchlist
*/
async function deleteFromWatchlist(itemId) {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -123,7 +122,7 @@ async function resumeWatchlistItem(itemId) {
* Check specific anime for new episodes
*/
async function checkWatchlistItem(itemId) {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -146,7 +145,7 @@ async function checkWatchlistItem(itemId) {
* Check all watchlist items
*/
async function checkAllWatchlistItems() {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -169,7 +168,7 @@ async function checkAllWatchlistItems() {
* Get watchlist settings
*/
async function getWatchlistSettings() {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -191,7 +190,7 @@ async function getWatchlistSettings() {
* Update watchlist settings
*/
async function updateWatchlistSettings(settings) {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -216,7 +215,7 @@ async function updateWatchlistSettings(settings) {
* Get watchlist statistics
*/
async function getWatchlistStats() {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -238,7 +237,7 @@ async function getWatchlistStats() {
* Get scheduler status
*/
async function getSchedulerStatus() {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -260,7 +259,7 @@ async function getSchedulerStatus() {
* Start scheduler
*/
async function startScheduler() {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -283,7 +282,7 @@ async function startScheduler() {
* Stop scheduler
*/
async function stopScheduler() {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}