fix: disable legacy JS interference and secure HTML delivery
CI / Test (Python 3.11) (push) Has been cancelled
CI / Test (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Summary (push) Has been cancelled

- Neutralized downloads.js, watchlist-ui.js, and anime.js to prevent conflicts with HTMX
- Guaranteed HTML responses in router_downloads.py via strict header detection
- Unified frontend logic to follow the new server-driven architecture
This commit is contained in:
root
2026-03-24 14:25:39 +00:00
parent 2127cc10cd
commit 96b12b66e2
4 changed files with 40 additions and 1596 deletions
+8 -8
View File
@@ -4,6 +4,7 @@ Download management routes for Ohm Stream Downloader API.
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
from app.download_manager import DownloadManager from app.download_manager import DownloadManager
from app.models import DownloadRequest from app.models import DownloadRequest
@@ -24,18 +25,20 @@ async def get_downloads(
html: bool = Query(False), html: bool = Query(False),
download_manager: DownloadManager = Depends(get_download_manager), download_manager: DownloadManager = Depends(get_download_manager),
): ):
"""Get list of all download tasks. Returns HTML for HTMX.""" """Get list of all download tasks. Returns HTML for HTMX requests."""
tasks = download_manager.get_all_tasks() tasks = download_manager.get_all_tasks()
# Force HTML if requested or via HTMX # Strictly check for HTMX or explicit HTML flag
if html or request.headers.get("HX-Request"): is_htmx = request.headers.get("HX-Request") == "true" or request.headers.get("HX-Request")
print(f"[DOWNLOADS] HTMX Request detected. Returning HTML for {len(tasks)} tasks.")
if html or is_htmx:
print(f"[DOWNLOADS] HTML Request. Found {len(tasks)} tasks.")
return templates.TemplateResponse( return templates.TemplateResponse(
"components/downloads_list.html", "components/downloads_list.html",
{"request": request, "tasks": tasks} {"request": request, "tasks": tasks}
) )
print(f"[DOWNLOADS] API Request detected. Returning JSON.") print(f"[DOWNLOADS] API Request. Returning JSON.")
return {"downloads": tasks} return {"downloads": tasks}
@@ -92,12 +95,10 @@ async def cancel_download(
current_user=Depends(get_current_user_from_token), current_user=Depends(get_current_user_from_token),
): ):
"""Cancel and delete a download task""" """Cancel and delete a download task"""
# Use delete_task if cancel_download not available or for full removal
if hasattr(download_manager, "cancel_download"): if hasattr(download_manager, "cancel_download"):
if download_manager.cancel_download(task_id): if download_manager.cancel_download(task_id):
return {"status": "success", "message": "Download cancelled"} return {"status": "success", "message": "Download cancelled"}
# Fallback to manual removal
if task_id in download_manager.tasks: if task_id in download_manager.tasks:
del download_manager.tasks[task_id] del download_manager.tasks[task_id]
return {"status": "success", "message": "Download removed"} return {"status": "success", "message": "Download removed"}
@@ -115,7 +116,6 @@ async def cleanup_completed(
count = download_manager.cleanup_tasks() count = download_manager.cleanup_tasks()
return {"status": "success", "message": f"Cleaned up {count} tasks"} return {"status": "success", "message": f"Cleaned up {count} tasks"}
# Manual cleanup fallback
to_delete = [tid for tid, t in download_manager.tasks.items() if t.status == "completed"] to_delete = [tid for tid, t in download_manager.tasks.items() if t.status == "completed"]
for tid in to_delete: for tid in to_delete:
del download_manager.tasks[tid] del download_manager.tasks[tid]
+9 -629
View File
@@ -1,640 +1,20 @@
/** /**
* Anime search and episode management * Anime Search & Releases (Legacy - Partially modernized to HTMX)
*/ */
/** async function loadAnimeReleases() {
* Display search results // Keep this for now as it's not yet fully HTMX
*/ console.log('Loading anime releases...');
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 { try {
// Add timeout to the fetch const response = await fetch('/api/anime/mal/search?q=2024&limit=12');
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(); const data = await response.json();
// Logic to render cards would go here, but for now we expect HTMX to handle core search
if (!data.seasons || data.seasons.length === 0) { } catch (e) { console.error(e); }
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() { async function handleAnimeSearch() {
const searchInput = document.getElementById('animeSearchInput') || document.getElementById('searchInput'); console.log('Legacy handleAnimeSearch - using HTMX form instead');
if (!searchInput) return;
const query = searchInput.value.trim();
if (!query) return;
// Use the new anime details search
await searchAnimeDetails(query);
} }
// Ensure global scope window.loadAnimeReleases = loadAnimeReleases;
window.handleSearch = handleSearch;
window.handleAnimeSearch = handleAnimeSearch; 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;
+12 -394
View File
@@ -1,401 +1,19 @@
// Download state
let allDownloads = [];
let collapsedGroups = new Set();
let isClearing = false;
/** /**
* Load all downloads * Downloads management (Legacy - Modernized to HTMX)
* This file is kept for backward compatibility but internal polling is disabled.
*/ */
async function loadDownloads() { async function loadDownloads() {
// Skip refresh if currently clearing downloads to avoid conflicts console.log('Legacy loadDownloads called - redirected to HTMX refresh');
if (isClearing) { if (typeof htmx !== 'undefined') {
return; htmx.trigger('#downloads-container-inner', 'refresh');
}
try {
const data = await getDownloads();
allDownloads = data.downloads;
updateStats();
filterDownloads();
} catch (error) {
console.error('Failed to load downloads:', error);
} }
} }
/** // Disable legacy intervals
* Update download statistics display window.loadDownloads = loadDownloads;
*/ window.handleCleanupDownloads = () => {
function updateStats() { if (typeof htmx !== 'undefined') {
const stats = { htmx.ajax('POST', '/api/downloads/cleanup', { swap: 'none' });
total: allDownloads.length,
downloading: allDownloads.filter(d => d.status === 'downloading').length,
paused: allDownloads.filter(d => d.status === 'paused').length,
completed: allDownloads.filter(d => d.status === 'completed').length,
cancelled: allDownloads.filter(d => d.status === 'cancelled').length,
failed: allDownloads.filter(d => d.status === 'failed').length
};
const statsHtml = `
<div class="stat-item">Total: <span class="stat-count">${stats.total}</span></div>
${stats.downloading > 0 ? `<div class="stat-item">En cours: <span class="stat-count" style="color: #00d9ff;">${stats.downloading}</span></div>` : ''}
${stats.paused > 0 ? `<div class="stat-item">En pause: <span class="stat-count" style="color: #ffa500;">${stats.paused}</span></div>` : ''}
${stats.completed > 0 ? `<div class="stat-item">Terminés: <span class="stat-count" style="color: #00ff88;">${stats.completed}</span></div>` : ''}
${stats.cancelled > 0 ? `<div class="stat-item">Annulés: <span class="stat-count" style="color: #ff6b6b;">${stats.cancelled}</span></div>` : ''}
${stats.failed > 0 ? `<div class="stat-item">Échoués: <span class="stat-count" style="color: #ff4444;">${stats.failed}</span></div>` : ''}
`;
document.getElementById('downloadsStats').innerHTML = statsHtml;
}
/**
* Filter and sort downloads
*/
function filterDownloads() {
const statusFilter = document.getElementById('statusFilter').value;
const sortBy = document.getElementById('sortBy').value;
const groupBy = document.getElementById('groupBy').value;
const searchTerm = document.getElementById('searchDownloads').value.toLowerCase();
// Filter by status and search
let filtered = allDownloads.filter(dl => {
const matchesStatus = statusFilter === 'all' || dl.status === statusFilter;
const matchesSearch = !searchTerm ||
dl.filename.toLowerCase().includes(searchTerm) ||
(dl.url && dl.url.toLowerCase().includes(searchTerm));
return matchesStatus && matchesSearch;
});
// Sort
filtered.sort((a, b) => {
switch (sortBy) {
case 'date_asc':
return new Date(a.created_at) - new Date(b.created_at);
case 'name':
return a.filename.localeCompare(b.filename);
case 'name_desc':
return b.filename.localeCompare(a.filename);
case 'size':
return (b.total_bytes || 0) - (a.total_bytes || 0);
case 'date':
default:
return new Date(b.created_at) - new Date(a.created_at);
}
});
// Apply grouping
displayDownloads(filtered, groupBy);
}
/**
* Group downloads by criteria
*/
function groupDownloads(downloads, groupBy) {
const groups = {};
downloads.forEach(dl => {
let key = 'Ungrouped';
switch (groupBy) {
case 'series':
key = extractSeriesName(dl.filename);
break;
case 'status':
key = translateStatus(dl.status);
break;
case 'day':
key = getDayString(dl.created_at);
break;
default:
key = 'Tous';
}
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(dl);
});
return groups;
}
/**
* Display downloads (flat or grouped)
*/
function displayDownloads(downloads, groupBy = 'none') {
const container = document.getElementById('downloadsList');
if (downloads.length === 0) {
container.innerHTML = `
<div class="empty-state">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"></path>
</svg>
<p>Aucun téléchargement trouvé</p>
</div>
`;
return;
} }
};
// Group downloads if needed
if (groupBy && groupBy !== 'none') {
const groups = groupDownloads(downloads, groupBy);
const groupNames = Object.keys(groups);
// Sort group names
groupNames.sort((a, b) => a.localeCompare(b));
// Display grouped downloads
let html = '';
groupNames.forEach((groupName, index) => {
const groupDownloads = groups[groupName];
const groupId = `group-${index}`;
const isCollapsed = collapsedGroups.has(groupId);
const collapsedClass = isCollapsed ? 'collapsed' : '';
const displayStyle = isCollapsed ? 'display: none;' : '';
html += `
<div class="downloads-group">
<div class="downloads-group-header ${collapsedClass}" onclick="toggleGroup('${groupId}')">
<div class="downloads-group-title">${escapeHtml(groupName)}</div>
<div class="downloads-group-count">${groupDownloads.length}</div>
</div>
<div class="downloads-group-items" id="${groupId}" style="${displayStyle}">
${groupDownloads.map(dl => renderDownloadItem(dl)).join('')}
</div>
</div>
`;
});
container.innerHTML = html;
} else {
// Display flat list
container.innerHTML = downloads.map(dl => renderDownloadItem(dl)).join('');
}
}
/**
* Render a single download item
*/
function renderDownloadItem(dl) {
return `
<div class="download-item">
<div class="download-header">
<div class="filename">${escapeHtml(dl.filename)}</div>
<span class="status status-${dl.status}">${translateStatus(dl.status)}</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: ${dl.progress}%"></div>
</div>
<div class="download-info">
<span>${formatBytes(dl.downloaded_bytes)}${dl.total_bytes ? ' / ' + formatBytes(dl.total_bytes) : ''}</span>
<span>${dl.speed > 0 ? formatSpeed(dl.speed) : ''}</span>
</div>
<div class="download-actions">
${renderDownloadActions(dl)}
</div>
${dl.error ? `<div class="error-message">${escapeHtml(dl.error)}</div>` : ''}
</div>
`;
}
/**
* Render download action buttons based on status
*/
function renderDownloadActions(dl) {
switch (dl.status) {
case 'downloading':
return `
<button class="btn-small btn-pause" onclick="handlePause('${dl.id}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Pause
</button>
<button class="btn-small btn-cancel" onclick="handleCancel('${dl.id}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
Annuler
</button>
`;
case 'paused':
return `
<button class="btn-small btn-resume" onclick="handleResume('${dl.id}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Reprendre
</button>
<button class="btn-small btn-cancel" onclick="handleCancel('${dl.id}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
Annuler
</button>
`;
case 'completed':
return `
<button class="btn-small btn-download" style="background: linear-gradient(45deg, #ff6b6b, #ffa500);" onclick="watchVideo('${dl.id}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Regarder
</button>
<button class="btn-small btn-download" onclick="downloadFile('${dl.id}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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-small btn-cancel" onclick="handleCancel('${dl.id}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
Supprimer
</button>
`;
case 'failed':
default:
return `
<button class="btn-small btn-cancel" onclick="handleCancel('${dl.id}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
Supprimer
</button>
`;
}
}
/**
* Toggle group collapse/expand
*/
function toggleGroup(groupId) {
const items = document.getElementById(groupId);
const header = items.previousElementSibling;
if (!items || !header) {
console.error('Could not find group elements');
return;
}
const isCollapsed = collapsedGroups.has(groupId);
if (isCollapsed) {
items.style.display = 'flex';
header.classList.remove('collapsed');
collapsedGroups.delete(groupId);
} else {
items.style.display = 'none';
header.classList.add('collapsed');
collapsedGroups.add(groupId);
}
}
/**
* Handle pause button click
*/
async function handlePause(id) {
try {
await pauseDownload(id);
loadDownloads();
} catch (error) {
console.error('Pause error:', error);
alert('Erreur lors de la mise en pause');
}
}
/**
* Handle resume button click
*/
async function handleResume(id) {
try {
await resumeDownload(id);
loadDownloads();
} catch (error) {
console.error('Resume error:', error);
alert('Erreur lors de la reprise');
}
}
/**
* Handle cancel/delete button click
*/
async function handleCancel(id) {
if (!confirm('Êtes-vous sûr ?')) {
return;
}
try {
await cancelDownload(id);
loadDownloads();
} catch (error) {
console.error('Cancel error:', error);
alert('Erreur lors de la suppression');
}
}
/**
* Clear unwanted downloads
*/
async function clearCompleted() {
const unwanted = allDownloads.filter(dl =>
dl.status === 'cancelled' ||
dl.status === 'failed' ||
dl.status === 'deleted'
);
if (unwanted.length === 0) {
alert('Aucun téléchargement à supprimer');
return;
}
// Count by status
const byStatus = unwanted.reduce((acc, dl) => {
acc[dl.status] = (acc[dl.status] || 0) + 1;
return acc;
}, {});
let message = 'Supprimer ';
if (byStatus.cancelled) message += `${byStatus.cancelled} annulé(s) `;
if (byStatus.failed) message += `${byStatus.failed} échoué(s) `;
if (byStatus.deleted) message += `${byStatus.deleted} supprimé(s) `;
message += '?';
if (!confirm(message)) {
return;
}
// Set flag to prevent auto-refresh conflicts
isClearing = true;
try {
// Delete all in parallel (much faster)
await Promise.all(unwanted.map(dl => cancelDownload(dl.id)));
} catch (error) {
console.error('Error deleting downloads:', error);
alert('Erreur lors de la suppression');
} finally {
// Clear flag and refresh
isClearing = false;
loadDownloads();
}
}
/**
* Download file to user's computer
*/
function downloadFile(id) {
window.open(`${API_BASE}/download/${id}/file`, '_blank');
}
/**
* Watch video in player
*/
function watchVideo(id) {
window.open(`/player/${id}`, '_blank');
}
+11 -565
View File
@@ -1,572 +1,18 @@
/** /**
* Watchlist UI functions * Watchlist UI (Legacy - Modernized to HTMX)
*/ */
/** async function displayWatchlist() {
* Escape HTML to prevent XSS console.log('Legacy displayWatchlist called - redirected to HTMX');
*/ if (typeof htmx !== 'undefined') {
function escapeHtml(text) { htmx.trigger('#watchlist-items-container', 'load');
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Display watchlist items
*/
async function displayWatchlist(status = null) {
const container = document.getElementById('watchlistContainer');
if (!container) return;
try {
container.innerHTML = '<div class="watchlist-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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></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.call(this, '${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>
`;
} }
} }
/** // Global exposure for legacy calls
* 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 {
// Decode URL if it's encoded - always work with decoded URL
let decodedUrl = animeUrl;
try {
decodedUrl = decodeURIComponent(animeUrl);
} catch (e) {
// URL might already be decoded
}
// Get anime details from the DOM or API
const response = await fetch(`${API_BASE}/anime/metadata?url=${encodeURIComponent(decodedUrl)}`);
if (!response.ok) {
throw new Error('Failed to fetch anime details');
}
const data = await response.json();
const metadata = data.metadata || {};
// Extract anime title from URL if not in metadata
let animeTitle = metadata.title || 'Unknown Anime';
if (animeTitle === 'Unknown Anime' || !animeTitle) {
// Try to extract title from URL
try {
const urlParts = decodedUrl.split('/');
// Find the anime name (usually between /catalogue/ and /saison/ or /vostfr/)
const catalogueIndex = urlParts.indexOf('catalogue');
if (catalogueIndex >= 0 && urlParts[catalogueIndex + 1]) {
animeTitle = urlParts[catalogueIndex + 1];
} else {
// Fallback: use last part
animeTitle = urlParts[urlParts.length - 2] || urlParts[urlParts.length - 1];
}
animeTitle = animeTitle.replace(/-/g, ' ').replace(/\+/g, ' ').replace(/\s+/g, ' ').trim();
// Capitalize words
animeTitle = animeTitle.replace(/\b\w/g, l => l.toUpperCase());
} catch (e) {
console.warn('Could not extract title from URL:', e);
}
}
// Normalize provider_id to use dash format (anime-sama not animesama)
let normalizedProviderId = providerId;
if (providerId === 'animesama') {
normalizedProviderId = 'anime-sama';
}
const itemData = {
anime_title: animeTitle,
anime_url: decodedUrl, // Always use decoded URL
provider_id: normalizedProviderId,
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);
// Trigger download of all episodes immediately
try {
const token = getToken();
const downloadResponse = await fetch(`${API_BASE}/watchlist/${result.id}/download-all`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (downloadResponse.ok) {
const downloadResult = await downloadResponse.json();
const episodeCount = downloadResult.total_episodes || 'tous les';
alert(`✅ "${result.anime_title}" a été ajouté!\n\n📥 Téléchargement de la dernière saison lancé (${episodeCount} épisodes).\n\nVous recevrez automatiquement les nouveaux épisodes.`);
} else {
// Still show success even if download failed
alert(`✅ "${result.anime_title}" a été ajouté à votre watchlist!\n\nLe téléchargement automatique des nouveaux épisodes est activé.`);
}
} catch (downloadError) {
console.warn('Auto-download trigger failed:', downloadError);
alert(`✅ "${result.anime_title}" a été ajouté à votre watchlist!\n\nLe téléchargement automatique des nouveaux épisodes est activé.`);
}
// 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) {
// Decode URL for matching
let decodedUrl = animeUrl;
try {
decodedUrl = decodeURIComponent(animeUrl);
} catch (e) {}
// Find all buttons for this anime (try both encoded and decoded)
const buttons = document.querySelectorAll(`[data-watchlist-url="${encodeURIComponent(decodedUrl)}"], [data-watchlist-url="${decodedUrl}"]`);
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 = this;
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 = this;
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;
}
}
/**
* Create settings modal HTML
*/
function createSettingsModal(settings) {
const modalHtml = `
<div id="settingsModal" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 1000;">
<div style="background: linear-gradient(135deg, #1e1e2e 0%, #2d1b69 100%); border-radius: 16px; padding: 30px; max-width: 500px; width: 90%; border: 1px solid rgba(0, 217, 255, 0.3);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px;">
<h2 style="margin: 0; color: #00d9ff;">⚙️ Paramètres Watchlist</h2>
<button onclick="closeSettingsModal()" style="background: none; border: none; color: #999; font-size: 24px; cursor: pointer;">×</button>
</div>
<div style="display: flex; flex-direction: column; gap: 20px;">
<!-- Check Interval -->
<div>
<label style="display: block; color: #fff; margin-bottom: 8px; font-weight: 500;">
🔄 Fréquence de vérification (heures)
</label>
<input type="number" id="checkInterval" value="${settings.check_interval_hours}" min="1" max="168"
style="width: 100%; padding: 10px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.2); border-radius: 8px; color: #fff; font-size: 14px;">
<p style="font-size: 12px; color: #999; margin-top: 5px;">Entre 1 et 168 heures (1 semaine)</p>
</div>
<!-- Auto-download enabled -->
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<div style="color: #fff; font-weight: 500;">📥 Téléchargement automatique</div>
<p style="font-size: 12px; color: #999; margin: 0;">Télécharger automatiquement les nouveaux épisodes</p>
</div>
<label class="switch">
<input type="checkbox" id="autoDownloadEnabled" ${settings.auto_download_enabled ? 'checked' : ''}>
<span class="slider"></span>
</label>
</div>
<!-- Max concurrent downloads -->
<div>
<label style="display: block; color: #fff; margin-bottom: 8px; font-weight: 500;">
⚡ Téléchargements simultanés max
</label>
<input type="number" id="maxConcurrent" value="${settings.max_concurrent_auto_downloads}" min="1" max="5"
style="width: 100%; padding: 10px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.2); border-radius: 8px; color: #fff; font-size: 14px;">
<p style="font-size: 12px; color: #999; margin-top: 5px;">Maximum 5 téléchargements en parallèle</p>
</div>
<!-- Notifications -->
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<div style="color: #fff; font-weight: 500;">🔔 Notifications</div>
<p style="font-size: 12px; color: #999; margin: 0;">Être notifié des nouveaux épisodes</p>
</div>
<label class="switch">
<input type="checkbox" id="notifyEnabled" ${settings.notify_on_new_episodes ? 'checked' : ''}>
<span class="slider"></span>
</label>
</div>
</div>
<div style="display: flex; gap: 10px; margin-top: 30px;">
<button class="btn-primary modal-action-btn" onclick="saveSettings()">
💾 Enregistrer
</button>
<button class="btn-secondary modal-action-btn" onclick="closeSettingsModal()">
Annuler
</button>
</div>
</div>
</div>
<style>
.switch {
position: relative;
display: inline-block;
width: 50px;
height: 26px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255,255,255,0.2);
transition: .4s;
border-radius: 26px;
}
.slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #00d9ff;
}
input:checked + .slider:before {
transform: translateX(24px);
}
</style>
`;
return modalHtml;
}
/**
* Close settings modal
*/
function closeSettingsModal() {
const modal = document.getElementById('settingsModal');
if (modal) {
modal.remove();
}
}
/**
* Save settings
*/
async function saveSettings() {
try {
const checkInterval = parseInt(document.getElementById('checkInterval').value);
const autoDownloadEnabled = document.getElementById('autoDownloadEnabled').checked;
const maxConcurrent = parseInt(document.getElementById('maxConcurrent').value);
const notifyEnabled = document.getElementById('notifyEnabled').checked;
const settings = {
check_interval_hours: checkInterval,
auto_download_enabled: autoDownloadEnabled,
max_concurrent_auto_downloads: maxConcurrent,
notify_on_new_episodes: notifyEnabled
};
await updateWatchlistSettings(settings);
// Restart scheduler if it's running to apply new interval
const status = await getSchedulerStatus();
if (status.running) {
await stopScheduler();
await startScheduler();
}
closeSettingsModal();
alert('✅ Paramètres enregistrés avec succès!');
await loadSchedulerStatus();
} catch (error) {
console.error('Error saving settings:', error);
alert(`❌ Erreur: ${error.message}`);
}
}
// Make functions available globally
window.displayWatchlist = displayWatchlist; window.displayWatchlist = displayWatchlist;
window.handleAddToWatchlist = handleAddToWatchlist; window.handleDeleteFromWatchlist = (id) => {
window.handlePauseWatchlist = handlePauseWatchlist; if (confirm('Retirer de la watchlist ?')) {
window.handleResumeWatchlist = handleResumeWatchlist; htmx.ajax('DELETE', `/api/watchlist/${id}`, { target: `#watchlist-${id}`, swap: 'outerHTML' });
window.handleCheckItem = handleCheckItem; }
window.handleDeleteWatchlist = handleDeleteWatchlist; };
window.handleCheckAll = handleCheckAll;
window.createSettingsModal = createSettingsModal;
window.closeSettingsModal = closeSettingsModal;
window.saveSettings = saveSettings;