refactor: migrate main.py to modular routers and add project roadmap
- Migrated monolithic main.py to feature-scoped routers in app/routers/ - Added GEMINI.md for project context and AI instructional guidelines - Updated README.md with a comprehensive modernization plan (SQL migration, robust scraping DSL, frontend modernization) - Improved authentication with cookie support and modular JS - Updated test suite and documentation
This commit is contained in:
+179
-17
@@ -1,8 +1,9 @@
|
||||
"""User authentication and management system"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict
|
||||
from passlib.context import CryptContext
|
||||
@@ -15,11 +16,6 @@ logger = logging.getLogger(__name__)
|
||||
# Password hashing context
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# JWT Secret key - SHOULD BE CONFIGURED VIA ENV
|
||||
SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-secret-change-in-production")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
||||
|
||||
# Users database file
|
||||
USERS_DB_FILE = "config/users.json"
|
||||
|
||||
@@ -36,7 +32,7 @@ class UserManager:
|
||||
"""Load users from JSON file"""
|
||||
try:
|
||||
if os.path.exists(self.db_file):
|
||||
with open(self.db_file, 'r', encoding='utf-8') as f:
|
||||
with open(self.db_file, "r", encoding="utf-8") as f:
|
||||
self.users = json.load(f)
|
||||
logger.info(f"Loaded {len(self.users)} users from database")
|
||||
except Exception as e:
|
||||
@@ -47,7 +43,7 @@ class UserManager:
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.db_file), exist_ok=True)
|
||||
temp_file = f"{self.db_file}.tmp"
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
with open(temp_file, "w", encoding="utf-8") as f:
|
||||
json.dump(self.users, f, indent=2, ensure_ascii=False, default=str)
|
||||
os.replace(temp_file, self.db_file)
|
||||
logger.info(f"Saved {len(self.users)} users to database")
|
||||
@@ -61,19 +57,21 @@ class UserManager:
|
||||
def get_user_by_id(self, user_id: str) -> Optional[dict]:
|
||||
"""Get user by ID"""
|
||||
for user in self.users.values():
|
||||
if user.get('id') == user_id:
|
||||
if user.get("id") == user_id:
|
||||
return user
|
||||
return None
|
||||
|
||||
def create_user(self, username: str, password: str, email: str = None, full_name: str = None) -> dict:
|
||||
def create_user(
|
||||
self, username: str, password: str, email: str = None, full_name: str = None
|
||||
) -> dict:
|
||||
"""Create a new user"""
|
||||
if username in self.users:
|
||||
raise ValueError(f"Username '{username}' already exists")
|
||||
|
||||
# Truncate password to 72 bytes if necessary (bcrypt limitation)
|
||||
password_bytes = password.encode('utf-8')
|
||||
password_bytes = password.encode("utf-8")
|
||||
if len(password_bytes) > 72:
|
||||
password = password_bytes[:72].decode('utf-8', errors='ignore')
|
||||
password = password_bytes[:72].decode("utf-8", errors="ignore")
|
||||
|
||||
# Hash password
|
||||
hashed_password = pwd_context.hash(password)
|
||||
@@ -87,7 +85,7 @@ class UserManager:
|
||||
"hashed_password": hashed_password,
|
||||
"is_active": True,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"last_login": None
|
||||
"last_login": None,
|
||||
}
|
||||
|
||||
self.users[username] = user
|
||||
@@ -133,10 +131,28 @@ def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def _get_jwt_config() -> dict:
|
||||
"""Get JWT configuration from settings"""
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
return {
|
||||
"SECRET_KEY": settings.jwt_secret_key,
|
||||
"ALGORITHM": settings.jwt_algorithm,
|
||||
"ACCESS_TOKEN_EXPIRE_MINUTES": settings.access_token_expire_minutes,
|
||||
"REFRESH_TOKEN_EXPIRE_DAYS": settings.refresh_token_expire_days,
|
||||
}
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta = None) -> str:
|
||||
"""Create JWT access token"""
|
||||
from jose import jwt
|
||||
|
||||
jwt_config = _get_jwt_config()
|
||||
SECRET_KEY = jwt_config["SECRET_KEY"]
|
||||
ALGORITHM = jwt_config["ALGORITHM"]
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = jwt_config["ACCESS_TOKEN_EXPIRE_MINUTES"]
|
||||
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
@@ -155,6 +171,10 @@ def verify_token(token: str) -> Optional[str]:
|
||||
from jose import jwt
|
||||
from jose.exceptions import JWTError
|
||||
|
||||
jwt_config = _get_jwt_config()
|
||||
SECRET_KEY = jwt_config["SECRET_KEY"]
|
||||
ALGORITHM = jwt_config["ALGORITHM"]
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
@@ -169,10 +189,6 @@ def verify_token(token: str) -> Optional[str]:
|
||||
get_user_from_token = verify_token
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials) -> dict:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials) -> dict:
|
||||
"""Get current user from JWT token"""
|
||||
token = credentials.credentials
|
||||
@@ -185,3 +201,149 @@ def get_current_user(credentials: HTTPAuthorizationCredentials) -> dict:
|
||||
raise HTTPException(status_code=401, detail="Inactive user")
|
||||
return user
|
||||
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
|
||||
# Refresh tokens storage
|
||||
REFRESH_TOKENS_FILE = "config/refresh_tokens.json"
|
||||
|
||||
|
||||
def _load_refresh_tokens() -> Dict[str, dict]:
|
||||
"""Load refresh tokens from file"""
|
||||
try:
|
||||
if os.path.exists(REFRESH_TOKENS_FILE):
|
||||
with open(REFRESH_TOKENS_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading refresh tokens: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _save_refresh_tokens(tokens: Dict[str, dict]):
|
||||
"""Save refresh tokens to file"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(REFRESH_TOKENS_FILE), exist_ok=True)
|
||||
with open(REFRESH_TOKENS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(tokens, f, indent=2, ensure_ascii=False, default=str)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving refresh tokens: {e}")
|
||||
|
||||
|
||||
def create_access_refresh_tokens(data: dict) -> tuple[str, str]:
|
||||
"""
|
||||
Create both access and refresh tokens.
|
||||
|
||||
Access token: short-lived (24 hours by default)
|
||||
Refresh token: long-lived (30 days by default)
|
||||
|
||||
Returns: (access_token, refresh_token)
|
||||
"""
|
||||
from jose import jwt
|
||||
import secrets
|
||||
|
||||
jwt_config = _get_jwt_config()
|
||||
|
||||
# Create access token (short-lived)
|
||||
access_expire = datetime.utcnow() + timedelta(minutes=jwt_config["ACCESS_TOKEN_EXPIRE_MINUTES"])
|
||||
access_data = data.copy()
|
||||
access_data.update({"exp": access_expire, "type": "access"})
|
||||
access_token = jwt.encode(
|
||||
access_data,
|
||||
jwt_config["SECRET_KEY"],
|
||||
algorithm=jwt_config["ALGORITHM"]
|
||||
)
|
||||
|
||||
# Create refresh token (long-lived)
|
||||
refresh_expire = datetime.utcnow() + timedelta(days=jwt_config["REFRESH_TOKEN_EXPIRE_DAYS"])
|
||||
# Generate a unique token ID
|
||||
token_id = secrets.token_urlsafe(32)
|
||||
refresh_data = {
|
||||
"sub": data["sub"],
|
||||
"token_id": token_id,
|
||||
"exp": refresh_expire,
|
||||
"type": "refresh"
|
||||
}
|
||||
refresh_token = jwt.encode(
|
||||
refresh_data,
|
||||
jwt_config["SECRET_KEY"],
|
||||
algorithm=jwt_config["ALGORITHM"]
|
||||
)
|
||||
|
||||
# Store refresh token mapping
|
||||
refresh_tokens = _load_refresh_tokens()
|
||||
refresh_tokens[token_id] = {
|
||||
"username": data["sub"],
|
||||
"token_id": token_id,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"expires_at": refresh_expire.isoformat()
|
||||
}
|
||||
_save_refresh_tokens(refresh_tokens)
|
||||
|
||||
return access_token, refresh_token
|
||||
|
||||
|
||||
def verify_refresh_token(token: str) -> Optional[str]:
|
||||
"""
|
||||
Verify refresh token and return username if valid.
|
||||
Returns None if token is invalid or expired.
|
||||
"""
|
||||
from jose import jwt
|
||||
from jose.exceptions import JWTError
|
||||
|
||||
jwt_config = _get_jwt_config()
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]])
|
||||
|
||||
# Verify this is a refresh token
|
||||
if payload.get("type") != "refresh":
|
||||
return None
|
||||
|
||||
username = payload.get("sub")
|
||||
token_id = payload.get("token_id")
|
||||
|
||||
if not username or not token_id:
|
||||
return None
|
||||
|
||||
# Check if token exists in storage
|
||||
refresh_tokens = _load_refresh_tokens()
|
||||
stored_token = refresh_tokens.get(token_id)
|
||||
|
||||
if not stored_token:
|
||||
return None
|
||||
|
||||
# Verify token hasn't been revoked or expired
|
||||
if stored_token.get("revoked"):
|
||||
return None
|
||||
|
||||
return username
|
||||
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def revoke_refresh_token(token: str) -> bool:
|
||||
"""
|
||||
Revoke a refresh token.
|
||||
Returns True if token was revoked, False if not found.
|
||||
"""
|
||||
from jose import jwt
|
||||
from jose.exceptions import JWTError
|
||||
|
||||
jwt_config = _get_jwt_config()
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]])
|
||||
token_id = payload.get("token_id")
|
||||
|
||||
if not token_id:
|
||||
return False
|
||||
|
||||
refresh_tokens = _load_refresh_tokens()
|
||||
if token_id in refresh_tokens:
|
||||
refresh_tokens[token_id]["revoked"] = True
|
||||
refresh_tokens[token_id]["revoked_at"] = datetime.now().isoformat()
|
||||
_save_refresh_tokens(refresh_tokens)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except JWTError:
|
||||
return False
|
||||
|
||||
+38
-2
@@ -1,7 +1,11 @@
|
||||
"""Application configuration using environment variables"""
|
||||
import secrets
|
||||
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import model_validator
|
||||
from typing import List
|
||||
import os
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables"""
|
||||
@@ -16,6 +20,38 @@ class Settings(BaseSettings):
|
||||
port: int = 3000
|
||||
reload: bool = True
|
||||
|
||||
# Authentication
|
||||
jwt_secret_key: str = "dev-secret-change-in-production"
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 60 * 24 # 24 hours (short-lived for security)
|
||||
refresh_token_expire_days: int = 30
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_jwt_secret_key(self) -> "Settings":
|
||||
"""Validate JWT_SECRET_KEY is not the default or too short"""
|
||||
default_secret = "dev-secret-change-in-production"
|
||||
|
||||
if self.jwt_secret_key == default_secret:
|
||||
raise ValueError(
|
||||
f"JWT_SECRET_KEY cannot be the default value '{default_secret}'. "
|
||||
f"Please set a secure secret in your .env file. "
|
||||
f"Use Settings.generate_secret() to generate a secure secret."
|
||||
)
|
||||
|
||||
if len(self.jwt_secret_key) < 32:
|
||||
raise ValueError(
|
||||
f"JWT_SECRET_KEY must be at least 32 characters long. "
|
||||
f"Current length: {len(self.jwt_secret_key)} characters. "
|
||||
f"Use Settings.generate_secret() to generate a secure secret."
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
@staticmethod
|
||||
def generate_secret() -> str:
|
||||
"""Generate a cryptographically secure JWT secret key"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
# Downloads
|
||||
download_dir: str = "downloads"
|
||||
max_parallel_downloads: int = 3
|
||||
@@ -26,7 +62,7 @@ class Settings(BaseSettings):
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://192.168.1.204:3000",
|
||||
"http://192.168.1.204"
|
||||
"http://192.168.1.204",
|
||||
]
|
||||
|
||||
# Storage
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Anime Sites Downloaders
|
||||
|
||||
## OVERVIEW
|
||||
Handlers for French anime streaming catalogs that provide metadata and episode listings, delegating actual video extraction to video player handlers.
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `base.py` | Abstract `BaseAnimeSite` class defining the interface all anime sites implement |
|
||||
| `animesama.py` | Primary provider with dynamic domain switching, multiple video player extraction |
|
||||
| `nekosama.py` | Neko-Sama / Gupy integration (metadata-only, no direct downloads) |
|
||||
| `animeultime.py` | Anime-Ultime catalog handler |
|
||||
| `vostfree.py` | Vostfree catalog handler |
|
||||
| `frenchmanga.py` | French-Manga catalog handler |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
### Interface Contract
|
||||
Each site must implement four async methods from `BaseAnimeSite`:
|
||||
- `can_handle(url: str) -> bool` — URL pattern matching
|
||||
- `search_anime(query, lang) -> list[dict]` — Returns `{title, url, cover_image}`
|
||||
- `get_episodes(anime_url, lang) -> list[dict]` — Returns `{episode_number, url, title, host}`
|
||||
- `get_anime_metadata(anime_url) -> dict` — Returns `{synopsis, genres, rating, release_year, studio, poster_image, total_episodes, status}`
|
||||
- `get_download_link(url) -> tuple[str, str]` — Returns `(video_player_url, filename)`
|
||||
|
||||
### Key Patterns
|
||||
- **Pipe-separated URLs**: `video_url|anime_page_url|episode_title` — preserves context across extraction
|
||||
- **Language parameter**: `lang="vostfr"` or `"vf"` — controls which episodes to return
|
||||
- **Video player delegation**: Anime sites return player URLs (vidmoly, sendvid, sibnet, lpayer), not direct downloads
|
||||
- **Filename generation**: `{anime_name} - S{season} - {episode}.mp4` format
|
||||
- **HTTP headers**: Browser UA and referer required to avoid blocking
|
||||
|
||||
### Domain Detection
|
||||
- `AnimeSamaDownloader` fetches current domain from `anime-sama.pw` dynamically
|
||||
- Uses fallback chain for video extraction: detected player → cached player → priority list
|
||||
|
||||
### Error Handling
|
||||
- Raise `Exception` with descriptive message on failure
|
||||
- Log at appropriate level (`debug` for expected failures, `error` for unexpected)
|
||||
- Validate extracted URLs with `_test_video_url()` before returning
|
||||
@@ -33,7 +33,7 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
"""Downloader for anime-sama.org / anime-sama.store"""
|
||||
|
||||
# Static list of known domains (will be updated dynamically)
|
||||
BASE_DOMAINS = ["anime-sama.tv", "anime-sama.si", "www.anime-sama.si", "anime-sama.org", "anime-sama.store", "anime-sama.eu"]
|
||||
BASE_DOMAINS = ["anime-sama.to", "www.anime-sama.to", "anime-sama.tv", "www.anime-sama.tv", "anime-sama.si", "www.anime-sama.si", "anime-sama.org", "anime-sama.store", "anime-sama.eu"]
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize AnimeSamaDownloader with working player cache"""
|
||||
@@ -43,46 +43,34 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
@classmethod
|
||||
async def get_current_domain(cls) -> str:
|
||||
"""
|
||||
Fetch the current active domain from anime-sama.pw
|
||||
Returns the current domain (e.g., 'anime-sama.si')
|
||||
Fetch the current active domain by testing known domains
|
||||
Returns the current working domain (e.g., 'anime-sama.to')
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
||||
response = await client.get("https://anime-sama.pw")
|
||||
# Test known domains in order of recency
|
||||
for test_domain in ["anime-sama.to", "anime-sama.tv", "anime-sama.si", "anime-sama.org"]:
|
||||
try:
|
||||
test_url = f"https://{test_domain}/catalogue"
|
||||
response = await client.get(test_url)
|
||||
|
||||
# Look for the main link in the HTML
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
# Check if we got a valid page (not 404 and has content)
|
||||
if response.status_code == 200 and len(response.text) > 1000:
|
||||
# Check if it's the real anime-sama site (has catalog cards)
|
||||
if 'catalogue' in response.text or 'catalog-card' in response.text:
|
||||
logger.info(f"Working domain found: {test_domain}")
|
||||
return test_domain
|
||||
except Exception as e:
|
||||
logger.debug(f"Domain {test_domain} failed: {e}")
|
||||
continue
|
||||
|
||||
# Look for the primary button/link
|
||||
primary_link = soup.find('a', class_='btn-primary')
|
||||
if primary_link and primary_link.get('href'):
|
||||
href = primary_link['href']
|
||||
# Extract domain from URL
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(href)
|
||||
domain = parsed.netloc # e.g., 'anime-sama.si'
|
||||
logger.info(f"Current domain from anime-sama.pw: {domain}")
|
||||
return domain
|
||||
|
||||
# Fallback: look for any anime-sama.* link
|
||||
for link in soup.find_all('a', href=True):
|
||||
href = link['href']
|
||||
if 'anime-sama.' in href and href.startswith('https://'):
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(href)
|
||||
domain = parsed.netloc
|
||||
if domain not in ['anime-sama.pw', 'www.anime-sama.pw']:
|
||||
logger.info(f"Found domain via fallback: {domain}")
|
||||
return domain
|
||||
|
||||
logger.warning("Could not determine current domain, using default")
|
||||
return "anime-sama.si"
|
||||
logger.warning("Could not determine working domain, using default")
|
||||
return "anime-sama.to"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching current domain: {e}")
|
||||
return "anime-sama.si"
|
||||
return "anime-sama.to"
|
||||
|
||||
@classmethod
|
||||
async def update_domains(cls) -> None:
|
||||
@@ -164,6 +152,14 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
anime_page_url=url,
|
||||
episode_title=None
|
||||
)
|
||||
# Handle Smoothpre URLs
|
||||
elif 'smoothpre' in url.lower():
|
||||
logger.info(f"Using fallback for Smoothpre: {url[:80]}...")
|
||||
return await self.get_download_link_with_fallback(
|
||||
url,
|
||||
anime_page_url=None,
|
||||
episode_title=None
|
||||
)
|
||||
# If it's an anime-sama page, try to find the video
|
||||
if 'anime-sama' in url.lower():
|
||||
if 'dingtez' in url or 'dingz' in url:
|
||||
@@ -190,7 +186,7 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
|
||||
for iframe in iframes:
|
||||
src = iframe.get('src', '')
|
||||
if src and any(provider in src for provider in ['vidmoly', 'player', 'stream', 'play', 'embed']):
|
||||
if src and any(provider in src for provider in ['vidmoly', 'player', 'stream', 'play', 'embed', 'smoothpre']):
|
||||
if not src.startswith('http'):
|
||||
src = urljoin(final_url, src)
|
||||
logger.debug(f"Found iframe: {src}")
|
||||
@@ -201,6 +197,11 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
logger.debug(f"Extracting from vidmoly iframe: {src}")
|
||||
video_url, filename = await self._extract_from_vidmoly(src, anime_page_url=url, episode_title="Episode")
|
||||
return video_url, filename
|
||||
# For smoothpre, use the smoothpre extractor
|
||||
elif 'smoothpre' in src.lower():
|
||||
logger.debug(f"Extracting from smoothpre iframe: {src}")
|
||||
video_url, filename = await self._extract_from_smoothpre(src, anime_page_url=url, episode_title="Episode")
|
||||
return video_url, filename
|
||||
else:
|
||||
video_url = await self._extract_from_player(src)
|
||||
if video_url:
|
||||
@@ -563,6 +564,49 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
# If yt-dlp fails, return m3u8 URL anyway (let download manager handle it)
|
||||
return m3u8_url, filename
|
||||
|
||||
async def _extract_from_smoothpre(self, url: str, anime_page_url: str = None, episode_title: str = None) -> tuple[str, str]:
|
||||
"""Extract video URL from smoothpre player - delegate to SmoothpreDownloader"""
|
||||
try:
|
||||
logger.debug(f"Extracting from smoothpre: {url}")
|
||||
logger.debug(f"Delegating to SmoothpreDownloader...")
|
||||
|
||||
# Import SmoothpreDownloader
|
||||
from ..video_players.smoothpre import SmoothpreDownloader
|
||||
|
||||
# Generate the target filename first
|
||||
if episode_title and anime_page_url:
|
||||
anime_name = self._generate_anime_name(anime_page_url)
|
||||
season_num = self._extract_season_number(anime_page_url)
|
||||
if season_num:
|
||||
target_filename = f"{anime_name} - S{season_num} - {episode_title}.mp4"
|
||||
else:
|
||||
target_filename = f"{anime_name} - {episode_title}.mp4"
|
||||
logger.debug(f"Generated filename: {target_filename} (episode: {episode_title})")
|
||||
elif anime_page_url:
|
||||
target_filename = self._generate_filename_from_anime_url(anime_page_url)
|
||||
logger.debug(f"Generated filename: {target_filename} (no episode title)")
|
||||
else:
|
||||
target_filename = None
|
||||
logger.debug(f"No target_filename generated")
|
||||
|
||||
# Use SmoothpreDownloader to extract the video URL
|
||||
smoothpre_downloader = SmoothpreDownloader()
|
||||
video_url, temp_filename = await smoothpre_downloader.get_download_link(url, target_filename=target_filename)
|
||||
|
||||
# Use the target filename if available
|
||||
filename = target_filename if target_filename else temp_filename
|
||||
|
||||
logger.debug(f"Got video: {filename}")
|
||||
logger.debug(f"Video URL: {video_url[:100] if video_url else 'None'}...")
|
||||
|
||||
# Return the direct video URL
|
||||
# The download_manager will handle the actual download
|
||||
return video_url, filename
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Smoothpre extraction error: {e}")
|
||||
raise Exception(f"Error extracting from smoothpre: {str(e)}")
|
||||
|
||||
async def _extract_from_player(self, player_url: str) -> str | None:
|
||||
"""Try to extract direct video URL from player iframe"""
|
||||
try:
|
||||
@@ -808,9 +852,9 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
start = time.time()
|
||||
logger.debug(f"Searching for '{query}' ({lang})...")
|
||||
|
||||
# Use anime-sama.tv directly (anime-sama.si has redirect issues)
|
||||
current_domain = "anime-sama.tv"
|
||||
|
||||
# Get the current working domain
|
||||
current_domain = await self.get_current_domain()
|
||||
logger.info(f"Using domain: {current_domain}")
|
||||
|
||||
# Use the official search API endpoint
|
||||
search_api_url = f"https://{current_domain}/template-php/defaut/fetch.php"
|
||||
@@ -1016,7 +1060,7 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
Exception: If all players fail
|
||||
"""
|
||||
# Define player priority list
|
||||
player_priority = ['vidmoly', 'sendvid', 'sibnet', 'lpayer']
|
||||
player_priority = ['vidmoly', 'sendvid', 'sibnet', 'lpayer', 'smoothpre']
|
||||
|
||||
# Extract video URLs from pipe format if needed
|
||||
# Format: url1|url2|url3|anime_page_url|episode_title
|
||||
@@ -1038,7 +1082,48 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
anime_page_url = parts[1]
|
||||
else:
|
||||
video_urls = [url]
|
||||
|
||||
|
||||
# Filter out empty or invalid URLs
|
||||
valid_video_urls = []
|
||||
for vu in video_urls:
|
||||
vu = vu.strip()
|
||||
# Skip empty URLs
|
||||
if not vu:
|
||||
logger.warning(f"Skipping empty URL")
|
||||
continue
|
||||
|
||||
# Skip URLs with incomplete query parameters (e.g., "videoid=" without value)
|
||||
if '=&' in vu or vu.endswith('='):
|
||||
logger.warning(f"Skipping incomplete URL (missing parameter value): {vu[:80]}...")
|
||||
continue
|
||||
|
||||
# Skip URLs that are just a base domain without ID (e.g., "https://sendvid.com/embed/")
|
||||
if vu.endswith('/') and len(vu) > 10:
|
||||
# Check if it's a base player URL without video ID
|
||||
base_urls = [
|
||||
'https://sendvid.com/embed/',
|
||||
'https://sendvid.com/embed',
|
||||
'https://vidmoly.to/embed/',
|
||||
'https://vidmoly.to/embed',
|
||||
'https://vidmoly.biz/embed/',
|
||||
'https://vidmoly.biz/embed',
|
||||
]
|
||||
if any(vu.startswith(base) for base in base_urls):
|
||||
logger.warning(f"Skipping incomplete URL (no video ID): {vu[:60]}...")
|
||||
continue
|
||||
|
||||
# Skip URLs with incomplete HTML filenames (e.g., "embed-.html")
|
||||
if 'embed-.html' in vu or 'embed_' in vu:
|
||||
logger.warning(f"Skipping malformed URL (incomplete HTML): {vu[:80]}...")
|
||||
continue
|
||||
|
||||
valid_video_urls.append(vu)
|
||||
|
||||
video_urls = valid_video_urls
|
||||
|
||||
if not video_urls:
|
||||
raise Exception("No valid video URLs found after filtering")
|
||||
|
||||
# Try each video URL in order (each may have different player)
|
||||
last_error = None
|
||||
for video_url in video_urls:
|
||||
@@ -1104,7 +1189,11 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
)
|
||||
elif player_name == 'lpayer':
|
||||
video_url_result, filename = await self._extract_from_lpayer_api(video_url, anime_page_url, episode_title, target_filename)
|
||||
|
||||
elif player_name == 'smoothpre':
|
||||
video_url_result, filename = await self._extract_from_smoothpre(
|
||||
video_url, anime_page_url, episode_title
|
||||
)
|
||||
|
||||
# Validate the extracted URL
|
||||
logger.info(f"Validating extracted URL from {player_name}...")
|
||||
is_valid = await self._test_video_url(video_url_result)
|
||||
@@ -1580,7 +1669,7 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
Exception: If all players fail
|
||||
"""
|
||||
# Define player priority list
|
||||
player_priority = ['vidmoly', 'sendvid', 'sibnet', 'lpayer']
|
||||
player_priority = ['vidmoly', 'sendvid', 'sibnet', 'lpayer', 'smoothpre']
|
||||
|
||||
# Extract video URLs from pipe format if needed
|
||||
# Format: url1|url2|url3|anime_page_url|episode_title
|
||||
@@ -1602,12 +1691,53 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
anime_page_url = parts[1]
|
||||
else:
|
||||
video_urls = [url]
|
||||
|
||||
|
||||
# Filter out empty or invalid URLs
|
||||
valid_video_urls = []
|
||||
for vu in video_urls:
|
||||
vu = vu.strip()
|
||||
# Skip empty URLs
|
||||
if not vu:
|
||||
logger.warning(f"Skipping empty URL")
|
||||
continue
|
||||
|
||||
# Skip URLs with incomplete query parameters (e.g., "videoid=" without value)
|
||||
if '=&' in vu or vu.endswith('='):
|
||||
logger.warning(f"Skipping incomplete URL (missing parameter value): {vu[:80]}...")
|
||||
continue
|
||||
|
||||
# Skip URLs that are just a base domain without ID (e.g., "https://sendvid.com/embed/")
|
||||
if vu.endswith('/') and len(vu) > 10:
|
||||
# Check if it's a base player URL without video ID
|
||||
base_urls = [
|
||||
'https://sendvid.com/embed/',
|
||||
'https://sendvid.com/embed',
|
||||
'https://vidmoly.to/embed/',
|
||||
'https://vidmoly.to/embed',
|
||||
'https://vidmoly.biz/embed/',
|
||||
'https://vidmoly.biz/embed',
|
||||
]
|
||||
if any(vu.startswith(base) for base in base_urls):
|
||||
logger.warning(f"Skipping incomplete URL (no video ID): {vu[:60]}...")
|
||||
continue
|
||||
|
||||
# Skip URLs with incomplete HTML filenames (e.g., "embed-.html")
|
||||
if 'embed-.html' in vu or 'embed_' in vu:
|
||||
logger.warning(f"Skipping malformed URL (incomplete HTML): {vu[:80]}...")
|
||||
continue
|
||||
|
||||
valid_video_urls.append(vu)
|
||||
|
||||
video_urls = valid_video_urls
|
||||
|
||||
if not video_urls:
|
||||
raise Exception("No valid video URLs found after filtering")
|
||||
|
||||
# Try each video URL in order (each may have different player)
|
||||
last_error = None
|
||||
for video_url in video_urls:
|
||||
logger.info(f"Trying video URL: {video_url[:50]}...")
|
||||
|
||||
|
||||
# Detect player type from URL
|
||||
detected_player = None
|
||||
url_lower = video_url.lower()
|
||||
@@ -1619,21 +1749,13 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
detected_player = 'sibnet'
|
||||
elif 'lpayer' in url_lower:
|
||||
detected_player = 'lpayer'
|
||||
elif 'dingtez' in url_lower:
|
||||
detected_player = 'dingtez'
|
||||
|
||||
url_lower = video_url.lower()
|
||||
if 'vidmoly' in url_lower:
|
||||
detected_player = 'vidmoly'
|
||||
elif 'sendvid' in url_lower:
|
||||
detected_player = 'sendvid'
|
||||
elif 'sibnet' in url_lower:
|
||||
detected_player = 'sibnet'
|
||||
elif 'lpayer' in url_lower or 'embed' in url_lower:
|
||||
detected_player = 'lpayer'
|
||||
elif 'smoothpre' in url_lower:
|
||||
detected_player = 'smoothpre'
|
||||
elif 'myvi' in url_lower or 'myvi.tv' in url_lower:
|
||||
detected_player = 'vidmoly' # MyVi is similar to VidMoly, try VidMoly downloader first
|
||||
elif 'dingtez' in url_lower:
|
||||
detected_player = 'lpayer' # Unknown player, try lpayer as fallback
|
||||
|
||||
|
||||
logger.debug(f"Detected player from URL: {detected_player}")
|
||||
|
||||
# Determine which player to try first
|
||||
@@ -1644,22 +1766,32 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
|
||||
# Build player order: cached player first, then detected, then rest in priority order
|
||||
player_order = []
|
||||
if cached_player and cached_player in player_priority:
|
||||
player_order.append(cached_player)
|
||||
if detected_player and detected_player not in player_order and detected_player in player_priority:
|
||||
player_order.append(detected_player)
|
||||
for p in player_priority:
|
||||
if p not in player_order:
|
||||
player_order.append(p)
|
||||
|
||||
|
||||
# Only try detected player if single video URL
|
||||
if len(video_urls) == 1:
|
||||
# When we have multiple video URLs, only try the detected player for each URL
|
||||
# If the detected player fails, we'll move to the next URL instead of trying other players
|
||||
if len(video_urls) > 1:
|
||||
# Multiple URLs: only try the detected player (or first in priority if none detected)
|
||||
if detected_player and detected_player in player_priority:
|
||||
player_order = [detected_player]
|
||||
logger.info(f"Multiple URLs detected, trying only detected player: {detected_player}")
|
||||
else:
|
||||
player_order = [player_priority[0]]
|
||||
|
||||
# No player detected, try cached if available, otherwise first in priority
|
||||
if cached_player and cached_player in player_priority:
|
||||
player_order = [cached_player]
|
||||
logger.info(f"Multiple URLs with no detected player, trying cached: {cached_player}")
|
||||
else:
|
||||
player_order = [player_priority[0]]
|
||||
logger.info(f"Multiple URLs with no detected/cached player, trying: {player_order[0]}")
|
||||
else:
|
||||
# Single URL: try cached player first, then detected, then all others in priority
|
||||
if cached_player and cached_player in player_priority:
|
||||
player_order.append(cached_player)
|
||||
if detected_player and detected_player not in player_order and detected_player in player_priority:
|
||||
player_order.append(detected_player)
|
||||
for p in player_priority:
|
||||
if p not in player_order:
|
||||
player_order.append(p)
|
||||
|
||||
logger.info(f"Player order: {player_order}")
|
||||
|
||||
# Try each player for this video URL
|
||||
@@ -1681,7 +1813,11 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
)
|
||||
elif player_name == 'lpayer':
|
||||
video_url_result, filename = await self._extract_from_lpayer_api(video_url, anime_page_url, episode_title, target_filename)
|
||||
|
||||
elif player_name == 'smoothpre':
|
||||
video_url_result, filename = await self._extract_from_smoothpre(
|
||||
video_url, anime_page_url, episode_title
|
||||
)
|
||||
|
||||
# Validate the extracted URL
|
||||
logger.info(f"Validating extracted URL from {player_name}...")
|
||||
is_valid = await self._test_video_url(video_url_result)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Video Players (app/downloaders/video_players)
|
||||
|
||||
## OVERVIEW
|
||||
File hosting extractors that extract direct download links from video player pages (Doodstream, Sibnet, VidMoly, etc.).
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Need | File |
|
||||
|------|------|
|
||||
| Base class | `base.py` - `BaseVideoPlayer` abstract class |
|
||||
| Add new player | Create new `.py` file, inherit `BaseVideoPlayer`, add to `__init__.py` |
|
||||
| URL detection logic | Each player's `can_handle()` method |
|
||||
| Extract download link | Each player's `get_download_link()` method |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
**Class naming**: `{Provider}Downloader` (e.g., `DoodStreamDownloader`)
|
||||
|
||||
**Required methods**:
|
||||
```python
|
||||
def can_handle(self, url: str) -> bool: ...
|
||||
async def get_download_link(self, url: str, target_filename: str = None) -> tuple[str, str]: ...
|
||||
```
|
||||
|
||||
**File operation**: Always use `sanitize_filename()` on extracted filenames.
|
||||
|
||||
**HTTP client**: Use `self.client` (AsyncClient from base class). Always close via `await self.close()` when done.
|
||||
|
||||
**Return format**: `(download_url, filename)` tuple.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- Do NOT hardcode User-Agent in each player (use base class headers)
|
||||
- Do NOT forget to call `await self.close()` after extraction
|
||||
- Do NOT return None for missing URLs, raise an exception
|
||||
- Do NOT use sync `requests`, use async `httpx`
|
||||
- Do NOT skip the `target_filename` parameter, even if unused
|
||||
@@ -12,6 +12,7 @@ from .rapidfile import RapidFileDownloader
|
||||
from .vidzy import VidzyDownloader
|
||||
from .luluv import LuLuvidDownloader
|
||||
from .uqload import UqloadDownloader
|
||||
from .smoothpre import SmoothpreDownloader
|
||||
|
||||
__all__ = [
|
||||
"BaseVideoPlayer",
|
||||
@@ -26,6 +27,7 @@ __all__ = [
|
||||
"VidzyDownloader",
|
||||
"LuLuvidDownloader",
|
||||
"UqloadDownloader",
|
||||
"SmoothpreDownloader",
|
||||
]
|
||||
|
||||
|
||||
@@ -43,6 +45,7 @@ def get_video_player(url: str) -> BaseVideoPlayer:
|
||||
VidzyDownloader(),
|
||||
LuLuvidDownloader(),
|
||||
UqloadDownloader(),
|
||||
SmoothpreDownloader(),
|
||||
]
|
||||
|
||||
for player in players:
|
||||
|
||||
+8
-2
@@ -3,8 +3,8 @@
|
||||
ANIME_PROVIDERS = {
|
||||
"anime-sama": {
|
||||
"name": "Anime-Sama",
|
||||
"domains": ["anime-sama.si", "www.anime-sama.si", "anime-sama.org", "anime-sama.store", "anime-sama.eu"],
|
||||
"url_pattern": "https://anime-sama.si/catalogue/{anime}/saison{season}/{lang}/",
|
||||
"domains": ["anime-sama.to", "www.anime-sama.to", "anime-sama.tv", "www.anime-sama.tv", "anime-sama.si", "www.anime-sama.si", "anime-sama.org", "anime-sama.store", "anime-sama.eu"],
|
||||
"url_pattern": "https://anime-sama.to/catalogue/{anime}/saison{season}/{lang}/",
|
||||
"icon": "🎬",
|
||||
"color": "#00d9ff"
|
||||
},
|
||||
@@ -114,6 +114,12 @@ FILE_HOSTS = {
|
||||
"domains": ["uqload.bz", "uqload.com", "www.uqload.bz", "www.uqload.com"],
|
||||
"icon": "📺",
|
||||
"color": "#fd79a8"
|
||||
},
|
||||
"smoothpre": {
|
||||
"name": "Smoothpre",
|
||||
"domains": ["smoothpre.com", "www.smoothpre.com"],
|
||||
"icon": "🎬",
|
||||
"color": "#a29bfe"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Routers package for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
from app.routers.router_auth import router as auth_router
|
||||
from app.routers.router_downloads import (
|
||||
router as downloads_router,
|
||||
legacy_router as downloads_legacy_router,
|
||||
)
|
||||
from app.routers.router_anime import router as anime_router
|
||||
from app.routers.router_favorites import router as favorites_router
|
||||
from app.routers.router_recommendations import router as recommendations_router
|
||||
from app.routers.router_watchlist import router as watchlist_router
|
||||
from app.routers.router_sonarr import router as sonarr_router
|
||||
from app.routers.router_player import router as player_router
|
||||
from app.routers.router_static import router as static_router
|
||||
from app.routers.router_root import router as root_router
|
||||
|
||||
__all__ = [
|
||||
"auth_router",
|
||||
"downloads_router",
|
||||
"downloads_legacy_router",
|
||||
"anime_router",
|
||||
"favorites_router",
|
||||
"recommendations_router",
|
||||
"watchlist_router",
|
||||
"sonarr_router",
|
||||
"player_router",
|
||||
"static_router",
|
||||
"root_router",
|
||||
]
|
||||
@@ -0,0 +1,519 @@
|
||||
"""
|
||||
Anime and series search routes for Ohm Stream Downloader API.
|
||||
|
||||
Endpoints:
|
||||
- GET /api/anime/search - Search across all anime providers
|
||||
- GET /api/series/search - Search across all TV series providers
|
||||
- GET /api/anime/metadata - Get detailed metadata for a specific anime
|
||||
- GET /api/anime/episodes - Get list of episodes for an anime
|
||||
- GET /api/anime/providers - Get list of anime providers
|
||||
- GET /api/anime-sama/search - Search for anime on anime-sama (legacy)
|
||||
- POST /api/anime/download - Download an anime episode
|
||||
- GET /api/anime/frieren/episodes - Get Frieren episodes from local database
|
||||
- POST /api/anime/frieren/download - Download Frieren episode from local database
|
||||
- POST /api/anime/download-season - Download all episodes of a season
|
||||
- GET /api/anime/seasons - Get list of seasons for an anime
|
||||
- GET /api/anime/mal/search - Search for anime on MyAnimeList
|
||||
- GET /api/anime/mal/{mal_id} - Get full details by MyAnimeList ID
|
||||
- POST /api/translate - Translate text from English to French
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
|
||||
|
||||
from app.download_manager import DownloadManager
|
||||
from app.downloaders import (
|
||||
AnimeSamaDownloader,
|
||||
AnimeUltimeDownloader,
|
||||
NekoSamaDownloader,
|
||||
VostfreeDownloader,
|
||||
get_downloader,
|
||||
)
|
||||
from app.models import DownloadRequest
|
||||
from app.providers import get_anime_providers, get_series_providers
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["anime"])
|
||||
|
||||
|
||||
def get_download_manager() -> DownloadManager:
|
||||
"""Get the download manager instance from main app"""
|
||||
from main import download_manager
|
||||
|
||||
return download_manager
|
||||
|
||||
|
||||
# ==================== ANIME SEARCH ====================
|
||||
|
||||
|
||||
@router.get("/anime/search")
|
||||
async def search_anime_unified(
|
||||
q: str,
|
||||
lang: str = "vostfr",
|
||||
include_metadata: bool = False,
|
||||
):
|
||||
"""
|
||||
Search across all anime providers
|
||||
|
||||
Args:
|
||||
q: Search query
|
||||
lang: Language preference (vostfr, vf)
|
||||
include_metadata: Whether to fetch full metadata (slower but more detailed)
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
print(
|
||||
f"\n[SEARCH] Starting search for '{q}' in {lang} (metadata={include_metadata})"
|
||||
)
|
||||
start_time = time.time()
|
||||
|
||||
results = {}
|
||||
|
||||
# Create downloader instances
|
||||
downloaders = {
|
||||
"anime-sama": AnimeSamaDownloader(),
|
||||
"anime-ultime": AnimeUltimeDownloader(),
|
||||
"neko-sama": NekoSamaDownloader(),
|
||||
"vostfree": VostfreeDownloader(),
|
||||
}
|
||||
|
||||
# Generate search query variations for better matching
|
||||
search_queries = [q]
|
||||
|
||||
# Add fallback queries if original has spaces
|
||||
if " " in q or "-" in q:
|
||||
normalized = re.sub(r"[\s\-–—_:]+", "", q)
|
||||
if normalized != q and len(normalized) >= 4:
|
||||
search_queries.append(normalized)
|
||||
|
||||
first_word = q.split()[0] if q.split() else None
|
||||
if first_word and len(first_word) >= 4:
|
||||
search_queries.append(first_word)
|
||||
|
||||
print(f"[SEARCH] Query variations: {search_queries}")
|
||||
|
||||
# Search with fallback queries
|
||||
all_search_tasks = []
|
||||
all_provider_ids = []
|
||||
|
||||
for search_query in search_queries:
|
||||
print(f"[SEARCH] Trying query variant: '{search_query}'")
|
||||
|
||||
for provider_id, provider in get_anime_providers().items():
|
||||
if provider_id in downloaders:
|
||||
downloader = downloaders[provider_id]
|
||||
print(
|
||||
f"[SEARCH] Queueing search on {provider_id} for '{search_query}'..."
|
||||
)
|
||||
all_search_tasks.append(
|
||||
{
|
||||
"query": search_query,
|
||||
"provider_id": provider_id,
|
||||
"task": downloader.search_anime(
|
||||
search_query, lang, include_metadata=include_metadata
|
||||
),
|
||||
}
|
||||
)
|
||||
all_provider_ids.append(provider_id)
|
||||
|
||||
print(f"[SEARCH] Waiting for {len(all_search_tasks)} searches...")
|
||||
search_results = await asyncio.gather(
|
||||
*[t["task"] for t in all_search_tasks], return_exceptions=True
|
||||
)
|
||||
|
||||
# Process results
|
||||
seen_urls = {}
|
||||
|
||||
for task_info, result in zip(all_search_tasks, search_results):
|
||||
provider_id = task_info["provider_id"]
|
||||
search_query = task_info["query"]
|
||||
|
||||
if isinstance(result, Exception):
|
||||
print(
|
||||
f"[SEARCH] {provider_id} (query: '{search_query}') error: {str(result)}"
|
||||
)
|
||||
elif result:
|
||||
print(
|
||||
f"[SEARCH] {provider_id} (query: '{search_query}') found {len(result)} results"
|
||||
)
|
||||
|
||||
if provider_id not in results:
|
||||
results[provider_id] = []
|
||||
|
||||
provider_results = results[provider_id]
|
||||
for item in result:
|
||||
url = item.get("url", "")
|
||||
if url and url not in seen_urls:
|
||||
seen_urls[url] = True
|
||||
if search_query.lower() == q.lower():
|
||||
item["_relevance_boost"] = 1.0
|
||||
else:
|
||||
item["_relevance_boost"] = 0.5
|
||||
provider_results.append(item)
|
||||
else:
|
||||
print(f"[SEARCH] {provider_id} (query: '{search_query}') no results")
|
||||
|
||||
# Sort results by relevance
|
||||
for provider_id in results:
|
||||
results[provider_id].sort(
|
||||
key=lambda x: (
|
||||
-x.get("_relevance_boost", 0),
|
||||
(x.get("title") or "").lower().find(q.lower()),
|
||||
)
|
||||
)
|
||||
for item in results[provider_id]:
|
||||
item.pop("_relevance_boost", None)
|
||||
|
||||
# Remove providers with empty results
|
||||
results = {k: v for k, v in results.items() if v}
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
print(
|
||||
f"[SEARCH] Completed in {elapsed:.2f}s - Total results: {sum(len(r) for r in results.values())}\n"
|
||||
)
|
||||
|
||||
return {
|
||||
"query": q,
|
||||
"lang": lang,
|
||||
"include_metadata": include_metadata,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/series/search")
|
||||
async def search_series_unified(
|
||||
q: str,
|
||||
lang: str = "vf",
|
||||
):
|
||||
"""
|
||||
Search across all TV series providers (FS7, etc.)
|
||||
"""
|
||||
import asyncio
|
||||
from app.downloaders.series_sites import FS7Downloader
|
||||
|
||||
print(f"\n[SERIES SEARCH] Starting search for '{q}' in {lang}")
|
||||
start_time = time.time()
|
||||
|
||||
results = {}
|
||||
|
||||
series_downloaders = {"fs7": FS7Downloader()}
|
||||
|
||||
search_tasks = []
|
||||
provider_ids = []
|
||||
|
||||
for provider_id, provider in get_series_providers().items():
|
||||
if provider_id in series_downloaders:
|
||||
downloader = series_downloaders[provider_id]
|
||||
print(f"[SERIES SEARCH] Queueing search on {provider_id}...")
|
||||
search_tasks.append(downloader.search_anime(q, lang))
|
||||
provider_ids.append(provider_id)
|
||||
|
||||
print(f"[SERIES SEARCH] Waiting for {len(search_tasks)} searches...")
|
||||
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
||||
|
||||
for provider_id, result in zip(provider_ids, search_results):
|
||||
if isinstance(result, Exception):
|
||||
print(f"[SERIES SEARCH] {provider_id} error: {str(result)}")
|
||||
elif result:
|
||||
print(f"[SERIES SEARCH] {provider_id} found {len(result)} results")
|
||||
results[provider_id] = result
|
||||
else:
|
||||
print(f"[SERIES SEARCH] {provider_id} no results")
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
print(
|
||||
f"[SERIES SEARCH] Completed in {elapsed:.2f}s - Total results: {sum(len(r) for r in results.values())}\n"
|
||||
)
|
||||
|
||||
return {"query": q, "lang": lang, "results": results}
|
||||
|
||||
|
||||
@router.get("/anime/metadata")
|
||||
async def get_anime_metadata(url: str):
|
||||
"""Get detailed metadata for a specific anime"""
|
||||
try:
|
||||
downloader = get_downloader(url)
|
||||
|
||||
if hasattr(downloader, "get_anime_metadata"):
|
||||
metadata = await downloader.get_anime_metadata(url)
|
||||
return {"url": url, "metadata": metadata}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Downloader for {url} does not support metadata extraction",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/anime/episodes")
|
||||
async def get_anime_episodes(
|
||||
url: str,
|
||||
lang: str = "vostfr",
|
||||
):
|
||||
"""Get list of episodes for an anime"""
|
||||
downloader = get_downloader(url)
|
||||
episodes = await downloader.get_episodes(url, lang)
|
||||
|
||||
return {"url": url, "lang": lang, "episodes": episodes}
|
||||
|
||||
|
||||
@router.get("/anime/providers")
|
||||
async def get_anime_providers_list():
|
||||
"""Get list of anime providers with info"""
|
||||
return {"providers": get_anime_providers()}
|
||||
|
||||
|
||||
# ==================== ANIME-SAMA SPECIFIC ====================
|
||||
|
||||
|
||||
@router.get("/anime-sama/search")
|
||||
async def search_anime_sama(
|
||||
q: str,
|
||||
lang: str = "vostfr",
|
||||
):
|
||||
"""Search for anime on anime-sama"""
|
||||
downloader = AnimeSamaDownloader()
|
||||
results = await downloader.search_anime(q, lang)
|
||||
return {"query": q, "lang": lang, "results": results}
|
||||
|
||||
|
||||
@router.post("/anime/download")
|
||||
async def download_anime_episode(
|
||||
url: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
episode: str | None = None,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
):
|
||||
"""Download an anime episode"""
|
||||
if episode and "episode-" not in url and "|" not in url:
|
||||
url = f"{url.rstrip('/')}/episode-{episode}"
|
||||
|
||||
request = DownloadRequest(url=url)
|
||||
task = download_manager.create_task(request)
|
||||
background_tasks.add_task(download_manager.start_download, task.id)
|
||||
return {"task_id": task.id, "task": task}
|
||||
|
||||
|
||||
# ==================== FRIEREN LEGACY ENDPOINTS ====================
|
||||
|
||||
|
||||
@router.get("/anime/frieren/episodes")
|
||||
async def get_frieren_episodes():
|
||||
"""Get Frieren episodes from local database"""
|
||||
try:
|
||||
with open("app/frieren_episodes.json", "r") as f:
|
||||
data = json.load(f)
|
||||
return data
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=404, detail=f"Episodes not found: {e}")
|
||||
|
||||
|
||||
@router.post("/anime/frieren/download")
|
||||
async def download_frieren_episode(
|
||||
season: int,
|
||||
episode: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
):
|
||||
"""Download Frieren episode from local database"""
|
||||
try:
|
||||
with open("app/frieren_episodes.json", "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
season_key = str(season)
|
||||
if season_key not in data["seasons"]:
|
||||
raise HTTPException(status_code=404, detail=f"Season {season} not found")
|
||||
|
||||
season_data = data["seasons"][season_key]
|
||||
ep_data = next(
|
||||
(ep for ep in season_data["episodes"] if ep["episode"] == episode), None
|
||||
)
|
||||
|
||||
if not ep_data:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Episode {episode} not found in season {season}",
|
||||
)
|
||||
|
||||
url = ep_data["sibnet_url"]
|
||||
filename = f"Frieren - S{season} - Episode {episode}.mp4"
|
||||
|
||||
request = DownloadRequest(url=url, filename=filename)
|
||||
task = download_manager.create_task(request)
|
||||
background_tasks.add_task(download_manager.start_download, task.id)
|
||||
|
||||
return {"task_id": task.id, "task": task}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
|
||||
|
||||
|
||||
# ==================== DOWNLOAD SEASON ====================
|
||||
|
||||
|
||||
@router.post("/anime/download-season")
|
||||
async def download_anime_season(
|
||||
url: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
lang: str = "vostfr",
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
):
|
||||
"""Download all episodes of an anime season"""
|
||||
downloader = get_downloader(url)
|
||||
episodes = await downloader.get_episodes(url, lang)
|
||||
|
||||
if not episodes:
|
||||
raise HTTPException(status_code=404, detail="No episodes found")
|
||||
|
||||
task_ids = []
|
||||
for episode in episodes:
|
||||
request = DownloadRequest(url=episode["url"])
|
||||
task = download_manager.create_task(request)
|
||||
task_ids.append(task.id)
|
||||
background_tasks.add_task(download_manager.start_download, task.id)
|
||||
|
||||
return {
|
||||
"message": f"Started downloading {len(task_ids)} episodes",
|
||||
"task_ids": task_ids,
|
||||
"total_episodes": len(episodes),
|
||||
}
|
||||
|
||||
|
||||
# ==================== SEASONS ====================
|
||||
|
||||
|
||||
@router.get("/anime/seasons")
|
||||
async def get_anime_seasons(url: str):
|
||||
"""Get list of seasons for an anime"""
|
||||
downloader = get_downloader(url)
|
||||
|
||||
if hasattr(downloader, "get_seasons"):
|
||||
seasons = await downloader.get_seasons(url)
|
||||
|
||||
if not seasons:
|
||||
return {"seasons": [], "message": "No seasons found"}
|
||||
|
||||
return {"seasons": seasons}
|
||||
else:
|
||||
return {
|
||||
"seasons": [],
|
||||
"message": "Season information not available for this provider",
|
||||
}
|
||||
|
||||
|
||||
# ==================== MYANIMELIST INTEGRATION ====================
|
||||
|
||||
|
||||
@router.get("/anime/mal/search")
|
||||
async def search_anime_mal_details(
|
||||
q: str = Query(..., description="Anime search query"),
|
||||
limit: int = Query(5, description="Number of results"),
|
||||
):
|
||||
"""Search for anime on MyAnimeList and get full details"""
|
||||
from app.recommendations import AnimeReleasesFetcher
|
||||
|
||||
fetcher = AnimeReleasesFetcher()
|
||||
|
||||
try:
|
||||
search_results = await fetcher.search_anime(q, limit=limit)
|
||||
|
||||
if not search_results:
|
||||
return {"anime": None, "message": "No anime found"}
|
||||
|
||||
main_anime = search_results[0]
|
||||
anime_details = await fetcher.get_anime_details(main_anime["mal_id"])
|
||||
|
||||
alternatives = search_results[1:] if len(search_results) > 1 else []
|
||||
|
||||
return {
|
||||
"anime": anime_details,
|
||||
"alternatives": alternatives,
|
||||
"total_results": len(search_results),
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await fetcher.close()
|
||||
|
||||
|
||||
@router.get("/anime/mal/{mal_id}")
|
||||
async def get_anime_by_id(mal_id: int):
|
||||
"""Get full details of an anime by its MyAnimeList ID"""
|
||||
from app.recommendations import AnimeReleasesFetcher
|
||||
|
||||
fetcher = AnimeReleasesFetcher()
|
||||
|
||||
try:
|
||||
anime_details = await fetcher.get_anime_details(mal_id)
|
||||
|
||||
if not anime_details:
|
||||
raise HTTPException(status_code=404, detail="Anime not found")
|
||||
|
||||
return anime_details
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await fetcher.close()
|
||||
|
||||
|
||||
# ==================== TRANSLATION ====================
|
||||
|
||||
|
||||
@router.post("/translate")
|
||||
async def translate_text(request: Request):
|
||||
"""Translate text from English to French using Google Translate"""
|
||||
import httpx
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
text = body.get("text", "")
|
||||
|
||||
if not text:
|
||||
raise HTTPException(status_code=400, detail="Text is required")
|
||||
|
||||
text = text[:5000]
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
url = "https://translate.googleapis.com/translate_a/single"
|
||||
params = {"client": "gtx", "sl": "en", "tl": "fr", "dt": "t", "q": text}
|
||||
|
||||
logger.info(f"Translation request for text length: {len(text)}")
|
||||
|
||||
response = await client.get(url, params=params)
|
||||
|
||||
logger.info(f"Translation API response status: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
if data and len(data) > 0 and data[0]:
|
||||
translated_text = "".join([item[0] for item in data[0] if item[0]])
|
||||
|
||||
if translated_text:
|
||||
logger.info(
|
||||
f"Translation successful, length: {len(translated_text)}"
|
||||
)
|
||||
return {"translatedText": translated_text, "status": "success"}
|
||||
|
||||
logger.warning(
|
||||
f"Unexpected Google Translate response structure: {data}"
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=500, detail="Translation failed")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Translation error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Translation error: {str(e)}")
|
||||
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Authentication routes for Ohm Stream Downloader API.
|
||||
|
||||
Endpoints:
|
||||
- POST /api/auth/register - Register a new user
|
||||
- POST /api/auth/login - Login user and return JWT token
|
||||
- GET /api/auth/me - Get current user information
|
||||
- POST /api/auth/logout - Logout user (client-side)
|
||||
- POST /api/auth/refresh - Refresh access token
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from app.auth import (
|
||||
create_access_token,
|
||||
user_manager,
|
||||
verify_token,
|
||||
)
|
||||
from app.models.auth import User, UserCreate, UserLogin
|
||||
|
||||
security = HTTPBearer()
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
async def get_current_user_from_token(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> User:
|
||||
"""Dependency to get current user from JWT token"""
|
||||
token = credentials.credentials
|
||||
username = verify_token(token)
|
||||
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user_dict = user_manager.get_user(username)
|
||||
if user_dict is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return User(**user_dict)
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(user_data: UserCreate):
|
||||
"""Register a new user"""
|
||||
try:
|
||||
existing_user = user_manager.get_user(user_data.username)
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username already registered",
|
||||
)
|
||||
|
||||
user = user_manager.create_user(
|
||||
username=user_data.username,
|
||||
password=user_data.password,
|
||||
email=user_data.email,
|
||||
full_name=user_data.full_name,
|
||||
)
|
||||
|
||||
user_response = User(
|
||||
id=user["id"],
|
||||
username=user["username"],
|
||||
email=user.get("email"),
|
||||
full_name=user.get("full_name"),
|
||||
is_active=user["is_active"],
|
||||
created_at=datetime.fromisoformat(user["created_at"]),
|
||||
last_login=datetime.fromisoformat(user["last_login"])
|
||||
if user.get("last_login")
|
||||
else None,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "User registered successfully",
|
||||
"user": user_response,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
except Exception as e:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.error(f"Error registering user: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to register user",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(form_data: UserLogin):
|
||||
"""Login user and return JWT token"""
|
||||
user = user_manager.authenticate_user(form_data.username, form_data.password)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.get("is_active", True):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled"
|
||||
)
|
||||
|
||||
access_token = create_access_token(
|
||||
data={"sub": user["username"]}, expires_delta=timedelta(days=7)
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
"email": user.get("email"),
|
||||
"full_name": user.get("full_name"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(current_user: User = Depends(get_current_user_from_token)):
|
||||
"""Get current user information"""
|
||||
return {
|
||||
"user": {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"full_name": current_user.full_name,
|
||||
"is_active": current_user.is_active,
|
||||
"created_at": current_user.created_at,
|
||||
"last_login": current_user.last_login,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout():
|
||||
"""Logout user (client-side only)"""
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Logout successful. Please remove the token from client storage.",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh_token(refresh_request: dict):
|
||||
"""Refresh access token using a valid refresh token."""
|
||||
from app.auth import (
|
||||
verify_refresh_token,
|
||||
create_access_refresh_tokens,
|
||||
revoke_refresh_token,
|
||||
user_manager as um,
|
||||
)
|
||||
|
||||
refresh_token_value = refresh_request.get("refresh_token")
|
||||
if not refresh_token_value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Refresh token is required"
|
||||
)
|
||||
|
||||
username = verify_refresh_token(refresh_token_value)
|
||||
if not username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired refresh token",
|
||||
)
|
||||
|
||||
user = um.get_user(username)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
|
||||
)
|
||||
if not user.get("is_active", True):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled"
|
||||
)
|
||||
|
||||
revoke_refresh_token(refresh_token_value)
|
||||
|
||||
access_token, new_refresh_token = create_access_refresh_tokens(
|
||||
data={"sub": username}
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": new_refresh_token,
|
||||
"token_type": "bearer",
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
Download management routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.download_manager import DownloadManager
|
||||
from app.models import DownloadRequest, DownloadStatus
|
||||
from app.utils import is_safe_filename, sanitize_filename
|
||||
|
||||
router = APIRouter(prefix="/api/download", tags=["downloads"])
|
||||
|
||||
|
||||
def get_download_manager() -> DownloadManager:
|
||||
from main import download_manager
|
||||
|
||||
return download_manager
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_download(
|
||||
request: DownloadRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
):
|
||||
"""Create a new download task"""
|
||||
if request.filename:
|
||||
request.filename = sanitize_filename(request.filename)
|
||||
if not is_safe_filename(request.filename):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid filename. Path traversal attempts are not allowed.",
|
||||
)
|
||||
|
||||
task = download_manager.create_task(request)
|
||||
background_tasks.add_task(download_manager.start_download, task.id)
|
||||
return {"task_id": task.id, "task": task}
|
||||
|
||||
|
||||
@router.get("/direct")
|
||||
async def direct_download(
|
||||
url: str,
|
||||
filename: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
):
|
||||
"""Download directly from a video URL with custom filename"""
|
||||
request = DownloadRequest(url=url, filename=filename)
|
||||
task = download_manager.create_task(request)
|
||||
background_tasks.add_task(download_manager.start_download, task.id)
|
||||
return {"task_id": task.id, "task": task}
|
||||
|
||||
|
||||
@router.get("/{task_id}")
|
||||
async def get_download_status(
|
||||
task_id: str,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
):
|
||||
"""Get status of a specific download"""
|
||||
task = download_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/{task_id}/pause")
|
||||
async def pause_download(
|
||||
task_id: str,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
):
|
||||
"""Pause a download"""
|
||||
task = download_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
await download_manager.pause_download(task_id)
|
||||
return {"status": "paused"}
|
||||
|
||||
|
||||
@router.post("/{task_id}/resume")
|
||||
async def resume_download(
|
||||
task_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
):
|
||||
"""Resume a paused download"""
|
||||
task = download_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
if task.status == DownloadStatus.PAUSED:
|
||||
background_tasks.add_task(download_manager.start_download, task_id)
|
||||
return {"status": "resumed"}
|
||||
|
||||
return {"status": "already running or completed"}
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
async def delete_download(
|
||||
task_id: str,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
):
|
||||
"""Delete/cancel a download"""
|
||||
task = download_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
await download_manager.delete_task(task_id)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/{task_id}/file")
|
||||
async def download_file(
|
||||
task_id: str,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
):
|
||||
"""Download the completed file"""
|
||||
task = download_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
if task.status != DownloadStatus.COMPLETED:
|
||||
raise HTTPException(status_code=400, detail="Download not completed")
|
||||
|
||||
if not task.file_path or not os.path.exists(task.file_path):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
return FileResponse(
|
||||
task.file_path, filename=task.filename, media_type="application/octet-stream"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_downloads(
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
):
|
||||
"""List all download tasks"""
|
||||
return {"downloads": download_manager.get_all_tasks()}
|
||||
|
||||
|
||||
# Legacy endpoint for /api/downloads
|
||||
legacy_router = APIRouter(prefix="/api", tags=["downloads-legacy"])
|
||||
|
||||
|
||||
@legacy_router.get("/downloads")
|
||||
async def list_all_downloads(
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
):
|
||||
"""List all download tasks (legacy endpoint at /api/downloads)"""
|
||||
return {"downloads": download_manager.get_all_tasks()}
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Favorites management routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.requests import Request
|
||||
|
||||
from app.favorites import get_favorites_manager
|
||||
|
||||
router = APIRouter(prefix="/api/favorites", tags=["favorites"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_favorites(
|
||||
sort_by: str = "created_at",
|
||||
order: str = "desc",
|
||||
filter_provider: str = None,
|
||||
filter_genre: str = None,
|
||||
):
|
||||
"""List all favorite anime with optional sorting and filtering"""
|
||||
fav_manager = get_favorites_manager()
|
||||
favorites = await fav_manager.list_favorites(
|
||||
sort_by=sort_by,
|
||||
order=order,
|
||||
filter_provider=filter_provider,
|
||||
filter_genre=filter_genre,
|
||||
)
|
||||
return {
|
||||
"favorites": favorites,
|
||||
"total": len(favorites),
|
||||
"filters": {
|
||||
"sort_by": sort_by,
|
||||
"order": order,
|
||||
"provider": filter_provider,
|
||||
"genre": filter_genre,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def add_favorite(request: Request):
|
||||
"""Add an anime to favorites"""
|
||||
data = await request.json()
|
||||
|
||||
required_fields = ["anime_id", "title", "url", "provider"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Missing required field: {field}"
|
||||
)
|
||||
|
||||
fav_manager = get_favorites_manager()
|
||||
favorite = await fav_manager.add_favorite(
|
||||
anime_id=data["anime_id"],
|
||||
title=data["title"],
|
||||
url=data["url"],
|
||||
provider=data["provider"],
|
||||
metadata=data.get("metadata"),
|
||||
poster_url=data.get("poster_url"),
|
||||
)
|
||||
|
||||
return {"status": "added", "favorite": favorite}
|
||||
|
||||
|
||||
@router.delete("/{anime_id}")
|
||||
async def remove_favorite(anime_id: str):
|
||||
"""Remove an anime from favorites"""
|
||||
fav_manager = get_favorites_manager()
|
||||
removed = await fav_manager.remove_favorite(anime_id)
|
||||
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail="Favorite not found")
|
||||
|
||||
return {"status": "removed", "anime_id": anime_id}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_favorites_stats():
|
||||
"""Get statistics about favorites"""
|
||||
fav_manager = get_favorites_manager()
|
||||
stats = await fav_manager.get_stats()
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/{anime_id}")
|
||||
async def get_favorite(anime_id: str):
|
||||
"""Get details of a specific favorite anime"""
|
||||
fav_manager = get_favorites_manager()
|
||||
favorite = await fav_manager.get_favorite(anime_id)
|
||||
|
||||
if not favorite:
|
||||
raise HTTPException(status_code=404, detail="Favorite not found")
|
||||
|
||||
return {"favorite": favorite}
|
||||
|
||||
|
||||
@router.post("/toggle")
|
||||
async def toggle_favorite(request: Request):
|
||||
"""Toggle an anime in favorites"""
|
||||
data = await request.json()
|
||||
|
||||
required_fields = ["anime_id", "title", "url", "provider"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Missing required field: {field}"
|
||||
)
|
||||
|
||||
fav_manager = get_favorites_manager()
|
||||
result = await fav_manager.toggle_favorite(
|
||||
anime_id=data["anime_id"],
|
||||
title=data["title"],
|
||||
url=data["url"],
|
||||
provider=data["provider"],
|
||||
metadata=data.get("metadata"),
|
||||
poster_url=data.get("poster_url"),
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Video streaming routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from app.models import DownloadStatus
|
||||
|
||||
router = APIRouter(tags=["player"])
|
||||
|
||||
|
||||
def get_download_manager():
|
||||
from main import download_manager
|
||||
|
||||
return download_manager
|
||||
|
||||
|
||||
def get_templates():
|
||||
from main import templates
|
||||
|
||||
return templates
|
||||
|
||||
|
||||
@router.get("/video/{task_id}")
|
||||
async def stream_video(task_id: str, request: Request):
|
||||
"""Stream a video file with Range support for seeking"""
|
||||
download_manager = get_download_manager()
|
||||
task = download_manager.get_task(task_id)
|
||||
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
if task.status != DownloadStatus.COMPLETED:
|
||||
raise HTTPException(status_code=400, detail="Download not completed")
|
||||
|
||||
if not task.file_path or not os.path.exists(task.file_path):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
file_path = Path(task.file_path)
|
||||
file_size = file_path.stat().st_size
|
||||
|
||||
range_header = request.headers.get("range")
|
||||
headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": "video/mp4",
|
||||
}
|
||||
|
||||
if range_header:
|
||||
try:
|
||||
range_match = re.match(r"bytes=(\d+)-(\d*)", range_header)
|
||||
start = int(range_match.group(1))
|
||||
end = int(range_match.group(2)) if range_match.group(2) else file_size - 1
|
||||
|
||||
if start >= file_size or end >= file_size or start > end:
|
||||
headers["Content-Range"] = f"bytes */{file_size}"
|
||||
return Response(
|
||||
status_code=416,
|
||||
headers=headers,
|
||||
content="Requested Range Not Satisfiable",
|
||||
)
|
||||
|
||||
content_length = end - start + 1
|
||||
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
||||
headers["Content-Length"] = str(content_length)
|
||||
|
||||
def video_range_reader():
|
||||
with open(file_path, "rb") as f:
|
||||
f.seek(start)
|
||||
remaining = content_length
|
||||
while remaining > 0:
|
||||
chunk_size = min(1024 * 1024, remaining)
|
||||
data = f.read(chunk_size)
|
||||
if not data:
|
||||
break
|
||||
remaining -= len(data)
|
||||
yield data
|
||||
|
||||
return Response(
|
||||
content=video_range_reader(), status_code=206, headers=headers
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid Range header: {e}")
|
||||
else:
|
||||
|
||||
def video_reader():
|
||||
with open(file_path, "rb") as f:
|
||||
while True:
|
||||
data = f.read(1024 * 1024)
|
||||
if not data:
|
||||
break
|
||||
yield data
|
||||
|
||||
headers["Content-Length"] = str(file_size)
|
||||
return Response(content=video_reader(), headers=headers)
|
||||
|
||||
|
||||
@router.get("/stream/{filename}")
|
||||
async def stream_video_by_filename(filename: str, request: Request):
|
||||
"""Stream a video file by filename with Range support"""
|
||||
filename = os.path.basename(filename)
|
||||
file_path = Path("downloads") / filename
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
file_size = file_path.stat().st_size
|
||||
range_header = request.headers.get("range")
|
||||
|
||||
if range_header:
|
||||
try:
|
||||
range_match = re.match(r"bytes=(\d+)-(\d*)", range_header)
|
||||
start = int(range_match.group(1))
|
||||
end = int(range_match.group(2)) if range_match.group(2) else file_size - 1
|
||||
|
||||
if start >= file_size or end >= file_size or start > end:
|
||||
return Response(
|
||||
status_code=416,
|
||||
headers={
|
||||
"Content-Range": f"bytes */{file_size}",
|
||||
"Accept-Ranges": "bytes",
|
||||
},
|
||||
content="Requested Range Not Satisfiable",
|
||||
)
|
||||
|
||||
content_length = end - start + 1
|
||||
|
||||
def video_range_reader():
|
||||
with open(file_path, "rb") as f:
|
||||
f.seek(start)
|
||||
remaining = content_length
|
||||
while remaining > 0:
|
||||
chunk_size = min(1024 * 1024, remaining)
|
||||
data = f.read(chunk_size)
|
||||
if not data:
|
||||
break
|
||||
remaining -= len(data)
|
||||
yield data
|
||||
|
||||
return StreamingResponse(
|
||||
video_range_reader(),
|
||||
status_code=206,
|
||||
headers={
|
||||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||
"Content-Length": str(content_length),
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": "video/mp4",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid Range header: {e}")
|
||||
else:
|
||||
|
||||
def video_reader():
|
||||
with open(file_path, "rb") as f:
|
||||
while True:
|
||||
data = f.read(1024 * 1024)
|
||||
if not data:
|
||||
break
|
||||
yield data
|
||||
|
||||
return StreamingResponse(
|
||||
video_reader(),
|
||||
headers={
|
||||
"Content-Length": str(file_size),
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": "video/mp4",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/player/{task_id}")
|
||||
async def video_player(request: Request, task_id: str):
|
||||
"""Video player page for watching downloaded anime"""
|
||||
from main import download_manager, templates
|
||||
|
||||
task = download_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
if task.status != DownloadStatus.COMPLETED:
|
||||
raise HTTPException(status_code=400, detail="Download not completed")
|
||||
|
||||
if not task.file_path or not os.path.exists(task.file_path):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
file_path = Path(task.file_path)
|
||||
file_size = file_path.stat().st_size
|
||||
estimated_duration_seconds = int(file_size / (1.5 * 1024 * 1024))
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"player.html",
|
||||
{
|
||||
"request": request,
|
||||
"task_id": task_id,
|
||||
"filename": task.filename,
|
||||
"file_size": file_size,
|
||||
"estimated_duration": estimated_duration_seconds,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/watch/{filename}")
|
||||
async def video_player_by_filename(request: Request, filename: str):
|
||||
"""Video player page for watching downloaded anime by filename"""
|
||||
from main import templates
|
||||
from app.utils import is_safe_filename, sanitize_filename
|
||||
|
||||
filename = sanitize_filename(filename)
|
||||
|
||||
if not is_safe_filename(filename):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid filename. Path traversal attempts are not allowed.",
|
||||
)
|
||||
|
||||
file_path = Path("downloads") / filename
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
file_size = file_path.stat().st_size
|
||||
estimated_duration_seconds = int(file_size / (1.5 * 1024 * 1024))
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"player.html",
|
||||
{
|
||||
"request": request,
|
||||
"task_id": filename,
|
||||
"filename": filename,
|
||||
"file_size": file_size,
|
||||
"estimated_duration": estimated_duration_seconds,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Recommendations and releases routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.recommendation_engine import RecommendationEngine
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["recommendations"])
|
||||
|
||||
|
||||
@router.get("/recommendations")
|
||||
async def get_recommendations(limit: int = 15):
|
||||
"""Get personalized anime recommendations based on download history"""
|
||||
engine = RecommendationEngine(download_dir="downloads")
|
||||
|
||||
try:
|
||||
recommendations = await engine.get_personalized_recommendations(limit=limit)
|
||||
|
||||
return {"recommendations": recommendations, "count": len(recommendations)}
|
||||
except Exception as e:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await engine.close()
|
||||
|
||||
|
||||
@router.get("/releases/latest")
|
||||
async def get_latest_releases(limit: int = 20):
|
||||
"""Get latest anime releases"""
|
||||
from app.recommendations import get_latest_releases_with_info
|
||||
|
||||
try:
|
||||
releases = await get_latest_releases_with_info(limit=limit)
|
||||
|
||||
return {
|
||||
"releases": releases,
|
||||
"count": len(releases),
|
||||
"updated": datetime.now().isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/releases/seasonal")
|
||||
async def get_seasonal_anime(
|
||||
year: Optional[int] = None,
|
||||
season: Optional[str] = None,
|
||||
):
|
||||
"""Get current/previously seasonal anime"""
|
||||
from app.recommendations import AnimeReleasesFetcher
|
||||
|
||||
fetcher = AnimeReleasesFetcher()
|
||||
|
||||
try:
|
||||
anime = await fetcher.get_seasonal_anime(year, season)
|
||||
|
||||
return {
|
||||
"anime": anime,
|
||||
"count": len(anime),
|
||||
"year": year or datetime.now().year,
|
||||
"season": season or "current",
|
||||
}
|
||||
except Exception as e:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await fetcher.close()
|
||||
|
||||
|
||||
@router.get("/releases/scheduled")
|
||||
async def get_scheduled_anime(day: Optional[str] = None):
|
||||
"""Get anime scheduled for a specific day"""
|
||||
from app.recommendations import AnimeReleasesFetcher
|
||||
|
||||
fetcher = AnimeReleasesFetcher()
|
||||
|
||||
try:
|
||||
anime = await fetcher.get_scheduled_anime(day)
|
||||
|
||||
return {"anime": anime, "count": len(anime), "day": day or "today"}
|
||||
except Exception as e:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await fetcher.close()
|
||||
|
||||
|
||||
@router.get("/releases/top")
|
||||
async def get_top_anime(
|
||||
type: str = "tv",
|
||||
limit: int = 15,
|
||||
):
|
||||
"""Get top rated anime"""
|
||||
from app.recommendations import AnimeReleasesFetcher
|
||||
|
||||
fetcher = AnimeReleasesFetcher()
|
||||
|
||||
try:
|
||||
anime = await fetcher.get_top_anime(type=type, limit=limit)
|
||||
|
||||
return {"anime": anime, "count": len(anime)}
|
||||
except Exception as e:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await fetcher.close()
|
||||
|
||||
|
||||
@router.get("/stats/downloads")
|
||||
async def get_download_statistics():
|
||||
"""Get download statistics and preferences"""
|
||||
engine = RecommendationEngine(download_dir="downloads")
|
||||
|
||||
try:
|
||||
stats = await engine.get_download_stats()
|
||||
|
||||
return stats
|
||||
except Exception as e:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await engine.close()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Root routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app import providers
|
||||
|
||||
router = APIRouter(prefix="", tags=["root"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def root():
|
||||
"""Root endpoint with API information"""
|
||||
return {
|
||||
"message": "Ohm Stream Downloader API",
|
||||
"status": "running",
|
||||
"version": "2.2",
|
||||
"endpoints": {
|
||||
"POST /api/download": "Start a new download",
|
||||
"GET /api/downloads": "List all downloads",
|
||||
"GET /api/download/{task_id}": "Get download status",
|
||||
"POST /api/download/{task_id}/pause": "Pause a download",
|
||||
"POST /api/download/{task_id}/resume": "Resume a download",
|
||||
"DELETE /api/download/{task_id}": "Cancel a download",
|
||||
"GET /api/providers": "List all supported providers",
|
||||
"GET /api/anime/search": "Search anime across all providers",
|
||||
"GET /api/anime/metadata": "Get detailed anime metadata",
|
||||
"GET /api/anime/episodes": "Get episode list for an anime",
|
||||
"POST /api/anime/download-season": "Download all episodes of a season",
|
||||
"GET /api/favorites": "List all favorite anime",
|
||||
"POST /api/favorites": "Add anime to favorites",
|
||||
"DELETE /api/favorites/{anime_id}": "Remove from favorites",
|
||||
"GET /api/favorites/{anime_id}": "Get favorite anime details",
|
||||
"GET /api/favorites/stats": "Get favorites statistics",
|
||||
"POST /api/favorites/toggle": "Toggle anime in favorites",
|
||||
"GET /web": "Web interface",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
@router.get("/api/providers")
|
||||
async def list_providers():
|
||||
"""List all supported anime, series and file hosting providers"""
|
||||
return {
|
||||
"anime_providers": providers.get_anime_providers(),
|
||||
"series_providers": providers.get_series_providers(),
|
||||
"file_hosts": providers.get_file_hosts(),
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
Sonarr integration routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||
from fastapi.requests import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.models import DownloadRequest
|
||||
from app.models.auth import User
|
||||
from app.models.sonarr import SonarrConfig, SonarrDownloadRequest, SonarrMapping
|
||||
from app.routers.router_auth import get_current_user_from_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["sonarr"])
|
||||
|
||||
|
||||
def get_sonarr_handler():
|
||||
from app.sonarr_handler import get_sonarr_handler
|
||||
|
||||
return get_sonarr_handler()
|
||||
|
||||
|
||||
@router.post("/webhook/sonarr")
|
||||
async def sonarr_webhook(request: Request):
|
||||
"""Receive and process Sonarr webhook events"""
|
||||
from app.models.sonarr import SonarrWebhookPayload
|
||||
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
|
||||
body = await request.body()
|
||||
signature = request.headers.get("X-Sonarr-Event", "")
|
||||
if not sonarr_handler.verify_hmac(body, signature):
|
||||
logger.warning("Invalid HMAC signature for Sonarr webhook")
|
||||
raise HTTPException(status_code=403, detail="Invalid signature")
|
||||
|
||||
try:
|
||||
payload_data = await request.json()
|
||||
payload = SonarrWebhookPayload(**payload_data)
|
||||
result = await sonarr_handler.process_webhook(payload)
|
||||
return JSONResponse(content=result, status_code=200)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing Sonarr webhook: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=422, detail=f"Invalid payload: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/webhook/test/sonarr")
|
||||
async def test_sonarr_webhook(request: Request):
|
||||
"""Test endpoint for Sonarr webhook configuration"""
|
||||
try:
|
||||
payload = await request.json()
|
||||
logger.info(
|
||||
f"Received test Sonarr webhook: {payload.get('eventType', 'unknown')}"
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Test webhook received successfully",
|
||||
"received_payload": payload,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error in test webhook: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@router.get("/sonarr/config")
|
||||
async def get_sonarr_config():
|
||||
"""Get Sonarr webhook configuration"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
return sonarr_handler.get_config()
|
||||
|
||||
|
||||
@router.put("/sonarr/config")
|
||||
async def update_sonarr_config(config: SonarrConfig):
|
||||
"""Update Sonarr webhook configuration"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
try:
|
||||
updated_config = sonarr_handler.update_config(config)
|
||||
return {"status": "success", "config": updated_config}
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating Sonarr config: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/sonarr/mappings")
|
||||
async def get_sonarr_mappings():
|
||||
"""Get all Sonarr to anime mappings"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
return sonarr_handler.get_mappings()
|
||||
|
||||
|
||||
@router.get("/sonarr/mappings/{series_id}")
|
||||
async def get_sonarr_mapping(series_id: int):
|
||||
"""Get specific mapping by Sonarr series ID"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
mapping = sonarr_handler.get_mapping(series_id)
|
||||
if not mapping:
|
||||
raise HTTPException(status_code=404, detail="Mapping not found")
|
||||
return mapping
|
||||
|
||||
|
||||
@router.post("/sonarr/mappings")
|
||||
async def create_sonarr_mapping(mapping: SonarrMapping):
|
||||
"""Create or update a Sonarr to anime mapping"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
try:
|
||||
mapping = sonarr_handler.add_mapping(mapping)
|
||||
return {"status": "success", "mapping": mapping}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating mapping: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/sonarr/mappings/{series_id}")
|
||||
async def delete_sonarr_mapping(series_id: int):
|
||||
"""Delete a Sonarr mapping"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
success = sonarr_handler.delete_mapping(series_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Mapping not found")
|
||||
return {"status": "success", "message": f"Mapping for series {series_id} deleted"}
|
||||
|
||||
|
||||
@router.get("/sonarr/search")
|
||||
async def search_anime_for_sonarr(
|
||||
q: str = Query(..., description="Series title to search"),
|
||||
provider: str = Query("anime-sama", description="Anime provider to search"),
|
||||
lang: str = Query("vostfr", description="Language (vostfr, vf)"),
|
||||
):
|
||||
"""Search for anime on providers to create Sonarr mappings"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
try:
|
||||
results = await sonarr_handler.search_anime_by_title(q, provider, lang)
|
||||
return {
|
||||
"status": "success",
|
||||
"query": q,
|
||||
"provider": provider,
|
||||
"lang": lang,
|
||||
"results": results,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching anime: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/sonarr/episodes")
|
||||
async def get_anime_episodes(
|
||||
url: str = Query(..., description="Anime URL from provider"),
|
||||
provider: str = Query("anime-sama", description="Anime provider"),
|
||||
lang: str = Query("vostfr", description="Language (vostfr, vf)"),
|
||||
):
|
||||
"""Get episode list for anime"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
try:
|
||||
episodes = await sonarr_handler.get_episodes_for_anime(url, provider, lang)
|
||||
return {
|
||||
"status": "success",
|
||||
"url": url,
|
||||
"provider": provider,
|
||||
"lang": lang,
|
||||
"episodes": episodes,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting episodes: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/sonarr/suggest")
|
||||
async def suggest_anime_mapping(
|
||||
sonarr_title: str = Query(..., description="Sonarr series title"),
|
||||
provider: str = Query("anime-sama", description="Anime provider"),
|
||||
lang: str = Query("vostfr", description="Language"),
|
||||
):
|
||||
"""Suggest possible anime mappings based on Sonarr series title"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
try:
|
||||
suggestions = await sonarr_handler.suggest_mapping(sonarr_title, provider, lang)
|
||||
return {
|
||||
"status": "success",
|
||||
"sonarr_title": sonarr_title,
|
||||
"provider": provider,
|
||||
"lang": lang,
|
||||
"suggestions": suggestions,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting suggestions: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/sonarr/download")
|
||||
async def trigger_sonarr_download(
|
||||
request: SonarrDownloadRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
):
|
||||
"""Manually trigger a download based on Sonarr information"""
|
||||
from main import download_manager
|
||||
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
|
||||
mapping = sonarr_handler.get_mapping(request.sonarr_series_id)
|
||||
if not mapping:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No mapping found for series {request.sonarr_series_id}. Create a mapping first.",
|
||||
)
|
||||
|
||||
try:
|
||||
episodes = await sonarr_handler.get_episodes_for_anime(
|
||||
mapping.anime_url,
|
||||
request.provider or mapping.anime_provider,
|
||||
request.lang or mapping.lang,
|
||||
)
|
||||
|
||||
target_episode = None
|
||||
for ep in episodes:
|
||||
ep_num = ep.get("episode", 0)
|
||||
season_num = ep.get("season", 1)
|
||||
if ep_num == request.episode_number and season_num == request.season_number:
|
||||
target_episode = ep
|
||||
break
|
||||
|
||||
if not target_episode:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Episode S{request.season_number}E{request.episode_number} not found",
|
||||
)
|
||||
|
||||
episode_url = target_episode.get("url")
|
||||
if not episode_url:
|
||||
raise HTTPException(status_code=400, detail="Episode URL not found")
|
||||
|
||||
download_request = DownloadRequest(
|
||||
url=episode_url,
|
||||
filename=f"{mapping.anime_title} - S{request.season_number}E{request.episode_number}.mp4",
|
||||
)
|
||||
|
||||
task = download_manager.create_task(download_request)
|
||||
background_tasks.add_task(download_manager.start_download, task.id)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"task_id": task.id,
|
||||
"message": f"Download started for {mapping.anime_title} S{request.season_number}E{request.episode_number}",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error triggering download: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Static pages routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
router = APIRouter(tags=["static"])
|
||||
|
||||
|
||||
def get_templates():
|
||||
from main import templates
|
||||
|
||||
return templates
|
||||
|
||||
|
||||
@router.get("/web")
|
||||
async def web_interface(request: Request):
|
||||
"""Web interface"""
|
||||
templates = get_templates()
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login_page(request: Request):
|
||||
"""Login/Register page"""
|
||||
templates = get_templates()
|
||||
return templates.TemplateResponse("login.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/watchlist")
|
||||
async def watchlist_redirect():
|
||||
"""Redirect /watchlist to web interface with watchlist hash"""
|
||||
return RedirectResponse("/web#watchlist")
|
||||
@@ -0,0 +1,459 @@
|
||||
"""
|
||||
Watchlist management routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
|
||||
from app.download_manager import DownloadManager
|
||||
from app.downloaders import get_downloader
|
||||
from app.models import DownloadRequest
|
||||
from app.models.auth import User
|
||||
from app.models.watchlist import (
|
||||
WatchlistItem,
|
||||
WatchlistItemCreate,
|
||||
WatchlistItemUpdate,
|
||||
WatchlistSettings,
|
||||
WatchlistStatus,
|
||||
)
|
||||
from app.routers.router_auth import get_current_user_from_token
|
||||
|
||||
router = APIRouter(prefix="/api/watchlist", tags=["watchlist"])
|
||||
|
||||
|
||||
def get_download_manager() -> DownloadManager:
|
||||
from main import download_manager
|
||||
|
||||
return download_manager
|
||||
|
||||
|
||||
@router.post("", response_model=WatchlistItem)
|
||||
async def add_to_watchlist(
|
||||
item_data: WatchlistItemCreate,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Add an anime to the watchlist"""
|
||||
from main import watchlist_manager
|
||||
|
||||
try:
|
||||
item = watchlist_manager.create(current_user.id, item_data)
|
||||
return item
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.error(f"Error adding to watchlist: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("", response_model=List[WatchlistItem])
|
||||
async def get_watchlist(
|
||||
status: Optional[WatchlistStatus] = None,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get user's watchlist"""
|
||||
from main import watchlist_manager
|
||||
|
||||
try:
|
||||
items = watchlist_manager.get_all(user_id=current_user.id, status=status)
|
||||
return items
|
||||
except Exception as e:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.error(f"Error getting watchlist: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/settings", response_model=WatchlistSettings)
|
||||
async def get_watchlist_settings(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get global watchlist settings"""
|
||||
from main import watchlist_manager
|
||||
|
||||
try:
|
||||
settings = watchlist_manager.get_settings()
|
||||
return settings
|
||||
except Exception as e:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.error(f"Error getting watchlist settings: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/settings", response_model=WatchlistSettings)
|
||||
async def update_watchlist_settings(
|
||||
settings: WatchlistSettings,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Update global watchlist settings"""
|
||||
from main import auto_download_scheduler, watchlist_manager
|
||||
|
||||
try:
|
||||
updated_settings = watchlist_manager.update_settings(settings)
|
||||
if auto_download_scheduler.is_running():
|
||||
auto_download_scheduler.restart()
|
||||
return updated_settings
|
||||
except Exception as e:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.error(f"Error updating watchlist settings: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_watchlist_stats(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get watchlist statistics"""
|
||||
from main import watchlist_manager
|
||||
|
||||
try:
|
||||
stats = watchlist_manager.get_stats(user_id=current_user.id)
|
||||
return stats
|
||||
except Exception as e:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.error(f"Error getting watchlist stats: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/check-all")
|
||||
async def check_all_watchlist_items(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Manually trigger a check for all due watchlist items"""
|
||||
from main import episode_checker, watchlist_manager
|
||||
|
||||
try:
|
||||
results = await episode_checker.check_all_due()
|
||||
user_results = []
|
||||
for result in results:
|
||||
item = watchlist_manager.get_by_id(result.watchlist_item_id)
|
||||
if item and item.user_id == current_user.id:
|
||||
user_results.append(result)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"checked": len(user_results),
|
||||
"total_new_episodes": sum(r.new_episodes_found for r in user_results),
|
||||
"total_downloaded": sum(len(r.episodes_downloaded) for r in user_results),
|
||||
"results": user_results,
|
||||
}
|
||||
except Exception as e:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.error(f"Error checking all watchlist items: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/scheduler/status")
|
||||
async def get_scheduler_status(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get auto-download scheduler status"""
|
||||
from main import auto_download_scheduler, watchlist_manager
|
||||
|
||||
try:
|
||||
return {
|
||||
"running": auto_download_scheduler.is_running(),
|
||||
"next_run": auto_download_scheduler.get_next_run_time(),
|
||||
"settings": watchlist_manager.get_settings(),
|
||||
}
|
||||
except Exception as e:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.error(f"Error getting scheduler status: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/scheduler/start")
|
||||
async def start_scheduler(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Start the auto-download scheduler"""
|
||||
from main import auto_download_scheduler
|
||||
|
||||
try:
|
||||
if auto_download_scheduler.is_running():
|
||||
return {
|
||||
"status": "already_running",
|
||||
"message": "Scheduler is already running",
|
||||
}
|
||||
auto_download_scheduler.start()
|
||||
return {"status": "started", "message": "Scheduler started successfully"}
|
||||
except Exception as e:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.error(f"Error starting scheduler: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/scheduler/stop")
|
||||
async def stop_scheduler(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Stop the auto-download scheduler"""
|
||||
from main import auto_download_scheduler
|
||||
|
||||
try:
|
||||
if not auto_download_scheduler.is_running():
|
||||
return {"status": "not_running", "message": "Scheduler is not running"}
|
||||
auto_download_scheduler.stop()
|
||||
return {"status": "stopped", "message": "Scheduler stopped successfully"}
|
||||
except Exception as e:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.error(f"Error stopping scheduler: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{item_id}", response_model=WatchlistItem)
|
||||
async def get_watchlist_item(
|
||||
item_id: str,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get a specific watchlist item"""
|
||||
from main import watchlist_manager
|
||||
|
||||
try:
|
||||
item = watchlist_manager.get_by_id(item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Watchlist item not found")
|
||||
if item.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return item
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.error(f"Error getting watchlist item: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{item_id}", response_model=WatchlistItem)
|
||||
async def update_watchlist_item(
|
||||
item_id: str,
|
||||
update_data: WatchlistItemUpdate,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Update a watchlist item"""
|
||||
from main import watchlist_manager
|
||||
|
||||
try:
|
||||
item = watchlist_manager.get_by_id(item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Watchlist item not found")
|
||||
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:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.error(f"Error updating watchlist item: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{item_id}")
|
||||
async def delete_watchlist_item(
|
||||
item_id: str,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Delete an anime from the watchlist"""
|
||||
from main import watchlist_manager
|
||||
|
||||
try:
|
||||
item = watchlist_manager.get_by_id(item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Watchlist item not found")
|
||||
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:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.error(f"Error deleting watchlist item: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{item_id}/check")
|
||||
async def check_watchlist_item(
|
||||
item_id: str,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Manually trigger a check for new episodes"""
|
||||
from main import episode_checker, watchlist_manager
|
||||
|
||||
try:
|
||||
item = watchlist_manager.get_by_id(item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Watchlist item not found")
|
||||
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:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.error(f"Error checking watchlist item: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{item_id}/download-all")
|
||||
async def download_all_episodes(
|
||||
item_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Download the LATEST SEASON episodes for a watchlist item"""
|
||||
from main import download_manager, watchlist_manager
|
||||
|
||||
try:
|
||||
item = watchlist_manager.get_by_id(item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Watchlist item not found")
|
||||
if item.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
downloader = get_downloader(item.anime_url)
|
||||
latest_season_url = item.anime_url
|
||||
|
||||
if hasattr(downloader, "get_seasons"):
|
||||
try:
|
||||
seasons = await downloader.get_seasons(item.anime_url)
|
||||
if seasons and len(seasons) > 0:
|
||||
latest_season = seasons[-1]
|
||||
latest_season_url = latest_season.get("url", item.anime_url)
|
||||
except Exception as e:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.warning(f"Could not fetch seasons, using default URL: {e}")
|
||||
|
||||
episodes = await downloader.get_episodes(latest_season_url, item.lang)
|
||||
|
||||
if not episodes:
|
||||
return {
|
||||
"status": "warning",
|
||||
"message": f"No episodes found for {item.anime_title}",
|
||||
"result": {"new_episodes_found": 0, "episodes_downloaded": []},
|
||||
}
|
||||
|
||||
task_ids = []
|
||||
season_match = re.search(r"saison(\d+)", latest_season_url, re.IGNORECASE)
|
||||
season_num = season_match.group(1) if season_match else "1"
|
||||
anime_title_clean = (
|
||||
item.anime_title.replace("/", "-").replace("\\", "-").strip()
|
||||
)
|
||||
|
||||
for episode in episodes:
|
||||
ep_num = episode.get("episode", "01")
|
||||
filename = f"{anime_title_clean} - S{season_num} - Episode {ep_num}.mp4"
|
||||
request = DownloadRequest(url=episode["url"], filename=filename)
|
||||
task = download_manager.create_task(request)
|
||||
task_ids.append(task.id)
|
||||
background_tasks.add_task(download_manager.start_download, task.id)
|
||||
|
||||
watchlist_manager.update(
|
||||
item_id,
|
||||
{"last_episode_downloaded": len(episodes), "total_episodes": len(episodes)},
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Downloading {len(task_ids)} episodes from latest season for {item.anime_title}",
|
||||
"task_ids": task_ids,
|
||||
"total_episodes": len(episodes),
|
||||
"season_url": latest_season_url,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.error(f"Error downloading all episodes: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{item_id}/pause", response_model=WatchlistItem)
|
||||
async def pause_watchlist_item(
|
||||
item_id: str,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Pause automatic downloading for a specific anime"""
|
||||
from main import watchlist_manager
|
||||
|
||||
try:
|
||||
item = watchlist_manager.get_by_id(item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Watchlist item not found")
|
||||
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:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.error(f"Error pausing watchlist item: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{item_id}/resume", response_model=WatchlistItem)
|
||||
async def resume_watchlist_item(
|
||||
item_id: str,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Resume automatic downloading for a paused anime"""
|
||||
from main import watchlist_manager
|
||||
|
||||
try:
|
||||
item = watchlist_manager.get_by_id(item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Watchlist item not found")
|
||||
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:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.error(f"Error resuming watchlist item: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
Reference in New Issue
Block a user