refactor: migrate main.py to modular routers and add project roadmap
CI / Test (Python 3.11) (push) Has been cancelled
CI / Test (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Summary (push) Has been cancelled

- 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:
root
2026-03-24 10:12:04 +00:00
parent 1b5d7f9238
commit d4d8d8a3b6
42 changed files with 4518 additions and 2426 deletions
+179 -17
View File
@@ -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
View File
@@ -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
+41
View File
@@ -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
+204 -68
View File
@@ -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)
+37
View File
@@ -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
View File
@@ -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"
}
}
+31
View File
@@ -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",
]
+519
View File
@@ -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)}")
+203
View File
@@ -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",
}
+151
View File
@@ -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()}
+119
View File
@@ -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
+238
View File
@@ -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,
},
)
+133
View File
@@ -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()
+55
View File
@@ -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(),
}
+253
View File
@@ -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))
+34
View File
@@ -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")
+459
View File
@@ -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))