""" Anime and series search routes for Ohm Stream Downloader API. """ import json import re import time import logging import asyncio import hashlib from fastapi import ( APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response, ) from fastapi.templating import Jinja2Templates from sqlmodel import Session, select from app.database import get_session from app.models.settings import AppSettingsTable from app.routers.router_auth import get_current_user_from_token from app.models.auth import User from app.download_manager import DownloadManager from app.downloaders import ( AnimeSamaDownloader, AnimeUltimeDownloader, NekoSamaDownloader, VostfreeDownloader, ZoneTelechargementDownloader, get_downloader, ) from app.models import DownloadRequest from app.providers import get_anime_providers, get_series_providers from app.providers_manager import providers_manager from app.metadata_enrichment import get_metadata_enricher logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["anime"]) templates = Jinja2Templates(directory="templates") # Add custom filters to Jinja2 def hash_filter(s): return hashlib.md5(s.encode()).hexdigest()[:10] templates.env.filters["hash"] = hash_filter @router.get("/providers/health") async def get_providers_health(): """Get the current health status of all providers""" return providers_manager.get_all_status() @router.post("/providers/health/check") async def trigger_providers_health_check(background_tasks: BackgroundTasks): """Trigger a manual health check of all providers in the background""" from app.auto_download_scheduler import auto_download_scheduler background_tasks.add_task(auto_download_scheduler.trigger_health_check_now) return {"status": "Health check triggered in background"} 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( request: Request, q: str, lang: str = "vostfr", include_metadata: bool = False, html: bool = Query(False), current_user: User = Depends(get_current_user_from_token), session: Session = Depends(get_session), ): """ Search across all anime providers. Returns HTML for HTMX requests or if html=True parameter is set. """ print(f"\n[SEARCH] Starting search for '{q}'. html={html}") start_time = time.time() # Get user settings for disabled providers statement = select(AppSettingsTable).where( AppSettingsTable.user_id == current_user.id ) settings_obj = session.exec(statement).first() disabled_providers = settings_obj.disabled_providers if settings_obj else [] results = {} # 1. Prepare search tasks (Generic + Legacy) search_tasks = [] task_metadata = [] # Generic YAML providers active_generic = providers_manager.get_active_providers() for provider in active_generic: provider_id = getattr(provider, "id", None) if provider_id and provider_id not in disabled_providers: if hasattr(provider, "search"): search_tasks.append(provider.search(q)) task_metadata.append({"id": provider_id, "type": "generic"}) elif hasattr(provider, "search_anime"): search_tasks.append(provider.search_anime(q, lang)) task_metadata.append({"id": provider_id, "type": "legacy"}) # Legacy providers (already included in providers_manager, but keep for fallback) legacy_downloaders = { "anime-ultime": AnimeUltimeDownloader(), "neko-sama": NekoSamaDownloader(), "vostfree": VostfreeDownloader(), } for pid, dl in legacy_downloaders.items(): if pid not in disabled_providers and pid not in { getattr(p, "id", None) for p in active_generic }: search_tasks.append(dl.search_anime(q, lang)) task_metadata.append({"id": pid, "type": "legacy"}) # 2. Run searches in parallel all_raw_results = await asyncio.gather(*search_tasks, return_exceptions=True) # 3. Organize results by provider seen_urls = set() enricher = await get_metadata_enricher() enrichment_tasks = [] enrichment_mapping = [] for i, raw_result in enumerate(all_raw_results): provider_info = task_metadata[i] pid = provider_info["id"] if isinstance(raw_result, Exception): logger.error(f"Search failed for {pid}: {raw_result}") continue if not raw_result: continue if pid not in results: results[pid] = [] for item in raw_result: item_dict = item.model_dump() if hasattr(item, "model_dump") else item url = item_dict.get("url") if url and url not in seen_urls: seen_urls.add(url) if q.lower() in (item_dict.get("title") or "").lower(): item_dict["_relevance_boost"] = 1.0 else: item_dict["_relevance_boost"] = 0.5 results[pid].append(item_dict) # Prepare enrichment task for top 15 results per provider if len(results[pid]) <= 15: enrichment_tasks.append( enricher.enrich_metadata( item_dict.get("metadata", {}), item_dict.get("title", ""), url, ) ) enrichment_mapping.append((pid, len(results[pid]) - 1)) else: if "metadata" not in item_dict: item_dict["metadata"] = {} # 4. Perform parallel enrichment if enrichment_tasks: enriched_metas = await asyncio.gather(*enrichment_tasks, return_exceptions=True) for idx, (pid, pos) in enumerate(enrichment_mapping): if idx < len(enriched_metas): meta = enriched_metas[idx] if not isinstance(meta, Exception) and meta: results[pid][pos]["metadata"] = meta.model_dump() # 5. Sort results for pid in results: results[pid].sort(key=lambda x: -x.get("_relevance_boost", 0)) for item in results[pid]: item.pop("_relevance_boost", None) elapsed = time.time() - start_time total_found = sum(len(r) for r in results.values()) print( f"[SEARCH] Finished in {elapsed:.2f}s. Found {total_found} results. HX-Request={request.headers.get('HX-Request')}" ) # 6. Return HTML for HTMX or JSON for API if html or request.headers.get("HX-Request"): print("[SEARCH] Returning HTML response") return templates.TemplateResponse( "components/anime_search_results.html", {"request": request, "results": results, "settings": settings_obj}, ) print("[SEARCH] Returning JSON response") return { "query": q, "lang": lang, "include_metadata": include_metadata, "results": results, } @router.get("/series/search") async def search_series_unified( request: Request, q: str, lang: str = "vf", html: bool = Query(False), current_user: User = Depends(get_current_user_from_token), session: Session = Depends(get_session), ): """ Search across all TV series providers (FS7, etc.) Returns HTML for HTMX requests or if html=True parameter is set. """ import asyncio from app.downloaders.series_sites import FS7Downloader, ZoneTelechargementDownloader print(f"\n[SERIES SEARCH] Starting search for '{q}' in {lang}. html={html}") start_time = time.time() # Get user settings for disabled providers statement = select(AppSettingsTable).where( AppSettingsTable.user_id == current_user.id ) settings_obj = session.exec(statement).first() disabled_providers = settings_obj.disabled_providers if settings_obj else [] results = {} series_downloaders = { "fs7": FS7Downloader(), "zonetelechargement": ZoneTelechargementDownloader(), } search_tasks = [] provider_ids = [] for provider_id, provider in get_series_providers().items(): if provider_id in series_downloaders and provider_id not in disabled_providers: downloader = series_downloaders[provider_id] search_tasks.append(downloader.search_anime(q, lang)) provider_ids.append(provider_id) search_results = await asyncio.gather(*search_tasks, return_exceptions=True) # Enrich results with metadata (synopsis, rating, genres) enricher = await get_metadata_enricher() enrichment_tasks = [] enrichment_mapping = [] for provider_id, result in zip(provider_ids, search_results): if isinstance(result, Exception): print(f"[SERIES SEARCH] {provider_id} error: {str(result)}") logger.error(f"Series search error for {provider_id}: {result}") elif result: results[provider_id] = result print(f"[SERIES SEARCH] {provider_id}: Found {len(result)} results") # Prepare enrichment for top 15 results for idx, item in enumerate(result[:15]): if isinstance(item, dict): enrichment_tasks.append( enricher.enrich_metadata( item.get("metadata", {}), item.get("title", ""), item.get("url", ""), ) ) enrichment_mapping.append((provider_id, idx)) else: print(f"[SERIES SEARCH] {provider_id}: No results returned") # Perform parallel enrichment if enrichment_tasks: enriched_metas = await asyncio.gather(*enrichment_tasks, return_exceptions=True) for idx, (provider_id, pos) in enumerate(enrichment_mapping): if idx < len(enriched_metas): meta = enriched_metas[idx] if ( not isinstance(meta, Exception) and meta and provider_id in results and pos < len(results[provider_id]) ): results[provider_id][pos]["metadata"] = ( meta.model_dump() if hasattr(meta, "model_dump") else meta ) elapsed = time.time() - start_time total_found = sum(len(r) for r in results.values()) print( f"[SERIES SEARCH] Finished in {elapsed:.2f}s. Found {total_found} results. HX-Request={request.headers.get('HX-Request')}" ) # Return HTML for HTMX or JSON for API if html or request.headers.get("HX-Request"): return templates.TemplateResponse( "components/series_search_results.html", {"request": request, "results": results, "settings": settings_obj}, ) 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( request: Request, url: str, lang: str = "vostfr", html: bool = Query(False), ): """ Get list of episodes for an anime. Returns HTML for HTMX requests or JSON for API. """ downloader = get_downloader(url) episodes = await downloader.get_episodes(url, lang) if html or request.headers.get("HX-Request"): # Extract title from first episode or URL for the display anime_title = "Épisodes" if episodes and len(episodes) > 0: # Try to get a cleaner title from the first episode if available first_ep = episodes[0] if "|" in first_ep.get("url", ""): anime_title = first_ep.get("url").split("|")[-1].split(" - ")[0] return templates.TemplateResponse( "components/episode_list.html", { "request": request, "episodes": episodes, "anime_url": url, "anime_title": anime_title, "lang": 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()} @router.post("/anime/download") async def download_anime_episode( url: str, background_tasks: BackgroundTasks, response: Response, 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) # Add toast notification for HTMX response.headers["HX-Trigger"] = json.dumps( { "show-toast": { "message": f"Téléchargement lancé : {task.filename}", "type": "success", } } ) return {"task_id": task.id, "task": task} @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), } @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) return {"seasons": seasons or []} return {"seasons": [], "message": "Season info not available for this provider"} @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"]) return { "anime": anime_details, "alternatives": search_results[1:], "total_results": len(search_results), } finally: await fetcher.close() @router.post("/translate") async def translate_text(request: Request): """Translate text from English to French using Google Translate""" import httpx try: body = await request.json() text = body.get("text", "") if not text: raise HTTPException(status_code=400, detail="Text is required") 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[:5000], } response = await client.get(url, params=params) if response.status_code == 200: data = response.json() if data and data[0]: translated = "".join([item[0] for item in data[0] if item[0]]) return {"translatedText": translated, "status": "success"} raise HTTPException(status_code=500, detail="Translation failed") except Exception as e: raise HTTPException(status_code=500, detail=f"Translation error: {str(e)}")