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:
root
2026-01-24 21:25:47 +00:00
parent 92ef76ed2a
commit 1fe7392063
49 changed files with 8651 additions and 2110 deletions
+1254
View File
File diff suppressed because it is too large Load Diff
+476
View File
@@ -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)');
}
+371
View File
@@ -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;
+151
View File
@@ -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();
}
+401
View File
@@ -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');
}
+213
View File
@@ -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();
}
}
}
+273
View File
@@ -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);
}
}
+92
View File
@@ -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' });
}
}
+81
View File
@@ -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>