Files
ohm_streaming/app/scrapers/base.py
T
Roman 41566ab5fb v0.1.0 — Réécriture complète de OhmStreaming
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).
2026-09-22 10:05:47 +00:00

193 lines
5.6 KiB
Python

"""Contrats et registres des scrapers.
Architecture à 2 registres :
- sources (sites de catalogues animes/séries) : recherche, détails, épisodes, liens embed
- hébergeurs vidéo : résolution d'une URL embed → URL directe du fichier
Ajouter une source = écrire une classe qui implémente le contrat et la décorer
@register_source / @register_hoster.
"""
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
class ScrapeError(Exception):
"""Échec de scraping/extraction — jamais silencieux, toujours journalisé et remonté."""
# ---------------------------------------------------------------- modèles
@dataclass
class SearchResult:
source: str
source_id: str # identifiant stable chez la source (slug, id…)
title: str
url: str
image_url: str | None = None
media_type: str = "anime" # anime | serie | film
@dataclass
class Episode:
number: float # 1, 2, 2.5 pour les OAV intermédiaires
title: str | None
url: str # page de l'épisode chez la source
season: int = 1
@dataclass
class TitleDetails:
source: str
source_id: str
title: str
url: str
synopsis: str | None = None
image_url: str | None = None
banner_url: str | None = None
genres: list[str] = field(default_factory=list)
rating: float | None = None
year: int | None = None
episode_count: int | None = None
episodes: list[Episode] = field(default_factory=list)
media_type: str = "anime"
@dataclass
class VideoLink:
"""Lien vidéo résolu (URL directe du fichier, lisible par un lecteur)."""
url: str
hoster: str
quality: str | None = None
headers: dict[str, str] = field(default_factory=dict) # ex. Referer obligatoire
is_hls: bool = False
# ---------------------------------------------------------------- contrats
class SourceScraper(ABC):
"""Contrat d'un site catalogue (animes, séries…)."""
name: str # identifiant unique, ex. "vostfree"
label: str # nom affiché
base_url: str
media_types: tuple[str, ...] = ("anime",)
@abstractmethod
async def search(self, query: str) -> list[SearchResult]: ...
@abstractmethod
async def get_details(self, source_id: str) -> TitleDetails: ...
@abstractmethod
async def list_episodes(self, source_id: str) -> list[Episode]: ...
@abstractmethod
async def extract_embed_links(self, episode_url: str) -> list[str]:
"""URLs des lecteurs embarqués trouvés sur la page d'un épisode."""
...
async def latest(self) -> list[SearchResult]:
"""Ajouts récents du catalogue (découverte). Vide si la source ne le supporte pas."""
return []
class HosterExtractor(ABC):
"""Contrat d'un hébergeur vidéo : embed URL → URL directe."""
name: str
domains: tuple[str, ...] = ()
def can_handle(self, url: str) -> bool:
return any(d in url for d in self.domains)
@abstractmethod
async def extract(self, embed_url: str) -> VideoLink: ...
# ---------------------------------------------------------------- registres
_source_registry: dict[str, type[SourceScraper]] = {}
_hoster_registry: list[type[HosterExtractor]] = []
def register_source(cls: type[SourceScraper]) -> type[SourceScraper]:
if cls.name in _source_registry:
raise ValueError(f"Source déjà enregistrée : {cls.name}")
_source_registry[cls.name] = cls
logger.debug("Source enregistrée : %s", cls.name)
return cls
def register_hoster(cls: type[HosterExtractor]) -> type[HosterExtractor]:
_hoster_registry.append(cls)
logger.debug("Hébergeur enregistré : %s", cls.name)
return cls
_source_instances: dict[str, SourceScraper] = {}
_hoster_instances: list[HosterExtractor] | None = None
def get_source(name: str) -> SourceScraper:
if name not in _source_instances:
cls = _source_registry.get(name)
if cls is None:
raise ScrapeError(f"Source inconnue : {name}")
_source_instances[name] = cls()
return _source_instances[name]
def all_sources() -> list[SourceScraper]:
return [get_source(name) for name in _source_registry]
def resolve_hoster(url: str) -> HosterExtractor | None:
"""Trouve l'extracteur capable de traiter une URL embed (None → générique)."""
global _hoster_instances
if _hoster_instances is None:
_hoster_instances = [cls() for cls in _hoster_registry]
for extractor in _hoster_instances:
if extractor.can_handle(url):
return extractor
return None
def import_all_scrapers() -> None:
"""Importe tous les modules pour déclencher les décorateurs d'enregistrement."""
import importlib
import pkgutil
import app.scrapers.hosters as hosters_pkg
import app.scrapers.sources as sources_pkg
for pkg in (sources_pkg, hosters_pkg):
for mod in pkgutil.iter_modules(pkg.__path__):
importlib.import_module(f"{pkg.__name__}.{mod.name}")
# ---------------------------------------------------------------- format interne
INTERNAL_SEP = "|"
def encode_internal_url(video_url: str, page_url: str, title: str) -> str:
"""Format interne `video_url|page_url|titre` pour transporter le contexte."""
for part in (video_url, page_url, title):
if INTERNAL_SEP in part:
raise ValueError(f"Caractère interdit '{INTERNAL_SEP}' dans : {part!r}")
return INTERNAL_SEP.join([video_url, page_url, title])
def decode_internal_url(value: str) -> tuple[str, str, str]:
parts = value.split(INTERNAL_SEP)
if len(parts) != 3 or not parts[0]:
raise ValueError(f"URL interne invalide : {value!r}")
return parts[0], parts[1], parts[2]