Files
ohm_streaming/app/sonarr_handler.py
T
Kimi Agent 520be53901
CI / Test (Python 3.11) (push) Has been cancelled
CI / Test (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Summary (push) Has been cancelled
fix: migrations, auth, providers health check, E2E tests, remove neko-sama
- Add proper Alembic initial migration (0001_initial_schema.py)
- Migrate refresh tokens from JSON file to SQLite (RefreshTokenTable)
- Remove Neko-Sama provider entirely (redirects to Gupy, not a host)
- Fix provider health check always showing UNKNOWN
  - Run check_all_health() on startup
  - Fix POST /providers/health/check background task bug
  - Add HTMX refresh after manual health check trigger
- Fix anime search relevance scoring with MIN_RELEVANCE_THRESHOLD=0.5
- Replace bare 'except:' with 'except Exception:' across codebase
- Add Playwright E2E test suite (12 tests, auth setup, helpers)
- Fix toast container blocking clicks via pointer-events: none
- Remove obsolete Jest/Vite test files and config
- Clean up obsolete test_watchlist scripts
- Update sonarr model comment for active providers
2026-05-12 11:45:56 +00:00

416 lines
17 KiB
Python

"""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, 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=<hex>'
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(),
"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