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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user