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
+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)');
}