diff --git a/CLAUDE.md b/CLAUDE.md index 3e43517..ee52c0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -281,7 +281,54 @@ The downloaders are organized into three categories with separate base classes: - Provides enriched metadata (synopsis, genres, ratings, poster images) - Used as fallback when provider metadata is incomplete -### 10. Pydantic Models (`app/models/`) +### 10. Watchlist & Auto-Download System + +**WatchlistManager** (`app/watchlist.py`): +- JSON-based storage in `config/watchlist.json` +- Per-user watchlist management (multi-tenant) +- CRUD operations for tracked anime +- Statistics and queries +- Settings management in `config/watchlist_settings.json` + +**EpisodeChecker** (`app/episode_checker.py`): +- Checks for new episodes for anime in watchlist +- Downloads episodes automatically when detected +- Integrates with existing downloaders +- Handles errors and retries +- Lazy initialization to avoid circular imports + +**AutoDownloadScheduler** (`app/auto_download_scheduler.py`): +- APScheduler-based periodic checking +- Configurable intervals (1-168 hours) +- Start/stop control via API +- Next run tracking +- Background task execution + +**Watchlist Models** (`app/models/watchlist.py`): +- `WatchlistItem` - Tracked anime with settings +- `WatchlistStatus` - ACTIVE, PAUSED, COMPLETED, ARCHIVED +- `QualityPreference` - AUTO, 1080p, 720p, 480p +- `WatchlistSettings` - Global configuration +- `AutoDownloadResult` - Operation results + +**Watchlist Endpoints:** +- `GET /api/watchlist` - List user's watchlist (with status filter) +- `POST /api/watchlist` - Add anime to watchlist +- `GET /api/watchlist/{item_id}` - Get specific item +- `PUT /api/watchlist/{item_id}` - Update watchlist item +- `DELETE /api/watchlist/{item_id}` - Remove from watchlist +- `POST /api/watchlist/{item_id}/check` - Check specific anime +- `POST /api/watchlist/check-all` - Check all due items +- `POST /api/watchlist/{item_id}/pause` - Pause tracking +- `POST /api/watchlist/{item_id}/resume` - Resume tracking +- `GET /api/watchlist/settings` - Get global settings +- `PUT /api/watchlist/settings` - Update settings +- `GET /api/watchlist/stats` - Get watchlist statistics +- `GET /api/watchlist/scheduler/status` - Get scheduler status +- `POST /api/watchlist/scheduler/start` - Start scheduler +- `POST /api/watchlist/scheduler/stop` - Stop scheduler + +### 11. Pydantic Models (`app/models/`) - **`__init__.py`** - Core models: - `DownloadStatus` - Enum for task states (PENDING, DOWNLOADING, PAUSED, COMPLETED, FAILED, CANCELLED) - `HostType` - Enum for file host types (RAPIDFILE, UNFICHIER, DOODSTREAM, OTHER) @@ -294,6 +341,17 @@ The downloaders are organized into three categories with separate base classes: - `SonarrEventType` - Enum for event types (Grab, Download, Rename, Delete, Test) - `SonarrMapping` - Mapping between Sonarr series and anime providers - `SonarrConfig` - Webhook configuration (enabled, secret, auto-download, etc.) +- **`auth.py`** - Authentication models: + - `UserCreate` - User registration request + - `UserLogin` - Login request + - `User` - User profile + - `Token` - JWT token response +- **`watchlist.py`** - Watchlist models: + - `WatchlistItem` - Tracked anime item + - `WatchlistItemCreate` - Create request + - `WatchlistItemUpdate` - Update request + - `WatchlistStatus` - Status enum + - `WatchlistSettings` - Global settings ## Test Structure @@ -521,6 +579,8 @@ JWT_SECRET_KEY=change-me-in-production # JWT signing key for auth - `config/users.json` - User authentication database (created automatically) - `config/sonarr.json` - Sonarr webhook configuration (created automatically) - `config/sonarr_mappings.json` - Sonarr to anime provider mappings (created automatically) +- `config/watchlist.json` - User watchlist items (created automatically) +- `config/watchlist_settings.json` - Watchlist global settings (created automatically) - `config/.gitkeep` - Ensures config directory is tracked in git - Example files: `config/sonarr.example.json`, `config/sonarr_mappings.example.json` @@ -530,6 +590,27 @@ JWT_SECRET_KEY=change-me-in-production # JWT signing key for auth - `docs/SONARR_INTEGRATION.md` - Complete Sonarr setup guide - `docs/SONARR_IMPLEMENTATION.md` - Technical implementation summary - `docs/IMPROVEMENTS_2024-01-24.md` - Recent security and quality improvements +- `docs/WATCHLIST_AUTO_DOWNLOAD.md` - Watchlist system documentation + +## Security + +**Filename Sanitization (`app/utils.py`):** +- `sanitize_filename()` - Removes dangerous characters (`\ / : * ? " < > |`) +- `is_safe_filename()` - Validates against path traversal patterns +- Used throughout the codebase for all file operations +- Prevents `../../../etc/passwd` style attacks +- Limits filename length to 255 characters + +**CORS Configuration:** +- Restricted origins (not `*`) in production +- Specific allowed methods (GET, POST, PUT, DELETE, PATCH, OPTIONS) +- Configured in `main.py` via environment variables + +**Authentication:** +- JWT token-based authentication with 7-day expiration +- bcrypt password hashing with passlib +- Passwords truncated to 72 bytes (bcrypt limitation) +- Credentials stored in `config/users.json` ## Key Implementation Details @@ -570,9 +651,14 @@ JWT_SECRET_KEY=change-me-in-production # JWT signing key for auth - beautifulsoup4, lxml - HTML parsing - aiofiles - Async file operations - jieba - Chinese text segmentation for fuzzy search +- passlib[bcrypt] - Password hashing +- python-jose[cryptography] - JWT token handling +- apscheduler - Task scheduling for auto-download **Testing:** - pytest - Test framework - pytest-asyncio - Async test support - pytest-cov - Coverage reporting - pytest-mock - Mocking support +- pytest-timeout - Test timeout handling +- pytest-html - HTML test reports diff --git a/app/auth.py b/app/auth.py index 98c2609..1212451 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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") diff --git a/app/episode_checker.py b/app/episode_checker.py index 10a0f72..c11f4b6 100644 --- a/app/episode_checker.py +++ b/app/episode_checker.py @@ -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: diff --git a/app/sonarr_handler.py b/app/sonarr_handler.py index 8d847a4..317c9b8 100644 --- a/app/sonarr_handler.py +++ b/app/sonarr_handler.py @@ -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 diff --git a/app/watchlist.py b/app/watchlist.py index bf2c2af..8cb7164 100644 --- a/app/watchlist.py +++ b/app/watchlist.py @@ -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}") diff --git a/main.py b/main.py index d4e6921..82fc136 100644 --- a/main.py +++ b/main.py @@ -32,7 +32,7 @@ from app.models.sonarr import ( SonarrDownloadRequest ) 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.auth import user_manager, create_access_token, verify_token from app.utils import sanitize_filename, is_safe_filename # Watchlist and auto-download @@ -73,6 +73,17 @@ download_manager = DownloadManager(download_dir="downloads", max_parallel=3) episode_checker.set_download_manager(download_manager) +@app.on_event("startup") +async def startup_event(): + from app.sonarr_handler import get_sonarr_handler + sonarr_handler = get_sonarr_handler() + sonarr_handler.set_download_manager(download_manager) + + from app.auto_download_scheduler import auto_download_scheduler + auto_download_scheduler.start() + logger.info("Application started: Sonarr handler and scheduler initialized") + + def restore_completed_downloads(): """Scan downloads directory and restore completed download tasks""" import logging @@ -186,15 +197,16 @@ async def get_current_user_from_token(credentials: HTTPAuthorizationCredentials headers={"WWW-Authenticate": "Bearer"}, ) - user = user_manager.get_user(username) - if user is None: + user_dict = user_manager.get_user(username) + if user_dict is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found", headers={"WWW-Authenticate": "Bearer"}, ) - return user + # Convert dict to User Pydantic model + return User(**user_dict) @app.post("/api/auth/register") @@ -294,7 +306,7 @@ async def login(form_data: UserLogin): @app.get("/api/auth/me") -async def get_me(current_user: dict = Depends(get_current_user_from_token)): +async def get_me(current_user: User = Depends(get_current_user_from_token)): """ Get current user information @@ -303,13 +315,13 @@ async def get_me(current_user: dict = Depends(get_current_user_from_token)): """ return { "user": { - "id": current_user["id"], - "username": current_user["username"], - "email": current_user.get("email"), - "full_name": current_user.get("full_name"), - "is_active": current_user.get("is_active", True), - "created_at": current_user.get("created_at"), - "last_login": current_user.get("last_login") + "id": current_user.id, + "username": current_user.username, + "email": current_user.email, + "full_name": current_user.full_name, + "is_active": current_user.is_active, + "created_at": current_user.created_at, + "last_login": current_user.last_login } } @@ -1808,7 +1820,7 @@ async def trigger_sonarr_download(request: SonarrDownloadRequest, background_tas @app.post("/api/watchlist", response_model=WatchlistItem, tags=["Watchlist"]) async def add_to_watchlist( item_data: WatchlistItemCreate, - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user_from_token) ): """Add an anime to the watchlist for automatic episode tracking""" try: @@ -1824,7 +1836,7 @@ async def add_to_watchlist( @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) + current_user: User = Depends(get_current_user_from_token) ): """Get user's watchlist, optionally filtered by status""" try: @@ -1835,161 +1847,9 @@ async def get_watchlist( 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) + current_user: User = Depends(get_current_user_from_token) ): """Get global watchlist settings""" try: @@ -2003,7 +1863,7 @@ async def get_watchlist_settings( @app.put("/api/watchlist/settings", response_model=WatchlistSettings, tags=["Watchlist"]) async def update_watchlist_settings( settings: WatchlistSettings, - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user_from_token) ): """Update global watchlist settings""" try: @@ -2021,7 +1881,7 @@ async def update_watchlist_settings( @app.get("/api/watchlist/stats", tags=["Watchlist"]) async def get_watchlist_stats( - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user_from_token) ): """Get watchlist statistics""" try: @@ -2034,7 +1894,7 @@ async def get_watchlist_stats( @app.post("/api/watchlist/check-all", tags=["Watchlist"]) async def check_all_watchlist_items( - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user_from_token) ): """Manually trigger a check for all due watchlist items""" try: @@ -2061,7 +1921,7 @@ async def check_all_watchlist_items( @app.get("/api/watchlist/scheduler/status", tags=["Watchlist"]) async def get_scheduler_status( - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user_from_token) ): """Get auto-download scheduler status""" try: @@ -2077,7 +1937,7 @@ async def get_scheduler_status( @app.post("/api/watchlist/scheduler/start", tags=["Watchlist"]) async def start_scheduler( - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user_from_token) ): """Start the auto-download scheduler""" try: @@ -2093,7 +1953,7 @@ async def start_scheduler( @app.post("/api/watchlist/scheduler/stop", tags=["Watchlist"]) async def stop_scheduler( - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user_from_token) ): """Stop the auto-download scheduler""" try: @@ -2107,6 +1967,158 @@ async def stop_scheduler( 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_from_token) +): + """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_from_token) +): + """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_from_token) +): + """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_from_token) +): + """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_from_token) +): + """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_from_token) +): + """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)) + + if __name__ == "__main__": uvicorn.run( "main:app", diff --git a/static/css/style.css b/static/css/style.css index 6cbe3df..55b11ec 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -241,22 +241,24 @@ .anime-card-actions { display: flex; + flex-direction: column; gap: 8px; margin-top: 15px; } .anime-card-actions select { - flex: 1; + width: 100%; padding: 8px; border: 2px solid rgba(255, 255, 255, 0.1); border-radius: 6px; background: rgba(0, 0, 0, 0.3); color: #fff; font-size: 13px; + box-sizing: border-box; } .anime-card-actions button { - flex: 1; + width: 100%; padding: 8px 12px; font-size: 12px; } diff --git a/static/js/anime-details.js b/static/js/anime-details.js index 05bbbf0..d24bf4f 100644 --- a/static/js/anime-details.js +++ b/static/js/anime-details.js @@ -62,26 +62,27 @@ async function searchAnimeDetails(query, malId = null) { const providersData = await getProvidersInfo(); // Build results HTML - streamingHtml = ` -