Compare commits
15
Commits
5fedc4f9d7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f79c0cc86e | ||
|
|
00aae109b0 | ||
|
|
c7e178eecf | ||
|
|
12bdc8a7ef | ||
|
|
eae97f9129 | ||
|
|
fed61adc56 | ||
|
|
d5fa546dd7 | ||
|
|
c45f1b79fd | ||
|
|
d747c574b8 | ||
|
|
248ec60d35 | ||
|
|
79a44297fd | ||
|
|
89d9a98f80 | ||
|
|
28f1bbde31 | ||
|
|
65761885ba | ||
|
|
7399f4b781 |
@@ -18,6 +18,8 @@ RUN uv sync --frozen --no-dev --no-cache
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM python:3.13-slim-bookworm
|
||||
|
||||
LABEL org.opencontainers.image.source="https://git.lanro.eu/Roman/ohm_streaming" \
|
||||
org.opencontainers.image.description="Ohm Stream Downloader — outil technique d'automatisation, ne fournit ni n'héberge aucun contenu. L'usage relève de la seule responsabilité de l'utilisateur (droit d'auteur)."
|
||||
# ffmpeg (téléchargements HLS), ca-certificates (scraping HTTPS),
|
||||
# gosu (bascule utilisateur non-root dans l'entrypoint)
|
||||
RUN apt-get update \
|
||||
|
||||
@@ -3,10 +3,34 @@
|
||||
Application web **auto-hébergée** (homelab) : centre de contrôle unique pour découvrir,
|
||||
regarder et télécharger des animes et séries VOSTFR/VF.
|
||||
|
||||
## ⚖️ Avertissement légal
|
||||
|
||||
Ohm Stream Downloader est un **outil technique d'automatisation** : il ne
|
||||
fournit, n'héberge ni ne distribue **aucun contenu**. Il automatise uniquement
|
||||
l'accès à des flux déjà publiquement accessibles sur des sites tiers, dont
|
||||
l'auteur n'est pas responsable et qu'il n'exploite pas.
|
||||
|
||||
L'utilisation de ce logiciel relève de la **seule responsabilité de
|
||||
l'utilisateur**, tenu de respecter la législation de son pays — notamment le
|
||||
droit d'auteur (en France : art. L.122-4 du Code de la propriété
|
||||
intellectuelle). Le téléchargement ou la diffusion d'œuvres protégées sans
|
||||
autorisation est interdit par la loi.
|
||||
|
||||
Projet publié à des fins **éducatives et de recherche**. L'auteur décline
|
||||
toute responsabilité en cas d'usage illégal et n'encourage en aucune façon le
|
||||
piratage.
|
||||
|
||||
> **Disclaimer (EN)**: this project is a technical automation tool. It does
|
||||
> not provide, host or distribute any content — it merely automates access to
|
||||
> streams already publicly available on third-party websites that the author
|
||||
> neither controls nor operates. Users are solely responsible for complying
|
||||
> with their local laws, including copyright. The author assumes no liability
|
||||
> for illegal use and does not endorse piracy.
|
||||
|
||||
## Déploiement (Docker) — recommandé
|
||||
|
||||
Le déploiement officiel passe par Docker Compose : l'image (ffmpeg inclus) est
|
||||
hébergée sur le **registre privé du Gitea** — rien n'est publié publiquement.
|
||||
hébergée publiquement sur **GHCR** (`ghcr.io/aulyriusohm/ohm-streaming`) — aucun compte requis.
|
||||
|
||||
### Installation guidée (recommandée)
|
||||
|
||||
@@ -18,14 +42,13 @@ git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming
|
||||
Le script vérifie Docker, demande la **destination des épisodes** (dossier dédié
|
||||
recommandé — voir « Bibliothèque Plex/Sonarr » ci-dessous), le port, génère les
|
||||
secrets, branche le montage et démarre. Non interactif aussi :
|
||||
`./scripts/install.sh --dir /srv/animes --port 8777 --skip-login`.
|
||||
`./scripts/install.sh --dir /srv/animes --port 8777`.
|
||||
|
||||
### À la main
|
||||
|
||||
```bash
|
||||
# 1. Récupérer le projet puis se connecter au registre privé (compte Gitea avec accès lecture)
|
||||
# 1. Récupérer le projet
|
||||
git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming
|
||||
docker login git.lanro.eu
|
||||
|
||||
# 2. Configuration locale
|
||||
cp .env.example .env
|
||||
@@ -36,7 +59,33 @@ docker compose up -d
|
||||
```
|
||||
|
||||
Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**.
|
||||
Les données (`data/`, `downloads/`) sont montées en volumes : elles survivent aux
|
||||
Les données (`data/`, `downloads/`) sont montées en volumes : elles survivent aux mises à jour.
|
||||
|
||||
### Variante Podman
|
||||
|
||||
Même déploiement via `docker-compose.podman.yml` (socket Podman pour Watchtower,
|
||||
au lieu du socket Docker) :
|
||||
|
||||
```bash
|
||||
# 0. Socket Podman (une seule fois) — rootless :
|
||||
systemctl --user enable --now podman.socket
|
||||
sudo loginctl enable-linger $USER # persister après déconnexion
|
||||
export PODMAN_SOCKET=/run/user/$(id -u)/podman/podman.sock
|
||||
# rootful : sudo systemctl enable --now podman.socket (aucun export nécessaire)
|
||||
|
||||
# 1. Récupérer le projet
|
||||
git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming
|
||||
|
||||
# 2. Configuration locale (identique à Docker)
|
||||
cp .env.example .env # → OHM_SECRET_KEY et WATCHTOWER_TOKEN obligatoires
|
||||
|
||||
# 3. Démarrage
|
||||
podman compose -f docker-compose.podman.yml up -d
|
||||
# (« podman compose » délègue à docker-compose ; sinon : podman-compose)
|
||||
```
|
||||
|
||||
La mise à jour depuis la page Admin (bouton Watchtower) fonctionne à l'identique ;
|
||||
à la main : `podman compose -f docker-compose.podman.yml pull && podman compose -f docker-compose.podman.yml up -d`.
|
||||
|
||||
|
||||
### Bibliothèque Plex / Sonarr existante
|
||||
@@ -111,24 +160,30 @@ Variables d'environnement (préfixe `OHM_`, voir `.env.example`) :
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- **Recherche unifiée** sur plusieurs sources (Vostfree, French-Manga) — chaque source
|
||||
- **Recherche unifiée** sur plusieurs sources (Vostfree, French-Manga, VoirAnime pour les
|
||||
animes ; French-Stream pour les séries et films VF/VOSTFR) — chaque source
|
||||
est un module interchangeable activable/désactivable à chaud (page Admin), dont l'URL
|
||||
est modifiable à la volée (utile si un site change de domaine).
|
||||
- **Extraction en 2 niveaux** : page d'épisode → lecteurs embarqués → URL directe
|
||||
(Sibnet, SendVid, VidMoly, Uqload, Vidzy, Luluvdo).
|
||||
(Sibnet, SendVid, VidMoly, Uqload, Vidzy, Luluvdo ; VoirAnime résout via son
|
||||
endpoint « prepare » : MP4 direct ou HLS relayé par le proxy du site).
|
||||
- **Préférence de contenus par compte** : sélecteur topbar ⛨ Animés / 📺 Séries /
|
||||
✨ Les deux — la recherche et les nouveautés filtrent par type de média, et les
|
||||
sources hors périmètre ne sont même pas interrogées.
|
||||
- **Proxy vidéo intégré** (`/api/proxy`) : contourne les protections (tokens liés à l'IP, Referer/UA obligatoires), réécrit les playlists HLS.
|
||||
- **Streaming HLS** via hls.js ; **téléchargement HLS** via ffmpeg (remux mp4, progression temps réel).
|
||||
- **Métadonnées enrichies** via Kitsu (synopsis, genres, note, images) avec cache 72 h.
|
||||
- **Téléchargements** : file parallèle, pause/reprise (HTTP Range), retry, anti-doublons,
|
||||
progression en temps réel (SSE), persistance au redémarrage.
|
||||
- **Bibliothèque locale** : streaming avec range requests, reprise de lecture,
|
||||
navigation épisode suivant/précédent.
|
||||
- **Comptes** : JWT court + refresh token (rotation), rôles admin/utilisateur,
|
||||
administration des comptes.
|
||||
- **Découverte** (`/discover`) : 🆕 nouveautés fusionnées de toutes les sources, triées
|
||||
par date de sortie réelle (enrichissement Kitsu, badge « en cours de diffusion »),
|
||||
🔥 incontournables (top popularité Kitsu) et ✨ recommandations par genres déduites
|
||||
des téléchargements et favoris, titres déjà possédés exclus (cache mémoire).
|
||||
- **Découverte** (`/discover`) : 🎭 exploration par genre (animés via Kitsu, séries/films
|
||||
via French-Stream — état dans l'URL, partageable), 🆕 nouveautés en rails séparés
|
||||
(animés triés par date de sortie via Kitsu ; séries & films de French-Stream), 🔥
|
||||
incontournables animés (top popularité Kitsu) et ✨ recommandations par genres déduites
|
||||
des téléchargements et favoris, titres déjà possédés exclus (cache mémoire). Sans
|
||||
Sonarr, les genres des séries/films téléchargés sont lus sur leur fiche source ;
|
||||
sans aucun historique, une carte d'amorçage invite à télécharger un premier titre.
|
||||
|
||||
|
||||
## Intégration Sonarr / Prowlarr (*arr)
|
||||
@@ -198,7 +253,7 @@ app/
|
||||
├── scrapers/
|
||||
│ ├── base.py # contrats SourceScraper/HosterExtractor + registres
|
||||
│ ├── configs/ # sélecteurs YAML externalisés (réparer sans coder)
|
||||
│ ├── sources/ # vostfree, french_manga
|
||||
│ ├── sources/ # vostfree, french_manga, voiranime, french_stream
|
||||
├── services/ # downloads, kitsu, discover, sonarr, torznab, settings
|
||||
└── templates/ + static/ # UI htmx + Alpine.js, thème sombre
|
||||
```
|
||||
@@ -209,6 +264,6 @@ app/
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
uv run pytest # 86 tests
|
||||
uv run pytest # 132 tests
|
||||
uv run ruff check . # lint
|
||||
```
|
||||
|
||||
+26
-4
@@ -25,6 +25,10 @@ class User:
|
||||
username: str
|
||||
is_admin: bool
|
||||
is_active: bool
|
||||
content_preference: str = "both" # anime | serie | both
|
||||
|
||||
|
||||
VALID_CONTENT_PREFERENCES = ("anime", "serie", "both")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- mots de passe
|
||||
@@ -56,7 +60,8 @@ async def create_user(username: str, password: str) -> User:
|
||||
|
||||
async def authenticate(username: str, password: str) -> User | None:
|
||||
row = await db.fetchone(
|
||||
"SELECT id, username, password_hash, is_admin, is_active FROM users WHERE username = ?",
|
||||
"SELECT id, username, password_hash, is_admin, is_active, content_preference "
|
||||
"FROM users WHERE username = ?",
|
||||
(username.strip(),),
|
||||
)
|
||||
if row is None or not verify_password(password, row["password_hash"]):
|
||||
@@ -66,18 +71,35 @@ async def authenticate(username: str, password: str) -> User | None:
|
||||
logger.warning("Compte désactivé : %r", username)
|
||||
return None
|
||||
return User(
|
||||
id=row["id"], username=row["username"], is_admin=bool(row["is_admin"]), is_active=True
|
||||
id=row["id"],
|
||||
username=row["username"],
|
||||
is_admin=bool(row["is_admin"]),
|
||||
is_active=True,
|
||||
content_preference=row["content_preference"],
|
||||
)
|
||||
|
||||
|
||||
async def get_user(user_id: int) -> User | None:
|
||||
row = await db.fetchone(
|
||||
"SELECT id, username, is_admin, is_active FROM users WHERE id = ?", (user_id,)
|
||||
"SELECT id, username, is_admin, is_active, content_preference FROM users WHERE id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
if row is None or not row["is_active"]:
|
||||
return None
|
||||
return User(
|
||||
id=row["id"], username=row["username"], is_admin=bool(row["is_admin"]), is_active=True
|
||||
id=row["id"],
|
||||
username=row["username"],
|
||||
is_admin=bool(row["is_admin"]),
|
||||
is_active=True,
|
||||
content_preference=row["content_preference"],
|
||||
)
|
||||
|
||||
|
||||
async def set_content_preference(user_id: int, preference: str) -> None:
|
||||
if preference not in VALID_CONTENT_PREFERENCES:
|
||||
raise ValueError(f"Préférence invalide : {preference!r}")
|
||||
await db.execute(
|
||||
"UPDATE users SET content_preference = ? WHERE id = ?", (preference, user_id)
|
||||
)
|
||||
|
||||
|
||||
|
||||
+4
-1
@@ -34,7 +34,10 @@ class Settings(BaseSettings):
|
||||
kitsu_base_url: str = "https://kitsu.io/api/edge"
|
||||
metadata_cache_ttl_hours: int = 72
|
||||
|
||||
# Mise à jour (déploiement Docker — Watchtower compagnon)
|
||||
# Mise à jour (dépôt public — lecture anonyme de l'API Gitea)
|
||||
gitea_url: str = "https://git.lanro.eu"
|
||||
gitea_repo: str = "Roman/ohm_streaming"
|
||||
# Déploiement Docker — Watchtower compagnon
|
||||
watchtower_url: str = ""
|
||||
watchtower_token: str = ""
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
content_preference TEXT NOT NULL DEFAULT 'both'
|
||||
CHECK (content_preference IN ('anime','serie','both')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
@@ -89,8 +91,18 @@ class Database:
|
||||
await self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
await self._conn.execute("PRAGMA foreign_keys=ON")
|
||||
await self._conn.executescript(SCHEMA)
|
||||
await self._migrate()
|
||||
await self._conn.commit()
|
||||
|
||||
async def _migrate(self) -> None:
|
||||
"""Migrations légères : colonnes ajoutées après coup pour les bases existantes."""
|
||||
cursor = await self._conn.execute("PRAGMA table_info(users)")
|
||||
columns = {row["name"] for row in await cursor.fetchall()}
|
||||
if "content_preference" not in columns:
|
||||
await self._conn.execute(
|
||||
"ALTER TABLE users ADD COLUMN content_preference TEXT NOT NULL DEFAULT 'both'"
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._conn is not None:
|
||||
await self._conn.close()
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
await db.connect()
|
||||
await apply_source_base_urls()
|
||||
await download_manager.start()
|
||||
warmup = asyncio.create_task(discover_service.latest())
|
||||
warmup = asyncio.create_task(discover_service.latest_by_type())
|
||||
warmup.add_done_callback(_log_task_error)
|
||||
logger.info("%s prêt", settings.app_name)
|
||||
yield
|
||||
|
||||
+1
-13
@@ -28,7 +28,6 @@ from app.services.settings import (
|
||||
set_source_base_url,
|
||||
set_source_enabled,
|
||||
set_source_health,
|
||||
set_update_config,
|
||||
)
|
||||
from app.services.sonarr import sonarr
|
||||
from app.services.update import UpdateError, fetch_latest_version, trigger_update
|
||||
@@ -222,23 +221,12 @@ async def test_sonarr(admin: AdminUser) -> dict:
|
||||
# ---------------------------------------------------------------- mise à jour logicielle
|
||||
|
||||
|
||||
class UpdateConfig(BaseModel):
|
||||
gitea_url: str
|
||||
repo: str
|
||||
token: str
|
||||
|
||||
|
||||
@router.get("/update")
|
||||
async def get_update(admin: AdminUser) -> dict:
|
||||
"""Version courante, dernière version disponible et configuration Gitea."""
|
||||
"""Version courante, dernière version disponible et patchnote."""
|
||||
return await update_status()
|
||||
|
||||
|
||||
@router.put("/update")
|
||||
async def save_update(payload: UpdateConfig, admin: AdminUser) -> dict:
|
||||
await set_update_config(payload.gitea_url, payload.repo, payload.token)
|
||||
return await update_status()
|
||||
|
||||
@router.post("/update/check")
|
||||
async def check_update(admin: AdminUser) -> dict:
|
||||
"""Force la re-vérification de la dernière version (ignore le cache)."""
|
||||
|
||||
+20
-2
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response, status
|
||||
from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app import auth
|
||||
@@ -107,4 +107,22 @@ async def logout(user: CurrentUser) -> RedirectResponse:
|
||||
|
||||
@router.get("/me")
|
||||
async def me(user: CurrentUser) -> dict:
|
||||
return {"id": user.id, "username": user.username, "is_admin": user.is_admin}
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"is_admin": user.is_admin,
|
||||
"content_preference": user.content_preference,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/preferences")
|
||||
async def update_preferences(
|
||||
user: CurrentUser,
|
||||
content_preference: Annotated[str, Body(embed=True)],
|
||||
) -> dict:
|
||||
"""Préférence de contenus : animés, séries, ou les deux."""
|
||||
try:
|
||||
await auth.set_content_preference(user.id, content_preference)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, detail=str(exc)) from exc
|
||||
return {"content_preference": content_preference}
|
||||
|
||||
+60
-5
@@ -1,4 +1,4 @@
|
||||
"""Découverte : nouveautés des sources, incontournables, recommandations."""
|
||||
"""Découverte : nouveautés par type, incontournables, recommandations, exploration."""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
@@ -6,7 +6,9 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.routers.auth import CurrentUser, current_user
|
||||
from app.services.discover import discover
|
||||
from app.routers.search import _allowed_media_types
|
||||
from app.scrapers.base import ScrapeError, get_source
|
||||
from app.services.discover import ANIME_GENRES, discover
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -20,8 +22,61 @@ async def get_discover(
|
||||
must_watch_limit: Annotated[int, Query(ge=1, le=20)] = 20,
|
||||
for_you_limit: Annotated[int, Query(ge=1, le=20)] = 20,
|
||||
) -> dict:
|
||||
"""Les trois sections de découverte en une requête (sections vides si source KO)."""
|
||||
"""Rails de découverte en une requête (sections vides si source KO).
|
||||
|
||||
« latest_anime » et « latest_serie » (séries + films réels) sont des rails
|
||||
indépendants, filtrés par la préférence de contenu du compte.
|
||||
"""
|
||||
allowed = _allowed_media_types(user.content_preference)
|
||||
rails = await discover.latest_by_type(latest_limit)
|
||||
latest_anime = rails["anime"] if "anime" in allowed else []
|
||||
latest_serie = (rails["serie"] + rails["film"]) if {"serie", "film"} & allowed else []
|
||||
# Incontournables et Pour toi sont issus de Kitsu (catalogue animés) :
|
||||
# en mode séries, elles n'ont pas de sens — on ne les calcule même pas.
|
||||
if "anime" in allowed:
|
||||
must_watch = await discover.must_watch(must_watch_limit)
|
||||
for_you = await discover.for_you(user.id, for_you_limit)
|
||||
latest = await discover.latest(latest_limit)
|
||||
return {"latest": latest, "must_watch": must_watch, "for_you": for_you}
|
||||
else:
|
||||
must_watch = []
|
||||
for_you = {"based_on": [], "items": []}
|
||||
return {
|
||||
"latest_anime": latest_anime,
|
||||
"latest_serie": latest_serie,
|
||||
"must_watch": must_watch,
|
||||
"for_you": for_you,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/discover/genres")
|
||||
async def get_genres(user: CurrentUser) -> dict:
|
||||
"""Catalogue des genres parcourables (page Explorer), selon la préférence."""
|
||||
allowed = _allowed_media_types(user.content_preference)
|
||||
catalog: dict[str, list[dict]] = {}
|
||||
if "anime" in allowed:
|
||||
catalog["anime"] = [{"key": key, "label": label} for key, label in ANIME_GENRES.items()]
|
||||
try:
|
||||
french_stream = get_source("french_stream")
|
||||
available = french_stream.browse_catalog()
|
||||
except ScrapeError:
|
||||
available = {}
|
||||
for kind in ("serie", "film"):
|
||||
if kind in allowed and kind in available:
|
||||
catalog[kind] = [
|
||||
{"key": key, "label": label} for key, label in available[kind].items()
|
||||
]
|
||||
return catalog
|
||||
|
||||
|
||||
@router.get("/discover/browse")
|
||||
async def get_browse(
|
||||
user: CurrentUser,
|
||||
type: Annotated[str, Query(pattern="^(anime|serie|film)$")],
|
||||
genre: Annotated[str, Query(min_length=1, max_length=40)],
|
||||
limit: Annotated[int, Query(ge=1, le=40)] = 24,
|
||||
) -> dict:
|
||||
"""Titres d'un genre : animés via Kitsu, séries/films via French-Stream."""
|
||||
if type not in _allowed_media_types(user.content_preference):
|
||||
return {"items": []} # hors préférence du compte
|
||||
if type == "anime":
|
||||
return {"items": await discover.browse_anime(genre, limit)}
|
||||
return {"items": await discover.browse_serie_film(type, genre, limit)}
|
||||
|
||||
@@ -12,6 +12,13 @@ protected = APIRouter(tags=["pages"], include_in_schema=False, dependencies=[Dep
|
||||
|
||||
templates = Jinja2Templates(directory=BASE_DIR / "app" / "templates")
|
||||
|
||||
# Cache-busting des assets : version dérivée de la date des fichiers statiques —
|
||||
# tout changement de CSS/JS invalide le cache navigateur sans intervention.
|
||||
_static_root = BASE_DIR / "app" / "static"
|
||||
templates.env.globals["asset_v"] = str(
|
||||
max(int(p.stat().st_mtime) for p in _static_root.rglob("*") if p.is_file())
|
||||
)
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request) -> HTMLResponse:
|
||||
|
||||
+29
-5
@@ -7,7 +7,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.routers.auth import current_user
|
||||
from app.routers.auth import CurrentUser, current_user
|
||||
from app.scrapers.base import (
|
||||
ScrapeError,
|
||||
SourceScraper,
|
||||
@@ -27,13 +27,35 @@ router = APIRouter(prefix="/api", tags=["search"], dependencies=[Depends(current
|
||||
import_all_scrapers()
|
||||
kitsu = KitsuService()
|
||||
|
||||
# Types de médias gardés selon la préférence. Les films réels (French-Stream)
|
||||
# accompagnent les séries — le mode animés reste sur l'animation.
|
||||
_PREFERENCE_MEDIA_TYPES = {
|
||||
"anime": {"anime"},
|
||||
"serie": {"serie", "film"},
|
||||
"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
|
||||
|
||||
|
||||
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")
|
||||
@@ -52,9 +74,10 @@ async def list_sources() -> list[dict]:
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def search(q: Annotated[str, Query(min_length=2)]) -> dict:
|
||||
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."""
|
||||
sources = await enabled_sources()
|
||||
allowed = _allowed_media_types(user.content_preference)
|
||||
sources = await enabled_sources(allowed)
|
||||
|
||||
async def safe_search(source: SourceScraper) -> tuple[list, str | None]:
|
||||
try:
|
||||
@@ -66,6 +89,7 @@ async def search(q: Annotated[str, Query(min_length=2)]) -> dict:
|
||||
|
||||
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}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Sélecteurs/endpoints French-Stream (french-stream.lat, DataLife Engine) — surchargeables
|
||||
# sans toucher au code. Structure vérifiée en live (films & séries VF/VOSTFR).
|
||||
|
||||
endpoints:
|
||||
search: "/engine/ajax/search.php" # POST query=<q>&page=1
|
||||
episodes: "/ep-data.php?id={newsid}&format=js" # JSON {vf, vostfr, vo, info}
|
||||
film: "/engine/ajax/film_api.php?id={newsid}" # JSON {players: {hoster: {version: url}}}
|
||||
|
||||
search:
|
||||
item: "div.search-item" # bloc résultat (lien dans onclick="location.href='...'")
|
||||
title: ".search-title"
|
||||
poster: ".search-poster img"
|
||||
|
||||
details:
|
||||
title: "h1#s-title"
|
||||
synopsis: "div.fdesc" # le boilerplate p.desc-text est retiré
|
||||
synopsis_boilerplate: "p.desc-text"
|
||||
genres: ".facts .genres"
|
||||
year: ".facts .release"
|
||||
poster_serie: ".fposter img"
|
||||
poster_film: "#film-data" # attribut data-affiche
|
||||
serie_marker: "#serie-config" # présent = fiche série (sinon film)
|
||||
|
||||
latest:
|
||||
path: "/series/" # mix films/séries ; « Saison » dans le titre = série
|
||||
item: "div.short"
|
||||
link: "a.short-poster" # href = fiche (/index.php?newsid=N), alt = titre
|
||||
image: "img"
|
||||
|
||||
browse: # parcours par genre (page Explorer) — chemins vérifiés en live
|
||||
serie: # pages /<genre>-series-/ (9 genres exposés par le site)
|
||||
aventure: {path: "/aventure-series-/", label: "Aventure"}
|
||||
familles: {path: "/familles-series-/", label: "Famille"}
|
||||
fantastique: {path: "/fantastique-series-/", label: "Fantastique"}
|
||||
judiciaire: {path: "/judiciare-series-/", label: "Judiciaire"} # coquille du site
|
||||
medical: {path: "/medical-series-/", label: "Médical"}
|
||||
romance: {path: "/romance-series-/", label: "Romance"}
|
||||
science-fiction: {path: "/science-fiction-series-/", label: "Science-Fiction"}
|
||||
thriller: {path: "/thriller-series-/", label: "Thriller"}
|
||||
western: {path: "/western-series-/", label: "Western"}
|
||||
film: # pages /films/<genre>/
|
||||
actions: {path: "/films/actions/", label: "Action"}
|
||||
animations: {path: "/films/animations/", label: "Animation"}
|
||||
aventures: {path: "/films/aventures/", label: "Aventure"}
|
||||
biopics: {path: "/films/biopics/", label: "Biopic"}
|
||||
comedies: {path: "/films/comedies/", label: "Comédie"}
|
||||
cultes: {path: "/films/cultes/", label: "Culte"}
|
||||
documentaires: {path: "/films/documentaires/", label: "Documentaire"}
|
||||
drames: {path: "/films/drames/", label: "Drame"}
|
||||
epouvante-horreurs: {path: "/films/epouvante-horreurs/", label: "Épouvante-Horreur"}
|
||||
espionnages: {path: "/films/espionnages/", label: "Espionnage"}
|
||||
familles: {path: "/films/familles/", label: "Famille"}
|
||||
fantastiques: {path: "/films/fantastiques/", label: "Fantastique"}
|
||||
guerres: {path: "/films/guerres/", label: "Guerre"}
|
||||
historiques: {path: "/films/historiques/", label: "Historique"}
|
||||
policiers: {path: "/films/policiers/", label: "Policier"}
|
||||
romances: {path: "/films/romances/", label: "Romance"}
|
||||
science-fictions: {path: "/films/science-fictions/", label: "Science-Fiction"}
|
||||
thrillers: {path: "/films/thrillers/", label: "Thriller"}
|
||||
westerns: {path: "/films/westerns/", label: "Western"}
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Extracteur Uqload (uqload.to/.co/.com/.io) — page embed → mp4/m3u8.
|
||||
"""Extracteur Uqload (uqload.to/.co/.com/.io/.vc) — page embed → mp4/m3u8.
|
||||
|
||||
La page embed contient un jwplayer configuré dans du JS packé
|
||||
(p,a,c,k,e,d) : `sources:[{file:"https://.../master.m3u8?..."}]`.
|
||||
@@ -24,7 +24,7 @@ _PATTERNS = (
|
||||
@register_hoster
|
||||
class UqloadExtractor(HosterExtractor):
|
||||
name = "uqload"
|
||||
domains = ("uqload.to", "uqload.co", "uqload.com", "uqload.io")
|
||||
domains = ("uqload.to", "uqload.co", "uqload.com", "uqload.io", "uqload.vc")
|
||||
|
||||
async def extract(self, embed_url: str) -> VideoLink:
|
||||
html = await fetch(embed_url)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Extracteur Vidzy (vidzy.org) — page embed videojs → m3u8.
|
||||
"""Extracteur Vidzy (vidzy.org/.cc/.live) — page embed videojs → m3u8.
|
||||
|
||||
La page embed ne contient pas l'URL vidéo en clair : le script videojs appelle
|
||||
une fonction de décodage inline `atob(s)` + reverse + XOR, avec une graine
|
||||
@@ -45,7 +45,7 @@ def _decode(b64: str, hostname: str) -> str | None:
|
||||
@register_hoster
|
||||
class VidzyExtractor(HosterExtractor):
|
||||
name = "vidzy"
|
||||
domains = ("vidzy.org",)
|
||||
domains = ("vidzy.org", "vidzy.cc", "vidzy.live")
|
||||
|
||||
async def extract(self, embed_url: str) -> VideoLink:
|
||||
html = await fetch(embed_url)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Extracteur VoirAnime — endpoint « prepare » → URL lisible (directe ou proxy site).
|
||||
|
||||
Chaque source d'un épisode voiranime.xyz se résout via
|
||||
`/lecteur/prepare/<source_id>?content=..&episode=..` (AJAX : en-têtes Accept JSON et
|
||||
X-Requested-With obligatoires) qui renvoie :
|
||||
`{"success": true, "media_type": "mp4"|"hls", "stream_url": "/proxy/media?payload=<b64>"}`.
|
||||
|
||||
Le payload base64 est un JSON `{"url": ..., "referer": ..., "kind": ...}` où `url` est
|
||||
l'URL directe chez l'hébergeur d'origine. Deux cas (vérifiés en live) :
|
||||
- mp4 : l'URL d'origine est libre d'accès avec son Referer (Sibnet…) → on la retourne
|
||||
directement (téléchargement/Range natifs, sans double saut).
|
||||
- hls : les CDN utilisés (SmoothPre & co) signent leurs playlists pour le backend du
|
||||
site — accès direct 403. Le proxy `/proxy/media?payload=…` du site, lui, sert la
|
||||
playlist ET ses segments (chemins re-hostés) → on retourne l'URL proxy absolue.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
|
||||
from app.scrapers.http import fetch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_hoster
|
||||
class VoirAnimeExtractor(HosterExtractor):
|
||||
name = "voiranime"
|
||||
domains = ("voiranime.xyz",)
|
||||
|
||||
async def extract(self, embed_url: str) -> VideoLink:
|
||||
origin = self._origin_of(embed_url)
|
||||
raw = await fetch(
|
||||
embed_url,
|
||||
referer=self._referer_for(embed_url),
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ScrapeError(f"voiranime : réponse prepare invalide : {raw[:120]}") from exc
|
||||
if not data.get("success"):
|
||||
raise ScrapeError(f"voiranime : {data.get('error') or 'échec de préparation du lecteur'}")
|
||||
|
||||
media_type = str(data.get("media_type") or "").lower()
|
||||
stream_url = str(data.get("stream_url") or "")
|
||||
|
||||
if media_type == "hls":
|
||||
if not stream_url:
|
||||
raise ScrapeError(f"voiranime : flux HLS sans stream_url dans {embed_url}")
|
||||
return VideoLink(
|
||||
url=f"{origin}/{stream_url.lstrip('/')}",
|
||||
hoster=self.name,
|
||||
headers={"Referer": f"{origin}/"},
|
||||
is_hls=True,
|
||||
)
|
||||
|
||||
payload = self._extract_payload(stream_url)
|
||||
url = str(payload.get("url") or "")
|
||||
if not url:
|
||||
raise ScrapeError(f"voiranime : payload sans URL de flux dans {embed_url}")
|
||||
referer = str(payload.get("referer") or "")
|
||||
return VideoLink(
|
||||
url=url,
|
||||
hoster=self.name,
|
||||
headers={"Referer": referer} if referer else {},
|
||||
is_hls=url.split("?")[0].endswith(".m3u8"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _origin_of(embed_url: str) -> str:
|
||||
parsed = urlparse(embed_url)
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
@staticmethod
|
||||
def _referer_for(embed_url: str) -> str:
|
||||
"""Reconstruit la page lecteur d'où provient la requête prepare."""
|
||||
origin = VoirAnimeExtractor._origin_of(embed_url)
|
||||
query = parse_qs(urlparse(embed_url).query)
|
||||
content = query.get("content", [""])[0]
|
||||
episode = query.get("episode", [""])[0]
|
||||
if content and episode:
|
||||
return f"{origin}/lecteur/{content}/{episode}"
|
||||
return f"{origin}/"
|
||||
|
||||
@staticmethod
|
||||
def _extract_payload(stream_url: str) -> dict:
|
||||
payload_b64 = parse_qs(urlparse(stream_url).query).get("payload", [""])[0]
|
||||
if not payload_b64:
|
||||
raise ScrapeError(f"voiranime : pas de payload dans stream_url ({stream_url[:80]})")
|
||||
padded = payload_b64 + "=" * (-len(payload_b64) % 4)
|
||||
try:
|
||||
decoded = base64.b64decode(padded)
|
||||
except (binascii.Error, ValueError):
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(padded)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise ScrapeError(f"voiranime : payload illisible : {payload_b64[:60]}") from exc
|
||||
try:
|
||||
data = json.loads(decoded)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
raise ScrapeError(f"voiranime : payload non-JSON : {payload_b64[:60]}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise ScrapeError("voiranime : payload inattendu (pas un objet JSON)")
|
||||
return data
|
||||
+18
-3
@@ -41,15 +41,30 @@ async def fetch(
|
||||
*,
|
||||
referer: str | None = None,
|
||||
retries: int = 2,
|
||||
headers: dict[str, str] | None = None,
|
||||
data: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""GET d'une page avec retries ; lève ScrapeError en cas d'échec définitif."""
|
||||
headers = {"Referer": referer} if referer else {}
|
||||
"""GET (ou POST si `data` est fourni) avec retries ; lève ScrapeError en cas d'échec définitif."""
|
||||
request_headers = dict(headers) if headers else {}
|
||||
if referer:
|
||||
request_headers.setdefault("Referer", referer)
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
response = await get_client().get(url, headers=headers)
|
||||
if data is None:
|
||||
response = await get_client().get(url, headers=request_headers)
|
||||
else:
|
||||
response = await get_client().post(url, headers=request_headers, data=data)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code
|
||||
if 400 <= status < 500 and status not in (408, 429):
|
||||
raise ScrapeError(f"Échec de récupération de {url} : HTTP {status} (définitif)") from exc
|
||||
last_error = exc
|
||||
logger.warning("fetch %s — HTTP %d, tentative %d/%d", url, status, attempt + 1, retries + 1)
|
||||
if attempt < retries:
|
||||
await asyncio.sleep(1.0 * (attempt + 1))
|
||||
except (httpx.HTTPError, httpx.InvalidURL) as exc:
|
||||
last_error = exc
|
||||
logger.warning("fetch %s — tentative %d/%d : %s", url, attempt + 1, retries + 1, exc)
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
"""Source French-Stream (french-stream.lat) — films & séries VF/VOSTFR, moteur DataLife Engine.
|
||||
|
||||
Faits structurels (vérifiés en live) :
|
||||
- Recherche : POST /engine/ajax/search.php (query, page) → blocs `div.search-item`
|
||||
(lien dans onclick="location.href='...'", poster `.search-poster img`).
|
||||
- Fiche : `/index.php?newsid=<id>` (ou la jolie URL `/<id>-<slug>.html`).
|
||||
`source_id` = newsid numérique.
|
||||
- Séries : une fiche par saison (« Titre - Saison N »). Épisodes via
|
||||
GET /ep-data.php?id=<newsid>&format=js → JSON
|
||||
`{"vf": {"1": {"vidzy": url, "uqload": url, ...}}, "vostfr": {...}, "info": {...}}`.
|
||||
- Films : lecteurs via GET /engine/ajax/film_api.php?id=<newsid> → JSON
|
||||
`{"players": {"vidzy": {"default": url, "vff": url, "vostfr": url, ...}}}`.
|
||||
- URL d'épisode interne : `<fiche>#vf-3` (série) ou `<fiche>#film` (film).
|
||||
- Nouveautés : /series/ → blocs `div.short` (mix films/séries, « Saison » dans le
|
||||
titre = série).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from copy import deepcopy
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
from app.scrapers.base import (
|
||||
Episode,
|
||||
ScrapeError,
|
||||
SearchResult,
|
||||
SourceScraper,
|
||||
TitleDetails,
|
||||
register_source,
|
||||
)
|
||||
from app.scrapers.config_loader import load_scraper_config
|
||||
from app.scrapers.http import fetch, fetch_soup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"endpoints": {
|
||||
"search": "/engine/ajax/search.php",
|
||||
"episodes": "/ep-data.php?id={newsid}&format=js",
|
||||
"film": "/engine/ajax/film_api.php?id={newsid}",
|
||||
},
|
||||
"search": {
|
||||
"item": "div.search-item",
|
||||
"title": ".search-title",
|
||||
"poster": ".search-poster img",
|
||||
},
|
||||
"details": {
|
||||
"title": "h1#s-title",
|
||||
"synopsis": "div.fdesc",
|
||||
"synopsis_boilerplate": "p.desc-text",
|
||||
"genres": ".facts .genres",
|
||||
"year": ".facts .release",
|
||||
"poster_serie": ".fposter img",
|
||||
"poster_film": "#film-data",
|
||||
"serie_marker": "#serie-config",
|
||||
},
|
||||
"latest": {
|
||||
"path": "/series/",
|
||||
"item": "div.short",
|
||||
"link": "a.short-poster",
|
||||
"image": "img",
|
||||
},
|
||||
# Parcours par genre (page Explorer) : type → clé → {path, label}.
|
||||
# Chemins vérifiés en live : films = /films/<genre>/, séries = /<genre>-series-/.
|
||||
"browse": {
|
||||
"serie": {
|
||||
"aventure": {"path": "/aventure-series-/", "label": "Aventure"},
|
||||
"familles": {"path": "/familles-series-/", "label": "Famille"},
|
||||
"fantastique": {"path": "/fantastique-series-/", "label": "Fantastique"},
|
||||
"judiciaire": {"path": "/judiciare-series-/", "label": "Judiciaire"}, # coquille du site
|
||||
"medical": {"path": "/medical-series-/", "label": "Médical"},
|
||||
"romance": {"path": "/romance-series-/", "label": "Romance"},
|
||||
"science-fiction": {"path": "/science-fiction-series-/", "label": "Science-Fiction"},
|
||||
"thriller": {"path": "/thriller-series-/", "label": "Thriller"},
|
||||
"western": {"path": "/western-series-/", "label": "Western"},
|
||||
},
|
||||
"film": {
|
||||
"actions": {"path": "/films/actions/", "label": "Action"},
|
||||
"animations": {"path": "/films/animations/", "label": "Animation"},
|
||||
"aventures": {"path": "/films/aventures/", "label": "Aventure"},
|
||||
"biopics": {"path": "/films/biopics/", "label": "Biopic"},
|
||||
"comedies": {"path": "/films/comedies/", "label": "Comédie"},
|
||||
"cultes": {"path": "/films/cultes/", "label": "Culte"},
|
||||
"documentaires": {"path": "/films/documentaires/", "label": "Documentaire"},
|
||||
"drames": {"path": "/films/drames/", "label": "Drame"},
|
||||
"epouvante-horreurs": {"path": "/films/epouvante-horreurs/", "label": "Épouvante-Horreur"},
|
||||
"espionnages": {"path": "/films/espionnages/", "label": "Espionnage"},
|
||||
"familles": {"path": "/films/familles/", "label": "Famille"},
|
||||
"fantastiques": {"path": "/films/fantastiques/", "label": "Fantastique"},
|
||||
"guerres": {"path": "/films/guerres/", "label": "Guerre"},
|
||||
"historiques": {"path": "/films/historiques/", "label": "Historique"},
|
||||
"policiers": {"path": "/films/policiers/", "label": "Policier"},
|
||||
"romances": {"path": "/films/romances/", "label": "Romance"},
|
||||
"science-fictions": {"path": "/films/science-fictions/", "label": "Science-Fiction"},
|
||||
"thrillers": {"path": "/films/thrillers/", "label": "Thriller"},
|
||||
"westerns": {"path": "/films/westerns/", "label": "Western"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_NEWSID_RE = re.compile(r"(?:newsid=|/)(\d+)(?:-[^/]*)?\.?html?$|(?:newsid=)(\d+)")
|
||||
_ONCLICK_URL_RE = re.compile(r"location\.href='([^']+)'")
|
||||
_SEASON_RE = re.compile(r"saison\s*:?\s*(\d+)", re.IGNORECASE)
|
||||
_YEAR_RE = re.compile(r"(\d{4})")
|
||||
_FRAGMENT_RE = re.compile(r"^(vf|vostfr|vo)-(\d+(?:\.\d+)?)$")
|
||||
_VERSION_ORDER = {"vf": 0, "vostfr": 1, "vo": 2}
|
||||
|
||||
|
||||
def _merged_config() -> dict:
|
||||
merged = deepcopy(DEFAULT_CONFIG)
|
||||
for key, values in load_scraper_config("french_stream").items():
|
||||
if isinstance(values, dict) and isinstance(merged.get(key), dict):
|
||||
merged[key].update(values)
|
||||
else:
|
||||
merged[key] = values
|
||||
return merged
|
||||
|
||||
|
||||
def _newsid_from_url(url: str) -> str | None:
|
||||
match = _NEWSID_RE.search(url)
|
||||
if not match:
|
||||
return None
|
||||
return match.group(1) or match.group(2)
|
||||
|
||||
|
||||
def _media_type(title: str, url: str) -> str:
|
||||
if _SEASON_RE.search(title) or "-saison-" in url:
|
||||
return "serie"
|
||||
return "film"
|
||||
|
||||
|
||||
@register_source
|
||||
class FrenchStreamScraper(SourceScraper):
|
||||
name = "french_stream"
|
||||
label = "French-Stream"
|
||||
base_url = "https://french-stream.lat"
|
||||
media_types = ("serie", "film")
|
||||
|
||||
# ------------------------------------------------------------- helpers
|
||||
|
||||
def _title_url(self, source_id: str) -> str:
|
||||
return f"{self.base_url}/index.php?newsid={source_id}"
|
||||
|
||||
@staticmethod
|
||||
def _text(element: Tag | None) -> str:
|
||||
return element.get_text(" ", strip=True) if element else ""
|
||||
|
||||
def _is_serie(self, soup: BeautifulSoup, title: str, url: str) -> bool:
|
||||
config = _merged_config()
|
||||
if soup.select_one(config["details"]["serie_marker"]):
|
||||
return True
|
||||
return _media_type(title, url) == "serie"
|
||||
|
||||
# ------------------------------------------------------------- search
|
||||
|
||||
async def search(self, query: str) -> list[SearchResult]:
|
||||
config = _merged_config()
|
||||
url = f"{self.base_url}{config['endpoints']['search']}"
|
||||
html = await fetch(
|
||||
url, referer=f"{self.base_url}/", data={"query": query, "page": "1"}
|
||||
)
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
results: list[SearchResult] = []
|
||||
for block in soup.select(config["search"]["item"]):
|
||||
onclick = block.get("onclick", "")
|
||||
url_match = _ONCLICK_URL_RE.search(onclick)
|
||||
if not url_match:
|
||||
logger.warning("french_stream : résultat sans lien, ignoré")
|
||||
continue
|
||||
href = url_match.group(1)
|
||||
source_id = _newsid_from_url(href)
|
||||
if not source_id:
|
||||
logger.warning("french_stream : newsid introuvable dans %s", href)
|
||||
continue
|
||||
title = self._text(block.select_one(config["search"]["title"]))
|
||||
image = block.select_one(config["search"]["poster"])
|
||||
results.append(
|
||||
SearchResult(
|
||||
source=self.name,
|
||||
source_id=source_id,
|
||||
title=title,
|
||||
url=f"{self.base_url}{href}" if href.startswith("/") else href,
|
||||
image_url=image.get("src") if image else None,
|
||||
media_type=_media_type(title, href),
|
||||
)
|
||||
)
|
||||
logger.info("french_stream : %d résultats pour %r", len(results), query)
|
||||
return results
|
||||
|
||||
# ------------------------------------------------------------- latest
|
||||
|
||||
async def latest(self) -> list[SearchResult]:
|
||||
"""Ajouts récents — page /series/ (mix films & séries)."""
|
||||
config = _merged_config()
|
||||
soup = await fetch_soup(f"{self.base_url}{config['latest']['path']}")
|
||||
results = self._short_blocks(soup)
|
||||
logger.info("french_stream : %d nouveautés récupérées", len(results))
|
||||
return results
|
||||
|
||||
async def browse(self, media_type: str, category: str) -> list[SearchResult]:
|
||||
"""Parcours par genre — pages /films/<genre>/ et /<genre>-series-/.
|
||||
|
||||
``media_type`` (« serie » | « film ») et ``category`` (clé du catalogue
|
||||
YAML, ex. « thriller »). Le type est forcé sur les résultats : une page
|
||||
genre séries ne liste que des séries, une page genre films que des films.
|
||||
"""
|
||||
catalog = _merged_config().get("browse", {}).get(media_type, {})
|
||||
entry = catalog.get(category)
|
||||
if entry is None:
|
||||
raise ScrapeError(f"Catégorie inconnue : {media_type}/{category}")
|
||||
soup = await fetch_soup(f"{self.base_url}{entry['path']}")
|
||||
results = self._short_blocks(soup, force_type=media_type)
|
||||
logger.info("french_stream : %d titres dans %s/%s", len(results), media_type, category)
|
||||
return results
|
||||
|
||||
def _short_blocks(self, soup: BeautifulSoup, force_type: str | None = None) -> list[SearchResult]:
|
||||
"""Bloc `div.short` → SearchResult (structure partagée nouveautés/genres)."""
|
||||
config = _merged_config()
|
||||
latest_cfg = config["latest"]
|
||||
results: list[SearchResult] = []
|
||||
for block in soup.select(latest_cfg["item"]):
|
||||
link = block.select_one(latest_cfg["link"])
|
||||
href = link.get("href") if link else None
|
||||
if not href:
|
||||
continue
|
||||
source_id = _newsid_from_url(href)
|
||||
if not source_id:
|
||||
logger.warning("french_stream : newsid introuvable dans %s", href)
|
||||
continue
|
||||
image = link.select_one(latest_cfg["image"])
|
||||
title = link.get("alt") or self._text(link)
|
||||
results.append(
|
||||
SearchResult(
|
||||
source=self.name,
|
||||
source_id=source_id,
|
||||
title=title,
|
||||
url=f"{self.base_url}{href}" if href.startswith("/") else href,
|
||||
image_url=image.get("src") if image else None,
|
||||
media_type=force_type or _media_type(title, href),
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
def browse_catalog(self) -> dict[str, dict[str, str]]:
|
||||
"""Catalogue des genres parcourables : ``{type: {clé: libellé}}``."""
|
||||
catalog = _merged_config().get("browse", {})
|
||||
return {
|
||||
media_type: {key: entry.get("label", key) for key, entry in genres.items()}
|
||||
for media_type, genres in catalog.items()
|
||||
}
|
||||
|
||||
|
||||
async def genres_of_page(self, page_url: str) -> list[str]:
|
||||
"""Genres (libellés FR) de la fiche pointée par l'URL d'un téléchargement.
|
||||
|
||||
Utilisé par les recommandations « Pour toi » pour les séries/films,
|
||||
absents de Kitsu (et donc sans signal de genre sans Sonarr).
|
||||
"""
|
||||
if self.base_url.split("//")[-1].split("/")[0] not in page_url:
|
||||
return [] # fiche d'une autre source (animé) : rien à lire ici
|
||||
source_id = _newsid_from_url(page_url)
|
||||
try:
|
||||
details = await self.get_details(source_id)
|
||||
except ScrapeError:
|
||||
return []
|
||||
return details.genres or []
|
||||
# ------------------------------------------------------------- details
|
||||
|
||||
async def get_details(self, source_id: str) -> TitleDetails:
|
||||
config = _merged_config()
|
||||
details_cfg = config["details"]
|
||||
url = self._title_url(source_id)
|
||||
soup = await fetch_soup(url)
|
||||
|
||||
title = " ".join(self._text(soup.select_one(details_cfg["title"])).split())
|
||||
if not title:
|
||||
raise ScrapeError(f"french_stream : fiche introuvable pour {source_id} ({url})")
|
||||
|
||||
synopsis_el = soup.select_one(details_cfg["synopsis"])
|
||||
if synopsis_el is not None:
|
||||
for boilerplate in synopsis_el.select(details_cfg["synopsis_boilerplate"]):
|
||||
boilerplate.decompose()
|
||||
synopsis = self._text(synopsis_el) or None
|
||||
|
||||
genres_el = soup.select_one(details_cfg["genres"])
|
||||
if genres_el and genres_el.select("a"):
|
||||
genres = [a.get_text(strip=True) for a in genres_el.select("a") if a.get_text(strip=True)]
|
||||
else:
|
||||
genres = [g.strip() for g in self._text(genres_el).split(",") if g.strip()]
|
||||
|
||||
year_match = _YEAR_RE.search(self._text(soup.select_one(details_cfg["year"])))
|
||||
if year_match is None:
|
||||
year_match = _YEAR_RE.search(title)
|
||||
|
||||
poster_el = soup.select_one(details_cfg["poster_serie"])
|
||||
poster = poster_el.get("src") if poster_el else None
|
||||
if not poster:
|
||||
film_data = soup.select_one(details_cfg["poster_film"])
|
||||
poster = film_data.get("data-affiche") if film_data else None
|
||||
|
||||
is_serie = self._is_serie(soup, title, url)
|
||||
episodes = await self._fetch_episodes(source_id, url, title) if is_serie else []
|
||||
|
||||
return TitleDetails(
|
||||
source=self.name,
|
||||
source_id=source_id,
|
||||
title=title,
|
||||
url=url,
|
||||
synopsis=synopsis,
|
||||
image_url=poster,
|
||||
genres=genres,
|
||||
year=int(year_match.group(1)) if year_match else None,
|
||||
episode_count=len(episodes) if is_serie else 1,
|
||||
episodes=episodes if is_serie else [self._film_episode(url)],
|
||||
media_type="serie" if is_serie else "film",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------ episodes
|
||||
|
||||
async def list_episodes(self, source_id: str) -> list[Episode]:
|
||||
url = self._title_url(source_id)
|
||||
soup = await fetch_soup(url)
|
||||
title = " ".join(
|
||||
self._text(soup.select_one(_merged_config()["details"]["title"])).split()
|
||||
)
|
||||
if not self._is_serie(soup, title, url):
|
||||
return [self._film_episode(url)]
|
||||
episodes = await self._fetch_episodes(source_id, url, title)
|
||||
if not episodes:
|
||||
raise ScrapeError(f"french_stream : aucun épisode trouvé pour {source_id} ({url})")
|
||||
return episodes
|
||||
|
||||
@staticmethod
|
||||
def _film_episode(page_url: str) -> Episode:
|
||||
return Episode(number=1, title="Film", url=f"{page_url}#film", season=1)
|
||||
|
||||
async def _fetch_episodes(
|
||||
self, source_id: str, page_url: str, title: str
|
||||
) -> list[Episode]:
|
||||
"""Épisodes d'une saison via l'API JSON du site (versions vf/vostfr/vo)."""
|
||||
config = _merged_config()
|
||||
endpoint = config["endpoints"]["episodes"].format(newsid=source_id)
|
||||
raw = await fetch(f"{self.base_url}{endpoint}", referer=page_url)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ScrapeError(
|
||||
f"french_stream : JSON d'épisodes invalide pour {source_id}"
|
||||
) from exc
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
|
||||
season_match = _SEASON_RE.search(title)
|
||||
season = int(season_match.group(1)) if season_match else 1
|
||||
info = data.get("info") if isinstance(data.get("info"), dict) else {}
|
||||
|
||||
episodes: list[Episode] = []
|
||||
for version, eps in data.items():
|
||||
if version == "info" or not isinstance(eps, dict):
|
||||
continue
|
||||
for number_key in eps:
|
||||
number_match = re.match(r"^(\d+(?:\.\d+)?)$", number_key)
|
||||
if not number_match:
|
||||
continue
|
||||
number = float(number_key)
|
||||
ep_info = info.get(number_key) or info.get(str(int(number))) or {}
|
||||
episodes.append(
|
||||
Episode(
|
||||
number=number,
|
||||
title=ep_info.get("title") if isinstance(ep_info, dict) else None,
|
||||
url=f"{page_url}#{version}-{number_key}",
|
||||
season=season,
|
||||
version=version,
|
||||
)
|
||||
)
|
||||
episodes.sort(
|
||||
key=lambda e: (e.number, _VERSION_ORDER.get(e.version or "", 99))
|
||||
)
|
||||
return episodes
|
||||
|
||||
# -------------------------------------------------------------- embeds
|
||||
|
||||
async def extract_embed_links(self, episode_url: str) -> list[str]:
|
||||
config = _merged_config()
|
||||
page_url, _, fragment = episode_url.partition("#")
|
||||
source_id = _newsid_from_url(page_url)
|
||||
if not source_id:
|
||||
raise ScrapeError(f"french_stream : newsid introuvable dans {episode_url}")
|
||||
|
||||
if fragment == "film":
|
||||
endpoint = config["endpoints"]["film"].format(newsid=source_id)
|
||||
raw = await fetch(f"{self.base_url}{endpoint}", referer=page_url)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ScrapeError(
|
||||
f"french_stream : JSON film invalide pour {source_id}"
|
||||
) from exc
|
||||
links: list[str] = []
|
||||
for hoster_urls in (data.get("players") or {}).values():
|
||||
if not isinstance(hoster_urls, dict):
|
||||
continue
|
||||
for embed in hoster_urls.values():
|
||||
if isinstance(embed, str) and embed.startswith("http") and embed not in links:
|
||||
links.append(embed)
|
||||
else:
|
||||
fragment_match = _FRAGMENT_RE.match(fragment)
|
||||
if not fragment_match:
|
||||
raise ScrapeError(
|
||||
f"french_stream : fragment d'épisode invalide dans {episode_url}"
|
||||
)
|
||||
version, number = fragment_match.group(1), fragment_match.group(2)
|
||||
endpoint = config["endpoints"]["episodes"].format(newsid=source_id)
|
||||
raw = await fetch(f"{self.base_url}{endpoint}", referer=page_url)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ScrapeError(
|
||||
f"french_stream : JSON d'épisodes invalide pour {source_id}"
|
||||
) from exc
|
||||
hosters = (data.get(version) or {}).get(number) or {}
|
||||
links = [u for u in hosters.values() if isinstance(u, str) and u.startswith("http")]
|
||||
|
||||
if not links:
|
||||
raise ScrapeError(f"french_stream : aucun lien embed extrait de {episode_url}")
|
||||
logger.info("french_stream : %d liens embed pour %s", len(links), episode_url)
|
||||
return links
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Source VoirAnime (voiranime.xyz) — plateforme VODSPHERE, animes VOSTFR.
|
||||
|
||||
Faits structurels (vérifiés en live) :
|
||||
- Recherche : GET /catalogue?q=<q> → `article.catalogue-card` (lien `a.catalogue-poster`,
|
||||
poster et titre dans `img[src]` / `img[alt]`).
|
||||
- Fiche : /catalogue/<slug> — titre `h1`, poster `img[alt^="Affiche de"]`, compteur
|
||||
d'épisodes dans `.media-detail-meta span` (« 1192 épisodes »), synopsis `.media-detail-story p`.
|
||||
- Épisodes : `a.detail-episode-card` avec `data-season` et libellé `<small>S1 · E1</small>`.
|
||||
- Lecteur : /lecteur/<content>/<episode> — attributs data-prepare-url (résolution AJAX)
|
||||
et data-fallback-source-url (source suivante) ; en fin de chaîne le fallback repointe
|
||||
vers une source déjà vue → dédoublonnage par id de source obligatoire.
|
||||
- Accueil : carrousels #carousel-new (nouveautés) et #carousel-added (ajouts), 24 cartes.
|
||||
- La résolution d'un lien direct se fait dans l'extracteur hoster dédié (voir
|
||||
app/scrapers/hosters/voiranime.py) : cette source ne renvoie que les URLs prepare.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from urllib.parse import quote_plus, urljoin
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
from app.scrapers.base import (
|
||||
Episode,
|
||||
ScrapeError,
|
||||
SearchResult,
|
||||
SourceScraper,
|
||||
TitleDetails,
|
||||
register_source,
|
||||
)
|
||||
from app.scrapers.config_loader import load_scraper_config
|
||||
from app.scrapers.http import fetch_soup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"search": {
|
||||
"endpoint": "/catalogue?q={query}",
|
||||
"result": "article.catalogue-card",
|
||||
"link": "a.catalogue-poster",
|
||||
},
|
||||
"details": {
|
||||
"title": "h1",
|
||||
"poster": 'img[alt^="Affiche de"]',
|
||||
"meta_count": ".media-detail-meta span",
|
||||
"synopsis": ".media-detail-story p",
|
||||
},
|
||||
"episodes": {
|
||||
"card": "a.detail-episode-card",
|
||||
},
|
||||
"player": {
|
||||
"root": "[data-prepare-url]",
|
||||
"prepare_attr": "data-prepare-url",
|
||||
"fallback_attr": "data-fallback-source-url",
|
||||
"max_sources": 4,
|
||||
},
|
||||
"latest": {
|
||||
"path": "/",
|
||||
"carousels": ("#carousel-new", "#carousel-added"),
|
||||
"item": "article.content-card",
|
||||
"link": "a.card-poster",
|
||||
},
|
||||
}
|
||||
|
||||
_SLUG_RE = re.compile(r"/catalogue/([a-z0-9-]+)", re.IGNORECASE)
|
||||
_EPISODE_LABEL_RE = re.compile(r"S\s*(\d+)\s*·\s*E\s*(\d+(?:[.,]\d+)?)", re.IGNORECASE)
|
||||
_EPISODE_COUNT_RE = re.compile(r"(\d+)\s*épisodes", re.IGNORECASE)
|
||||
_SOURCE_ID_RE = re.compile(r"/lecteur/prepare/(\d+)")
|
||||
|
||||
_FALLBACK_SOURCE_RE = re.compile(r"[?&]source=(\d+)")
|
||||
def _merged_config() -> dict:
|
||||
merged = deepcopy(DEFAULT_CONFIG)
|
||||
for key, values in load_scraper_config("voiranime").items():
|
||||
if isinstance(values, dict) and isinstance(merged.get(key), dict):
|
||||
merged[key].update(values)
|
||||
else:
|
||||
merged[key] = values
|
||||
return merged
|
||||
|
||||
|
||||
@register_source
|
||||
class VoirAnimeScraper(SourceScraper):
|
||||
name = "voiranime"
|
||||
label = "VoirAnime"
|
||||
base_url = "https://voiranime.xyz"
|
||||
media_types = ("anime",)
|
||||
|
||||
# ------------------------------------------------------------- helpers
|
||||
|
||||
def _title_url(self, source_id: str) -> str:
|
||||
return f"{self.base_url}/catalogue/{source_id}"
|
||||
|
||||
@staticmethod
|
||||
def _slug_from_url(url: str) -> str | None:
|
||||
match = _SLUG_RE.search(url)
|
||||
return match.group(1) if match else None
|
||||
|
||||
@staticmethod
|
||||
def _text(element: Tag | None) -> str:
|
||||
return element.get_text(" ", strip=True) if element else ""
|
||||
|
||||
def _result_from_card(self, link: Tag) -> SearchResult | None:
|
||||
href = link.get("href")
|
||||
if not href:
|
||||
return None
|
||||
source_id = self._slug_from_url(str(href))
|
||||
if not source_id:
|
||||
return None
|
||||
img = link.select_one("img")
|
||||
title = (img.get("alt") if img else None) or source_id.replace("-", " ")
|
||||
image = img.get("src") if img else None
|
||||
return SearchResult(
|
||||
source=self.name,
|
||||
source_id=source_id,
|
||||
title=str(title).strip(),
|
||||
url=self._title_url(source_id),
|
||||
image_url=str(image) if image else None,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------- search
|
||||
|
||||
async def search(self, query: str) -> list[SearchResult]:
|
||||
config = _merged_config()
|
||||
search_cfg = config["search"]
|
||||
url = self.base_url + search_cfg["endpoint"].format(query=quote_plus(query))
|
||||
soup = await fetch_soup(url)
|
||||
results: list[SearchResult] = []
|
||||
seen: set[str] = set()
|
||||
for card in soup.select(search_cfg["result"]):
|
||||
link = card.select_one(search_cfg["link"])
|
||||
if link is None:
|
||||
logger.warning("voiranime : carte de résultat sans lien, ignorée")
|
||||
continue
|
||||
result = self._result_from_card(link)
|
||||
if result and result.source_id not in seen:
|
||||
seen.add(result.source_id)
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
# ------------------------------------------------------------- détails
|
||||
|
||||
async def get_details(self, source_id: str) -> TitleDetails:
|
||||
config = _merged_config()
|
||||
details_cfg = config["details"]
|
||||
url = self._title_url(source_id)
|
||||
soup = await fetch_soup(url)
|
||||
|
||||
title = self._text(soup.select_one(details_cfg["title"])) or source_id.replace("-", " ")
|
||||
poster = soup.select_one(details_cfg["poster"])
|
||||
image = str(poster["src"]) if poster and poster.get("src") else None
|
||||
synopsis = self._text(soup.select_one(details_cfg["synopsis"])) or None
|
||||
|
||||
episode_count: int | None = None
|
||||
count_el = soup.select_one(details_cfg["meta_count"])
|
||||
if count_el:
|
||||
match = _EPISODE_COUNT_RE.search(self._text(count_el))
|
||||
if match:
|
||||
episode_count = int(match.group(1))
|
||||
|
||||
episodes = self._parse_episodes(soup, config)
|
||||
return TitleDetails(
|
||||
source=self.name,
|
||||
source_id=source_id,
|
||||
title=title,
|
||||
url=url,
|
||||
synopsis=synopsis,
|
||||
image_url=image,
|
||||
episode_count=episode_count if episode_count is not None else len(episodes),
|
||||
episodes=episodes,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------- épisodes
|
||||
|
||||
async def list_episodes(self, source_id: str) -> list[Episode]:
|
||||
config = _merged_config()
|
||||
soup = await fetch_soup(self._title_url(source_id))
|
||||
return self._parse_episodes(soup, config)
|
||||
|
||||
def _parse_episodes(self, soup: BeautifulSoup, config: dict) -> list[Episode]:
|
||||
episodes: list[Episode] = []
|
||||
for card in soup.select(config["episodes"]["card"]):
|
||||
href = card.get("href")
|
||||
if not href:
|
||||
continue
|
||||
label = self._text(card)
|
||||
match = _EPISODE_LABEL_RE.search(label)
|
||||
if not match:
|
||||
logger.warning("voiranime : libellé d'épisode illisible : %r", label[:60])
|
||||
continue
|
||||
episodes.append(
|
||||
Episode(
|
||||
number=float(match.group(2).replace(",", ".")),
|
||||
title=None,
|
||||
url=urljoin(self.base_url + "/", str(href)),
|
||||
season=int(match.group(1)),
|
||||
)
|
||||
)
|
||||
return episodes
|
||||
|
||||
# ------------------------------------------------------------- lecteurs
|
||||
|
||||
async def extract_embed_links(self, episode_url: str) -> list[str]:
|
||||
"""Parcourt la chaîne Lecteur 1 → 2 → … et renvoie les URLs prepare absolues."""
|
||||
config = _merged_config()
|
||||
player_cfg = config["player"]
|
||||
prepare_urls: list[str] = []
|
||||
seen_sources: set[str] = set()
|
||||
page_url = episode_url
|
||||
for _ in range(int(player_cfg["max_sources"])):
|
||||
soup = await fetch_soup(page_url)
|
||||
root = soup.select_one(player_cfg["root"])
|
||||
if root is None:
|
||||
break
|
||||
prepare = root.get(player_cfg["prepare_attr"])
|
||||
if not prepare:
|
||||
break
|
||||
prepare_url = urljoin(self.base_url + "/", str(prepare))
|
||||
match = _SOURCE_ID_RE.search(prepare_url)
|
||||
source_key = match.group(1) if match else prepare_url
|
||||
if source_key in seen_sources:
|
||||
break
|
||||
seen_sources.add(source_key)
|
||||
prepare_urls.append(prepare_url)
|
||||
fallback = root.get(player_cfg["fallback_attr"])
|
||||
if not fallback:
|
||||
break
|
||||
page_url = urljoin(self.base_url + "/", str(fallback))
|
||||
next_source = _FALLBACK_SOURCE_RE.search(page_url)
|
||||
if next_source and next_source.group(1) in seen_sources:
|
||||
break
|
||||
if not prepare_urls:
|
||||
raise ScrapeError(f"voiranime : aucun lecteur trouvé sur {episode_url}")
|
||||
return prepare_urls
|
||||
|
||||
# ------------------------------------------------------------- découverte
|
||||
|
||||
async def latest(self) -> list[SearchResult]:
|
||||
"""Nouveautés + ajouts récents — carrousels de la page d'accueil."""
|
||||
config = _merged_config()
|
||||
latest_cfg = config["latest"]
|
||||
soup = await fetch_soup(self.base_url + latest_cfg["path"])
|
||||
results: list[SearchResult] = []
|
||||
seen: set[str] = set()
|
||||
for carousel in latest_cfg["carousels"]:
|
||||
section = soup.select_one(carousel)
|
||||
if section is None:
|
||||
logger.debug("voiranime : carrousel %s absent de l'accueil", carousel)
|
||||
continue
|
||||
for card in section.select(latest_cfg["item"]):
|
||||
link = card.select_one(latest_cfg["link"])
|
||||
if link is None:
|
||||
continue
|
||||
result = self._result_from_card(link)
|
||||
if result and result.source_id not in seen:
|
||||
seen.add(result.source_id)
|
||||
results.append(result)
|
||||
return results
|
||||
+188
-27
@@ -30,6 +30,7 @@ from app.scrapers.base import (
|
||||
ScrapeError,
|
||||
SourceScraper,
|
||||
all_sources,
|
||||
get_source,
|
||||
import_all_scrapers,
|
||||
)
|
||||
from app.services.kitsu import KitsuService, normalize_title
|
||||
@@ -44,11 +45,56 @@ import_all_scrapers()
|
||||
_MAX_HISTORY_TITLES = 12 # titres récents analysés (téléchargements + favoris)
|
||||
_MAX_GENRES = 4 # genres retenus pour la requête Kitsu
|
||||
_KITSU_PAGE_MAX = 20 # limite dure de l'API Kitsu (page[limit] > 20 → 400)
|
||||
_ENRICH_CONCURRENCY = 6 # enrichissements Kitsu parallèles max (nouveautés)
|
||||
# (les TTL des sections suivent, plus bas)
|
||||
|
||||
# Genres animés proposés à l'exploration — slugs des catégories Kitsu officielles.
|
||||
ANIME_GENRES: dict[str, str] = {
|
||||
"action": "Action",
|
||||
"adventure": "Aventure",
|
||||
"comedy": "Comédie",
|
||||
"drama": "Drame",
|
||||
"fantasy": "Fantasy",
|
||||
"science-fiction": "Science-Fiction",
|
||||
"romance": "Romance",
|
||||
"slice-of-life": "Tranche de vie",
|
||||
"sports": "Sport",
|
||||
"supernatural": "Surnaturel",
|
||||
"mystery": "Mystère",
|
||||
"psychological": "Psychologique",
|
||||
"horror": "Horreur",
|
||||
"mecha": "Mecha",
|
||||
"isekai": "Isekai",
|
||||
"music": "Musique",
|
||||
}
|
||||
|
||||
# Genres des fiches séries/films (French-Stream, libellés FR, slugifiés) →
|
||||
# catégorie Kitsu équivalente. Les genres sans équivalent (Médical, Western…)
|
||||
# sont ignorés : mieux vaut un signal incomplet qu'une catégorie inexistante.
|
||||
_GENRE_FR_TO_KITSU: dict[str, str] = {
|
||||
"action": "Action",
|
||||
"aventure": "Adventure",
|
||||
"aventures": "Adventure",
|
||||
"comedie": "Comedy",
|
||||
"comedies": "Comedy",
|
||||
"drame": "Drama",
|
||||
"drames": "Drama",
|
||||
"epouvante-horreur": "Horror",
|
||||
"horreur": "Horror",
|
||||
"fantastique": "Fantasy",
|
||||
"fantastiques": "Fantasy",
|
||||
"romance": "Romance",
|
||||
"romances": "Romance",
|
||||
"science-fiction": "Science Fiction",
|
||||
"science-fictions": "Science Fiction",
|
||||
"surnaturel": "Supernatural",
|
||||
"thriller": "Thriller",
|
||||
"thrillers": "Thriller",
|
||||
}
|
||||
|
||||
_LATEST_TTL_SECONDS = 600 # nouveautés : re-scrape au bout de 10 min
|
||||
_MUST_WATCH_TTL_SECONDS = 21600 # incontournables : quasi statique, 6 h
|
||||
_FOR_YOU_TTL_SECONDS = 3600 # recommandations : 1 h (l'historique évolue lentement)
|
||||
_ENRICH_CONCURRENCY = 6 # enrichissements Kitsu parallèles max (nouveautés)
|
||||
|
||||
|
||||
class _TTLCache:
|
||||
@@ -92,14 +138,18 @@ class DiscoverService:
|
||||
|
||||
# ------------------------------------------------------------ nouveautés
|
||||
|
||||
async def latest(self, limit: int = 24) -> list[dict]:
|
||||
"""Nouveautés toutes sources confondues, triées par date de sortie réelle.
|
||||
async def latest_by_type(self, limit: int = 24) -> dict[str, list[dict]]:
|
||||
"""Nouveautés par type de média : ``{"anime": [...], "serie": [...], "film": [...]}``.
|
||||
|
||||
Les « récemment ajoutés » de chaque source sont fusionnés (doublons retirés),
|
||||
enrichis via Kitsu (date de début, statut de diffusion) puis triés du plus
|
||||
récent au plus ancien — ce qui sort / vient de sortir en premier.
|
||||
Les « récemment ajoutés » de chaque source activée sont fusionnés (doublons
|
||||
retirés) puis répartis en rails indépendants — chaque type garde sa place,
|
||||
aucun n'évince les autres. Le rail animés est enrichi via Kitsu (date de
|
||||
sortie, statut) et trié du plus récent au plus ancien ; les séries et films
|
||||
réels, absents de Kitsu, gardent l'ordre du site (déjà « récents d'abord »).
|
||||
Chaque rail est tronqué à ``limit``.
|
||||
"""
|
||||
cached = self._cache.get(f"latest:{limit}")
|
||||
cache_key = f"latest_by_type:{limit}"
|
||||
cached = self._cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached # type: ignore[return-value]
|
||||
|
||||
@@ -113,17 +163,28 @@ class DiscoverService:
|
||||
existing = merged.get(key)
|
||||
if existing is None or (not existing.get("image_url") and item.get("image_url")):
|
||||
merged[key] = item
|
||||
|
||||
by_type: dict[str, list[dict]] = {"anime": [], "serie": [], "film": []}
|
||||
for item in merged.values():
|
||||
by_type.setdefault(item.get("media_type", "anime"), []).append(item)
|
||||
|
||||
# Enrichissement Kitsu limité aux animés (seul catalogue couvert) —
|
||||
# inutile de bombarder l'API pour des titres qui n'y figurent pas.
|
||||
semaphore = asyncio.Semaphore(_ENRICH_CONCURRENCY)
|
||||
|
||||
async def bounded(item: dict) -> dict:
|
||||
async with semaphore:
|
||||
return await self._with_release_info(item)
|
||||
|
||||
enriched = await asyncio.gather(*(bounded(item) for item in merged.values()))
|
||||
result = sorted(enriched, key=lambda it: it.get("start_date") or "", reverse=True)
|
||||
result = result[:limit]
|
||||
self._cache.set(f"latest:{limit}", result, _LATEST_TTL_SECONDS)
|
||||
return result
|
||||
enriched = await asyncio.gather(*(bounded(item) for item in by_type["anime"]))
|
||||
by_type["anime"] = sorted(
|
||||
enriched, key=lambda it: it.get("start_date") or "", reverse=True
|
||||
)[:limit]
|
||||
for kind in ("serie", "film"):
|
||||
by_type[kind] = by_type[kind][:limit]
|
||||
|
||||
self._cache.set(cache_key, by_type, _LATEST_TTL_SECONDS)
|
||||
return by_type
|
||||
|
||||
async def _latest_of(self, source: SourceScraper) -> list[dict] | None:
|
||||
"""Items latest() d'une source, aplatis avec les infos de source ([] si KO)."""
|
||||
@@ -164,6 +225,46 @@ class DiscoverService:
|
||||
self._cache.set(key, items, _MUST_WATCH_TTL_SECONDS)
|
||||
return items
|
||||
|
||||
|
||||
async def browse_serie_film(self, media_type: str, genre: str, limit: int = 24) -> list[dict]:
|
||||
"""Séries/films d'un genre French-Stream, aplatis comme les nouveautés.
|
||||
|
||||
Liste vide si la catégorie est inconnue ou la source en échec.
|
||||
"""
|
||||
key = f"browse_fs:{media_type}:{genre}:{limit}"
|
||||
cached = self._cache.get(key)
|
||||
if cached is not None:
|
||||
return cached # type: ignore[return-value]
|
||||
try:
|
||||
scraper = get_source("french_stream")
|
||||
results = await scraper.browse(media_type, genre)
|
||||
except ScrapeError as exc:
|
||||
logger.warning("Parcours %s/%s indisponible : %s", media_type, genre, exc)
|
||||
return []
|
||||
items = [
|
||||
{**dataclasses.asdict(r), "source": scraper.name, "label": scraper.label}
|
||||
for r in results
|
||||
][:limit]
|
||||
self._cache.set(key, items, _LATEST_TTL_SECONDS)
|
||||
return items
|
||||
async def browse_anime(self, genre: str, limit: int = _KITSU_PAGE_MAX) -> list[dict]:
|
||||
"""Animés populaires d'une catégorie Kitsu (genre = slug du catalogue).
|
||||
|
||||
Liste vide si le genre est inconnu ou si Kitsu échoue (dégradation gracieuse).
|
||||
"""
|
||||
limit = min(limit, _KITSU_PAGE_MAX)
|
||||
if genre not in ANIME_GENRES:
|
||||
return []
|
||||
key = f"browse_anime:{genre}:{limit}"
|
||||
cached = self._cache.get(key)
|
||||
if cached is not None:
|
||||
return cached # type: ignore[return-value]
|
||||
items = await self._kitsu_anime(
|
||||
{"filter[categories]": genre, "sort": "-userCount", "page[limit]": limit}
|
||||
)
|
||||
self._cache.set(key, items, _FOR_YOU_TTL_SECONDS)
|
||||
return items
|
||||
|
||||
# ------------------------------------------------------------ pour toi
|
||||
|
||||
async def for_you(self, user_id: int, limit: int = _KITSU_PAGE_MAX) -> dict:
|
||||
@@ -179,49 +280,109 @@ class DiscoverService:
|
||||
if cached is not None:
|
||||
return cached # type: ignore[return-value]
|
||||
|
||||
owned, favorite_genres = await self._owned(user_id)
|
||||
owned, favorite_genres, series_pages = await self._owned(user_id)
|
||||
genre_counts = await self._genres_from_downloads(owned)
|
||||
for genre, count in favorite_genres.items():
|
||||
genre_counts[genre] = genre_counts.get(genre, 0) + count
|
||||
if series_pages:
|
||||
# Séries/films téléchargés : genres lus sur leur fiche source (sans Sonarr)
|
||||
for genre, count in (await self._genres_from_series_pages(series_pages)).items():
|
||||
genre_counts[genre] = genre_counts.get(genre, 0) + count
|
||||
sonarr_owned, sonarr_genres = await sonarr.profile()
|
||||
for genre, count in sonarr_genres.items():
|
||||
genre_counts[genre] = genre_counts.get(genre, 0) + count
|
||||
|
||||
if not (owned or favorite_genres or sonarr_owned):
|
||||
# Aucun historique : proposition d'amorçage plutôt qu'une section muette
|
||||
return {"based_on": [], "items": [], "cold_start": True}
|
||||
owned |= sonarr_owned
|
||||
|
||||
if not genre_counts:
|
||||
result: dict = {"based_on": [], "items": []}
|
||||
self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS)
|
||||
return result
|
||||
|
||||
top_genres = sorted(genre_counts, key=genre_counts.get, reverse=True)[:_MAX_GENRES]
|
||||
slugs = [category_slug(genre) for genre in top_genres]
|
||||
items = await self._kitsu_anime(
|
||||
{
|
||||
"filter[categories]": ",".join(slugs),
|
||||
"sort": "-userCount",
|
||||
"page[limit]": limit, # le déjà-possédé est filtré après
|
||||
}
|
||||
# Une requête par genre (Kitsu cumule les catégories en ET : quatre genres
|
||||
# ensemble ne laissent que quelques titres — parfois le déjà-possédé !).
|
||||
# Fusion entrelacée : chaque genre contribue, doublons retirés.
|
||||
pools = [
|
||||
list(pool)
|
||||
for pool in await asyncio.gather(*(
|
||||
self._kitsu_anime(
|
||||
{"filter[categories]": category_slug(g), "sort": "-userCount", "page[limit]": limit}
|
||||
)
|
||||
kept = [item for item in items if item["title"] and item["title"].casefold() not in owned]
|
||||
for g in top_genres
|
||||
))
|
||||
if pool
|
||||
]
|
||||
candidates: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
while pools:
|
||||
for pool in pools[:]:
|
||||
item = pool.pop(0)
|
||||
key = str(item.get("kitsu_id") or item.get("title", "").casefold())
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
candidates.append(item)
|
||||
if not pool:
|
||||
pools.remove(pool)
|
||||
kept = [
|
||||
item for item in candidates
|
||||
if item["title"] and normalize_title(item["title"]).casefold() not in owned
|
||||
][:limit]
|
||||
result = {"based_on": top_genres, "items": kept}
|
||||
self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS)
|
||||
return result
|
||||
|
||||
async def _genres_from_series_pages(self, pages: dict[str, str]) -> dict[str, int]:
|
||||
"""Genres Kitsu des séries/films téléchargés, lus sur leur fiche source.
|
||||
|
||||
Les libellés français des fiches sont convertis vers les catégories Kitsu
|
||||
(anglais) ; les genres sans équivalent sont ignorés.
|
||||
"""
|
||||
if not pages:
|
||||
return {}
|
||||
try:
|
||||
scraper = get_source("french_stream")
|
||||
except ScrapeError:
|
||||
return {}
|
||||
counts: dict[str, int] = {}
|
||||
for page_url in list(pages.values())[:_MAX_HISTORY_TITLES]:
|
||||
for genre_fr in await scraper.genres_of_page(page_url):
|
||||
canonical = _GENRE_FR_TO_KITSU.get(category_slug(genre_fr))
|
||||
if canonical:
|
||||
counts[canonical] = counts.get(canonical, 0) + 1
|
||||
return counts
|
||||
|
||||
def invalidate_for_you(self) -> None:
|
||||
"""Recommandations recalculées au prochain appel (réglages Sonarr modifiés)."""
|
||||
self._cache.clear("for_you:")
|
||||
|
||||
async def _owned(self, user_id: int) -> tuple[set[str], dict[str, int]]:
|
||||
"""Titres possédés (normalisés) + genres directement connus via les favoris."""
|
||||
async def _owned(self, user_id: int) -> tuple[set[str], dict[str, int], dict[str, str]]:
|
||||
"""Titres possédés (normalisés), genres des favoris, fiches des téléchargements.
|
||||
|
||||
``series_pages`` relie un titre possédé à l'URL de sa fiche chez la source
|
||||
(séries/films French-Stream) : sans Sonarr, c'est la seule source de genres.
|
||||
"""
|
||||
rows = await db.fetchall(
|
||||
"SELECT DISTINCT title FROM downloads ORDER BY created_at DESC LIMIT ?",
|
||||
"SELECT DISTINCT title, page_url FROM downloads ORDER BY created_at DESC LIMIT ?",
|
||||
(_MAX_HISTORY_TITLES,),
|
||||
)
|
||||
fav_rows = await db.fetchall(
|
||||
"SELECT payload FROM favorites WHERE user_id = ? ORDER BY created_at DESC LIMIT ?",
|
||||
(user_id, _MAX_HISTORY_TITLES),
|
||||
)
|
||||
owned = {normalize_title(row["title"]).casefold() for row in rows}
|
||||
owned.discard("")
|
||||
owned: set[str] = set()
|
||||
series_pages: dict[str, str] = {}
|
||||
for row in rows:
|
||||
key = normalize_title(row["title"]).casefold()
|
||||
if not key:
|
||||
continue
|
||||
owned.add(key)
|
||||
page_url = row["page_url"] or ""
|
||||
if page_url and key not in series_pages:
|
||||
series_pages[key] = page_url
|
||||
genre_counts: dict[str, int] = {}
|
||||
for row in fav_rows:
|
||||
try:
|
||||
@@ -231,7 +392,7 @@ class DiscoverService:
|
||||
for genre in payload.get("genres") or []:
|
||||
if isinstance(genre, str) and genre.strip():
|
||||
genre_counts[genre.strip()] = genre_counts.get(genre.strip(), 0) + 1
|
||||
return owned, genre_counts
|
||||
return owned, genre_counts, series_pages
|
||||
|
||||
async def _genres_from_downloads(self, titles: set[str]) -> dict[str, int]:
|
||||
"""Genres Kitsu des titres téléchargés (cache DB puis recherche)."""
|
||||
|
||||
@@ -533,6 +533,10 @@ class DownloadManager:
|
||||
)
|
||||
await self._emit(download_id)
|
||||
logger.info("Téléchargement terminé (id=%s, %d octets)", download_id, size)
|
||||
# L'historique a changé : les recommandations « Pour toi » se recalculent
|
||||
from app.services.discover import discover
|
||||
|
||||
discover.invalidate_for_you()
|
||||
|
||||
# ------------------------------------------------------------ helpers
|
||||
|
||||
|
||||
+14
-3
@@ -21,15 +21,26 @@ logger = logging.getLogger(__name__)
|
||||
_NOISE_WORDS_RE = re.compile(
|
||||
r"\b(?:VOSTFR\d*|VOST|VF[IV]?|TRUEFRENCH|FRENCH|MULTI|SUBFR?)\b", re.IGNORECASE
|
||||
)
|
||||
_TRAILING_SEASON_RE = re.compile(r"[\s\-–—:.]*\s*(?:saison|season)\s*\d+\s*$", re.IGNORECASE)
|
||||
_TRAILING_CODE_RE = re.compile(r"[\s\-–—:.]*\s*S\d+(?:E\d+)?\s*$", re.IGNORECASE)
|
||||
_TRAILING_SEASON_RE = re.compile(r"[\s\-–—:.]*\b(?:saison|season)\s*\d+\s*$", re.IGNORECASE)
|
||||
_TRAILING_CODE_RE = re.compile(r"[\s\-–—:.]*\bS\d+(?:\s*E\d+)?\s*$", re.IGNORECASE)
|
||||
_TRAILING_EPISODE_RE = re.compile(r"[\s\-–—:.]*\b(?:e|ep|episode)\s*\d+\s*$", re.IGNORECASE)
|
||||
|
||||
|
||||
def normalize_title(title: str) -> str:
|
||||
"""Nettoie un titre de scraping avant recherche Kitsu (bruit, saison, tirets)."""
|
||||
"""Nettoie un titre de scraping avant comparaison/recherche Kitsu.
|
||||
|
||||
Retire le bruit (VF/VOSTFR…), les marqueurs de saison/épisode en fin de titre
|
||||
(« - Saison 1 - E3 », « S1 E1 ») — appliqués en boucle : un titre canonique
|
||||
émergera identique d'un téléchargement (« Titre - Saison 1 - E3 ») ou d'une
|
||||
fiche Kitsu (« Titre »), condition de l'exclusion du déjà-possédé.
|
||||
"""
|
||||
cleaned = _NOISE_WORDS_RE.sub(" ", title)
|
||||
previous = None
|
||||
while previous != cleaned:
|
||||
previous = cleaned
|
||||
cleaned = _TRAILING_SEASON_RE.sub("", cleaned)
|
||||
cleaned = _TRAILING_CODE_RE.sub("", cleaned)
|
||||
cleaned = _TRAILING_EPISODE_RE.sub("", cleaned)
|
||||
cleaned = re.sub(r"\s*[-–—_]+\s*", " ", cleaned)
|
||||
cleaned = re.sub(r"\s+", " ", cleaned)
|
||||
return cleaned.strip(" -–—:.")
|
||||
|
||||
@@ -114,23 +114,3 @@ async def set_sonarr_config(url: str, apikey: str) -> None:
|
||||
await set_setting(SONARR_APIKEY_KEY, apikey.strip())
|
||||
logger.info("Configuration Sonarr enregistrée (%s)", url)
|
||||
|
||||
# ---------------------------------------------------------------- mise à jour logicielle
|
||||
|
||||
UPDATE_GITEA_URL_KEY = "update:gitea_url"
|
||||
UPDATE_REPO_KEY = "update:repo"
|
||||
UPDATE_TOKEN_KEY = "update:token"
|
||||
|
||||
|
||||
async def get_update_config() -> dict[str, str]:
|
||||
return {
|
||||
"gitea_url": await get_setting(UPDATE_GITEA_URL_KEY, "https://git.lanro.eu"),
|
||||
"repo": await get_setting(UPDATE_REPO_KEY, "Roman/ohm_streaming"),
|
||||
"token": await get_setting(UPDATE_TOKEN_KEY, ""),
|
||||
}
|
||||
|
||||
|
||||
async def set_update_config(gitea_url: str, repo: str, token: str) -> None:
|
||||
await set_setting(UPDATE_GITEA_URL_KEY, gitea_url.rstrip("/"))
|
||||
await set_setting(UPDATE_REPO_KEY, repo.strip().strip("/"))
|
||||
await set_setting(UPDATE_TOKEN_KEY, token.strip())
|
||||
logger.info("Configuration de mise à jour enregistrée (%s)", repo)
|
||||
|
||||
+18
-21
@@ -1,6 +1,7 @@
|
||||
"""Mises à jour logicielles.
|
||||
|
||||
- Détection : dernier tag semver du dépôt Gitea via son API (jeton requis, repo privé).
|
||||
- Détection : dernier tag semver du dépôt Gitea public via son API (lecture anonyme),
|
||||
avec le message du tag comme patchnote.
|
||||
- Application : POST à Watchtower (compagnon docker-compose) qui tire la nouvelle
|
||||
image et recrée le conteneur — quelques secondes d'indisponibilité.
|
||||
"""
|
||||
@@ -12,7 +13,6 @@ import time
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from app.services.settings import get_update_config
|
||||
from app.version import get_version
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -20,11 +20,11 @@ logger = logging.getLogger(__name__)
|
||||
_TAG_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
|
||||
_CACHE_TTL = 300.0 # secondes
|
||||
|
||||
_cache: dict[str, object] = {"checked_at": 0.0, "latest": None}
|
||||
_cache: dict[str, object] = {"checked_at": 0.0, "latest": None, "notes": None}
|
||||
|
||||
|
||||
class UpdateError(Exception):
|
||||
"""Erreur de mise à jour (config absente, Gitea injoignable, Watchtower KO)."""
|
||||
"""Erreur de mise à jour (Gitea injoignable, Watchtower KO)."""
|
||||
|
||||
|
||||
def parse_tag(tag: str) -> tuple[int, int, int] | None:
|
||||
@@ -41,51 +41,48 @@ def is_newer(latest: str, current: str) -> bool:
|
||||
|
||||
|
||||
async def fetch_latest_version(*, force: bool = False) -> str | None:
|
||||
"""Dernier tag semver du dépôt (cache 5 min). None si non configuré ou erreur."""
|
||||
"""Dernier tag semver du dépôt + patchnote (cache 5 min). None si erreur."""
|
||||
now = time.monotonic()
|
||||
latest_cache = _cache["latest"]
|
||||
if not force and latest_cache and now - float(_cache["checked_at"]) < _CACHE_TTL:
|
||||
return str(latest_cache) # type: ignore[arg-type]
|
||||
|
||||
config = await get_update_config()
|
||||
if not config["repo"]:
|
||||
return None
|
||||
|
||||
url = f"{config['gitea_url']}/api/v1/repos/{config['repo']}/tags?limit=20"
|
||||
# Jeton requis uniquement pour un dépôt privé (public en lecture : inutile)
|
||||
headers = {"Authorization": f"token {config['token']}"} if config["token"] else {}
|
||||
settings = get_settings()
|
||||
url = f"{settings.gitea_url}/api/v1/repos/{settings.gitea_repo}/tags?limit=20"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
tags = [t["name"] for t in resp.json() if parse_tag(t.get("name", ""))]
|
||||
tags = [
|
||||
(t["name"], (t.get("message") or "").strip())
|
||||
for t in resp.json()
|
||||
if parse_tag(t.get("name", ""))
|
||||
]
|
||||
except (httpx.HTTPError, ValueError, KeyError) as exc:
|
||||
logger.warning("Vérification de mise à jour impossible : %s", exc)
|
||||
return None
|
||||
|
||||
latest = max(tags, key=parse_tag) if tags else None # type: ignore[arg-type]
|
||||
_cache.update(checked_at=now, latest=latest)
|
||||
latest, notes = max(tags, key=lambda t: parse_tag(t[0])) if tags else (None, None)
|
||||
_cache.update(checked_at=now, latest=latest, notes=notes or None)
|
||||
if latest and is_newer(latest, get_version()):
|
||||
logger.info("Nouvelle version disponible : %s (courante %s)", latest, get_version())
|
||||
return latest
|
||||
|
||||
|
||||
def invalidate_cache() -> None:
|
||||
_cache.update(checked_at=0.0, latest=None)
|
||||
_cache.update(checked_at=0.0, latest=None, notes=None)
|
||||
|
||||
|
||||
async def status() -> dict[str, object]:
|
||||
"""État complet : version courante, dernière dispo, config."""
|
||||
config = await get_update_config()
|
||||
"""État complet : version courante, dernière dispo, patchnote."""
|
||||
current = get_version()
|
||||
latest = await fetch_latest_version()
|
||||
return {
|
||||
"current": current,
|
||||
"latest": latest,
|
||||
"notes": _cache["notes"],
|
||||
"update_available": bool(latest and is_newer(latest, current)),
|
||||
"configured": bool(config["repo"]),
|
||||
"docker": bool(get_settings().watchtower_url),
|
||||
"config": config,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -89,6 +89,14 @@ button { font-family: inherit; }
|
||||
}
|
||||
.icon-btn:hover { color: var(--text); background: var(--surface-2); }
|
||||
|
||||
.pref-select {
|
||||
background: var(--surface); color: var(--text-dim);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
font: inherit; font-size: 0.85rem;
|
||||
padding: 0.35rem 0.5rem; cursor: pointer;
|
||||
}
|
||||
.pref-select:hover { color: var(--text); border-color: var(--accent); }
|
||||
|
||||
/* ------------------------------------------------------------ layout */
|
||||
|
||||
.main { padding: 2rem clamp(1rem, 3.5vw, 4rem) 4rem; }
|
||||
@@ -544,6 +552,52 @@ button { font-family: inherit; }
|
||||
|
||||
.rail-section { margin-bottom: 2.4rem; animation: rise 0.5s ease backwards; }
|
||||
|
||||
/* ------------------------------------------------------------- Explorer */
|
||||
|
||||
.explore-section { animation-delay: 0s; }
|
||||
.explore-filters { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
.chip-row { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
.genre-chip {
|
||||
background: var(--surface); color: var(--text-dim);
|
||||
border: 1px solid var(--border); border-radius: 99px;
|
||||
font: inherit; font-size: 0.82rem; font-weight: 600;
|
||||
padding: 0.32rem 0.85rem; cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.genre-chip:hover { color: var(--text); border-color: var(--accent); }
|
||||
.genre-chip.active {
|
||||
background: var(--accent); border-color: var(--accent); color: #fff;
|
||||
}
|
||||
.explore-skel .rail-skel { margin-top: 0.2rem; }
|
||||
|
||||
/* CTA d'amorçage « Pour toi » (aucun historique) */
|
||||
.cta-card {
|
||||
display: flex; align-items: center; gap: 1.6rem;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 14px; padding: 1.6rem 1.8rem;
|
||||
}
|
||||
.cta-emoji { font-size: 2.6rem; }
|
||||
.cta-body { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.cta-title { font-weight: 700; font-size: 1.05rem; }
|
||||
.cta-body p { margin: 0; color: var(--text-dim); font-size: 0.92rem; }
|
||||
.cta-actions { display: flex; gap: 0.6rem; margin-top: 0.6rem; }
|
||||
.cta-btn {
|
||||
background: var(--accent); color: #fff; border: none; border-radius: 8px;
|
||||
font: inherit; font-size: 0.88rem; font-weight: 600;
|
||||
padding: 0.5rem 1rem; cursor: pointer; text-decoration: none;
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
.cta-btn:hover { filter: brightness(1.12); }
|
||||
.cta-btn.ghost {
|
||||
background: transparent; color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.cta-btn.ghost:hover { border-color: var(--accent); color: var(--accent); filter: none; }
|
||||
@media (max-width: 640px) {
|
||||
.cta-card { flex-direction: column; text-align: center; }
|
||||
.cta-actions { justify-content: center; flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
.rail-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
|
||||
+11
-26
@@ -111,28 +111,23 @@
|
||||
</p>
|
||||
|
||||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
|
||||
<input class="input" x-model="upd.config.gitea_url" placeholder="https://git.lanro.eu"
|
||||
style="flex:2;min-width:220px" @change="upd._dirty = true">
|
||||
<input class="input" x-model="upd.config.repo" placeholder="Roman/ohm_streaming"
|
||||
style="flex:1;min-width:180px" @change="upd._dirty = true">
|
||||
<input class="input" x-model="upd.config.token" type="password" placeholder="Jeton d'accès Gitea"
|
||||
style="flex:1;min-width:180px" @change="upd._dirty = true">
|
||||
<button class="btn btn-sm" @click="saveUpdate()" :disabled="!upd._dirty">Enregistrer</button>
|
||||
<button class="btn btn-sm btn-ghost" @click="checkUpdate()" :disabled="upd._checking"
|
||||
x-text="upd._checking ? '…' : 'Vérifier'"></button>
|
||||
<button class="btn btn-sm btn-accent" x-show="upd.update_available" @click="applyUpdate()"
|
||||
:disabled="upd._applying" x-text="upd._applying ? 'Mise à jour…' : '⬆ Mettre à jour maintenant'"></button>
|
||||
</div>
|
||||
<p x-show="!upd.configured" style="margin:0.5rem 0 0;font-size:0.85rem;color:var(--text-dim)">
|
||||
Renseignez le dépôt Gitea pour activer la détection des mises à jour.
|
||||
Jeton d'accès (droit « lecture ») nécessaire uniquement si le dépôt est privé.
|
||||
</p>
|
||||
<p x-show="upd.configured && !upd.docker" style="margin:0.5rem 0 0;font-size:0.85rem;color:var(--text-dim)">
|
||||
<details x-show="upd.notes" style="margin:0.6rem 0 0">
|
||||
<summary style="cursor:pointer;font-size:0.85rem;color:var(--text-dim)">
|
||||
Patchnote <span x-text="upd.latest"></span>
|
||||
</summary>
|
||||
<pre style="white-space:pre-wrap;font-size:0.82rem;margin:0.5rem 0 0;color:var(--text-dim)"
|
||||
x-text="upd.notes"></pre>
|
||||
</details>
|
||||
<p x-show="!upd.docker" style="margin:0.5rem 0 0;font-size:0.85rem;color:var(--text-dim)">
|
||||
⚠ Mise à jour automatique disponible uniquement en déploiement Docker
|
||||
(<code>docker compose pull && docker compose up -d</code> sinon).
|
||||
</p>
|
||||
</div>
|
||||
+
|
||||
|
||||
<div class="panel">
|
||||
<h2>👥 Utilisateurs</h2>
|
||||
@@ -171,9 +166,8 @@ function adminPage() {
|
||||
return {
|
||||
users: [], sources: [], stats: {}, forbidden: false,
|
||||
upd: {
|
||||
current: '', latest: null, update_available: false, configured: false, docker: false,
|
||||
config: { gitea_url: '', repo: '', token: '' },
|
||||
_dirty: false, _checking: false, _applying: false,
|
||||
current: '', latest: null, notes: null, update_available: false, docker: false,
|
||||
_checking: false, _applying: false,
|
||||
},
|
||||
integrations: {
|
||||
torznab: { apikey: '', endpoint: '' }, sonarr: { url: '', apikey: '' },
|
||||
@@ -287,18 +281,9 @@ function adminPage() {
|
||||
applyUpdateData(data) {
|
||||
this.upd.current = data.current;
|
||||
this.upd.latest = data.latest;
|
||||
this.upd.notes = data.notes;
|
||||
this.upd.update_available = data.update_available;
|
||||
this.upd.configured = data.configured;
|
||||
this.upd.docker = data.docker;
|
||||
this.upd.config = { ...data.config, _dirty: false };
|
||||
},
|
||||
|
||||
async saveUpdate() {
|
||||
const res = await fetch('/api/admin/update', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this.upd.config),
|
||||
});
|
||||
if (res.ok) { this.applyUpdateData(await res.json()); toast('✔ Configuration enregistrée'); }
|
||||
},
|
||||
|
||||
async checkUpdate() {
|
||||
|
||||
+10
-4
@@ -4,14 +4,14 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Ohm Stream{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ asset_v }}">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar" x-data="{ username: '' }" x-init="fetch('/auth/me').then(r => r.json()).then(u => username = u.username).catch(() => {})">
|
||||
<header class="topbar" x-data="{ username: '', pref: 'both' }" x-init="fetch('/auth/me').then(r => r.json()).then(u => { username = u.username; pref = u.content_preference || 'both'; }).catch(() => {})">
|
||||
<a class="logo" href="/">OHM<span>STREAM</span></a>
|
||||
<nav class="topnav">
|
||||
<a class="{{ 'active' if active == 'discover' }}" href="/discover">Découvrir</a>
|
||||
@@ -22,6 +22,12 @@
|
||||
<a class="{{ 'active' if active == 'admin' }}" href="/admin">Admin</a>
|
||||
</nav>
|
||||
<div class="topbar-right">
|
||||
<select class="pref-select" x-model="pref" x-show="username" title="Types de contenus"
|
||||
@change="fetch('/auth/preferences', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ content_preference: pref }) }).then(() => location.reload())">
|
||||
<option value="anime">⛩ Animés</option>
|
||||
<option value="serie">📺 Séries</option>
|
||||
<option value="both">✨ Les deux</option>
|
||||
</select>
|
||||
<div class="avatar" x-text="username ? username[0] : '·'" :title="username"></div>
|
||||
<form method="post" action="/auth/logout" style="display:inline">
|
||||
<button class="icon-btn" type="submit" title="Déconnexion">⏻</button>
|
||||
@@ -31,8 +37,8 @@
|
||||
<main class="main">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/update-watcher.js"></script>
|
||||
<script src="/static/js/app.js?v={{ asset_v }}"></script>
|
||||
<script src="/static/js/update-watcher.js?v={{ asset_v }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+171
-15
@@ -4,10 +4,61 @@
|
||||
|
||||
{% block content %}
|
||||
<h1 class="page-title">Découvrir</h1>
|
||||
<p class="page-sub">Nouveautés de tes sources, incontournables et suggestions basées sur tes téléchargements.</p>
|
||||
<p class="page-sub">Explore par genre, nouveautés de tes sources, incontournables et suggestions basées sur tes téléchargements.</p>
|
||||
|
||||
<div x-data="discoverPage()" x-init="load()" x-cloak>
|
||||
|
||||
<!-- ------------------------------------------------ Explorer par genre -->
|
||||
<section class="rail-section explore-section" x-show="!loading && types.length > 0">
|
||||
<h2 class="rail-title">🎭 Explorer
|
||||
<span class="rail-source" x-text="exploreSubtitle"></span>
|
||||
</h2>
|
||||
<div class="explore-filters">
|
||||
<div class="chip-row" role="tablist" aria-label="Type de contenu">
|
||||
<template x-for="t in types" :key="t">
|
||||
<button class="genre-chip" :class="{ active: exploreType === t }" role="tab"
|
||||
:aria-selected="exploreType === t" @click="setType(t)"
|
||||
x-text="typeLabels[t]"></button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="chip-row" role="tablist" aria-label="Genre" x-show="currentGenres.length > 0">
|
||||
<template x-for="g in currentGenres" :key="g.key">
|
||||
<button class="genre-chip" :class="{ active: exploreGenre === g.key }" role="tab"
|
||||
:aria-selected="exploreGenre === g.key" @click="setGenre(g.key)"
|
||||
x-text="g.label"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rail-wrap" x-show="!exploreLoading">
|
||||
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
|
||||
@click="scrollRail($el, -1)">‹</button>
|
||||
<div class="rail">
|
||||
<template x-for="(item, i) in exploreItems" :key="'ex' + i + (item.source_id || item.kitsu_id || item.title)">
|
||||
<a class="card rail-card" :style="`--i:${i}`" :href="exploreHref(item)"
|
||||
:title="item.source ? item.title : `Rechercher « ${item.title} »`">
|
||||
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
|
||||
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
|
||||
<span class="card-chip" x-show="item.label" x-text="item.label"></span>
|
||||
<span class="card-chip" x-show="!item.label && item.rating" x-text="item.rating ? '★ ' + item.rating : ''"></span>
|
||||
<div class="card-overlay">
|
||||
<div class="card-title" x-text="item.title"></div>
|
||||
<div class="card-meta">
|
||||
<span class="badge badge-type" x-text="item.start_date ? item.start_date.slice(0, 4) : (item.year || '')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
<button class="rail-nav rail-next" type="button" aria-label="Défiler à droite"
|
||||
@click="scrollRail($el, 1)">›</button>
|
||||
</div>
|
||||
<div class="explore-skel" x-show="exploreLoading">
|
||||
<div class="rail rail-skel">
|
||||
<template x-for="i in 8" :key="'sk' + i"><div class="rail-card skel skel-card"></div></template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------ Skeletons de chargement -->
|
||||
<div x-show="loading">
|
||||
<section class="rail-section" x-data="{ n: 8 }">
|
||||
@@ -28,14 +79,14 @@
|
||||
<div class="login-error" x-text="error"></div>
|
||||
</template>
|
||||
|
||||
<!-- ------------------------------------------------ Nouveautés -->
|
||||
<section class="rail-section" x-show="!loading && latest.length > 0">
|
||||
<h2 class="rail-title">🆕 Nouveautés <span class="rail-source">de tes sources, triées par date de sortie</span></h2>
|
||||
<!-- ------------------------------------------------ Nouveautés animés -->
|
||||
<section class="rail-section" x-show="!loading && latestAnime.length > 0">
|
||||
<h2 class="rail-title">🆕 Nouveautés animés <span class="rail-source">triées par date de sortie</span></h2>
|
||||
<div class="rail-wrap">
|
||||
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
|
||||
@click="scrollRail($el, -1)">‹</button>
|
||||
<div class="rail">
|
||||
<template x-for="(item, i) in latest" :key="item.source + item.source_id">
|
||||
<template x-for="(item, i) in latestAnime" :key="item.source + item.source_id">
|
||||
<a class="card rail-card" :style="`--i:${i}`" :href="`/title/${item.source}/${encodeURIComponent(item.source_id)}`">
|
||||
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
|
||||
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
|
||||
@@ -56,9 +107,33 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------ Nouveautés séries & films -->
|
||||
<section class="rail-section" x-show="!loading && latestSerie.length > 0">
|
||||
<h2 class="rail-title">🆕 Nouveautés séries & films <span class="rail-source">ajouts récents de French-Stream</span></h2>
|
||||
<div class="rail-wrap">
|
||||
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
|
||||
@click="scrollRail($el, -1)">‹</button>
|
||||
<div class="rail">
|
||||
<template x-for="(item, i) in latestSerie" :key="item.source + item.source_id">
|
||||
<a class="card rail-card" :style="`--i:${i}`" :href="`/title/${item.source}/${encodeURIComponent(item.source_id)}`">
|
||||
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
|
||||
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
|
||||
<span class="card-chip" x-text="item.label"></span>
|
||||
<span class="card-year" x-text="item.media_type === 'film' ? 'Film' : ''"></span>
|
||||
<div class="card-overlay">
|
||||
<div class="card-title" x-text="item.title"></div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
<button class="rail-nav rail-next" type="button" aria-label="Défiler à droite"
|
||||
@click="scrollRail($el, 1)">›</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------ Incontournables -->
|
||||
<section class="rail-section" x-show="!loading && mustWatch.length > 0">
|
||||
<h2 class="rail-title">🔥 Incontournables <span class="rail-source">les classiques les mieux notés</span></h2>
|
||||
<h2 class="rail-title">🔥 Incontournables animés <span class="rail-source">les classiques les mieux notés</span></h2>
|
||||
<div class="rail-wrap">
|
||||
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
|
||||
@click="scrollRail($el, -1)">‹</button>
|
||||
@@ -81,11 +156,26 @@
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------ Pour toi -->
|
||||
<section class="rail-section" x-show="!loading && forYou.items.length > 0">
|
||||
<section class="rail-section" x-show="!loading && (forYou.items.length > 0 || forYou.cold_start)">
|
||||
<h2 class="rail-title">✨ Pour toi
|
||||
<span class="rail-source" x-show="forYou.based_on.length"
|
||||
x-text="forYou.based_on.length ? 'parce que tu aimes : ' + forYou.based_on.join(', ') : ''"></span>
|
||||
</h2>
|
||||
|
||||
|
||||
<!-- Aucun historique : carte d'amorçage au lieu d'une section muette -->
|
||||
<div class="cta-card" x-show="!loading && forYou.cold_start">
|
||||
<div class="cta-emoji">🧭</div>
|
||||
<div class="cta-body">
|
||||
<div class="cta-title">On ne te connaît pas encore…</div>
|
||||
<p>Télécharge tes premiers animés ou séries, ou ajoute des favoris :
|
||||
« Pour toi » s'ajustera automatiquement à tes genres préférés.</p>
|
||||
<div class="cta-actions">
|
||||
<a class="cta-btn" href="/">Chercher un premier titre</a>
|
||||
<a class="cta-btn ghost" href="/discover">Explorer par genre</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rail-wrap">
|
||||
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
|
||||
@click="scrollRail($el, -1)">‹</button>
|
||||
@@ -107,7 +197,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template x-if="!loading && !error && latest.length === 0 && mustWatch.length === 0 && forYou.items.length === 0">
|
||||
<template x-if="!loading && !error && latestAnime.length === 0 && latestSerie.length === 0 && mustWatch.length === 0 && forYou.items.length === 0 && exploreItems.length === 0">
|
||||
<div class="empty-state"><div class="big">🏜️</div>Rien à afficher pour le moment — réessaie plus tard.</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -117,23 +207,89 @@
|
||||
<script>
|
||||
function discoverPage() {
|
||||
return {
|
||||
latest: [], mustWatch: [], forYou: { based_on: [], items: [] },
|
||||
latestAnime: [], latestSerie: [], mustWatch: [], forYou: { based_on: [], items: [] },
|
||||
genres: {}, exploreType: null, exploreGenre: null,
|
||||
exploreItems: [], exploreLoading: false,
|
||||
loading: true, error: null,
|
||||
typeLabels: { anime: '⛩ Animés', serie: '📺 Séries', film: '🎬 Films' },
|
||||
|
||||
get types() { return Object.keys(this.genres); },
|
||||
get currentGenres() { return this.genres[this.exploreType] || []; },
|
||||
get exploreSubtitle() {
|
||||
const genre = (this.currentGenres.find(g => g.key === this.exploreGenre) || {}).label;
|
||||
return genre ? `${this.typeLabels[this.exploreType] || ''} · ${genre}` : 'choisis un genre';
|
||||
},
|
||||
|
||||
scrollRail(el, dir) {
|
||||
const rail = el.closest('.rail-wrap').querySelector('.rail');
|
||||
rail.scrollBy({ left: dir * rail.clientWidth * 0.85, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
exploreHref(item) {
|
||||
return item.source
|
||||
? `/title/${item.source}/${encodeURIComponent(item.source_id)}`
|
||||
: `/?q=${encodeURIComponent(item.title)}`;
|
||||
},
|
||||
|
||||
setType(type) {
|
||||
this.exploreType = type;
|
||||
this.exploreGenre = (this.genres[type][0] || {}).key || null;
|
||||
this.browse();
|
||||
},
|
||||
|
||||
setGenre(genre) {
|
||||
this.exploreGenre = genre;
|
||||
this.browse();
|
||||
},
|
||||
|
||||
async browse() {
|
||||
if (!this.exploreType || !this.exploreGenre) return;
|
||||
this.exploreLoading = true;
|
||||
const params = new URLSearchParams({ type: this.exploreType, genre: this.exploreGenre });
|
||||
const url = `${location.pathname}?${params}`;
|
||||
history.replaceState(null, '', url); // état partageable
|
||||
try {
|
||||
const res = await fetch(`/api/discover/browse?${params}`);
|
||||
if (!res.ok) throw new Error('Erreur ' + res.status);
|
||||
this.exploreItems = (await res.json()).items;
|
||||
} catch (e) {
|
||||
this.exploreItems = [];
|
||||
console.error('Explorer :', e);
|
||||
} finally {
|
||||
this.exploreLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async load() {
|
||||
this.loading = true; this.error = null;
|
||||
try {
|
||||
const res = await fetch('/api/discover');
|
||||
if (!res.ok) throw new Error('Erreur ' + res.status);
|
||||
const data = await res.json();
|
||||
this.latest = data.latest;
|
||||
const [discoverRes, genresRes] = await Promise.all([
|
||||
fetch('/api/discover'), fetch('/api/discover/genres'),
|
||||
]);
|
||||
if (!discoverRes.ok) throw new Error('Erreur ' + discoverRes.status);
|
||||
const data = await discoverRes.json();
|
||||
this.latestAnime = data.latest_anime;
|
||||
this.latestSerie = data.latest_serie;
|
||||
this.mustWatch = data.must_watch;
|
||||
this.forYou = data.for_you;
|
||||
} catch (e) { this.error = e.message; }
|
||||
finally { this.loading = false; }
|
||||
this.genres = await genresRes.json();
|
||||
|
||||
// État initial : paramètres d'URL (?t=&g=) sinon premier type/genre dispo
|
||||
const params = new URLSearchParams(location.search);
|
||||
let type = params.get('t');
|
||||
if (!this.genres[type]) type = this.types[0];
|
||||
let genre = params.get('g');
|
||||
if (!(this.genres[type] || []).some(g => g.key === genre)) {
|
||||
genre = ((this.genres[type] || [])[0] || {}).key;
|
||||
}
|
||||
this.exploreType = type || null;
|
||||
this.exploreGenre = genre || null;
|
||||
await this.browse();
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Connexion — Ohm Stream</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ asset_v }}">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Déploiement OhmStreaming sous Podman — variante de docker-compose.yml
|
||||
#
|
||||
# 1. Activer le socket Podman (une seule fois) :
|
||||
# rootless : systemctl --user enable --now podman.socket
|
||||
# sudo loginctl enable-linger $USER # persister après déconnexion
|
||||
# export PODMAN_SOCKET=/run/user/$(id -u)/podman/podman.sock
|
||||
# rootful : sudo systemctl enable --now podman.socket
|
||||
# 2. cp .env.example .env && $EDITOR .env (OHM_SECRET_KEY + WATCHTOWER_TOKEN obligatoires)
|
||||
# 3. podman compose -f docker-compose.podman.yml up -d
|
||||
# (ou podman-compose -f docker-compose.podman.yml up -d)
|
||||
#
|
||||
# Mise à jour : page Admin → « Mise à jour », ou manuellement :
|
||||
# podman compose -f docker-compose.podman.yml pull && podman compose -f docker-compose.podman.yml up -d
|
||||
|
||||
services:
|
||||
ohm:
|
||||
image: ${OHM_IMAGE:-ghcr.io/aulyriusohm/ohm-streaming:latest}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${OHM_PORT:-8777}:8777"
|
||||
env_file: .env
|
||||
environment:
|
||||
# Chemins internes (volumes ci-dessous) — ne pas changer
|
||||
OHM_DATA_DIR: /data
|
||||
OHM_DOWNLOAD_DIR: /downloads
|
||||
OHM_DATABASE_PATH: /data/ohm.db
|
||||
# Watchtower compagnon sur le réseau interne compose
|
||||
OHM_WATCHTOWER_URL: http://watchtower:8080
|
||||
OHM_WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:?WATCHTOWER_TOKEN manquant — voir .env.example}
|
||||
volumes:
|
||||
# Fedora/SELinux : suffixer de « :z » en cas d'erreur « Permission denied »
|
||||
- ./data:/data
|
||||
- ./downloads:/downloads
|
||||
|
||||
# Compagnon de mise à jour : recrée le conteneur ohm quand l'admin le demande
|
||||
# (API HTTP interne uniquement, aucun port publié sur l'hôte)
|
||||
watchtower:
|
||||
image: containrrr/watchtower
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Socket Podman (compatible API Docker) au lieu de /var/run/docker.sock
|
||||
- ${PODMAN_SOCKET:-/run/podman/podman.sock}:/var/run/docker.sock
|
||||
environment:
|
||||
# Pas de vérification périodique : uniquement sur appel de l'admin
|
||||
WATCHTOWER_HTTP_API_UPDATE: "true"
|
||||
WATCHTOWER_HTTP_API_TOKEN: ${WATCHTOWER_TOKEN:?WATCHTOWER_TOKEN manquant — voir .env.example}
|
||||
# Ne mettre à jour que les conteneurs étiquetés (ohm), pas watchtower lui-même
|
||||
WATCHTOWER_LABEL_ENABLE: "true"
|
||||
# Pas de DOCKER_API_VERSION : Podman annonce lui-même la version d'API
|
||||
# Supprimer les anciennes images après mise à jour
|
||||
WATCHTOWER_CLEANUP: "true"
|
||||
+6
-4
@@ -2,16 +2,18 @@
|
||||
#
|
||||
# Installation guidée (recommandée) : ./scripts/install.sh
|
||||
# Ou à la main :
|
||||
# 1. docker login git.lanro.eu (compte Gitea avec accès lecture au repo)
|
||||
# 2. cp .env.example .env && $EDITOR .env (OHM_SECRET_KEY + WATCHTOWER_TOKEN obligatoires)
|
||||
# 3. docker compose up -d
|
||||
# 1. cp .env.example .env && $EDITOR .env (OHM_SECRET_KEY + WATCHTOWER_TOKEN obligatoires)
|
||||
# 2. docker compose up -d
|
||||
#
|
||||
# Image par défaut : GHCR (publique). Pour utiliser le registre Gitea à la place :
|
||||
# OHM_IMAGE=git.lanro.eu/roman/ohm_streaming:latest dans .env (+ docker login git.lanro.eu)
|
||||
#
|
||||
# Mise à jour : page Admin → « Mise à jour », ou manuellement :
|
||||
# docker compose pull && docker compose up -d
|
||||
|
||||
services:
|
||||
ohm:
|
||||
image: git.lanro.eu/roman/ohm_streaming:latest
|
||||
image: ${OHM_IMAGE:-ghcr.io/aulyriusohm/ohm-streaming:latest}
|
||||
# Build local (dev) : décommenter ces lignes et commenter « image: » ci-dessus
|
||||
# build:
|
||||
# context: .
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "ohm-stream"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = "Ohm Stream Downloader — centre de contrôle auto-hébergé pour animes et séries VOSTFR"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
|
||||
+2
-18
@@ -5,7 +5,7 @@
|
||||
# bash scripts/install.sh (depuis un clone du dépôt)
|
||||
# curl -fsSL <gitea>/raw/branch/main/scripts/install.sh | bash
|
||||
#
|
||||
# Options non interactives : --dir <chemin> --port <n> --skip-login
|
||||
# Options non interactives : --dir <chemin> --port <n>
|
||||
# (les valeurs passées en option court-circuitent les questions)
|
||||
#
|
||||
# Pose les questions (destination des animés, port), génère les secrets,
|
||||
@@ -23,12 +23,11 @@ ok() { printf " ${GREEN}✔${RESET} %s\n" "$1"; }
|
||||
warn() { printf " ${YELLOW}⚠${RESET} %s\n" "$1"; }
|
||||
die() { printf " ${RED}✖ %s${RESET}\n" "$1" >&2; exit 1; }
|
||||
|
||||
ARG_DIR="" ARG_PORT="" ARG_SKIP_LOGIN=0
|
||||
ARG_DIR="" ARG_PORT=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dir) ARG_DIR="$2"; shift 2;;
|
||||
--port) ARG_PORT="$2"; shift 2;;
|
||||
--skip-login) ARG_SKIP_LOGIN=1; shift;;
|
||||
*) shift;;
|
||||
esac
|
||||
done
|
||||
@@ -74,21 +73,6 @@ if [ ! -f "$COMPOSE_FILE" ]; then
|
||||
fi
|
||||
ok "Projet prêt dans $(pwd)"
|
||||
|
||||
# ---------------------------------------------------------------- registre privé
|
||||
step "Accès au registre privé ($REGISTRY_HOST)"
|
||||
if [ "$ARG_SKIP_LOGIN" = "1" ]; then
|
||||
warn "--skip-login : connexion au registre ignorée"
|
||||
elif docker login "$REGISTRY_HOST" 2>/dev/null; then
|
||||
ok "Connecté au registre"
|
||||
else
|
||||
warn "Pas encore connecté — un compte Gitea avec accès lecture au dépôt est requis."
|
||||
if [ "$(ask "Se connecter maintenant (docker login) ?" "o")" = "o" ]; then
|
||||
docker login "$REGISTRY_HOST" || die "docker login a échoué — relance le script après t'être connecté."
|
||||
ok "Connecté au registre"
|
||||
else
|
||||
die "Sans docker login, l'image ne pourra pas être tirée."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- destination des épisodes
|
||||
step "Destination des épisodes téléchargés"
|
||||
|
||||
Executable → Regular
+12
-9
@@ -1,13 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publie une version d'OhmStreaming :
|
||||
# bump de version → commit + tag git → build de l'image Docker → push registre Gitea.
|
||||
# bump de version → commit + tag git → build de l'image Docker → push GHCR (public) + Gitea.
|
||||
#
|
||||
# Usage : ./scripts/release.sh 0.2.0
|
||||
# Usage : ./scripts/release.sh 0.2.0 ["patchnote multi-lignes"]
|
||||
# Option : OHM_REGISTRY=git.lanro.eu/roman/ohm_streaming (défaut) pour viser un autre registre.
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:?Usage : ./scripts/release.sh <version> (ex. 0.2.0)}"
|
||||
VERSION="${1:?Usage : ./scripts/release.sh <version> [patchnote] (ex : 0.2.0)}"
|
||||
REGISTRY="${OHM_REGISTRY:-git.lanro.eu/roman/ohm_streaming}"
|
||||
GHCR="${OHM_GHCR:-ghcr.io/aulyriusohm/ohm-streaming}"
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "✖ « $VERSION » n'est pas un semver X.Y.Z"; exit 1; }
|
||||
@@ -30,15 +31,17 @@ git add pyproject.toml uv.lock
|
||||
git commit -m "v$VERSION"
|
||||
|
||||
echo "▶ 2/3 Tag et push git"
|
||||
git tag "$TAG"
|
||||
git tag -a "$TAG" -m "${2:-$TAG}"
|
||||
git push origin HEAD
|
||||
git push origin "$TAG"
|
||||
|
||||
echo "▶ 3/3 Image Docker → $REGISTRY"
|
||||
docker build --build-arg VERSION="$VERSION" -t "$REGISTRY:$VERSION" -t "$REGISTRY:latest" .
|
||||
docker push "$REGISTRY:$VERSION"
|
||||
docker push "$REGISTRY:latest"
|
||||
echo "▶ 3/3 Image Docker → $GHCR (public) + $REGISTRY (Gitea)"
|
||||
docker build --build-arg VERSION="$VERSION" -t "$GHCR:$VERSION" -t "$GHCR:latest" .
|
||||
docker push "$GHCR:$VERSION" && docker push "$GHCR:latest"
|
||||
docker tag "$GHCR:$VERSION" "$REGISTRY:$VERSION"
|
||||
docker tag "$GHCR:latest" "$REGISTRY:latest"
|
||||
docker push "$REGISTRY:$VERSION" && docker push "$REGISTRY:latest"
|
||||
|
||||
echo
|
||||
echo "✔ v$VERSION publiée ($REGISTRY:$VERSION et :latest)"
|
||||
echo "✔ v$VERSION publiée ($GHCR:$VERSION et :latest — public, miroir Gitea $REGISTRY)"
|
||||
echo " Les instances la détecteront via Admin → Mise à jour → « Vérifier »."
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Tests unitaires : format interne, sanitisation, auth, registres scrapers."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app import auth
|
||||
from app.scrapers import http as http_module
|
||||
from app.scrapers.base import (
|
||||
ScrapeError,
|
||||
decode_internal_url,
|
||||
encode_internal_url,
|
||||
get_source,
|
||||
@@ -133,3 +136,43 @@ def test_hoster_resolution():
|
||||
assert resolve_hoster("https://vidmoly.to/embed-x.html").name == "vidmoly"
|
||||
assert resolve_hoster("https://sendvid.com/embed/x").name == "sendvid"
|
||||
assert resolve_hoster("https://inconnu.example.com/v/1") is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------ client HTTP
|
||||
|
||||
|
||||
class _StatusClient:
|
||||
"""Fake client HTTP : sert une série de codes statut puis un corps 200."""
|
||||
|
||||
def __init__(self, statuses: list[int]):
|
||||
self._statuses = statuses
|
||||
self.calls = 0
|
||||
|
||||
async def get(self, url, headers=None):
|
||||
self.calls += 1
|
||||
request = httpx.Request("GET", url)
|
||||
status = self._statuses.pop(0) if self._statuses else 200
|
||||
response = httpx.Response(status, request=request, text="ok")
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
|
||||
async def test_fetch_fails_fast_on_definitive_4xx(monkeypatch):
|
||||
client = _StatusClient([422])
|
||||
monkeypatch.setattr("app.scrapers.http.get_client", lambda: client)
|
||||
with pytest.raises(ScrapeError, match="422"):
|
||||
await http_module.fetch("https://site.example/lecteur/prepare/1?content=2&episode=3")
|
||||
assert client.calls == 1 # aucun retry sur erreur définitive
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [429, 500, 503])
|
||||
async def test_fetch_retries_transitory_errors(monkeypatch, status):
|
||||
client = _StatusClient([status, 200])
|
||||
monkeypatch.setattr("app.scrapers.http.get_client", lambda: client)
|
||||
|
||||
async def no_sleep(_delay):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("app.scrapers.http.asyncio.sleep", no_sleep)
|
||||
assert await http_module.fetch("https://site.example/page", retries=2) == "ok"
|
||||
assert client.calls == 2
|
||||
|
||||
+177
-19
@@ -154,7 +154,7 @@ async def test_latest_merges_and_sorts_by_release_date(monkeypatch):
|
||||
|
||||
service = DiscoverService()
|
||||
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
|
||||
result = await service.latest(limit=10)
|
||||
result = (await service.latest_by_type(limit=10))["anime"]
|
||||
|
||||
titles = [item["title"] for item in result]
|
||||
assert titles.count("Frieren VOSTFR") + titles.count("Frieren - Saison 1") == 1 # dédoublonné
|
||||
@@ -186,11 +186,52 @@ async def test_latest_orders_by_kitsu_start_date(monkeypatch):
|
||||
|
||||
service = DiscoverService()
|
||||
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
|
||||
result = await service.latest(limit=10)
|
||||
result = (await service.latest_by_type(limit=10))["anime"]
|
||||
assert [item["title"] for item in result] == ["Anime 1", "Anime 0", "Anime 2"]
|
||||
assert result[0]["rating"] is None
|
||||
|
||||
|
||||
async def test_latest_by_type_keeps_every_type(monkeypatch):
|
||||
"""Chaque type garde son rail complet : les animés nombreux ne vicient pas
|
||||
le rail séries (les séries n'existent pas chez Kitsu, donc sans date)."""
|
||||
import app.services.discover as discover_module
|
||||
|
||||
animes = [
|
||||
SearchResult(source="vostfree", source_id=str(n), title=f"Animé {n}", url=f"https://a/{n}")
|
||||
for n in range(1, 31)
|
||||
]
|
||||
series = [
|
||||
SearchResult(
|
||||
source="french_stream", source_id=str(n), title=f"Série {n}",
|
||||
url=f"https://s/{n}", media_type="serie",
|
||||
)
|
||||
for n in range(1, 10)
|
||||
]
|
||||
|
||||
class AnimeSource(_FakeSource):
|
||||
name, label, base_url, media_types = "vostfree", "Vostfree", "https://a", ("anime",)
|
||||
|
||||
class SerieSource(_FakeSource):
|
||||
name, label, base_url, media_types = "french_stream", "French-Stream", "https://s", ("serie", "film")
|
||||
|
||||
monkeypatch.setattr(discover_module, "all_sources", lambda: [AnimeSource(animes), SerieSource(series)])
|
||||
|
||||
async def enabled(name: str) -> bool:
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(discover_module, "is_source_enabled", enabled)
|
||||
|
||||
async def fake_match(title: str):
|
||||
return None # rien chez Kitsu → aucune date de sortie
|
||||
|
||||
service = DiscoverService()
|
||||
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
|
||||
|
||||
rails = await service.latest_by_type(limit=24)
|
||||
assert len(rails["anime"]) == 24 # tronqué à la limite
|
||||
assert len(rails["serie"]) == 9 # les 9 séries intactes, malgré 30 animés
|
||||
assert all(item["media_type"] == "serie" for item in rails["serie"])
|
||||
|
||||
async def test_latest_skips_broken_source_and_uses_cache(monkeypatch):
|
||||
"""Une source en échec disparaît sans erreur, et le TTL évite les re-scrapes."""
|
||||
import app.services.discover as discover_module
|
||||
@@ -214,8 +255,8 @@ async def test_latest_skips_broken_source_and_uses_cache(monkeypatch):
|
||||
monkeypatch.setattr(discover_module, "is_source_enabled", enabled)
|
||||
|
||||
service = DiscoverService()
|
||||
assert await service.latest() == []
|
||||
assert await service.latest() == []
|
||||
assert await service.latest_by_type() == {"anime": [], "serie": [], "film": []}
|
||||
assert await service.latest_by_type() == {"anime": [], "serie": [], "film": []}
|
||||
assert calls["n"] == 1 # deuxième appel servi depuis le cache TTL
|
||||
|
||||
|
||||
@@ -256,16 +297,15 @@ async def history(monkeypatch):
|
||||
return ["Fantasy", "Adventure"] if anime_id == "46474" else []
|
||||
|
||||
async def fake_kitsu_anime(params: dict) -> list[dict]:
|
||||
slugs = frozenset(params["filter[categories]"].split(","))
|
||||
assert params["sort"] == "-userCount"
|
||||
catalog = {
|
||||
frozenset(["fantasy", "adventure", "action", "comedy"]): [
|
||||
# Une catégorie par requête (le cumul Kitsu est un ET trop restrictif)
|
||||
assert "," not in params["filter[categories]"]
|
||||
return [
|
||||
{"title": "Helck", "kitsu_id": "999"},
|
||||
{"title": "Sousou no Frieren", "kitsu_id": "46474"},
|
||||
{"title": "Konosuba", "kitsu_id": "1"},
|
||||
],
|
||||
}
|
||||
return catalog.get(slugs, [])
|
||||
]
|
||||
|
||||
|
||||
service = DiscoverService()
|
||||
service.test_user_id = user_id # pour les assertions du test
|
||||
@@ -284,11 +324,78 @@ async def test_for_you_aggregates_genres_and_excludes_owned(history):
|
||||
assert "Sousou no Frieren" in titles
|
||||
assert "Konosuba" in titles
|
||||
|
||||
async def test_for_you_excludes_owned_with_episode_markers(monkeypatch):
|
||||
"""Régression : « Titre - Saison 1 - E3 » possédé doit exclure le « Titre »
|
||||
canonique recommandé (la normalisation retire saison ET épisode, en boucle)."""
|
||||
cursor = await db.execute(
|
||||
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
||||
("reg-user", "x" * 64),
|
||||
)
|
||||
user_id = cursor.lastrowid
|
||||
await db.execute(
|
||||
"INSERT INTO downloads (source_key, video_url, title, status) VALUES (?,?,?,?)",
|
||||
("kr", "https://v/r", "The Eminence in Shadow - Saison 1 - E3", "done"),
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO favorites (user_id, source, source_id, title, image_url, payload) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(user_id, "vostfree", "r", "Favori R", None, '{"genres": ["Fantasy"]}'),
|
||||
)
|
||||
|
||||
async def test_for_you_empty_without_history(monkeypatch):
|
||||
async def fake_match(title: str):
|
||||
return None # genres apportés par le favori
|
||||
|
||||
async def fake_kitsu_anime(params: dict) -> list[dict]:
|
||||
return [
|
||||
{"title": "The Eminence in Shadow", "kitsu_id": "1"},
|
||||
{"title": "Autre Anime", "kitsu_id": "2"},
|
||||
]
|
||||
|
||||
service = DiscoverService()
|
||||
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
|
||||
monkeypatch.setattr(service, "_kitsu_anime", fake_kitsu_anime)
|
||||
result = await service.for_you(user_id=user_id, limit=10)
|
||||
titles = [item["title"] for item in result["items"]]
|
||||
assert "The Eminence in Shadow" not in titles # possédé (« - Saison 1 - E3 »)
|
||||
assert "Autre Anime" in titles
|
||||
|
||||
async def test_for_you_cold_start_without_history(monkeypatch):
|
||||
"""Aucun téléchargement/favori/Sonarr : amorçage signalé (non caché)."""
|
||||
service = DiscoverService()
|
||||
result = await service.for_you(user_id=42)
|
||||
assert result == {"based_on": [], "items": []}
|
||||
assert result == {"based_on": [], "items": [], "cold_start": True}
|
||||
assert service._cache.get("for_you:42:20") is None # pas de cache : bon marché, change au 1er téléchargement
|
||||
|
||||
async def test_for_you_uses_series_page_genres_without_sonarr(monkeypatch):
|
||||
"""Sans Sonarr, les séries téléchargées donnent leurs genres via leur fiche
|
||||
source (libellés FR convertis en catégories Kitsu)."""
|
||||
cursor = await db.execute(
|
||||
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
||||
("serie-user", "x" * 64),
|
||||
)
|
||||
user_id = cursor.lastrowid
|
||||
await db.execute(
|
||||
"INSERT INTO downloads (source_key, video_url, page_url, title, status) VALUES (?,?,?,?,?)",
|
||||
("k9", "https://v/9", "https://french-stream.lat/15138132-stat-saison-5.html",
|
||||
"STAT - Saison 5", "done"),
|
||||
)
|
||||
|
||||
async def fake_no_match(title: str):
|
||||
return None # série réelle : aucun match Kitsu
|
||||
|
||||
async def fake_genres_of_page(self, page_url: str):
|
||||
assert "15138132" in page_url
|
||||
return ["Drame", "Médical"] # Médical : sans équivalent Kitsu → ignoré
|
||||
|
||||
service = DiscoverService()
|
||||
monkeypatch.setattr(service._kitsu, "search_anime", fake_no_match)
|
||||
monkeypatch.setattr(
|
||||
"app.scrapers.sources.french_stream.FrenchStreamScraper.genres_of_page",
|
||||
fake_genres_of_page,
|
||||
)
|
||||
result = await service.for_you(user_id=user_id, limit=10)
|
||||
assert result["based_on"] == ["Drama"] # « Drame » converti, « Médical » écarté
|
||||
assert result["items"] # des recommandations issues de la catégorie drama
|
||||
|
||||
|
||||
|
||||
@@ -336,11 +443,18 @@ async def test_api_discover_requires_auth(client):
|
||||
|
||||
|
||||
async def test_api_discover_sections(client, admin_cookies, monkeypatch):
|
||||
async def fake_latest(limit: int = 24):
|
||||
return [
|
||||
async def fake_latest_by_type(limit: int = 24):
|
||||
return {
|
||||
"anime": [
|
||||
{"source": "vostfree", "label": "Vostfree", "source_id": "a", "title": "T",
|
||||
"start_date": "2026-01-01", "status": "current", "rating": 8.1},
|
||||
]
|
||||
"media_type": "anime", "start_date": "2026-01-01", "status": "current", "rating": 8.1},
|
||||
],
|
||||
"serie": [
|
||||
{"source": "french_stream", "label": "French-Stream", "source_id": "b",
|
||||
"title": "S", "media_type": "serie"},
|
||||
],
|
||||
"film": [],
|
||||
}
|
||||
|
||||
async def fake_must_watch(limit: int = 20):
|
||||
return [{"kitsu_id": "1", "title": "Attack on Titan", "rating": 8.5}]
|
||||
@@ -348,19 +462,63 @@ async def test_api_discover_sections(client, admin_cookies, monkeypatch):
|
||||
async def fake_for_you(user_id: int, limit: int = 20):
|
||||
return {"based_on": ["Action"], "items": [{"kitsu_id": "2", "title": "X"}]}
|
||||
|
||||
monkeypatch.setattr(discover, "latest", fake_latest)
|
||||
monkeypatch.setattr(discover, "latest_by_type", fake_latest_by_type)
|
||||
monkeypatch.setattr(discover, "must_watch", fake_must_watch)
|
||||
monkeypatch.setattr(discover, "for_you", fake_for_you)
|
||||
|
||||
r = await client.get("/api/discover", cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["latest"][0]["label"] == "Vostfree"
|
||||
assert data["latest"][0]["start_date"] == "2026-01-01"
|
||||
assert data["latest_anime"][0]["label"] == "Vostfree"
|
||||
assert data["latest_anime"][0]["start_date"] == "2026-01-01"
|
||||
assert data["latest_serie"][0]["label"] == "French-Stream"
|
||||
assert data["must_watch"][0]["title"] == "Attack on Titan"
|
||||
assert data["for_you"]["based_on"] == ["Action"]
|
||||
|
||||
|
||||
async def test_api_browse(client, admin_cookies, monkeypatch):
|
||||
"""Explorer : animés via Kitsu, séries via French-Stream, hors préférence → vide."""
|
||||
|
||||
async def fake_browse_anime(genre: str, limit: int = 20):
|
||||
assert genre == "comedy"
|
||||
return [{"kitsu_id": "1", "title": "Nichijou"}]
|
||||
|
||||
async def fake_browse_serie_film(media_type: str, genre: str, limit: int = 24):
|
||||
assert (media_type, genre) == ("serie", "medical")
|
||||
return [{"source": "french_stream", "source_id": "9", "title": "STAT - Saison 5",
|
||||
"media_type": "serie"}]
|
||||
|
||||
monkeypatch.setattr(discover, "browse_anime", fake_browse_anime)
|
||||
monkeypatch.setattr(discover, "browse_serie_film", fake_browse_serie_film)
|
||||
|
||||
r = await client.get(
|
||||
"/api/discover/browse", params={"type": "anime", "genre": "comedy"}, cookies=admin_cookies
|
||||
)
|
||||
assert r.json()["items"][0]["title"] == "Nichijou"
|
||||
|
||||
r = await client.get(
|
||||
"/api/discover/browse", params={"type": "serie", "genre": "medical"}, cookies=admin_cookies
|
||||
)
|
||||
assert r.json()["items"][0]["title"] == "STAT - Saison 5"
|
||||
|
||||
# Préférence animés : le parcours séries renvoie vide sans même scraper
|
||||
await client.put("/auth/preferences", json={"content_preference": "anime"}, cookies=admin_cookies)
|
||||
r = await client.get(
|
||||
"/api/discover/browse", params={"type": "serie", "genre": "medical"}, cookies=admin_cookies
|
||||
)
|
||||
assert r.json()["items"] == []
|
||||
|
||||
|
||||
async def test_api_genres_catalog(client, admin_cookies):
|
||||
"""Catalogue de genres : animés + séries/films French-Stream en mode « les deux »."""
|
||||
r = await client.get("/api/discover/genres", cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
catalog = r.json()
|
||||
assert {"anime", "serie", "film"} <= set(catalog)
|
||||
assert {"key": "thriller", "label": "Thriller"} in catalog["serie"]
|
||||
assert any(g["key"] == "comedies" for g in catalog["film"])
|
||||
|
||||
|
||||
async def test_discover_page_renders(client, admin_cookies):
|
||||
r = await client.get("/discover", cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Tests du scraper French-Stream (séries/films VF-VOSTFR) sur fixtures réelles."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.scrapers.base import ScrapeError, get_source, import_all_scrapers
|
||||
from app.scrapers.sources.french_stream import FrenchStreamScraper
|
||||
|
||||
# ------------------------------------------------------------ fixtures
|
||||
|
||||
SEARCH_HTML = """
|
||||
<div class='search-item' onclick="location.href='/9562-breaking-bad-saison-2-streaming-complet-vf-vostfr.html'">
|
||||
<div class='search-poster'><img src='https://image.tmdb.org/t/p/w400/bb.jpg' alt='Breaking Bad - Saison 2'></div>
|
||||
<div class='search-info'><div class='search-title'>Breaking Bad - Saison 2</div></div>
|
||||
</div>
|
||||
<div class='search-item' onclick="location.href='/1022-inception-streaming-complet-vf-vostfr.html'">
|
||||
<div class='search-poster'><img src='https://image.tmdb.org/t/p/w400/inc.jpg' alt='Inception'></div>
|
||||
<div class='search-info'><div class='search-title'>Inception (2010)</div></div>
|
||||
</div>
|
||||
<div class='search-item'><div class='search-info'><div class='search-title'>Sans lien</div></div></div>
|
||||
"""
|
||||
|
||||
SERIE_DETAILS_HTML = """
|
||||
<div id="film-data" data-newsid="9562" data-affiche="https://image.tmdb.org/t/p/w300/bb.jpg"></div>
|
||||
<div class="fmain"><div class="fcols fx-row"><div class="fmid">
|
||||
<h1 id="s-title">Breaking Bad - Saison 2 </h1>
|
||||
<div class="facts">
|
||||
<span class="certification">-12</span> - <span class="release">2008 -</span>
|
||||
<span class="genres">Drame, Crime</span> - <span class="runtime">48 min</span>
|
||||
</div>
|
||||
<div class="flist clearfix"><div class="fdesc"><p>Walter et Jesse montent leur propre affaire.</p></div></div>
|
||||
</div></div></div>
|
||||
<div class="fleft"><div class="fposter"><img src="https://image.tmdb.org/t/p/w400/bb.jpg" alt="Breaking Bad - Saison 2"></div></div>
|
||||
<div id="serie-config" style="display:none" data-title="Breaking Bad - Saison 2" data-news-id="9562"></div>
|
||||
"""
|
||||
|
||||
FILM_DETAILS_HTML = """
|
||||
<div id="film-data" data-newsid="1022" data-affiche="https://image.tmdb.org/t/p/w300/inc.jpg"></div>
|
||||
<div class="fmain"><div class="fcols fx-row"><div class="fmid">
|
||||
<h1 id="s-title"> Inception - 2010 <span class="tag release_date"></span> </h1>
|
||||
<div class="facts">
|
||||
<span class="genres"><a href="/index.php?do=xfsearch&xf=Action">Action</a><a href="/index.php?do=xfsearch&xf=Science-Fiction">Science-Fiction</a></span>
|
||||
<span class="runtime">- 2h28</span>
|
||||
</div>
|
||||
<div class="fdesc clearfix slice-this" id="s-desc">
|
||||
<p class="desc-text">Résumé du film Inception en streaming complet vf et vostfr hd vod gratuit</p>
|
||||
Dom Cobb est un voleur expérimenté.
|
||||
</div>
|
||||
</div></div></div>
|
||||
"""
|
||||
|
||||
EP_DATA = {
|
||||
"vf": {
|
||||
"1": {"vidzy": "https://vidzy.cc/embed-aaa.html", "uqload": "https://uqload.vc/embed-bbb.html"},
|
||||
"2": {"vidzy": "https://vidzy.cc/embed-ccc.html"},
|
||||
},
|
||||
"vostfr": {
|
||||
"1": {"vidzy": "https://vidzy.cc/embed-ddd.html"},
|
||||
"2": {"vidzy": "https://vidzy.cc/embed-eee.html"},
|
||||
},
|
||||
"vo": {},
|
||||
"info": {
|
||||
"1": {"title": "Traqués", "synopsis": "Walt et Jesse...", "poster": "https://img/ep1.jpg"},
|
||||
"2": {"title": "Chasse à l'homme"},
|
||||
},
|
||||
}
|
||||
|
||||
FILM_API = {
|
||||
"players": {
|
||||
"vidzy": {
|
||||
"default": "https://vidzy.live/embed-fff.html",
|
||||
"vff": "https://vidzy.live/embed-fff.html",
|
||||
"vostfr": "https://vidzy.live/embed-ggg.html",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LATEST_HTML = """
|
||||
<div class="short"><div class="short-in nl">
|
||||
<a class="short-poster img-box with-mask" href="/index.php?newsid=15137274" alt="The Drop - Saison 1">
|
||||
<img src="https://image.tmdb.org/t/p/w300/drop.jpg">
|
||||
</a>
|
||||
</div></div>
|
||||
<div class="short"><div class="short-in nl">
|
||||
<a class="short-poster img-box with-mask" href="/index.php?newsid=15139056" alt="La Rumeur">
|
||||
<img src="https://image.tmdb.org/t/p/w300/rumeur.jpg">
|
||||
</a>
|
||||
</div></div>
|
||||
"""
|
||||
|
||||
|
||||
def _soup(html: str):
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
return BeautifulSoup(html, "lxml")
|
||||
|
||||
|
||||
# ------------------------------------------------------------ source
|
||||
|
||||
|
||||
async def test_search(monkeypatch):
|
||||
async def fake_fetch(url, **kwargs):
|
||||
assert url.endswith("/engine/ajax/search.php")
|
||||
assert kwargs["data"] == {"query": "breaking bad", "page": "1"}
|
||||
return SEARCH_HTML
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch", fake_fetch)
|
||||
results = await FrenchStreamScraper().search("breaking bad")
|
||||
assert len(results) == 2
|
||||
serie, film = results
|
||||
assert serie.source_id == "9562"
|
||||
assert serie.media_type == "serie"
|
||||
assert serie.title == "Breaking Bad - Saison 2"
|
||||
assert serie.url == "https://french-stream.lat/9562-breaking-bad-saison-2-streaming-complet-vf-vostfr.html"
|
||||
assert serie.image_url == "https://image.tmdb.org/t/p/w400/bb.jpg"
|
||||
assert film.media_type == "film"
|
||||
assert film.source_id == "1022"
|
||||
|
||||
|
||||
async def test_latest(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
assert url.endswith("/series/")
|
||||
return _soup(LATEST_HTML)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch_soup", fake_fetch_soup)
|
||||
results = await FrenchStreamScraper().latest()
|
||||
assert [(r.source_id, r.media_type) for r in results] == [
|
||||
("15137274", "serie"),
|
||||
("15139056", "film"),
|
||||
]
|
||||
assert results[0].title == "The Drop - Saison 1"
|
||||
|
||||
|
||||
async def test_browse(monkeypatch):
|
||||
"""Parcours par genre : page du genre scrapée, type forcé sur les résultats."""
|
||||
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
assert url.endswith("/medical-series-/")
|
||||
return _soup(LATEST_HTML)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch_soup", fake_fetch_soup)
|
||||
results = await FrenchStreamScraper().browse("serie", "medical")
|
||||
# LATEST_HTML contient un film sans « Saison » : le type est forcé à serie
|
||||
assert [r.media_type for r in results] == ["serie", "serie"]
|
||||
assert results[0].title == "The Drop - Saison 1"
|
||||
|
||||
|
||||
async def test_browse_unknown_category():
|
||||
with pytest.raises(ScrapeError, match="Catégorie inconnue"):
|
||||
await FrenchStreamScraper().browse("film", "inexistant")
|
||||
|
||||
async def test_get_details_serie(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
assert "newsid=9562" in url
|
||||
return _soup(SERIE_DETAILS_HTML)
|
||||
|
||||
async def fake_fetch(url, **kwargs):
|
||||
assert "ep-data.php?id=9562" in url
|
||||
return json.dumps(EP_DATA)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch_soup", fake_fetch_soup)
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch", fake_fetch)
|
||||
details = await FrenchStreamScraper().get_details("9562")
|
||||
assert details.media_type == "serie"
|
||||
assert details.title == "Breaking Bad - Saison 2"
|
||||
assert details.year == 2008
|
||||
assert details.genres == ["Drame", "Crime"]
|
||||
assert details.synopsis == "Walter et Jesse montent leur propre affaire."
|
||||
assert details.image_url == "https://image.tmdb.org/t/p/w400/bb.jpg"
|
||||
assert details.episode_count == 4
|
||||
vf1 = details.episodes[0]
|
||||
assert (vf1.season, vf1.number, vf1.version, vf1.title) == (2, 1.0, "vf", "Traqués")
|
||||
assert vf1.url.endswith("#vf-1")
|
||||
versions = {(e.number, e.version) for e in details.episodes}
|
||||
assert versions == {(1.0, "vf"), (1.0, "vostfr"), (2.0, "vf"), (2.0, "vostfr")}
|
||||
|
||||
|
||||
async def test_get_details_film(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
return _soup(FILM_DETAILS_HTML)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch_soup", fake_fetch_soup)
|
||||
details = await FrenchStreamScraper().get_details("1022")
|
||||
assert details.media_type == "film"
|
||||
assert details.title == "Inception - 2010"
|
||||
assert details.year == 2010
|
||||
assert details.genres == ["Action", "Science-Fiction"]
|
||||
assert "Résumé du film" not in details.synopsis
|
||||
assert details.episode_count == 1
|
||||
assert details.episodes[0].url.endswith("#film")
|
||||
|
||||
|
||||
async def test_list_episodes_film(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
return _soup(FILM_DETAILS_HTML)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch_soup", fake_fetch_soup)
|
||||
episodes = await FrenchStreamScraper().list_episodes("1022")
|
||||
assert len(episodes) == 1
|
||||
assert episodes[0].number == 1
|
||||
|
||||
|
||||
async def test_extract_embed_links_episode(monkeypatch):
|
||||
async def fake_fetch(url, **kwargs):
|
||||
return json.dumps(EP_DATA)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch", fake_fetch)
|
||||
links = await FrenchStreamScraper().extract_embed_links(
|
||||
"https://french-stream.lat/index.php?newsid=9562#vf-1"
|
||||
)
|
||||
assert links == ["https://vidzy.cc/embed-aaa.html", "https://uqload.vc/embed-bbb.html"]
|
||||
|
||||
|
||||
async def test_extract_embed_links_film_dedup(monkeypatch):
|
||||
async def fake_fetch(url, **kwargs):
|
||||
assert "film_api.php?id=1022" in url
|
||||
return json.dumps(FILM_API)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch", fake_fetch)
|
||||
links = await FrenchStreamScraper().extract_embed_links(
|
||||
"https://french-stream.lat/index.php?newsid=1022#film"
|
||||
)
|
||||
assert links == ["https://vidzy.live/embed-fff.html", "https://vidzy.live/embed-ggg.html"]
|
||||
|
||||
|
||||
async def test_extract_embed_links_bad_fragment():
|
||||
with pytest.raises(ScrapeError):
|
||||
await FrenchStreamScraper().extract_embed_links(
|
||||
"https://french-stream.lat/index.php?newsid=9562#nimportequoi"
|
||||
)
|
||||
|
||||
|
||||
async def test_extract_embed_links_invalid_json(monkeypatch):
|
||||
async def fake_fetch(url, **kwargs):
|
||||
return "<html>oops</html>"
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch", fake_fetch)
|
||||
with pytest.raises(ScrapeError):
|
||||
await FrenchStreamScraper().extract_embed_links(
|
||||
"https://french-stream.lat/index.php?newsid=9562#vf-1"
|
||||
)
|
||||
|
||||
|
||||
async def test_unknown_detail_page(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
return _soup("<html><body>404</body></html>")
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch_soup", fake_fetch_soup)
|
||||
with pytest.raises(ScrapeError):
|
||||
await FrenchStreamScraper().get_details("999999999")
|
||||
|
||||
|
||||
def test_registered():
|
||||
import_all_scrapers()
|
||||
source = get_source("french_stream")
|
||||
assert source.media_types == ("serie", "film")
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Tests de la préférence de contenus par utilisateur (animés / séries / les deux)."""
|
||||
|
||||
from app.scrapers.base import SearchResult
|
||||
|
||||
|
||||
class MixedSource:
|
||||
"""Source factice renvoyant animés, séries et films."""
|
||||
|
||||
name = "mixed"
|
||||
label = "Mixed"
|
||||
base_url = "https://mixed.example"
|
||||
media_types = ("anime", "serie")
|
||||
|
||||
async def search(self, query: str) -> list[SearchResult]:
|
||||
return [
|
||||
SearchResult(source="mixed", source_id="a1", title="Animé X", url="https://mixed.example/a1"),
|
||||
SearchResult(source="mixed", source_id="s1", title="Série Y", url="https://mixed.example/s1", media_type="serie"),
|
||||
SearchResult(source="mixed", source_id="f1", title="Film Z", url="https://mixed.example/f1", media_type="film"),
|
||||
]
|
||||
|
||||
async def latest(self) -> list[SearchResult]:
|
||||
return await self.search("")
|
||||
|
||||
|
||||
class AnimeOnlySource:
|
||||
name = "animeonly"
|
||||
label = "AnimeOnly"
|
||||
base_url = "https://animeonly.example"
|
||||
media_types = ("anime",)
|
||||
|
||||
async def search(self, query: str) -> list[SearchResult]:
|
||||
return [
|
||||
SearchResult(source="animeonly", source_id="a2", title="Animé W", url="https://animeonly.example/a2")
|
||||
]
|
||||
|
||||
async def latest(self) -> list[SearchResult]:
|
||||
return await self.search("")
|
||||
|
||||
|
||||
async def _patch_sources(monkeypatch, *sources):
|
||||
import app.routers.search as search_module
|
||||
|
||||
monkeypatch.setattr(search_module, "all_sources", lambda: list(sources))
|
||||
import app.services.discover as discover_module
|
||||
|
||||
async def enabled(name):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(discover_module, "is_source_enabled", enabled)
|
||||
discover_module.discover._cache.clear()
|
||||
monkeypatch.setattr(discover_module, "all_sources", lambda: list(sources))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- endpoints
|
||||
|
||||
|
||||
async def test_me_exposes_default_preference(client, admin_cookies):
|
||||
r = await client.get("/auth/me", cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["content_preference"] == "both"
|
||||
|
||||
|
||||
async def test_preference_roundtrip(client, admin_cookies):
|
||||
r = await client.put(
|
||||
"/auth/preferences", json={"content_preference": "serie"}, cookies=admin_cookies
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert (await client.get("/auth/me", cookies=admin_cookies)).json()["content_preference"] == "serie"
|
||||
|
||||
|
||||
async def test_preference_rejects_invalid_value(client, admin_cookies):
|
||||
r = await client.put(
|
||||
"/auth/preferences", json={"content_preference": "documentaire"}, cookies=admin_cookies
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- filtrage recherche
|
||||
|
||||
|
||||
async def test_search_both_keeps_everything(client, admin_cookies, monkeypatch):
|
||||
await _patch_sources(monkeypatch, MixedSource(), AnimeOnlySource())
|
||||
r = await client.get("/api/search", params={"q": "test"}, cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["count"] == 4 # animés + série + film
|
||||
|
||||
|
||||
async def test_search_anime_filters_serie(client, admin_cookies, monkeypatch):
|
||||
await _patch_sources(monkeypatch, MixedSource(), AnimeOnlySource())
|
||||
await client.put("/auth/preferences", json={"content_preference": "anime"}, cookies=admin_cookies)
|
||||
r = await client.get("/api/search", params={"q": "test"}, cookies=admin_cookies)
|
||||
types = {x["media_type"] for x in r.json()["results"]}
|
||||
assert types == {"anime"} # le mode animés reste sur l'animation (films réels → côté séries)
|
||||
|
||||
|
||||
async def test_search_serie_skips_anime_sources(client, admin_cookies, monkeypatch):
|
||||
"""Préférence séries : les sources anime-only ne sont même pas interrogées."""
|
||||
await _patch_sources(monkeypatch, MixedSource(), AnimeOnlySource())
|
||||
await client.put("/auth/preferences", json={"content_preference": "serie"}, cookies=admin_cookies)
|
||||
|
||||
queried = []
|
||||
|
||||
class SpySource(AnimeOnlySource):
|
||||
async def search(self, query: str) -> list[SearchResult]:
|
||||
queried.append(self.name)
|
||||
return await super().search(query)
|
||||
|
||||
import app.routers.search as search_module
|
||||
|
||||
monkeypatch.setattr(search_module, "all_sources", lambda: [MixedSource(), SpySource()])
|
||||
|
||||
r = await client.get("/api/search", params={"q": "test"}, cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
assert queried == [] # source anime-only ignorée
|
||||
assert {x["media_type"] for x in r.json()["results"]} == {"serie", "film"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- filtrage découverte
|
||||
|
||||
|
||||
async def test_discover_latest_filtered_by_preference(client, admin_cookies, monkeypatch):
|
||||
"""Rails par type : chaque mode n'expose que ses rails (les films suivent les séries)."""
|
||||
await _patch_sources(monkeypatch, MixedSource())
|
||||
|
||||
r = await client.get("/api/discover", cookies=admin_cookies)
|
||||
data = r.json()
|
||||
assert {i["media_type"] for i in data["latest_anime"]} == {"anime"}
|
||||
assert {i["media_type"] for i in data["latest_serie"]} == {"serie", "film"}
|
||||
|
||||
await client.put("/auth/preferences", json={"content_preference": "serie"}, cookies=admin_cookies)
|
||||
data = (await client.get("/api/discover", cookies=admin_cookies)).json()
|
||||
assert data["latest_anime"] == []
|
||||
assert {i["media_type"] for i in data["latest_serie"]} == {"serie", "film"}
|
||||
|
||||
await client.put("/auth/preferences", json={"content_preference": "anime"}, cookies=admin_cookies)
|
||||
data = (await client.get("/api/discover", cookies=admin_cookies)).json()
|
||||
assert {i["media_type"] for i in data["latest_anime"]} == {"anime"}
|
||||
assert data["latest_serie"] == []
|
||||
|
||||
async def test_discover_hides_kitsu_sections_in_serie_mode(client, admin_cookies, monkeypatch):
|
||||
"""Incontournables et Pour toi (Kitsu = animés) disparaissent en mode séries."""
|
||||
import app.services.discover as discover_module
|
||||
|
||||
await _patch_sources(monkeypatch, MixedSource())
|
||||
|
||||
async def fake_must_watch(limit=20):
|
||||
return [{"kitsu_id": 1, "title": "Animé top"}]
|
||||
|
||||
async def fake_for_you(user_id=0, limit=20):
|
||||
return {"based_on": ["Action"], "items": [{"kitsu_id": 2}]}
|
||||
|
||||
monkeypatch.setattr(discover_module.discover, "must_watch", fake_must_watch)
|
||||
monkeypatch.setattr(discover_module.discover, "for_you", fake_for_you)
|
||||
|
||||
r = await client.get("/api/discover", cookies=admin_cookies)
|
||||
assert len(r.json()["must_watch"]) == 1
|
||||
assert len(r.json()["for_you"]["items"]) == 1
|
||||
|
||||
await client.put("/auth/preferences", json={"content_preference": "serie"}, cookies=admin_cookies)
|
||||
data = (await client.get("/api/discover", cookies=admin_cookies)).json()
|
||||
assert data["must_watch"] == []
|
||||
assert data["for_you"] == {"based_on": [], "items": []}
|
||||
|
||||
+19
-2
@@ -128,9 +128,26 @@ async def test_torznab_tvsearch_all_episodes(client, apikey, fake_source):
|
||||
assert "Frieren S02E01 VOSTFR WEB-DL" in titles
|
||||
|
||||
|
||||
async def test_torznab_search_requires_query(client, apikey):
|
||||
async def test_torznab_search_without_query_serves_latest(client, apikey, fake_source):
|
||||
# Sans q : flux RSS des nouveautés (RSS sync / test Sonarr), pas une erreur 400.
|
||||
async def fake_latest():
|
||||
return [
|
||||
SearchResult(
|
||||
source="fake",
|
||||
source_id="frieren-1",
|
||||
title="Frieren",
|
||||
url="https://fake.example/frieren",
|
||||
)
|
||||
]
|
||||
|
||||
fake_source.latest = fake_latest
|
||||
r = await client.get("/torznab/api", params={"t": "search", "apikey": apikey})
|
||||
assert r.status_code == 400
|
||||
assert r.status_code == 200
|
||||
root = ET.fromstring(r.text)
|
||||
items = root.findall(".//item")
|
||||
assert len(items) == 1
|
||||
# seul le dernier épisode de la nouveauté est publié (S02E01 > S01E02 > S01E01)
|
||||
assert items[0].find("title").text == "Frieren S02E01 VOSTFR WEB-DL"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- grab
|
||||
|
||||
+27
-50
@@ -3,8 +3,8 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.config import get_settings
|
||||
from app.services import update as update_service
|
||||
from app.services.settings import get_update_config
|
||||
from app.version import get_version
|
||||
|
||||
|
||||
@@ -15,6 +15,12 @@ def reset_update_cache():
|
||||
update_service.invalidate_cache()
|
||||
|
||||
|
||||
def _patch_gitea(monkeypatch, url: str = "https://git.example", repo: str = "roman/ohm") -> None:
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, "gitea_url", url)
|
||||
monkeypatch.setattr(settings, "gitea_repo", repo)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- semver / version
|
||||
|
||||
|
||||
@@ -41,18 +47,9 @@ def test_get_version_env_override(monkeypatch):
|
||||
# ---------------------------------------------------------------- détection Gitea
|
||||
|
||||
|
||||
async def test_fetch_latest_without_repo():
|
||||
from app.services.settings import set_update_config
|
||||
|
||||
await set_update_config("https://git.example", "", "tok")
|
||||
assert await update_service.fetch_latest_version(force=True) is None
|
||||
|
||||
|
||||
async def test_fetch_latest_public_repo_no_token(monkeypatch):
|
||||
"""Dépôt public : pas de jeton → appel sans en-tête Authorization."""
|
||||
from app.services.settings import set_update_config
|
||||
|
||||
await set_update_config("https://git.example", "roman/ohm", "")
|
||||
async def test_fetch_latest_no_auth_header(monkeypatch):
|
||||
"""Dépôt public : appel sans en-tête Authorization."""
|
||||
_patch_gitea(monkeypatch)
|
||||
|
||||
class FakeResponse:
|
||||
def raise_for_status(self) -> None:
|
||||
@@ -73,17 +70,20 @@ async def test_fetch_latest_public_repo_no_token(monkeypatch):
|
||||
|
||||
|
||||
async def test_fetch_latest_picks_highest_semver(monkeypatch):
|
||||
"""Avec jeton : en-tête Authorization + plus haut semver retenu, puis cache."""
|
||||
from app.services.settings import set_update_config
|
||||
|
||||
await set_update_config("https://git.example", "roman/ohm", "tok")
|
||||
"""Plus haut semver retenu + patchnote du tag, puis cache."""
|
||||
_patch_gitea(monkeypatch)
|
||||
|
||||
class FakeResponse:
|
||||
def raise_for_status(self) -> None:
|
||||
pass
|
||||
|
||||
def json(self) -> list[dict]:
|
||||
return [{"name": "v0.1.0"}, {"name": "v0.2.3"}, {"name": "v1.0.0-rc"}, {"name": "divers"}]
|
||||
return [
|
||||
{"name": "v0.1.0", "message": "ancien"},
|
||||
{"name": "v0.2.3", "message": "correctifs\n"},
|
||||
{"name": "v1.0.0-rc"},
|
||||
{"name": "divers"},
|
||||
]
|
||||
|
||||
calls: list[tuple[str, dict]] = []
|
||||
|
||||
@@ -92,21 +92,18 @@ async def test_fetch_latest_picks_highest_semver(monkeypatch):
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
|
||||
latest = await update_service.fetch_latest_version(force=True)
|
||||
status = await update_service.status()
|
||||
|
||||
assert latest == "v0.2.3"
|
||||
assert calls == [
|
||||
("https://git.example/api/v1/repos/roman/ohm/tags?limit=20", {"Authorization": "token tok"})
|
||||
]
|
||||
assert status["latest"] == "v0.2.3"
|
||||
assert status["notes"] == "correctifs"
|
||||
assert calls == [("https://git.example/api/v1/repos/roman/ohm/tags?limit=20", {})]
|
||||
# puis servi par le cache (plus d'appel réseau)
|
||||
assert await update_service.fetch_latest_version() == "v0.2.3"
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
async def test_fetch_latest_network_error_degrades(monkeypatch):
|
||||
from app.services.settings import set_update_config
|
||||
|
||||
await set_update_config("https://git.example", "roman/ohm", "tok")
|
||||
_patch_gitea(monkeypatch)
|
||||
|
||||
async def fake_get(self: httpx.AsyncClient, url: str, headers: dict | None = None):
|
||||
raise httpx.ConnectError("injoignable")
|
||||
@@ -148,33 +145,13 @@ async def test_update_status_defaults(client, admin_cookies, monkeypatch):
|
||||
r = await client.get("/api/admin/update", cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["configured"] is True and data["docker"] is False # repo par défaut présent
|
||||
assert data["latest"] is None and data["update_available"] is False
|
||||
assert data["config"]["gitea_url"] == "https://git.lanro.eu"
|
||||
assert data["config"]["repo"] == "Roman/ohm_streaming"
|
||||
assert data["docker"] is False and data["notes"] is None
|
||||
|
||||
|
||||
async def test_update_save_config_normalizes(client, admin_cookies):
|
||||
r = await client.put(
|
||||
"/api/admin/update",
|
||||
json={"gitea_url": "https://git.example/", "repo": "/roman/ohm/", "token": " tok "},
|
||||
cookies=admin_cookies,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert await get_update_config() == {
|
||||
"gitea_url": "https://git.example",
|
||||
"repo": "roman/ohm",
|
||||
"token": "tok",
|
||||
}
|
||||
|
||||
|
||||
async def test_update_check_degrades_gracefully(client, admin_cookies):
|
||||
# Gitea configuré mais injoignable : le check répond quand même (latest=None)
|
||||
await client.put(
|
||||
"/api/admin/update",
|
||||
json={"gitea_url": "https://git.inexistant", "repo": "roman/ohm", "token": "tok"},
|
||||
cookies=admin_cookies,
|
||||
)
|
||||
async def test_update_check_degrades_gracefully(client, admin_cookies, monkeypatch):
|
||||
# Gitea injoignable : le check répond quand même (latest=None)
|
||||
_patch_gitea(monkeypatch, url="https://git.inexistant", repo="roman/ohm")
|
||||
r = await client.post("/api/admin/update/check", cookies=admin_cookies)
|
||||
assert r.status_code == 200 and r.json()["latest"] is None
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Tests du scraper VoirAnime (source + extracteur prepare) sur fixtures HTML réelles."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.scrapers.base import ScrapeError, get_source, import_all_scrapers, resolve_hoster
|
||||
from app.scrapers.hosters.voiranime import VoirAnimeExtractor
|
||||
from app.scrapers.sources.voiranime import VoirAnimeScraper
|
||||
|
||||
# ------------------------------------------------------------ fixtures HTML
|
||||
|
||||
SEARCH_HTML = """
|
||||
<div class="catalogue-grid">
|
||||
<article class="catalogue-card catalogue-media-card">
|
||||
<a href="/catalogue/one-piece" class="catalogue-poster">
|
||||
<img src="https://img.example/one-piece.jpg" alt="one piece">
|
||||
</a>
|
||||
</article>
|
||||
<article class="catalogue-card catalogue-media-card">
|
||||
<a href="/catalogue/one-punch-man" class="catalogue-poster">
|
||||
<img src="https://img.example/opm.jpg" alt="one punch man">
|
||||
</a>
|
||||
</article>
|
||||
<article class="catalogue-card catalogue-media-card">
|
||||
<a href="/catalogue/one-outs" class="catalogue-poster">
|
||||
<img src="/assets/img/placeholders/content-placeholder.svg" alt="one outs">
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
"""
|
||||
|
||||
DETAILS_HTML = """
|
||||
<h1>solo leveling</h1>
|
||||
<div class="media-detail-meta"><span>26 épisodes</span></div>
|
||||
<img src="https://img.example/solo.jpg" alt="Affiche de solo leveling">
|
||||
<div class="media-detail-story">
|
||||
<span class="media-detail-section-label">Synopsis</span>
|
||||
<p> Sung Jinwoo, chasseur le plus faible. </p>
|
||||
</div>
|
||||
<a href="/lecteur/965/29054" class="detail-episode-card" data-season="1">
|
||||
<small>S1 · E1</small><span>Episode 1</span>
|
||||
</a>
|
||||
<a href="/lecteur/965/29055" class="detail-episode-card" data-season="1">
|
||||
<small>S1 · E2</small><span>Episode 2</span>
|
||||
</a>
|
||||
<a href="/lecteur/965/29060" class="detail-episode-card" data-season="2">
|
||||
<small>S2 · E1</small><span>Episode 1</span>
|
||||
</a>
|
||||
<a href="/lecteur/965/29061" class="detail-episode-card" data-season="2">
|
||||
<small>S2 · E2.5</small><span>Episode 2.5</span>
|
||||
</a>
|
||||
"""
|
||||
|
||||
PLAYER_PAGE_1 = """
|
||||
<main class="tv-player" data-player
|
||||
data-content-id="1626" data-episode-id="46416" data-source-id="169261"
|
||||
data-prepare-url="/lecteur/prepare/169261?content=1626&episode=46416"
|
||||
data-fallback-source-url="/lecteur/1626/46416?lang=vostfr&source=169262&previous_source=169261">
|
||||
</main>
|
||||
"""
|
||||
|
||||
# fin de chaîne : le fallback repointe vers la source 1 (aller-retour observé en live)
|
||||
PLAYER_PAGE_2 = """
|
||||
<main class="tv-player" data-player
|
||||
data-content-id="1626" data-episode-id="46416" data-source-id="169262"
|
||||
data-prepare-url="/lecteur/prepare/169262?content=1626&episode=46416"
|
||||
data-fallback-source-url="/lecteur/1626/46416?lang=vostfr&source=169261&previous_source=169262">
|
||||
</main>
|
||||
"""
|
||||
|
||||
HOME_HTML = """
|
||||
<div class="carousel" id="carousel-new">
|
||||
<article class="content-card">
|
||||
<a href="/catalogue/you-and-i-are-polar-opposites" class="card-poster">
|
||||
<img src="https://img.example/polar.jpg" alt="you and i are polar opposites">
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
<div class="carousel" id="carousel-added">
|
||||
<article class="content-card">
|
||||
<a href="/catalogue/you-and-i-are-polar-opposites" class="card-poster">
|
||||
<img src="https://img.example/polar.jpg" alt="you and i are polar opposites">
|
||||
</a>
|
||||
</article>
|
||||
<article class="content-card">
|
||||
<a href="/catalogue/solo-leveling" class="card-poster">
|
||||
<img src="https://img.example/solo.jpg" alt="solo leveling">
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _soup(html: str):
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
return BeautifulSoup(html, "lxml")
|
||||
|
||||
|
||||
def _prepare_payload(url: str, referer: str) -> str:
|
||||
raw = json.dumps({"url": url, "referer": referer, "kind": "file"}).encode()
|
||||
return base64.b64encode(raw).decode()
|
||||
|
||||
|
||||
# ------------------------------------------------------------ source
|
||||
|
||||
|
||||
async def test_search_parses_catalogue_cards(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
assert "q=one+piece" in url
|
||||
return _soup(SEARCH_HTML)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
|
||||
results = await VoirAnimeScraper().search("one piece")
|
||||
assert [r.source_id for r in results] == ["one-piece", "one-punch-man", "one-outs"]
|
||||
assert results[0].title == "one piece"
|
||||
assert results[0].url == "https://voiranime.xyz/catalogue/one-piece"
|
||||
assert results[0].image_url == "https://img.example/one-piece.jpg"
|
||||
assert results[2].image_url == "/assets/img/placeholders/content-placeholder.svg"
|
||||
|
||||
|
||||
async def test_get_details_and_episodes(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
assert url.endswith("/catalogue/solo-leveling")
|
||||
return _soup(DETAILS_HTML)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
|
||||
scraper = VoirAnimeScraper()
|
||||
details = await scraper.get_details("solo-leveling")
|
||||
assert details.title == "solo leveling"
|
||||
assert details.episode_count == 26
|
||||
assert details.image_url == "https://img.example/solo.jpg"
|
||||
assert details.synopsis.startswith("Sung Jinwoo")
|
||||
|
||||
episodes = details.episodes
|
||||
assert len(episodes) == 4
|
||||
assert (episodes[0].season, episodes[0].number) == (1, 1.0)
|
||||
assert (episodes[2].season, episodes[2].number) == (2, 1.0)
|
||||
assert (episodes[3].season, episodes[3].number) == (2, 2.5)
|
||||
assert episodes[0].url == "https://voiranime.xyz/lecteur/965/29054"
|
||||
|
||||
standalone = await scraper.list_episodes("solo-leveling")
|
||||
assert len(standalone) == len(episodes)
|
||||
|
||||
|
||||
async def test_extract_embed_links_follows_fallback_chain(monkeypatch):
|
||||
pages = {
|
||||
"https://voiranime.xyz/lecteur/1626/46416": PLAYER_PAGE_1,
|
||||
"https://voiranime.xyz/lecteur/1626/46416?lang=vostfr&source=169262&previous_source=169261": PLAYER_PAGE_2,
|
||||
}
|
||||
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
return _soup(pages[url])
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
|
||||
urls = await VoirAnimeScraper().extract_embed_links("https://voiranime.xyz/lecteur/1626/46416")
|
||||
assert urls == [
|
||||
"https://voiranime.xyz/lecteur/prepare/169261?content=1626&episode=46416",
|
||||
"https://voiranime.xyz/lecteur/prepare/169262?content=1626&episode=46416",
|
||||
]
|
||||
|
||||
|
||||
async def test_extract_embed_links_no_player_raises(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
return _soup("<html><body>pas de lecteur</body></html>")
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
|
||||
with pytest.raises(ScrapeError):
|
||||
await VoirAnimeScraper().extract_embed_links("https://voiranime.xyz/lecteur/1/2")
|
||||
|
||||
|
||||
async def test_latest_merges_carousels_dedup(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
assert url == "https://voiranime.xyz/"
|
||||
return _soup(HOME_HTML)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
|
||||
results = await VoirAnimeScraper().latest()
|
||||
assert [r.source_id for r in results] == ["you-and-i-are-polar-opposites", "solo-leveling"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------ extracteur hoster
|
||||
|
||||
|
||||
async def test_hoster_decodes_prepare_payload(monkeypatch):
|
||||
payload = _prepare_payload(
|
||||
"https://video.sibnet.ru/v/abc123/6235096.mp4", "https://video.sibnet.ru/"
|
||||
)
|
||||
|
||||
async def fake_fetch(url, **kwargs):
|
||||
assert "X-Requested-With" in kwargs.get("headers", {})
|
||||
assert kwargs.get("referer") == "https://voiranime.xyz/lecteur/1626/46416"
|
||||
return json.dumps(
|
||||
{"success": True, "media_type": "mp4", "stream_url": f"/proxy/media?payload={payload}"}
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.hosters.voiranime.fetch", fake_fetch)
|
||||
link = await VoirAnimeExtractor().extract(
|
||||
"https://voiranime.xyz/lecteur/prepare/169261?content=1626&episode=46416"
|
||||
)
|
||||
assert link.url == "https://video.sibnet.ru/v/abc123/6235096.mp4"
|
||||
assert link.hoster == "voiranime"
|
||||
assert link.headers == {"Referer": "https://video.sibnet.ru/"}
|
||||
assert link.is_hls is False
|
||||
|
||||
|
||||
async def test_hoster_marks_hls(monkeypatch):
|
||||
payload = _prepare_payload("https://cdn.example/hls/master.m3u8", "https://cdn.example/")
|
||||
|
||||
async def fake_fetch(url, **kwargs):
|
||||
return json.dumps(
|
||||
{"success": True, "media_type": "hls", "stream_url": f"/proxy/media?payload={payload}"}
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.hosters.voiranime.fetch", fake_fetch)
|
||||
link = await VoirAnimeExtractor().extract("https://voiranime.xyz/lecteur/prepare/1?content=2&episode=3")
|
||||
# HLS signé pour le backend du site → on sert l'URL proxy du site, pas le CDN
|
||||
assert link.url == f"https://voiranime.xyz/proxy/media?payload={payload}"
|
||||
assert link.is_hls is True
|
||||
assert link.headers == {"Referer": "https://voiranime.xyz/"}
|
||||
|
||||
|
||||
async def test_hoster_prepare_failure_raises(monkeypatch):
|
||||
async def fake_fetch(url, **kwargs):
|
||||
return json.dumps({"success": False, "error": "Flux extrait inaccessible"})
|
||||
|
||||
monkeypatch.setattr("app.scrapers.hosters.voiranime.fetch", fake_fetch)
|
||||
with pytest.raises(ScrapeError, match="Flux extrait inaccessible"):
|
||||
await VoirAnimeExtractor().extract("https://voiranime.xyz/lecteur/prepare/1?content=2&episode=3")
|
||||
|
||||
|
||||
def test_source_and_hoster_registered():
|
||||
import_all_scrapers()
|
||||
assert get_source("voiranime").label == "VoirAnime"
|
||||
extractor = resolve_hoster("https://voiranime.xyz/lecteur/prepare/169261?content=1&episode=2")
|
||||
assert extractor is not None and extractor.name == "voiranime"
|
||||
Reference in New Issue
Block a user