15 Commits
Author SHA1 Message Date
Roman f79c0cc86e Distribution publique GHCR (miroir, aucun compte requis) + avertissement légal dans le README et le label OCI 2026-09-25 16:36:03 +00:00
Roman 00aae109b0 Déploiement Podman : variante compose (socket podman pour Watchtower) + doc 2026-09-25 16:01:03 +00:00
Roman c7e178eecf v0.2.0 2026-09-25 15:39:35 +00:00
Roman 12bdc8a7ef release.sh : tag annoté avec patchnote (affiché par le panneau MAJ) 2026-09-25 15:39:07 +00:00
Roman eae97f9129 Panneau MAJ simplifié : dépôt public, plus de configuration Gitea — patchnote du dernier tag 2026-09-25 15:39:00 +00:00
Roman fed61adc56 Pour toi : recommandations diverses et déjà-possédé réellement exclu
Deux défauts enchaînés : la requête Kitsu cumulait les genres en ET
(quatre catégories → 1 seul titre, précisément le téléchargement à
l'origine des genres), et l'exclusion échouait car « Titre - Saison 1 -
E3 » ne normalisait jamais vers « Titre » canonique.

- Une requête Kitsu par genre, fusion entrelacée (chaque genre contribue)
- normalize_title retire saison/épisode en boucle avec frontières de mots
  (« - Saison 1 - E3 », « S1 E1 » → titre canonique ; « Série 9 » intact)
- test de non-régession : le possédé avec marqueurs n'est plus recommandé
2026-09-25 15:23:29 +00:00
Roman d5fa546dd7 Pour toi : fallback sans Sonarr et amorçage sans historique
- Sans Sonarr, les séries/films téléchargés donnent leurs genres via leur
  fiche French-Stream (page_url des téléchargements), convertis vers les
  catégories Kitsu (mapping FR→EN ; genres sans équivalent ignorés)
- Aucun historique (ni téléchargement, ni favori, ni Sonarr) : réponse
  cold_start non cachée → carte d'amorçage « téléchargez un premier titre »
- Fin de téléchargement → invalidation immédiate des recommandations
- genres_of_page ignore les fiches hors French-Stream (URLs d'animés)
2026-09-25 15:14:14 +00:00
Roman c45f1b79fd Cache-busting des assets statiques
Le navigateur servait un CSS périmé après mise à jour (aucun invalidateur
sur /static). Les liens CSS/JS portent désormais ?v=<mtime des fichiers
statiques> : toute modification du design est visible immédiatement chez
tous les clients, sans hard-refresh ni bump manuel de version.
2026-09-25 14:58:42 +00:00
Roman d747c574b8 Découvrir v2 : rails par type, exploration par genre
- Rails Nouveautés séparés : 🆕 animés (tri date Kitsu) et 🆕 séries & films
  (French-Stream) — chaque type garde son rail, plus d'écrasement mutuel
- Section 🎭 Explorer : chips type × genre — animés via catégories Kitsu,
  séries (/<genre>-series-/) et films (/films/<genre>/) via French-Stream ;
  état dans l'URL (/discover?t=serie&g=medical), partageable
- Scraper : browse(media_type, genre) + catalogue YAML surchargeable ;
  parsing div.short mutualisé avec les nouveautés
- Films réels retirés du mode « Animés » (ils suivent le rail séries)
- Endpoints /api/discover (latest_anime/latest_serie), /api/discover/genres,
  /api/discover/browse — parcours filtré par la préférence du compte
- Warmup démarrage : latest_by_type()
2026-09-25 14:48:29 +00:00
Roman 248ec60d35 Découverte : le filtre par préférence s'applique avant la troncature du rail
En mode séries, le rail Nouveautés était vide : discover.latest() triait par
date de sortie Kitsu (catalogue animés uniquement) puis tronquait à 24 AVANT
le filtrage — les séries et films réels, absents de Kitsu (donc sans date),
étaient systématiquement évincés.

- allowed (types autorisés par préférence) descend dans discover.latest()
  et filtre dès la collecte des sources
- remplissage du rail équilibré par type (round-robin) : en mode « les deux »,
  8 animés / 8 séries / 8 films au lieu de 24 animés
- test de non-régression : 9 séries conservées face à 30 animés datés
2026-09-25 14:22:10 +00:00
Roman 79a44297fd Ajout de la source French-Stream (french-stream.lat) : séries et films
- Scraper DataLife Engine : recherche AJAX, fiches par saison, épisodes via
  ep-data.php (VF/VOSTFR), films via film_api.php, nouveautés /series/
- Config YAML externalisée (sélecteurs réparables sans coder)
- Domaines étendus : uqload.vc, vidzy.cc/.live
- 11 tests (126 au total)
2026-09-25 14:08:10 +00:00
Roman 89d9a98f80 Découverte : masquer Incontournables et Pour toi en mode séries
Ces deux sections sont issues de Kitsu (catalogue animés) — elles
n'ont pas de sens pour un compte en préférence « séries » et montraient
des animés malgré le filtre des nouveautés. Elles ne sont même plus
calculées (aucun appel Kitsu) et l'UI masque les sections vides.

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

Prête pour la première source de séries : il ne reste qu'un module
SourceScraper avec media_types=("serie",) à écrire.
2026-09-23 15:02:59 +00:00
Roman 65761885ba Corrections : test Torznab périmé et retries sur erreurs définitives
- test_torznab_search_requires_query attendait un 400 mais le commit
  853b4e0 a volontairement fait servir les nouveautés quand q est
  absent (RSS sync Sonarr) — le test valide désormais ce comportement.
- fetch() ne retente plus les 4xx définitifs (sauf 408/429) : un
  lecteur VoirAnime en échec 422 coûtait 2 requêtes et 3 s de pauses
  avant de basculer sur le lecteur suivant.
- 4 nouveaux tests unitaires du client HTTP (fail-fast + retries).
2026-09-23 12:42:24 +00:00
Roman 7399f4b781 Ajout de la source VoirAnime (voiranime.xyz)
- Scraper catalogue : recherche /catalogue?q=, fiches + épisodes
  (cartes S·E, multi-saisons), chaîne de lecteurs prepare/fallback
  avec dédoublonnage par id de source, latest via carrousels accueil.
- Extracteur hoster : endpoint prepare (AJAX) → MP4 direct décodé du
  payload (Referer d'origine) ou HLS relayé par le proxy du site
  (CDN signés pour leur backend → accès direct en 403).
- fetch() accepte désormais des en-têtes additionnels (X-Requested-With).
- 9 tests sur fixtures HTML réelles ; README mis à jour.
2026-09-23 10:51:29 +00:00
41 changed files with 2589 additions and 283 deletions
+2
View File
@@ -18,6 +18,8 @@ RUN uv sync --frozen --no-dev --no-cache
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
FROM python:3.13-slim-bookworm 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), # ffmpeg (téléchargements HLS), ca-certificates (scraping HTTPS),
# gosu (bascule utilisateur non-root dans l'entrypoint) # gosu (bascule utilisateur non-root dans l'entrypoint)
RUN apt-get update \ RUN apt-get update \
+70 -15
View File
@@ -3,10 +3,34 @@
Application web **auto-hébergée** (homelab) : centre de contrôle unique pour découvrir, 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. 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é ## Déploiement (Docker) — recommandé
Le déploiement officiel passe par Docker Compose : l'image (ffmpeg inclus) est 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) ### 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é 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 recommandé — voir « Bibliothèque Plex/Sonarr » ci-dessous), le port, génère les
secrets, branche le montage et démarre. Non interactif aussi : 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 ### À la main
```bash ```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 git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming
docker login git.lanro.eu
# 2. Configuration locale # 2. Configuration locale
cp .env.example .env cp .env.example .env
@@ -36,7 +59,33 @@ docker compose up -d
``` ```
Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**. 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 ### Bibliothèque Plex / Sonarr existante
@@ -111,24 +160,30 @@ Variables d'environnement (préfixe `OHM_`, voir `.env.example`) :
## Fonctionnalités ## 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 un module interchangeable activable/désactivable à chaud (page Admin), dont l'URL
est modifiable à la volée (utile si un site change de domaine). est modifiable à la volée (utile si un site change de domaine).
- **Extraction en 2 niveaux** : page d'épisode → lecteurs embarqués → URL directe - **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. - **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, - **Téléchargements** : file parallèle, pause/reprise (HTTP Range), retry, anti-doublons,
progression en temps réel (SSE), persistance au redémarrage. progression en temps réel (SSE), persistance au redémarrage.
- **Bibliothèque locale** : streaming avec range requests, reprise de lecture, - **Bibliothèque locale** : streaming avec range requests, reprise de lecture,
navigation épisode suivant/précédent. navigation épisode suivant/précédent.
- **Comptes** : JWT court + refresh token (rotation), rôles admin/utilisateur, - **Comptes** : JWT court + refresh token (rotation), rôles admin/utilisateur,
administration des comptes. administration des comptes.
- **Découverte** (`/discover`) : 🆕 nouveautés fusionnées de toutes les sources, triées - **Découverte** (`/discover`) : 🎭 exploration par genre (animés via Kitsu, séries/films
par date de sortie réelle (enrichissement Kitsu, badge « en cours de diffusion »), via French-Stream — état dans l'URL, partageable), 🆕 nouveautés en rails séparés
🔥 incontournables (top popularité Kitsu) et ✨ recommandations par genres déduites (animés triés par date de sortie via Kitsu ; séries & films de French-Stream), 🔥
des téléchargements et favoris, titres déjà possédés exclus (cache mémoire). 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) ## Intégration Sonarr / Prowlarr (*arr)
@@ -198,7 +253,7 @@ app/
├── scrapers/ ├── scrapers/
│ ├── base.py # contrats SourceScraper/HosterExtractor + registres │ ├── base.py # contrats SourceScraper/HosterExtractor + registres
│ ├── configs/ # sélecteurs YAML externalisés (réparer sans coder) │ ├── 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 ├── services/ # downloads, kitsu, discover, sonarr, torznab, settings
└── templates/ + static/ # UI htmx + Alpine.js, thème sombre └── templates/ + static/ # UI htmx + Alpine.js, thème sombre
``` ```
@@ -209,6 +264,6 @@ app/
## Tests ## Tests
```bash ```bash
uv run pytest # 86 tests uv run pytest # 132 tests
uv run ruff check . # lint uv run ruff check . # lint
``` ```
+26 -4
View File
@@ -25,6 +25,10 @@ class User:
username: str username: str
is_admin: bool is_admin: bool
is_active: bool is_active: bool
content_preference: str = "both" # anime | serie | both
VALID_CONTENT_PREFERENCES = ("anime", "serie", "both")
# ---------------------------------------------------------------- mots de passe # ---------------------------------------------------------------- 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: async def authenticate(username: str, password: str) -> User | None:
row = await db.fetchone( 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(),), (username.strip(),),
) )
if row is None or not verify_password(password, row["password_hash"]): 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) logger.warning("Compte désactivé : %r", username)
return None return None
return User( 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: async def get_user(user_id: int) -> User | None:
row = await db.fetchone( 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"]: if row is None or not row["is_active"]:
return None return None
return User( 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
View File
@@ -34,7 +34,10 @@ class Settings(BaseSettings):
kitsu_base_url: str = "https://kitsu.io/api/edge" kitsu_base_url: str = "https://kitsu.io/api/edge"
metadata_cache_ttl_hours: int = 72 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_url: str = ""
watchtower_token: str = "" watchtower_token: str = ""
+12
View File
@@ -11,6 +11,8 @@ CREATE TABLE IF NOT EXISTS users (
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0, is_admin INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1, 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')) 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 journal_mode=WAL")
await self._conn.execute("PRAGMA foreign_keys=ON") await self._conn.execute("PRAGMA foreign_keys=ON")
await self._conn.executescript(SCHEMA) await self._conn.executescript(SCHEMA)
await self._migrate()
await self._conn.commit() 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: async def close(self) -> None:
if self._conn is not None: if self._conn is not None:
await self._conn.close() await self._conn.close()
+1 -1
View File
@@ -45,7 +45,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await db.connect() await db.connect()
await apply_source_base_urls() await apply_source_base_urls()
await download_manager.start() 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) warmup.add_done_callback(_log_task_error)
logger.info("%s prêt", settings.app_name) logger.info("%s prêt", settings.app_name)
yield yield
+1 -13
View File
@@ -28,7 +28,6 @@ from app.services.settings import (
set_source_base_url, set_source_base_url,
set_source_enabled, set_source_enabled,
set_source_health, set_source_health,
set_update_config,
) )
from app.services.sonarr import sonarr from app.services.sonarr import sonarr
from app.services.update import UpdateError, fetch_latest_version, trigger_update 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 # ---------------------------------------------------------------- mise à jour logicielle
class UpdateConfig(BaseModel):
gitea_url: str
repo: str
token: str
@router.get("/update") @router.get("/update")
async def get_update(admin: AdminUser) -> dict: 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() 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") @router.post("/update/check")
async def check_update(admin: AdminUser) -> dict: async def check_update(admin: AdminUser) -> dict:
"""Force la re-vérification de la dernière version (ignore le cache).""" """Force la re-vérification de la dernière version (ignore le cache)."""
+20 -2
View File
@@ -3,7 +3,7 @@
import logging import logging
from typing import Annotated 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 fastapi.responses import RedirectResponse
from app import auth from app import auth
@@ -107,4 +107,22 @@ async def logout(user: CurrentUser) -> RedirectResponse:
@router.get("/me") @router.get("/me")
async def me(user: CurrentUser) -> dict: 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
View File
@@ -1,4 +1,4 @@
"""Découverte : nouveautés des sources, incontournables, recommandations.""" """Découverte : nouveautés par type, incontournables, recommandations, exploration."""
import logging import logging
from typing import Annotated from typing import Annotated
@@ -6,7 +6,9 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from app.routers.auth import CurrentUser, current_user 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__) logger = logging.getLogger(__name__)
@@ -20,8 +22,61 @@ async def get_discover(
must_watch_limit: Annotated[int, Query(ge=1, le=20)] = 20, must_watch_limit: Annotated[int, Query(ge=1, le=20)] = 20,
for_you_limit: Annotated[int, Query(ge=1, le=20)] = 20, for_you_limit: Annotated[int, Query(ge=1, le=20)] = 20,
) -> dict: ) -> 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) must_watch = await discover.must_watch(must_watch_limit)
for_you = await discover.for_you(user.id, for_you_limit) for_you = await discover.for_you(user.id, for_you_limit)
latest = await discover.latest(latest_limit) else:
return {"latest": latest, "must_watch": must_watch, "for_you": for_you} 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)}
+7
View File
@@ -12,6 +12,13 @@ protected = APIRouter(tags=["pages"], include_in_schema=False, dependencies=[Dep
templates = Jinja2Templates(directory=BASE_DIR / "app" / "templates") 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) @router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request) -> HTMLResponse: async def login_page(request: Request) -> HTMLResponse:
+29 -5
View File
@@ -7,7 +7,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query 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 ( from app.scrapers.base import (
ScrapeError, ScrapeError,
SourceScraper, SourceScraper,
@@ -27,13 +27,35 @@ router = APIRouter(prefix="/api", tags=["search"], dependencies=[Depends(current
import_all_scrapers() import_all_scrapers()
kitsu = KitsuService() 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 = [] sources = []
for source in all_sources(): for source in all_sources():
if await is_source_enabled(source.name): if await is_source_enabled(source.name):
sources.append(source) sources.append(source)
return sources
@router.get("/sources") @router.get("/sources")
@@ -52,9 +74,10 @@ async def list_sources() -> list[dict]:
@router.get("/search") @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.""" """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]: async def safe_search(source: SourceScraper) -> tuple[list, str | None]:
try: 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)) outcomes = await asyncio.gather(*(safe_search(s) for s in sources))
results = [item for items, _ in outcomes for item in items] 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] failed = [name for _, name in outcomes if name]
return {"query": q, "count": len(results), "results": results, "failed_sources": failed} return {"query": q, "count": len(results), "results": results, "failed_sources": failed}
+60
View File
@@ -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"}
+2 -2
View File
@@ -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é La page embed contient un jwplayer configuré dans du JS packé
(p,a,c,k,e,d) : `sources:[{file:"https://.../master.m3u8?..."}]`. (p,a,c,k,e,d) : `sources:[{file:"https://.../master.m3u8?..."}]`.
@@ -24,7 +24,7 @@ _PATTERNS = (
@register_hoster @register_hoster
class UqloadExtractor(HosterExtractor): class UqloadExtractor(HosterExtractor):
name = "uqload" 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: async def extract(self, embed_url: str) -> VideoLink:
html = await fetch(embed_url) html = await fetch(embed_url)
+2 -2
View File
@@ -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 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 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 @register_hoster
class VidzyExtractor(HosterExtractor): class VidzyExtractor(HosterExtractor):
name = "vidzy" name = "vidzy"
domains = ("vidzy.org",) domains = ("vidzy.org", "vidzy.cc", "vidzy.live")
async def extract(self, embed_url: str) -> VideoLink: async def extract(self, embed_url: str) -> VideoLink:
html = await fetch(embed_url) html = await fetch(embed_url)
+111
View File
@@ -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
View File
@@ -41,15 +41,30 @@ async def fetch(
*, *,
referer: str | None = None, referer: str | None = None,
retries: int = 2, retries: int = 2,
headers: dict[str, str] | None = None,
data: dict[str, str] | None = None,
) -> str: ) -> str:
"""GET d'une page avec retries ; lève ScrapeError en cas d'échec définitif.""" """GET (ou POST si `data` est fourni) avec retries ; lève ScrapeError en cas d'échec définitif."""
headers = {"Referer": referer} if referer else {} request_headers = dict(headers) if headers else {}
if referer:
request_headers.setdefault("Referer", referer)
last_error: Exception | None = None last_error: Exception | None = None
for attempt in range(retries + 1): for attempt in range(retries + 1):
try: 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() response.raise_for_status()
return response.text 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: except (httpx.HTTPError, httpx.InvalidURL) as exc:
last_error = exc last_error = exc
logger.warning("fetch %s — tentative %d/%d : %s", url, attempt + 1, retries + 1, exc) logger.warning("fetch %s — tentative %d/%d : %s", url, attempt + 1, retries + 1, exc)
+429
View File
@@ -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
+258
View File
@@ -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
View File
@@ -30,6 +30,7 @@ from app.scrapers.base import (
ScrapeError, ScrapeError,
SourceScraper, SourceScraper,
all_sources, all_sources,
get_source,
import_all_scrapers, import_all_scrapers,
) )
from app.services.kitsu import KitsuService, normalize_title 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_HISTORY_TITLES = 12 # titres récents analysés (téléchargements + favoris)
_MAX_GENRES = 4 # genres retenus pour la requête Kitsu _MAX_GENRES = 4 # genres retenus pour la requête Kitsu
_KITSU_PAGE_MAX = 20 # limite dure de l'API Kitsu (page[limit] > 20 → 400) _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 _LATEST_TTL_SECONDS = 600 # nouveautés : re-scrape au bout de 10 min
_MUST_WATCH_TTL_SECONDS = 21600 # incontournables : quasi statique, 6 h _MUST_WATCH_TTL_SECONDS = 21600 # incontournables : quasi statique, 6 h
_FOR_YOU_TTL_SECONDS = 3600 # recommandations : 1 h (l'historique évolue lentement) _FOR_YOU_TTL_SECONDS = 3600 # recommandations : 1 h (l'historique évolue lentement)
_ENRICH_CONCURRENCY = 6 # enrichissements Kitsu parallèles max (nouveautés)
class _TTLCache: class _TTLCache:
@@ -92,14 +138,18 @@ class DiscoverService:
# ------------------------------------------------------------ nouveautés # ------------------------------------------------------------ nouveautés
async def latest(self, limit: int = 24) -> list[dict]: async def latest_by_type(self, limit: int = 24) -> dict[str, list[dict]]:
"""Nouveautés toutes sources confondues, triées par date de sortie réelle. """Nouveautés par type de média : ``{"anime": [...], "serie": [...], "film": [...]}``.
Les « récemment ajoutés » de chaque source sont fusionnés (doublons retirés), Les « récemment ajoutés » de chaque source activée sont fusionnés (doublons
enrichis via Kitsu (date de début, statut de diffusion) puis triés du plus retirés) puis répartis en rails indépendants — chaque type garde sa place,
récent au plus ancien — ce qui sort / vient de sortir en premier. 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: if cached is not None:
return cached # type: ignore[return-value] return cached # type: ignore[return-value]
@@ -113,17 +163,28 @@ class DiscoverService:
existing = merged.get(key) existing = merged.get(key)
if existing is None or (not existing.get("image_url") and item.get("image_url")): if existing is None or (not existing.get("image_url") and item.get("image_url")):
merged[key] = item 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) semaphore = asyncio.Semaphore(_ENRICH_CONCURRENCY)
async def bounded(item: dict) -> dict: async def bounded(item: dict) -> dict:
async with semaphore: async with semaphore:
return await self._with_release_info(item) return await self._with_release_info(item)
enriched = await asyncio.gather(*(bounded(item) for item in merged.values())) enriched = await asyncio.gather(*(bounded(item) for item in by_type["anime"]))
result = sorted(enriched, key=lambda it: it.get("start_date") or "", reverse=True) by_type["anime"] = sorted(
result = result[:limit] enriched, key=lambda it: it.get("start_date") or "", reverse=True
self._cache.set(f"latest:{limit}", result, _LATEST_TTL_SECONDS) )[:limit]
return result 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: async def _latest_of(self, source: SourceScraper) -> list[dict] | None:
"""Items latest() d'une source, aplatis avec les infos de source ([] si KO).""" """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) self._cache.set(key, items, _MUST_WATCH_TTL_SECONDS)
return items 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 # ------------------------------------------------------------ pour toi
async def for_you(self, user_id: int, limit: int = _KITSU_PAGE_MAX) -> dict: 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: if cached is not None:
return cached # type: ignore[return-value] 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) genre_counts = await self._genres_from_downloads(owned)
for genre, count in favorite_genres.items(): for genre, count in favorite_genres.items():
genre_counts[genre] = genre_counts.get(genre, 0) + count 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() sonarr_owned, sonarr_genres = await sonarr.profile()
for genre, count in sonarr_genres.items(): for genre, count in sonarr_genres.items():
genre_counts[genre] = genre_counts.get(genre, 0) + count 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 owned |= sonarr_owned
if not genre_counts: if not genre_counts:
result: dict = {"based_on": [], "items": []} result: dict = {"based_on": [], "items": []}
self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS) self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS)
return result return result
top_genres = sorted(genre_counts, key=genre_counts.get, reverse=True)[:_MAX_GENRES] top_genres = sorted(genre_counts, key=genre_counts.get, reverse=True)[:_MAX_GENRES]
slugs = [category_slug(genre) for genre in top_genres] # Une requête par genre (Kitsu cumule les catégories en ET : quatre genres
items = await self._kitsu_anime( # ensemble ne laissent que quelques titres — parfois le déjà-possédé !).
{ # Fusion entrelacée : chaque genre contribue, doublons retirés.
"filter[categories]": ",".join(slugs), pools = [
"sort": "-userCount", list(pool)
"page[limit]": limit, # le déjà-possédé est filtré après 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} result = {"based_on": top_genres, "items": kept}
self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS) self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS)
return result 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: def invalidate_for_you(self) -> None:
"""Recommandations recalculées au prochain appel (réglages Sonarr modifiés).""" """Recommandations recalculées au prochain appel (réglages Sonarr modifiés)."""
self._cache.clear("for_you:") self._cache.clear("for_you:")
async def _owned(self, user_id: int) -> tuple[set[str], dict[str, int]]: async def _owned(self, user_id: int) -> tuple[set[str], dict[str, int], dict[str, str]]:
"""Titres possédés (normalisés) + genres directement connus via les favoris.""" """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( 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,), (_MAX_HISTORY_TITLES,),
) )
fav_rows = await db.fetchall( fav_rows = await db.fetchall(
"SELECT payload FROM favorites WHERE user_id = ? ORDER BY created_at DESC LIMIT ?", "SELECT payload FROM favorites WHERE user_id = ? ORDER BY created_at DESC LIMIT ?",
(user_id, _MAX_HISTORY_TITLES), (user_id, _MAX_HISTORY_TITLES),
) )
owned = {normalize_title(row["title"]).casefold() for row in rows} owned: set[str] = set()
owned.discard("") 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] = {} genre_counts: dict[str, int] = {}
for row in fav_rows: for row in fav_rows:
try: try:
@@ -231,7 +392,7 @@ class DiscoverService:
for genre in payload.get("genres") or []: for genre in payload.get("genres") or []:
if isinstance(genre, str) and genre.strip(): if isinstance(genre, str) and genre.strip():
genre_counts[genre.strip()] = genre_counts.get(genre.strip(), 0) + 1 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]: async def _genres_from_downloads(self, titles: set[str]) -> dict[str, int]:
"""Genres Kitsu des titres téléchargés (cache DB puis recherche).""" """Genres Kitsu des titres téléchargés (cache DB puis recherche)."""
+4
View File
@@ -533,6 +533,10 @@ class DownloadManager:
) )
await self._emit(download_id) await self._emit(download_id)
logger.info("Téléchargement terminé (id=%s, %d octets)", download_id, size) 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 # ------------------------------------------------------------ helpers
+14 -3
View File
@@ -21,15 +21,26 @@ logger = logging.getLogger(__name__)
_NOISE_WORDS_RE = re.compile( _NOISE_WORDS_RE = re.compile(
r"\b(?:VOSTFR\d*|VOST|VF[IV]?|TRUEFRENCH|FRENCH|MULTI|SUBFR?)\b", re.IGNORECASE 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_SEASON_RE = re.compile(r"[\s\-–—:.]*\b(?:saison|season)\s*\d+\s*$", re.IGNORECASE)
_TRAILING_CODE_RE = re.compile(r"[\s\-–—:.]*\s*S\d+(?:E\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: 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) cleaned = _NOISE_WORDS_RE.sub(" ", title)
previous = None
while previous != cleaned:
previous = cleaned
cleaned = _TRAILING_SEASON_RE.sub("", cleaned) cleaned = _TRAILING_SEASON_RE.sub("", cleaned)
cleaned = _TRAILING_CODE_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*[-–—_]+\s*", " ", cleaned)
cleaned = re.sub(r"\s+", " ", cleaned) cleaned = re.sub(r"\s+", " ", cleaned)
return cleaned.strip(" -–—:.") return cleaned.strip(" -–—:.")
-20
View File
@@ -114,23 +114,3 @@ async def set_sonarr_config(url: str, apikey: str) -> None:
await set_setting(SONARR_APIKEY_KEY, apikey.strip()) await set_setting(SONARR_APIKEY_KEY, apikey.strip())
logger.info("Configuration Sonarr enregistrée (%s)", url) 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
View File
@@ -1,6 +1,7 @@
"""Mises à jour logicielles. """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 - Application : POST à Watchtower (compagnon docker-compose) qui tire la nouvelle
image et recrée le conteneur — quelques secondes d'indisponibilité. image et recrée le conteneur — quelques secondes d'indisponibilité.
""" """
@@ -12,7 +13,6 @@ import time
import httpx import httpx
from app.config import get_settings from app.config import get_settings
from app.services.settings import get_update_config
from app.version import get_version from app.version import get_version
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -20,11 +20,11 @@ logger = logging.getLogger(__name__)
_TAG_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") _TAG_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
_CACHE_TTL = 300.0 # secondes _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): 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: 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: 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() now = time.monotonic()
latest_cache = _cache["latest"] latest_cache = _cache["latest"]
if not force and latest_cache and now - float(_cache["checked_at"]) < _CACHE_TTL: if not force and latest_cache and now - float(_cache["checked_at"]) < _CACHE_TTL:
return str(latest_cache) # type: ignore[arg-type] return str(latest_cache) # type: ignore[arg-type]
config = await get_update_config() settings = get_settings()
if not config["repo"]: url = f"{settings.gitea_url}/api/v1/repos/{settings.gitea_repo}/tags?limit=20"
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 {}
try: try:
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client: 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() 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: except (httpx.HTTPError, ValueError, KeyError) as exc:
logger.warning("Vérification de mise à jour impossible : %s", exc) logger.warning("Vérification de mise à jour impossible : %s", exc)
return None return None
latest = max(tags, key=parse_tag) if tags else None # type: ignore[arg-type] latest, notes = max(tags, key=lambda t: parse_tag(t[0])) if tags else (None, None)
_cache.update(checked_at=now, latest=latest) _cache.update(checked_at=now, latest=latest, notes=notes or None)
if latest and is_newer(latest, get_version()): if latest and is_newer(latest, get_version()):
logger.info("Nouvelle version disponible : %s (courante %s)", latest, get_version()) logger.info("Nouvelle version disponible : %s (courante %s)", latest, get_version())
return latest return latest
def invalidate_cache() -> None: 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]: async def status() -> dict[str, object]:
"""État complet : version courante, dernière dispo, config.""" """État complet : version courante, dernière dispo, patchnote."""
config = await get_update_config()
current = get_version() current = get_version()
latest = await fetch_latest_version() latest = await fetch_latest_version()
return { return {
"current": current, "current": current,
"latest": latest, "latest": latest,
"notes": _cache["notes"],
"update_available": bool(latest and is_newer(latest, current)), "update_available": bool(latest and is_newer(latest, current)),
"configured": bool(config["repo"]),
"docker": bool(get_settings().watchtower_url), "docker": bool(get_settings().watchtower_url),
"config": config,
} }
+54
View File
@@ -89,6 +89,14 @@ button { font-family: inherit; }
} }
.icon-btn:hover { color: var(--text); background: var(--surface-2); } .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 */ /* ------------------------------------------------------------ layout */
.main { padding: 2rem clamp(1rem, 3.5vw, 4rem) 4rem; } .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; } .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 { .rail-title {
font-size: 1.15rem; font-size: 1.15rem;
font-weight: 700; font-weight: 700;
+11 -26
View File
@@ -111,28 +111,23 @@
</p> </p>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center"> <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" <button class="btn btn-sm btn-ghost" @click="checkUpdate()" :disabled="upd._checking"
x-text="upd._checking ? '…' : 'Vérifier'"></button> x-text="upd._checking ? '…' : 'Vérifier'"></button>
<button class="btn btn-sm btn-accent" x-show="upd.update_available" @click="applyUpdate()" <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> :disabled="upd._applying" x-text="upd._applying ? 'Mise à jour…' : '⬆ Mettre à jour maintenant'"></button>
</div> </div>
<p x-show="!upd.configured" 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">
Renseignez le dépôt Gitea pour activer la détection des mises à jour. <summary style="cursor:pointer;font-size:0.85rem;color:var(--text-dim)">
Jeton d'accès (droit « lecture ») nécessaire uniquement si le dépôt est privé. Patchnote <span x-text="upd.latest"></span>
</p> </summary>
<p x-show="upd.configured && !upd.docker" style="margin:0.5rem 0 0;font-size:0.85rem;color:var(--text-dim)"> <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 ⚠ Mise à jour automatique disponible uniquement en déploiement Docker
(<code>docker compose pull &amp;&amp; docker compose up -d</code> sinon). (<code>docker compose pull &amp;&amp; docker compose up -d</code> sinon).
</p> </p>
</div> </div>
+
<div class="panel"> <div class="panel">
<h2>👥 Utilisateurs</h2> <h2>👥 Utilisateurs</h2>
@@ -171,9 +166,8 @@ function adminPage() {
return { return {
users: [], sources: [], stats: {}, forbidden: false, users: [], sources: [], stats: {}, forbidden: false,
upd: { upd: {
current: '', latest: null, update_available: false, configured: false, docker: false, current: '', latest: null, notes: null, update_available: false, docker: false,
config: { gitea_url: '', repo: '', token: '' }, _checking: false, _applying: false,
_dirty: false, _checking: false, _applying: false,
}, },
integrations: { integrations: {
torznab: { apikey: '', endpoint: '' }, sonarr: { url: '', apikey: '' }, torznab: { apikey: '', endpoint: '' }, sonarr: { url: '', apikey: '' },
@@ -287,18 +281,9 @@ function adminPage() {
applyUpdateData(data) { applyUpdateData(data) {
this.upd.current = data.current; this.upd.current = data.current;
this.upd.latest = data.latest; this.upd.latest = data.latest;
this.upd.notes = data.notes;
this.upd.update_available = data.update_available; this.upd.update_available = data.update_available;
this.upd.configured = data.configured;
this.upd.docker = data.docker; 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() { async checkUpdate() {
+10 -4
View File
@@ -4,14 +4,14 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Ohm Stream{% endblock %}</title> <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 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"> <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 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> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
</head> </head>
<body> <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> <a class="logo" href="/">OHM<span>STREAM</span></a>
<nav class="topnav"> <nav class="topnav">
<a class="{{ 'active' if active == 'discover' }}" href="/discover">Découvrir</a> <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> <a class="{{ 'active' if active == 'admin' }}" href="/admin">Admin</a>
</nav> </nav>
<div class="topbar-right"> <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> <div class="avatar" x-text="username ? username[0] : '·'" :title="username"></div>
<form method="post" action="/auth/logout" style="display:inline"> <form method="post" action="/auth/logout" style="display:inline">
<button class="icon-btn" type="submit" title="Déconnexion">⏻</button> <button class="icon-btn" type="submit" title="Déconnexion">⏻</button>
@@ -31,8 +37,8 @@
<main class="main"> <main class="main">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<script src="/static/js/app.js"></script> <script src="/static/js/app.js?v={{ asset_v }}"></script>
<script src="/static/js/update-watcher.js"></script> <script src="/static/js/update-watcher.js?v={{ asset_v }}"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>
+171 -15
View File
@@ -4,10 +4,61 @@
{% block content %} {% block content %}
<h1 class="page-title">Découvrir</h1> <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> <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 --> <!-- ------------------------------------------------ Skeletons de chargement -->
<div x-show="loading"> <div x-show="loading">
<section class="rail-section" x-data="{ n: 8 }"> <section class="rail-section" x-data="{ n: 8 }">
@@ -28,14 +79,14 @@
<div class="login-error" x-text="error"></div> <div class="login-error" x-text="error"></div>
</template> </template>
<!-- ------------------------------------------------ Nouveautés --> <!-- ------------------------------------------------ Nouveautés animés -->
<section class="rail-section" x-show="!loading && latest.length > 0"> <section class="rail-section" x-show="!loading && latestAnime.length > 0">
<h2 class="rail-title">🆕 Nouveautés <span class="rail-source">de tes sources, triées par date de sortie</span></h2> <h2 class="rail-title">🆕 Nouveautés animés <span class="rail-source">triées par date de sortie</span></h2>
<div class="rail-wrap"> <div class="rail-wrap">
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche" <button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
@click="scrollRail($el, -1)">‹</button> @click="scrollRail($el, -1)">‹</button>
<div class="rail"> <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)}`"> <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" <img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
loading="lazy" onerror="this.src='/static/img/placeholder.svg'"> loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
@@ -56,9 +107,33 @@
</div> </div>
</section> </section>
<!-- ------------------------------------------------ Nouveautés séries & films -->
<section class="rail-section" x-show="!loading && latestSerie.length > 0">
<h2 class="rail-title">🆕 Nouveautés séries &amp; 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 --> <!-- ------------------------------------------------ Incontournables -->
<section class="rail-section" x-show="!loading && mustWatch.length > 0"> <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"> <div class="rail-wrap">
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche" <button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
@click="scrollRail($el, -1)">‹</button> @click="scrollRail($el, -1)">‹</button>
@@ -81,11 +156,26 @@
</section> </section>
<!-- ------------------------------------------------ Pour toi --> <!-- ------------------------------------------------ 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 <h2 class="rail-title">✨ Pour toi
<span class="rail-source" x-show="forYou.based_on.length" <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> x-text="forYou.based_on.length ? 'parce que tu aimes : ' + forYou.based_on.join(', ') : ''"></span>
</h2> </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"> <div class="rail-wrap">
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche" <button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
@click="scrollRail($el, -1)">‹</button> @click="scrollRail($el, -1)">‹</button>
@@ -107,7 +197,7 @@
</div> </div>
</section> </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> <div class="empty-state"><div class="big">🏜️</div>Rien à afficher pour le moment — réessaie plus tard.</div>
</template> </template>
</div> </div>
@@ -117,23 +207,89 @@
<script> <script>
function discoverPage() { function discoverPage() {
return { 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, 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) { scrollRail(el, dir) {
const rail = el.closest('.rail-wrap').querySelector('.rail'); const rail = el.closest('.rail-wrap').querySelector('.rail');
rail.scrollBy({ left: dir * rail.clientWidth * 0.85, behavior: 'smooth' }); 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() { async load() {
this.loading = true; this.error = null; this.loading = true; this.error = null;
try { try {
const res = await fetch('/api/discover'); const [discoverRes, genresRes] = await Promise.all([
if (!res.ok) throw new Error('Erreur ' + res.status); fetch('/api/discover'), fetch('/api/discover/genres'),
const data = await res.json(); ]);
this.latest = data.latest; 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.mustWatch = data.must_watch;
this.forYou = data.for_you; this.forYou = data.for_you;
} catch (e) { this.error = e.message; } this.genres = await genresRes.json();
finally { this.loading = false; }
// É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;
}
}, },
}; };
} }
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Connexion — Ohm Stream</title> <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"> <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> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
</head> </head>
+51
View File
@@ -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
View File
@@ -2,16 +2,18 @@
# #
# Installation guidée (recommandée) : ./scripts/install.sh # Installation guidée (recommandée) : ./scripts/install.sh
# Ou à la main : # Ou à la main :
# 1. docker login git.lanro.eu (compte Gitea avec accès lecture au repo) # 1. cp .env.example .env && $EDITOR .env (OHM_SECRET_KEY + WATCHTOWER_TOKEN obligatoires)
# 2. cp .env.example .env && $EDITOR .env (OHM_SECRET_KEY + WATCHTOWER_TOKEN obligatoires) # 2. docker compose up -d
# 3. 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 : # Mise à jour : page Admin → « Mise à jour », ou manuellement :
# docker compose pull && docker compose up -d # docker compose pull && docker compose up -d
services: services:
ohm: 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 local (dev) : décommenter ces lignes et commenter « image: » ci-dessus
# build: # build:
# context: . # context: .
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "ohm-stream" 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" description = "Ohm Stream Downloader — centre de contrôle auto-hébergé pour animes et séries VOSTFR"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
+2 -18
View File
@@ -5,7 +5,7 @@
# bash scripts/install.sh (depuis un clone du dépôt) # bash scripts/install.sh (depuis un clone du dépôt)
# curl -fsSL <gitea>/raw/branch/main/scripts/install.sh | bash # 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) # (les valeurs passées en option court-circuitent les questions)
# #
# Pose les questions (destination des animés, port), génère les secrets, # 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"; } warn() { printf " ${YELLOW}⚠${RESET} %s\n" "$1"; }
die() { printf " ${RED}✖ %s${RESET}\n" "$1" >&2; exit 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 while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--dir) ARG_DIR="$2"; shift 2;; --dir) ARG_DIR="$2"; shift 2;;
--port) ARG_PORT="$2"; shift 2;; --port) ARG_PORT="$2"; shift 2;;
--skip-login) ARG_SKIP_LOGIN=1; shift;;
*) shift;; *) shift;;
esac esac
done done
@@ -74,21 +73,6 @@ if [ ! -f "$COMPOSE_FILE" ]; then
fi fi
ok "Projet prêt dans $(pwd)" 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 # ---------------------------------------------------------------- destination des épisodes
step "Destination des épisodes téléchargés" step "Destination des épisodes téléchargés"
Executable → Regular
+12 -9
View File
@@ -1,13 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Publie une version d'OhmStreaming : # 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. # Option : OHM_REGISTRY=git.lanro.eu/roman/ohm_streaming (défaut) pour viser un autre registre.
set -euo pipefail 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}" REGISTRY="${OHM_REGISTRY:-git.lanro.eu/roman/ohm_streaming}"
GHCR="${OHM_GHCR:-ghcr.io/aulyriusohm/ohm-streaming}"
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "✖ « $VERSION » n'est pas un semver X.Y.Z"; exit 1; } [[ "$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" git commit -m "v$VERSION"
echo "▶ 2/3 Tag et push git" echo "▶ 2/3 Tag et push git"
git tag "$TAG" git tag -a "$TAG" -m "${2:-$TAG}"
git push origin HEAD git push origin HEAD
git push origin "$TAG" git push origin "$TAG"
echo "▶ 3/3 Image Docker → $REGISTRY" echo "▶ 3/3 Image Docker → $GHCR (public) + $REGISTRY (Gitea)"
docker build --build-arg VERSION="$VERSION" -t "$REGISTRY:$VERSION" -t "$REGISTRY:latest" . docker build --build-arg VERSION="$VERSION" -t "$GHCR:$VERSION" -t "$GHCR:latest" .
docker push "$REGISTRY:$VERSION" docker push "$GHCR:$VERSION" && docker push "$GHCR:latest"
docker push "$REGISTRY:latest" docker tag "$GHCR:$VERSION" "$REGISTRY:$VERSION"
docker tag "$GHCR:latest" "$REGISTRY:latest"
docker push "$REGISTRY:$VERSION" && docker push "$REGISTRY:latest"
echo 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 »." echo " Les instances la détecteront via Admin → Mise à jour → « Vérifier »."
+43
View File
@@ -1,9 +1,12 @@
"""Tests unitaires : format interne, sanitisation, auth, registres scrapers.""" """Tests unitaires : format interne, sanitisation, auth, registres scrapers."""
import httpx
import pytest import pytest
from app import auth from app import auth
from app.scrapers import http as http_module
from app.scrapers.base import ( from app.scrapers.base import (
ScrapeError,
decode_internal_url, decode_internal_url,
encode_internal_url, encode_internal_url,
get_source, 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://vidmoly.to/embed-x.html").name == "vidmoly"
assert resolve_hoster("https://sendvid.com/embed/x").name == "sendvid" assert resolve_hoster("https://sendvid.com/embed/x").name == "sendvid"
assert resolve_hoster("https://inconnu.example.com/v/1") is None 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
View File
@@ -154,7 +154,7 @@ async def test_latest_merges_and_sorts_by_release_date(monkeypatch):
service = DiscoverService() service = DiscoverService()
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match) 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] titles = [item["title"] for item in result]
assert titles.count("Frieren VOSTFR") + titles.count("Frieren - Saison 1") == 1 # dédoublonné 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() service = DiscoverService()
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match) 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 [item["title"] for item in result] == ["Anime 1", "Anime 0", "Anime 2"]
assert result[0]["rating"] is None 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): 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.""" """Une source en échec disparaît sans erreur, et le TTL évite les re-scrapes."""
import app.services.discover as discover_module 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) monkeypatch.setattr(discover_module, "is_source_enabled", enabled)
service = DiscoverService() service = DiscoverService()
assert await service.latest() == [] assert await service.latest_by_type() == {"anime": [], "serie": [], "film": []}
assert await service.latest() == [] assert await service.latest_by_type() == {"anime": [], "serie": [], "film": []}
assert calls["n"] == 1 # deuxième appel servi depuis le cache TTL 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 [] return ["Fantasy", "Adventure"] if anime_id == "46474" else []
async def fake_kitsu_anime(params: dict) -> list[dict]: async def fake_kitsu_anime(params: dict) -> list[dict]:
slugs = frozenset(params["filter[categories]"].split(","))
assert params["sort"] == "-userCount" assert params["sort"] == "-userCount"
catalog = { # Une catégorie par requête (le cumul Kitsu est un ET trop restrictif)
frozenset(["fantasy", "adventure", "action", "comedy"]): [ assert "," not in params["filter[categories]"]
return [
{"title": "Helck", "kitsu_id": "999"}, {"title": "Helck", "kitsu_id": "999"},
{"title": "Sousou no Frieren", "kitsu_id": "46474"}, {"title": "Sousou no Frieren", "kitsu_id": "46474"},
{"title": "Konosuba", "kitsu_id": "1"}, {"title": "Konosuba", "kitsu_id": "1"},
], ]
}
return catalog.get(slugs, [])
service = DiscoverService() service = DiscoverService()
service.test_user_id = user_id # pour les assertions du test 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 "Sousou no Frieren" in titles
assert "Konosuba" 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() service = DiscoverService()
result = await service.for_you(user_id=42) 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 test_api_discover_sections(client, admin_cookies, monkeypatch):
async def fake_latest(limit: int = 24): async def fake_latest_by_type(limit: int = 24):
return [ return {
"anime": [
{"source": "vostfree", "label": "Vostfree", "source_id": "a", "title": "T", {"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): async def fake_must_watch(limit: int = 20):
return [{"kitsu_id": "1", "title": "Attack on Titan", "rating": 8.5}] 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): async def fake_for_you(user_id: int, limit: int = 20):
return {"based_on": ["Action"], "items": [{"kitsu_id": "2", "title": "X"}]} 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, "must_watch", fake_must_watch)
monkeypatch.setattr(discover, "for_you", fake_for_you) monkeypatch.setattr(discover, "for_you", fake_for_you)
r = await client.get("/api/discover", cookies=admin_cookies) r = await client.get("/api/discover", cookies=admin_cookies)
assert r.status_code == 200 assert r.status_code == 200
data = r.json() data = r.json()
assert data["latest"][0]["label"] == "Vostfree" assert data["latest_anime"][0]["label"] == "Vostfree"
assert data["latest"][0]["start_date"] == "2026-01-01" 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["must_watch"][0]["title"] == "Attack on Titan"
assert data["for_you"]["based_on"] == ["Action"] 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): async def test_discover_page_renders(client, admin_cookies):
r = await client.get("/discover", cookies=admin_cookies) r = await client.get("/discover", cookies=admin_cookies)
assert r.status_code == 200 assert r.status_code == 200
+257
View File
@@ -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&amp;xf=Action">Action</a><a href="/index.php?do=xfsearch&amp;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")
+163
View File
@@ -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
View File
@@ -128,9 +128,26 @@ async def test_torznab_tvsearch_all_episodes(client, apikey, fake_source):
assert "Frieren S02E01 VOSTFR WEB-DL" in titles 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}) 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 # ---------------------------------------------------------------- grab
+27 -50
View File
@@ -3,8 +3,8 @@
import httpx import httpx
import pytest import pytest
from app.config import get_settings
from app.services import update as update_service from app.services import update as update_service
from app.services.settings import get_update_config
from app.version import get_version from app.version import get_version
@@ -15,6 +15,12 @@ def reset_update_cache():
update_service.invalidate_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 # ---------------------------------------------------------------- semver / version
@@ -41,18 +47,9 @@ def test_get_version_env_override(monkeypatch):
# ---------------------------------------------------------------- détection Gitea # ---------------------------------------------------------------- détection Gitea
async def test_fetch_latest_without_repo(): async def test_fetch_latest_no_auth_header(monkeypatch):
from app.services.settings import set_update_config """Dépôt public : appel sans en-tête Authorization."""
_patch_gitea(monkeypatch)
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", "")
class FakeResponse: class FakeResponse:
def raise_for_status(self) -> None: 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): async def test_fetch_latest_picks_highest_semver(monkeypatch):
"""Avec jeton : en-tête Authorization + plus haut semver retenu, puis cache.""" """Plus haut semver retenu + patchnote du tag, puis cache."""
from app.services.settings import set_update_config _patch_gitea(monkeypatch)
await set_update_config("https://git.example", "roman/ohm", "tok")
class FakeResponse: class FakeResponse:
def raise_for_status(self) -> None: def raise_for_status(self) -> None:
pass pass
def json(self) -> list[dict]: 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]] = [] calls: list[tuple[str, dict]] = []
@@ -92,21 +92,18 @@ async def test_fetch_latest_picks_highest_semver(monkeypatch):
return FakeResponse() return FakeResponse()
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get) 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 status["latest"] == "v0.2.3"
assert calls == [ assert status["notes"] == "correctifs"
("https://git.example/api/v1/repos/roman/ohm/tags?limit=20", {"Authorization": "token tok"}) assert calls == [("https://git.example/api/v1/repos/roman/ohm/tags?limit=20", {})]
]
# puis servi par le cache (plus d'appel réseau) # puis servi par le cache (plus d'appel réseau)
assert await update_service.fetch_latest_version() == "v0.2.3" assert await update_service.fetch_latest_version() == "v0.2.3"
assert len(calls) == 1 assert len(calls) == 1
async def test_fetch_latest_network_error_degrades(monkeypatch): async def test_fetch_latest_network_error_degrades(monkeypatch):
from app.services.settings import set_update_config _patch_gitea(monkeypatch)
await set_update_config("https://git.example", "roman/ohm", "tok")
async def fake_get(self: httpx.AsyncClient, url: str, headers: dict | None = None): async def fake_get(self: httpx.AsyncClient, url: str, headers: dict | None = None):
raise httpx.ConnectError("injoignable") 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) r = await client.get("/api/admin/update", cookies=admin_cookies)
assert r.status_code == 200 assert r.status_code == 200
data = r.json() 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["latest"] is None and data["update_available"] is False
assert data["config"]["gitea_url"] == "https://git.lanro.eu" assert data["docker"] is False and data["notes"] is None
assert data["config"]["repo"] == "Roman/ohm_streaming"
async def test_update_save_config_normalizes(client, admin_cookies): async def test_update_check_degrades_gracefully(client, admin_cookies, monkeypatch):
r = await client.put( # Gitea injoignable : le check répond quand même (latest=None)
"/api/admin/update", _patch_gitea(monkeypatch, url="https://git.inexistant", repo="roman/ohm")
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,
)
r = await client.post("/api/admin/update/check", cookies=admin_cookies) r = await client.post("/api/admin/update/check", cookies=admin_cookies)
assert r.status_code == 200 and r.json()["latest"] is None assert r.status_code == 200 and r.json()["latest"] is None
+238
View File
@@ -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&amp;episode=46416"
data-fallback-source-url="/lecteur/1626/46416?lang=vostfr&amp;source=169262&amp;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&amp;episode=46416"
data-fallback-source-url="/lecteur/1626/46416?lang=vostfr&amp;source=169261&amp;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"
Generated
+1 -1
View File
@@ -774,7 +774,7 @@ wheels = [
[[package]] [[package]]
name = "ohm-stream" name = "ohm-stream"
version = "0.1.0" version = "0.2.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiosqlite" }, { name = "aiosqlite" },