fix: disable legacy JS interference and secure HTML delivery
- 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:
@@ -4,6 +4,7 @@ Download management routes for Ohm Stream Downloader API.
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.download_manager import DownloadManager
|
||||
from app.models import DownloadRequest
|
||||
@@ -24,18 +25,20 @@ async def get_downloads(
|
||||
html: bool = Query(False),
|
||||
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()
|
||||
|
||||
# Force HTML if requested or via HTMX
|
||||
if html or request.headers.get("HX-Request"):
|
||||
print(f"[DOWNLOADS] HTMX Request detected. Returning HTML for {len(tasks)} tasks.")
|
||||
# Strictly check for HTMX or explicit HTML flag
|
||||
is_htmx = request.headers.get("HX-Request") == "true" or request.headers.get("HX-Request")
|
||||
|
||||
if html or is_htmx:
|
||||
print(f"[DOWNLOADS] HTML Request. Found {len(tasks)} tasks.")
|
||||
return templates.TemplateResponse(
|
||||
"components/downloads_list.html",
|
||||
{"request": request, "tasks": tasks}
|
||||
)
|
||||
|
||||
print(f"[DOWNLOADS] API Request detected. Returning JSON.")
|
||||
print(f"[DOWNLOADS] API Request. Returning JSON.")
|
||||
return {"downloads": tasks}
|
||||
|
||||
|
||||
@@ -92,12 +95,10 @@ async def cancel_download(
|
||||
current_user=Depends(get_current_user_from_token),
|
||||
):
|
||||
"""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 download_manager.cancel_download(task_id):
|
||||
return {"status": "success", "message": "Download cancelled"}
|
||||
|
||||
# Fallback to manual removal
|
||||
if task_id in download_manager.tasks:
|
||||
del download_manager.tasks[task_id]
|
||||
return {"status": "success", "message": "Download removed"}
|
||||
@@ -115,7 +116,6 @@ async def cleanup_completed(
|
||||
count = download_manager.cleanup_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"]
|
||||
for tid in to_delete:
|
||||
del download_manager.tasks[tid]
|
||||
|
||||
+9
-629
@@ -1,640 +1,20 @@
|
||||
/**
|
||||
* Anime search and episode management
|
||||
* Anime Search & Releases (Legacy - Partially modernized to HTMX)
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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';
|
||||
|
||||
async function loadAnimeReleases() {
|
||||
// Keep this for now as it's not yet fully HTMX
|
||||
console.log('Loading anime releases...');
|
||||
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 response = await fetch('/api/anime/mal/search?q=2024&limit=12');
|
||||
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);
|
||||
}
|
||||
// Logic to render cards would go here, but for now we expect HTMX to handle core search
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
console.log('Legacy handleAnimeSearch - using HTMX form instead');
|
||||
}
|
||||
|
||||
// Ensure global scope
|
||||
window.handleSearch = handleSearch;
|
||||
window.loadAnimeReleases = loadAnimeReleases;
|
||||
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
@@ -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() {
|
||||
// Skip refresh if currently clearing downloads to avoid conflicts
|
||||
if (isClearing) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getDownloads();
|
||||
allDownloads = data.downloads;
|
||||
updateStats();
|
||||
filterDownloads();
|
||||
} catch (error) {
|
||||
console.error('Failed to load downloads:', error);
|
||||
console.log('Legacy loadDownloads called - redirected to HTMX refresh');
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.trigger('#downloads-container-inner', 'refresh');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update download statistics display
|
||||
*/
|
||||
function updateStats() {
|
||||
const stats = {
|
||||
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;
|
||||
// Disable legacy intervals
|
||||
window.loadDownloads = loadDownloads;
|
||||
window.handleCleanupDownloads = () => {
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.ajax('POST', '/api/downloads/cleanup', { swap: 'none' });
|
||||
}
|
||||
|
||||
// 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
@@ -1,572 +1,18 @@
|
||||
/**
|
||||
* Watchlist UI functions
|
||||
* Watchlist UI (Legacy - Modernized to HTMX)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
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>
|
||||
`;
|
||||
async function displayWatchlist() {
|
||||
console.log('Legacy displayWatchlist called - redirected to HTMX');
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.trigger('#watchlist-items-container', 'load');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
// Global exposure for legacy calls
|
||||
window.displayWatchlist = displayWatchlist;
|
||||
window.handleAddToWatchlist = handleAddToWatchlist;
|
||||
window.handlePauseWatchlist = handlePauseWatchlist;
|
||||
window.handleResumeWatchlist = handleResumeWatchlist;
|
||||
window.handleCheckItem = handleCheckItem;
|
||||
window.handleDeleteWatchlist = handleDeleteWatchlist;
|
||||
window.handleCheckAll = handleCheckAll;
|
||||
window.createSettingsModal = createSettingsModal;
|
||||
window.closeSettingsModal = closeSettingsModal;
|
||||
window.saveSettings = saveSettings;
|
||||
window.handleDeleteFromWatchlist = (id) => {
|
||||
if (confirm('Retirer de la watchlist ?')) {
|
||||
htmx.ajax('DELETE', `/api/watchlist/${id}`, { target: `#watchlist-${id}`, swap: 'outerHTML' });
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user