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
+47 -2
View File
@@ -1,6 +1,7 @@
import asyncio
import os
import uuid
import logging
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional
@@ -8,6 +9,8 @@ import httpx
from app.models import DownloadTask, DownloadStatus, DownloadRequest
from app.downloaders import get_downloader
logger = logging.getLogger(__name__)
class DownloadManager:
"""Manages multiple downloads with queue and progress tracking"""
@@ -102,16 +105,42 @@ class DownloadManager:
downloader = get_downloader(task.url)
download_url, filename = await downloader.get_download_link(task.url)
logger.info(f"Download URL: {download_url[:100] if len(download_url) > 100 else download_url}")
logger.debug(f"Downloader filename: {filename}")
logger.debug(f"Task filename before: {task.filename}")
if not task.filename or task.filename == "download":
task.filename = filename
logger.debug(f"Task filename updated to: {task.filename}")
else:
logger.debug(f"Task filename kept as: {task.filename}")
task.file_path = str(self.download_dir / task.filename)
# Check if download_url is a local file path (VidMoly M3U8 pre-download)
if os.path.exists(download_url):
logger.info(f"VidMoly already downloaded file to: {download_url}")
# Move file to expected location if different
import shutil
if download_url != task.file_path:
shutil.move(download_url, task.file_path)
logger.debug(f"Moved file to: {task.file_path}")
# Mark as complete
file_size = os.path.getsize(task.file_path)
logger.info(f"File size: {file_size / (1024*1024):.2f} MB")
task.status = DownloadStatus.COMPLETED
task.progress = 100.0
task.downloaded_bytes = file_size
task.total_bytes = file_size
task.completed_at = datetime.now()
return
# Check if file already exists and is complete (for VidMoly which downloads directly)
if os.path.exists(task.file_path):
file_size = os.path.getsize(task.file_path)
if file_size > 1024: # More than 1KB - assume complete
print(f"[DOWNLOAD] File already exists: {task.filename} ({file_size / (1024*1024):.2f} MB)")
logger.info(f"File already exists: {task.filename} ({file_size / (1024*1024):.2f} MB)")
task.status = DownloadStatus.COMPLETED
task.progress = 100.0
task.downloaded_bytes = file_size
@@ -131,6 +160,14 @@ class DownloadManager:
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Referer': 'https://sendvid.com/',
})
# Add Sibnet-specific headers to avoid 403 errors
elif 'sibnet.ru' in download_url:
headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
'Referer': 'https://video.sibnet.ru/',
'Accept': '*/*',
'Accept-Language': 'en-US,en;q=0.9',
})
if downloaded_bytes > 0:
headers['Range'] = f'bytes={downloaded_bytes}-'
@@ -145,7 +182,7 @@ class DownloadManager:
except httpx.HTTPStatusError as e:
# If server doesn't support Range (416 error), restart from beginning
if e.response.status_code == 416 and downloaded_bytes > 0:
print(f"[DOWNLOAD] Server doesn't support Range, restarting download: {task.filename}")
logger.info(f" Server doesn't support Range, restarting download: {task.filename}")
# Remove partial file and restart without Range header
if os.path.exists(task.file_path):
os.remove(task.file_path)
@@ -166,6 +203,10 @@ class DownloadManager:
async def _process_download(self, response, task: DownloadTask, downloaded_bytes: int):
"""Process the download response stream"""
# Log response info
logger.info(f" Response status: {response.status_code}")
logger.info(f" Response headers: {dict(response.headers)}")
# Get total size
if 'content-range' in response.headers:
# Resume mode
@@ -205,3 +246,7 @@ class DownloadManager:
task.status = DownloadStatus.COMPLETED
task.completed_at = datetime.now()
task.progress = 100.0
# Log completion info
final_size = os.path.getsize(task.file_path) if os.path.exists(task.file_path) else 0
logger.info(f" ✅ Completed: {task.filename} ({final_size / (1024*1024):.2f} MB)")