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:
@@ -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)');
|
||||
}
|
||||
Reference in New Issue
Block a user