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:
+69
-18
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user