""" 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)}")