diff --git a/app/auto_download_scheduler.py b/app/auto_download_scheduler.py new file mode 100644 index 0000000..76ed5dd --- /dev/null +++ b/app/auto_download_scheduler.py @@ -0,0 +1,153 @@ +"""Scheduler for automatic episode checking and downloading""" +import logging +from datetime import datetime +from typing import Optional + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.interval import IntervalTrigger + +from app.watchlist import watchlist_manager, WatchlistManager +from app.episode_checker import EpisodeChecker, episode_checker + +logger = logging.getLogger(__name__) + + +class AutoDownloadScheduler: + """Manages automatic episode checking and downloading on a schedule""" + + def __init__( + self, + wlm: Optional[WatchlistManager] = None, + checker: Optional[EpisodeChecker] = None + ): + self.wlm = wlm or watchlist_manager + self.checker = checker or episode_checker + self.scheduler: Optional[AsyncIOScheduler] = None + self._running = False + + async def _check_job(self): + """Job function that runs periodically to check for new episodes""" + try: + logger.info("Running scheduled episode check...") + results = await self.checker.check_all_due() + + # Log summary + for result in results: + if result.new_episodes_found > 0: + logger.info( + f"โœ“ {result.anime_title}: " + f"{result.new_episodes_found} new, " + f"{len(result.episodes_downloaded)} downloaded" + ) + + logger.info(f"Scheduled check complete: processed {len(results)} items") + + except Exception as e: + logger.error(f"Error in scheduled check job: {e}", exc_info=True) + + def start(self): + """Start the scheduler""" + if self._running: + logger.warning("Scheduler already running") + return + + try: + self.scheduler = AsyncIOScheduler() + + # Get initial check interval from settings + settings = self.wlm.get_settings() + interval_hours = settings.check_interval_hours + + # Add the job + self.scheduler.add_job( + self._check_job, + trigger=IntervalTrigger(hours=interval_hours), + id='episode_check', + name='Check for new episodes', + replace_existing=True + ) + + # Start the scheduler + self.scheduler.start() + self._running = True + + logger.info( + f"Auto-download scheduler started (checking every {interval_hours}h)" + ) + + except Exception as e: + logger.error(f"Error starting scheduler: {e}", exc_info=True) + raise + + def stop(self): + """Stop the scheduler""" + if not self._running: + logger.warning("Scheduler not running") + return + + try: + if self.scheduler: + self.scheduler.shutdown(wait=False) + self.scheduler = None + + self._running = False + logger.info("Auto-download scheduler stopped") + + except Exception as e: + logger.error(f"Error stopping scheduler: {e}", exc_info=True) + + def restart(self): + """Restart the scheduler with updated settings""" + logger.info("Restarting scheduler with new settings...") + self.stop() + self.start() + + def update_interval(self, hours: int): + """Update the check interval""" + if not self._running: + logger.warning("Scheduler not running, interval will be applied on start") + return + + try: + settings = self.wlm.get_settings() + settings.check_interval_hours = hours + self.wlm.update_settings(settings) + + # Restart to apply new interval + self.restart() + + logger.info(f"Updated check interval to {hours}h") + + except Exception as e: + logger.error(f"Error updating interval: {e}", exc_info=True) + + def get_next_run_time(self) -> Optional[datetime]: + """Get the next scheduled run time""" + if not self._running or not self.scheduler: + return None + + try: + job = self.scheduler.get_job('episode_check') + if job: + return job.next_run_time + except Exception as e: + logger.error(f"Error getting next run time: {e}") + + return None + + def is_running(self) -> bool: + """Check if scheduler is running""" + return self._running + + async def trigger_check_now(self): + """Manually trigger an episode check now""" + logger.info("Manually triggering episode check...") + try: + await self._check_job() + except Exception as e: + logger.error(f"Error in manual check: {e}", exc_info=True) + raise + + +# Global scheduler instance +auto_download_scheduler = AutoDownloadScheduler() diff --git a/app/episode_checker.py b/app/episode_checker.py new file mode 100644 index 0000000..10a0f72 --- /dev/null +++ b/app/episode_checker.py @@ -0,0 +1,245 @@ +"""Episode checker for detecting and downloading new episodes automatically""" +import logging +from typing import List, Optional, Dict +from datetime import datetime + +from app.watchlist import watchlist_manager, WatchlistManager +from app.models.watchlist import ( + WatchlistItem, + WatchlistSettings, + NewEpisodeInfo, + AutoDownloadResult +) + +logger = logging.getLogger(__name__) + + +class EpisodeChecker: + """Checks for new episodes and downloads them automatically""" + + def __init__(self, wlm: Optional[WatchlistManager] = None): + self.wlm = wlm or watchlist_manager + self.download_manager = None # Will be set by main.py + + def set_download_manager(self, download_manager): + """Set the download manager (called by main.py to avoid circular import)""" + self.download_manager = download_manager + + async def check_anime(self, item: WatchlistItem) -> List[NewEpisodeInfo]: + """ + Check for new episodes of a specific anime + + Args: + item: WatchlistItem to check + + Returns: + List of NewEpisodeInfo objects + """ + try: + logger.info(f"Checking for new episodes: {item.anime_title}") + + # Import here to avoid circular imports + from app.downloaders import get_downloader + + # Get the appropriate downloader + downloader = get_downloader(item.anime_url) + if not downloader: + logger.error(f"No downloader found for URL: {item.anime_url}") + return [] + + # Get episodes list + episodes = await downloader.get_episodes(item.anime_url, item.lang) + if not episodes: + logger.warning(f"No episodes found for {item.anime_title}") + return [] + + # Filter new episodes + new_episodes = [] + for ep in episodes: + ep_num = ep.get('episode_number', 0) + if ep_num > item.last_episode_downloaded: + new_episodes.append(NewEpisodeInfo( + episode_number=ep_num, + episode_title=ep.get('title'), + episode_url=ep['url'], + season_number=ep.get('season'), + anime_title=item.anime_title, + provider_id=item.provider_id + )) + + if new_episodes: + logger.info(f"Found {len(new_episodes)} new episodes for {item.anime_title}") + else: + logger.info(f"No new episodes for {item.anime_title}") + + return new_episodes + + except Exception as e: + logger.error(f"Error checking anime {item.anime_title}: {e}", exc_info=True) + return [] + + async def download_new_episodes( + self, + item: WatchlistItem, + episodes: List[NewEpisodeInfo] + ) -> AutoDownloadResult: + """ + Download new episodes for a watchlist item + + Args: + item: WatchlistItem + episodes: List of new episodes to download + + Returns: + AutoDownloadResult with download status + """ + result = AutoDownloadResult( + watchlist_item_id=item.id, + anime_title=item.anime_title, + new_episodes_found=len(episodes), + checked_at=datetime.now() + ) + + if not episodes: + return result + + # Get settings + settings = self.wlm.get_settings() + if not settings.auto_download_enabled: + logger.info(f"Auto-download disabled, skipping {len(episodes)} episodes") + return result + + try: + # Import here to avoid circular imports + from app.downloaders import get_downloader + + downloader = get_downloader(item.anime_url) + + # Download each new episode + for ep_info in episodes: + try: + logger.info(f"Downloading {item.anime_title} Episode {ep_info.episode_number}") + + # Get download link + 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 + ) + + if task: + result.episodes_downloaded.append(ep_info.episode_number) + logger.info(f"Started download: {filename}") + else: + result.episodes_failed.append((ep_info.episode_number, "Failed to create download task")) + + except Exception as e: + error_msg = str(e) + logger.error(f"Error downloading episode {ep_info.episode_number}: {error_msg}") + result.episodes_failed.append((ep_info.episode_number, error_msg)) + + # Update watchlist with last episode downloaded + if result.episodes_downloaded: + last_ep = max(result.episodes_downloaded) + self.wlm.update_check_time(item.id, last_ep) + + except Exception as e: + logger.error(f"Error in download_new_episodes: {e}", exc_info=True) + + return result + + async def check_and_download(self, item: WatchlistItem) -> AutoDownloadResult: + """ + Check for new episodes and download them if auto_download is enabled + + Args: + item: WatchlistItem to check + + Returns: + AutoDownloadResult + """ + # Check for new episodes + new_episodes = await self.check_anime(item) + + result = AutoDownloadResult( + watchlist_item_id=item.id, + anime_title=item.anime_title, + new_episodes_found=len(new_episodes), + checked_at=datetime.now() + ) + + # Download if auto_download is enabled + if item.auto_download and new_episodes: + settings = self.wlm.get_settings() + if settings.auto_download_enabled: + download_result = await self.download_new_episodes(item, new_episodes) + result = download_result + else: + logger.info(f"Auto-download globally disabled, skipping {len(new_episodes)} episodes") + + # Update check time even if no downloads + self.wlm.update_check_time(item.id, item.last_episode_downloaded) + + return result + + async def check_all_due(self) -> List[AutoDownloadResult]: + """ + Check all watchlist items that are due for checking + + Returns: + List of AutoDownloadResult objects + """ + settings = self.wlm.get_settings() + due_items = self.wlm.get_due_for_check(settings.check_interval_hours) + + logger.info(f"Checking {len(due_items)} due watchlist items") + + results = [] + for item in due_items: + try: + result = await self.check_and_download(item) + results.append(result) + except Exception as e: + logger.error(f"Error processing {item.anime_title}: {e}", exc_info=True) + # Still add a result to track the failure + results.append(AutoDownloadResult( + watchlist_item_id=item.id, + anime_title=item.anime_title, + new_episodes_found=0, + checked_at=datetime.now() + )) + + # Log summary + total_new = sum(r.new_episodes_found for r in results) + total_downloaded = sum(len(r.episodes_downloaded) for r in results) + total_failed = sum(len(r.episodes_failed) for r in results) + + logger.info( + f"Check complete: {total_new} new episodes found, " + f"{total_downloaded} downloaded, {total_failed} failed" + ) + + return results + + async def manual_check(self, item_id: str) -> Optional[AutoDownloadResult]: + """ + Manually trigger a check for a specific watchlist item + + Args: + item_id: Watchlist item ID + + Returns: + AutoDownloadResult or None if item not found + """ + item = self.wlm.get_by_id(item_id) + if not item: + logger.error(f"Watchlist item not found: {item_id}") + return None + + return await self.check_and_download(item) + + +# Global episode checker instance +episode_checker = EpisodeChecker() diff --git a/app/models/watchlist.py b/app/models/watchlist.py new file mode 100644 index 0000000..383d34d --- /dev/null +++ b/app/models/watchlist.py @@ -0,0 +1,121 @@ +"""Pydantic models for Watchlist and Auto-Download system""" +from pydantic import BaseModel, Field +from typing import Optional, Literal +from datetime import datetime +from enum import Enum + + +class WatchlistStatus(str, Enum): + """Status of a watchlist item""" + ACTIVE = "active" # Currently tracking for new episodes + PAUSED = "paused" # Temporarily paused + COMPLETED = "completed" # Anime completed, no longer tracking + ARCHIVED = "archived" # Archived but kept for history + + +class QualityPreference(str, Enum): + """Preferred video quality""" + AUTO = "auto" # Let provider decide + P1080 = "1080p" # Full HD + P720 = "720p" # HD + P480 = "480p" # SD + + +class WatchlistItem(BaseModel): + """An anime being tracked for automatic episode downloads""" + id: str = Field(..., description="Unique identifier (UUID)") + user_id: str = Field(..., description="User ID who owns this watchlist item") + anime_title: str = Field(..., description="Title of the anime") + anime_url: str = Field(..., description="URL to the anime page") + provider_id: str = Field(..., description="Provider ID (animesama, nekosama, etc.)") + lang: Literal["vostfr", "vf"] = Field(default="vostfr", description="Language preference") + + # Tracking state + last_checked: Optional[datetime] = Field(None, description="Last time we checked for new episodes") + last_episode_downloaded: int = Field(default=0, description="Last episode number downloaded") + total_episodes: Optional[int] = Field(None, description="Total episodes if known") + + # Settings + auto_download: bool = Field(default=True, description="Automatically download new episodes") + quality_preference: QualityPreference = Field(default=QualityPreference.AUTO, description="Preferred quality") + status: WatchlistStatus = Field(default=WatchlistStatus.ACTIVE, description="Tracking status") + + # Metadata + poster_image: Optional[str] = Field(None, description="URL to poster image") + cover_image: Optional[str] = Field(None, description="URL to cover image") + synopsis: Optional[str] = Field(None, description="Anime synopsis") + genres: list[str] = Field(default_factory=list, description="Anime genres") + + # Timestamps + added_at: datetime = Field(default_factory=datetime.now, description="When added to watchlist") + updated_at: datetime = Field(default_factory=datetime.now, description="Last update time") + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() + } + + +class WatchlistItemCreate(BaseModel): + """Model for creating a new watchlist item""" + anime_title: str + anime_url: str + provider_id: str + lang: Literal["vostfr", "vf"] = "vostfr" + auto_download: bool = True + quality_preference: QualityPreference = QualityPreference.AUTO + + # Optional metadata + poster_image: Optional[str] = None + cover_image: Optional[str] = None + synopsis: Optional[str] = None + genres: list[str] = [] + + +class WatchlistItemUpdate(BaseModel): + """Model for updating a watchlist item""" + auto_download: Optional[bool] = None + quality_preference: Optional[QualityPreference] = None + status: Optional[WatchlistStatus] = None + last_episode_downloaded: Optional[int] = None + total_episodes: Optional[int] = None + + +class NewEpisodeInfo(BaseModel): + """Information about a newly detected episode""" + episode_number: int + episode_title: Optional[str] = None + episode_url: str + season_number: Optional[int] = None + anime_title: str + provider_id: str + + +class AutoDownloadResult(BaseModel): + """Result of an automatic download check""" + watchlist_item_id: str + anime_title: str + new_episodes_found: int + episodes_downloaded: list[int] = Field(default_factory=list) + episodes_failed: list[tuple[int, str]] = Field(default_factory=list) # (episode_number, error_message) + checked_at: datetime = Field(default_factory=datetime.now) + + +class WatchlistSettings(BaseModel): + """Global watchlist settings""" + check_interval_hours: int = Field(default=6, ge=1, le=168, description="Check interval (1-168 hours)") + auto_download_enabled: bool = Field(default=True, description="Global auto-download toggle") + max_concurrent_auto_downloads: int = Field(default=2, ge=1, le=10, description="Max concurrent auto-downloads") + notify_on_new_episodes: bool = Field(default=False, description="Send notifications for new episodes") + include_completed_anime: bool = Field(default=False, description="Check completed anime too") + + class Config: + json_schema_extra = { + "example": { + "check_interval_hours": 6, + "auto_download_enabled": True, + "max_concurrent_auto_downloads": 2, + "notify_on_new_episodes": False, + "include_completed_anime": False + } + } diff --git a/app/watchlist.py b/app/watchlist.py new file mode 100644 index 0000000..37c2277 --- /dev/null +++ b/app/watchlist.py @@ -0,0 +1,251 @@ +"""Watchlist management system for automatic episode tracking and downloading""" +import json +import os +import uuid +import logging +from datetime import datetime, timedelta +from typing import List, Optional, Dict +from pathlib import Path + +from app.models.watchlist import ( + WatchlistItem, + WatchlistItemCreate, + WatchlistItemUpdate, + WatchlistStatus, + WatchlistSettings, + NewEpisodeInfo, + AutoDownloadResult +) + +logger = logging.getLogger(__name__) + +# Watchlist database file +WATCHLIST_DB_FILE = "config/watchlist.json" +WATCHLIST_SETTINGS_FILE = "config/watchlist_settings.json" + + +class WatchlistManager: + """Manages user watchlist for automatic episode downloads""" + + def __init__(self, db_file: str = WATCHLIST_DB_FILE): + self.db_file = db_file + self.settings_file = WATCHLIST_SETTINGS_FILE + self.watchlist: Dict[str, WatchlistItem] = {} + self.settings: Optional[WatchlistSettings] = None + self._load_watchlist() + self._load_settings() + + def _load_watchlist(self): + """Load watchlist from JSON file""" + try: + if os.path.exists(self.db_file): + with open(self.db_file, 'r', encoding='utf-8') as f: + data = json.load(f) + self.watchlist = { + item_id: WatchlistItem(**item_data) + for item_id, item_data in data.items() + } + logger.info(f"Loaded {len(self.watchlist)} items from watchlist") + else: + self.watchlist = {} + logger.info("Watchlist database not found, starting with empty watchlist") + except Exception as e: + logger.error(f"Error loading watchlist: {e}") + 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: + json.dump(data, f, indent=2, ensure_ascii=False, default=str) + logger.debug(f"Saved {len(self.watchlist)} items to watchlist") + except Exception as e: + logger.error(f"Error saving watchlist: {e}") + + def _load_settings(self): + """Load watchlist settings from JSON file""" + try: + if os.path.exists(self.settings_file): + with open(self.settings_file, 'r', encoding='utf-8') as f: + data = json.load(f) + self.settings = WatchlistSettings(**data) + logger.info(f"Loaded watchlist settings") + else: + self.settings = WatchlistSettings() + self._save_settings() + logger.info("Settings file not found, using defaults") + except Exception as e: + logger.error(f"Error loading settings: {e}") + 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: + json.dump(self.settings.model_dump(mode='json'), f, indent=2, ensure_ascii=False) + logger.debug("Saved watchlist settings") + except Exception as e: + logger.error(f"Error saving settings: {e}") + + def get_all(self, user_id: Optional[str] = None, status: Optional[WatchlistStatus] = None) -> List[WatchlistItem]: + """Get all watchlist items, optionally filtered by user and status""" + items = list(self.watchlist.values()) + + if user_id: + items = [item for item in items if item.user_id == user_id] + + if status: + items = [item for item in items if item.status == status] + + # Sort by added_at descending + items.sort(key=lambda x: x.added_at, reverse=True) + return items + + def get_by_id(self, item_id: str) -> Optional[WatchlistItem]: + """Get a watchlist item by ID""" + return self.watchlist.get(item_id) + + def get_by_anime_url(self, anime_url: str, user_id: str) -> Optional[WatchlistItem]: + """Get a watchlist item by anime URL and user ID""" + for item in self.watchlist.values(): + if item.anime_url == anime_url and item.user_id == user_id: + return item + return None + + def create(self, user_id: str, item_data: WatchlistItemCreate) -> WatchlistItem: + """Create a new watchlist item""" + # Check if already exists + existing = self.get_by_anime_url(item_data.anime_url, user_id) + if existing: + raise ValueError(f"Anime already in watchlist (ID: {existing.id})") + + # Create new item + item_id = str(uuid.uuid4()) + now = datetime.now() + + watchlist_item = WatchlistItem( + id=item_id, + user_id=user_id, + anime_title=item_data.anime_title, + anime_url=item_data.anime_url, + provider_id=item_data.provider_id, + lang=item_data.lang, + auto_download=item_data.auto_download, + quality_preference=item_data.quality_preference, + status=WatchlistStatus.ACTIVE, + poster_image=item_data.poster_image, + cover_image=item_data.cover_image, + synopsis=item_data.synopsis, + genres=item_data.genres, + added_at=now, + updated_at=now, + last_checked=None, + last_episode_downloaded=0, + total_episodes=None + ) + + self.watchlist[item_id] = watchlist_item + self._save_watchlist() + logger.info(f"Added anime to watchlist: {watchlist_item.anime_title} (ID: {item_id})") + return watchlist_item + + def update(self, item_id: str, update_data: WatchlistItemUpdate) -> Optional[WatchlistItem]: + """Update a watchlist item""" + item = self.watchlist.get(item_id) + if not item: + return None + + # Update fields + update_dict = update_data.model_dump(exclude_unset=True) + for field, value in update_dict.items(): + if value is not None: + setattr(item, field, value) + + item.updated_at = datetime.now() + self._save_watchlist() + logger.info(f"Updated watchlist item: {item_id}") + return item + + def delete(self, item_id: str) -> bool: + """Delete a watchlist item""" + if item_id in self.watchlist: + del self.watchlist[item_id] + self._save_watchlist() + logger.info(f"Deleted watchlist item: {item_id}") + return True + return False + + def update_check_time(self, item_id: str, last_episode: int) -> Optional[WatchlistItem]: + """Update last_checked time and last_episode_downloaded""" + item = self.watchlist.get(item_id) + if not item: + return None + + item.last_checked = datetime.now() + item.last_episode_downloaded = max(item.last_episode_downloaded, last_episode) + item.updated_at = datetime.now() + self._save_watchlist() + return item + + def get_settings(self) -> WatchlistSettings: + """Get watchlist settings""" + if not self.settings: + self.settings = WatchlistSettings() + return self.settings + + def update_settings(self, settings: WatchlistSettings) -> WatchlistSettings: + """Update watchlist settings""" + self.settings = settings + self._save_settings() + logger.info("Updated watchlist settings") + return self.settings + + def get_due_for_check(self, check_interval_hours: Optional[int] = None) -> List[WatchlistItem]: + """Get items that are due for checking""" + if check_interval_hours is None: + check_interval_hours = self.settings.check_interval_hours + + cutoff_time = datetime.now() - timedelta(hours=check_interval_hours) + + due_items = [] + for item in self.watchlist.values(): + # Only check active items with auto_download enabled + if item.status != WatchlistStatus.ACTIVE or not item.auto_download: + continue + + # Check if due + if item.last_checked is None or item.last_checked < cutoff_time: + due_items.append(item) + + logger.info(f"Found {len(due_items)} items due for check") + return due_items + + def get_stats(self, user_id: Optional[str] = None) -> Dict: + """Get watchlist statistics""" + items = self.get_all(user_id=user_id) + + stats = { + "total": len(items), + "active": len([i for i in items if i.status == WatchlistStatus.ACTIVE]), + "paused": len([i for i in items if i.status == WatchlistStatus.PAUSED]), + "completed": len([i for i in items if i.status == WatchlistStatus.COMPLETED]), + "auto_download_enabled": len([i for i in items if i.auto_download]), + "providers": {} + } + + # Count by provider + for item in items: + provider = item.provider_id + stats["providers"][provider] = stats["providers"].get(provider, 0) + 1 + + return stats + + +# Global watchlist manager instance +watchlist_manager = WatchlistManager() diff --git a/docs/WATCHLIST_AUTO_DOWNLOAD.md b/docs/WATCHLIST_AUTO_DOWNLOAD.md new file mode 100644 index 0000000..7a78800 --- /dev/null +++ b/docs/WATCHLIST_AUTO_DOWNLOAD.md @@ -0,0 +1,441 @@ +# Watchlist & Auto-Download System + +## ๐ŸŽฏ Overview + +The Watchlist & Auto-Download system allows users to automatically track and download new episodes of their favorite anime. It features periodic checking, automatic downloads, and a flexible scheduler. + +## ๐Ÿ“‹ Features + +### Core Functionality +- **Automatic Episode Tracking**: Track new episodes for anime in your watchlist +- **Periodic Checking**: Configurable check intervals (1-168 hours) +- **Auto-Download**: Automatically download new episodes when detected +- **Manual Checks**: Trigger checks on-demand via API +- **Per-Anime Settings**: Configure auto-download, quality, and status per anime +- **Scheduler Management**: Start/stop the automatic scheduler + +### Status Types +- **ACTIVE**: Currently tracking for new episodes +- **PAUSED**: Temporarily paused (won't check) +- **COMPLETED**: Anime completed, no longer tracking +- **ARCHIVED**: Archived but kept for history + +## ๐Ÿš€ Installation + +### 1. Install APScheduler + +The system requires APScheduler for scheduling: + +```bash +# If using virtual environment (recommended) +source venv/bin/activate +pip install apscheduler==3.11.0 + +# Or add to requirements.txt and install +pip install -r requirements.txt +``` + +### 2. Configuration Files + +The system uses JSON files for persistence: + +``` +config/ +โ”œโ”€โ”€ watchlist.json # User watchlist items (auto-created) +โ”œโ”€โ”€ watchlist_settings.json # Global settings (auto-created) +โ””โ”€โ”€ .gitkeep +``` + +## ๐Ÿ“š API Endpoints + +### Watchlist Management + +#### Add Anime to Watchlist +```http +POST /api/watchlist +Content-Type: application/json +Authorization: Bearer + +{ + "anime_title": "Naruto Shippuden", + "anime_url": "https://anime-sama.si/catalogue/naruto-shippuden/saison1/vostfr/", + "provider_id": "animesama", + "lang": "vostfr", + "auto_download": true, + "quality_preference": "auto", + "poster_image": "https://...", + "synopsis": "Ninja anime...", + "genres": ["Action", "Adventure"] +} +``` + +#### Get User's Watchlist +```http +GET /api/watchlist?status=active +Authorization: Bearer +``` + +#### Get Specific Item +```http +GET /api/watchlist/{item_id} +Authorization: Bearer +``` + +#### Update Watchlist Item +```http +PUT /api/watchlist/{item_id} +Content-Type: application/json +Authorization: Bearer + +{ + "auto_download": false, + "status": "paused", + "last_episode_downloaded": 12 +} +``` + +#### Delete from Watchlist +```http +DELETE /api/watchlist/{item_id} +Authorization: Bearer +``` + +### Episode Checking + +#### Check Specific Anime +```http +POST /api/watchlist/{item_id}/check +Authorization: Bearer +``` + +#### Check All Due Items +```http +POST /api/watchlist/check-all +Authorization: Bearer +``` + +### Pause/Resume + +#### Pause Tracking +```http +POST /api/watchlist/{item_id}/pause +Authorization: Bearer +``` + +#### Resume Tracking +```http +POST /api/watchlist/{item_id}/resume +Authorization: Bearer +``` + +### Settings + +#### Get Settings +```http +GET /api/watchlist/settings +Authorization: Bearer +``` + +Response: +```json +{ + "check_interval_hours": 6, + "auto_download_enabled": true, + "max_concurrent_auto_downloads": 2, + "notify_on_new_episodes": false, + "include_completed_anime": false +} +``` + +#### Update Settings +```http +PUT /api/watchlist/settings +Content-Type: application/json +Authorization: Bearer + +{ + "check_interval_hours": 12, + "auto_download_enabled": true +} +``` + +### Statistics + +#### Get Watchlist Stats +```http +GET /api/watchlist/stats +Authorization: Bearer +``` + +Response: +```json +{ + "total": 15, + "active": 12, + "paused": 2, + "completed": 1, + "auto_download_enabled": 12, + "providers": { + "animesama": 8, + "nekosama": 5, + "animeultime": 2 + } +} +``` + +### Scheduler Control + +#### Get Scheduler Status +```http +GET /api/watchlist/scheduler/status +Authorization: Bearer +``` + +Response: +```json +{ + "running": true, + "next_run": "2026-01-29T18:00:00Z", + "settings": { ... } +} +``` + +#### Start Scheduler +```http +POST /api/watchlist/scheduler/start +Authorization: Bearer +``` + +#### Stop Scheduler +```http +POST /api/watchlist/scheduler/stop +Authorization: Bearer +``` + +## ๐Ÿ—๏ธ Architecture + +### Components + +1. **WatchlistManager** (`app/watchlist.py`) + - Manages watchlist storage (JSON-based) + - CRUD operations for watchlist items + - Settings management + - Statistics and queries + +2. **EpisodeChecker** (`app/episode_checker.py`) + - Checks for new episodes + - Downloads episodes automatically + - Integrates with existing downloaders + - Handles errors and retries + +3. **AutoDownloadScheduler** (`app/auto_download_scheduler.py`) + - APScheduler-based periodic checking + - Configurable intervals + - Start/stop control + - Next run tracking + +4. **Pydantic Models** (`app/models/watchlist.py`) + - WatchlistItem + - WatchlistItemCreate + - WatchlistItemUpdate + - WatchlistSettings + - AutoDownloadResult + +### Data Flow + +``` +User Request โ†’ API Endpoint โ†’ WatchlistManager โ†’ JSON Storage + โ†“ +Scheduler (periodic) โ†’ EpisodeChecker โ†’ Downloaders โ†’ DownloadManager +``` + +## ๐Ÿ’ก Usage Examples + +### Example 1: Add Anime and Enable Auto-Download + +```bash +curl -X POST "http://localhost:3000/api/watchlist" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "anime_title": "Frieren", + "anime_url": "https://anime-sama.si/catalogue/frieren/saison1/vostfr/", + "provider_id": "animesama", + "lang": "vostfr", + "auto_download": true + }' +``` + +### Example 2: Check All Animes Manually + +```bash +curl -X POST "http://localhost:3000/api/watchlist/check-all" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### Example 3: Pause Tracking for an Anime + +```bash +curl -X POST "http://localhost:3000/api/watchlist/ITEM_ID/pause" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### Example 4: Update Settings to Check Every 3 Hours + +```bash +curl -X PUT "http://localhost:3000/api/watchlist/settings" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "check_interval_hours": 3, + "auto_download_enabled": true + }' +``` + +## ๐Ÿ”ง Configuration + +### Environment Variables + +No special environment variables required. The system uses: + +- `DOWNLOAD_DIR`: From app/config.py (default: "downloads") +- `MAX_PARALLEL_DOWNLOADS`: From app/config.py (default: 3) + +### Settings + +Settings are stored in `config/watchlist_settings.json`: + +```json +{ + "check_interval_hours": 6, + "auto_download_enabled": true, + "max_concurrent_auto_downloads": 2, + "notify_on_new_episodes": false, + "include_completed_anime": false +} +``` + +## ๐ŸŽจ Frontend Integration (TODO) + +The UI components to be implemented: + +1. **Watchlist Page** (`/watchlist`) + - List of tracked anime + - Status indicators + - Pause/Resume buttons + - Settings modal + +2. **Add to Watchlist Button** + - On anime search results + - On anime detail pages + - Quick-add with confirmation + +3. **Settings Panel** + - Global toggle for auto-download + - Check interval selector + - Scheduler controls + +4. **Notifications** + - New episode alerts + - Download progress + - Error notifications + +## ๐Ÿงช Testing + +### Manual Testing + +```python +# Test adding to watchlist +from app.watchlist import watchlist_manager +from app.models.watchlist import WatchlistItemCreate + +item_data = WatchlistItemCreate( + anime_title="Test Anime", + anime_url="https://anime-sama.si/catalogue/test/vostfr/", + provider_id="animesama", + lang="vostfr" +) +item = watchlist_manager.create("user_id", item_data) +print(f"Created: {item.id}") + +# Test getting stats +stats = watchlist_manager.get_stats("user_id") +print(f"Stats: {stats}") +``` + +### API Testing + +```bash +# Start the server +uvicorn main:app --reload + +# Test endpoints +curl -X GET "http://localhost:3000/api/watchlist" \ + -H "Authorization: Bearer YOUR_TOKEN" + +# Start scheduler +curl -X POST "http://localhost:3000/api/watchlist/scheduler/start" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +## ๐Ÿ” Troubleshooting + +### Scheduler Not Running + +1. Check scheduler status: + ```bash + curl "http://localhost:3000/api/watchlist/scheduler/status" + ``` + +2. Check logs for APScheduler errors + +3. Ensure APScheduler is installed: + ```bash + pip list | grep apscheduler + ``` + +### Episodes Not Downloading + +1. Verify auto_download is enabled: + ```json + { + "auto_download": true, + "status": "active" + } + ``` + +2. Check global settings: + ```json + { + "auto_download_enabled": true + } + ``` + +3. Check download manager has capacity + +### Circular Import Errors + +The system uses lazy initialization to avoid circular imports: +- `EpisodeChecker.set_download_manager()` is called by `main.py` +- Do not import `download_manager` directly in other modules + +## ๐Ÿ“Š Future Enhancements + +Potential improvements: + +1. **Notifications**: Email, Telegram, Discord alerts +2. **Quality Selection**: Choose 1080p/720p/480p per anime +3. **Smart Detection**: Detect completed anime automatically +4. **Batch Operations**: Add multiple anime at once +5. **Calendar View**: Visual schedule of episode releases +6. **Statistics Dashboard**: Charts of download history +7. **RSS Feeds**: Generate RSS feeds for watchlist +8. **Watchlist Sharing**: Share lists between users + +## ๐Ÿ“ Notes + +- The scheduler runs in the background and is started/stopped via API +- All operations are per-user (multi-tenant) +- Failed downloads are logged but don't stop the scheduler +- The system is resilient to temporary network failures +- Watchlist data is persisted in JSON format for easy backup diff --git a/main.py b/main.py index a17131c..fca0f9a 100644 --- a/main.py +++ b/main.py @@ -35,6 +35,18 @@ from app.models.auth import UserCreate, UserLogin, User, Token from app.auth import user_manager, create_access_token, verify_token, get_current_user from app.utils import sanitize_filename, is_safe_filename +# Watchlist and auto-download +from app.watchlist import watchlist_manager +from app.episode_checker import episode_checker +from app.auto_download_scheduler import auto_download_scheduler +from app.models.watchlist import ( + WatchlistItem, + WatchlistItemCreate, + WatchlistItemUpdate, + WatchlistStatus, + WatchlistSettings +) + # Security security = HTTPBearer() @@ -57,6 +69,9 @@ app.add_middleware( # Initialize download manager download_manager = DownloadManager(download_dir="downloads", max_parallel=3) +# Initialize episode checker with download manager +episode_checker.set_download_manager(download_manager) + def restore_completed_downloads(): """Scan downloads directory and restore completed download tasks""" @@ -1780,6 +1795,312 @@ async def trigger_sonarr_download(request: SonarrDownloadRequest, background_tas raise HTTPException(status_code=500, detail=str(e)) +# ================================ +# WATCHLIST & AUTO-DOWNLOAD ENDPOINTS +# ================================ + +@app.post("/api/watchlist", response_model=WatchlistItem, tags=["Watchlist"]) +async def add_to_watchlist( + item_data: WatchlistItemCreate, + current_user: User = Depends(get_current_user) +): + """Add an anime to the watchlist for automatic episode tracking""" + try: + item = watchlist_manager.create(current_user.id, item_data) + return item + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error adding to watchlist: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/watchlist", response_model=List[WatchlistItem], tags=["Watchlist"]) +async def get_watchlist( + status: Optional[WatchlistStatus] = None, + current_user: User = Depends(get_current_user) +): + """Get user's watchlist, optionally filtered by status""" + try: + items = watchlist_manager.get_all(user_id=current_user.id, status=status) + return items + except Exception as e: + logger.error(f"Error getting watchlist: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/watchlist/{item_id}", response_model=WatchlistItem, tags=["Watchlist"]) +async def get_watchlist_item( + item_id: str, + current_user: User = Depends(get_current_user) +): + """Get a specific watchlist item""" + try: + item = watchlist_manager.get_by_id(item_id) + if not item: + raise HTTPException(status_code=404, detail="Watchlist item not found") + + # Check ownership + if item.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Access denied") + + return item + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting watchlist item: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.put("/api/watchlist/{item_id}", response_model=WatchlistItem, tags=["Watchlist"]) +async def update_watchlist_item( + item_id: str, + update_data: WatchlistItemUpdate, + current_user: User = Depends(get_current_user) +): + """Update a watchlist item (settings, status, etc.)""" + try: + item = watchlist_manager.get_by_id(item_id) + if not item: + raise HTTPException(status_code=404, detail="Watchlist item not found") + + # Check ownership + if item.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Access denied") + + updated_item = watchlist_manager.update(item_id, update_data) + return updated_item + except HTTPException: + raise + except Exception as e: + logger.error(f"Error updating watchlist item: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.delete("/api/watchlist/{item_id}", tags=["Watchlist"]) +async def delete_watchlist_item( + item_id: str, + current_user: User = Depends(get_current_user) +): + """Delete an anime from the watchlist""" + try: + item = watchlist_manager.get_by_id(item_id) + if not item: + raise HTTPException(status_code=404, detail="Watchlist item not found") + + # Check ownership + if item.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Access denied") + + success = watchlist_manager.delete(item_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete item") + + return {"status": "success", "message": "Item deleted from watchlist"} + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting watchlist item: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/watchlist/{item_id}/check", tags=["Watchlist"]) +async def check_watchlist_item( + item_id: str, + current_user: User = Depends(get_current_user) +): + """Manually trigger a check for new episodes of a specific anime""" + try: + item = watchlist_manager.get_by_id(item_id) + if not item: + raise HTTPException(status_code=404, detail="Watchlist item not found") + + # Check ownership + if item.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Access denied") + + result = await episode_checker.manual_check(item_id) + if not result: + raise HTTPException(status_code=500, detail="Check failed") + + return result + except HTTPException: + raise + except Exception as e: + logger.error(f"Error checking watchlist item: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/watchlist/{item_id}/pause", response_model=WatchlistItem, tags=["Watchlist"]) +async def pause_watchlist_item( + item_id: str, + current_user: User = Depends(get_current_user) +): + """Pause automatic downloading for a specific anime""" + try: + item = watchlist_manager.get_by_id(item_id) + if not item: + raise HTTPException(status_code=404, detail="Watchlist item not found") + + # Check ownership + if item.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Access denied") + + update_data = WatchlistItemUpdate(status=WatchlistStatus.PAUSED) + updated_item = watchlist_manager.update(item_id, update_data) + return updated_item + except HTTPException: + raise + except Exception as e: + logger.error(f"Error pausing watchlist item: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/watchlist/{item_id}/resume", response_model=WatchlistItem, tags=["Watchlist"]) +async def resume_watchlist_item( + item_id: str, + current_user: User = Depends(get_current_user) +): + """Resume automatic downloading for a paused anime""" + try: + item = watchlist_manager.get_by_id(item_id) + if not item: + raise HTTPException(status_code=404, detail="Watchlist item not found") + + # Check ownership + if item.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Access denied") + + update_data = WatchlistItemUpdate(status=WatchlistStatus.ACTIVE) + updated_item = watchlist_manager.update(item_id, update_data) + return updated_item + except HTTPException: + raise + except Exception as e: + logger.error(f"Error resuming watchlist item: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/watchlist/settings", response_model=WatchlistSettings, tags=["Watchlist"]) +async def get_watchlist_settings( + current_user: User = Depends(get_current_user) +): + """Get global watchlist settings""" + try: + settings = watchlist_manager.get_settings() + return settings + except Exception as e: + logger.error(f"Error getting watchlist settings: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.put("/api/watchlist/settings", response_model=WatchlistSettings, tags=["Watchlist"]) +async def update_watchlist_settings( + settings: WatchlistSettings, + current_user: User = Depends(get_current_user) +): + """Update global watchlist settings""" + try: + updated_settings = watchlist_manager.update_settings(settings) + + # Restart scheduler with new interval if it's running + if auto_download_scheduler.is_running(): + auto_download_scheduler.restart() + + return updated_settings + except Exception as e: + logger.error(f"Error updating watchlist settings: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/watchlist/stats", tags=["Watchlist"]) +async def get_watchlist_stats( + current_user: User = Depends(get_current_user) +): + """Get watchlist statistics""" + try: + stats = watchlist_manager.get_stats(user_id=current_user.id) + return stats + except Exception as e: + logger.error(f"Error getting watchlist stats: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/watchlist/check-all", tags=["Watchlist"]) +async def check_all_watchlist_items( + current_user: User = Depends(get_current_user) +): + """Manually trigger a check for all due watchlist items""" + try: + results = await episode_checker.check_all_due() + + # Filter results to only show user's items + user_results = [] + for result in results: + item = watchlist_manager.get_by_id(result.watchlist_item_id) + if item and item.user_id == current_user.id: + user_results.append(result) + + return { + "status": "success", + "checked": len(user_results), + "total_new_episodes": sum(r.new_episodes_found for r in user_results), + "total_downloaded": sum(len(r.episodes_downloaded) for r in user_results), + "results": user_results + } + except Exception as e: + logger.error(f"Error checking all watchlist items: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/watchlist/scheduler/status", tags=["Watchlist"]) +async def get_scheduler_status( + current_user: User = Depends(get_current_user) +): + """Get auto-download scheduler status""" + try: + return { + "running": auto_download_scheduler.is_running(), + "next_run": auto_download_scheduler.get_next_run_time(), + "settings": watchlist_manager.get_settings() + } + except Exception as e: + logger.error(f"Error getting scheduler status: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/watchlist/scheduler/start", tags=["Watchlist"]) +async def start_scheduler( + current_user: User = Depends(get_current_user) +): + """Start the auto-download scheduler""" + try: + if auto_download_scheduler.is_running(): + return {"status": "already_running", "message": "Scheduler is already running"} + + auto_download_scheduler.start() + return {"status": "started", "message": "Scheduler started successfully"} + except Exception as e: + logger.error(f"Error starting scheduler: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/watchlist/scheduler/stop", tags=["Watchlist"]) +async def stop_scheduler( + current_user: User = Depends(get_current_user) +): + """Stop the auto-download scheduler""" + try: + if not auto_download_scheduler.is_running(): + return {"status": "not_running", "message": "Scheduler is not running"} + + auto_download_scheduler.stop() + return {"status": "stopped", "message": "Scheduler stopped successfully"} + except Exception as e: + logger.error(f"Error stopping scheduler: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + if __name__ == "__main__": uvicorn.run( "main:app", diff --git a/requirements.txt b/requirements.txt index e2fc92d..6a9fe5e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,3 +23,6 @@ pytest-html==4.1.1 passlib[bcrypt]==1.7.4 python-jose[cryptography]==3.3.0 bcrypt<4.0 + +# Scheduler for auto-download +apscheduler==3.11.0