520be53901
- 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
416 lines
17 KiB
Python
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
|