da5403a307
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>
385 lines
15 KiB
Python
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
|