d4d8d8a3b6
- 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
520 lines
16 KiB
Python
520 lines
16 KiB
Python
"""
|
|
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)}")
|