"""Sonarr webhook handler and integration logic using SQLModel""" import hmac import hashlib import logging from typing import Optional, Dict, List, Any from datetime import datetime from sqlmodel import Session, select from app.database import engine from app.models.sonarr import ( SonarrWebhookPayload, SonarrEventType, SonarrMapping, SonarrMappingTable, SonarrConfig, SonarrConfigTable, 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 using SQL database""" def __init__(self, config_path: str = None, mappings_path: str = None): self.download_manager = None self._ensure_default_config() def set_download_manager(self, download_manager): self.download_manager = download_manager def _ensure_default_config(self): """Ensure a default config exists in the database""" with Session(engine) as session: statement = select(SonarrConfigTable) if not session.exec(statement).first(): session.add(SonarrConfigTable()) session.commit() def get_config(self) -> SonarrConfig: """Get current configuration""" with Session(engine) as session: statement = select(SonarrConfigTable) db_config = session.exec(statement).first() if db_config: return SonarrConfig( webhook_enabled=db_config.webhook_enabled, webhook_secret=db_config.webhook_secret, auto_download_enabled=db_config.auto_download_enabled, default_language=db_config.default_language, default_quality=db_config.default_quality, default_provider=db_config.default_provider, verify_hmac=db_config.verify_hmac, log_webhooks=db_config.log_webhooks ) return SonarrConfig() def update_config(self, config: SonarrConfig) -> SonarrConfig: """Update configuration""" with Session(engine) as session: statement = select(SonarrConfigTable) db_config = session.exec(statement).first() if not db_config: db_config = SonarrConfigTable() db_config.webhook_enabled = config.webhook_enabled db_config.webhook_secret = config.webhook_secret db_config.auto_download_enabled = config.auto_download_enabled db_config.default_language = config.default_language db_config.default_quality = config.default_quality db_config.default_provider = config.default_provider db_config.verify_hmac = config.verify_hmac db_config.log_webhooks = config.log_webhooks session.add(db_config) session.commit() logger.info("Sonarr configuration updated in database") return config def _to_pydantic(self, db_mapping: SonarrMappingTable) -> SonarrMapping: return SonarrMapping( sonarr_series_id=db_mapping.sonarr_series_id, sonarr_title=db_mapping.sonarr_title, anime_provider=db_mapping.anime_provider, anime_url=db_mapping.anime_url, anime_title=db_mapping.anime_title, lang=db_mapping.lang, quality_preference=db_mapping.quality_preference, auto_download=db_mapping.auto_download, created_at=db_mapping.created_at, updated_at=db_mapping.updated_at ) def get_mappings(self) -> List[SonarrMapping]: """Get all mappings""" with Session(engine) as session: statement = select(SonarrMappingTable) db_mappings = session.exec(statement).all() return [self._to_pydantic(m) for m in db_mappings] def get_mapping(self, sonarr_series_id: int) -> Optional[SonarrMapping]: """Get mapping for specific series""" with Session(engine) as session: statement = select(SonarrMappingTable).where(SonarrMappingTable.sonarr_series_id == sonarr_series_id) db_mapping = session.exec(statement).first() if db_mapping: return self._to_pydantic(db_mapping) return None def add_mapping(self, mapping: SonarrMapping) -> SonarrMapping: """Add or update a mapping""" with Session(engine) as session: statement = select(SonarrMappingTable).where(SonarrMappingTable.sonarr_series_id == mapping.sonarr_series_id) db_mapping = session.exec(statement).first() if db_mapping: # Update existing db_mapping.sonarr_title = mapping.sonarr_title db_mapping.anime_provider = mapping.anime_provider db_mapping.anime_url = mapping.anime_url db_mapping.anime_title = mapping.anime_title db_mapping.lang = mapping.lang db_mapping.quality_preference = mapping.quality_preference db_mapping.auto_download = mapping.auto_download db_mapping.updated_at = datetime.now() logger.info(f"Updated mapping for series {mapping.sonarr_title}") else: # Create new db_mapping = SonarrMappingTable( user_id="default", sonarr_series_id=mapping.sonarr_series_id, sonarr_title=mapping.sonarr_title, anime_provider=mapping.anime_provider, anime_url=mapping.anime_url, anime_title=mapping.anime_title, lang=mapping.lang, quality_preference=mapping.quality_preference, auto_download=mapping.auto_download, created_at=datetime.now(), updated_at=datetime.now() ) logger.info(f"Added mapping for series {mapping.sonarr_title}") session.add(db_mapping) session.commit() session.refresh(db_mapping) return self._to_pydantic(db_mapping) def delete_mapping(self, sonarr_series_id: int) -> bool: """Delete a mapping""" with Session(engine) as session: statement = select(SonarrMappingTable).where(SonarrMappingTable.sonarr_series_id == sonarr_series_id) db_mapping = session.exec(statement).first() if db_mapping: session.delete(db_mapping) session.commit() logger.info(f"Deleted mapping for series ID {sonarr_series_id}") return True return False def verify_hmac(self, payload: bytes, signature: str) -> bool: """Verify HMAC SHA256 signature""" config = self.get_config() if not config.verify_hmac or not config.webhook_secret: return True try: # Sonarr sends signature as 'sha256=' if signature.startswith('sha256='): signature = signature[7:] computed_hmac = hmac.new( 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 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""" config = self.get_config() if not config.webhook_enabled: return {"status": "ignored", "reason": "Webhook not enabled"} if 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, config) 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, config: SonarrConfig) -> Dict: """Handle Grab event (when Sonarr downloads a release)""" if not 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