1fe7392063
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]>
402 lines
14 KiB
JavaScript
402 lines
14 KiB
JavaScript
// 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');
|
|
}
|