Files
ohm_streaming/app/routers/search.py
T
Roman 28f1bbde31 Préférence de contenus par compte : animés / séries / les deux
- Colonne users.content_preference (anime|serie|both, défaut both) avec
  migration automatique des bases existantes.
- GET /auth/me expose la préférence ; PUT /auth/preferences la met à
  jour (422 si valeur invalide).
- /api/search : filtre les résultats par media_type ET ignore les
  sources hors périmètre (une source anime-only n'est pas interrogée
  en mode séries). Les films restent associés aux animés.
- /api/discover : nouveautés filtrées selon la même règle.
- Sélecteur compact dans la topbar (⛩/📺/✨), persisté serveur.
- 7 tests dédiés (roundtrip, rejet invalide, filtrage search/discover,
  skip de sources) — 114 au total, ruff clean.

Prête pour la première source de séries : il ne reste qu'un module
SourceScraper avec media_types=("serie",) à écrire.
2026-09-23 15:02:59 +00:00

156 lines
5.4 KiB
Python

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