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:
+14
-5
@@ -7,6 +7,8 @@ from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict
|
||||
from passlib.context import CryptContext
|
||||
import logging
|
||||
from fastapi import HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,11 +44,12 @@ class UserManager:
|
||||
self.users = {}
|
||||
|
||||
def _save_users(self):
|
||||
"""Save users to JSON file"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.db_file), exist_ok=True)
|
||||
with open(self.db_file, 'w', encoding='utf-8') as f:
|
||||
temp_file = f"{self.db_file}.tmp"
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.users, f, indent=2, ensure_ascii=False, default=str)
|
||||
os.replace(temp_file, self.db_file)
|
||||
logger.info(f"Saved {len(self.users)} users to database")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving users: {e}")
|
||||
@@ -162,9 +165,15 @@ def verify_token(token: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user(token: str) -> Optional[dict]:
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials) -> dict:
|
||||
"""Get current user from JWT token"""
|
||||
token = credentials.credentials
|
||||
username = verify_token(token)
|
||||
if username:
|
||||
return user_manager.get_user(username)
|
||||
return None
|
||||
user = user_manager.get_user(username)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
if not user.get("is_active", True):
|
||||
raise HTTPException(status_code=401, detail="Inactive user")
|
||||
return user
|
||||
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import List, Optional, Dict
|
||||
from datetime import datetime
|
||||
|
||||
from app.watchlist import watchlist_manager, WatchlistManager
|
||||
from app.models import DownloadRequest, DownloadTask, DownloadStatus
|
||||
from app.models.watchlist import (
|
||||
WatchlistItem,
|
||||
WatchlistSettings,
|
||||
@@ -124,12 +125,11 @@ class EpisodeChecker:
|
||||
download_link, filename = await downloader.get_download_link(ep_info.episode_url)
|
||||
|
||||
# Create download task
|
||||
task = await self.download_manager.add_download(
|
||||
url=download_link,
|
||||
filename=filename
|
||||
)
|
||||
|
||||
request = DownloadRequest(url=download_link, filename=filename)
|
||||
task = self.download_manager.create_task(request)
|
||||
|
||||
if task:
|
||||
await self.download_manager.start_download(task.id)
|
||||
result.episodes_downloaded.append(ep_info.episode_number)
|
||||
logger.info(f"Started download: {filename}")
|
||||
else:
|
||||
|
||||
+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
|
||||
|
||||
+6
-4
@@ -54,15 +54,16 @@ class WatchlistManager:
|
||||
self.watchlist = {}
|
||||
|
||||
def _save_watchlist(self):
|
||||
"""Save watchlist to JSON file"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.db_file), exist_ok=True)
|
||||
data = {
|
||||
item_id: item.model_dump(mode='json')
|
||||
for item_id, item in self.watchlist.items()
|
||||
}
|
||||
with open(self.db_file, 'w', encoding='utf-8') as f:
|
||||
temp_file = f"{self.db_file}.tmp"
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False, default=str)
|
||||
os.replace(temp_file, self.db_file)
|
||||
logger.debug(f"Saved {len(self.watchlist)} items to watchlist")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving watchlist: {e}")
|
||||
@@ -84,11 +85,12 @@ class WatchlistManager:
|
||||
self.settings = WatchlistSettings()
|
||||
|
||||
def _save_settings(self):
|
||||
"""Save watchlist settings to JSON file"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.settings_file), exist_ok=True)
|
||||
with open(self.settings_file, 'w', encoding='utf-8') as f:
|
||||
temp_file = f"{self.settings_file}.tmp"
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.settings.model_dump(mode='json'), f, indent=2, ensure_ascii=False)
|
||||
os.replace(temp_file, self.settings_file)
|
||||
logger.debug("Saved watchlist settings")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving settings: {e}")
|
||||
|
||||
Reference in New Issue
Block a user