"""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=' 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