feat: Complete watchlist & auto-download system with UI

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>
This commit is contained in:
root
2026-02-24 09:13:22 +00:00
parent c6be191699
commit da5403a307
17 changed files with 1733 additions and 259 deletions
+69 -18
View File
@@ -3,6 +3,7 @@ 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
@@ -14,6 +15,7 @@ from app.models.sonarr import (
SonarrConfig,
SonarrDownloadRequest
)
from app.models import DownloadRequest
from app.downloaders import get_downloader, AnimeSamaDownloader, NekoSamaDownloader, AnimeUltimeDownloader, VostfreeDownloader
# Configure logging
@@ -28,11 +30,15 @@ class SonarrHandler:
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():
@@ -45,10 +51,11 @@ class SonarrHandler:
return SonarrConfig()
def _save_config(self):
"""Save Sonarr configuration to file"""
try:
with open(self.config_path, 'w') as f:
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
@@ -65,11 +72,13 @@ class SonarrHandler:
return []
def _save_mappings(self):
"""Save mappings to file"""
try:
with open(self.mappings_path, 'w') as f:
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
@@ -231,26 +240,25 @@ class SonarrHandler:
downloads = []
for episode in payload.episodes:
try:
download_request = SonarrDownloadRequest(
sonarr_series_id=payload.series.tvdbId,
sonarr_title=payload.series.title,
season_number=episode.seasonNumber,
episode_number=episode.episodeNumber,
quality=payload.release.quality.quality.get('name') if payload.release else mapping.quality_preference,
lang=mapping.lang,
provider=mapping.anime_provider
success = await self._trigger_download(
mapping,
episode.seasonNumber,
episode.episodeNumber
)
# Trigger the download (will be implemented in main.py)
downloads.append({
"season": episode.seasonNumber,
"episode": episode.episodeNumber,
"status": "queued"
"status": "started" if success else "failed"
})
logger.info(f"Queued download for {mapping.anime_title} S{episode.seasonNumber}E{episode.episodeNumber}")
except Exception as e:
logger.error(f"Failed to queue download for episode {episode.episodeNumber}: {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",
@@ -259,6 +267,49 @@ class SonarrHandler:
"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