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:
@@ -281,7 +281,54 @@ The downloaders are organized into three categories with separate base classes:
|
|||||||
- Provides enriched metadata (synopsis, genres, ratings, poster images)
|
- Provides enriched metadata (synopsis, genres, ratings, poster images)
|
||||||
- Used as fallback when provider metadata is incomplete
|
- 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:
|
- **`__init__.py`** - Core models:
|
||||||
- `DownloadStatus` - Enum for task states (PENDING, DOWNLOADING, PAUSED, COMPLETED, FAILED, CANCELLED)
|
- `DownloadStatus` - Enum for task states (PENDING, DOWNLOADING, PAUSED, COMPLETED, FAILED, CANCELLED)
|
||||||
- `HostType` - Enum for file host types (RAPIDFILE, UNFICHIER, DOODSTREAM, OTHER)
|
- `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)
|
- `SonarrEventType` - Enum for event types (Grab, Download, Rename, Delete, Test)
|
||||||
- `SonarrMapping` - Mapping between Sonarr series and anime providers
|
- `SonarrMapping` - Mapping between Sonarr series and anime providers
|
||||||
- `SonarrConfig` - Webhook configuration (enabled, secret, auto-download, etc.)
|
- `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
|
## 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/users.json` - User authentication database (created automatically)
|
||||||
- `config/sonarr.json` - Sonarr webhook configuration (created automatically)
|
- `config/sonarr.json` - Sonarr webhook configuration (created automatically)
|
||||||
- `config/sonarr_mappings.json` - Sonarr to anime provider mappings (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
|
- `config/.gitkeep` - Ensures config directory is tracked in git
|
||||||
- Example files: `config/sonarr.example.json`, `config/sonarr_mappings.example.json`
|
- 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_INTEGRATION.md` - Complete Sonarr setup guide
|
||||||
- `docs/SONARR_IMPLEMENTATION.md` - Technical implementation summary
|
- `docs/SONARR_IMPLEMENTATION.md` - Technical implementation summary
|
||||||
- `docs/IMPROVEMENTS_2024-01-24.md` - Recent security and quality improvements
|
- `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
|
## Key Implementation Details
|
||||||
|
|
||||||
@@ -570,9 +651,14 @@ JWT_SECRET_KEY=change-me-in-production # JWT signing key for auth
|
|||||||
- beautifulsoup4, lxml - HTML parsing
|
- beautifulsoup4, lxml - HTML parsing
|
||||||
- aiofiles - Async file operations
|
- aiofiles - Async file operations
|
||||||
- jieba - Chinese text segmentation for fuzzy search
|
- jieba - Chinese text segmentation for fuzzy search
|
||||||
|
- passlib[bcrypt] - Password hashing
|
||||||
|
- python-jose[cryptography] - JWT token handling
|
||||||
|
- apscheduler - Task scheduling for auto-download
|
||||||
|
|
||||||
**Testing:**
|
**Testing:**
|
||||||
- pytest - Test framework
|
- pytest - Test framework
|
||||||
- pytest-asyncio - Async test support
|
- pytest-asyncio - Async test support
|
||||||
- pytest-cov - Coverage reporting
|
- pytest-cov - Coverage reporting
|
||||||
- pytest-mock - Mocking support
|
- pytest-mock - Mocking support
|
||||||
|
- pytest-timeout - Test timeout handling
|
||||||
|
- pytest-html - HTML test reports
|
||||||
|
|||||||
+14
-5
@@ -7,6 +7,8 @@ from datetime import datetime, timedelta
|
|||||||
from typing import Optional, Dict
|
from typing import Optional, Dict
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
import logging
|
import logging
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -42,11 +44,12 @@ class UserManager:
|
|||||||
self.users = {}
|
self.users = {}
|
||||||
|
|
||||||
def _save_users(self):
|
def _save_users(self):
|
||||||
"""Save users to JSON file"""
|
|
||||||
try:
|
try:
|
||||||
os.makedirs(os.path.dirname(self.db_file), exist_ok=True)
|
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)
|
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")
|
logger.info(f"Saved {len(self.users)} users to database")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving users: {e}")
|
logger.error(f"Error saving users: {e}")
|
||||||
@@ -162,9 +165,15 @@ def verify_token(token: str) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(token: str) -> Optional[dict]:
|
def get_current_user(credentials: HTTPAuthorizationCredentials) -> dict:
|
||||||
"""Get current user from JWT token"""
|
"""Get current user from JWT token"""
|
||||||
|
token = credentials.credentials
|
||||||
username = verify_token(token)
|
username = verify_token(token)
|
||||||
if username:
|
if username:
|
||||||
return user_manager.get_user(username)
|
user = user_manager.get_user(username)
|
||||||
return None
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="User not found")
|
||||||
|
if not user.get("is_active", True):
|
||||||
|
raise HTTPException(status_code=401, detail="Inactive user")
|
||||||
|
return user
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from typing import List, Optional, Dict
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from app.watchlist import watchlist_manager, WatchlistManager
|
from app.watchlist import watchlist_manager, WatchlistManager
|
||||||
|
from app.models import DownloadRequest, DownloadTask, DownloadStatus
|
||||||
from app.models.watchlist import (
|
from app.models.watchlist import (
|
||||||
WatchlistItem,
|
WatchlistItem,
|
||||||
WatchlistSettings,
|
WatchlistSettings,
|
||||||
@@ -124,12 +125,11 @@ class EpisodeChecker:
|
|||||||
download_link, filename = await downloader.get_download_link(ep_info.episode_url)
|
download_link, filename = await downloader.get_download_link(ep_info.episode_url)
|
||||||
|
|
||||||
# Create download task
|
# Create download task
|
||||||
task = await self.download_manager.add_download(
|
request = DownloadRequest(url=download_link, filename=filename)
|
||||||
url=download_link,
|
task = self.download_manager.create_task(request)
|
||||||
filename=filename
|
|
||||||
)
|
|
||||||
|
|
||||||
if task:
|
if task:
|
||||||
|
await self.download_manager.start_download(task.id)
|
||||||
result.episodes_downloaded.append(ep_info.episode_number)
|
result.episodes_downloaded.append(ep_info.episode_number)
|
||||||
logger.info(f"Started download: {filename}")
|
logger.info(f"Started download: {filename}")
|
||||||
else:
|
else:
|
||||||
|
|||||||
+68
-17
@@ -3,6 +3,7 @@ import hmac
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from typing import Optional, Dict, List, Tuple, Any
|
from typing import Optional, Dict, List, Tuple, Any
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -14,6 +15,7 @@ from app.models.sonarr import (
|
|||||||
SonarrConfig,
|
SonarrConfig,
|
||||||
SonarrDownloadRequest
|
SonarrDownloadRequest
|
||||||
)
|
)
|
||||||
|
from app.models import DownloadRequest
|
||||||
from app.downloaders import get_downloader, AnimeSamaDownloader, NekoSamaDownloader, AnimeUltimeDownloader, VostfreeDownloader
|
from app.downloaders import get_downloader, AnimeSamaDownloader, NekoSamaDownloader, AnimeUltimeDownloader, VostfreeDownloader
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -28,11 +30,15 @@ class SonarrHandler:
|
|||||||
self.mappings_path = Path(mappings_path)
|
self.mappings_path = Path(mappings_path)
|
||||||
self.config = self._load_config()
|
self.config = self._load_config()
|
||||||
self.mappings = self._load_mappings()
|
self.mappings = self._load_mappings()
|
||||||
|
self.download_manager = None
|
||||||
|
|
||||||
# Create config directories if they don't exist
|
# Create config directories if they don't exist
|
||||||
self.config_path.parent.mkdir(exist_ok=True)
|
self.config_path.parent.mkdir(exist_ok=True)
|
||||||
self.mappings_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:
|
def _load_config(self) -> SonarrConfig:
|
||||||
"""Load Sonarr configuration from file"""
|
"""Load Sonarr configuration from file"""
|
||||||
if self.config_path.exists():
|
if self.config_path.exists():
|
||||||
@@ -45,10 +51,11 @@ class SonarrHandler:
|
|||||||
return SonarrConfig()
|
return SonarrConfig()
|
||||||
|
|
||||||
def _save_config(self):
|
def _save_config(self):
|
||||||
"""Save Sonarr configuration to file"""
|
|
||||||
try:
|
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)
|
json.dump(self.config.model_dump(mode='json'), f, indent=2)
|
||||||
|
os.replace(temp_file, self.config_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save Sonarr config: {e}")
|
logger.error(f"Failed to save Sonarr config: {e}")
|
||||||
raise
|
raise
|
||||||
@@ -65,11 +72,13 @@ class SonarrHandler:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def _save_mappings(self):
|
def _save_mappings(self):
|
||||||
"""Save mappings to file"""
|
|
||||||
try:
|
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]
|
mappings_data = [m.model_dump(mode='json') for m in self.mappings]
|
||||||
json.dump(mappings_data, f, indent=2)
|
json.dump(mappings_data, f, indent=2)
|
||||||
|
os.replace(temp_file, self.mappings_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save mappings: {e}")
|
logger.error(f"Failed to save mappings: {e}")
|
||||||
raise
|
raise
|
||||||
@@ -231,26 +240,25 @@ class SonarrHandler:
|
|||||||
downloads = []
|
downloads = []
|
||||||
for episode in payload.episodes:
|
for episode in payload.episodes:
|
||||||
try:
|
try:
|
||||||
download_request = SonarrDownloadRequest(
|
success = await self._trigger_download(
|
||||||
sonarr_series_id=payload.series.tvdbId,
|
mapping,
|
||||||
sonarr_title=payload.series.title,
|
episode.seasonNumber,
|
||||||
season_number=episode.seasonNumber,
|
episode.episodeNumber
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Trigger the download (will be implemented in main.py)
|
|
||||||
downloads.append({
|
downloads.append({
|
||||||
"season": episode.seasonNumber,
|
"season": episode.seasonNumber,
|
||||||
"episode": episode.episodeNumber,
|
"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:
|
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 {
|
return {
|
||||||
"status": "processing",
|
"status": "processing",
|
||||||
@@ -259,6 +267,49 @@ class SonarrHandler:
|
|||||||
"downloads": downloads
|
"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:
|
async def _handle_download(self, payload: SonarrWebhookPayload) -> Dict:
|
||||||
"""Handle Download event (when Sonarr completes download)"""
|
"""Handle Download event (when Sonarr completes download)"""
|
||||||
# Similar to Grab but for post-download processing
|
# Similar to Grab but for post-download processing
|
||||||
|
|||||||
+6
-4
@@ -54,15 +54,16 @@ class WatchlistManager:
|
|||||||
self.watchlist = {}
|
self.watchlist = {}
|
||||||
|
|
||||||
def _save_watchlist(self):
|
def _save_watchlist(self):
|
||||||
"""Save watchlist to JSON file"""
|
|
||||||
try:
|
try:
|
||||||
os.makedirs(os.path.dirname(self.db_file), exist_ok=True)
|
os.makedirs(os.path.dirname(self.db_file), exist_ok=True)
|
||||||
data = {
|
data = {
|
||||||
item_id: item.model_dump(mode='json')
|
item_id: item.model_dump(mode='json')
|
||||||
for item_id, item in self.watchlist.items()
|
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)
|
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")
|
logger.debug(f"Saved {len(self.watchlist)} items to watchlist")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving watchlist: {e}")
|
logger.error(f"Error saving watchlist: {e}")
|
||||||
@@ -84,11 +85,12 @@ class WatchlistManager:
|
|||||||
self.settings = WatchlistSettings()
|
self.settings = WatchlistSettings()
|
||||||
|
|
||||||
def _save_settings(self):
|
def _save_settings(self):
|
||||||
"""Save watchlist settings to JSON file"""
|
|
||||||
try:
|
try:
|
||||||
os.makedirs(os.path.dirname(self.settings_file), exist_ok=True)
|
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)
|
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")
|
logger.debug("Saved watchlist settings")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving settings: {e}")
|
logger.error(f"Error saving settings: {e}")
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from app.models.sonarr import (
|
|||||||
SonarrDownloadRequest
|
SonarrDownloadRequest
|
||||||
)
|
)
|
||||||
from app.models.auth import UserCreate, UserLogin, User, Token
|
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
|
from app.utils import sanitize_filename, is_safe_filename
|
||||||
|
|
||||||
# Watchlist and auto-download
|
# Watchlist and auto-download
|
||||||
@@ -73,6 +73,17 @@ download_manager = DownloadManager(download_dir="downloads", max_parallel=3)
|
|||||||
episode_checker.set_download_manager(download_manager)
|
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():
|
def restore_completed_downloads():
|
||||||
"""Scan downloads directory and restore completed download tasks"""
|
"""Scan downloads directory and restore completed download tasks"""
|
||||||
import logging
|
import logging
|
||||||
@@ -186,15 +197,16 @@ async def get_current_user_from_token(credentials: HTTPAuthorizationCredentials
|
|||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
user = user_manager.get_user(username)
|
user_dict = user_manager.get_user(username)
|
||||||
if user is None:
|
if user_dict is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="User not found",
|
detail="User not found",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
return user
|
# Convert dict to User Pydantic model
|
||||||
|
return User(**user_dict)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/register")
|
@app.post("/api/auth/register")
|
||||||
@@ -294,7 +306,7 @@ async def login(form_data: UserLogin):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/auth/me")
|
@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
|
Get current user information
|
||||||
|
|
||||||
@@ -303,13 +315,13 @@ async def get_me(current_user: dict = Depends(get_current_user_from_token)):
|
|||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"user": {
|
"user": {
|
||||||
"id": current_user["id"],
|
"id": current_user.id,
|
||||||
"username": current_user["username"],
|
"username": current_user.username,
|
||||||
"email": current_user.get("email"),
|
"email": current_user.email,
|
||||||
"full_name": current_user.get("full_name"),
|
"full_name": current_user.full_name,
|
||||||
"is_active": current_user.get("is_active", True),
|
"is_active": current_user.is_active,
|
||||||
"created_at": current_user.get("created_at"),
|
"created_at": current_user.created_at,
|
||||||
"last_login": current_user.get("last_login")
|
"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"])
|
@app.post("/api/watchlist", response_model=WatchlistItem, tags=["Watchlist"])
|
||||||
async def add_to_watchlist(
|
async def add_to_watchlist(
|
||||||
item_data: WatchlistItemCreate,
|
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"""
|
"""Add an anime to the watchlist for automatic episode tracking"""
|
||||||
try:
|
try:
|
||||||
@@ -1824,7 +1836,7 @@ async def add_to_watchlist(
|
|||||||
@app.get("/api/watchlist", response_model=List[WatchlistItem], tags=["Watchlist"])
|
@app.get("/api/watchlist", response_model=List[WatchlistItem], tags=["Watchlist"])
|
||||||
async def get_watchlist(
|
async def get_watchlist(
|
||||||
status: Optional[WatchlistStatus] = None,
|
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"""
|
"""Get user's watchlist, optionally filtered by status"""
|
||||||
try:
|
try:
|
||||||
@@ -1835,161 +1847,9 @@ async def get_watchlist(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
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"])
|
@app.get("/api/watchlist/settings", response_model=WatchlistSettings, tags=["Watchlist"])
|
||||||
async def get_watchlist_settings(
|
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"""
|
"""Get global watchlist settings"""
|
||||||
try:
|
try:
|
||||||
@@ -2003,7 +1863,7 @@ async def get_watchlist_settings(
|
|||||||
@app.put("/api/watchlist/settings", response_model=WatchlistSettings, tags=["Watchlist"])
|
@app.put("/api/watchlist/settings", response_model=WatchlistSettings, tags=["Watchlist"])
|
||||||
async def update_watchlist_settings(
|
async def update_watchlist_settings(
|
||||||
settings: WatchlistSettings,
|
settings: WatchlistSettings,
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user_from_token)
|
||||||
):
|
):
|
||||||
"""Update global watchlist settings"""
|
"""Update global watchlist settings"""
|
||||||
try:
|
try:
|
||||||
@@ -2021,7 +1881,7 @@ async def update_watchlist_settings(
|
|||||||
|
|
||||||
@app.get("/api/watchlist/stats", tags=["Watchlist"])
|
@app.get("/api/watchlist/stats", tags=["Watchlist"])
|
||||||
async def get_watchlist_stats(
|
async def get_watchlist_stats(
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user_from_token)
|
||||||
):
|
):
|
||||||
"""Get watchlist statistics"""
|
"""Get watchlist statistics"""
|
||||||
try:
|
try:
|
||||||
@@ -2034,7 +1894,7 @@ async def get_watchlist_stats(
|
|||||||
|
|
||||||
@app.post("/api/watchlist/check-all", tags=["Watchlist"])
|
@app.post("/api/watchlist/check-all", tags=["Watchlist"])
|
||||||
async def check_all_watchlist_items(
|
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"""
|
"""Manually trigger a check for all due watchlist items"""
|
||||||
try:
|
try:
|
||||||
@@ -2061,7 +1921,7 @@ async def check_all_watchlist_items(
|
|||||||
|
|
||||||
@app.get("/api/watchlist/scheduler/status", tags=["Watchlist"])
|
@app.get("/api/watchlist/scheduler/status", tags=["Watchlist"])
|
||||||
async def get_scheduler_status(
|
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"""
|
"""Get auto-download scheduler status"""
|
||||||
try:
|
try:
|
||||||
@@ -2077,7 +1937,7 @@ async def get_scheduler_status(
|
|||||||
|
|
||||||
@app.post("/api/watchlist/scheduler/start", tags=["Watchlist"])
|
@app.post("/api/watchlist/scheduler/start", tags=["Watchlist"])
|
||||||
async def start_scheduler(
|
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"""
|
"""Start the auto-download scheduler"""
|
||||||
try:
|
try:
|
||||||
@@ -2093,7 +1953,7 @@ async def start_scheduler(
|
|||||||
|
|
||||||
@app.post("/api/watchlist/scheduler/stop", tags=["Watchlist"])
|
@app.post("/api/watchlist/scheduler/stop", tags=["Watchlist"])
|
||||||
async def stop_scheduler(
|
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"""
|
"""Stop the auto-download scheduler"""
|
||||||
try:
|
try:
|
||||||
@@ -2107,6 +1967,158 @@ async def stop_scheduler(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
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__":
|
if __name__ == "__main__":
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"main:app",
|
"main:app",
|
||||||
|
|||||||
@@ -241,22 +241,24 @@
|
|||||||
|
|
||||||
.anime-card-actions {
|
.anime-card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 15px;
|
margin-top: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.anime-card-actions select {
|
.anime-card-actions select {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.anime-card-actions button {
|
.anime-card-actions button {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-20
@@ -62,26 +62,27 @@ async function searchAnimeDetails(query, malId = null) {
|
|||||||
const providersData = await getProvidersInfo();
|
const providersData = await getProvidersInfo();
|
||||||
|
|
||||||
// Build results HTML
|
// Build results HTML
|
||||||
streamingHtml = `
|
const streamingParts = [
|
||||||
<div class="streaming-results-header">
|
`<div class="streaming-results-header">
|
||||||
<h3>🎬 Résultats de streaming</h3>
|
<h3>🎬 Résultats de streaming</h3>
|
||||||
</div>
|
</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)) {
|
for (const [providerId, results] of Object.entries(streamingData.value.results)) {
|
||||||
if (results && results.length > 0) {
|
if (results && results.length > 0) {
|
||||||
const provider = providersData.anime_providers[providerId];
|
const provider = providersData.anime_providers[providerId];
|
||||||
|
|
||||||
results.forEach((anime) => {
|
// Render all cards for this provider
|
||||||
// Use the same renderAnimeCard function from anime.js for consistency
|
const cardPromises = results.map((anime) => renderAnimeCard(anime, providerId, provider, 'vostfr'));
|
||||||
streamingHtml += renderAnimeCard(anime, providerId, provider, 'vostfr');
|
const cards = await Promise.all(cardPromises);
|
||||||
});
|
streamingParts.push(...cards);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
streamingHtml += '</div>';
|
streamingParts.push('</div>');
|
||||||
|
streamingHtml = streamingParts.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display results
|
// Display results
|
||||||
@@ -149,12 +150,12 @@ async function getProviderSearchResults(query) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build results HTML
|
// Build results HTML
|
||||||
let html = `
|
const htmlParts = [
|
||||||
<div class="streaming-results-header">
|
`<div class="streaming-results-header">
|
||||||
<h3>🎬 Résultats de streaming</h3>
|
<h3>🎬 Résultats de streaming</h3>
|
||||||
</div>
|
</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
|
||||||
for (const [providerId, results] of Object.entries(data.results)) {
|
for (const [providerId, results] of Object.entries(data.results)) {
|
||||||
@@ -162,16 +163,16 @@ async function getProviderSearchResults(query) {
|
|||||||
const providersData = await getProvidersInfo();
|
const providersData = await getProvidersInfo();
|
||||||
const provider = providersData.anime_providers[providerId];
|
const provider = providersData.anime_providers[providerId];
|
||||||
|
|
||||||
results.forEach((anime, index) => {
|
// Render all cards for this provider in parallel
|
||||||
// Use the same renderAnimeCard function from anime.js for consistency
|
const cardPromises = results.map((anime) => renderAnimeCard(anime, providerId, provider, 'vostfr'));
|
||||||
html += 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) {
|
} catch (error) {
|
||||||
console.error('Error getting provider search results:', error);
|
console.error('Error getting provider search results:', error);
|
||||||
|
|||||||
+208
-4
@@ -62,7 +62,7 @@ async function renderAnimeCard(anime, providerId, providerInfo, lang) {
|
|||||||
const supportsSeasons = await providerSupportsSeasons(providerId, anime.url);
|
const supportsSeasons = await providerSupportsSeasons(providerId, anime.url);
|
||||||
|
|
||||||
const seasonSelectHtml = supportsSeasons ? `
|
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>
|
<option value="">Chargement des saisons...</option>
|
||||||
</select>
|
</select>
|
||||||
` : '';
|
` : '';
|
||||||
@@ -76,8 +76,10 @@ async function renderAnimeCard(anime, providerId, providerInfo, lang) {
|
|||||||
${metadataHtml}
|
${metadataHtml}
|
||||||
<div class="anime-card-actions">
|
<div class="anime-card-actions">
|
||||||
${seasonSelectHtml}
|
${seasonSelectHtml}
|
||||||
<select id="episodes-${providerId}-${encodeURIComponent(anime.url)}">
|
<select id="episodes-${providerId}-${encodeURIComponent(anime.url)}"
|
||||||
<option value="">${supportsSeasons ? 'Sélectionner une saison d\'abord' : 'Charger les épisodes...'}</option>
|
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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="anime-card-actions" id="actions-${providerId}-${encodeURIComponent(anime.url)}" style="display:none;">
|
<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 seasonSelectId = `seasons-${providerId}-${encodedUrl}`;
|
||||||
|
|
||||||
const seasonSelectElement = document.getElementById(seasonSelectId);
|
const seasonSelectElement = document.getElementById(seasonSelectId);
|
||||||
if (!seasonSelectElement) return;
|
if (!seasonSelectElement) {
|
||||||
|
console.log('Season select element not found:', seasonSelectId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if provider supports seasons
|
// Check if provider supports seasons
|
||||||
const supportsSeasons = await providerSupportsSeasons(providerId, url);
|
const supportsSeasons = await providerSupportsSeasons(providerId, url);
|
||||||
if (!supportsSeasons) {
|
if (!supportsSeasons) {
|
||||||
|
console.log('Provider does not support seasons:', providerId);
|
||||||
seasonSelectElement.style.display = 'none';
|
seasonSelectElement.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Loading seasons for:', url, 'Element:', seasonSelectId);
|
||||||
|
|
||||||
// Mark as loading to prevent duplicate requests
|
// Mark as loading to prevent duplicate requests
|
||||||
if (seasonSelectElement.dataset.loading === 'true') {
|
if (seasonSelectElement.dataset.loading === 'true') {
|
||||||
console.log('Season loading already in progress, skipping...');
|
console.log('Season loading already in progress, skipping...');
|
||||||
@@ -196,8 +204,10 @@ async function loadSeasonsForAnime(providerId, encodedUrl) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Loaded ${data.seasons.length} seasons`);
|
console.log(`Loaded ${data.seasons.length} seasons`);
|
||||||
|
seasonSelectElement.style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
// No seasons found, hide season selector and load episodes directly
|
// No seasons found, hide season selector and load episodes directly
|
||||||
|
console.log('No seasons found, hiding selector');
|
||||||
seasonSelectElement.style.display = 'none';
|
seasonSelectElement.style.display = 'none';
|
||||||
loadEpisodesForAnime(providerId, encodedUrl, 'vostfr');
|
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
|
* Handle search form submission
|
||||||
*/
|
*/
|
||||||
@@ -434,3 +633,8 @@ window.handleDownloadEpisode = handleDownloadEpisode;
|
|||||||
window.handleDownloadSeason = handleDownloadSeason;
|
window.handleDownloadSeason = handleDownloadSeason;
|
||||||
window.handleSearch = handleSearch;
|
window.handleSearch = handleSearch;
|
||||||
window.handleDirectDownload = handleDirectDownload;
|
window.handleDirectDownload = handleDirectDownload;
|
||||||
|
window.loadAllSeasonsAndEpisodes = loadAllSeasonsAndEpisodes;
|
||||||
|
window.toggleSeasonEpisodes = toggleSeasonEpisodes;
|
||||||
|
window.loadSeasonEpisodes = loadSeasonEpisodes;
|
||||||
|
window.downloadSeasonEpisode = downloadSeasonEpisode;
|
||||||
|
window.downloadEntireSeason = downloadEntireSeason;
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ async function providerSupportsSeasons(providerId, url = null) {
|
|||||||
return provider.supports_seasons;
|
return provider.supports_seasons;
|
||||||
}
|
}
|
||||||
// Otherwise, check by provider ID (known season-supporting providers)
|
// 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
|
// Fallback: check URL if provided
|
||||||
|
|||||||
+13
-14
@@ -1,14 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Watchlist management and auto-download UI
|
* 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
|
* Get user's watchlist
|
||||||
*/
|
*/
|
||||||
async function getWatchlist(status = null) {
|
async function getWatchlist(status = null) {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('auth_token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -35,7 +34,7 @@ async function getWatchlist(status = null) {
|
|||||||
* Add anime to watchlist
|
* Add anime to watchlist
|
||||||
*/
|
*/
|
||||||
async function addToWatchlist(animeData) {
|
async function addToWatchlist(animeData) {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('auth_token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -61,7 +60,7 @@ async function addToWatchlist(animeData) {
|
|||||||
* Update watchlist item
|
* Update watchlist item
|
||||||
*/
|
*/
|
||||||
async function updateWatchlistItem(itemId, updateData) {
|
async function updateWatchlistItem(itemId, updateData) {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('auth_token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -86,7 +85,7 @@ async function updateWatchlistItem(itemId, updateData) {
|
|||||||
* Delete from watchlist
|
* Delete from watchlist
|
||||||
*/
|
*/
|
||||||
async function deleteFromWatchlist(itemId) {
|
async function deleteFromWatchlist(itemId) {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('auth_token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -123,7 +122,7 @@ async function resumeWatchlistItem(itemId) {
|
|||||||
* Check specific anime for new episodes
|
* Check specific anime for new episodes
|
||||||
*/
|
*/
|
||||||
async function checkWatchlistItem(itemId) {
|
async function checkWatchlistItem(itemId) {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('auth_token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -146,7 +145,7 @@ async function checkWatchlistItem(itemId) {
|
|||||||
* Check all watchlist items
|
* Check all watchlist items
|
||||||
*/
|
*/
|
||||||
async function checkAllWatchlistItems() {
|
async function checkAllWatchlistItems() {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('auth_token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -169,7 +168,7 @@ async function checkAllWatchlistItems() {
|
|||||||
* Get watchlist settings
|
* Get watchlist settings
|
||||||
*/
|
*/
|
||||||
async function getWatchlistSettings() {
|
async function getWatchlistSettings() {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('auth_token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -191,7 +190,7 @@ async function getWatchlistSettings() {
|
|||||||
* Update watchlist settings
|
* Update watchlist settings
|
||||||
*/
|
*/
|
||||||
async function updateWatchlistSettings(settings) {
|
async function updateWatchlistSettings(settings) {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('auth_token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -216,7 +215,7 @@ async function updateWatchlistSettings(settings) {
|
|||||||
* Get watchlist statistics
|
* Get watchlist statistics
|
||||||
*/
|
*/
|
||||||
async function getWatchlistStats() {
|
async function getWatchlistStats() {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('auth_token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -238,7 +237,7 @@ async function getWatchlistStats() {
|
|||||||
* Get scheduler status
|
* Get scheduler status
|
||||||
*/
|
*/
|
||||||
async function getSchedulerStatus() {
|
async function getSchedulerStatus() {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('auth_token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -260,7 +259,7 @@ async function getSchedulerStatus() {
|
|||||||
* Start scheduler
|
* Start scheduler
|
||||||
*/
|
*/
|
||||||
async function startScheduler() {
|
async function startScheduler() {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('auth_token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -283,7 +282,7 @@ async function startScheduler() {
|
|||||||
* Stop scheduler
|
* Stop scheduler
|
||||||
*/
|
*/
|
||||||
async function stopScheduler() {
|
async function stopScheduler() {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('auth_token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-10
@@ -9,16 +9,18 @@
|
|||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
|
||||||
<!-- JavaScript -->
|
<!-- JavaScript -->
|
||||||
<script src="/static/js/auth.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.9" defer></script>
|
<script src="/static/js/api.js?v=1.11" defer></script>
|
||||||
<script src="/static/js/utils.js?v=1.9" defer></script>
|
<script src="/static/js/utils.js?v=1.11" defer></script>
|
||||||
<script src="/static/js/downloads.js?v=1.9" defer></script>
|
<script src="/static/js/downloads.js?v=1.11" defer></script>
|
||||||
<script src="/static/js/anime.js?v=1.9" defer></script>
|
<script src="/static/js/anime.js?v=1.11" defer></script>
|
||||||
<script src="/static/js/anime-details.js?v=1.9" defer></script>
|
<script src="/static/js/anime-details.js?v=1.11" defer></script>
|
||||||
<script src="/static/js/series-search.js?v=1.9" defer></script>
|
<script src="/static/js/series-search.js?v=1.11" defer></script>
|
||||||
<script src="/static/js/recommendations.js?v=1.9" defer></script>
|
<script src="/static/js/recommendations.js?v=1.11" defer></script>
|
||||||
<script src="/static/js/tabs.js?v=1.9" defer></script>
|
<script src="/static/js/tabs.js?v=1.11" defer></script>
|
||||||
<script src="/static/js/main.js?v=1.9" 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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="/static/js/api.js"></script>
|
<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.js"></script>
|
||||||
<script src="/static/js/watchlist-ui.js"></script>
|
<script src="/static/js/watchlist-ui.js"></script>
|
||||||
<script src="/static/js/auth.js"></script>
|
<script src="/static/js/auth.js"></script>
|
||||||
@@ -251,12 +252,33 @@
|
|||||||
* Check authentication
|
* Check authentication
|
||||||
*/
|
*/
|
||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('auth_token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ async def favorites_manager(temp_dir):
|
|||||||
"""Create a FavoritesManager instance with temporary storage"""
|
"""Create a FavoritesManager instance with temporary storage"""
|
||||||
storage_path = temp_dir / "test_favorites.json"
|
storage_path = temp_dir / "test_favorites.json"
|
||||||
manager = FavoritesManager(storage_path=str(storage_path))
|
manager = FavoritesManager(storage_path=str(storage_path))
|
||||||
|
# Initialize asynchronously
|
||||||
|
await manager._load()
|
||||||
yield manager
|
yield manager
|
||||||
# Cleanup
|
# Cleanup
|
||||||
if storage_path.exists():
|
if storage_path.exists():
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user