Nouvelle version réécrite de zéro : recherche multi-sources (Vostfree, French-Manga), extraction 2 niveaux, proxy vidéo intégré, streaming/téléchargement HLS, métadonnées Kitsu, bibliothèque locale, comptes JWT + administration, découverte fusionnée, indexeur Torznab (Sonarr/Prowlarr).
132 lines
4.4 KiB
Python
132 lines
4.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 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()
|
|
|
|
|
|
async def enabled_sources() -> list[SourceScraper]:
|
|
sources = []
|
|
for source in all_sources():
|
|
if await is_source_enabled(source.name):
|
|
sources.append(source)
|
|
return sources
|
|
|
|
|
|
@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)]) -> dict:
|
|
"""Recherche unifiée : une requête interroge toutes les sources activées."""
|
|
sources = await enabled_sources()
|
|
|
|
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]
|
|
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}")
|