Files
ohm_streaming/static/js/anime.js
T
root da5403a307 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 <[email protected]>
2026-02-24 09:13:22 +00:00

641 lines
25 KiB
JavaScript

/**
* Anime search and episode management
*/
/**
* Display search results
*/
async function displaySearchResults(data, lang) {
const resultsContainer = document.getElementById('searchResults');
const providers = await getProvidersInfo();
let totalResults = 0;
let htmlPromises = [];
for (const [providerId, results] of Object.entries(data.results)) {
if (results && results.length > 0) {
totalResults += results.length;
results.forEach(anime => {
const providerInfo = providers.anime_providers[providerId];
// Collect promises for async rendering
htmlPromises.push(renderAnimeCard(anime, providerId, providerInfo, lang));
});
}
}
if (totalResults === 0) {
resultsContainer.innerHTML = '<div class="no-results">Aucun résultat trouvé</div>';
return;
}
// Wait for all cards to be rendered
const htmlSegments = await Promise.all(htmlPromises);
resultsContainer.innerHTML = htmlSegments.join('');
// Auto-load seasons for providers that support them
// Stagger the requests to avoid overwhelming the server
let delayCounter = 0;
for (const [providerId, results] of Object.entries(data.results)) {
if (results && results.length > 0) {
results.forEach((anime, index) => {
// Stagger requests: 500ms delay between each anime
setTimeout(() => {
// Try to load seasons first (if provider supports them)
if (anime.url) {
loadSeasonsForAnime(providerId, encodeURIComponent(anime.url));
}
}, 500 * index);
delayCounter++;
});
}
}
}
/**
* Render anime card HTML
*/
async function renderAnimeCard(anime, providerId, providerInfo, lang) {
const metadataHtml = renderAnimeMetadata(anime.metadata);
// Check if provider supports seasons using helper function
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; 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>
` : '';
return `
<div class="anime-card" id="anime-${providerId}-${encodeURIComponent(anime.url)}">
<div class="anime-card-header">
<div class="anime-card-title">${escapeHtml(anime.title)}</div>
<div class="anime-card-provider">${providerInfo?.icon || ''} ${providerInfo?.name || providerId}</div>
</div>
${metadataHtml}
<div class="anime-card-actions">
${seasonSelectHtml}
<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;">
<button class="btn-primary" onclick="handleDownloadEpisode('${encodeURIComponent(anime.url)}', '${providerId}', '${lang}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;margin-right:4px;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Télécharger
</button>
<button class="btn-primary" style="background: linear-gradient(45deg, #ff6b6b, #ffa500);" onclick="handleDownloadSeason('${encodeURIComponent(anime.url)}', '${lang}')" title="Télécharger toute la saison">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;margin-right:4px;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
</svg>
Toute la saison
</button>
<button class="btn-secondary" onclick="handleAddToWatchlist('${encodeURIComponent(anime.url)}', '${providerId}')"
data-watchlist-url="${encodeURIComponent(anime.url)}"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; padding: 6px 16px; font-size: 13px; border-radius: 6px; cursor: pointer; transition: all 0.2s;"
onmouseover="this.style.transform='scale(1.05)'"
onmouseout="this.style.transform='scale(1)'">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;margin-right:4px;vertical-align:middle;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364 0z"></path>
</svg>
<span style="font-weight:500;">+ Suivre</span>
</button>
</div>
</div>
`;
}
/**
* Render anime metadata
*/
function renderAnimeMetadata(metadata) {
if (!metadata) return '';
let metaParts = [];
if (metadata.release_year) metaParts.push(`📅 ${metadata.release_year}`);
if (metadata.rating) metaParts.push(`${metadata.rating}`);
if (metadata.genres && metadata.genres.length > 0) metaParts.push(`🏷️ ${metadata.genres.slice(0, 3).join(', ')}`);
if (metadata.total_episodes) metaParts.push(`📺 ${metadata.total_episodes} épisodes`);
if (metadata.status) metaParts.push(`📡 ${metadata.status === 'Ongoing' ? 'En cours' : 'Terminé'}`);
let html = '';
if (metaParts.length > 0) {
html += `
<div class="anime-metadata">
${metaParts.join(' • ')}
</div>
`;
}
if (metadata.synopsis) {
html += `
<details class="anime-synopsis">
<summary>📖 Synopsis</summary>
<p>${escapeHtml(metadata.synopsis)}</p>
</details>
`;
}
return html;
}
/**
* Load seasons for anime (if provider supports it)
*/
async function loadSeasonsForAnime(providerId, encodedUrl) {
const url = decodeURIComponent(encodedUrl);
const seasonSelectId = `seasons-${providerId}-${encodedUrl}`;
const seasonSelectElement = document.getElementById(seasonSelectId);
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...');
return;
}
seasonSelectElement.dataset.loading = 'true';
try {
// Add timeout to the fetch
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000); // 15 second timeout
const response = await fetch(`${API_BASE}/anime/seasons?url=${encodeURIComponent(url)}`, {
signal: controller.signal
});
clearTimeout(timeoutId);
if (response.ok) {
const data = await response.json();
if (data.seasons && data.seasons.length > 0) {
seasonSelectElement.innerHTML = '<option value="">Sélectionner une saison</option>';
data.seasons.forEach(season => {
const option = document.createElement('option');
option.value = season.url;
const episodeText = season.episode_count ?
`${season.episode_count} épisodes` :
'Chargement...';
option.textContent = `${season.title} (${episodeText})`;
option.dataset.seasonNum = season.season;
seasonSelectElement.appendChild(option);
});
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');
}
} else {
console.error('Failed to load seasons:', response.status);
seasonSelectElement.style.display = 'none';
loadEpisodesForAnime(providerId, encodedUrl, 'vostfr');
}
} catch (error) {
if (error.name === 'AbortError') {
console.error('Season loading timeout');
seasonSelectElement.innerHTML = '<option value="">⏱️ Timeout - Réessayez</option>';
// Add retry functionality
seasonSelectElement.disabled = false;
seasonSelectElement.onclick = () => {
seasonSelectElement.dataset.loading = 'false';
seasonSelectElement.onclick = null;
loadSeasonsForAnime(providerId, encodedUrl);
};
} else {
console.error('Error loading seasons:', error);
seasonSelectElement.style.display = 'none';
loadEpisodesForAnime(providerId, encodedUrl, 'vostfr');
}
} finally {
seasonSelectElement.dataset.loading = 'false';
}
}
/**
* Handle season selection change
*/
async function handleSeasonChange(providerId, encodedUrl, lang) {
const seasonSelectId = `seasons-${providerId}-${encodedUrl}`;
const seasonSelectElement = document.getElementById(seasonSelectId);
const selectedSeasonUrl = seasonSelectElement.value;
const encodedSeasonUrl = encodeURIComponent(selectedSeasonUrl);
if (!selectedSeasonUrl) {
// Clear episodes if no season selected
const episodeSelectId = `episodes-${providerId}-${encodedUrl}`;
const episodeSelectElement = document.getElementById(episodeSelectId);
episodeSelectElement.innerHTML = '<option value="">Sélectionner une saison d\'abord</option>';
episodeSelectElement.disabled = true;
return;
}
// Find the episode select element (it's based on the original anime URL)
const episodeSelectId = `episodes-${providerId}-${encodedUrl}`;
const selectElement = document.getElementById(episodeSelectId);
if (!selectElement) {
console.error('Episode select element not found:', episodeSelectId);
return;
}
// Show loading state
selectElement.innerHTML = '<option value="">Chargement...</option>';
selectElement.disabled = false;
try {
// Load episodes for the selected season
const data = await loadEpisodes(selectedSeasonUrl, lang);
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);
});
// Show download buttons
const actionsId = `actions-${providerId}-${encodedUrl}`;
const actionsDiv = document.getElementById(actionsId);
actionsDiv.style.display = 'flex';
} else {
selectElement.innerHTML = '<option value="">Aucun épisode disponible</option>';
selectElement.disabled = true;
}
} catch (error) {
console.error('Error loading episodes:', error);
selectElement.innerHTML = '<option value="">Erreur de chargement</option>';
}
}
/**
* Load episodes for an anime
*/
async function loadEpisodesForAnime(providerId, encodedUrl, lang) {
const url = decodeURIComponent(encodedUrl);
const selectId = `episodes-${providerId}-${encodedUrl}`;
const actionsId = `actions-${providerId}-${encodedUrl}`;
const selectElement = document.getElementById(selectId);
if (!selectElement) return;
selectElement.innerHTML = '<option value="">Chargement...</option>';
try {
const data = await loadEpisodes(url, lang);
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);
});
// Show download buttons
const actionsDiv = document.getElementById(actionsId);
actionsDiv.style.display = 'flex';
} else {
selectElement.innerHTML = '<option value="">Aucun épisode disponible</option>';
selectElement.disabled = true;
// Add warning message
const card = document.getElementById(`anime-${providerId}-${encodedUrl}`);
if (card) {
const warning = document.createElement('div');
warning.style.cssText = 'margin-top: 10px; padding: 10px; background: rgba(255, 100, 100, 0.2); border-radius: 6px; font-size: 12px; color: #ff6b6b;';
warning.textContent = '⚠️ Aucun épisode trouvé. Essayez une recherche exacte ou un autre fournisseur.';
card.appendChild(warning);
}
}
} catch (error) {
console.error('Error loading episodes:', error);
selectElement.innerHTML = '<option value="">Erreur de chargement</option>';
}
}
/**
* Handle episode download
*/
async function handleDownloadEpisode(encodedUrl, providerId, lang) {
const url = decodeURIComponent(encodedUrl);
const selectId = `episodes-${providerId}-${encodedUrl}`;
const selectElement = document.getElementById(selectId);
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');
}
}
/**
* Handle season download
*/
async function handleDownloadSeason(encodedUrl, lang) {
const url = decodeURIComponent(encodedUrl);
if (!confirm(`⚠️ Attention: Vous allez télécharger toute la saison. Cela peut prendre du temps et utiliser beaucoup d'espace disque.\n\nVoulez-vous continuer ?`)) {
return;
}
try {
const data = await downloadSeason(url, 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');
}
}
/**
* 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
*/
async function handleSearch() {
const query = document.getElementById('searchInput').value.trim();
if (!query) return;
// Use the new anime details search
await searchAnimeDetails(query);
}
// Handle anime search (new dedicated function)
async function handleAnimeSearch() {
const searchInput = document.getElementById('animeSearchInput') || document.getElementById('searchInput');
if (!searchInput) return;
const query = searchInput.value.trim();
if (!query) return;
// Use the new anime details search
await searchAnimeDetails(query);
}
// Ensure global scope
window.handleSearch = handleSearch;
window.handleAnimeSearch = handleAnimeSearch;
/**
* Handle direct download form submission
*/
async function handleDirectDownload(e) {
e.preventDefault();
const url = document.getElementById('urlInput').value;
try {
await startDownload(url);
document.getElementById('urlInput').value = '';
loadDownloads();
} catch (error) {
console.error('Download error:', error);
alert('Erreur lors du démarrage du téléchargement');
}
}
// Ensure all functions are globally accessible
window.displaySearchResults = displaySearchResults;
window.renderAnimeCard = renderAnimeCard;
window.renderAnimeMetadata = renderAnimeMetadata;
window.loadSeasonsForAnime = loadSeasonsForAnime;
window.handleSeasonChange = handleSeasonChange;
window.loadEpisodesForAnime = loadEpisodesForAnime;
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;