"""Recherche multi-sources, fiches de titres et extraction de liens vidéo.""" import asyncio import dataclasses import logging from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query from app.routers.auth import CurrentUser, current_user from app.scrapers.base import ( ScrapeError, SourceScraper, VideoLink, all_sources, get_source, import_all_scrapers, resolve_hoster, ) from app.services.kitsu import KitsuService from app.services.settings import is_source_enabled logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["search"], dependencies=[Depends(current_user)]) import_all_scrapers() kitsu = KitsuService() # Types de médias gardés selon la préférence : les films restent avec les animés # (cinéma d'animation), une future source séries les exclurait donc du mode « séries ». _PREFERENCE_MEDIA_TYPES = { "anime": {"anime", "film"}, "serie": {"serie"}, "both": {"anime", "serie", "film"}, } def _allowed_media_types(preference: str) -> set[str]: return _PREFERENCE_MEDIA_TYPES.get(preference, _PREFERENCE_MEDIA_TYPES["both"]) async def enabled_sources(allowed: set[str] | None = None) -> list[SourceScraper]: """Sources activées, limitées à celles pouvant servir les types de médias autorisés.""" sources = [] for source in all_sources(): if not await is_source_enabled(source.name): continue if allowed is not None and not set(source.media_types) & allowed: continue sources.append(source) return sources sources = [] for source in all_sources(): if await is_source_enabled(source.name): sources.append(source) @router.get("/sources") async def list_sources() -> list[dict]: """Sources disponibles avec leur état d'activation.""" return [ { "name": source.name, "label": source.label, "base_url": source.base_url, "media_types": list(source.media_types), "enabled": await is_source_enabled(source.name), } for source in all_sources() ] @router.get("/search") async def search(q: Annotated[str, Query(min_length=2)], user: CurrentUser) -> dict: """Recherche unifiée : une requête interroge toutes les sources activées.""" allowed = _allowed_media_types(user.content_preference) sources = await enabled_sources(allowed) async def safe_search(source: SourceScraper) -> tuple[list, str | None]: try: results = [dataclasses.asdict(r) for r in await source.search(q)] return results, None except ScrapeError as exc: logger.error("Recherche échouée sur %s : %s", source.name, exc) return [], source.name outcomes = await asyncio.gather(*(safe_search(s) for s in sources)) results = [item for items, _ in outcomes for item in items] results = [r for r in results if r.get("media_type", "anime") in allowed] failed = [name for _, name in outcomes if name] return {"query": q, "count": len(results), "results": results, "failed_sources": failed} @router.get("/titles/{source}/{source_id:path}") async def title_details(source: str, source_id: str, enrich: bool = True) -> dict: """Fiche détaillée d'un titre (+ enrichissement Kitsu des champs manquants).""" scraper = get_source(source) try: details = await scraper.get_details(source_id) except ScrapeError as exc: raise HTTPException(502, detail=str(exc)) from exc if enrich: details = await kitsu.enrich(details) return dataclasses.asdict(details) @router.get("/episodes/{source}/{source_id:path}") async def list_episodes(source: str, source_id: str) -> dict: scraper = get_source(source) try: episodes = await scraper.list_episodes(source_id) except ScrapeError as exc: raise HTTPException(502, detail=str(exc)) from exc return {"episodes": [dataclasses.asdict(e) for e in episodes]} @router.get("/extract") async def extract(episode_url: Annotated[str, Query()]) -> dict: """Résout la chaîne complète : page d'épisode → embeds → URL vidéo directe.""" try: source = _find_source_for_url(episode_url) embeds = await source.extract_embed_links(episode_url) except ScrapeError as exc: raise HTTPException(502, detail=str(exc)) from exc links: list[dict] = [] errors: list[str] = [] for embed in embeds: extractor = resolve_hoster(embed) if extractor is None: errors.append(f"Hébergeur non supporté : {embed}") logger.warning("Aucun extracteur pour %s", embed) continue try: link: VideoLink = await extractor.extract(embed) data = dataclasses.asdict(link) data["embed_url"] = embed links.append(data) except ScrapeError as exc: errors.append(str(exc)) logger.error("Extraction échouée pour %s : %s", embed, exc) if not links and not embeds: raise HTTPException(502, detail="Aucun lecteur trouvé sur la page de l'épisode") return {"episode_url": episode_url, "links": links, "errors": errors} def _find_source_for_url(url: str) -> SourceScraper: for source in all_sources(): if source.base_url.split("//")[-1].split("/")[0] in url: return source raise ScrapeError(f"Aucune source ne correspond à l'URL : {url}")