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:
@@ -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