feat: Complete watchlist & auto-download system with UI

Implement comprehensive watchlist system with automatic episode detection
and downloading. Features include per-user watchlists, scheduler-based
periodic checks, and a modern web UI.

**Backend Components:**
- WatchlistManager: JSON-based storage with multi-tenant support
- EpisodeChecker: Detects and downloads new episodes automatically
- AutoDownloadScheduler: APScheduler-based periodic task execution
- Complete REST API for CRUD operations and scheduler control

**Frontend Components:**
- Modern watchlist page with dark theme and animations
- Real-time status updates and progress tracking
- Scheduler controls with next-run display
- Add anime directly from search results

**Models & Configuration:**
- WatchlistItem with status, quality, and auto-download settings
- WatchlistSettings for global configuration
- Per-user statistics and provider tracking

**API Endpoints:**
- GET/POST /api/watchlist - List and add items
- PUT/DELETE /api/watchlist/{id} - Update and delete
- POST /api/watchlist/{id}/check - Manual check trigger
- POST /api/watchlist/check-all - Check all due items
- GET/PUT /api/watchlist/settings - Global settings
- GET /api/watchlist/stats - Statistics
- GET/POST /api/watchlist/scheduler/* - Scheduler control

**Configuration Files:**
- config/watchlist.json - User watchlist data
- config/watchlist_settings.json - Global settings

Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2026-02-24 09:13:22 +00:00
parent c6be191699
commit da5403a307
17 changed files with 1733 additions and 259 deletions
+87 -1
View File
@@ -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
+14 -5
View File
@@ -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 -4
View File
@@ -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:
+68 -17
View File
@@ -3,6 +3,7 @@ import hmac
import hashlib
import json
import logging
import os
from typing import Optional, Dict, List, Tuple, Any
from pathlib import Path
from datetime import datetime
@@ -14,6 +15,7 @@ from app.models.sonarr import (
SonarrConfig,
SonarrDownloadRequest
)
from app.models import DownloadRequest
from app.downloaders import get_downloader, AnimeSamaDownloader, NekoSamaDownloader, AnimeUltimeDownloader, VostfreeDownloader
# Configure logging
@@ -28,11 +30,15 @@ class SonarrHandler:
self.mappings_path = Path(mappings_path)
self.config = self._load_config()
self.mappings = self._load_mappings()
self.download_manager = None
# Create config directories if they don't exist
self.config_path.parent.mkdir(exist_ok=True)
self.mappings_path.parent.mkdir(exist_ok=True)
def set_download_manager(self, download_manager):
self.download_manager = download_manager
def _load_config(self) -> SonarrConfig:
"""Load Sonarr configuration from file"""
if self.config_path.exists():
@@ -45,10 +51,11 @@ class SonarrHandler:
return SonarrConfig()
def _save_config(self):
"""Save Sonarr configuration to file"""
try:
with open(self.config_path, 'w') as f:
temp_file = f"{self.config_path}.tmp"
with open(temp_file, 'w') as f:
json.dump(self.config.model_dump(mode='json'), f, indent=2)
os.replace(temp_file, self.config_path)
except Exception as e:
logger.error(f"Failed to save Sonarr config: {e}")
raise
@@ -65,11 +72,13 @@ class SonarrHandler:
return []
def _save_mappings(self):
"""Save mappings to file"""
try:
with open(self.mappings_path, 'w') as f:
os.makedirs(os.path.dirname(self.mappings_path), exist_ok=True)
temp_file = f"{self.mappings_path}.tmp"
with open(temp_file, 'w') as f:
mappings_data = [m.model_dump(mode='json') for m in self.mappings]
json.dump(mappings_data, f, indent=2)
os.replace(temp_file, self.mappings_path)
except Exception as e:
logger.error(f"Failed to save mappings: {e}")
raise
@@ -231,26 +240,25 @@ class SonarrHandler:
downloads = []
for episode in payload.episodes:
try:
download_request = SonarrDownloadRequest(
sonarr_series_id=payload.series.tvdbId,
sonarr_title=payload.series.title,
season_number=episode.seasonNumber,
episode_number=episode.episodeNumber,
quality=payload.release.quality.quality.get('name') if payload.release else mapping.quality_preference,
lang=mapping.lang,
provider=mapping.anime_provider
success = await self._trigger_download(
mapping,
episode.seasonNumber,
episode.episodeNumber
)
# Trigger the download (will be implemented in main.py)
downloads.append({
"season": episode.seasonNumber,
"episode": episode.episodeNumber,
"status": "queued"
"status": "started" if success else "failed"
})
logger.info(f"Queued download for {mapping.anime_title} S{episode.seasonNumber}E{episode.episodeNumber}")
except Exception as e:
logger.error(f"Failed to queue download for episode {episode.episodeNumber}: {e}")
logger.error(f"Failed to trigger download for episode {episode.episodeNumber}: {e}")
downloads.append({
"season": episode.seasonNumber,
"episode": episode.episodeNumber,
"status": "error",
"error": str(e)
})
return {
"status": "processing",
@@ -259,6 +267,49 @@ class SonarrHandler:
"downloads": downloads
}
async def _trigger_download(self, mapping: SonarrMapping, season_number: int, episode_number: int) -> bool:
if not self.download_manager:
logger.error("DownloadManager not set in SonarrHandler")
return False
try:
downloader = get_downloader(mapping.anime_url)
if not downloader:
logger.error(f"No downloader for {mapping.anime_url}")
return False
episodes = await downloader.get_episodes(mapping.anime_url, mapping.lang)
target_episode = None
for ep in episodes:
if ep.get('episode_number') == episode_number:
if ep.get('season') and ep['season'] != season_number:
continue
target_episode = ep
break
if not target_episode:
logger.warning(f"Episode {episode_number} not found for {mapping.anime_title}")
return False
video_url, _ = await downloader.get_download_link(target_episode['url'])
player_handler = get_downloader(video_url)
download_url, filename = await player_handler.get_download_link(video_url)
request = DownloadRequest(url=download_url, filename=filename)
task = self.download_manager.create_task(request)
if task:
await self.download_manager.start_download(task.id)
logger.info(f"Sonarr: Started download for {mapping.anime_title} S{season_number}E{episode_number}")
return True
return False
except Exception as e:
logger.error(f"Error triggering Sonarr download: {e}")
return False
async def _handle_download(self, payload: SonarrWebhookPayload) -> Dict:
"""Handle Download event (when Sonarr completes download)"""
# Similar to Grab but for post-download processing
+6 -4
View File
@@ -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}")
+185 -173
View File
@@ -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",
+4 -2
View File
@@ -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;
}
+21 -20
View File
@@ -62,26 +62,27 @@ async function searchAnimeDetails(query, malId = null) {
const providersData = await getProvidersInfo();
// Build results HTML
streamingHtml = `
<div class="streaming-results-header">
const streamingParts = [
`<div class="streaming-results-header">
<h3>🎬 Résultats de streaming</h3>
</div>
<div class="search-results" style="margin-top: 20px;">
`;
<div class="search-results" style="margin-top: 20px;">`
];
// Display results from each provider
// Display results from each provider - render all cards in parallel
for (const [providerId, results] of Object.entries(streamingData.value.results)) {
if (results && results.length > 0) {
const provider = providersData.anime_providers[providerId];
results.forEach((anime) => {
// Use the same renderAnimeCard function from anime.js for consistency
streamingHtml += renderAnimeCard(anime, providerId, provider, 'vostfr');
});
// Render all cards for this provider
const cardPromises = results.map((anime) => renderAnimeCard(anime, providerId, provider, 'vostfr'));
const cards = await Promise.all(cardPromises);
streamingParts.push(...cards);
}
}
streamingHtml += '</div>';
streamingParts.push('</div>');
streamingHtml = streamingParts.join('');
}
// Display results
@@ -149,12 +150,12 @@ async function getProviderSearchResults(query) {
}
// Build results HTML
let html = `
<div class="streaming-results-header">
const htmlParts = [
`<div class="streaming-results-header">
<h3>🎬 Résultats de streaming</h3>
</div>
<div class="search-results" style="margin-top: 20px;">
`;
<div class="search-results" style="margin-top: 20px;">`
];
// Display results from each provider
for (const [providerId, results] of Object.entries(data.results)) {
@@ -162,16 +163,16 @@ async function getProviderSearchResults(query) {
const providersData = await getProvidersInfo();
const provider = providersData.anime_providers[providerId];
results.forEach((anime, index) => {
// Use the same renderAnimeCard function from anime.js for consistency
html += renderAnimeCard(anime, providerId, provider, 'vostfr');
});
// Render all cards for this provider in parallel
const cardPromises = results.map((anime) => renderAnimeCard(anime, providerId, provider, 'vostfr'));
const cards = await Promise.all(cardPromises);
htmlParts.push(...cards);
}
}
html += '</div>';
htmlParts.push('</div>');
return html;
return htmlParts.join('');
} catch (error) {
console.error('Error getting provider search results:', error);
+208 -4
View File
@@ -62,7 +62,7 @@ async function renderAnimeCard(anime, providerId, providerInfo, lang) {
const supportsSeasons = await providerSupportsSeasons(providerId, anime.url);
const seasonSelectHtml = supportsSeasons ? `
<select id="seasons-${providerId}-${encodeURIComponent(anime.url)}" onchange="handleSeasonChange('${providerId}', '${encodeURIComponent(anime.url)}', '${lang}')" style="margin-bottom: 8px;">
<select id="seasons-${providerId}-${encodeURIComponent(anime.url)}" onchange="handleSeasonChange('${providerId}', '${encodeURIComponent(anime.url)}', '${lang}')" style="margin-bottom: 8px; width: 100%; padding: 8px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; color: #fff; font-size: 14px;">
<option value="">Chargement des saisons...</option>
</select>
` : '';
@@ -76,8 +76,10 @@ async function renderAnimeCard(anime, providerId, providerInfo, lang) {
${metadataHtml}
<div class="anime-card-actions">
${seasonSelectHtml}
<select id="episodes-${providerId}-${encodeURIComponent(anime.url)}">
<option value="">${supportsSeasons ? 'Sélectionner une saison d\'abord' : 'Charger les épisodes...'}</option>
<select id="episodes-${providerId}-${encodeURIComponent(anime.url)}"
onclick="${!supportsSeasons ? `loadEpisodesForAnime('${providerId}', '${encodeURIComponent(anime.url)}', '${lang}')` : ''}"
onchange="${!supportsSeasons ? `loadEpisodesForAnime('${providerId}', '${encodeURIComponent(anime.url)}', '${lang}')` : ''}">
<option value="">${supportsSeasons ? 'Sélectionner une saison d\'abord' : 'Cliquez pour charger les épisodes...'}</option>
</select>
</div>
<div class="anime-card-actions" id="actions-${providerId}-${encodeURIComponent(anime.url)}" style="display:none;">
@@ -152,15 +154,21 @@ async function loadSeasonsForAnime(providerId, encodedUrl) {
const seasonSelectId = `seasons-${providerId}-${encodedUrl}`;
const seasonSelectElement = document.getElementById(seasonSelectId);
if (!seasonSelectElement) return;
if (!seasonSelectElement) {
console.log('Season select element not found:', seasonSelectId);
return;
}
// Check if provider supports seasons
const supportsSeasons = await providerSupportsSeasons(providerId, url);
if (!supportsSeasons) {
console.log('Provider does not support seasons:', providerId);
seasonSelectElement.style.display = 'none';
return;
}
console.log('Loading seasons for:', url, 'Element:', seasonSelectId);
// Mark as loading to prevent duplicate requests
if (seasonSelectElement.dataset.loading === 'true') {
console.log('Season loading already in progress, skipping...');
@@ -196,8 +204,10 @@ async function loadSeasonsForAnime(providerId, encodedUrl) {
});
console.log(`Loaded ${data.seasons.length} seasons`);
seasonSelectElement.style.display = 'block';
} else {
// No seasons found, hide season selector and load episodes directly
console.log('No seasons found, hiding selector');
seasonSelectElement.style.display = 'none';
loadEpisodesForAnime(providerId, encodedUrl, 'vostfr');
}
@@ -378,6 +388,195 @@ async function handleDownloadSeason(encodedUrl, lang) {
}
}
/**
* Load all seasons and episodes and display them
*/
async function loadAllSeasonsAndEpisodes(providerId, encodedUrl, lang) {
const url = decodeURIComponent(encodedUrl);
const cardId = `anime-${providerId}-${encodedUrl}`;
const card = document.getElementById(cardId);
if (!card) {
console.error('Card not found:', cardId);
return;
}
// Remove existing all-seasons container if present
const existingContainer = document.getElementById(`all-seasons-${providerId}-${encodedUrl}`);
if (existingContainer) {
existingContainer.remove();
return;
}
// Create container for all seasons
const container = document.createElement('div');
container.id = `all-seasons-${providerId}-${encodedUrl}`;
container.style.cssText = 'margin-top: 16px;';
try {
// Fetch all seasons
const response = await fetch(`${API_BASE}/anime/seasons?url=${encodeURIComponent(url)}`);
if (!response.ok) {
throw new Error('Failed to fetch seasons');
}
const data = await response.json();
if (!data.seasons || data.seasons.length === 0) {
container.innerHTML = '<div style="padding: 10px; color: #888;">Aucune saison disponible</div>';
card.appendChild(container);
return;
}
// Create HTML for all seasons
let html = '<div style="margin-bottom: 12px;"><strong>Toutes les saisons</strong></div>';
for (const season of data.seasons) {
const seasonId = `season-${encodeURIComponent(season.url)}`;
html += `
<div class="season-block" style="margin-bottom: 12px; padding: 12px; background: rgba(255, 255, 255, 0.05); border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.1);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div style="font-weight: 600; color: #00d9ff;">${escapeHtml(season.title)}</div>
<div style="font-size: 12px; color: #888;">${season.episode_count || '?'} épisodes</div>
</div>
<div id="${seasonId}-episodes" style="display: none;">
<select class="episode-select" data-season-url="${escapeHtml(season.url)}" style="width: 100%; margin-bottom: 8px;">
<option value="">Cliquez pour charger les épisodes...</option>
</select>
<div class="season-actions" style="display: none; gap: 8px;">
<button class="btn-primary btn-small" onclick="downloadSeasonEpisode('${encodeURIComponent(season.url)}', '${providerId}', '${lang}')">
📥 Télécharger
</button>
<button class="btn-secondary btn-small" onclick="downloadEntireSeason('${encodeURIComponent(season.url)}', '${lang}')" style="background: linear-gradient(45deg, #ff6b6b, #ffa500);">
📦 Saison complète
</button>
</div>
</div>
<button class="btn-secondary btn-small" onclick="toggleSeasonEpisodes('${seasonId}')" style="width: 100%;">
▼ Afficher les épisodes
</button>
</div>
`;
}
container.innerHTML = html;
card.appendChild(container);
} catch (error) {
console.error('Error loading all seasons:', error);
container.innerHTML = '<div style="padding: 10px; color: #ff6b6b;">Erreur de chargement des saisons</div>';
card.appendChild(container);
}
}
/**
* Toggle season episodes visibility
*/
function toggleSeasonEpisodes(seasonId) {
const episodesDiv = document.getElementById(`${seasonId}-episodes`);
const button = episodesDiv.parentElement.querySelector('button[onclick^="toggleSeasonEpisodes"]');
if (episodesDiv.style.display === 'none') {
episodesDiv.style.display = 'block';
button.textContent = '▲ Masquer les épisodes';
// Load episodes if not already loaded
const select = episodesDiv.querySelector('.episode-select');
if (select && select.options.length <= 1) {
const seasonUrl = select.dataset.seasonUrl;
loadSeasonEpisodes(seasonUrl, select);
}
} else {
episodesDiv.style.display = 'none';
button.textContent = '▼ Afficher les épisodes';
}
}
/**
* Load episodes for a specific season
*/
async function loadSeasonEpisodes(seasonUrl, selectElement) {
try {
selectElement.innerHTML = '<option value="">Chargement...</option>';
selectElement.disabled = true;
const data = await loadEpisodes(decodeURIComponent(seasonUrl), 'vostfr');
if (data.episodes && data.episodes.length > 0) {
selectElement.innerHTML = '<option value="">Sélectionner un épisode</option>';
data.episodes.forEach(ep => {
const option = document.createElement('option');
option.value = ep.url;
option.textContent = `Épisode ${ep.episode}`;
selectElement.appendChild(option);
});
selectElement.disabled = false;
// Show action buttons
const actionsDiv = selectElement.parentElement.querySelector('.season-actions');
if (actionsDiv) {
actionsDiv.style.display = 'flex';
}
} else {
selectElement.innerHTML = '<option value="">Aucun épisode disponible</option>';
}
} catch (error) {
console.error('Error loading episodes:', error);
selectElement.innerHTML = '<option value="">Erreur de chargement</option>';
}
}
/**
* Download selected episode from season
*/
async function downloadSeasonEpisode(encodedSeasonUrl, providerId, lang) {
const seasonUrl = decodeURIComponent(encodedSeasonUrl);
const selectElement = document.querySelector(`[data-season-url="${seasonUrl}"]`);
if (!selectElement) {
console.error('Select element not found');
return;
}
const episodeUrl = selectElement.value;
if (!episodeUrl) {
alert('Veuillez sélectionner un épisode');
return;
}
try {
await downloadEpisode(episodeUrl);
loadDownloads();
alert('Téléchargement démarré!');
selectElement.value = '';
} catch (error) {
console.error('Download error:', error);
alert('Erreur lors du démarrage du téléchargement');
}
}
/**
* Download entire season
*/
async function downloadEntireSeason(encodedSeasonUrl, lang) {
const seasonUrl = decodeURIComponent(encodedSeasonUrl);
if (!confirm(`⚠️ Attention: Vous allez télécharger toute cette saison. Cela peut prendre du temps et utiliser beaucoup d'espace disque.\n\nVoulez-vous continuer ?`)) {
return;
}
try {
const data = await downloadSeason(seasonUrl, lang);
loadDownloads();
alert(`${data.message}\n\n${data.total_episodes} épisodes ont été ajoutés à la file de téléchargement!`);
} catch (error) {
console.error('Season download error:', error);
alert('Erreur lors du démarrage du téléchargement de la saison');
}
}
/**
* Handle search form submission
*/
@@ -434,3 +633,8 @@ window.handleDownloadEpisode = handleDownloadEpisode;
window.handleDownloadSeason = handleDownloadSeason;
window.handleSearch = handleSearch;
window.handleDirectDownload = handleDirectDownload;
window.loadAllSeasonsAndEpisodes = loadAllSeasonsAndEpisodes;
window.toggleSeasonEpisodes = toggleSeasonEpisodes;
window.loadSeasonEpisodes = loadSeasonEpisodes;
window.downloadSeasonEpisode = downloadSeasonEpisode;
window.downloadEntireSeason = downloadEntireSeason;
+1 -1
View File
@@ -33,7 +33,7 @@ async function providerSupportsSeasons(providerId, url = null) {
return provider.supports_seasons;
}
// Otherwise, check by provider ID (known season-supporting providers)
return ['animesama', 'frenchmanga'].includes(providerId);
return ['anime-sama', 'anime-ultime', 'french-manga'].includes(providerId);
}
// Fallback: check URL if provided
+13 -14
View File
@@ -1,14 +1,13 @@
/**
* Watchlist management and auto-download UI
* Note: API_BASE is defined in api.js (loaded before this file)
*/
const API_BASE = '/api';
/**
* Get user's watchlist
*/
async function getWatchlist(status = null) {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -35,7 +34,7 @@ async function getWatchlist(status = null) {
* Add anime to watchlist
*/
async function addToWatchlist(animeData) {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -61,7 +60,7 @@ async function addToWatchlist(animeData) {
* Update watchlist item
*/
async function updateWatchlistItem(itemId, updateData) {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -86,7 +85,7 @@ async function updateWatchlistItem(itemId, updateData) {
* Delete from watchlist
*/
async function deleteFromWatchlist(itemId) {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -123,7 +122,7 @@ async function resumeWatchlistItem(itemId) {
* Check specific anime for new episodes
*/
async function checkWatchlistItem(itemId) {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -146,7 +145,7 @@ async function checkWatchlistItem(itemId) {
* Check all watchlist items
*/
async function checkAllWatchlistItems() {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -169,7 +168,7 @@ async function checkAllWatchlistItems() {
* Get watchlist settings
*/
async function getWatchlistSettings() {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -191,7 +190,7 @@ async function getWatchlistSettings() {
* Update watchlist settings
*/
async function updateWatchlistSettings(settings) {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -216,7 +215,7 @@ async function updateWatchlistSettings(settings) {
* Get watchlist statistics
*/
async function getWatchlistStats() {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -238,7 +237,7 @@ async function getWatchlistStats() {
* Get scheduler status
*/
async function getSchedulerStatus() {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -260,7 +259,7 @@ async function getSchedulerStatus() {
* Start scheduler
*/
async function startScheduler() {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
@@ -283,7 +282,7 @@ async function startScheduler() {
* Stop scheduler
*/
async function stopScheduler() {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Not authenticated');
}
+12 -10
View File
@@ -9,16 +9,18 @@
<link rel="stylesheet" href="/static/css/style.css">
<!-- JavaScript -->
<script src="/static/js/auth.js?v=1.9" defer></script>
<script src="/static/js/api.js?v=1.9" defer></script>
<script src="/static/js/utils.js?v=1.9" defer></script>
<script src="/static/js/downloads.js?v=1.9" defer></script>
<script src="/static/js/anime.js?v=1.9" defer></script>
<script src="/static/js/anime-details.js?v=1.9" defer></script>
<script src="/static/js/series-search.js?v=1.9" defer></script>
<script src="/static/js/recommendations.js?v=1.9" defer></script>
<script src="/static/js/tabs.js?v=1.9" defer></script>
<script src="/static/js/main.js?v=1.9" defer></script>
<script src="/static/js/auth.js?v=1.10" defer></script>
<script src="/static/js/api.js?v=1.11" defer></script>
<script src="/static/js/utils.js?v=1.11" defer></script>
<script src="/static/js/downloads.js?v=1.11" defer></script>
<script src="/static/js/anime.js?v=1.11" defer></script>
<script src="/static/js/anime-details.js?v=1.11" defer></script>
<script src="/static/js/series-search.js?v=1.11" defer></script>
<script src="/static/js/recommendations.js?v=1.11" defer></script>
<script src="/static/js/tabs.js?v=1.11" defer></script>
<script src="/static/js/watchlist.js?v=1.11" defer></script>
<script src="/static/js/watchlist-ui.js?v=1.11" defer></script>
<script src="/static/js/main.js?v=1.11" defer></script>
</head>
<body>
<div class="container">
+23 -1
View File
@@ -232,6 +232,7 @@
<!-- Scripts -->
<script src="/static/js/api.js"></script>
<script src="/static/js/utils.js"></script>
<script src="/static/js/watchlist.js"></script>
<script src="/static/js/watchlist-ui.js"></script>
<script src="/static/js/auth.js"></script>
@@ -251,12 +252,33 @@
* Check authentication
*/
async function checkAuth() {
const token = localStorage.getItem('token');
const token = localStorage.getItem('auth_token');
if (!token) {
window.location.href = '/login';
return false;
}
// Verify token with server
try {
const response = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
// Token invalid, remove it and redirect
localStorage.removeItem('auth_token');
localStorage.removeItem('user');
window.location.href = '/login';
return false;
}
return true;
} catch (error) {
console.error('Auth check error:', error);
window.location.href = '/login';
return false;
}
}
/**
+2
View File
@@ -126,6 +126,8 @@ async def favorites_manager(temp_dir):
"""Create a FavoritesManager instance with temporary storage"""
storage_path = temp_dir / "test_favorites.json"
manager = FavoritesManager(storage_path=str(storage_path))
# Initialize asynchronously
await manager._load()
yield manager
# Cleanup
if storage_path.exists():
+370
View File
@@ -0,0 +1,370 @@
"""
Unit tests for authentication system (app/auth.py)
Tests JWT tokens, user management, and password hashing
"""
import pytest
import json
from pathlib import Path
from datetime import datetime, timedelta
from unittest.mock import patch, Mock
from app.auth import UserManager, create_access_token, verify_token, get_user_from_token
class TestUserManager:
"""Tests for UserManager class"""
@pytest.fixture
def temp_users_file(self, temp_dir):
"""Create a temporary users.json file"""
return temp_dir / "users.json"
@pytest.fixture
def user_manager(self, temp_users_file):
"""Create a UserManager instance with temporary storage"""
manager = UserManager(json_path=str(temp_users_file))
yield manager
# Cleanup
if temp_users_file.exists():
temp_users_file.unlink()
def test_user_manager_init_creates_file(self, user_manager, temp_users_file):
"""Test that UserManager creates the users file on init"""
assert temp_users_file.exists()
data = json.loads(temp_users_file.read_text())
assert "users" in data
assert isinstance(data["users"], dict)
def test_user_manager_init_existing_file(self, temp_users_file):
"""Test UserManager initialization with existing file"""
# Create a file with existing data
existing_data = {
"users": {
"existing_user": {
"username": "existing_user",
"password_hash": "hash",
"created_at": "2024-01-01T00:00:00",
"last_login": None
}
}
}
temp_users_file.write_text(json.dumps(existing_data))
manager = UserManager(json_path=str(temp_users_file))
# Should load existing data
assert "existing_user" in manager.users
def test_create_user_success(self, user_manager):
"""Test successful user creation"""
user = user_manager.create_user("testuser", "password123")
assert user["username"] == "testuser"
assert "password_hash" in user
assert "created_at" in user
assert user["last_login"] is None
assert "testuser" in user_manager.users
def test_create_user_hashing(self, user_manager):
"""Test that passwords are properly hashed with bcrypt"""
user = user_manager.create_user("testuser", "password123")
# Hash should not be the plain password
assert user["password_hash"] != "password123"
# Bcrypt hashes start with $2b$
assert user["password_hash"].startswith("$2b$")
# Hash should be 60 characters (bcrypt standard)
assert len(user["password_hash"]) == 60
def test_create_user_duplicate(self, user_manager):
"""Test that duplicate usernames are rejected"""
user_manager.create_user("testuser", "password123")
with pytest.raises(ValueError, match="already exists"):
user_manager.create_user("testuser", "different456")
def test_create_user_short_password(self, user_manager):
"""Test that short passwords are rejected"""
with pytest.raises(ValueError, match="at least 6 characters"):
user_manager.create_user("testuser", "short")
def test_create_user_password_truncation(self, user_manager):
"""Test that passwords longer than 72 bytes are truncated"""
# Bcrypt has a 72-byte limit
long_password = "a" * 100
user = user_manager.create_user("testuser", long_password)
# Should succeed (password truncated internally)
assert user["username"] == "testuser"
def test_authenticate_user_success(self, user_manager):
"""Test successful user authentication"""
user_manager.create_user("testuser", "password123")
user = user_manager.authenticate_user("testuser", "password123")
assert user is not None
assert user["username"] == "testuser"
assert user["last_login"] is not None
def test_authenticate_user_wrong_password(self, user_manager):
"""Test authentication with wrong password"""
user_manager.create_user("testuser", "password123")
user = user_manager.authenticate_user("testuser", "wrongpassword")
assert user is None
def test_authenticate_user_nonexistent(self, user_manager):
"""Test authentication with non-existent user"""
user = user_manager.authenticate_user("nonexistent", "password")
assert user is None
def test_authenticate_updates_last_login(self, user_manager):
"""Test that authentication updates last_login timestamp"""
user_manager.create_user("testuser", "password123")
user_before = user_manager.users["testuser"]
assert user_before["last_login"] is None
user_manager.authenticate_user("testuser", "password123")
user_after = user_manager.users["testuser"]
assert user_after["last_login"] is not None
def test_get_user(self, user_manager):
"""Test getting a user by username"""
user_manager.create_user("testuser", "password123")
user = user_manager.get_user("testuser")
assert user is not None
assert user["username"] == "testuser"
def test_get_user_nonexistent(self, user_manager):
"""Test getting a non-existent user"""
user = user_manager.get_user("nonexistent")
assert user is None
def test_update_user_last_login(self, user_manager):
"""Test updating user's last login timestamp"""
user_manager.create_user("testuser", "password123")
user_manager.update_last_login("testuser")
user = user_manager.users["testuser"]
assert user["last_login"] is not None
def test_deprecated_scheme_migration(self, user_manager):
"""Test migration from deprecated password schemes"""
# This tests the passlib auto-migration feature
# In practice, this is handled by passlib automatically
user_manager.create_user("testuser", "password123")
user = user_manager.users["testuser"]
# Should use bcrypt scheme
assert user["password_hash"].startswith("$2b$")
class TestJWTTokens:
"""Tests for JWT token creation and verification"""
def test_create_access_token(self):
"""Test JWT token creation"""
token = create_access_token(data={"sub": "testuser"}, expires_delta=timedelta(minutes=30))
assert isinstance(token, str)
# JWT tokens have 3 parts separated by dots
assert len(token.split(".")) == 3
def test_create_token_default_expiration(self):
"""Test token creation with default expiration"""
token = create_access_token(data={"sub": "testuser"})
assert isinstance(token, str)
def test_verify_token_valid(self):
"""Test verifying a valid token"""
token = create_access_token(data={"sub": "testuser"})
payload = verify_token(token)
assert payload is not None
assert payload.get("sub") == "testuser"
def test_verify_token_invalid(self):
"""Test verifying an invalid token"""
payload = verify_token("invalid.token.here")
assert payload is None
def test_verify_token_expired(self):
"""Test verifying an expired token"""
# Create a token that's already expired
token = create_access_token(
data={"sub": "testuser"},
expires_delta=timedelta(seconds=-1) # Expired
)
payload = verify_token(token)
# Should return None for expired token
assert payload is None
def test_token_contains_username(self):
"""Test that token contains the username in 'sub' claim"""
token = create_access_token(data={"sub": "testuser"})
payload = verify_token(token)
assert payload["sub"] == "testuser"
def test_token_with_custom_claims(self):
"""Test token creation with custom claims"""
token = create_access_token(data={"sub": "testuser", "role": "admin"})
payload = verify_token(token)
assert payload["sub"] == "testuser"
assert payload["role"] == "admin"
def test_get_user_from_token_valid(self):
"""Test getting user from valid token"""
token = create_access_token(data={"sub": "testuser"})
username = get_user_from_token(token)
assert username == "testuser"
def test_get_user_from_token_invalid(self):
"""Test getting user from invalid token"""
username = get_user_from_token("invalid.token")
assert username is None
def test_get_user_from_token_no_sub(self):
"""Test getting user from token without 'sub' claim"""
# Create token without 'sub' claim
token = create_access_token(data={"user": "testuser"})
username = get_user_from_token(token)
assert username is None
def test_different_secrets(self):
"""Test that tokens can't be verified with different secrets"""
token = create_access_token(data={"sub": "testuser"})
# Try to verify with different secret (by mocking)
with patch('app.auth.JWT_SECRET_KEY', 'different-secret'):
payload = verify_token(token)
# Should fail verification
assert payload is None
class TestTokenExpiration:
"""Tests for token expiration handling"""
def test_token_expiration_time(self):
"""Test that token expiration time is correct"""
from app.auth import ACCESS_TOKEN_EXPIRE_MINUTES
# Create token with custom expiration
expires = timedelta(minutes=30)
token = create_access_token(data={"sub": "testuser"}, expires_delta=expires)
# Token should be valid immediately
payload = verify_token(token)
assert payload is not None
def test_default_expiration_from_config(self):
"""Test that default expiration matches configuration"""
from app.config import get_settings
settings = get_settings()
# Just verify the setting exists
assert hasattr(settings, 'ACCESS_TOKEN_EXPIRE_MINUTES') or 'ACCESS_TOKEN_EXPIRE_MINUTES' in dir(settings)
class TestPasswordSecurity:
"""Tests for password handling security"""
def test_password_not_stored_plaintext(self, user_manager):
"""Test that passwords are never stored in plain text"""
user_manager.create_user("testuser", "password123")
user_data = user_manager.users["testuser"]
assert "password" not in user_data
assert "password_hash" in user_data
assert user_data["password_hash"] != "password123"
def test_password_case_sensitive(self, user_manager):
"""Test that password authentication is case-sensitive"""
user_manager.create_user("testuser", "Password123")
# Wrong case should fail
user = user_manager.authenticate_user("testuser", "password123")
assert user is None
def test_different_users_same_password(self, user_manager):
"""Test that different users with same password have different hashes"""
# Bcrypt uses salt, so hashes should be different
user1 = user_manager.create_user("user1", "samepassword")
user2 = user_manager.create_user("user2", "samepassword")
assert user1["password_hash"] != user2["password_hash"]
def test_password_hash_algorithm(self, user_manager):
"""Test that bcrypt is used for password hashing"""
user = user_manager.create_user("testuser", "password123")
# Bcrypt hashes start with $2b$
assert user["password_hash"].startswith("$2b$")
class TestUserDataPersistence:
"""Tests for user data persistence and file operations"""
@pytest.fixture
def user_manager_with_file(self, temp_dir):
"""Create a UserManager and allow file operations"""
users_file = temp_dir / "test_users.json"
manager = UserManager(json_path=str(users_file))
yield manager
if users_file.exists():
users_file.unlink()
def test_user_saved_to_file(self, user_manager_with_file, temp_dir):
"""Test that users are saved to file"""
users_file = temp_dir / "test_users.json"
manager = user_manager_with_file
manager.create_user("testuser", "password123")
# Read file directly
data = json.loads(users_file.read_text())
assert "testuser" in data["users"]
def test_multiple_users_persisted(self, user_manager_with_file, temp_dir):
"""Test that multiple users are persisted correctly"""
users_file = temp_dir / "test_users.json"
manager = user_manager_with_file
manager.create_user("user1", "password1")
manager.create_user("user2", "password2")
manager.create_user("user3", "password3")
data = json.loads(users_file.read_text())
assert len(data["users"]) == 3
assert "user1" in data["users"]
assert "user2" in data["users"]
assert "user3" in data["users"]
def test_user_data_has_required_fields(self, user_manager_with_file):
"""Test that user data contains all required fields"""
manager = user_manager_with_file
user = manager.create_user("testuser", "password123")
required_fields = ["username", "password_hash", "created_at", "last_login"]
for field in required_fields:
assert field in user
def test_created_at_is_iso_format(self, user_manager_with_file):
"""Test that created_at is in ISO format"""
manager = user_manager_with_file
user = manager.create_user("testuser", "password123")
# Should be parseable as ISO datetime
datetime.fromisoformat(user["created_at"])
class TestUsernameValidation:
"""Tests for username validation"""
@pytest.fixture
def user_manager(self, temp_dir):
users_file = temp_dir / "users.json"
manager = UserManager(json_path=str(users_file))
yield manager
if users_file.exists():
users_file.unlink()
def test_username_case_sensitive(self, user_manager):
"""Test that usernames are case-sensitive"""
user_manager.create_user("TestUser", "password123")
# Different case should be treated as different user
user2 = user_manager.create_user("testuser", "password456")
assert user2["username"] == "testuser"
# Both should exist
assert "TestUser" in user_manager.users
assert "testuser" in user_manager.users
def test_username_with_special_chars(self, user_manager):
"""Test usernames with special characters"""
# Should accept most characters
user = user_manager.create_user("user-123", "password123")
assert user["username"] == "user-123"
def test_username_with_spaces(self, user_manager):
"""Test usernames with spaces"""
user = user_manager.create_user("test user", "password123")
assert user["username"] == "test user"
+238
View File
@@ -0,0 +1,238 @@
"""
Unit tests for utility functions (app/utils.py)
Tests filename sanitization and security validation
"""
import pytest
from app.utils import sanitize_filename, is_safe_filename
class TestSanitizeFilename:
"""Tests for sanitize_filename function"""
def test_sanitize_simple_filename(self):
"""Test sanitizing a simple, safe filename"""
filename = "simple_video.mp4"
result = sanitize_filename(filename)
assert result == "simple_video.mp4"
def test_sanitize_with_dangerous_chars(self):
"""Test removal of dangerous characters"""
# Test each dangerous character
assert sanitize_filename("file\\name.mp4") == "file_name.mp4"
assert sanitize_filename("file/name.mp4") == "file_name.mp4"
assert sanitize_filename("file:name.mp4") == "file_name.mp4"
assert sanitize_filename("file*name.mp4") == "file_name.mp4"
assert sanitize_filename("file?name.mp4") == "file_name.mp4"
assert sanitize_filename('file"name.mp4') == "file_name.mp4"
assert sanitize_filename("file<name>.mp4") == "file_name_.mp4"
assert sanitize_filename("file|name.mp4") == "file_name.mp4"
def test_sanitize_all_dangerous_chars(self):
"""Test filename with all dangerous characters"""
filename = 'file\\/:*?"<>|name.mp4'
result = sanitize_filename(filename)
assert result == "file________name.mp4"
def test_sanitize_path_traversal(self):
"""Test path traversal attempts are blocked"""
# Parent directory traversal
assert sanitize_filename("../../../etc/passwd") == "______etc_passwd"
assert sanitize_filename("../../secret.txt") == "____secret.txt"
# Current directory reference
assert sanitize_filename("./file.txt") == "file.txt"
assert sanitize_filename(".hidden") == "hidden"
# Absolute path attempts
assert sanitize_filename("/etc/passwd") == "passwd"
assert sanitize_filename("\\windows\\system32") == "system32"
def test_sanitize_leading_dots_and_dashes(self):
"""Test removal of leading dots and dashes"""
assert sanitize_filename(".hidden") == "hidden"
assert sanitize_filename("..hidden") == "hidden"
assert sanitize_filename("---file.txt") == "file.txt"
assert sanitize_filename("...test...mp4") == "test...mp4" # Only leading
def test_sanitize_empty_filename(self):
"""Test empty filename returns default"""
assert sanitize_filename("") == "download"
assert sanitize_filename(" ") == "download"
def test_sanitize_only_dangerous_chars(self):
"""Test filename with only dangerous characters"""
assert sanitize_filename("\\/:*?\"<>|") == "download"
def test_sanitize_length_limit(self):
"""Test filename length is limited"""
# Create a very long filename
long_name = "a" * 300 + ".mp4"
result = sanitize_filename(long_name, max_length=255)
assert len(result) <= 255
assert result.endswith(".mp4")
def test_sanitize_length_limit_preserves_extension(self):
"""Test that extension is preserved when limiting length"""
long_name = "x" * 260 + ".mp4"
result = sanitize_filename(long_name, max_length=255)
assert result.endswith(".mp4")
# Name part is truncated but extension kept
name, ext = result.rsplit(".", 1)
assert len(name) + len(ext) + 1 == 255
def test_sanitize_unicode(self):
"""Test sanitization with unicode characters"""
# Japanese characters
assert sanitize_filename("アニメ.mp4") == "アニメ.mp4"
# Accented characters
assert sanitize_filename("café.mp4") == "café.mp4"
# Emoji
assert sanitize_filename("video🎬.mp4") == "video🎬.mp4"
def test_sanitize_multiple_extensions(self):
"""Test filename with multiple dots"""
assert sanitize_filename("file.name.with.dots.tar.gz") == "file.name.with.dots.tar.gz"
# Only the last part is used for extension in length limit
def test_sanitize_no_extension(self):
"""Test filename without extension"""
assert sanitize_filename("README") == "README"
assert sanitize_filename("file\\name") == "file_name"
def test_sanitize_custom_max_length(self):
"""Test custom max length parameter"""
filename = "very_long_filename_here.txt"
result = sanitize_filename(filename, max_length=10)
assert len(result) <= 10
# Truncates name but keeps extension
assert result.endswith(".txt")
def test_sanitize_special_cases(self):
"""Test various special cases"""
# CON, PRN, AUX etc (Windows reserved names) - not handled currently
# but we document behavior
assert sanitize_filename("CON.txt") == "CON.txt"
# Filenames with spaces
assert sanitize_filename("my video file.mp4") == "my video file.mp4"
# Mixed case
assert sanitize_filename("ViDeO.Mp4") == "ViDeO.Mp4"
class TestIsSafeFilename:
"""Tests for is_safe_filename function"""
def test_safe_filenames(self):
"""Test that safe filenames return True"""
assert is_safe_filename("file.txt") is True
assert is_safe_filename("my_video.mp4") is True
assert is_safe_filename("document.pdf") is True
assert is_safe_filename("archive.tar.gz") is True
assert is_safe_filename("README") is True
assert is_safe_filename("file with spaces.txt") is True
assert is_safe_filename("file-with-dashes.txt") is True
assert is_safe_filename("file_with_underscores.txt") is True
def test_unsafe_path_traversal(self):
"""Test that path traversal attempts return False"""
assert is_safe_filename("../etc/passwd") is False
assert is_safe_filename("../../secret") is False
assert is_safe_filename("../../../file.txt") is False
assert is_safe_filename("....\\....\\file.txt") is False
def test_unsafe_absolute_paths(self):
"""Test that absolute paths return False"""
assert is_safe_filename("/etc/passwd") is False
assert is_safe_filename("/var/log/file.txt") is False
assert is_safe_filename("\\windows\\system32") is False
assert is_safe_filename("\\\\network\\share") is False
def test_unsafe_current_directory(self):
"""Test that current directory references return False"""
assert is_safe_filename("./file.txt") is False
assert is_safe_filename(".hidden") is False # Leading dot
assert is_safe_filename("././file.txt") is False
def test_unsafe_windows_drives(self):
"""Test that Windows drive letters return False"""
assert is_safe_filename("C:\\file.txt") is False
assert is_safe_filename("D:\\data\\file.txt") is False
assert is_safe_filename("E:/file.txt") is False
assert is_safe_filename("c:file.txt") is False
def test_empty_filename(self):
"""Test that empty filename returns False"""
assert is_safe_filename("") is False
assert is_safe_filename(" ") is False
def test_mixed_slashes(self):
"""Test mixed forward and backward slashes"""
assert is_safe_filename("folder\\file/name.txt") is False
assert is_safe_filename("folder/sub\\file.txt") is False
def test_unicode_safe(self):
"""Test unicode filenames are considered safe if no path traversal"""
assert is_safe_filename("ファイル.txt") is True
assert is_safe_filename("café.txt") is True
assert is_safe_filename("файл.txt") is True
def test_edge_cases(self):
"""Test edge cases"""
# Just a dot
assert is_safe_filename(".") is False
# Multiple dots
assert is_safe_filename("...") is False
# Dots in middle are OK
assert is_safe_filename("file.name.txt") is True
# Slash at end
assert is_safe_filename("file.txt/") is False
# Backslash at end
assert is_safe_filename("file.txt\\") is False
# Spaces only
assert is_safe_filename(" ") is False
class TestUtilityIntegration:
"""Integration tests for utility functions working together"""
def test_sanitize_then_is_safe(self):
"""Test that sanitized filenames are always safe"""
unsafe_filenames = [
"../../../etc/passwd",
"/absolute/path/file.txt",
"C:\\windows\\file.txt",
"./local/file.txt",
".hidden",
"file\\with:bad*chars?.txt",
]
for filename in unsafe_filenames:
sanitized = sanitize_filename(filename)
assert is_safe_filename(sanitized), f"Sanitized '{filename}' -> '{sanitized}' is not safe"
def test_roundtrip_safe_filenames(self):
"""Test that safe filenames remain unchanged"""
safe_filenames = [
"file.txt",
"my_video.mp4",
"document.pdf",
"archive.tar.gz",
"README",
"file with spaces.txt",
]
for filename in safe_filenames:
sanitized = sanitize_filename(filename)
assert sanitized == filename, f"Safe filename '{filename}' was changed to '{sanitized}'"
def test_empty_string_handling(self):
"""Test that empty string is handled consistently"""
sanitized = sanitize_filename("")
assert sanitized == "download"
assert is_safe_filename(sanitized) is True
+474
View File
@@ -0,0 +1,474 @@
"""
Unit tests for Watchlist system (app/watchlist.py, app/models/watchlist.py)
Tests watchlist CRUD operations, episode checking, and scheduler
"""
import pytest
import json
from pathlib import Path
from datetime import datetime
from unittest.mock import AsyncMock, Mock, patch
from app.watchlist import WatchlistManager
from app.models.watchlist import (
WatchlistItem,
WatchlistItemCreate,
WatchlistItemUpdate,
WatchlistStatus,
QualityPreference,
WatchlistSettings
)
class TestWatchlistManager:
"""Tests for WatchlistManager class"""
@pytest.fixture
def temp_watchlist_file(self, temp_dir):
"""Create a temporary watchlist.json file"""
return temp_dir / "watchlist.json"
@pytest.fixture
def watchlist_manager(self, temp_watchlist_file):
"""Create a WatchlistManager instance with temporary storage"""
manager = WatchlistManager(json_path=str(temp_watchlist_file))
yield manager
# Cleanup
if temp_watchlist_file.exists():
temp_watchlist_file.unlink()
@pytest.fixture
def sample_watchlist_item(self):
"""Create a sample watchlist item"""
return WatchlistItemCreate(
anime_url="https://anime-sama.si/catalogue/test/s1/vostfr/",
anime_title="Test Anime",
provider="anime-sama",
lang="vostfr",
quality_preference=QualityPreference.AUTO,
auto_download=True
)
def test_watchlist_manager_init_creates_file(self, watchlist_manager, temp_watchlist_file):
"""Test that WatchlistManager creates the file on init"""
assert temp_watchlist_file.exists()
data = json.loads(temp_watchlist_file.read_text())
assert "items" in data
def test_add_item_success(self, watchlist_manager, sample_watchlist_item):
"""Test adding an item to watchlist"""
item = watchlist_manager.add_item(
user_id="test_user",
item_data=sample_watchlist_item
)
assert item.id is not None
assert item.anime_title == "Test Anime"
assert item.status == WatchlistStatus.ACTIVE
assert item.user_id == "test_user"
def test_add_item_duplicate(self, watchlist_manager, sample_watchlist_item):
"""Test that duplicate items are rejected"""
watchlist_manager.add_item(user_id="test_user", item_data=sample_watchlist_item)
with pytest.raises(ValueError, match="already exists"):
watchlist_manager.add_item(user_id="test_user", item_data=sample_watchlist_item)
def test_get_items_empty(self, watchlist_manager):
"""Test getting items when watchlist is empty"""
items = watchlist_manager.get_items("test_user")
assert items == []
def test_get_items_with_data(self, watchlist_manager, sample_watchlist_item):
"""Test getting items after adding one"""
watchlist_manager.add_item(user_id="test_user", item_data=sample_watchlist_item)
items = watchlist_manager.get_items("test_user")
assert len(items) == 1
assert items[0].anime_title == "Test Anime"
def test_get_items_by_status(self, watchlist_manager):
"""Test filtering items by status"""
from app.models.watchlist import WatchlistItemCreate
# Add items with different statuses
item1 = WatchlistItemCreate(
anime_url="https://anime-sama.si/test1/",
anime_title="Anime 1",
provider="anime-sama",
lang="vostfr"
)
item2 = WatchlistItemCreate(
anime_url="https://anime-sama.si/test2/",
anime_title="Anime 2",
provider="anime-sama",
lang="vostfr"
)
watchlist_manager.add_item(user_id="test_user", item_data=item1)
item2_id = watchlist_manager.add_item(user_id="test_user", item_data=item2).id
# Pause one item
watchlist_manager.update_item(
user_id="test_user",
item_id=item2_id,
item_data=WatchlistItemUpdate(status=WatchlistStatus.PAUSED)
)
# Get only active items
active_items = watchlist_manager.get_items("test_user", status=WatchlistStatus.ACTIVE)
assert len(active_items) == 1
assert active_items[0].anime_title == "Anime 1"
# Get only paused items
paused_items = watchlist_manager.get_items("test_user", status=WatchlistStatus.PAUSED)
assert len(paused_items) == 1
assert paused_items[0].anime_title == "Anime 2"
def test_get_item_by_id(self, watchlist_manager, sample_watchlist_item):
"""Test getting a specific item by ID"""
item = watchlist_manager.add_item(user_id="test_user", item_data=sample_watchlist_item)
retrieved = watchlist_manager.get_item(user_id="test_user", item_id=item.id)
assert retrieved is not None
assert retrieved.id == item.id
assert retrieved.anime_title == "Test Anime"
def test_get_item_by_id_not_found(self, watchlist_manager):
"""Test getting non-existent item"""
item = watchlist_manager.get_item(user_id="test_user", item_id="nonexistent")
assert item is None
def test_update_item(self, watchlist_manager, sample_watchlist_item):
"""Test updating an item"""
item = watchlist_manager.add_item(user_id="test_user", item_data=sample_watchlist_item)
updated = watchlist_manager.update_item(
user_id="test_user",
item_id=item.id,
item_data=WatchlistItemUpdate(
quality_preference=QualityPreference.FULLHD
)
)
assert updated.quality_preference == QualityPreference.FULLHD
assert updated.anime_title == "Test Anime" # Unchanged
def test_update_item_not_found(self, watchlist_manager):
"""Test updating non-existent item"""
with pytest.raises(ValueError, match="not found"):
watchlist_manager.update_item(
user_id="test_user",
item_id="nonexistent",
item_data=WatchlistItemUpdate()
)
def test_delete_item(self, watchlist_manager, sample_watchlist_item):
"""Test deleting an item"""
item = watchlist_manager.add_item(user_id="test_user", item_data=sample_watchlist_item)
watchlist_manager.delete_item(user_id="test_user", item_id=item.id)
# Should be deleted
items = watchlist_manager.get_items("test_user")
assert len(items) == 0
def test_delete_item_not_found(self, watchlist_manager):
"""Test deleting non-existent item"""
with pytest.raises(ValueError, match="not found"):
watchlist_manager.delete_item(user_id="test_user", item_id="nonexistent")
def test_pause_item(self, watchlist_manager, sample_watchlist_item):
"""Test pausing an item"""
item = watchlist_manager.add_item(user_id="test_user", item_data=sample_watchlist_item)
paused = watchlist_manager.pause_item(user_id="test_user", item_id=item.id)
assert paused.status == WatchlistStatus.PAUSED
def test_resume_item(self, watchlist_manager, sample_watchlist_item):
"""Test resuming a paused item"""
item = watchlist_manager.add_item(user_id="test_user", item_data=sample_watchlist_item)
# Pause first
watchlist_manager.pause_item(user_id="test_user", item_id=item.id)
# Resume
resumed = watchlist_manager.resume_item(user_id="test_user", item_id=item.id)
assert resumed.status == WatchlistStatus.ACTIVE
def test_get_stats(self, watchlist_manager):
"""Test getting watchlist statistics"""
from app.models.watchlist import WatchlistItemCreate
# Add multiple items
for i in range(3):
item = WatchlistItemCreate(
anime_url=f"https://anime-sama.si/test{i}/",
anime_title=f"Anime {i}",
provider="anime-sama",
lang="vostfr"
)
watchlist_manager.add_item(user_id="test_user", item_data=item)
stats = watchlist_manager.get_stats("test_user")
assert stats["total"] == 3
assert stats["by_status"]["active"] == 3
def test_multi_user_isolation(self, watchlist_manager):
"""Test that different users have separate watchlists"""
from app.models.watchlist import WatchlistItemCreate
item1 = WatchlistItemCreate(
anime_url="https://anime-sama.si/test1/",
anime_title="Anime 1",
provider="anime-sama",
lang="vostfr"
)
item2 = WatchlistItemCreate(
anime_url="https://anime-sama.si/test2/",
anime_title="Anime 2",
provider="anime-sama",
lang="vostfr"
)
watchlist_manager.add_item(user_id="user1", item_data=item1)
watchlist_manager.add_item(user_id="user2", item_data=item2)
# Each user should only see their own items
user1_items = watchlist_manager.get_items("user1")
user2_items = watchlist_manager.get_items("user2")
assert len(user1_items) == 1
assert len(user2_items) == 1
assert user1_items[0].anime_title == "Anime 1"
assert user2_items[0].anime_title == "Anime 2"
class TestWatchlistItemModel:
"""Tests for WatchlistItem Pydantic model"""
def test_watchlist_item_creation(self):
"""Test creating a WatchlistItem"""
item = WatchlistItem(
id="test-id",
user_id="test_user",
anime_url="https://anime-sama.si/test/",
anime_title="Test Anime",
provider="anime-sama",
lang="vostfr",
quality_preference=QualityPreference.AUTO,
auto_download=True,
status=WatchlistStatus.ACTIVE,
last_checked=None,
created_at=datetime.now()
)
assert item.anime_title == "Test Anime"
assert item.status == WatchlistStatus.ACTIVE
def test_quality_preference_enum(self):
"""Test QualityPreference enum values"""
assert QualityPreference.AUTO == "auto"
assert QualityPreference.FULLHD == "1080p"
assert QualityPreference.HD == "720p"
assert QualityPreference.SD == "480p"
def test_watchlist_status_enum(self):
"""Test WatchlistStatus enum values"""
assert WatchlistStatus.ACTIVE == "active"
assert WatchlistStatus.PAUSED == "paused"
assert WatchlistStatus.COMPLETED == "completed"
assert WatchlistStatus.ARCHIVED == "archived"
class TestWatchlistSettings:
"""Tests for WatchlistSettings model and management"""
@pytest.fixture
def temp_settings_file(self, temp_dir):
"""Create a temporary watchlist_settings.json file"""
return temp_dir / "watchlist_settings.json"
def test_watchlist_settings_defaults(self):
"""Test default values for WatchlistSettings"""
settings = WatchlistSettings()
assert settings.auto_download_enabled is True
assert settings.check_interval_hours >= 1
assert settings.check_interval_hours <= 168
def test_watchlist_settings_validation(self):
"""Test WatchlistSettings validation"""
# Valid settings
settings = WatchlistSettings(
auto_download_enabled=True,
check_interval_hours=24,
default_quality=QualityPreference.AUTO
)
assert settings.check_interval_hours == 24
def test_watchlist_settings_invalid_interval(self):
"""Test that invalid check intervals are rejected"""
# Less than 1 hour
with pytest.raises(ValueError):
WatchlistSettings(check_interval_hours=0)
# More than 168 hours (1 week)
with pytest.raises(ValueError):
WatchlistSettings(check_interval_hours=200)
class TestEpisodeChecker:
"""Tests for EpisodeChecker functionality"""
@pytest.mark.asyncio
async def test_check_new_episodes(self):
"""Test checking for new episodes"""
from app.episode_checker import EpisodeChecker
# Mock the downloader
with patch('app.episode_checker.get_downloader') as mock_get_downloader:
mock_downloader = AsyncMock()
mock_downloader.get_episodes.return_value = [
{"episode_number": 1, "url": "ep1"},
{"episode_number": 2, "url": "ep2"},
{"episode_number": 3, "url": "ep3"}
]
mock_get_downloader.return_value = mock_downloader
checker = EpisodeChecker()
# Test episode checking logic
episodes = await mock_downloader.get_episodes(
"https://anime-sama.si/test/",
"vostfr"
)
assert len(episodes) == 3
assert episodes[2]["episode_number"] == 3
@pytest.mark.asyncio
async def test_episode_download_creation(self):
"""Test that new episodes trigger downloads when auto_download is enabled"""
# This would test the integration with download_manager
# For now, just test the logic flow
pass
class TestAutoDownloadScheduler:
"""Tests for AutoDownloadScheduler functionality"""
def test_scheduler_initialization(self):
"""Test scheduler initialization"""
from app.auto_download_scheduler import AutoDownloadScheduler
scheduler = AutoDownloadScheduler()
assert scheduler.is_running() is False
def test_scheduler_start_stop(self):
"""Test starting and stopping scheduler"""
from app.auto_download_scheduler import AutoDownloadScheduler
scheduler = AutoDownloadScheduler()
# Start
scheduler.start()
assert scheduler.is_running() is True
# Stop
scheduler.stop()
assert scheduler.is_running() is False
def test_scheduler_interval_validation(self):
"""Test that scheduler validates intervals"""
from app.auto_download_scheduler import AutoDownloadScheduler
scheduler = AutoDownloadScheduler()
# Valid interval
scheduler.set_interval(24) # 24 hours
assert scheduler.get_interval() == 24
# Invalid interval (should raise or clamp)
with pytest.raises(ValueError):
scheduler.set_interval(0) # Too small
with pytest.raises(ValueError):
scheduler.set_interval(200) # Too large
class TestWatchlistIntegration:
"""Integration tests for watchlist system"""
@pytest.fixture
def temp_watchlist_file(self, temp_dir):
"""Create a temporary watchlist.json file"""
return temp_dir / "watchlist.json"
@pytest.fixture
def watchlist_manager(self, temp_watchlist_file):
"""Create a WatchlistManager instance"""
manager = WatchlistManager(json_path=str(temp_watchlist_file))
yield manager
if temp_watchlist_file.exists():
temp_watchlist_file.unlink()
def test_full_workflow(self, watchlist_manager):
"""Test complete workflow: add -> pause -> resume -> delete"""
from app.models.watchlist import WatchlistItemCreate
# Add
item_data = WatchlistItemCreate(
anime_url="https://anime-sama.si/test/",
anime_title="Test Anime",
provider="anime-sama",
lang="vostfr"
)
item = watchlist_manager.add_item(user_id="test_user", item_data=item_data)
assert item.status == WatchlistStatus.ACTIVE
# Pause
paused = watchlist_manager.pause_item(user_id="test_user", item_id=item.id)
assert paused.status == WatchlistStatus.PAUSED
# Resume
resumed = watchlist_manager.resume_item(user_id="test_user", item_id=item.id)
assert resumed.status == WatchlistStatus.ACTIVE
# Delete
watchlist_manager.delete_item(user_id="test_user", item_id=item.id)
items = watchlist_manager.get_items("test_user")
assert len(items) == 0
def test_update_quality_preference_workflow(self, watchlist_manager):
"""Test updating quality preference"""
from app.models.watchlist import WatchlistItemCreate
item_data = WatchlistItemCreate(
anime_url="https://anime-sama.si/test/",
anime_title="Test Anime",
provider="anime-sama",
lang="vostfr",
quality_preference=QualityPreference.AUTO
)
item = watchlist_manager.add_item(user_id="test_user", item_data=item_data)
# Update to 1080p
updated = watchlist_manager.update_item(
user_id="test_user",
item_id=item.id,
item_data=WatchlistItemUpdate(quality_preference=QualityPreference.FULLHD)
)
assert updated.quality_preference == QualityPreference.FULLHD
def test_filter_by_status_workflow(self, watchlist_manager):
"""Test filtering items by different statuses"""
from app.models.watchlist import WatchlistItemCreate
# Add multiple items
for i, status in enumerate([WatchlistStatus.ACTIVE, WatchlistStatus.PAUSED, WatchlistStatus.COMPLETED]):
item_data = WatchlistItemCreate(
anime_url=f"https://anime-sama.si/test{i}/",
anime_title=f"Anime {i}",
provider="anime-sama",
lang="vostfr"
)
item = watchlist_manager.add_item(user_id="test_user", item_data=item_data)
# Update status
watchlist_manager.update_item(
user_id="test_user",
item_id=item.id,
item_data=WatchlistItemUpdate(status=status)
)
# Count by status
stats = watchlist_manager.get_stats("test_user")
assert stats["total"] == 3
assert stats["by_status"]["active"] == 1
assert stats["by_status"]["paused"] == 1
assert stats["by_status"]["completed"] == 1