feat: Complete Sonarr integration with security enhancements
This commit adds comprehensive Sonarr webhook integration and implements critical security improvements identified in code review. ## Sonarr Integration - Full webhook support for Grab, Download, Rename, Delete, and Test events - HMAC SHA256 signature verification for webhook authentication - Series mapping system (Sonarr TVDB ID → Anime Provider URL) - 11 new API endpoints for configuration, mappings, search, and downloads - Comprehensive test suite (31 tests, all passing) - Complete documentation in docs/SONARR_INTEGRATION.md ## Security Enhancements - CORS restricted to specific origins (user's IP: 192.168.1.204:3000) - Path traversal prevention via sanitize_filename() and is_safe_filename() - Structured logging infrastructure (replaced all print() statements) - Environment-based configuration with .env support - Filename sanitization prevents malicious path attacks ## New Features - Lpayer and Sibnet downloader support - Kitsu API integration for anime metadata - Recommendation engine based on download history - Latest releases endpoint for new anime - Modular web interface with component-based templates ## Configuration - Centralized settings via app/config.py with pydantic-settings - Sonarr config auto-created in config/ directory - Example configurations provided for easy setup ## Tests - 31 Sonarr integration tests (23 functionality + 9 security) - 100+ tests passing in core test files - Security utilities fully tested ## Documentation - Updated CLAUDE.md with Sonarr and testing info - Added IMPROVEMENTS_2024-01-24.md analysis - Added SONARR_IMPLEMENTATION.md technical summary Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,476 @@
|
||||
// Anime details module
|
||||
|
||||
// Search anime and display details
|
||||
async function searchAnimeDetails(query) {
|
||||
const resultsContainer = document.getElementById('animeSearchResults');
|
||||
|
||||
if (!resultsContainer) return;
|
||||
|
||||
try {
|
||||
resultsContainer.innerHTML = '<div class="loading-spinner">Recherche en cours...</div>';
|
||||
|
||||
// Search MAL and get streaming results in parallel
|
||||
const [malResponse, streamingResults] = await Promise.allSettled([
|
||||
fetch(`${API_BASE}/anime/mal/search?q=${encodeURIComponent(query)}&limit=5`),
|
||||
getProviderSearchResults(query)
|
||||
]);
|
||||
|
||||
let animeData = null;
|
||||
let malFound = false;
|
||||
|
||||
// Check MAL search results
|
||||
if (malResponse.status === 'fulfilled') {
|
||||
try {
|
||||
// malResponse.value is the Response object from fetch
|
||||
const response = malResponse.value;
|
||||
|
||||
// Check if the HTTP request was successful
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('MAL search response:', data);
|
||||
|
||||
if (data.anime) {
|
||||
animeData = data.anime;
|
||||
malFound = true;
|
||||
}
|
||||
} else {
|
||||
console.warn(`MAL search returned HTTP ${response.status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing MAL response:', e);
|
||||
}
|
||||
} else {
|
||||
console.error('MAL search promise rejected:', malResponse.reason);
|
||||
}
|
||||
|
||||
// Display results
|
||||
if (malFound && animeData) {
|
||||
// We found MAL data - display anime details card
|
||||
let html = renderAnimeDetails(animeData);
|
||||
|
||||
// Append streaming results if available
|
||||
if (streamingResults.status === 'fulfilled' && streamingResults.value) {
|
||||
html += streamingResults.value;
|
||||
}
|
||||
|
||||
resultsContainer.innerHTML = html;
|
||||
} else {
|
||||
// MAL found nothing but we have streaming results
|
||||
if (streamingResults.status === 'fulfilled' && streamingResults.value) {
|
||||
resultsContainer.innerHTML = `
|
||||
<div class="no-results" style="margin-bottom: 20px;">
|
||||
<p>ℹ️ Aucune fiche trouvée sur MyAnimeList pour "${escapeHtml(query)}"</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
||||
Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End")
|
||||
</p>
|
||||
</div>
|
||||
${streamingResults.value}
|
||||
`;
|
||||
} else {
|
||||
resultsContainer.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>❌ Aucun résultat trouvé pour "${escapeHtml(query)}"</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
||||
Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End", "One Piece")
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error searching anime details:', error);
|
||||
resultsContainer.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>❌ Erreur lors de la recherche.</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Get provider search results as HTML
|
||||
async function getProviderSearchResults(query) {
|
||||
try {
|
||||
// Use the existing searchAnime function
|
||||
const data = await searchAnime(query, 'vostfr', false);
|
||||
|
||||
if (!data.results) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Build results HTML
|
||||
let html = `
|
||||
<div class="streaming-results-header">
|
||||
<h3>🎬 Résultats de streaming</h3>
|
||||
</div>
|
||||
<div class="search-results" style="margin-top: 20px;">
|
||||
`;
|
||||
|
||||
// Display results from each provider
|
||||
for (const [providerId, results] of Object.entries(data.results)) {
|
||||
if (results && results.length > 0) {
|
||||
const providersData = await getProvidersInfo();
|
||||
const provider = providersData.anime_providers[providerId];
|
||||
|
||||
results.forEach(anime => {
|
||||
// Use the same renderAnimeCard function from anime.js for consistency
|
||||
html += renderAnimeCard(anime, providerId, provider, 'vostfr');
|
||||
|
||||
// Auto-load seasons (for Anime-Sama) or episodes
|
||||
setTimeout(() => {
|
||||
loadSeasonsForAnime(providerId, encodeURIComponent(anime.url));
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
return html;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error getting provider search results:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Render anime details card
|
||||
function renderAnimeDetails(anime) {
|
||||
const images = anime.images || {};
|
||||
const imageUrl = images.jpg?.large_image_url || images.jpg?.image_url || images.webp?.large_image_url || '';
|
||||
|
||||
const genres = anime.genres || [];
|
||||
const themes = anime.themes || [];
|
||||
const studios = anime.studios || [];
|
||||
const score = anime.score || 0;
|
||||
const rank = anime.rank || 0;
|
||||
const popularity = anime.popularity || 0;
|
||||
const synopsis = anime.synopsis || '';
|
||||
const related = anime.related || [];
|
||||
|
||||
// Generate unique ID for synopsis element
|
||||
const synopsisId = `synopsis-${anime.mal_id}`;
|
||||
|
||||
// Filter only seasons (Sequel, Prequel)
|
||||
const seasons = related.filter(r => {
|
||||
const relationType = r.type?.toLowerCase() || '';
|
||||
return relationType === 'sequel' || relationType === 'prequel';
|
||||
});
|
||||
|
||||
return `
|
||||
<div class="anime-details-card">
|
||||
<!-- Header with poster and basic info -->
|
||||
<div class="anime-details-header">
|
||||
${imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="" class="anime-details-poster">` : ''}
|
||||
|
||||
<div class="anime-details-info">
|
||||
<h2 class="anime-details-title">${escapeHtml(anime.title)}</h2>
|
||||
${anime.title_english && anime.title_english !== anime.title ? `
|
||||
<p class="anime-details-subtitle">${escapeHtml(anime.title_english)}</p>
|
||||
` : ''}
|
||||
|
||||
<div class="anime-details-meta">
|
||||
${score > 0 ? `<div class="anime-details-rating">★ ${score.toFixed(2)}</div>` : ''}
|
||||
${rank > 0 ? `<div class="anime-details-rank">#${rank}</div>` : ''}
|
||||
${popularity > 0 ? `<div class="anime-details-popularity">Popularity #${popularity}</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="anime-details-stats">
|
||||
${anime.episodes ? `<span>📺 ${anime.episodes} épisodes</span>` : ''}
|
||||
${anime.status ? `<span>📡 ${translateStatus(anime.status)}</span>` : ''}
|
||||
${anime.duration ? `<span>⏱️ ${escapeHtml(anime.duration)}</span>` : ''}
|
||||
${anime.year ? `<span>📅 ${anime.year}</span>` : ''}
|
||||
</div>
|
||||
|
||||
${studios.length > 0 ? `
|
||||
<div class="anime-details-studios">
|
||||
Studio: ${studios.map(s => escapeHtml(s)).join(', ')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="anime-details-actions">
|
||||
<a href="${escapeHtml(anime.url)}" target="_blank" class="btn-secondary btn-small">
|
||||
🔗 Voir sur MAL
|
||||
</a>
|
||||
<button onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')" class="btn-primary btn-small">
|
||||
📥 Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Genres and themes -->
|
||||
${(genres.length > 0 || themes.length > 0) ? `
|
||||
<div class="anime-details-tags">
|
||||
${genres.map(g => `<span class="anime-details-tag genre">${escapeHtml(g)}</span>`).join('')}
|
||||
${themes.map(t => `<span class="anime-details-tag theme">${escapeHtml(t)}</span>`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Synopsis with translation button -->
|
||||
${synopsis ? `
|
||||
<div class="anime-details-section">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">📖 Synopsis</h3>
|
||||
<button onclick="translateSynopsis('${synopsisId}', this)" class="btn-secondary btn-small" style="font-size: 12px;">
|
||||
🌐 Traduire en français
|
||||
</button>
|
||||
</div>
|
||||
<p id="${synopsisId}" class="anime-details-synopsis">${escapeHtml(synopsis)}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Seasons (Sequel/Prequel) -->
|
||||
${seasons.length > 0 ? `
|
||||
<div class="anime-details-section">
|
||||
<h3>📺 Saisons</h3>
|
||||
<div class="anime-related-list">
|
||||
${seasons.map(season => `
|
||||
<div class="anime-related-group">
|
||||
<div class="anime-related-type">${translateRelationType(season.type)}</div>
|
||||
<div class="anime-related-items">
|
||||
${season.entries.map(entry => `
|
||||
<div class="anime-related-item" onclick="searchAnimeDetails('${escapeHtml(entry.title)}')" style="cursor: pointer;">
|
||||
${entry.type ? `<span style="color: #00d9ff; font-size: 11px; margin-right: 8px;">${escapeHtml(entry.type)}</span>` : ''}
|
||||
${escapeHtml(entry.title)}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Load streaming results from providers
|
||||
async function loadStreamingResults(query) {
|
||||
const container = document.getElementById('streamingResults');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
container.innerHTML = '<div class="loading-spinner">Recherche des sources de streaming...</div>';
|
||||
|
||||
// Load providers info
|
||||
const providersData = await getProvidersInfo();
|
||||
const animeProviders = Object.entries(providersData.anime_providers);
|
||||
|
||||
// Search on all providers
|
||||
const results = await Promise.allSettled(
|
||||
animeProviders.map(([id, provider]) =>
|
||||
loadEpisodes(null, query).then(episodes => ({
|
||||
provider: id,
|
||||
name: provider.name,
|
||||
icon: provider.icon,
|
||||
episodes: episodes.episodes || []
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
// Filter successful results
|
||||
const successfulResults = results
|
||||
.filter(r => r.status === 'fulfilled' && r.value.episodes.length > 0)
|
||||
.map(r => r.value);
|
||||
|
||||
if (successfulResults.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>⚠️ Aucun résultat de streaming trouvé pour "${escapeHtml(query)}"</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Display results
|
||||
container.innerHTML = `
|
||||
<div class="streaming-results-header">
|
||||
<h3>🎬 Disponible sur</h3>
|
||||
</div>
|
||||
<div class="streaming-results-grid">
|
||||
${successfulResults.map(result => renderStreamingResult(result, query)).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading streaming results:', error);
|
||||
container.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>❌ Erreur lors de la recherche des sources de streaming.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Render a single streaming result
|
||||
function renderStreamingResult(result, query) {
|
||||
const { provider, name, icon, episodes } = result;
|
||||
|
||||
return `
|
||||
<div class="streaming-result-card">
|
||||
<div class="streaming-result-header">
|
||||
<span class="streaming-result-icon">${icon}</span>
|
||||
<span class="streaming-result-name">${escapeHtml(name)}</span>
|
||||
<span class="streaming-result-count">${episodes.length} épisodes</span>
|
||||
</div>
|
||||
|
||||
<div class="streaming-result-episodes">
|
||||
<select class="streaming-episode-select" data-provider="${provider}" data-query="${escapeHtml(query)}">
|
||||
<option value="">Sélectionner un épisode</option>
|
||||
${episodes.slice(0, 20).map(ep => `
|
||||
<option value="${escapeHtml(ep.url)}">Épisode ${ep.episode}</option>
|
||||
`).join('')}
|
||||
${episodes.length > 20 ? `<option disabled>... et ${episodes.length - 20} autres</option>` : ''}
|
||||
</select>
|
||||
|
||||
<button class="btn-primary btn-small streaming-download-btn" onclick="downloadSelectedEpisode(this)">
|
||||
📥 Télécharger
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a href="#" class="streaming-result-link" onclick="searchAnimeOnProviders('${escapeHtml(query)}'); return false;">
|
||||
Voir tous les épisodes sur ${escapeHtml(name)} →
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Download selected episode from streaming results
|
||||
async function downloadSelectedEpisode(button) {
|
||||
const select = button.parentElement.querySelector('.streaming-episode-select');
|
||||
const episodeUrl = select.value;
|
||||
|
||||
if (!episodeUrl) {
|
||||
alert('Veuillez sélectionner un épisode');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadEpisode(episodeUrl);
|
||||
loadDownloads();
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
alert('Erreur lors du téléchargement');
|
||||
}
|
||||
}
|
||||
|
||||
// Translate status
|
||||
function translateStatus(status) {
|
||||
const translations = {
|
||||
'Airing': 'En cours',
|
||||
'Finished Airing': 'Terminé',
|
||||
'To Be Aired': 'À venir',
|
||||
'Currently Airing': 'En cours'
|
||||
};
|
||||
return translations[status] || status;
|
||||
}
|
||||
|
||||
// Translate relation type to French
|
||||
function translateRelationType(type) {
|
||||
const translations = {
|
||||
'Sequel': 'Suite',
|
||||
'Prequel': 'Préquelle',
|
||||
'Spin-off': 'Spin-off',
|
||||
'Side Story': 'Histoire secondaire',
|
||||
'Summary': 'Résumé',
|
||||
'Other': 'Autre',
|
||||
'Alternative Setting': 'Version alternative',
|
||||
'Full Story': 'Histoire complète'
|
||||
};
|
||||
return translations[type] || type;
|
||||
}
|
||||
|
||||
// Translate synopsis to French using backend API
|
||||
async function translateSynopsis(synopsisId, button) {
|
||||
const synopsisElement = document.getElementById(synopsisId);
|
||||
if (!synopsisElement) return;
|
||||
|
||||
// Get original text (use textContent to get pure text without HTML)
|
||||
const originalText = synopsisElement.dataset.original || synopsisElement.textContent;
|
||||
|
||||
// Check if already translated
|
||||
if (synopsisElement.dataset.translated === 'true') {
|
||||
// Revert to original
|
||||
synopsisElement.textContent = originalText;
|
||||
synopsisElement.dataset.translated = 'false';
|
||||
button.innerHTML = '🌐 Traduire en français';
|
||||
return;
|
||||
}
|
||||
|
||||
// Store original text
|
||||
synopsisElement.dataset.original = originalText;
|
||||
|
||||
// Show loading state
|
||||
button.disabled = true;
|
||||
button.innerHTML = '⏳ Traduction...';
|
||||
synopsisElement.style.opacity = '0.5';
|
||||
|
||||
try {
|
||||
console.log('Translating text (first 100 chars):', originalText.substring(0, 100) + '...');
|
||||
|
||||
// Use backend translation API
|
||||
const response = await fetch(`${API_BASE}/translate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: originalText.substring(0, 5000)
|
||||
})
|
||||
});
|
||||
|
||||
console.log('Translation API response status:', response.status);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('Translation successful!');
|
||||
|
||||
synopsisElement.textContent = data.translatedText;
|
||||
synopsisElement.dataset.translated = 'true';
|
||||
button.innerHTML = '🔄 Voir l\'original';
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
console.error('Translation API error:', errorData);
|
||||
throw new Error(errorData.detail || 'Translation failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Translation error:', error);
|
||||
synopsisElement.style.opacity = '1';
|
||||
|
||||
// Show user-friendly error
|
||||
const errorMessage = document.createElement('div');
|
||||
errorMessage.style.cssText = 'margin-top: 10px; padding: 10px; background: rgba(255, 107, 107, 0.2); border-radius: 8px; font-size: 12px; color: #ff6b6b;';
|
||||
errorMessage.innerHTML = `
|
||||
⚠️ Service de traduction temporairement indisponible.<br>
|
||||
<small>Essayez à nouveau dans quelques instants.</small>
|
||||
`;
|
||||
|
||||
// Remove existing error message if any
|
||||
const existingError = synopsisElement.parentElement.querySelector('.translation-error');
|
||||
if (existingError) {
|
||||
existingError.remove();
|
||||
}
|
||||
|
||||
errorMessage.className = 'translation-error';
|
||||
synopsisElement.parentElement.appendChild(errorMessage);
|
||||
|
||||
// Auto-remove error after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (errorMessage.parentElement) {
|
||||
errorMessage.remove();
|
||||
}
|
||||
}, 5000);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
synopsisElement.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback translation - kept for compatibility but no longer used
|
||||
async function fallbackTranslation(text, synopsisElement, button) {
|
||||
// This function is deprecated since we now use backend translation
|
||||
console.log('Fallback translation called (should not happen)');
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Anime search and episode management
|
||||
*/
|
||||
|
||||
/**
|
||||
* Display search results
|
||||
*/
|
||||
async function displaySearchResults(data, lang) {
|
||||
const resultsContainer = document.getElementById('searchResults');
|
||||
const providers = await getProvidersInfo();
|
||||
|
||||
let totalResults = 0;
|
||||
let html = '';
|
||||
|
||||
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];
|
||||
html += renderAnimeCard(anime, providerId, providerInfo, lang);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (totalResults === 0) {
|
||||
html = '<div class="no-results">Aucun résultat trouvé</div>';
|
||||
}
|
||||
|
||||
resultsContainer.innerHTML = html;
|
||||
|
||||
// Auto-load seasons (for Anime-Sama) or episodes for each anime
|
||||
for (const [providerId, results] of Object.entries(data.results)) {
|
||||
if (results && results.length > 0) {
|
||||
results.forEach(anime => {
|
||||
setTimeout(() => {
|
||||
// Try to load seasons first (for Anime-Sama)
|
||||
loadSeasonsForAnime(providerId, encodeURIComponent(anime.url));
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render anime card HTML
|
||||
*/
|
||||
function renderAnimeCard(anime, providerId, providerInfo, lang) {
|
||||
const metadataHtml = renderAnimeMetadata(anime.metadata);
|
||||
|
||||
// Check if this is Anime-Sama (for season support)
|
||||
const isAnimeSama = providerId === 'animesama' || anime.url?.includes('anime-sama');
|
||||
|
||||
const seasonSelectHtml = isAnimeSama ? `
|
||||
<select id="seasons-${providerId}-${encodeURIComponent(anime.url)}" onchange="handleSeasonChange('${providerId}', '${encodeURIComponent(anime.url)}', '${lang}')" style="margin-bottom: 8px;">
|
||||
<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)}">
|
||||
<option value="">${isAnimeSama ? 'Sélectionner une saison d\'abord' : '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>
|
||||
</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-Sama anime
|
||||
*/
|
||||
async function loadSeasonsForAnime(providerId, encodedUrl) {
|
||||
const url = decodeURIComponent(encodedUrl);
|
||||
const seasonSelectId = `seasons-${providerId}-${encodedUrl}`;
|
||||
|
||||
const seasonSelectElement = document.getElementById(seasonSelectId);
|
||||
if (!seasonSelectElement) return;
|
||||
|
||||
// Only proceed if this is Anime-Sama
|
||||
if (!url.includes('anime-sama')) {
|
||||
seasonSelectElement.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/anime/seasons?url=${encodeURIComponent(url)}`);
|
||||
|
||||
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;
|
||||
option.textContent = `${season.title} (${season.episode_count} épisodes)`;
|
||||
option.dataset.seasonNum = season.season;
|
||||
seasonSelectElement.appendChild(option);
|
||||
});
|
||||
|
||||
console.log(`Loaded ${data.seasons.length} seasons`);
|
||||
} else {
|
||||
// No seasons found, hide season selector and load episodes directly
|
||||
seasonSelectElement.style.display = 'none';
|
||||
loadEpisodesForAnime(providerId, encodedUrl, 'vostfr');
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to load seasons');
|
||||
seasonSelectElement.style.display = 'none';
|
||||
loadEpisodesForAnime(providerId, encodedUrl, 'vostfr');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading seasons:', error);
|
||||
seasonSelectElement.style.display = 'none';
|
||||
loadEpisodesForAnime(providerId, encodedUrl, 'vostfr');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
// Ensure global scope
|
||||
window.handleSearch = handleSearch;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,151 @@
|
||||
// API Base configuration
|
||||
const API_BASE = '/api';
|
||||
|
||||
// Cache for providers info
|
||||
let searchResultsCache = {};
|
||||
|
||||
/**
|
||||
* Get providers information
|
||||
*/
|
||||
async function getProvidersInfo() {
|
||||
if (!searchResultsCache.providers) {
|
||||
const response = await fetch(`${API_BASE}/providers`);
|
||||
searchResultsCache.providers = await response.json();
|
||||
}
|
||||
return searchResultsCache.providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search anime across all providers
|
||||
*/
|
||||
async function searchAnime(query, lang, includeMetadata) {
|
||||
if (!query) {
|
||||
throw new Error('Veuillez entrer un nom d\'anime');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE}/anime/search?q=${encodeURIComponent(query)}&lang=${lang}&include_metadata=${includeMetadata}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la recherche');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load episodes for an anime
|
||||
*/
|
||||
async function loadEpisodes(animeUrl, lang) {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/anime/episodes?url=${encodeURIComponent(animeUrl)}&lang=${lang}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors du chargement des épisodes');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an anime episode
|
||||
*/
|
||||
async function downloadEpisode(episodeUrl) {
|
||||
const response = await fetch(`${API_BASE}/anime/download?url=${encodeURIComponent(episodeUrl)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors du démarrage du téléchargement');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download entire season
|
||||
*/
|
||||
async function downloadSeason(animeUrl, lang) {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/anime/download-season?url=${encodeURIComponent(animeUrl)}&lang=${lang}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Impossible de démarrer le téléchargement de la saison');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a direct download
|
||||
*/
|
||||
async function startDownload(url) {
|
||||
const response = await fetch(`${API_BASE}/download`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors du démarrage du téléchargement');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all downloads
|
||||
*/
|
||||
async function getDownloads() {
|
||||
const response = await fetch(`${API_BASE}/downloads`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors du chargement des téléchargements');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a download
|
||||
*/
|
||||
async function pauseDownload(id) {
|
||||
const response = await fetch(`${API_BASE}/download/${id}/pause`, { method: 'POST' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la mise en pause');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a download
|
||||
*/
|
||||
async function resumeDownload(id) {
|
||||
const response = await fetch(`${API_BASE}/download/${id}/resume`, { method: 'POST' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la reprise');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel/delete a download
|
||||
*/
|
||||
async function cancelDownload(id) {
|
||||
const response = await fetch(`${API_BASE}/download/${id}`, { method: 'DELETE' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la suppression');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// Download state
|
||||
let allDownloads = [];
|
||||
let collapsedGroups = new Set();
|
||||
let isClearing = false;
|
||||
|
||||
/**
|
||||
* Load all downloads
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Main initialization and event handlers
|
||||
*/
|
||||
|
||||
// Initialize on DOM load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeForms();
|
||||
loadProviders();
|
||||
loadDownloads();
|
||||
setInterval(loadDownloads, 1000);
|
||||
|
||||
// Load home content (recommendations & releases)
|
||||
loadHomeContent();
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize form event listeners
|
||||
*/
|
||||
function initializeForms() {
|
||||
// Search form
|
||||
document.getElementById('searchInput').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
});
|
||||
|
||||
// Direct download form
|
||||
document.getElementById('downloadForm').addEventListener('submit', handleDirectDownload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load providers dynamically
|
||||
*/
|
||||
async function loadProviders() {
|
||||
try {
|
||||
const data = await getProvidersInfo();
|
||||
|
||||
// Update anime tabs
|
||||
const animeTabsContainer = document.querySelector('.tabs');
|
||||
const existingTabs = animeTabsContainer.querySelectorAll('.tab[data-tab-type="anime"]');
|
||||
existingTabs.forEach(tab => tab.remove());
|
||||
|
||||
// Add anime provider tabs
|
||||
Object.entries(data.anime_providers).forEach(([id, provider]) => {
|
||||
// Check if tab doesn't exist
|
||||
if (!document.querySelector(`.tab[data-provider="${id}"]`)) {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'tab';
|
||||
button.setAttribute('data-tab-type', 'anime');
|
||||
button.setAttribute('data-provider', id);
|
||||
button.innerHTML = `${provider.icon} ${provider.name}`;
|
||||
button.onclick = () => switchTab(`anime-${id}`);
|
||||
animeTabsContainer.appendChild(button);
|
||||
|
||||
// Create corresponding tab content
|
||||
const tabContent = document.createElement('div');
|
||||
tabContent.id = `tab-anime-${id}`;
|
||||
tabContent.className = 'tab-content';
|
||||
tabContent.innerHTML = createAnimeTabContent(id, provider);
|
||||
document.querySelector('.container').insertBefore(
|
||||
tabContent,
|
||||
document.getElementById('downloadsList')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Update supported hosts badges
|
||||
const hostsContainer = document.querySelector('.supported-hosts');
|
||||
hostsContainer.innerHTML = '';
|
||||
|
||||
Object.values(data.file_hosts).forEach(host => {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'host-badge';
|
||||
badge.textContent = `${host.icon} ${host.name}`;
|
||||
hostsContainer.appendChild(badge);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading providers:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create anime provider tab content
|
||||
*/
|
||||
function createAnimeTabContent(providerId, provider) {
|
||||
return `
|
||||
<div class="url-form">
|
||||
<div class="anime-input-group">
|
||||
<input
|
||||
type="text"
|
||||
id="${providerId}UrlInput"
|
||||
placeholder="URL de l'anime (ex: ${provider.url_pattern})"
|
||||
>
|
||||
<button type="button" class="btn-primary" onclick="handleLoadProviderEpisodes('${providerId}')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
Charger
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="${providerId}EpisodeSelector" class="anime-select-group" style="display:none;">
|
||||
<select id="${providerId}EpisodeSelect">
|
||||
<option value="">Sélectionner un épisode</option>
|
||||
</select>
|
||||
<button type="button" class="btn-primary" onclick="handleDownloadProviderEpisode('${providerId}')">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle load provider episodes
|
||||
*/
|
||||
async function handleLoadProviderEpisodes(providerId) {
|
||||
const animeUrl = document.getElementById(`${providerId}UrlInput`).value;
|
||||
if (!animeUrl) {
|
||||
alert('Veuillez entrer une URL d\'anime');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await loadEpisodes(animeUrl, null);
|
||||
|
||||
if (data.episodes && data.episodes.length > 0) {
|
||||
const select = document.getElementById(`${providerId}EpisodeSelect`);
|
||||
select.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}`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
document.getElementById(`${providerId}EpisodeSelector`).style.display = 'flex';
|
||||
} else {
|
||||
alert('Aucun épisode trouvé');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading episodes:', error);
|
||||
alert('Erreur lors du chargement des épisodes');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle download provider episode
|
||||
*/
|
||||
async function handleDownloadProviderEpisode(providerId) {
|
||||
const episodeUrl = document.getElementById(`${providerId}EpisodeSelect`).value;
|
||||
if (!episodeUrl) {
|
||||
alert('Veuillez sélectionner un épisode');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadEpisode(episodeUrl);
|
||||
document.getElementById(`${providerId}EpisodeSelect`).value = '';
|
||||
loadDownloads();
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
alert('Erreur lors du téléchargement');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch between tabs
|
||||
*/
|
||||
function switchTab(tabName) {
|
||||
// Hide all tabs
|
||||
document.querySelectorAll('.tab-content').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
document.querySelectorAll('.tab').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
|
||||
// Show selected tab
|
||||
const tabElement = document.getElementById(`tab-${tabName}`);
|
||||
if (tabElement) {
|
||||
tabElement.classList.add('active');
|
||||
}
|
||||
|
||||
// Find and activate the button
|
||||
const buttons = document.querySelectorAll('.tab');
|
||||
buttons.forEach(btn => {
|
||||
const tabType = btn.getAttribute('data-tab-type');
|
||||
|
||||
if (tabType === 'home' && tabName === 'home') {
|
||||
btn.classList.add('active');
|
||||
} else if (tabType === 'search' && tabName === 'search') {
|
||||
btn.classList.add('active');
|
||||
} else if (tabType === 'direct' && tabName === 'direct') {
|
||||
btn.classList.add('active');
|
||||
} else if (tabType === 'anime' && btn.getAttribute('data-provider') === tabName.replace('anime-', '')) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Load home content when switching to home tab
|
||||
if (tabName === 'home') {
|
||||
// Content is already loaded on init, but you can reload if needed
|
||||
if (typeof loadHomeContent === 'function' && !document.getElementById('recommendationsList').hasChildNodes()) {
|
||||
loadHomeContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// Recommendations and Latest Releases module
|
||||
|
||||
// Load personalized recommendations
|
||||
async function loadRecommendations() {
|
||||
const container = document.getElementById('recommendationsList');
|
||||
const section = document.getElementById('recommendationsSection');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
container.innerHTML = '<div class="loading-spinner">Analyse de vos téléchargements...</div>';
|
||||
|
||||
const response = await fetch(`${API_BASE}/recommendations?limit=12`);
|
||||
const data = await response.json();
|
||||
|
||||
console.log('Recommendations response:', data);
|
||||
|
||||
if (data.recommendations && data.recommendations.length > 0) {
|
||||
container.innerHTML = `<div class="recommendations-carousel">${data.recommendations.map(anime =>
|
||||
renderRecommendationCard(anime)
|
||||
).join('')}</div>`;
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>⚠️ Aucune recommandation disponible pour le moment.</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
||||
Soit l'API MyAnimeList est inaccessible, soit vous n'avez pas encore de téléchargements.
|
||||
</p>
|
||||
<button class="btn-secondary btn-small" onclick="loadRecommendations()" style="margin-top: 10px;">
|
||||
🔄 Réessayer
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
section.style.display = 'block';
|
||||
} catch (error) {
|
||||
console.error('Error loading recommendations:', error);
|
||||
container.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>❌ Erreur lors du chargement des recommandations.</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||
<button class="btn-secondary btn-small" onclick="loadRecommendations()" style="margin-top: 10px;">
|
||||
🔄 Réessayer
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
section.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Load latest releases
|
||||
async function loadLatestReleases() {
|
||||
const container = document.getElementById('releasesList');
|
||||
const section = document.getElementById('releasesSection');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
container.innerHTML = '<div class="loading-spinner">Chargement des dernières sorties...</div>';
|
||||
|
||||
const response = await fetch(`${API_BASE}/releases/latest?limit=12`);
|
||||
const data = await response.json();
|
||||
|
||||
console.log('Releases response:', data);
|
||||
|
||||
if (data.releases && data.releases.length > 0) {
|
||||
container.innerHTML = `<div class="releases-carousel">${data.releases.map(anime =>
|
||||
renderReleaseCard(anime)
|
||||
).join('')}</div>`;
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>⚠️ Aucune sortie disponible pour le moment.</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
||||
L'API MyAnimeList pourrait être temporairement inaccessible.
|
||||
</p>
|
||||
<button class="btn-secondary btn-small" onclick="loadLatestReleases()" style="margin-top: 10px;">
|
||||
🔄 Réessayer
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
section.style.display = 'block';
|
||||
} catch (error) {
|
||||
console.error('Error loading releases:', error);
|
||||
container.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>❌ Erreur lors du chargement des sorties.</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||
<button class="btn-secondary btn-small" onclick="loadLatestReleases()" style="margin-top: 10px;">
|
||||
🔄 Réessayer
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
section.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Load all home content
|
||||
async function loadHomeContent() {
|
||||
console.log('🏠 loadHomeContent() called');
|
||||
|
||||
const loading = document.getElementById('homeLoading');
|
||||
const recommendationsSection = document.getElementById('recommendationsSection');
|
||||
const releasesSection = document.getElementById('releasesSection');
|
||||
|
||||
console.log('Elements found:', {
|
||||
loading: !!loading,
|
||||
recommendationsSection: !!recommendationsSection,
|
||||
releasesSection: !!releasesSection
|
||||
});
|
||||
|
||||
if (loading) loading.style.display = 'block';
|
||||
if (recommendationsSection) recommendationsSection.style.display = 'none';
|
||||
if (releasesSection) releasesSection.style.display = 'none';
|
||||
|
||||
try {
|
||||
// Load both sections in parallel
|
||||
console.log('Loading recommendations and releases...');
|
||||
await Promise.all([
|
||||
loadRecommendations(),
|
||||
loadLatestReleases()
|
||||
]);
|
||||
console.log('✅ Home content loaded successfully');
|
||||
|
||||
// Show sections if they have content
|
||||
if (recommendationsSection) recommendationsSection.style.display = 'block';
|
||||
if (releasesSection) releasesSection.style.display = 'block';
|
||||
} catch (error) {
|
||||
console.error('❌ Error loading home content:', error);
|
||||
if (loading) {
|
||||
loading.innerHTML = 'Erreur lors du chargement. Consultez la console pour plus de détails.';
|
||||
}
|
||||
} finally {
|
||||
if (loading) loading.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Render recommendation card (horizontal compact)
|
||||
function renderRecommendationCard(anime) {
|
||||
const images = anime.images || {};
|
||||
const imageUrl = images.jpg?.image_url || images.webp?.image_url || '';
|
||||
|
||||
const genres = anime.genres || [];
|
||||
const score = anime.score || 0;
|
||||
const reason = anime.recommendation_reason || 'Recommandé';
|
||||
|
||||
return `
|
||||
<div class="anime-card-horizontal recommendation-card">
|
||||
${reason ? `<div class="recommendation-badge">💡 ${escapeHtml(reason)}</div>` : ''}
|
||||
|
||||
<div class="anime-card-header">
|
||||
<div class="anime-card-title">${escapeHtml(anime.title)}</div>
|
||||
${score > 0 ? `<div class="anime-card-rating">★ ${score.toFixed(1)}</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="anime-card-content">
|
||||
${imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="" class="anime-card-image" onerror="this.style.display='none'">` : ''}
|
||||
|
||||
<div class="anime-card-info">
|
||||
<div class="anime-genres">
|
||||
${genres.slice(0, 3).map(g => `<span class="anime-genre-tag">${escapeHtml(g)}</span>`).join('')}
|
||||
</div>
|
||||
|
||||
<div class="anime-card-meta">
|
||||
${anime.episodes ? `📺 ${anime.episodes} ep` : ''}
|
||||
${anime.episodes && anime.status ? ' • ' : ''}
|
||||
${anime.status ? translateStatus(anime.status) : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${anime.synopsis ? `
|
||||
<details class="anime-synopsis">
|
||||
<summary>📖 Synopsis</summary>
|
||||
<p>${escapeHtml(anime.synopsis.substring(0, 150))}${anime.synopsis.length > 150 ? '...' : ''}</p>
|
||||
</details>
|
||||
` : ''}
|
||||
|
||||
<div class="anime-card-actions">
|
||||
<button class="btn-secondary btn-small" onclick="window.open('${escapeHtml(anime.url)}', '_blank')">
|
||||
🔗 MAL
|
||||
</button>
|
||||
<button class="btn-primary btn-small" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
|
||||
📥 Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Render release card (horizontal compact)
|
||||
function renderReleaseCard(anime) {
|
||||
const images = anime.images || {};
|
||||
const imageUrl = images.jpg?.image_url || images.webp?.image_url || '';
|
||||
|
||||
const genres = anime.genres || [];
|
||||
const score = anime.score || 0;
|
||||
const releaseType = anime.release_type || 'Nouveau';
|
||||
|
||||
return `
|
||||
<div class="anime-card-horizontal release-card">
|
||||
<div class="release-badge">🔥 ${escapeHtml(releaseType)}</div>
|
||||
|
||||
<div class="anime-card-header">
|
||||
<div class="anime-card-title">${escapeHtml(anime.title)}</div>
|
||||
${score > 0 ? `<div class="anime-card-rating">★ ${score.toFixed(1)}</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="anime-card-content">
|
||||
${imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="" class="anime-card-image" onerror="this.style.display='none'">` : ''}
|
||||
|
||||
<div class="anime-card-info">
|
||||
<div class="anime-genres">
|
||||
${genres.slice(0, 3).map(g => `<span class="anime-genre-tag" style="color: #ff6b6b; background: rgba(255,107,107,0.15);">${escapeHtml(g)}</span>`).join('')}
|
||||
</div>
|
||||
|
||||
<div class="anime-card-meta">
|
||||
${anime.episodes ? `📺 ${anime.episodes} ep` : ''}
|
||||
${anime.episodes && anime.status ? ' • ' : ''}
|
||||
${anime.status ? translateStatus(anime.status) : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${anime.synopsis ? `
|
||||
<details class="anime-synopsis">
|
||||
<summary>📖 Synopsis</summary>
|
||||
<p>${escapeHtml(anime.synopsis.substring(0, 150))}${anime.synopsis.length > 150 ? '...' : ''}</p>
|
||||
</details>
|
||||
` : ''}
|
||||
|
||||
<div class="anime-card-actions">
|
||||
<button class="btn-secondary btn-small" onclick="window.open('${escapeHtml(anime.url)}', '_blank')">
|
||||
🔗 MAL
|
||||
</button>
|
||||
<button class="btn-primary btn-small" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
|
||||
📥 Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Get rating color based on score
|
||||
function getRatingColor(score) {
|
||||
if (score >= 9) return 'linear-gradient(45deg, #ffd700, #ffed4e)';
|
||||
if (score >= 8) return 'linear-gradient(45deg, #00ff88, #00d9ff)';
|
||||
if (score >= 7) return 'linear-gradient(45deg, #00d9ff, #6c5ce7)';
|
||||
if (score >= 6) return 'linear-gradient(45deg, #ffa500, #ff6b6b)';
|
||||
return 'linear-gradient(45deg, #666, #888)';
|
||||
}
|
||||
|
||||
// Search anime on providers (redirects to search tab)
|
||||
function searchAnimeOnProviders(title) {
|
||||
// Switch to search tab
|
||||
switchTab('search');
|
||||
|
||||
// Fill search input
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (searchInput) {
|
||||
searchInput.value = title;
|
||||
|
||||
// Trigger search
|
||||
setTimeout(() => {
|
||||
if (typeof searchAnime === 'function') {
|
||||
searchAnime();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format bytes to human readable format
|
||||
*/
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes per second to speed
|
||||
*/
|
||||
function formatSpeed(bytesPerSecond) {
|
||||
return formatBytes(bytesPerSecond) + '/s';
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate download status to French
|
||||
*/
|
||||
function translateStatus(status) {
|
||||
const translations = {
|
||||
'pending': 'En attente',
|
||||
'downloading': 'Téléchargement',
|
||||
'paused': 'En pause',
|
||||
'completed': 'Terminé',
|
||||
'failed': 'Échoué',
|
||||
'cancelled': 'Annulé'
|
||||
};
|
||||
return translations[status] || status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract series name from filename (for grouping)
|
||||
*/
|
||||
function extractSeriesName(filename) {
|
||||
let name = filename;
|
||||
|
||||
// Remove file extension
|
||||
name = name.replace(/\.[^/.]+$/, '');
|
||||
|
||||
// Remove episode numbers and patterns
|
||||
name = name
|
||||
.replace(/[-_ ]?(E(?:p)?|Episode|Épisode|Saison|Season)[-_: ]?\d+/gi, '')
|
||||
.replace(/[-_ ]?S\d{2}E\d{2}/gi, '')
|
||||
.replace(/\[.*?\]/g, '')
|
||||
.replace(/\(.*\)/g, '')
|
||||
.replace(/[-_ ]?\d{3,4}p/gi, '')
|
||||
.replace(/[-_ ]?(VOSTFR|VF|MULTI)/gi, '')
|
||||
.replace(/\s+/g, ' ') // Replace multiple spaces with single space
|
||||
.replace(/[-_]+$/, '') // Remove trailing dashes/underscores
|
||||
.trim();
|
||||
|
||||
// If nothing left or too short, use original filename without extension
|
||||
if (!name || name.length < 3) {
|
||||
return filename.replace(/\.[^/.]+$/, '');
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get day string for grouping
|
||||
*/
|
||||
function getDayString(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return "Aujourd'hui";
|
||||
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||
return "Hier";
|
||||
} else {
|
||||
return date.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'short' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test Home Content</h1>
|
||||
<div id="homeLoading">Loading...</div>
|
||||
<div id="recommendationsSection" style="display:none;">
|
||||
<h2>Recommendations</h2>
|
||||
<div id="recommendationsList"></div>
|
||||
</div>
|
||||
<div id="releasesSection" style="display:none;">
|
||||
<h2>Releases</h2>
|
||||
<div id="releasesList"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = '/api';
|
||||
|
||||
async function loadRecommendations() {
|
||||
console.log('Loading recommendations...');
|
||||
const container = document.getElementById('recommendationsList');
|
||||
const section = document.getElementById('recommendationsSection');
|
||||
const loading = document.getElementById('homeLoading');
|
||||
|
||||
try {
|
||||
loading.style.display = 'block';
|
||||
const response = await fetch(`${API_BASE}/recommendations?limit=5`);
|
||||
const data = await response.json();
|
||||
console.log('Response:', data);
|
||||
|
||||
loading.style.display = 'none';
|
||||
section.style.display = 'block';
|
||||
|
||||
if (data.recommendations && data.recommendations.length > 0) {
|
||||
container.innerHTML = data.recommendations.map(anime =>
|
||||
`<div><strong>${anime.title}</strong> (score: ${anime.score})</div>`
|
||||
).join('');
|
||||
} else {
|
||||
container.innerHTML = '<p>No recommendations</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
loading.innerHTML = 'Error: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLatestReleases() {
|
||||
console.log('Loading releases...');
|
||||
const container = document.getElementById('releasesList');
|
||||
const section = document.getElementById('releasesSection');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/releases/latest?limit=5`);
|
||||
const data = await response.json();
|
||||
console.log('Response:', data);
|
||||
|
||||
section.style.display = 'block';
|
||||
|
||||
if (data.releases && data.releases.length > 0) {
|
||||
container.innerHTML = data.releases.map(anime =>
|
||||
`<div><strong>${anime.title}</strong> (score: ${anime.score})</div>`
|
||||
).join('');
|
||||
} else {
|
||||
container.innerHTML = '<p>No releases</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
container.innerHTML = 'Error: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Load on start
|
||||
window.onload = async () => {
|
||||
await loadRecommendations();
|
||||
await loadLatestReleases();
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user