Files
ohm_streaming/static/js/anime-details.js
T
root 1fe7392063 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 <[email protected]>
Co-Authored-By: Happy <[email protected]>
2026-01-24 21:25:47 +00:00

477 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)');
}