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:
+47
-2
@@ -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)")
|
||||
|
||||
Reference in New Issue
Block a user