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
+52 -44
View File
@@ -4,11 +4,14 @@ Stores user's favorite anime with metadata in a local JSON file
"""
import json
import asyncio
import logging
from pathlib import Path
from typing import List, Dict, Optional
from datetime import datetime
import aiofiles
logger = logging.getLogger(__name__)
class FavoritesManager:
"""Manages user's favorite anime list"""
@@ -22,25 +25,28 @@ class FavoritesManager:
async def _load(self):
"""Load favorites from disk"""
async with self._lock:
if self.storage_path.exists():
try:
async with aiofiles.open(self.storage_path, 'r', encoding='utf-8') as f:
content = await f.read()
self._favorites = json.loads(content) if content.strip() else {}
except Exception as e:
print(f"Error loading favorites: {e}")
self._favorites = {}
else:
await self._load_for_operation()
async def _load_for_operation(self):
"""Load favorites from disk without acquiring lock (lock must already be held)"""
if self.storage_path.exists():
try:
async with aiofiles.open(self.storage_path, 'r', encoding='utf-8') as f:
content = await f.read()
self._favorites = json.loads(content) if content.strip() else {}
except Exception as e:
logger.error(f"Error loading favorites: {e}")
self._favorites = {}
else:
self._favorites = {}
async def _save(self):
"""Save favorites to disk"""
async with self._lock:
try:
async with aiofiles.open(self.storage_path, 'w', encoding='utf-8') as f:
await f.write(json.dumps(self._favorites, indent=2, ensure_ascii=False))
except Exception as e:
print(f"Error saving favorites: {e}")
"""Save favorites to disk (assumes lock is already held)"""
try:
async with aiofiles.open(self.storage_path, 'w', encoding='utf-8') as f:
await f.write(json.dumps(self._favorites, indent=2, ensure_ascii=False))
except Exception as e:
logger.error(f"Error saving favorites: {e}")
async def add_favorite(
self,
@@ -52,41 +58,43 @@ class FavoritesManager:
poster_url: Optional[str] = None
) -> Dict:
"""Add an anime to favorites"""
await self._load()
async with self._lock:
await self._load_for_operation()
if anime_id in self._favorites:
# Update existing favorite
self._favorites[anime_id]["updated_at"] = datetime.now().isoformat()
if metadata:
self._favorites[anime_id]["metadata"] = metadata
if poster_url:
self._favorites[anime_id]["poster_url"] = poster_url
else:
# Add new favorite
self._favorites[anime_id] = {
"id": anime_id,
"title": title,
"url": url,
"provider": provider,
"metadata": metadata or {},
"poster_url": poster_url,
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat()
}
if anime_id in self._favorites:
# Update existing favorite
self._favorites[anime_id]["updated_at"] = datetime.now().isoformat()
if metadata:
self._favorites[anime_id]["metadata"] = metadata
if poster_url:
self._favorites[anime_id]["poster_url"] = poster_url
else:
# Add new favorite
self._favorites[anime_id] = {
"id": anime_id,
"title": title,
"url": url,
"provider": provider,
"metadata": metadata or {},
"poster_url": poster_url,
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat()
}
await self._save()
return self._favorites[anime_id]
await self._save()
return self._favorites[anime_id]
async def remove_favorite(self, anime_id: str) -> bool:
"""Remove an anime from favorites"""
await self._load()
async with self._lock:
await self._load_for_operation()
if anime_id in self._favorites:
del self._favorites[anime_id]
await self._save()
return True
if anime_id in self._favorites:
del self._favorites[anime_id]
await self._save()
return True
return False
return False
async def get_favorite(self, anime_id: str) -> Optional[Dict]:
"""Get a specific favorite by ID"""