Files
ohm_streaming/app/sonarr_handler.py
T
root da5403a307 feat: Complete watchlist & auto-download system with UI
Implement comprehensive watchlist system with automatic episode detection
and downloading. Features include per-user watchlists, scheduler-based
periodic checks, and a modern web UI.

**Backend Components:**
- WatchlistManager: JSON-based storage with multi-tenant support
- EpisodeChecker: Detects and downloads new episodes automatically
- AutoDownloadScheduler: APScheduler-based periodic task execution
- Complete REST API for CRUD operations and scheduler control

**Frontend Components:**
- Modern watchlist page with dark theme and animations
- Real-time status updates and progress tracking
- Scheduler controls with next-run display
- Add anime directly from search results

**Models & Configuration:**
- WatchlistItem with status, quality, and auto-download settings
- WatchlistSettings for global configuration
- Per-user statistics and provider tracking

**API Endpoints:**
- GET/POST /api/watchlist - List and add items
- PUT/DELETE /api/watchlist/{id} - Update and delete
- POST /api/watchlist/{id}/check - Manual check trigger
- POST /api/watchlist/check-all - Check all due items
- GET/PUT /api/watchlist/settings - Global settings
- GET /api/watchlist/stats - Statistics
- GET/POST /api/watchlist/scheduler/* - Scheduler control

**Configuration Files:**
- config/watchlist.json - User watchlist data
- config/watchlist_settings.json - Global settings

Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-24 09:13:22 +00:00

385 lines
15 KiB
Python

"""Sonarr webhook handler and integration logic"""
import hmac
import hashlib
import json
import logging
import os
from typing import Optional, Dict, List, Tuple, Any
from pathlib import Path
from datetime import datetime
from app.models.sonarr import (
SonarrWebhookPayload,
SonarrEventType,
SonarrMapping,
SonarrConfig,
SonarrDownloadRequest
)
from app.models import DownloadRequest
from app.downloaders import get_downloader, AnimeSamaDownloader, NekoSamaDownloader, AnimeUltimeDownloader, VostfreeDownloader
# Configure logging
logger = logging.getLogger(__name__)
class SonarrHandler:
"""Handles Sonarr webhooks and manages series mappings"""
def __init__(self, config_path: str = "config/sonarr.json", mappings_path: str = "config/sonarr_mappings.json"):
self.config_path = Path(config_path)
self.mappings_path = Path(mappings_path)
self.config = self._load_config()
self.mappings = self._load_mappings()
self.download_manager = None
# Create config directories if they don't exist
self.config_path.parent.mkdir(exist_ok=True)
self.mappings_path.parent.mkdir(exist_ok=True)
def set_download_manager(self, download_manager):
self.download_manager = download_manager
def _load_config(self) -> SonarrConfig:
"""Load Sonarr configuration from file"""
if self.config_path.exists():
try:
with open(self.config_path, 'r') as f:
data = json.load(f)
return SonarrConfig(**data)
except Exception as e:
logger.warning(f"Failed to load Sonarr config: {e}")
return SonarrConfig()
def _save_config(self):
try:
temp_file = f"{self.config_path}.tmp"
with open(temp_file, 'w') as f:
json.dump(self.config.model_dump(mode='json'), f, indent=2)
os.replace(temp_file, self.config_path)
except Exception as e:
logger.error(f"Failed to save Sonarr config: {e}")
raise
def _load_mappings(self) -> List[SonarrMapping]:
"""Load Sonarr to anime mappings from file"""
if self.mappings_path.exists():
try:
with open(self.mappings_path, 'r') as f:
data = json.load(f)
return [SonarrMapping(**item) for item in data]
except Exception as e:
logger.warning(f"Failed to load Sonarr mappings: {e}")
return []
def _save_mappings(self):
try:
os.makedirs(os.path.dirname(self.mappings_path), exist_ok=True)
temp_file = f"{self.mappings_path}.tmp"
with open(temp_file, 'w') as f:
mappings_data = [m.model_dump(mode='json') for m in self.mappings]
json.dump(mappings_data, f, indent=2)
os.replace(temp_file, self.mappings_path)
except Exception as e:
logger.error(f"Failed to save mappings: {e}")
raise
def verify_hmac(self, payload: bytes, signature: str) -> bool:
"""Verify HMAC SHA256 signature"""
if not self.config.verify_hmac or not self.config.webhook_secret:
return True
try:
# Sonarr sends signature as 'sha256=<hex>'
if signature.startswith('sha256='):
signature = signature[7:]
computed_hmac = hmac.new(
self.config.webhook_secret.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(computed_hmac, signature)
except Exception as e:
logger.error(f"HMAC verification failed: {e}")
return False
def get_config(self) -> SonarrConfig:
"""Get current configuration"""
return self.config
def update_config(self, config: SonarrConfig) -> SonarrConfig:
"""Update configuration"""
self.config = config
self._save_config()
logger.info("Sonarr configuration updated")
return self.config
def get_mappings(self) -> List[SonarrMapping]:
"""Get all mappings"""
return self.mappings
def get_mapping(self, sonarr_series_id: int) -> Optional[SonarrMapping]:
"""Get mapping for specific series"""
for mapping in self.mappings:
if mapping.sonarr_series_id == sonarr_series_id:
return mapping
return None
def add_mapping(self, mapping: SonarrMapping) -> SonarrMapping:
"""Add or update a mapping"""
# Check if mapping already exists
for i, existing in enumerate(self.mappings):
if existing.sonarr_series_id == mapping.sonarr_series_id:
mapping.updated_at = datetime.now()
self.mappings[i] = mapping
self._save_mappings()
logger.info(f"Updated mapping for series {mapping.sonarr_title}")
return mapping
# Add new mapping
mapping.created_at = datetime.now()
mapping.updated_at = datetime.now()
self.mappings.append(mapping)
self._save_mappings()
logger.info(f"Added mapping for series {mapping.sonarr_title}")
return mapping
def delete_mapping(self, sonarr_series_id: int) -> bool:
"""Delete a mapping"""
for i, mapping in enumerate(self.mappings):
if mapping.sonarr_series_id == sonarr_series_id:
del self.mappings[i]
self._save_mappings()
logger.info(f"Deleted mapping for series ID {sonarr_series_id}")
return True
return False
async def search_anime_by_title(self, title: str, provider: str = "anime-sama", lang: str = "vostfr") -> List[Dict]:
"""Search for anime by title using specified provider"""
try:
downloader = self._get_provider_downloader(provider)
if not downloader:
logger.error(f"Provider {provider} not found")
return []
results = await downloader.search_anime(title, lang)
logger.info(f"Found {len(results)} results for '{title}' on {provider}")
return results
except Exception as e:
logger.error(f"Error searching anime: {e}")
return []
def _get_provider_downloader(self, provider: str):
"""Get downloader instance for provider"""
providers = {
"anime-sama": AnimeSamaDownloader(),
"neko-sama": NekoSamaDownloader(),
"anime-ultime": AnimeUltimeDownloader(),
"vostfree": VostfreeDownloader()
}
return providers.get(provider)
async def get_episodes_for_anime(self, anime_url: str, provider: str = "anime-sama", lang: str = "vostfr") -> List[Dict]:
"""Get episodes list for anime"""
try:
downloader = self._get_provider_downloader(provider)
if not downloader:
logger.error(f"Provider {provider} not found")
return []
episodes = await downloader.get_episodes(anime_url, lang)
logger.info(f"Found {len(episodes)} episodes for {anime_url}")
return episodes
except Exception as e:
logger.error(f"Error getting episodes: {e}")
return []
async def process_webhook(self, payload: SonarrWebhookPayload) -> Dict[str, Any]:
"""Process Sonarr webhook payload"""
if not self.config.webhook_enabled:
return {"status": "ignored", "reason": "Webhook not enabled"}
if self.config.log_webhooks:
logger.info(f"Received Sonarr webhook: {payload.eventType.value}")
# Handle different event types
if payload.eventType == SonarrEventType.GRAB:
return await self._handle_grab(payload)
elif payload.eventType == SonarrEventType.DOWNLOAD:
return await self._handle_download(payload)
elif payload.eventType == SonarrEventType.RENAME:
return await self._handle_rename(payload)
elif payload.eventType == SonarrEventType.DELETE:
return await self._handle_delete(payload)
elif payload.eventType == SonarrEventType.TEST:
return {"status": "ok", "message": "Test webhook received"}
else:
return {"status": "ignored", "reason": f"Unhandled event type: {payload.eventType}"}
async def _handle_grab(self, payload: SonarrWebhookPayload) -> Dict:
"""Handle Grab event (when Sonarr downloads a release)"""
if not self.config.auto_download_enabled:
return {"status": "ignored", "reason": "Auto-download disabled"}
if not payload.series or not payload.episodes:
return {"status": "error", "reason": "Missing series or episodes"}
# Check for mapping
mapping = self.get_mapping(payload.series.tvdbId)
if not mapping:
logger.info(f"No mapping found for series {payload.series.title} (ID: {payload.series.tvdbId})")
return {
"status": "no_mapping",
"series": payload.series.title,
"series_id": payload.series.tvdbId,
"reason": "No anime mapping configured"
}
# Trigger download for each episode
downloads = []
for episode in payload.episodes:
try:
success = await self._trigger_download(
mapping,
episode.seasonNumber,
episode.episodeNumber
)
downloads.append({
"season": episode.seasonNumber,
"episode": episode.episodeNumber,
"status": "started" if success else "failed"
})
except Exception as e:
logger.error(f"Failed to trigger download for episode {episode.episodeNumber}: {e}")
downloads.append({
"season": episode.seasonNumber,
"episode": episode.episodeNumber,
"status": "error",
"error": str(e)
})
return {
"status": "processing",
"mapping": mapping.anime_title,
"downloads_queued": len(downloads),
"downloads": downloads
}
async def _trigger_download(self, mapping: SonarrMapping, season_number: int, episode_number: int) -> bool:
if not self.download_manager:
logger.error("DownloadManager not set in SonarrHandler")
return False
try:
downloader = get_downloader(mapping.anime_url)
if not downloader:
logger.error(f"No downloader for {mapping.anime_url}")
return False
episodes = await downloader.get_episodes(mapping.anime_url, mapping.lang)
target_episode = None
for ep in episodes:
if ep.get('episode_number') == episode_number:
if ep.get('season') and ep['season'] != season_number:
continue
target_episode = ep
break
if not target_episode:
logger.warning(f"Episode {episode_number} not found for {mapping.anime_title}")
return False
video_url, _ = await downloader.get_download_link(target_episode['url'])
player_handler = get_downloader(video_url)
download_url, filename = await player_handler.get_download_link(video_url)
request = DownloadRequest(url=download_url, filename=filename)
task = self.download_manager.create_task(request)
if task:
await self.download_manager.start_download(task.id)
logger.info(f"Sonarr: Started download for {mapping.anime_title} S{season_number}E{episode_number}")
return True
return False
except Exception as e:
logger.error(f"Error triggering Sonarr download: {e}")
return False
async def _handle_download(self, payload: SonarrWebhookPayload) -> Dict:
"""Handle Download event (when Sonarr completes download)"""
# Similar to Grab but for post-download processing
logger.info(f"Download completed for {payload.series.title if payload.series else 'Unknown'}")
return {"status": "ok", "message": "Download event logged"}
async def _handle_rename(self, payload: SonarrWebhookPayload) -> Dict:
"""Handle Rename event (when Sonarr renames files)"""
logger.info(f"Rename event for {payload.series.title if payload.series else 'Unknown'}")
return {"status": "ok", "message": "Rename event logged"}
async def _handle_delete(self, payload: SonarrWebhookPayload) -> Dict:
"""Handle Delete event"""
logger.info(f"Delete event for series ID: {payload.series.tvdbId if payload.series else 'Unknown'}")
return {"status": "ok", "message": "Delete event logged"}
async def suggest_mapping(self, sonarr_title: str, provider: str = "anime-sama", lang: str = "vostfr") -> List[Dict]:
"""Suggest possible anime mappings based on Sonarr series title"""
try:
# Search for anime with similar title
results = await self.search_anime_by_title(sonarr_title, provider, lang)
suggestions = []
for result in results[:10]: # Limit to top 10 results
suggestions.append({
"title": result.get('title'),
"url": result.get('url'),
"cover_image": result.get('cover_image'),
"match_score": self._calculate_match_score(sonarr_title, result.get('title', ''))
})
# Sort by match score
suggestions.sort(key=lambda x: x['match_score'], reverse=True)
return suggestions
except Exception as e:
logger.error(f"Error suggesting mappings: {e}")
return []
def _calculate_match_score(self, sonarr_title: str, anime_title: str) -> float:
"""Calculate similarity score between titles (simple implementation)"""
# Simple case-insensitive comparison
sonarr_lower = sonarr_title.lower()
anime_lower = anime_title.lower()
if sonarr_lower == anime_lower:
return 1.0
elif sonarr_lower in anime_lower or anime_lower in sonarr_lower:
return 0.8
else:
# Calculate word overlap
sonarr_words = set(sonarr_lower.split())
anime_words = set(anime_lower.split())
if not sonarr_words or not anime_words:
return 0.0
intersection = sonarr_words & anime_words
union = sonarr_words | anime_words
return len(intersection) / len(union) if union else 0.0
# Global instance
_sonarr_handler: Optional[SonarrHandler] = None
def get_sonarr_handler() -> SonarrHandler:
"""Get or create Sonarr handler instance"""
global _sonarr_handler
if _sonarr_handler is None:
_sonarr_handler = SonarrHandler()
return _sonarr_handler