556 lines
22 KiB
JavaScript
556 lines
22 KiB
JavaScript
// Anime details module
|
||
|
||
// Search anime and display details
|
||
async function searchAnimeDetails(query, malId = null) {
|
||
const resultsContainer = document.getElementById('animeSearchResults');
|
||
|
||
if (!resultsContainer) return;
|
||
|
||
try {
|
||
resultsContainer.innerHTML = '<div class="loading-spinner">Recherche en cours...</div>';
|
||
|
||
// If we have a MAL ID, fetch directly by ID, otherwise search by query
|
||
let malUrl;
|
||
if (malId) {
|
||
malUrl = `${API_BASE}/anime/mal/${malId}`;
|
||
} else {
|
||
malUrl = `${API_BASE}/anime/mal/search?q=${encodeURIComponent(query)}&limit=5`;
|
||
}
|
||
|
||
// Search MAL and get streaming results in parallel
|
||
const [malResponse, streamingData] = await Promise.allSettled([
|
||
fetch(malUrl),
|
||
searchAnime(query, 'vostfr', false)
|
||
]);
|
||
|
||
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);
|
||
|
||
// Handle both direct ID response and search response
|
||
if (data.anime) {
|
||
animeData = data.anime;
|
||
malFound = true;
|
||
} else if (data.mal_id) {
|
||
// Direct MAL ID response
|
||
animeData = data;
|
||
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);
|
||
}
|
||
|
||
// Build streaming results HTML
|
||
let streamingHtml = '';
|
||
if (streamingData.status === 'fulfilled' && streamingData.value && streamingData.value.results) {
|
||
const providersData = await getProvidersInfo();
|
||
|
||
// Build results HTML
|
||
const streamingParts = [];
|
||
let hasResults = false;
|
||
|
||
// Display results from each provider - render all cards in parallel
|
||
for (const [providerId, results] of Object.entries(streamingData.value.results)) {
|
||
if (results && results.length > 0) {
|
||
hasResults = true;
|
||
const provider = providersData.anime_providers[providerId];
|
||
|
||
// Render all cards for this provider
|
||
const cardPromises = results.map((anime) => renderAnimeCard(anime, providerId, provider, 'vostfr'));
|
||
const cards = await Promise.all(cardPromises);
|
||
streamingParts.push(...cards);
|
||
}
|
||
}
|
||
|
||
// Only add header and wrapper if we have results
|
||
if (hasResults) {
|
||
streamingParts.unshift(
|
||
`<div class="streaming-results-header">
|
||
<h3>🎬 Résultats de streaming</h3>
|
||
</div>
|
||
<div class="search-results" style="margin-top: 20px;">`
|
||
);
|
||
streamingParts.push('</div>');
|
||
streamingHtml = streamingParts.join('');
|
||
}
|
||
}
|
||
|
||
// Display results
|
||
if (malFound && animeData) {
|
||
// We found MAL data - display anime details card
|
||
let html = renderAnimeDetails(animeData);
|
||
|
||
// Append streaming results if available
|
||
html += streamingHtml;
|
||
|
||
resultsContainer.innerHTML = html;
|
||
|
||
// Now load seasons after HTML is in DOM
|
||
if (streamingData.status === 'fulfilled' && streamingData.value && streamingData.value.results) {
|
||
loadStreamingResultsSeasons(streamingData.value.results);
|
||
}
|
||
} else {
|
||
// MAL found nothing but we have streaming results
|
||
if (streamingHtml) {
|
||
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>
|
||
${streamingHtml}
|
||
`;
|
||
|
||
// Now load seasons after HTML is in DOM
|
||
if (streamingData.status === 'fulfilled' && streamingData.value && streamingData.value.results) {
|
||
loadStreamingResultsSeasons(streamingData.value.results);
|
||
}
|
||
} 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
|
||
const htmlParts = [];
|
||
let hasResults = false;
|
||
|
||
// Display results from each provider
|
||
for (const [providerId, results] of Object.entries(data.results)) {
|
||
if (results && results.length > 0) {
|
||
hasResults = true;
|
||
const providersData = await getProvidersInfo();
|
||
const provider = providersData.anime_providers[providerId];
|
||
|
||
// Render all cards for this provider in parallel
|
||
const cardPromises = results.map((anime) => renderAnimeCard(anime, providerId, provider, 'vostfr'));
|
||
const cards = await Promise.all(cardPromises);
|
||
htmlParts.push(...cards);
|
||
}
|
||
}
|
||
|
||
// Only add header and wrapper if we have results
|
||
if (hasResults) {
|
||
htmlParts.unshift(
|
||
`<div class="streaming-results-header">
|
||
<h3>🎬 Résultats de streaming</h3>
|
||
</div>
|
||
<div class="search-results" style="margin-top: 20px;">`
|
||
);
|
||
htmlParts.push('</div>');
|
||
}
|
||
|
||
return htmlParts.join('');
|
||
|
||
} catch (error) {
|
||
console.error('Error getting provider search results:', error);
|
||
return '';
|
||
}
|
||
}
|
||
|
||
// After displaying streaming results, load seasons for Anime-Sama
|
||
async function loadStreamingResultsSeasons(providerResults) {
|
||
// providerResults should be the data.results object
|
||
let delayCounter = 0;
|
||
|
||
for (const [providerId, results] of Object.entries(providerResults)) {
|
||
if (results && results.length > 0) {
|
||
results.forEach((anime, index) => {
|
||
// Only load seasons for Anime-Sama
|
||
if (providerId === 'animesama' || (anime.url && anime.url.includes('anime-sama'))) {
|
||
// Stagger requests: 500ms delay between each anime
|
||
setTimeout(() => {
|
||
loadSeasonsForAnime(providerId, encodeURIComponent(anime.url));
|
||
}, 500 * delayCounter);
|
||
delayCounter++;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 btn-secondary btn-small">
|
||
🔗 Voir sur MAL
|
||
</a>
|
||
<button onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')" class="btn 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 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)}', ${entry.mal_id})" style="cursor: pointer;">
|
||
${entry.type ? `<span style="color: #f15025; font-size: 11px; margin-right: 8px;">${escapeHtml(entry.type)}</span>` : ''}
|
||
${escapeHtml(entry.title)}
|
||
${entry.url ? `<a href="${escapeHtml(entry.url)}" target="_blank" style="margin-left: auto; color: #888; font-size: 18px; text-decoration: none;" title="Voir sur MyAnimeList">↗</a>` : ''}
|
||
</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 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)');
|
||
}
|