18 Commits
Author SHA1 Message Date
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
roman 5fedc4f9d7 Nommage Plex SxxExx des fichiers téléchargés
Le scanner Plex ignorait les épisodes 1-9 : le pattern « Saison 1 - E9 »
n'est reconnu qu'avec un numéro d'épisode à 2 chiffres. Les titres
« Série - Saison N - EX (VF) » sont désormais renommés en
« Série - S01E09 (VF) » à l'ajout dans la file (plex_filename).
2026-09-23 09:30:31 +00:00
roman 853b4e0866 Compatibilité Sonarr : flux RSS Torznab, catégories qBittorrent, save_path, TLS auto-signé
- torznab : tvsearch sans q sert les nouveautés (RSS sync + test Sonarr)
  au lieu d'une erreur 400 — ajoute TorznabService.latest_releases()
- qbit : POST /torrents/createCategory + GET /torrents/categories stateful
  (Sonarr crée sa catégorie tv-sonarr puis la revérifie)
- qbit : /app/preferences expose save_path=/downloads (health Sonarr)
- sonarr : verify=False sur httpx — reverse proxy swizzin en certificat
  auto-signé (usage LAN)
2026-09-22 18:25:03 +00:00
Roman 4e1faf4cd9 Ohm comme client de téléchargement Sonarr (API compatible qBittorrent)
- /api/v2/* : login SID (mot de passe = clé Torznab), app/version,
  torrents/info (progression temps réel), properties (content_path),
  add (rejoue le grab encodé dans le .torrent de service, dédupliqué
  par infohash SHA-1), delete (± fichiers), pause/resume
- Les grabs Sonarr sont marqués « sonarr:<hash>| » dans source_key →
  suivis de bout en bout : Sonarr importe, renomme et range les épisodes
  dans sa bibliothèque, puis retire le torrent de la file Ohm
- L'indexeur Torznab embarque les paramètres du grab dans l'announce
- README : nouveau mode « client de téléchargement » recommandé (Remote
  Path Mapping documenté), blackhole en variante minimale
- 3 nouveaux tests (flux complet add → suivi → import → delete)
2026-09-22 16:42:12 +00:00
Roman 1f326d34dd Installation guidée + intégration bibliothèque Plex/Sonarr
- scripts/install.sh : premier setup interactif (destination des épisodes,
  port, secrets auto-générés, montage, démarrage) — options --dir/--port/
  --skip-login pour une install non interactive
- Épisodes rangés par animé sur le disque : <Animé>/<Animé> - E12 (VOSTFR).mp4
  (meilleur parsing Plex, suppression du dossier quand il se vide)
- Entrypoint : chown récursif uniquement si le dossier de téléchargements est
  vide — une bibliothèque Plex/Sonarr existante n'est jamais modifiée
- Scan disque récursif (sous-dossiers par animé restaurés)
- README : installation guidée + section « Bibliothèque Plex / Sonarr »
2026-09-22 12:38:18 +00:00
Roman 9500a84a5e Téléchargements : suppression par épisode et regroupement par animé
- DELETE /api/downloads/{id} (?delete_file=true pour effacer aussi le fichier)
- Événement SSE « removed » → toutes les pages ouvertes se synchronisent
- Page Téléchargements groupée par animé : groupes repliables (dépliés si
  actifs), compteurs par statut, taille totale, tri par numéro d'épisode
- Actions par groupe : 🧹 retirer les terminés, ✖ annuler les actifs
- Limite de liste 200 → 2000 tâches
2026-09-22 12:17:27 +00:00
49 changed files with 3668 additions and 375 deletions
+80 -17
View File
@@ -8,6 +8,21 @@ regarder et télécharger des animes et séries VOSTFR/VF.
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.
### Installation guidée (recommandée)
```bash
git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming
./scripts/install.sh
```
Le script vérifie Docker, demande la **destination des épisodes** (dossier dédié
recommandé — voir « Bibliothèque Plex/Sonarr » ci-dessous), le port, génère les
secrets, branche le montage et démarre. Non interactif aussi :
`./scripts/install.sh --dir /srv/animes --port 8777 --skip-login`.
### À la main
```bash
# 1. Récupérer le projet puis se connecter au registre privé (compte Gitea avec accès lecture)
git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming
docker login git.lanro.eu
@@ -22,7 +37,27 @@ docker compose up -d
Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**.
Les données (`data/`, `downloads/`) sont montées en volumes : elles survivent aux
mises à jour. Port hôte modifiable via `OHM_PORT` dans `.env`.
### Bibliothèque Plex / Sonarr existante
Ohm peut déposer ses épisodes directement dans ton serveur de média :
1. **Crée un dossier dédié** (recommandé : hors de la racine Sonarr, ex.
`/plex_videos/ohm`) et monte-le à la place de `./downloads` :
`- /plex_videos/ohm:/downloads` — c'est ce que fait `scripts/install.sh`.
2. **Ajoute ce dossier à Plex** comme dossier d'une bibliothèque (ou comme
dossier supplémentaire de ta bibliothèque animes). Les épisodes arrivent
rangés par animé : `One Piece/One Piece - E1010 (VOSTFR).mp4` — le nom du
dossier sert de série, le fichier d'épisode.
3. **Sonarr n'est pas touché** : l'entrypoint du conteneur ne prend possession
(`chown`) que d'un dossier de téléchargements **vide** ; une bibliothèque
existante et ses droits restent intacts. Pour piloter Ohm depuis Sonarr ou
Prowlarr, voir l'API Torznab plus bas.
Si tu pointes `/downloads` sur un dossier **non vide**, Ohm adopte les fichiers
présents (ils apparaissent dans sa bibliothèque interne) et conserve les droits
existants — assure-toi juste que l'uid 1000 du conteneur peut y écrire.
### Mettre à jour
@@ -76,31 +111,38 @@ Variables d'environnement (préfixe `OHM_`, voir `.env.example`) :
## Fonctionnalités
- **Recherche unifiée** sur plusieurs sources (Vostfree, French-Manga) — chaque source
- **Recherche unifiée** sur plusieurs sources (Vostfree, French-Manga, VoirAnime pour les
animes ; French-Stream pour les séries et films VF/VOSTFR) — chaque source
est un module interchangeable activable/désactivable à chaud (page Admin), dont l'URL
est modifiable à la volée (utile si un site change de domaine).
- **Extraction en 2 niveaux** : page d'épisode → lecteurs embarqués → URL directe
(Sibnet, SendVid, VidMoly, Uqload, Vidzy, Luluvdo).
(Sibnet, SendVid, VidMoly, Uqload, Vidzy, Luluvdo ; VoirAnime résout via son
endpoint « prepare » : MP4 direct ou HLS relayé par le proxy du site).
- **Préférence de contenus par compte** : sélecteur topbar ⛨ Animés / 📺 Séries /
✨ Les deux — la recherche et les nouveautés filtrent par type de média, et les
sources hors périmètre ne sont même pas interrogées.
- **Proxy vidéo intégré** (`/api/proxy`) : contourne les protections (tokens liés à l'IP, Referer/UA obligatoires), réécrit les playlists HLS.
- **Streaming HLS** via hls.js ; **téléchargement HLS** via ffmpeg (remux mp4, progression temps réel).
- **Métadonnées enrichies** via Kitsu (synopsis, genres, note, images) avec cache 72 h.
- **Téléchargements** : file parallèle, pause/reprise (HTTP Range), retry, anti-doublons,
progression en temps réel (SSE), persistance au redémarrage.
- **Bibliothèque locale** : streaming avec range requests, reprise de lecture,
navigation épisode suivant/précédent.
- **Comptes** : JWT court + refresh token (rotation), rôles admin/utilisateur,
administration des comptes.
- **Découverte** (`/discover`) : 🆕 nouveautés fusionnées de toutes les sources, triées
par date de sortie réelle (enrichissement Kitsu, badge « en cours de diffusion »),
🔥 incontournables (top popularité Kitsu) et ✨ recommandations par genres déduites
des téléchargements et favoris, titres déjà possédés exclus (cache mémoire).
- **Découverte** (`/discover`) : 🎭 exploration par genre (animés via Kitsu, séries/films
via French-Stream — état dans l'URL, partageable), 🆕 nouveautés en rails séparés
(animés triés par date de sortie via Kitsu ; séries & films de French-Stream), 🔥
incontournables animés (top popularité Kitsu) et ✨ recommandations par genres déduites
des téléchargements et favoris, titres déjà possédés exclus (cache mémoire). Sans
Sonarr, les genres des séries/films téléchargés sont lus sur leur fiche source ;
sans aucun historique, une carte d'amorçage invite à télécharger un premier titre.
## Intégration Sonarr / Prowlarr (*arr)
OhmStreaming expose une **API Torznab** : la suite *arr le voit comme un indexeur
de plus, et chaque grab Sonarr déclenche l'extraction + le téléchargement dans
la file interne (les épisodes arrivent dans la bibliothèque OhmStreaming).
OhmStreaming expose une **API Torznab** (indexeur) **et une API compatible
qBittorrent** (client de téléchargement) : Sonarr peut lui déléguer toute la
chaîne — recherche, téléchargement, puis **import et renommage automatiques**
dans la bibliothèque Sonarr.
### 1. OhmStreaming comme indexeur
@@ -115,13 +157,34 @@ Dans **Admin → Intégrations Sonarr / Prowlarr**, copier :
puis synchroniser vers Sonarr.
- **Sonarr** (direct) : Settings → Indexers → Add → *Torznab* → coller URL + clé.
Catégories : TV (5000) / Anime (5070).
- **Client de téléchargement** : « Torrent Blackhole » — Sonarr enregistre le
`.torrent` de service tandis qu'OhmStreaming télécharge réellement l'épisode
(extraction embed → HLS/HTTP → mp4 dans `downloads/`).
Endpoints : `t=caps`, `t=tvsearch` (q, season, ep), `t=search` — auth par
`?apikey=` ou en-tête `X-Api-Key`.
### 2. OhmStreaming comme client de téléchargement (recommandé)
Dans Sonarr : **Settings → Download Clients → Add → qBittorrent** :
| Champ | Valeur |
|---|---|
| Host | `http://<hote-ohm>:8777` |
| Username | `ohm` |
| Password | la clé API Torznab (Admin → Intégrations) |
Le flux complet devient : Sonarr grab → Ohm télécharge (progression visible
dans la file Sonarr) → Sonarr **importe, renomme et range** l'épisode dans sa
bibliothèque selon ses propres règles → le « torrent » est retiré de la file
Ohm (fichier inclu si « Remove Completed » est coché).
**Chemin d'accès** : Ohm annonce les fichiers sous `/downloads/<Animé>/…`
(chemin conteneur). Si Sonarr tourne dans Docker sans ce montage, ajouter un
*Remote Path Mapping* : hôte = `<hote-ohm>`, distant = `/downloads`, local =
le dossier hôte monté (ex. `/plex_videos/ohm`).
Variante minimale sans import : client « Torrent Blackhole » — Sonarr pose le
`.torrent` de service et l'épisode reste dans la bibliothèque OhmStreaming
seulement (pas d'import/renommage Sonarr).
### 2. « Pour toi » personnalisé par Sonarr
Toujours dans **Admin → Intégrations**, renseigner l'URL Sonarr
@@ -141,7 +204,7 @@ app/
├── scrapers/
│ ├── base.py # contrats SourceScraper/HosterExtractor + registres
│ ├── configs/ # sélecteurs YAML externalisés (réparer sans coder)
│ ├── sources/ # vostfree, french_manga
│ ├── sources/ # vostfree, french_manga, voiranime, french_stream
├── services/ # downloads, kitsu, discover, sonarr, torznab, settings
└── templates/ + static/ # UI htmx + Alpine.js, thème sombre
```
@@ -152,6 +215,6 @@ app/
## Tests
```bash
uv run pytest # 61 tests
uv run pytest # 132 tests
uv run ruff check . # lint
```
+26 -4
View File
@@ -25,6 +25,10 @@ class User:
username: str
is_admin: bool
is_active: bool
content_preference: str = "both" # anime | serie | both
VALID_CONTENT_PREFERENCES = ("anime", "serie", "both")
# ---------------------------------------------------------------- mots de passe
@@ -56,7 +60,8 @@ async def create_user(username: str, password: str) -> User:
async def authenticate(username: str, password: str) -> User | None:
row = await db.fetchone(
"SELECT id, username, password_hash, is_admin, is_active FROM users WHERE username = ?",
"SELECT id, username, password_hash, is_admin, is_active, content_preference "
"FROM users WHERE username = ?",
(username.strip(),),
)
if row is None or not verify_password(password, row["password_hash"]):
@@ -66,18 +71,35 @@ async def authenticate(username: str, password: str) -> User | None:
logger.warning("Compte désactivé : %r", username)
return None
return User(
id=row["id"], username=row["username"], is_admin=bool(row["is_admin"]), is_active=True
id=row["id"],
username=row["username"],
is_admin=bool(row["is_admin"]),
is_active=True,
content_preference=row["content_preference"],
)
async def get_user(user_id: int) -> User | None:
row = await db.fetchone(
"SELECT id, username, is_admin, is_active FROM users WHERE id = ?", (user_id,)
"SELECT id, username, is_admin, is_active, content_preference FROM users WHERE id = ?",
(user_id,),
)
if row is None or not row["is_active"]:
return None
return User(
id=row["id"], username=row["username"], is_admin=bool(row["is_admin"]), is_active=True
id=row["id"],
username=row["username"],
is_admin=bool(row["is_admin"]),
is_active=True,
content_preference=row["content_preference"],
)
async def set_content_preference(user_id: int, preference: str) -> None:
if preference not in VALID_CONTENT_PREFERENCES:
raise ValueError(f"Préférence invalide : {preference!r}")
await db.execute(
"UPDATE users SET content_preference = ? WHERE id = ?", (preference, user_id)
)
+4 -1
View File
@@ -34,7 +34,10 @@ class Settings(BaseSettings):
kitsu_base_url: str = "https://kitsu.io/api/edge"
metadata_cache_ttl_hours: int = 72
# Mise à jour (déploiement Docker — Watchtower compagnon)
# Mise à jour (dépôt public — lecture anonyme de l'API Gitea)
gitea_url: str = "https://git.lanro.eu"
gitea_repo: str = "Roman/ohm_streaming"
# Déploiement Docker — Watchtower compagnon
watchtower_url: str = ""
watchtower_token: str = ""
+12
View File
@@ -11,6 +11,8 @@ CREATE TABLE IF NOT EXISTS users (
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
content_preference TEXT NOT NULL DEFAULT 'both'
CHECK (content_preference IN ('anime','serie','both')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -89,8 +91,18 @@ class Database:
await self._conn.execute("PRAGMA journal_mode=WAL")
await self._conn.execute("PRAGMA foreign_keys=ON")
await self._conn.executescript(SCHEMA)
await self._migrate()
await self._conn.commit()
async def _migrate(self) -> None:
"""Migrations légères : colonnes ajoutées après coup pour les bases existantes."""
cursor = await self._conn.execute("PRAGMA table_info(users)")
columns = {row["name"] for row in await cursor.fetchall()}
if "content_preference" not in columns:
await self._conn.execute(
"ALTER TABLE users ADD COLUMN content_preference TEXT NOT NULL DEFAULT 'both'"
)
async def close(self) -> None:
if self._conn is not None:
await self._conn.close()
+3 -2
View File
@@ -17,6 +17,7 @@ from app.routers import (
library,
pages,
proxy,
qbit,
search,
system,
torznab,
@@ -44,7 +45,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await db.connect()
await apply_source_base_urls()
await download_manager.start()
warmup = asyncio.create_task(discover_service.latest())
warmup = asyncio.create_task(discover_service.latest_by_type())
warmup.add_done_callback(_log_task_error)
logger.info("%s prêt", settings.app_name)
yield
@@ -58,7 +59,7 @@ def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.include_router(torznab.router)
app.include_router(qbit.router)
app.include_router(system.router)
app.include_router(pages.router)
app.include_router(pages.protected)
+1 -13
View File
@@ -28,7 +28,6 @@ from app.services.settings import (
set_source_base_url,
set_source_enabled,
set_source_health,
set_update_config,
)
from app.services.sonarr import sonarr
from app.services.update import UpdateError, fetch_latest_version, trigger_update
@@ -222,23 +221,12 @@ async def test_sonarr(admin: AdminUser) -> dict:
# ---------------------------------------------------------------- mise à jour logicielle
class UpdateConfig(BaseModel):
gitea_url: str
repo: str
token: str
@router.get("/update")
async def get_update(admin: AdminUser) -> dict:
"""Version courante, dernière version disponible et configuration Gitea."""
"""Version courante, dernière version disponible et patchnote."""
return await update_status()
@router.put("/update")
async def save_update(payload: UpdateConfig, admin: AdminUser) -> dict:
await set_update_config(payload.gitea_url, payload.repo, payload.token)
return await update_status()
@router.post("/update/check")
async def check_update(admin: AdminUser) -> dict:
"""Force la re-vérification de la dernière version (ignore le cache)."""
+20 -2
View File
@@ -3,7 +3,7 @@
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response, status
from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from app import auth
@@ -107,4 +107,22 @@ async def logout(user: CurrentUser) -> RedirectResponse:
@router.get("/me")
async def me(user: CurrentUser) -> dict:
return {"id": user.id, "username": user.username, "is_admin": user.is_admin}
return {
"id": user.id,
"username": user.username,
"is_admin": user.is_admin,
"content_preference": user.content_preference,
}
@router.put("/preferences")
async def update_preferences(
user: CurrentUser,
content_preference: Annotated[str, Body(embed=True)],
) -> dict:
"""Préférence de contenus : animés, séries, ou les deux."""
try:
await auth.set_content_preference(user.id, content_preference)
except ValueError as exc:
raise HTTPException(422, detail=str(exc)) from exc
return {"content_preference": content_preference}
+62 -7
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
from typing import Annotated
@@ -6,7 +6,9 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.routers.auth import CurrentUser, current_user
from app.services.discover import discover
from app.routers.search import _allowed_media_types
from app.scrapers.base import ScrapeError, get_source
from app.services.discover import ANIME_GENRES, discover
logger = logging.getLogger(__name__)
@@ -20,8 +22,61 @@ async def get_discover(
must_watch_limit: Annotated[int, Query(ge=1, le=20)] = 20,
for_you_limit: Annotated[int, Query(ge=1, le=20)] = 20,
) -> dict:
"""Les trois sections de découverte en une requête (sections vides si source KO)."""
must_watch = await discover.must_watch(must_watch_limit)
for_you = await discover.for_you(user.id, for_you_limit)
latest = await discover.latest(latest_limit)
return {"latest": latest, "must_watch": must_watch, "for_you": for_you}
"""Rails de découverte en une requête (sections vides si source KO).
« latest_anime » et « latest_serie » (séries + films réels) sont des rails
indépendants, filtrés par la préférence de contenu du compte.
"""
allowed = _allowed_media_types(user.content_preference)
rails = await discover.latest_by_type(latest_limit)
latest_anime = rails["anime"] if "anime" in allowed else []
latest_serie = (rails["serie"] + rails["film"]) if {"serie", "film"} & allowed else []
# Incontournables et Pour toi sont issus de Kitsu (catalogue animés) :
# en mode séries, elles n'ont pas de sens — on ne les calcule même pas.
if "anime" in allowed:
must_watch = await discover.must_watch(must_watch_limit)
for_you = await discover.for_you(user.id, for_you_limit)
else:
must_watch = []
for_you = {"based_on": [], "items": []}
return {
"latest_anime": latest_anime,
"latest_serie": latest_serie,
"must_watch": must_watch,
"for_you": for_you,
}
@router.get("/discover/genres")
async def get_genres(user: CurrentUser) -> dict:
"""Catalogue des genres parcourables (page Explorer), selon la préférence."""
allowed = _allowed_media_types(user.content_preference)
catalog: dict[str, list[dict]] = {}
if "anime" in allowed:
catalog["anime"] = [{"key": key, "label": label} for key, label in ANIME_GENRES.items()]
try:
french_stream = get_source("french_stream")
available = french_stream.browse_catalog()
except ScrapeError:
available = {}
for kind in ("serie", "film"):
if kind in allowed and kind in available:
catalog[kind] = [
{"key": key, "label": label} for key, label in available[kind].items()
]
return catalog
@router.get("/discover/browse")
async def get_browse(
user: CurrentUser,
type: Annotated[str, Query(pattern="^(anime|serie|film)$")],
genre: Annotated[str, Query(min_length=1, max_length=40)],
limit: Annotated[int, Query(ge=1, le=40)] = 24,
) -> dict:
"""Titres d'un genre : animés via Kitsu, séries/films via French-Stream."""
if type not in _allowed_media_types(user.content_preference):
return {"items": []} # hors préférence du compte
if type == "anime":
return {"items": await discover.browse_anime(genre, limit)}
return {"items": await discover.browse_serie_film(type, genre, limit)}
+9 -2
View File
@@ -64,6 +64,13 @@ async def cancel(download_id: int) -> dict:
await download_manager.cancel(download_id)
return {"ok": True}
@router.delete("/{download_id}")
async def delete_download(download_id: int, delete_file: bool = False) -> dict:
"""Retire une tâche de la file ; delete_file=true efface aussi le fichier."""
if not await download_manager.delete(download_id, delete_file=delete_file):
raise HTTPException(404, detail="Téléchargement introuvable")
return {"ok": True}
@router.post("/cancel-all")
async def cancel_all() -> dict:
@@ -81,7 +88,7 @@ async def events() -> EventSourceResponse:
async def stream() -> AsyncIterator[dict]:
yield {"data": json.dumps({"type": "snapshot", "items": await download_manager.list_all()})}
async for update in download_manager.subscribe():
yield {"data": json.dumps({"type": "update", "item": update}, default=str)}
async for message in download_manager.subscribe():
yield {"data": json.dumps(message, default=str)}
return EventSourceResponse(stream())
+7
View File
@@ -12,6 +12,13 @@ protected = APIRouter(tags=["pages"], include_in_schema=False, dependencies=[Dep
templates = Jinja2Templates(directory=BASE_DIR / "app" / "templates")
# Cache-busting des assets : version dérivée de la date des fichiers statiques —
# tout changement de CSS/JS invalide le cache navigateur sans intervention.
_static_root = BASE_DIR / "app" / "static"
templates.env.globals["asset_v"] = str(
max(int(p.stat().st_mtime) for p in _static_root.rglob("*") if p.is_file())
)
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request) -> HTMLResponse:
+263
View File
@@ -0,0 +1,263 @@
"""API compatible qBittorrent Web API v2 — Ohm comme client de téléchargement Sonarr.
Sonarr sait piloter un qBittorrent ; Ohm implémente le sous-set suffisant pour
être vu comme un client de téléchargement « torrent » :
- login (`/auth/login`, mot de passe = clé API Torznab) + session SID
- `torrents/add` : Sonarr renvoie le .torrent de service servi par l'indexeur
Torznab → le grab est rejoué (dédupliqué par infohash) dans la file interne
- `torrents/info` / `properties` : progression temps réel, `content_path`
pointant vers le fichier dans /downloads (mapper en chemin hôte côté Sonarr
via « Remote Path Mapping » si besoin)
- `torrents/delete` : retrait de la file, avec ou sans le fichier
- `pause`/`resume` : branchés sur le gestionnaire de téléchargements
"""
import hashlib
import logging
import secrets
import time
from datetime import UTC, datetime
from urllib.parse import parse_qsl
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile
from app.db import db
from app.scrapers.base import ScrapeError
from app.services.downloads import download_manager
from app.services.settings import get_torznab_apikey
from app.services.torznab import _bencode, bdecode, torznab
logger = logging.getLogger(__name__)
def _plain(text: str) -> Response:
"""qBittorrent répond en texte brut, pas en JSON."""
return Response(text, media_type="text/plain")
router = APIRouter(tags=["qbit"])
_OPTIONAL_FILE = File(None)
_SID_TTL = 3600.0
_sessions: dict[str, float] = {}
_QBIT_VERSION = "v4.6.0"
_WEBAPI_VERSION = "2.9.3"
# status Ohm → état qBittorrent (noms compris par Sonarr v3/v4)
_QBIT_STATES = {
"pending": "queuedDL",
"downloading": "downloading",
"paused": "pausedDL",
"done": "pausedUP", # terminé → Sonarr importe
"failed": "error",
"cancelled": "error",
}
_PREFIX = "sonarr:"
async def _require_sid(request: Request) -> None:
sid = request.cookies.get("SID")
if not sid or _sessions.get(sid, 0.0) < time.time():
_sessions.pop(sid, None)
raise HTTPException(403, "Session invalide — (re)connecte-toi via /api/v2/auth/login")
@router.post("/api/v2/auth/login")
async def login(username: str = Form(""), password: str = Form("")) -> Response:
if password != await get_torznab_apikey():
logger.warning("Login qBittorrent refusé (utilisateur %r)", username)
raise HTTPException(403, "Fails.")
sid = secrets.token_hex(16)
_sessions[sid] = time.time() + _SID_TTL
response = _plain("Ok.")
response.set_cookie("SID", sid, httponly=True)
logger.info("Client qBittorrent authentifié (utilisateur %r)", username)
return response
@router.get("/api/v2/app/version", dependencies=[Depends(_require_sid)])
async def app_version() -> Response:
return _plain(_QBIT_VERSION)
@router.get("/api/v2/app/webapiVersion", dependencies=[Depends(_require_sid)])
async def webapi_version() -> Response:
return _plain(_WEBAPI_VERSION)
@router.get("/api/v2/app/preferences", dependencies=[Depends(_require_sid)])
async def app_preferences() -> dict:
return {"save_path": "/downloads"}
@router.get("/api/v2/transfer/info", dependencies=[Depends(_require_sid)])
async def transfer_info() -> dict:
return {"dl_info_speed": 0, "dl_info_data": 0, "up_info_speed": 0, "up_info_data": 0}
# ---------------------------------------------------------------- torrents
def _ts(sqlite_dt: str | None) -> int:
if not sqlite_dt:
return -1
try:
return int(
datetime.strptime(sqlite_dt, "%Y-%m-%d %H:%M:%S")
.replace(tzinfo=UTC)
.timestamp()
)
except ValueError:
return -1
async def _sonarr_rows() -> dict[str, dict]:
"""Téléchargements d'origine Sonarr, indexés par infohash (dernier par hash)."""
rows = await db.fetchall(
f"SELECT * FROM downloads WHERE source_key LIKE '{_PREFIX}%' ORDER BY id"
)
by_hash: dict[str, dict] = {}
for row in rows:
prefix_end = row["source_key"].find("|")
infohash = row["source_key"][len(_PREFIX) : prefix_end]
by_hash[infohash] = dict(row) # le plus grand id écrase les précédents
return by_hash
def _content_path(row: dict) -> str:
return "/downloads/" + (row["file_path"] or row["title"])
@router.get("/api/v2/torrents/info", dependencies=[Depends(_require_sid)])
async def torrents_info() -> list[dict]:
items = []
for infohash, row in (await _sonarr_rows()).items():
live = await download_manager.get(row["id"])
downloaded = live["downloaded_bytes"]
total = live["total_bytes"]
items.append(
{
"hash": infohash,
"name": row["title"],
"state": _QBIT_STATES.get(row["status"], "error"),
"progress": round(downloaded / total, 4) if total else 0.0,
"dlspeed": live["speed_bps"],
"eta": live["eta_seconds"] or 0,
"total_size": total or 0,
"completed": downloaded,
"amount_left": max(0, (total or 0) - downloaded),
"category": "",
"tags": "",
"save_path": _content_path(row).rsplit("/", 1)[0],
"content_path": _content_path(row),
"added_on": _ts(row["created_at"]),
"completion_on": _ts(row["updated_at"]) if row["status"] == "done" else -1,
"ratio": 1,
"num_seeds": 0,
}
)
return items
@router.get("/api/v2/torrents/properties", dependencies=[Depends(_require_sid)])
async def torrents_properties(hash: str) -> dict:
rows = await _sonarr_rows()
row = rows.get(hash.lower())
if row is None:
raise HTTPException(404, "Torrent introuvable")
live = await download_manager.get(row["id"])
return {
"name": row["title"],
"content_path": _content_path(row),
"save_path": _content_path(row).rsplit("/", 1)[0],
"total_size": live["total_bytes"] or 0,
"total_downloaded": live["downloaded_bytes"],
"addition_date": _ts(row["created_at"]),
"completion_date": _ts(row["updated_at"]) if row["status"] == "done" else -1,
"seeding_time": 0,
"share_ratio": 1,
}
_categories: dict[str, dict] = {}
@router.get("/api/v2/torrents/categories", dependencies=[Depends(_require_sid)])
async def torrents_categories() -> dict:
return _categories
@router.post("/api/v2/torrents/createCategory", dependencies=[Depends(_require_sid)])
async def torrents_create_category(category: str = Form(""), savePath: str = Form("")) -> Response:
if category:
_categories[category] = {"name": category, "savePath": savePath}
return _plain("Ok.")
@router.get("/api/v2/torrents/tags", dependencies=[Depends(_require_sid)])
async def torrents_tags() -> list:
return []
@router.post("/api/v2/torrents/setCategory", dependencies=[Depends(_require_sid)])
async def torrents_set_category() -> Response:
return _plain("Ok.")
async def _add_stub(stub: bytes) -> Response:
"""Rejoue le grab encodé dans un .torrent de service."""
try:
parsed = bdecode(stub)
info = parsed[b"info"]
announce = parsed[b"announce"].decode()
params = dict(parse_qsl(announce.split("?", 1)[1]))
infohash = hashlib.sha1(_bencode(info)).hexdigest()
await torznab.grab(
params["source"],
params["sid"],
int(params["season"]),
int(params["ep"]),
params["series"],
sonarr_hash=infohash,
)
except (ValueError, KeyError, ScrapeError) as exc:
logger.error("Ajout qBittorrent refusé : %s", exc)
return _plain("Fals.")
return _plain("Ok.")
@router.post("/api/v2/torrents/add", dependencies=[Depends(_require_sid)])
async def torrents_add(torrents: UploadFile | None = _OPTIONAL_FILE) -> Response:
if torrents is None or not torrents.filename:
return _plain("Fals.")
return await _add_stub(await torrents.read())
async def _ids_for_hashes(hashes: str) -> list[int]:
wanted = {h.lower() for h in hashes.split("|") if h}
rows = await _sonarr_rows()
return [row["id"] for infohash, row in rows.items() if infohash in wanted]
@router.post("/api/v2/torrents/delete", dependencies=[Depends(_require_sid)])
async def torrents_delete(
hashes: str = Form(...), deleteFiles: str = Form("false")
) -> Response:
for download_id in await _ids_for_hashes(hashes):
await download_manager.delete(download_id, delete_file=deleteFiles == "true")
return _plain("Ok.")
@router.post("/api/v2/torrents/pause", dependencies=[Depends(_require_sid)])
async def torrents_pause(hashes: str = Form(...)) -> Response:
for download_id in await _ids_for_hashes(hashes):
await download_manager.pause(download_id)
return _plain("Ok.")
@router.post("/api/v2/torrents/resume", dependencies=[Depends(_require_sid)])
async def torrents_resume(hashes: str = Form(...)) -> Response:
for download_id in await _ids_for_hashes(hashes):
await download_manager.resume(download_id)
return _plain("Ok.")
+29 -5
View File
@@ -7,7 +7,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from app.routers.auth import current_user
from app.routers.auth import CurrentUser, current_user
from app.scrapers.base import (
ScrapeError,
SourceScraper,
@@ -27,13 +27,35 @@ router = APIRouter(prefix="/api", tags=["search"], dependencies=[Depends(current
import_all_scrapers()
kitsu = KitsuService()
# Types de médias gardés selon la préférence. Les films réels (French-Stream)
# accompagnent les séries — le mode animés reste sur l'animation.
_PREFERENCE_MEDIA_TYPES = {
"anime": {"anime"},
"serie": {"serie", "film"},
"both": {"anime", "serie", "film"},
}
def _allowed_media_types(preference: str) -> set[str]:
return _PREFERENCE_MEDIA_TYPES.get(preference, _PREFERENCE_MEDIA_TYPES["both"])
async def enabled_sources(allowed: set[str] | None = None) -> list[SourceScraper]:
"""Sources activées, limitées à celles pouvant servir les types de médias autorisés."""
sources = []
for source in all_sources():
if not await is_source_enabled(source.name):
continue
if allowed is not None and not set(source.media_types) & allowed:
continue
sources.append(source)
return sources
async def enabled_sources() -> list[SourceScraper]:
sources = []
for source in all_sources():
if await is_source_enabled(source.name):
sources.append(source)
return sources
@router.get("/sources")
@@ -52,9 +74,10 @@ async def list_sources() -> list[dict]:
@router.get("/search")
async def search(q: Annotated[str, Query(min_length=2)]) -> dict:
async def search(q: Annotated[str, Query(min_length=2)], user: CurrentUser) -> dict:
"""Recherche unifiée : une requête interroge toutes les sources activées."""
sources = await enabled_sources()
allowed = _allowed_media_types(user.content_preference)
sources = await enabled_sources(allowed)
async def safe_search(source: SourceScraper) -> tuple[list, str | None]:
try:
@@ -66,6 +89,7 @@ async def search(q: Annotated[str, Query(min_length=2)]) -> dict:
outcomes = await asyncio.gather(*(safe_search(s) for s in sources))
results = [item for items, _ in outcomes for item in items]
results = [r for r in results if r.get("media_type", "anime") in allowed]
failed = [name for _, name in outcomes if name]
return {"query": q, "count": len(results), "results": results, "failed_sources": failed}
+25 -10
View File
@@ -6,12 +6,13 @@ des sessions utilisateurs — aucun cookie requis.
import logging
import secrets
from urllib.parse import urlencode
from fastapi import APIRouter, Request, Response
from app.scrapers.base import ScrapeError
from app.services.settings import get_torznab_apikey
from app.services.torznab import torrent_stub, torznab
from app.services.torznab import build_stub, torznab
logger = logging.getLogger(__name__)
@@ -57,10 +58,9 @@ async def torznab_api(
if t in ("tvsearch", "search"):
if not q or len(q) < 2:
releases = await torznab.latest_releases()
return Response(
torznab.error_xml(200, "Paramètre q requis"),
media_type="application/xml",
status_code=400,
torznab.results_xml(base, key, releases), media_type="application/rss+xml"
)
try:
releases = await torznab.tvsearch(q, season=season, ep=ep)
@@ -92,10 +92,12 @@ async def torznab_download(
series: str | None = None,
apikey: str | None = None,
) -> Response:
"""Grab : Sonarr récupère le « .torrent » ; OhmStreaming télécharge l'épisode.
"""Grab : Sonarr récupère le « .torrent » ; Ohm télécharge l'épisode.
Le flux retourne un .torrent de service (blackhole-friendly) pendant que
l'épisode réel entre dans la file de téléchargements interne.
Le flux retourne un .torrent de service : avec un client « Torrent
Blackhole » c'est un simple accusé de réception ; avec le client
qBittumber (l'API /api/v2 d'Ohm), Sonarr le renvoie et le grab est
rejoué/dédupliqué, puis suivi comme un téléchargement classique.
"""
error = await _auth_error(request, apikey)
if error is not None:
@@ -106,8 +108,17 @@ async def torznab_download(
media_type="application/xml",
status_code=400,
)
name = f"{series} S{season:02d}E{ep:02d}"
announce = (
_base_url(request)
+ "/torznab/api?"
+ urlencode(
{"source": source, "sid": sid, "season": season, "ep": ep, "series": series}
)
)
stub, infohash = build_stub(announce, name)
try:
result = await torznab.grab(source, sid, season, ep, series)
result = await torznab.grab(source, sid, season, ep, series, sonarr_hash=infohash)
logger.info("Torznab grab OK : %s → download %s", series, result.get("id"))
except ScrapeError as exc:
logger.error("Torznab grab KO : %s", exc)
@@ -116,9 +127,13 @@ async def torznab_download(
media_type="application/xml",
status_code=502,
)
stub = torrent_stub(_base_url(request) + "/torznab/api", f"{series} S{season:02d}E{ep:02d}")
return Response(
stub,
media_type="application/x-bittorrent",
headers={"Content-Disposition": f'attachment; filename="ohm-{series.replace("/", "-")}-S{season:02d}E{ep:02d}.torrent"'},
headers={
"Content-Disposition": (
f'attachment; filename="ohm-{series.replace("/", "-")}'
f"-S{season:02d}E{ep:02d}.torrent"
)
},
)
+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é
(p,a,c,k,e,d) : `sources:[{file:"https://.../master.m3u8?..."}]`.
@@ -24,7 +24,7 @@ _PATTERNS = (
@register_hoster
class UqloadExtractor(HosterExtractor):
name = "uqload"
domains = ("uqload.to", "uqload.co", "uqload.com", "uqload.io")
domains = ("uqload.to", "uqload.co", "uqload.com", "uqload.io", "uqload.vc")
async def extract(self, embed_url: str) -> VideoLink:
html = await fetch(embed_url)
+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
une fonction de décodage inline `atob(s)` + reverse + XOR, avec une graine
@@ -45,7 +45,7 @@ def _decode(b64: str, hostname: str) -> str | None:
@register_hoster
class VidzyExtractor(HosterExtractor):
name = "vidzy"
domains = ("vidzy.org",)
domains = ("vidzy.org", "vidzy.cc", "vidzy.live")
async def extract(self, embed_url: str) -> VideoLink:
html = await fetch(embed_url)
+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,
retries: int = 2,
headers: dict[str, str] | None = None,
data: dict[str, str] | None = None,
) -> str:
"""GET d'une page avec retries ; lève ScrapeError en cas d'échec définitif."""
headers = {"Referer": referer} if referer else {}
"""GET (ou POST si `data` est fourni) avec retries ; lève ScrapeError en cas d'échec définitif."""
request_headers = dict(headers) if headers else {}
if referer:
request_headers.setdefault("Referer", referer)
last_error: Exception | None = None
for attempt in range(retries + 1):
try:
response = await get_client().get(url, headers=headers)
if data is None:
response = await get_client().get(url, headers=request_headers)
else:
response = await get_client().post(url, headers=request_headers, data=data)
response.raise_for_status()
return response.text
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
if 400 <= status < 500 and status not in (408, 429):
raise ScrapeError(f"Échec de récupération de {url} : HTTP {status} (définitif)") from exc
last_error = exc
logger.warning("fetch %s — HTTP %d, tentative %d/%d", url, status, attempt + 1, retries + 1)
if attempt < retries:
await asyncio.sleep(1.0 * (attempt + 1))
except (httpx.HTTPError, httpx.InvalidURL) as exc:
last_error = exc
logger.warning("fetch %s — tentative %d/%d : %s", url, attempt + 1, retries + 1, exc)
+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
+189 -28
View File
@@ -30,6 +30,7 @@ from app.scrapers.base import (
ScrapeError,
SourceScraper,
all_sources,
get_source,
import_all_scrapers,
)
from app.services.kitsu import KitsuService, normalize_title
@@ -44,11 +45,56 @@ import_all_scrapers()
_MAX_HISTORY_TITLES = 12 # titres récents analysés (téléchargements + favoris)
_MAX_GENRES = 4 # genres retenus pour la requête Kitsu
_KITSU_PAGE_MAX = 20 # limite dure de l'API Kitsu (page[limit] > 20 → 400)
_ENRICH_CONCURRENCY = 6 # enrichissements Kitsu parallèles max (nouveautés)
# (les TTL des sections suivent, plus bas)
# Genres animés proposés à l'exploration — slugs des catégories Kitsu officielles.
ANIME_GENRES: dict[str, str] = {
"action": "Action",
"adventure": "Aventure",
"comedy": "Comédie",
"drama": "Drame",
"fantasy": "Fantasy",
"science-fiction": "Science-Fiction",
"romance": "Romance",
"slice-of-life": "Tranche de vie",
"sports": "Sport",
"supernatural": "Surnaturel",
"mystery": "Mystère",
"psychological": "Psychologique",
"horror": "Horreur",
"mecha": "Mecha",
"isekai": "Isekai",
"music": "Musique",
}
# Genres des fiches séries/films (French-Stream, libellés FR, slugifiés) →
# catégorie Kitsu équivalente. Les genres sans équivalent (Médical, Western…)
# sont ignorés : mieux vaut un signal incomplet qu'une catégorie inexistante.
_GENRE_FR_TO_KITSU: dict[str, str] = {
"action": "Action",
"aventure": "Adventure",
"aventures": "Adventure",
"comedie": "Comedy",
"comedies": "Comedy",
"drame": "Drama",
"drames": "Drama",
"epouvante-horreur": "Horror",
"horreur": "Horror",
"fantastique": "Fantasy",
"fantastiques": "Fantasy",
"romance": "Romance",
"romances": "Romance",
"science-fiction": "Science Fiction",
"science-fictions": "Science Fiction",
"surnaturel": "Supernatural",
"thriller": "Thriller",
"thrillers": "Thriller",
}
_LATEST_TTL_SECONDS = 600 # nouveautés : re-scrape au bout de 10 min
_MUST_WATCH_TTL_SECONDS = 21600 # incontournables : quasi statique, 6 h
_FOR_YOU_TTL_SECONDS = 3600 # recommandations : 1 h (l'historique évolue lentement)
_ENRICH_CONCURRENCY = 6 # enrichissements Kitsu parallèles max (nouveautés)
class _TTLCache:
@@ -92,14 +138,18 @@ class DiscoverService:
# ------------------------------------------------------------ nouveautés
async def latest(self, limit: int = 24) -> list[dict]:
"""Nouveautés toutes sources confondues, triées par date de sortie réelle.
async def latest_by_type(self, limit: int = 24) -> dict[str, list[dict]]:
"""Nouveautés par type de média : ``{"anime": [...], "serie": [...], "film": [...]}``.
Les « récemment ajoutés » de chaque source sont fusionnés (doublons retirés),
enrichis via Kitsu (date de début, statut de diffusion) puis triés du plus
récent au plus ancien — ce qui sort / vient de sortir en premier.
Les « récemment ajoutés » de chaque source activée sont fusionnés (doublons
retirés) puis répartis en rails indépendants — chaque type garde sa place,
aucun n'évince les autres. Le rail animés est enrichi via Kitsu (date de
sortie, statut) et trié du plus récent au plus ancien ; les séries et films
réels, absents de Kitsu, gardent l'ordre du site (déjà « récents d'abord »).
Chaque rail est tronqué à ``limit``.
"""
cached = self._cache.get(f"latest:{limit}")
cache_key = f"latest_by_type:{limit}"
cached = self._cache.get(cache_key)
if cached is not None:
return cached # type: ignore[return-value]
@@ -113,17 +163,28 @@ class DiscoverService:
existing = merged.get(key)
if existing is None or (not existing.get("image_url") and item.get("image_url")):
merged[key] = item
by_type: dict[str, list[dict]] = {"anime": [], "serie": [], "film": []}
for item in merged.values():
by_type.setdefault(item.get("media_type", "anime"), []).append(item)
# Enrichissement Kitsu limité aux animés (seul catalogue couvert) —
# inutile de bombarder l'API pour des titres qui n'y figurent pas.
semaphore = asyncio.Semaphore(_ENRICH_CONCURRENCY)
async def bounded(item: dict) -> dict:
async with semaphore:
return await self._with_release_info(item)
enriched = await asyncio.gather(*(bounded(item) for item in merged.values()))
result = sorted(enriched, key=lambda it: it.get("start_date") or "", reverse=True)
result = result[:limit]
self._cache.set(f"latest:{limit}", result, _LATEST_TTL_SECONDS)
return result
enriched = await asyncio.gather(*(bounded(item) for item in by_type["anime"]))
by_type["anime"] = sorted(
enriched, key=lambda it: it.get("start_date") or "", reverse=True
)[:limit]
for kind in ("serie", "film"):
by_type[kind] = by_type[kind][:limit]
self._cache.set(cache_key, by_type, _LATEST_TTL_SECONDS)
return by_type
async def _latest_of(self, source: SourceScraper) -> list[dict] | None:
"""Items latest() d'une source, aplatis avec les infos de source ([] si KO)."""
@@ -164,6 +225,46 @@ class DiscoverService:
self._cache.set(key, items, _MUST_WATCH_TTL_SECONDS)
return items
async def browse_serie_film(self, media_type: str, genre: str, limit: int = 24) -> list[dict]:
"""Séries/films d'un genre French-Stream, aplatis comme les nouveautés.
Liste vide si la catégorie est inconnue ou la source en échec.
"""
key = f"browse_fs:{media_type}:{genre}:{limit}"
cached = self._cache.get(key)
if cached is not None:
return cached # type: ignore[return-value]
try:
scraper = get_source("french_stream")
results = await scraper.browse(media_type, genre)
except ScrapeError as exc:
logger.warning("Parcours %s/%s indisponible : %s", media_type, genre, exc)
return []
items = [
{**dataclasses.asdict(r), "source": scraper.name, "label": scraper.label}
for r in results
][:limit]
self._cache.set(key, items, _LATEST_TTL_SECONDS)
return items
async def browse_anime(self, genre: str, limit: int = _KITSU_PAGE_MAX) -> list[dict]:
"""Animés populaires d'une catégorie Kitsu (genre = slug du catalogue).
Liste vide si le genre est inconnu ou si Kitsu échoue (dégradation gracieuse).
"""
limit = min(limit, _KITSU_PAGE_MAX)
if genre not in ANIME_GENRES:
return []
key = f"browse_anime:{genre}:{limit}"
cached = self._cache.get(key)
if cached is not None:
return cached # type: ignore[return-value]
items = await self._kitsu_anime(
{"filter[categories]": genre, "sort": "-userCount", "page[limit]": limit}
)
self._cache.set(key, items, _FOR_YOU_TTL_SECONDS)
return items
# ------------------------------------------------------------ pour toi
async def for_you(self, user_id: int, limit: int = _KITSU_PAGE_MAX) -> dict:
@@ -179,49 +280,109 @@ class DiscoverService:
if cached is not None:
return cached # type: ignore[return-value]
owned, favorite_genres = await self._owned(user_id)
owned, favorite_genres, series_pages = await self._owned(user_id)
genre_counts = await self._genres_from_downloads(owned)
for genre, count in favorite_genres.items():
genre_counts[genre] = genre_counts.get(genre, 0) + count
if series_pages:
# Séries/films téléchargés : genres lus sur leur fiche source (sans Sonarr)
for genre, count in (await self._genres_from_series_pages(series_pages)).items():
genre_counts[genre] = genre_counts.get(genre, 0) + count
sonarr_owned, sonarr_genres = await sonarr.profile()
for genre, count in sonarr_genres.items():
genre_counts[genre] = genre_counts.get(genre, 0) + count
if not (owned or favorite_genres or sonarr_owned):
# Aucun historique : proposition d'amorçage plutôt qu'une section muette
return {"based_on": [], "items": [], "cold_start": True}
owned |= sonarr_owned
if not genre_counts:
result: dict = {"based_on": [], "items": []}
self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS)
return result
top_genres = sorted(genre_counts, key=genre_counts.get, reverse=True)[:_MAX_GENRES]
slugs = [category_slug(genre) for genre in top_genres]
items = await self._kitsu_anime(
{
"filter[categories]": ",".join(slugs),
"sort": "-userCount",
"page[limit]": limit, # le déjà-possédé est filtré après
}
)
kept = [item for item in items if item["title"] and item["title"].casefold() not in owned]
# Une requête par genre (Kitsu cumule les catégories en ET : quatre genres
# ensemble ne laissent que quelques titres — parfois le déjà-possédé !).
# Fusion entrelacée : chaque genre contribue, doublons retirés.
pools = [
list(pool)
for pool in await asyncio.gather(*(
self._kitsu_anime(
{"filter[categories]": category_slug(g), "sort": "-userCount", "page[limit]": limit}
)
for g in top_genres
))
if pool
]
candidates: list[dict] = []
seen: set[str] = set()
while pools:
for pool in pools[:]:
item = pool.pop(0)
key = str(item.get("kitsu_id") or item.get("title", "").casefold())
if key not in seen:
seen.add(key)
candidates.append(item)
if not pool:
pools.remove(pool)
kept = [
item for item in candidates
if item["title"] and normalize_title(item["title"]).casefold() not in owned
][:limit]
result = {"based_on": top_genres, "items": kept}
self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS)
return result
async def _genres_from_series_pages(self, pages: dict[str, str]) -> dict[str, int]:
"""Genres Kitsu des séries/films téléchargés, lus sur leur fiche source.
Les libellés français des fiches sont convertis vers les catégories Kitsu
(anglais) ; les genres sans équivalent sont ignorés.
"""
if not pages:
return {}
try:
scraper = get_source("french_stream")
except ScrapeError:
return {}
counts: dict[str, int] = {}
for page_url in list(pages.values())[:_MAX_HISTORY_TITLES]:
for genre_fr in await scraper.genres_of_page(page_url):
canonical = _GENRE_FR_TO_KITSU.get(category_slug(genre_fr))
if canonical:
counts[canonical] = counts.get(canonical, 0) + 1
return counts
def invalidate_for_you(self) -> None:
"""Recommandations recalculées au prochain appel (réglages Sonarr modifiés)."""
self._cache.clear("for_you:")
async def _owned(self, user_id: int) -> tuple[set[str], dict[str, int]]:
"""Titres possédés (normalisés) + genres directement connus via les favoris."""
async def _owned(self, user_id: int) -> tuple[set[str], dict[str, int], dict[str, str]]:
"""Titres possédés (normalisés), genres des favoris, fiches des téléchargements.
``series_pages`` relie un titre possédé à l'URL de sa fiche chez la source
(séries/films French-Stream) : sans Sonarr, c'est la seule source de genres.
"""
rows = await db.fetchall(
"SELECT DISTINCT title FROM downloads ORDER BY created_at DESC LIMIT ?",
"SELECT DISTINCT title, page_url FROM downloads ORDER BY created_at DESC LIMIT ?",
(_MAX_HISTORY_TITLES,),
)
fav_rows = await db.fetchall(
"SELECT payload FROM favorites WHERE user_id = ? ORDER BY created_at DESC LIMIT ?",
(user_id, _MAX_HISTORY_TITLES),
)
owned = {normalize_title(row["title"]).casefold() for row in rows}
owned.discard("")
owned: set[str] = set()
series_pages: dict[str, str] = {}
for row in rows:
key = normalize_title(row["title"]).casefold()
if not key:
continue
owned.add(key)
page_url = row["page_url"] or ""
if page_url and key not in series_pages:
series_pages[key] = page_url
genre_counts: dict[str, int] = {}
for row in fav_rows:
try:
@@ -231,7 +392,7 @@ class DiscoverService:
for genre in payload.get("genres") or []:
if isinstance(genre, str) and genre.strip():
genre_counts[genre.strip()] = genre_counts.get(genre.strip(), 0) + 1
return owned, genre_counts
return owned, genre_counts, series_pages
async def _genres_from_downloads(self, titles: set[str]) -> dict[str, int]:
"""Genres Kitsu des titres téléchargés (cache DB puis recherche)."""
+113 -26
View File
@@ -63,6 +63,46 @@ def sanitize_filename(name: str) -> str:
return name[:150]
_SERIES_RE = re.compile(r"^(.*?)[\s\-–—]*(?:épisode|episode|ep|e)\s*\d", re.IGNORECASE)
def series_dirname(title: str) -> str | None:
"""Nom du sous-dossier d'un titre (« One Piece - E12 » → « One Piece »).
Range les épisodes par animé sur le disque (bien meilleur parsing Plex).
None si aucun marqueur d'épisode détecté → fichier à plat.
"""
match = _SERIES_RE.match(title)
base = sanitize_filename(match.group(1).strip()) if match else ""
return base or None
_EPISODE_RE = re.compile(
r"^(?P<series>.+?)\s*-\s*(?:Saison\s+(?P<season>\d+)\s*-\s*)?"
r"E(?P<ep>\d+(?:[.,]\d+)?)(?P<tail>\s*\([^)]*\))?\s*$",
re.IGNORECASE,
)
def plex_filename(title: str, extension: str) -> str | None:
"""Nom de fichier compatible Plex (« S01E09 ») pour un titre d'épisode.
Les scrapers produisent « Série - Saison 1 - E9 (VF) » ; le scanner Plex
exige « Série - S01E09 (VF) » (les épisodes 1-9 sont sinon ignorés).
None si le titre ne suit pas le format épisode → nommage inchangé.
"""
match = _EPISODE_RE.match(title)
if not match:
return None
series = match.group("series")
season = int(match.group("season") or 1)
ep_raw = match.group("ep").replace(",", ".")
ep = float(ep_raw)
ep_label = f"{int(ep):02d}" if ep.is_integer() else ep_raw
tail = match.group("tail") or ""
return sanitize_filename(f"{series} - S{season:02d}E{ep_label}{tail}") + extension
class DownloadManager:
"""File d'attente de téléchargements, injectée dans les routes via app.state."""
@@ -117,31 +157,41 @@ class DownloadManager:
download_dir = get_settings().download_dir
rows = await db.fetchall("SELECT file_path FROM downloads WHERE file_path IS NOT NULL")
known = {row["file_path"] for row in rows}
for path in download_dir.iterdir():
if path.is_file() and path.suffix != ".part" and path.name not in known:
size = path.stat().st_size
await db.execute(
"INSERT INTO downloads "
"(source_key, video_url, page_url, title, file_path, status, "
" total_bytes, downloaded_bytes) "
"VALUES (?, ?, ?, ?, ?, 'done', ?, ?)",
(
f"file:{path.name}",
"",
"",
path.stem,
path.name,
size,
size,
),
)
logger.info("Fichier restauré depuis le disque : %s", path.name)
for path in download_dir.rglob("*"):
if not path.is_file() or path.suffix == ".part":
continue
rel_path = path.relative_to(download_dir).as_posix()
if rel_path in known:
continue
size = path.stat().st_size
await db.execute(
"INSERT INTO downloads "
"(source_key, video_url, page_url, title, file_path, status, "
" total_bytes, downloaded_bytes) "
"VALUES (?, ?, ?, ?, ?, 'done', ?, ?)",
(
f"file:{rel_path}",
"",
"",
path.stem,
rel_path,
size,
size,
),
)
logger.info("Fichier restauré depuis le disque : %s", rel_path)
# ------------------------------------------------------------ API publique
async def enqueue(self, video_url: str, page_url: str, title: str) -> dict:
"""Ajoute un téléchargement ; retourne la tâche existante si doublon actif."""
source_key = video_url
async def enqueue(
self, video_url: str, page_url: str, title: str, source_key: str | None = None
) -> dict:
"""Ajoute un téléchargement ; retourne la tâche existante si doublon actif.
source_key : clé de déduplication (par défaut l'URL vidéo). Les grabs
Sonarr utilisent « sonarr:<infohash>|<url> » pour rester suivis.
"""
source_key = source_key or video_url
existing = await db.fetchone(
f"SELECT * FROM downloads WHERE source_key = ? AND status IN "
f"({','.join('?' * len(ACTIVE_STATUSES))})",
@@ -151,11 +201,14 @@ class DownloadManager:
logger.info("Anti-doublon : %s déjà en file (id=%s)", title, existing["id"])
return self._to_dict(existing, duplicate=True)
filename = sanitize_filename(title) + self._guess_extension(video_url)
extension = self._guess_extension(video_url)
filename = plex_filename(title, extension) or sanitize_filename(title) + extension
series = series_dirname(title)
file_path = f"{series}/{filename}" if series else filename
cursor = await db.execute(
"INSERT INTO downloads (source_key, video_url, page_url, title, file_path) "
"VALUES (?, ?, ?, ?, ?)",
(source_key, video_url, page_url, title, filename),
(source_key, video_url, page_url, title, file_path),
)
download_id = cursor.lastrowid
await self._queue.put(download_id)
@@ -223,7 +276,29 @@ class DownloadManager:
)
return cursor.rowcount or 0
async def list_all(self, limit: int = 200) -> list[dict]:
async def delete(self, download_id: int, delete_file: bool = False) -> bool:
"""Supprime une tâche de la file (annulée avant si active).
delete_file=True efface aussi le fichier téléchargé du disque.
"""
row = await db.fetchone("SELECT * FROM downloads WHERE id = ?", (download_id,))
if row is None:
return False
if row["status"] in ACTIVE_STATUSES:
await self.cancel(download_id)
if delete_file and row["file_path"]:
target = get_settings().download_dir / row["file_path"]
target.unlink(missing_ok=True)
if target.parent != get_settings().download_dir:
with contextlib.suppress(OSError): # dossier de l'animé vide → retiré
target.parent.rmdir()
self._part_path(row["file_path"]).unlink(missing_ok=True)
await db.execute("DELETE FROM downloads WHERE id = ?", (download_id,))
await self._emit_removed(download_id)
logger.info("Téléchargement supprimé (id=%s, fichier=%s)", download_id, delete_file)
return True
async def list_all(self, limit: int = 2000) -> list[dict]:
rows = await db.fetchall(
"SELECT * FROM downloads ORDER BY "
"CASE status WHEN 'downloading' THEN 0 WHEN 'pending' THEN 1 WHEN 'paused' THEN 2 "
@@ -251,9 +326,15 @@ class DownloadManager:
data = await self.get(download_id)
if data is None:
return
self._publish({"type": "update", "item": data})
async def _emit_removed(self, download_id: int) -> None:
self._publish({"type": "removed", "id": download_id})
def _publish(self, message: dict) -> None:
for queue in self._listeners:
with contextlib.suppress(asyncio.QueueFull):
queue.put_nowait(data)
queue.put_nowait(message)
# ------------------------------------------------------------ worker interne
@@ -300,6 +381,7 @@ class DownloadManager:
"""Téléchargement HTTP direct avec reprise via Range."""
video_url, file_path = row["video_url"], row["file_path"]
target = get_settings().download_dir / file_path
target.parent.mkdir(parents=True, exist_ok=True)
part = self._part_path(file_path)
downloaded = part.stat().st_size if part.exists() else 0
@@ -353,6 +435,7 @@ class DownloadManager:
"""
video_url, file_path = row["video_url"], row["file_path"]
target = get_settings().download_dir / file_path
target.parent.mkdir(parents=True, exist_ok=True)
part = self._part_path(file_path)
part.unlink(missing_ok=True) # pas de reprise partielle en HLS
@@ -450,6 +533,10 @@ class DownloadManager:
)
await self._emit(download_id)
logger.info("Téléchargement terminé (id=%s, %d octets)", download_id, size)
# L'historique a changé : les recommandations « Pour toi » se recalculent
from app.services.discover import discover
discover.invalidate_for_you()
# ------------------------------------------------------------ helpers
+16 -5
View File
@@ -21,15 +21,26 @@ logger = logging.getLogger(__name__)
_NOISE_WORDS_RE = re.compile(
r"\b(?:VOSTFR\d*|VOST|VF[IV]?|TRUEFRENCH|FRENCH|MULTI|SUBFR?)\b", re.IGNORECASE
)
_TRAILING_SEASON_RE = re.compile(r"[\s\-–—:.]*\s*(?:saison|season)\s*\d+\s*$", re.IGNORECASE)
_TRAILING_CODE_RE = re.compile(r"[\s\-–—:.]*\s*S\d+(?:E\d+)?\s*$", re.IGNORECASE)
_TRAILING_SEASON_RE = re.compile(r"[\s\-–—:.]*\b(?:saison|season)\s*\d+\s*$", re.IGNORECASE)
_TRAILING_CODE_RE = re.compile(r"[\s\-–—:.]*\bS\d+(?:\s*E\d+)?\s*$", re.IGNORECASE)
_TRAILING_EPISODE_RE = re.compile(r"[\s\-–—:.]*\b(?:e|ep|episode)\s*\d+\s*$", re.IGNORECASE)
def normalize_title(title: str) -> str:
"""Nettoie un titre de scraping avant recherche Kitsu (bruit, saison, tirets)."""
"""Nettoie un titre de scraping avant comparaison/recherche Kitsu.
Retire le bruit (VF/VOSTFR…), les marqueurs de saison/épisode en fin de titre
(« - Saison 1 - E3 », « S1 E1 ») — appliqués en boucle : un titre canonique
émergera identique d'un téléchargement (« Titre - Saison 1 - E3 ») ou d'une
fiche Kitsu (« Titre »), condition de l'exclusion du déjà-possédé.
"""
cleaned = _NOISE_WORDS_RE.sub(" ", title)
cleaned = _TRAILING_SEASON_RE.sub("", cleaned)
cleaned = _TRAILING_CODE_RE.sub("", cleaned)
previous = None
while previous != cleaned:
previous = cleaned
cleaned = _TRAILING_SEASON_RE.sub("", cleaned)
cleaned = _TRAILING_CODE_RE.sub("", cleaned)
cleaned = _TRAILING_EPISODE_RE.sub("", cleaned)
cleaned = re.sub(r"\s*[-–—_]+\s*", " ", cleaned)
cleaned = re.sub(r"\s+", " ", cleaned)
return cleaned.strip(" -–—:.")
-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())
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)
+1
View File
@@ -50,6 +50,7 @@ class SonarrService:
base_url=url,
timeout=_REQUEST_TIMEOUT,
headers={"X-Api-Key": apikey},
verify=False, # reverse proxy swizzin : certificat auto-signé (LAN uniquement)
)
async def _get(self, path: str, params: dict | None = None) -> dict | list | None:
+88 -9
View File
@@ -13,7 +13,9 @@ Formats :
- `t=search` → recherche libre (Prowlarr, recherche manuelle)
"""
import asyncio
import hashlib
import logging
import time
from dataclasses import dataclass
@@ -83,19 +85,59 @@ def _bencode(value) -> bytes:
def torrent_stub(announce_url: str, name: str) -> bytes:
"""Fichier .torrent minimal (le vrai téléchargement est fait par OhmStreaming)."""
return _bencode(
return build_stub(announce_url, name)[0]
def build_stub(announce_url: str, name: str) -> tuple[bytes, str]:
"""Fichier .torrent de service + infohash SHA-1 (identité côté Sonarr).
L'announce embarque les paramètres du grab (source, sid, season, ep, series) :
quand Sonarr renvoie ce .torrent à l'API compatible qBittorrent d'Ohm,
le grab est rejoué à l'identique.
"""
info = {"name": name + ".mp4", "length": 0, "piece length": 32768, "pieces": b"\x00" * 20}
data = _bencode(
{
"announce": announce_url,
"created by": "OhmStreaming",
"comment": name,
"info": {
"name": name + ".mp4",
"length": 0,
"piece length": 32768,
"pieces": b"\x00" * 20,
},
"info": info,
}
)
return data, hashlib.sha1(_bencode(info)).hexdigest()
def bdecode(data: bytes):
"""Décode un flux bencode (les clés dict reviennent en bytes)."""
def _parse(offset: int) -> tuple[object, int]:
char = data[offset : offset + 1]
if char == b"i":
end = data.index(b"e", offset)
return int(data[offset + 1 : end]), end + 1
if char in (b"d", b"l"):
is_dict = char == b"d"
items: dict | list = {} if is_dict else []
offset += 1
while data[offset : offset + 1] != b"e":
first, offset = _parse(offset)
if is_dict:
second, offset = _parse(offset)
items[first] = second
else:
items.append(first)
return items, offset + 1
if char.isdigit():
colon = data.index(b":", offset)
length = int(data[offset:colon])
start = colon + 1
return data[start : start + length], start + length
raise ValueError(f"bencode invalide à l'octet {offset}")
value, end = _parse(0)
if end != len(data):
raise ValueError("données après la fin du flux bencode")
return value
class TorznabService:
@@ -127,6 +169,31 @@ class TorznabService:
"""Recherche libre : tous les épisodes des fiches trouvées."""
return await self.tvsearch(q)
async def latest_releases(self) -> list[Release]:
"""Flux RSS (Sonarr) : dernier épisode de chaque nouveauté du catalogue."""
releases: list[Release] = []
for source in await self._enabled_sources():
try:
latest = await asyncio.wait_for(source.latest(), timeout=_SEARCH_TIMEOUT)
except (ScrapeError, TimeoutError) as exc:
logger.warning("Torznab : nouveautés %s KO : %s", source.name, exc)
continue
for result in latest[:_MAX_SERIES_PER_SOURCE]:
try:
episodes = await self._episodes_of(source, result.source_id)
except (ScrapeError, TimeoutError):
continue
whole = [e for e in episodes if e.number == int(e.number)]
if not whole:
continue
newest = max(whole, key=lambda e: (e.season, e.number))
releases.extend(
self._releases_for(result.title, source, result.source_id, [newest], None, None)
)
if len(releases) >= _MAX_RELEASES:
return releases[:_MAX_RELEASES]
return releases
async def _search_source(self, source: SourceScraper, q: str):
try:
return await asyncio.wait_for(source.search(q), timeout=_SEARCH_TIMEOUT)
@@ -179,11 +246,22 @@ class TorznabService:
# ------------------------------------------------------------ grab
async def grab(self, source: str, source_id: str, season: int, ep: int, series: str) -> dict:
async def grab(
self,
source: str,
source_id: str,
season: int,
ep: int,
series: str,
sonarr_hash: str | None = None,
) -> dict:
"""Résout l'épisode (embed → vidéo directe) puis l'ajoute à la file interne.
Retourne le dict du téléchargement (existant si doublon actif).
Lève ScrapeError si introuvable ou qu'aucun hébergeur n'a répondu.
sonarr_hash : infohash du .torrent de service — les téléchargements
Sonarr sont préfixés « sonarr:<hash>| » pour rester suivis via l'API
compatible qBittorrent.
"""
from app.scrapers.base import get_source
@@ -202,7 +280,8 @@ class TorznabService:
link = await self._resolve_video(scraper, match.url)
title = f"{series} S{season:02d}E{ep:02d}"
result = await download_manager.enqueue(link.url, match.url, title)
key = f"sonarr:{sonarr_hash}|{link.url}" if sonarr_hash else link.url
result = await download_manager.enqueue(link.url, match.url, title, source_key=key)
if link.is_hls or link.headers.get("Referer"):
result["note"] = "HLS/proxy : OhmStreaming gère le téléchargement via ffmpeg"
logger.info("Torznab grab %s → download id=%s", title, result.get("id"))
+18 -21
View File
@@ -1,6 +1,7 @@
"""Mises à jour logicielles.
- Détection : dernier tag semver du dépôt Gitea via son API (jeton requis, repo privé).
- Détection : dernier tag semver du dépôt Gitea public via son API (lecture anonyme),
avec le message du tag comme patchnote.
- Application : POST à Watchtower (compagnon docker-compose) qui tire la nouvelle
image et recrée le conteneur — quelques secondes d'indisponibilité.
"""
@@ -12,7 +13,6 @@ import time
import httpx
from app.config import get_settings
from app.services.settings import get_update_config
from app.version import get_version
logger = logging.getLogger(__name__)
@@ -20,11 +20,11 @@ logger = logging.getLogger(__name__)
_TAG_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
_CACHE_TTL = 300.0 # secondes
_cache: dict[str, object] = {"checked_at": 0.0, "latest": None}
_cache: dict[str, object] = {"checked_at": 0.0, "latest": None, "notes": None}
class UpdateError(Exception):
"""Erreur de mise à jour (config absente, Gitea injoignable, Watchtower KO)."""
"""Erreur de mise à jour (Gitea injoignable, Watchtower KO)."""
def parse_tag(tag: str) -> tuple[int, int, int] | None:
@@ -41,51 +41,48 @@ def is_newer(latest: str, current: str) -> bool:
async def fetch_latest_version(*, force: bool = False) -> str | None:
"""Dernier tag semver du dépôt (cache 5 min). None si non configuré ou erreur."""
"""Dernier tag semver du dépôt + patchnote (cache 5 min). None si erreur."""
now = time.monotonic()
latest_cache = _cache["latest"]
if not force and latest_cache and now - float(_cache["checked_at"]) < _CACHE_TTL:
return str(latest_cache) # type: ignore[arg-type]
config = await get_update_config()
if not config["repo"]:
return None
url = f"{config['gitea_url']}/api/v1/repos/{config['repo']}/tags?limit=20"
# Jeton requis uniquement pour un dépôt privé (public en lecture : inutile)
headers = {"Authorization": f"token {config['token']}"} if config["token"] else {}
settings = get_settings()
url = f"{settings.gitea_url}/api/v1/repos/{settings.gitea_repo}/tags?limit=20"
try:
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
resp = await client.get(url, headers=headers)
resp = await client.get(url)
resp.raise_for_status()
tags = [t["name"] for t in resp.json() if parse_tag(t.get("name", ""))]
tags = [
(t["name"], (t.get("message") or "").strip())
for t in resp.json()
if parse_tag(t.get("name", ""))
]
except (httpx.HTTPError, ValueError, KeyError) as exc:
logger.warning("Vérification de mise à jour impossible : %s", exc)
return None
latest = max(tags, key=parse_tag) if tags else None # type: ignore[arg-type]
_cache.update(checked_at=now, latest=latest)
latest, notes = max(tags, key=lambda t: parse_tag(t[0])) if tags else (None, None)
_cache.update(checked_at=now, latest=latest, notes=notes or None)
if latest and is_newer(latest, get_version()):
logger.info("Nouvelle version disponible : %s (courante %s)", latest, get_version())
return latest
def invalidate_cache() -> None:
_cache.update(checked_at=0.0, latest=None)
_cache.update(checked_at=0.0, latest=None, notes=None)
async def status() -> dict[str, object]:
"""État complet : version courante, dernière dispo, config."""
config = await get_update_config()
"""État complet : version courante, dernière dispo, patchnote."""
current = get_version()
latest = await fetch_latest_version()
return {
"current": current,
"latest": latest,
"notes": _cache["notes"],
"update_available": bool(latest and is_newer(latest, current)),
"configured": bool(config["repo"]),
"docker": bool(get_settings().watchtower_url),
"config": config,
}
+94 -1
View File
@@ -89,6 +89,14 @@ button { font-family: inherit; }
}
.icon-btn:hover { color: var(--text); background: var(--surface-2); }
.pref-select {
background: var(--surface); color: var(--text-dim);
border: 1px solid var(--border); border-radius: 8px;
font: inherit; font-size: 0.85rem;
padding: 0.35rem 0.5rem; cursor: pointer;
}
.pref-select:hover { color: var(--text); border-color: var(--accent); }
/* ------------------------------------------------------------ layout */
.main { padding: 2rem clamp(1rem, 3.5vw, 4rem) 4rem; }
@@ -344,7 +352,46 @@ button { font-family: inherit; }
.status-downloading { background: rgba(229, 9, 20, 0.16); color: #ff6b73; }
.status-done { background: rgba(70, 211, 105, 0.14); color: var(--success); }
.status-failed, .status-cancelled { background: rgba(255, 92, 92, 0.14); color: var(--danger); }
.dl-groups { display: flex; flex-direction: column; gap: 0.9rem; }
.dl-group { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); }
.dl-group-head {
display: flex;
align-items: center;
gap: 0.9rem;
flex-wrap: wrap;
padding: 0.75rem 1.1rem;
cursor: pointer;
user-select: none;
}
.dl-group-head:hover { background: var(--surface-2); }
.dl-chevron { color: var(--text-dim); font-size: 0.72rem; transition: transform 0.2s; }
.dl-chevron.open { transform: rotate(90deg); }
.dl-group-title { font-weight: 700; font-size: 1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dl-group-meta { display: flex; gap: 0.6rem; font-size: 0.78rem; font-weight: 600; white-space: nowrap; }
.dl-group-meta .m-downloading { color: #ff6b73; }
.dl-group-meta .m-pending { color: var(--warning); }
.dl-group-meta .m-done { color: var(--success); }
.dl-group-meta .m-failed { color: var(--danger); }
.dl-group-size { font-size: 0.8rem; color: var(--text-dim); font-variant-numeric: tabular-nums; }
.dl-group-body { display: flex; flex-direction: column; gap: 0.4rem; padding: 0.75rem; border-top: 1px solid var(--border); }
.dl-group-body .dl-row { border-color: transparent; background: transparent; padding: 0.55rem 0.7rem; }
.dl-group-body .dl-row:hover { background: var(--surface-2); border-color: var(--border); }
.dl-del-wrap { position: relative; }
.dl-menu {
position: absolute;
right: 0;
top: calc(100% + 0.35rem);
display: flex;
flex-direction: column;
gap: 0.4rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.45rem;
box-shadow: var(--shadow);
white-space: nowrap;
z-index: 20;
}
/* ------------------------------------------------------------ lecteur */
.player-wrap {
@@ -505,6 +552,52 @@ button { font-family: inherit; }
.rail-section { margin-bottom: 2.4rem; animation: rise 0.5s ease backwards; }
/* ------------------------------------------------------------- Explorer */
.explore-section { animation-delay: 0s; }
.explore-filters { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
.chip-row { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.genre-chip {
background: var(--surface); color: var(--text-dim);
border: 1px solid var(--border); border-radius: 99px;
font: inherit; font-size: 0.82rem; font-weight: 600;
padding: 0.32rem 0.85rem; cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.genre-chip:hover { color: var(--text); border-color: var(--accent); }
.genre-chip.active {
background: var(--accent); border-color: var(--accent); color: #fff;
}
.explore-skel .rail-skel { margin-top: 0.2rem; }
/* CTA d'amorçage « Pour toi » (aucun historique) */
.cta-card {
display: flex; align-items: center; gap: 1.6rem;
background: var(--surface); border: 1px solid var(--border);
border-radius: 14px; padding: 1.6rem 1.8rem;
}
.cta-emoji { font-size: 2.6rem; }
.cta-body { display: flex; flex-direction: column; gap: 0.4rem; }
.cta-title { font-weight: 700; font-size: 1.05rem; }
.cta-body p { margin: 0; color: var(--text-dim); font-size: 0.92rem; }
.cta-actions { display: flex; gap: 0.6rem; margin-top: 0.6rem; }
.cta-btn {
background: var(--accent); color: #fff; border: none; border-radius: 8px;
font: inherit; font-size: 0.88rem; font-weight: 600;
padding: 0.5rem 1rem; cursor: pointer; text-decoration: none;
transition: filter 0.15s;
}
.cta-btn:hover { filter: brightness(1.12); }
.cta-btn.ghost {
background: transparent; color: var(--text);
border: 1px solid var(--border);
}
.cta-btn.ghost:hover { border-color: var(--accent); color: var(--accent); filter: none; }
@media (max-width: 640px) {
.cta-card { flex-direction: column; text-align: center; }
.cta-actions { justify-content: center; flex-wrap: wrap; }
}
.rail-title {
font-size: 1.15rem;
font-weight: 700;
+17 -26
View File
@@ -59,6 +59,12 @@
Ajoutez un indexeur « Torznab » dans Prowlarr ou Sonarr avec cette URL et cette clé —
les épisodes grabés par Sonarr entrent directement dans la file de téléchargements OhmStreaming.
</p>
<p style="color:var(--text-dim);font-size:0.85rem;margin:0.6rem 0 0">
<strong>Encore mieux — client de téléchargement :</strong> dans Sonarr, ajoutez un client
<em>qBittorrent</em> pointant vers OhmStreaming (URL <code>http://&lt;hote-ohm&gt;:8777</code>,
utilisateur <code>ohm</code>, mot de passe = la clé API ci-dessus). Les épisodes seront
suivis en temps réel puis importés et renommés par Sonarr dans votre bibliothèque.
</p>
<table class="table">
<tbody>
<tr>
@@ -105,28 +111,23 @@
</p>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
<input class="input" x-model="upd.config.gitea_url" placeholder="https://git.lanro.eu"
style="flex:2;min-width:220px" @change="upd._dirty = true">
<input class="input" x-model="upd.config.repo" placeholder="Roman/ohm_streaming"
style="flex:1;min-width:180px" @change="upd._dirty = true">
<input class="input" x-model="upd.config.token" type="password" placeholder="Jeton d'accès Gitea"
style="flex:1;min-width:180px" @change="upd._dirty = true">
<button class="btn btn-sm" @click="saveUpdate()" :disabled="!upd._dirty">Enregistrer</button>
<button class="btn btn-sm btn-ghost" @click="checkUpdate()" :disabled="upd._checking"
x-text="upd._checking ? '…' : 'Vérifier'"></button>
<button class="btn btn-sm btn-accent" x-show="upd.update_available" @click="applyUpdate()"
:disabled="upd._applying" x-text="upd._applying ? 'Mise à jour…' : '⬆ Mettre à jour maintenant'"></button>
</div>
<p x-show="!upd.configured" style="margin:0.5rem 0 0;font-size:0.85rem;color:var(--text-dim)">
Renseignez le dépôt Gitea pour activer la détection des mises à jour.
Jeton d'accès (droit « lecture ») nécessaire uniquement si le dépôt est privé.
</p>
<p x-show="upd.configured && !upd.docker" style="margin:0.5rem 0 0;font-size:0.85rem;color:var(--text-dim)">
<details x-show="upd.notes" style="margin:0.6rem 0 0">
<summary style="cursor:pointer;font-size:0.85rem;color:var(--text-dim)">
Patchnote <span x-text="upd.latest"></span>
</summary>
<pre style="white-space:pre-wrap;font-size:0.82rem;margin:0.5rem 0 0;color:var(--text-dim)"
x-text="upd.notes"></pre>
</details>
<p x-show="!upd.docker" style="margin:0.5rem 0 0;font-size:0.85rem;color:var(--text-dim)">
⚠ Mise à jour automatique disponible uniquement en déploiement Docker
(<code>docker compose pull &amp;&amp; docker compose up -d</code> sinon).
</p>
</div>
+
<div class="panel">
<h2>👥 Utilisateurs</h2>
@@ -165,9 +166,8 @@ function adminPage() {
return {
users: [], sources: [], stats: {}, forbidden: false,
upd: {
current: '', latest: null, update_available: false, configured: false, docker: false,
config: { gitea_url: '', repo: '', token: '' },
_dirty: false, _checking: false, _applying: false,
current: '', latest: null, notes: null, update_available: false, docker: false,
_checking: false, _applying: false,
},
integrations: {
torznab: { apikey: '', endpoint: '' }, sonarr: { url: '', apikey: '' },
@@ -281,18 +281,9 @@ function adminPage() {
applyUpdateData(data) {
this.upd.current = data.current;
this.upd.latest = data.latest;
this.upd.notes = data.notes;
this.upd.update_available = data.update_available;
this.upd.configured = data.configured;
this.upd.docker = data.docker;
this.upd.config = { ...data.config, _dirty: false };
},
async saveUpdate() {
const res = await fetch('/api/admin/update', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.upd.config),
});
if (res.ok) { this.applyUpdateData(await res.json()); toast('✔ Configuration enregistrée'); }
},
async checkUpdate() {
+10 -4
View File
@@ -4,14 +4,14 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Ohm Stream{% endblock %}</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/style.css?v={{ asset_v }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
</head>
<body>
<header class="topbar" x-data="{ username: '' }" x-init="fetch('/auth/me').then(r => r.json()).then(u => username = u.username).catch(() => {})">
<header class="topbar" x-data="{ username: '', pref: 'both' }" x-init="fetch('/auth/me').then(r => r.json()).then(u => { username = u.username; pref = u.content_preference || 'both'; }).catch(() => {})">
<a class="logo" href="/">OHM<span>STREAM</span></a>
<nav class="topnav">
<a class="{{ 'active' if active == 'discover' }}" href="/discover">Découvrir</a>
@@ -22,6 +22,12 @@
<a class="{{ 'active' if active == 'admin' }}" href="/admin">Admin</a>
</nav>
<div class="topbar-right">
<select class="pref-select" x-model="pref" x-show="username" title="Types de contenus"
@change="fetch('/auth/preferences', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ content_preference: pref }) }).then(() => location.reload())">
<option value="anime">⛩ Animés</option>
<option value="serie">📺 Séries</option>
<option value="both">✨ Les deux</option>
</select>
<div class="avatar" x-text="username ? username[0] : '·'" :title="username"></div>
<form method="post" action="/auth/logout" style="display:inline">
<button class="icon-btn" type="submit" title="Déconnexion">⏻</button>
@@ -31,8 +37,8 @@
<main class="main">
{% block content %}{% endblock %}
</main>
<script src="/static/js/app.js"></script>
<script src="/static/js/update-watcher.js"></script>
<script src="/static/js/app.js?v={{ asset_v }}"></script>
<script src="/static/js/update-watcher.js?v={{ asset_v }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+171 -15
View File
@@ -4,10 +4,61 @@
{% block content %}
<h1 class="page-title">Découvrir</h1>
<p class="page-sub">Nouveautés de tes sources, incontournables et suggestions basées sur tes téléchargements.</p>
<p class="page-sub">Explore par genre, nouveautés de tes sources, incontournables et suggestions basées sur tes téléchargements.</p>
<div x-data="discoverPage()" x-init="load()" x-cloak>
<!-- ------------------------------------------------ Explorer par genre -->
<section class="rail-section explore-section" x-show="!loading && types.length > 0">
<h2 class="rail-title">🎭 Explorer
<span class="rail-source" x-text="exploreSubtitle"></span>
</h2>
<div class="explore-filters">
<div class="chip-row" role="tablist" aria-label="Type de contenu">
<template x-for="t in types" :key="t">
<button class="genre-chip" :class="{ active: exploreType === t }" role="tab"
:aria-selected="exploreType === t" @click="setType(t)"
x-text="typeLabels[t]"></button>
</template>
</div>
<div class="chip-row" role="tablist" aria-label="Genre" x-show="currentGenres.length > 0">
<template x-for="g in currentGenres" :key="g.key">
<button class="genre-chip" :class="{ active: exploreGenre === g.key }" role="tab"
:aria-selected="exploreGenre === g.key" @click="setGenre(g.key)"
x-text="g.label"></button>
</template>
</div>
</div>
<div class="rail-wrap" x-show="!exploreLoading">
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
@click="scrollRail($el, -1)">‹</button>
<div class="rail">
<template x-for="(item, i) in exploreItems" :key="'ex' + i + (item.source_id || item.kitsu_id || item.title)">
<a class="card rail-card" :style="`--i:${i}`" :href="exploreHref(item)"
:title="item.source ? item.title : `Rechercher « ${item.title} »`">
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
<span class="card-chip" x-show="item.label" x-text="item.label"></span>
<span class="card-chip" x-show="!item.label && item.rating" x-text="item.rating ? '★ ' + item.rating : ''"></span>
<div class="card-overlay">
<div class="card-title" x-text="item.title"></div>
<div class="card-meta">
<span class="badge badge-type" x-text="item.start_date ? item.start_date.slice(0, 4) : (item.year || '')"></span>
</div>
</div>
</a>
</template>
</div>
<button class="rail-nav rail-next" type="button" aria-label="Défiler à droite"
@click="scrollRail($el, 1)">›</button>
</div>
<div class="explore-skel" x-show="exploreLoading">
<div class="rail rail-skel">
<template x-for="i in 8" :key="'sk' + i"><div class="rail-card skel skel-card"></div></template>
</div>
</div>
</section>
<!-- ------------------------------------------------ Skeletons de chargement -->
<div x-show="loading">
<section class="rail-section" x-data="{ n: 8 }">
@@ -28,14 +79,14 @@
<div class="login-error" x-text="error"></div>
</template>
<!-- ------------------------------------------------ Nouveautés -->
<section class="rail-section" x-show="!loading && latest.length > 0">
<h2 class="rail-title">🆕 Nouveautés <span class="rail-source">de tes sources, triées par date de sortie</span></h2>
<!-- ------------------------------------------------ Nouveautés animés -->
<section class="rail-section" x-show="!loading && latestAnime.length > 0">
<h2 class="rail-title">🆕 Nouveautés animés <span class="rail-source">triées par date de sortie</span></h2>
<div class="rail-wrap">
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
@click="scrollRail($el, -1)">‹</button>
<div class="rail">
<template x-for="(item, i) in latest" :key="item.source + item.source_id">
<template x-for="(item, i) in latestAnime" :key="item.source + item.source_id">
<a class="card rail-card" :style="`--i:${i}`" :href="`/title/${item.source}/${encodeURIComponent(item.source_id)}`">
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
@@ -56,9 +107,33 @@
</div>
</section>
<!-- ------------------------------------------------ Nouveautés séries & films -->
<section class="rail-section" x-show="!loading && latestSerie.length > 0">
<h2 class="rail-title">🆕 Nouveautés séries &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 -->
<section class="rail-section" x-show="!loading && mustWatch.length > 0">
<h2 class="rail-title">🔥 Incontournables <span class="rail-source">les classiques les mieux notés</span></h2>
<h2 class="rail-title">🔥 Incontournables animés <span class="rail-source">les classiques les mieux notés</span></h2>
<div class="rail-wrap">
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
@click="scrollRail($el, -1)">‹</button>
@@ -81,11 +156,26 @@
</section>
<!-- ------------------------------------------------ Pour toi -->
<section class="rail-section" x-show="!loading && forYou.items.length > 0">
<section class="rail-section" x-show="!loading && (forYou.items.length > 0 || forYou.cold_start)">
<h2 class="rail-title">✨ Pour toi
<span class="rail-source" x-show="forYou.based_on.length"
x-text="forYou.based_on.length ? 'parce que tu aimes : ' + forYou.based_on.join(', ') : ''"></span>
</h2>
<!-- Aucun historique : carte d'amorçage au lieu d'une section muette -->
<div class="cta-card" x-show="!loading && forYou.cold_start">
<div class="cta-emoji">🧭</div>
<div class="cta-body">
<div class="cta-title">On ne te connaît pas encore…</div>
<p>Télécharge tes premiers animés ou séries, ou ajoute des favoris :
« Pour toi » s'ajustera automatiquement à tes genres préférés.</p>
<div class="cta-actions">
<a class="cta-btn" href="/">Chercher un premier titre</a>
<a class="cta-btn ghost" href="/discover">Explorer par genre</a>
</div>
</div>
</div>
<div class="rail-wrap">
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
@click="scrollRail($el, -1)">‹</button>
@@ -107,7 +197,7 @@
</div>
</section>
<template x-if="!loading && !error && latest.length === 0 && mustWatch.length === 0 && forYou.items.length === 0">
<template x-if="!loading && !error && latestAnime.length === 0 && latestSerie.length === 0 && mustWatch.length === 0 && forYou.items.length === 0 && exploreItems.length === 0">
<div class="empty-state"><div class="big">🏜️</div>Rien à afficher pour le moment — réessaie plus tard.</div>
</template>
</div>
@@ -117,23 +207,89 @@
<script>
function discoverPage() {
return {
latest: [], mustWatch: [], forYou: { based_on: [], items: [] },
latestAnime: [], latestSerie: [], mustWatch: [], forYou: { based_on: [], items: [] },
genres: {}, exploreType: null, exploreGenre: null,
exploreItems: [], exploreLoading: false,
loading: true, error: null,
typeLabels: { anime: '⛩ Animés', serie: '📺 Séries', film: '🎬 Films' },
get types() { return Object.keys(this.genres); },
get currentGenres() { return this.genres[this.exploreType] || []; },
get exploreSubtitle() {
const genre = (this.currentGenres.find(g => g.key === this.exploreGenre) || {}).label;
return genre ? `${this.typeLabels[this.exploreType] || ''} · ${genre}` : 'choisis un genre';
},
scrollRail(el, dir) {
const rail = el.closest('.rail-wrap').querySelector('.rail');
rail.scrollBy({ left: dir * rail.clientWidth * 0.85, behavior: 'smooth' });
},
exploreHref(item) {
return item.source
? `/title/${item.source}/${encodeURIComponent(item.source_id)}`
: `/?q=${encodeURIComponent(item.title)}`;
},
setType(type) {
this.exploreType = type;
this.exploreGenre = (this.genres[type][0] || {}).key || null;
this.browse();
},
setGenre(genre) {
this.exploreGenre = genre;
this.browse();
},
async browse() {
if (!this.exploreType || !this.exploreGenre) return;
this.exploreLoading = true;
const params = new URLSearchParams({ type: this.exploreType, genre: this.exploreGenre });
const url = `${location.pathname}?${params}`;
history.replaceState(null, '', url); // état partageable
try {
const res = await fetch(`/api/discover/browse?${params}`);
if (!res.ok) throw new Error('Erreur ' + res.status);
this.exploreItems = (await res.json()).items;
} catch (e) {
this.exploreItems = [];
console.error('Explorer :', e);
} finally {
this.exploreLoading = false;
}
},
async load() {
this.loading = true; this.error = null;
try {
const res = await fetch('/api/discover');
if (!res.ok) throw new Error('Erreur ' + res.status);
const data = await res.json();
this.latest = data.latest;
const [discoverRes, genresRes] = await Promise.all([
fetch('/api/discover'), fetch('/api/discover/genres'),
]);
if (!discoverRes.ok) throw new Error('Erreur ' + discoverRes.status);
const data = await discoverRes.json();
this.latestAnime = data.latest_anime;
this.latestSerie = data.latest_serie;
this.mustWatch = data.must_watch;
this.forYou = data.for_you;
} catch (e) { this.error = e.message; }
finally { this.loading = false; }
this.genres = await genresRes.json();
// État initial : paramètres d'URL (?t=&g=) sinon premier type/genre dispo
const params = new URLSearchParams(location.search);
let type = params.get('t');
if (!this.genres[type]) type = this.types[0];
let genre = params.get('g');
if (!(this.genres[type] || []).some(g => g.key === genre)) {
genre = ((this.genres[type] || [])[0] || {}).key;
}
this.exploreType = type || null;
this.exploreGenre = genre || null;
await this.browse();
} catch (e) {
this.error = e.message;
} finally {
this.loading = false;
}
},
};
}
+162 -52
View File
@@ -3,18 +3,18 @@
{% block title %}Téléchargements — Ohm Stream{% endblock %}
{% block content %}
<div x-data="downloadsPage()" x-init="connect()">
<div style="display:flex;align-items:center;gap:0.8rem;margin-bottom:1.6rem">
<div x-data="downloadsPage()" x-init="connect()" @click.outside="confirmDel = null">
<div style="display:flex;align-items:center;gap:0.8rem;margin-bottom:1.6rem;flex-wrap:wrap">
<div>
<h1 class="page-title">Téléchargements</h1>
<p class="page-sub" style="margin-bottom:0">
<span x-text="items.length"></span> tâche(s) —
<span x-text="groups.length"></span> animé(s) · <span x-text="items.length"></span> épisode(s) —
<span :style="connected ? 'color:var(--success)' : 'color:var(--danger)'"
x-text="connected ? '● temps réel' : '○ reconnecté…'"></span>
</p>
</div>
<div style="flex:1"></div>
<button class="btn btn-ghost btn-sm" @click="action('clear-finished')">🧹 Nettoyer</button>
<button class="btn btn-ghost btn-sm" @click="clearFinished()">🧹 Nettoyer</button>
<button class="btn btn-danger btn-sm" @click="action('cancel-all')">✖ Tout annuler</button>
</div>
@@ -22,43 +22,83 @@
<div class="empty-state"><div class="big">📭</div>Aucun téléchargement. Lance une recherche !</div>
</template>
<div class="episode-list">
<template x-for="d in items" :key="d.id">
<div class="dl-row">
<div class="dl-head">
<span class="status-pill" :class="'status-' + d.status" x-text="d.status_label"></span>
<span class="dl-title" x-text="d.title"></span>
<div class="dl-actions">
<template x-if="d.status === 'downloading'">
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/pause')">⏸</button>
</template>
<template x-if="d.status === 'paused'">
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/resume')">▶</button>
</template>
<template x-if="['failed', 'cancelled'].includes(d.status)">
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/retry')">↻</button>
</template>
<template x-if="['pending', 'downloading', 'paused'].includes(d.status)">
<button class="btn btn-sm btn-danger" @click="action(d.id + '/cancel')">✖</button>
</template>
<template x-if="d.status === 'done'">
<a class="btn btn-sm" :href="'/watch/' + d.id">▶ Regarder</a>
</template>
<div class="dl-groups">
<template x-for="g in groups" :key="g.name">
<div class="dl-group">
<div class="dl-group-head" @click="toggle(g)">
<span class="dl-chevron" :class="{ open: isOpen(g) }">▶</span>
<span class="dl-group-title" x-text="g.name"></span>
<span class="dl-group-meta">
<span class="m-downloading" x-show="g.counts.downloading"
x-text="'⬇ ' + g.counts.downloading"></span>
<span class="m-pending" x-show="g.counts.pending + g.counts.paused"
x-text="'⏳ ' + (g.counts.pending + g.counts.paused)"></span>
<span class="m-done" x-show="g.counts.done" x-text="'✔ ' + g.counts.done"></span>
<span class="m-failed" x-show="g.counts.failed + g.counts.cancelled"
x-text="'✖ ' + (g.counts.failed + g.counts.cancelled)"></span>
</span>
<div style="flex:1"></div>
<span class="dl-group-size" x-show="g.bytes" x-text="fmtBytes(g.bytes)"></span>
<div class="dl-actions" @click.stop>
<button class="btn btn-sm btn-ghost" title="Retirer les terminés de ce groupe"
x-show="g.counts.done + g.counts.failed + g.counts.cancelled"
@click="cleanGroup(g)">🧹</button>
<button class="btn btn-sm btn-danger" title="Annuler les téléchargements actifs de ce groupe"
x-show="g.counts.active" @click="cancelGroup(g)">✖</button>
</div>
</div>
<div style="display:flex;align-items:center;gap:0.9rem">
<div class="progress">
<div class="progress-fill" :class="d.status"
:style="`width:${d.percent ?? (d.status === 'done' ? 100 : 0)}%`"></div>
</div>
<span style="min-width:4rem;text-align:right;font-size:0.85rem;font-weight:600"
x-text="d.percent != null ? d.percent + '%' : (d.status === 'done' ? '100%' : '—')"></span>
</div>
<div class="dl-stats">
<span x-text="fmtBytes(d.downloaded_bytes) + ' / ' + (d.total_bytes ? fmtBytes(d.total_bytes) : '?')"></span>
<span x-show="d.speed_bps" x-text="'⚡ ' + fmtBytes(d.speed_bps) + '/s'"></span>
<span x-show="d.eta_seconds != null" x-text="'⏱ ' + fmtEta(d.eta_seconds)"></span>
<span x-show="d.error" style="color:var(--danger)" x-text="d.error"></span>
<div class="dl-group-body" x-show="isOpen(g)" x-cloak>
<template x-for="d in g.items" :key="d.id">
<div class="dl-row">
<div class="dl-head">
<span class="status-pill" :class="'status-' + d.status" x-text="d.status_label"></span>
<span class="dl-title" x-text="d._label || d.title"></span>
<div class="dl-actions">
<template x-if="d.status === 'downloading'">
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/pause')">⏸</button>
</template>
<template x-if="d.status === 'paused'">
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/resume')">▶</button>
</template>
<template x-if="['failed', 'cancelled'].includes(d.status)">
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/retry')">↻</button>
</template>
<template x-if="['pending', 'downloading', 'paused'].includes(d.status)">
<button class="btn btn-sm btn-danger" @click="action(d.id + '/cancel')">✖</button>
</template>
<template x-if="d.status === 'done'">
<a class="btn btn-sm" :href="'/watch/' + d.id">▶ Regarder</a>
</template>
<div class="dl-del-wrap">
<button class="btn btn-sm btn-ghost" title="Supprimer"
@click.stop="confirmDel = confirmDel === d.id ? null : d.id">🗑</button>
<div class="dl-menu" x-show="confirmDel === d.id" x-cloak>
<button class="btn btn-sm" @click="remove(d, false)"
x-text="d.status === 'done' ? 'Liste seulement' : 'Supprimer'"></button>
<template x-if="d.status === 'done'">
<button class="btn btn-sm btn-danger" @click="remove(d, true)">Liste + fichier</button>
</template>
</div>
</div>
</div>
</div>
<div style="display:flex;align-items:center;gap:0.9rem">
<div class="progress">
<div class="progress-fill" :class="d.status"
:style="`width:${d.percent ?? (d.status === 'done' ? 100 : 0)}%`"></div>
</div>
<span style="min-width:4rem;text-align:right;font-size:0.85rem;font-weight:600"
x-text="d.percent != null ? d.percent + '%' : (d.status === 'done' ? '100%' : '—')"></span>
</div>
<div class="dl-stats">
<span x-text="fmtBytes(d.downloaded_bytes) + ' / ' + (d.total_bytes ? fmtBytes(d.total_bytes) : '?')"></span>
<span x-show="d.speed_bps" x-text="'⚡ ' + fmtBytes(d.speed_bps) + '/s'"></span>
<span x-show="d.eta_seconds != null" x-text="'⏱ ' + fmtEta(d.eta_seconds)"></span>
<span x-show="d.error" style="color:var(--danger)" x-text="d.error"></span>
</div>
</div>
</template>
</div>
</div>
</template>
@@ -71,6 +111,8 @@
function downloadsPage() {
return {
items: [], connected: false, es: null,
confirmDel: null, // id de l'épisode dont le menu 🗑 est ouvert
toggled: {}, // choix manuel de repli/dépliage par groupe
connect() {
this.es = new EventSource('/api/downloads/events');
@@ -78,29 +120,97 @@ function downloadsPage() {
this.es.onerror = () => this.connected = false;
this.es.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'snapshot') this.items = msg.items;
else if (msg.type === 'update') this.upsert(msg.item);
if (msg.type === 'snapshot') this.items = msg.items.map(d => this.decorate(d));
else if (msg.type === 'update') this.upsert(this.decorate(msg.item));
else if (msg.type === 'removed') this.items = this.items.filter(d => d.id !== msg.id);
};
},
// Découpe « Anime - E12 (VOSTFR) » → base « Anime », numéro, libellé d'épisode
decorate(d) {
const m = d.title.match(/^(.*?)[\s\-–—]*(?:épisode|episode|ep|e)\s*(\d+(?:\.\d+)?)(.*)$/i);
if (m && m[1].trim()) {
d._base = m[1].trim();
d._num = parseFloat(m[2]);
d._label = ('E' + m[2] + ' ' + (m[3] || '')).replace(/\s+/g, ' ').trim();
} else {
d._base = d.title;
d._num = null;
d._label = '';
}
return d;
},
get groups() {
const map = new Map();
for (const d of this.items) {
if (!map.has(d._base)) map.set(d._base, []);
map.get(d._base).push(d);
}
const groups = [];
for (const [name, list] of map) {
const c = { downloading: 0, pending: 0, paused: 0, done: 0, failed: 0, cancelled: 0 };
for (const d of list) c[d.status]++;
c.active = c.downloading + c.pending + c.paused;
list.sort((a, b) => (a._num ?? Infinity) - (b._num ?? Infinity) || a.id - b.id);
groups.push({
name, items: list, counts: c,
bytes: list.reduce((s, d) => s + (d.status === 'done' ? d.total_bytes || 0 : 0), 0),
});
}
const prio = g => g.counts.downloading ? 0 : g.counts.active ? 1 : 2;
groups.sort((a, b) => prio(a) - prio(b) || a.name.localeCompare(b.name, 'fr'));
return groups;
},
isOpen(g) {
const t = this.toggled[g.name];
return t !== undefined ? t : g.counts.active > 0;
},
toggle(g) {
this.toggled[g.name] = !this.isOpen(g);
},
upsert(item) {
const i = this.items.findIndex(d => d.id === item.id);
if (i >= 0) this.items[i] = item;
else this.items.unshift(item);
this.sort();
},
sort() {
const order = { downloading: 0, pending: 1, paused: 2 };
this.items.sort((a, b) => (order[a.status] ?? 3) - (order[b.status] ?? 3));
else {
this.items.unshift(item);
delete this.toggled[item._base]; // nouveau groupe/épisode → retour au comportement par défaut
}
},
async action(path) {
await fetch('/api/downloads/' + path, { method: 'POST' });
if (path === 'clear-finished') {
const res = await fetch('/api/downloads');
this.items = await res.json();
}
},
async remove(d, deleteFile) {
this.confirmDel = null;
this.items = this.items.filter(i => i.id !== d.id);
await fetch('/api/downloads/' + d.id + (deleteFile ? '?delete_file=true' : ''), { method: 'DELETE' });
toast(deleteFile ? '🗑 Épisode et fichier supprimés' : '🗑 Retiré de la liste');
},
async clearFinished() {
await this.action('clear-finished');
const res = await fetch('/api/downloads');
this.items = (await res.json()).map(d => this.decorate(d));
},
async cleanGroup(g) {
const ids = g.items
.filter(d => ['done', 'failed', 'cancelled'].includes(d.status))
.map(d => d.id);
this.items = this.items.filter(d => !ids.includes(d.id));
await Promise.all(ids.map(id => fetch('/api/downloads/' + id, { method: 'DELETE' })));
toast('🧹 ' + ids.length + ' épisode(s) retiré(s) : ' + g.name);
},
async cancelGroup(g) {
const actives = g.items.filter(d => ['pending', 'downloading', 'paused'].includes(d.status));
await Promise.all(actives.map(d => this.action(d.id + '/cancel')));
toast('✖ ' + actives.length + ' téléchargement(s) annulé(s) : ' + g.name);
},
fmtBytes(n) {
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Connexion — Ohm Stream</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/style.css?v={{ asset_v }}">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
</head>
+11 -7
View File
@@ -1,8 +1,10 @@
# Déploiement OhmStreaming — https://git.lanro.eu/Roman/ohm_streaming
#
# 1. docker login git.lanro.eu (compte Gitea avec accès lecture au repo)
# 2. cp .env.example .env && $EDITOR .env (OHM_SECRET_KEY + WATCHTOWER_TOKEN obligatoires)
# 3. docker compose up -d
# Installation guidée (recommandée) : ./scripts/install.sh
# Ou à la main :
# 1. docker login git.lanro.eu (compte Gitea avec accès lecture au repo)
# 2. cp .env.example .env && $EDITOR .env (OHM_SECRET_KEY + WATCHTOWER_TOKEN obligatoires)
# 3. docker compose up -d
#
# Mise à jour : page Admin → « Mise à jour », ou manuellement :
# docker compose pull && docker compose up -d
@@ -29,10 +31,12 @@ services:
OHM_WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:?WATCHTOWER_TOKEN manquant — voir .env.example}
volumes:
- ./data:/data # base SQLite, persiste entre les mises à jour
- ./downloads:/downloads # bibliothèque téléchargée
labels:
# Watchtower ne touche que les conteneurs portant ce label
- com.centurylinklabs.watchtower.enable=true
# Bibliothèque d'épisodes : ./downloads par défaut, ou un dossier de ton
# serveur (script d'install ou à la main). Un dossier VIDE est chowné au
# démarrage par l'entrypoint ; un dossier existant (biblio Plex/Sonarr)
# n'est JAMAIS modifié — les épisodes arrivent rangés par animé :
# /plex_videos/ohm/One Piece/One Piece - E12 (VOSTFR).mp4
- ./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)
Regular → Executable
+10 -3
View File
@@ -1,10 +1,17 @@
#!/bin/sh
# Corrige les permissions des volumes (créés par Docker en root au premier up)
# puis bascule sur l'utilisateur non-root « ohm ».
# Prépare les volumes puis bascule sur l'utilisateur non-root « ohm ».
#
# /data (base SQLite) : toujours à nous → chown récursif.
# /downloads : peut être un montage sur une bibliothèque média existante
# (Plex/Sonarr…). On ne prend possession QUE d'un dossier vide — jamais
# d'une bibliothèque déjà remplie, dont les droits doivent rester intacts.
set -e
if [ "$(id -u)" = "0" ]; then
chown -R ohm:ohm /data /downloads 2>/dev/null || true
chown -R ohm:ohm /data 2>/dev/null || true
if [ -z "$(ls -A /downloads 2>/dev/null)" ]; then
chown -R ohm:ohm /downloads 2>/dev/null || true
fi
exec gosu ohm:ohm "$@"
fi
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "ohm-stream"
version = "0.1.0"
version = "0.2.0"
description = "Ohm Stream Downloader — centre de contrôle auto-hébergé pour animes et séries VOSTFR"
requires-python = ">=3.13"
dependencies = [
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# Installation guidée d'Ohm Stream Downloader (premier setup).
#
# bash scripts/install.sh (depuis un clone du dépôt)
# curl -fsSL <gitea>/raw/branch/main/scripts/install.sh | bash
#
# Options non interactives : --dir <chemin> --port <n> --skip-login
# (les valeurs passées en option court-circuitent les questions)
#
# Pose les questions (destination des animés, port), génère les secrets,
# écrit .env, branche le montage Docker, démarre et attend le service.
# ---------------------------------------------------------------------------
set -euo pipefail
REGISTRY_HOST="git.lanro.eu"
REPO_URL="https://${REGISTRY_HOST}/Roman/ohm_streaming.git"
COMPOSE_FILE="docker-compose.yml"
BOLD="\033[1m"; DIM="\033[2m"; GREEN="\033[32m"; YELLOW="\033[33m"; RED="\033[31m"; RESET="\033[0m"
step() { printf "\n${BOLD}▸ %s${RESET}\n" "$1"; }
ok() { printf " ${GREEN}✔${RESET} %s\n" "$1"; }
warn() { printf " ${YELLOW}⚠${RESET} %s\n" "$1"; }
die() { printf " ${RED}✖ %s${RESET}\n" "$1" >&2; exit 1; }
ARG_DIR="" ARG_PORT="" ARG_SKIP_LOGIN=0
while [ $# -gt 0 ]; do
case "$1" in
--dir) ARG_DIR="$2"; shift 2;;
--port) ARG_PORT="$2"; shift 2;;
--skip-login) ARG_SKIP_LOGIN=1; shift;;
*) shift;;
esac
done
ask() { # ask <question> <défaut> — lit /dev/tty pour rester compatible curl|bash
local answer=""
if [ -r /dev/tty ]; then
read -r -p "$(printf ' %s ${DIM}[%s]${RESET} ' "$1" "$2")" answer </dev/tty 2>/dev/null || true
fi
echo "${answer:-$2}"
}
rand_hex() { # rand_hex <nb octets>
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex "$1"
else
head -c "$1" /dev/urandom | od -An -tx1 | tr -d ' \n'
fi
}
printf "${BOLD}⛩ Ohm Stream Downloader — installation${RESET}\n"
printf "${DIM}Télécharge et streame des animes — Docker + Watchtower${RESET}\n"
# ---------------------------------------------------------------- prérequis
step "Vérification des prérequis"
command -v docker >/dev/null 2>&1 || die "Docker n'est pas installé. → curl -fsSL https://get.docker.com | sh"
docker compose version >/dev/null 2>&1 || die "Le plugin Docker Compose manque (paquet docker-compose-plugin)."
ok "Docker + Compose présents"
# Depuis un clone ? sinon on clone (permet curl … | bash)
if [ ! -f "$COMPOSE_FILE" ]; then
INSTALL_DIR="$(ask "Dossier d'installation" "$HOME/ohm_streaming")"
if [ -e "$INSTALL_DIR" ]; then
[ -f "$INSTALL_DIR/$COMPOSE_FILE" ] || die "$INSTALL_DIR existe déjà (et n'est pas une installation Ohm)."
step "Mise à jour du clone existant"
git -C "$INSTALL_DIR" pull --ff-only || warn "git pull a échoué, on continue avec la version locale."
else
step "Récupération du projet"
command -v git >/dev/null 2>&1 || die "git est requis pour cloner $REPO_URL"
git clone "$REPO_URL" "$INSTALL_DIR"
fi
cd "$INSTALL_DIR"
fi
ok "Projet prêt dans $(pwd)"
# ---------------------------------------------------------------- registre privé
step "Accès au registre privé ($REGISTRY_HOST)"
if [ "$ARG_SKIP_LOGIN" = "1" ]; then
warn "--skip-login : connexion au registre ignorée"
elif docker login "$REGISTRY_HOST" 2>/dev/null; then
ok "Connecté au registre"
else
warn "Pas encore connecté — un compte Gitea avec accès lecture au dépôt est requis."
if [ "$(ask "Se connecter maintenant (docker login) ?" "o")" = "o" ]; then
docker login "$REGISTRY_HOST" || die "docker login a échoué — relance le script après t'être connecté."
ok "Connecté au registre"
else
die "Sans docker login, l'image ne pourra pas être tirée."
fi
fi
# ---------------------------------------------------------------- destination des épisodes
step "Destination des épisodes téléchargés"
if [ -n "$ARG_DIR" ]; then
HOST_DIR="$ARG_DIR"
else
printf " 1) ${BOLD}Dossier dédié « ohm »${RESET} (recommandé) : Ohm crée/utilise un dossier\n\
à part, à ajouter à ta bibliothèque Plex. Tes dossiers Sonarr/Plex\n\
existants ne sont JAMAIS modifiés.\n\
2) Dossier de ton choix (ex. un sous-dossier de ta biblio d'animes).\n\
S'il n'est pas vide, Ohm importera aussi les fichiers déjà présents.\n"
MODE="$(ask "Choix (1/2)" "1")"
if [ "$MODE" = "1" ]; then
HOST_DIR="$(ask "Chemin du dossier dédié" "/opt/ohm/animes")"
else
HOST_DIR="$(ask "Chemin du dossier de téléchargement" "/plex_videos/anime/ohm")"
fi
fi
if [ -e "$HOST_DIR" ] && [ -n "$(ls -A "$HOST_DIR" 2>/dev/null)" ]; then
warn "$HOST_DIR n'est pas vide : Ohm l'adoptera tel quel (droits existants conservés)."
warn "Le conteneur (uid 1000) doit pouvoir y écrire."
fi
if ! mkdir -p "$HOST_DIR" 2>/dev/null; then
printf ' sudo mkdir -p %q && sudo chown 1000:1000 %q\n' "$HOST_DIR" "$HOST_DIR" >&2
die "Impossible de créer $HOST_DIR — crée-le avec sudo puis relance."
fi
ok "Épisodes → $HOST_DIR (un dossier vide est pris en charge automatiquement)"
# ---------------------------------------------------------------- port + secrets
step "Configuration"
PORT="${ARG_PORT:-$(ask "Port HTTP exposé" "8777")}"
step "Écriture de .env"
if [ -f .env ]; then
cp .env ".env.backup.$(date +%s)"
warn ".env existant sauvegardé"
fi
SECRET_KEY="$(rand_hex 32)"
WT_TOKEN="$(rand_hex 24)"
cat > .env <<EOF
# Généré par scripts/install.sh le $(date '+%Y-%m-%d %H:%M')
OHM_SECRET_KEY=${SECRET_KEY}
WATCHTOWER_TOKEN=${WT_TOKEN}
OHM_PORT=${PORT}
EOF
chmod 600 .env
ok "Secrets générés (OHM_SECRET_KEY, WATCHTOWER_TOKEN)"
# ---------------------------------------------------------------- compose : montage téléchargements
step "Configuration du montage Docker"
cp "$COMPOSE_FILE" "$COMPOSE_FILE.dist"
if sed -i "s|^\([[:space:]]*- \)\./downloads:/downloads.*$|\1${HOST_DIR}:/downloads|" "$COMPOSE_FILE" \
&& grep -q -- "${HOST_DIR}:/downloads" "$COMPOSE_FILE"; then
ok "/downloads → $HOST_DIR (original conservé en $COMPOSE_FILE.dist)"
else
mv "$COMPOSE_FILE.dist" "$COMPOSE_FILE"
die "Montage introuvable dans $COMPOSE_FILE — édite la ligne ./downloads:/downloads à la main."
fi
# ---------------------------------------------------------------- démarrage
step "Démarrage (docker compose up -d)"
docker compose up -d || die "Démarrage échoué — logs : docker compose logs"
step "Attente du démarrage"
HEALTH_URL="http://127.0.0.1:${PORT}/health"
for i in $(seq 1 30); do
if curl -sf "$HEALTH_URL" >/dev/null 2>&1 || wget -q -O /dev/null "$HEALTH_URL" 2>/dev/null; then
break
fi
sleep 2
if [ "$i" = "30" ]; then docker compose logs --tail 30 ohm; die "Le service ne répond pas sur $HEALTH_URL"; fi
done
ok "Service en ligne"
HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
printf "\n${BOLD}${GREEN}⛩ Ohm Stream est prêt !${RESET}\n\n"
printf " URL : ${BOLD}http://%s:%s${RESET}\n" "${HOST_IP:-<ip-serveur>}" "$PORT"
printf " Épisodes : %s\n" "$HOST_DIR"
printf " 1er compte : ${BOLD}le compte créé en premier devient administrateur${RESET}\n\n"
printf " ${DIM}Plex : ajoute %s comme dossier d'une bibliothèque (ou comme dossier\n supplémentaire de ta bibliothèque animes) — les épisodes arrivent rangés par\n animé : <Animé>/<Animé> - E12 (VOSTFR).mp4${RESET}\n\n" "$HOST_DIR"
printf " ${DIM}Commandes : docker compose logs -f · stop · pull && up -d (mise à jour)${RESET}\n"
Executable → Regular
+3 -3
View File
@@ -2,11 +2,11 @@
# Publie une version d'OhmStreaming :
# bump de version → commit + tag git → build de l'image Docker → push registre Gitea.
#
# Usage : ./scripts/release.sh 0.2.0
# Usage : ./scripts/release.sh 0.2.0 ["patchnote multi-lignes"]
# Option : OHM_REGISTRY=git.lanro.eu/roman/ohm_streaming (défaut) pour viser un autre registre.
set -euo pipefail
VERSION="${1:?Usage : ./scripts/release.sh <version> (ex. 0.2.0)}"
VERSION="${1:?Usage : ./scripts/release.sh <version> [patchnote] (ex : 0.2.0)}"
REGISTRY="${OHM_REGISTRY:-git.lanro.eu/roman/ohm_streaming}"
cd "$(dirname "$0")/.."
@@ -30,7 +30,7 @@ git add pyproject.toml uv.lock
git commit -m "v$VERSION"
echo "▶ 2/3 Tag et push git"
git tag "$TAG"
git tag -a "$TAG" -m "${2:-$TAG}"
git push origin HEAD
git push origin "$TAG"
+15
View File
@@ -69,6 +69,21 @@ async def test_stream_range(client, admin_cookies):
)
assert r.status_code == 416
async def test_delete_download(client, admin_cookies):
path = get_settings().download_dir / "del1.mp4"
path.write_bytes(b"data" * 100)
cursor = await db.execute(
"INSERT INTO downloads (source_key, video_url, page_url, title, file_path, status, "
"total_bytes, downloaded_bytes) "
"VALUES ('k2', 'u', 'p', 'Anime Y - E9 (VOSTFR)', 'del1.mp4', 'done', 400, 400)"
)
download_id = cursor.lastrowid
r = await client.delete(f"/api/downloads/{download_id}?delete_file=true", cookies=admin_cookies)
assert r.status_code == 200 and r.json() == {"ok": True}
assert not path.exists()
r = await client.delete(f"/api/downloads/{download_id}", cookies=admin_cookies)
assert r.status_code == 404
async def test_admin_required(client):
await client.post("/auth/register", data={"username": "admin2", "password": "secret123"})
+70 -1
View File
@@ -1,16 +1,19 @@
"""Tests unitaires : format interne, sanitisation, auth, registres scrapers."""
import httpx
import pytest
from app import auth
from app.scrapers import http as http_module
from app.scrapers.base import (
ScrapeError,
decode_internal_url,
encode_internal_url,
get_source,
import_all_scrapers,
resolve_hoster,
)
from app.services.downloads import sanitize_filename
from app.services.downloads import plex_filename, sanitize_filename
# ------------------------------------------------------------ format interne
@@ -52,6 +55,32 @@ def test_sanitize_no_traversal():
assert sanitize_filename("../../etc/passwd") == "etc passwd"
# ------------------------------------------------------------ nommage Plex
@pytest.mark.parametrize(
("title", "expected"),
[
(
"The Eminence in Shadow - Saison 1 - E9 (VF)",
"The Eminence in Shadow - S01E09 (VF).mp4",
),
(
"The Eminence in Shadow - Saison 1 - E10 (VF)",
"The Eminence in Shadow - S01E10 (VF).mp4",
),
("Anime X - E1 (VOSTFR)", "Anime X - S01E01 (VOSTFR).mp4"),
("One Piece - E1122", "One Piece - S01E1122.mp4"),
("Série - saison 2 - e3 (vf)", "Série - S02E03 (vf).mp4"),
("Episode Test 1", None),
("Mon Titre", None),
("Series S01E05 VOSTFR WEB-DL", None),
],
)
def test_plex_filename(title, expected):
assert plex_filename(title, ".mp4") == expected
# ------------------------------------------------------------ auth
@@ -107,3 +136,43 @@ def test_hoster_resolution():
assert resolve_hoster("https://vidmoly.to/embed-x.html").name == "vidmoly"
assert resolve_hoster("https://sendvid.com/embed/x").name == "sendvid"
assert resolve_hoster("https://inconnu.example.com/v/1") is None
# ------------------------------------------------------------ client HTTP
class _StatusClient:
"""Fake client HTTP : sert une série de codes statut puis un corps 200."""
def __init__(self, statuses: list[int]):
self._statuses = statuses
self.calls = 0
async def get(self, url, headers=None):
self.calls += 1
request = httpx.Request("GET", url)
status = self._statuses.pop(0) if self._statuses else 200
response = httpx.Response(status, request=request, text="ok")
response.raise_for_status()
return response
async def test_fetch_fails_fast_on_definitive_4xx(monkeypatch):
client = _StatusClient([422])
monkeypatch.setattr("app.scrapers.http.get_client", lambda: client)
with pytest.raises(ScrapeError, match="422"):
await http_module.fetch("https://site.example/lecteur/prepare/1?content=2&episode=3")
assert client.calls == 1 # aucun retry sur erreur définitive
@pytest.mark.parametrize("status", [429, 500, 503])
async def test_fetch_retries_transitory_errors(monkeypatch, status):
client = _StatusClient([status, 200])
monkeypatch.setattr("app.scrapers.http.get_client", lambda: client)
async def no_sleep(_delay):
return None
monkeypatch.setattr("app.scrapers.http.asyncio.sleep", no_sleep)
assert await http_module.fetch("https://site.example/page", retries=2) == "ok"
assert client.calls == 2
+181 -23
View File
@@ -154,7 +154,7 @@ async def test_latest_merges_and_sorts_by_release_date(monkeypatch):
service = DiscoverService()
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
result = await service.latest(limit=10)
result = (await service.latest_by_type(limit=10))["anime"]
titles = [item["title"] for item in result]
assert titles.count("Frieren VOSTFR") + titles.count("Frieren - Saison 1") == 1 # dédoublonné
@@ -186,11 +186,52 @@ async def test_latest_orders_by_kitsu_start_date(monkeypatch):
service = DiscoverService()
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
result = await service.latest(limit=10)
result = (await service.latest_by_type(limit=10))["anime"]
assert [item["title"] for item in result] == ["Anime 1", "Anime 0", "Anime 2"]
assert result[0]["rating"] is None
async def test_latest_by_type_keeps_every_type(monkeypatch):
"""Chaque type garde son rail complet : les animés nombreux ne vicient pas
le rail séries (les séries n'existent pas chez Kitsu, donc sans date)."""
import app.services.discover as discover_module
animes = [
SearchResult(source="vostfree", source_id=str(n), title=f"Animé {n}", url=f"https://a/{n}")
for n in range(1, 31)
]
series = [
SearchResult(
source="french_stream", source_id=str(n), title=f"Série {n}",
url=f"https://s/{n}", media_type="serie",
)
for n in range(1, 10)
]
class AnimeSource(_FakeSource):
name, label, base_url, media_types = "vostfree", "Vostfree", "https://a", ("anime",)
class SerieSource(_FakeSource):
name, label, base_url, media_types = "french_stream", "French-Stream", "https://s", ("serie", "film")
monkeypatch.setattr(discover_module, "all_sources", lambda: [AnimeSource(animes), SerieSource(series)])
async def enabled(name: str) -> bool:
return True
monkeypatch.setattr(discover_module, "is_source_enabled", enabled)
async def fake_match(title: str):
return None # rien chez Kitsu → aucune date de sortie
service = DiscoverService()
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
rails = await service.latest_by_type(limit=24)
assert len(rails["anime"]) == 24 # tronqué à la limite
assert len(rails["serie"]) == 9 # les 9 séries intactes, malgré 30 animés
assert all(item["media_type"] == "serie" for item in rails["serie"])
async def test_latest_skips_broken_source_and_uses_cache(monkeypatch):
"""Une source en échec disparaît sans erreur, et le TTL évite les re-scrapes."""
import app.services.discover as discover_module
@@ -214,8 +255,8 @@ async def test_latest_skips_broken_source_and_uses_cache(monkeypatch):
monkeypatch.setattr(discover_module, "is_source_enabled", enabled)
service = DiscoverService()
assert await service.latest() == []
assert await service.latest() == []
assert await service.latest_by_type() == {"anime": [], "serie": [], "film": []}
assert await service.latest_by_type() == {"anime": [], "serie": [], "film": []}
assert calls["n"] == 1 # deuxième appel servi depuis le cache TTL
@@ -256,16 +297,15 @@ async def history(monkeypatch):
return ["Fantasy", "Adventure"] if anime_id == "46474" else []
async def fake_kitsu_anime(params: dict) -> list[dict]:
slugs = frozenset(params["filter[categories]"].split(","))
assert params["sort"] == "-userCount"
catalog = {
frozenset(["fantasy", "adventure", "action", "comedy"]): [
{"title": "Helck", "kitsu_id": "999"},
{"title": "Sousou no Frieren", "kitsu_id": "46474"},
{"title": "Konosuba", "kitsu_id": "1"},
],
}
return catalog.get(slugs, [])
# Une catégorie par requête (le cumul Kitsu est un ET trop restrictif)
assert "," not in params["filter[categories]"]
return [
{"title": "Helck", "kitsu_id": "999"},
{"title": "Sousou no Frieren", "kitsu_id": "46474"},
{"title": "Konosuba", "kitsu_id": "1"},
]
service = DiscoverService()
service.test_user_id = user_id # pour les assertions du test
@@ -284,11 +324,78 @@ async def test_for_you_aggregates_genres_and_excludes_owned(history):
assert "Sousou no Frieren" in titles
assert "Konosuba" in titles
async def test_for_you_excludes_owned_with_episode_markers(monkeypatch):
"""Régression : « Titre - Saison 1 - E3 » possédé doit exclure le « Titre »
canonique recommandé (la normalisation retire saison ET épisode, en boucle)."""
cursor = await db.execute(
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
("reg-user", "x" * 64),
)
user_id = cursor.lastrowid
await db.execute(
"INSERT INTO downloads (source_key, video_url, title, status) VALUES (?,?,?,?)",
("kr", "https://v/r", "The Eminence in Shadow - Saison 1 - E3", "done"),
)
await db.execute(
"INSERT INTO favorites (user_id, source, source_id, title, image_url, payload) "
"VALUES (?,?,?,?,?,?)",
(user_id, "vostfree", "r", "Favori R", None, '{"genres": ["Fantasy"]}'),
)
async def test_for_you_empty_without_history(monkeypatch):
async def fake_match(title: str):
return None # genres apportés par le favori
async def fake_kitsu_anime(params: dict) -> list[dict]:
return [
{"title": "The Eminence in Shadow", "kitsu_id": "1"},
{"title": "Autre Anime", "kitsu_id": "2"},
]
service = DiscoverService()
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
monkeypatch.setattr(service, "_kitsu_anime", fake_kitsu_anime)
result = await service.for_you(user_id=user_id, limit=10)
titles = [item["title"] for item in result["items"]]
assert "The Eminence in Shadow" not in titles # possédé (« - Saison 1 - E3 »)
assert "Autre Anime" in titles
async def test_for_you_cold_start_without_history(monkeypatch):
"""Aucun téléchargement/favori/Sonarr : amorçage signalé (non caché)."""
service = DiscoverService()
result = await service.for_you(user_id=42)
assert result == {"based_on": [], "items": []}
assert result == {"based_on": [], "items": [], "cold_start": True}
assert service._cache.get("for_you:42:20") is None # pas de cache : bon marché, change au 1er téléchargement
async def test_for_you_uses_series_page_genres_without_sonarr(monkeypatch):
"""Sans Sonarr, les séries téléchargées donnent leurs genres via leur fiche
source (libellés FR convertis en catégories Kitsu)."""
cursor = await db.execute(
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
("serie-user", "x" * 64),
)
user_id = cursor.lastrowid
await db.execute(
"INSERT INTO downloads (source_key, video_url, page_url, title, status) VALUES (?,?,?,?,?)",
("k9", "https://v/9", "https://french-stream.lat/15138132-stat-saison-5.html",
"STAT - Saison 5", "done"),
)
async def fake_no_match(title: str):
return None # série réelle : aucun match Kitsu
async def fake_genres_of_page(self, page_url: str):
assert "15138132" in page_url
return ["Drame", "Médical"] # Médical : sans équivalent Kitsu → ignoré
service = DiscoverService()
monkeypatch.setattr(service._kitsu, "search_anime", fake_no_match)
monkeypatch.setattr(
"app.scrapers.sources.french_stream.FrenchStreamScraper.genres_of_page",
fake_genres_of_page,
)
result = await service.for_you(user_id=user_id, limit=10)
assert result["based_on"] == ["Drama"] # « Drame » converti, « Médical » écarté
assert result["items"] # des recommandations issues de la catégorie drama
@@ -336,11 +443,18 @@ async def test_api_discover_requires_auth(client):
async def test_api_discover_sections(client, admin_cookies, monkeypatch):
async def fake_latest(limit: int = 24):
return [
{"source": "vostfree", "label": "Vostfree", "source_id": "a", "title": "T",
"start_date": "2026-01-01", "status": "current", "rating": 8.1},
]
async def fake_latest_by_type(limit: int = 24):
return {
"anime": [
{"source": "vostfree", "label": "Vostfree", "source_id": "a", "title": "T",
"media_type": "anime", "start_date": "2026-01-01", "status": "current", "rating": 8.1},
],
"serie": [
{"source": "french_stream", "label": "French-Stream", "source_id": "b",
"title": "S", "media_type": "serie"},
],
"film": [],
}
async def fake_must_watch(limit: int = 20):
return [{"kitsu_id": "1", "title": "Attack on Titan", "rating": 8.5}]
@@ -348,19 +462,63 @@ async def test_api_discover_sections(client, admin_cookies, monkeypatch):
async def fake_for_you(user_id: int, limit: int = 20):
return {"based_on": ["Action"], "items": [{"kitsu_id": "2", "title": "X"}]}
monkeypatch.setattr(discover, "latest", fake_latest)
monkeypatch.setattr(discover, "latest_by_type", fake_latest_by_type)
monkeypatch.setattr(discover, "must_watch", fake_must_watch)
monkeypatch.setattr(discover, "for_you", fake_for_you)
r = await client.get("/api/discover", cookies=admin_cookies)
assert r.status_code == 200
data = r.json()
assert data["latest"][0]["label"] == "Vostfree"
assert data["latest"][0]["start_date"] == "2026-01-01"
assert data["latest_anime"][0]["label"] == "Vostfree"
assert data["latest_anime"][0]["start_date"] == "2026-01-01"
assert data["latest_serie"][0]["label"] == "French-Stream"
assert data["must_watch"][0]["title"] == "Attack on Titan"
assert data["for_you"]["based_on"] == ["Action"]
async def test_api_browse(client, admin_cookies, monkeypatch):
"""Explorer : animés via Kitsu, séries via French-Stream, hors préférence → vide."""
async def fake_browse_anime(genre: str, limit: int = 20):
assert genre == "comedy"
return [{"kitsu_id": "1", "title": "Nichijou"}]
async def fake_browse_serie_film(media_type: str, genre: str, limit: int = 24):
assert (media_type, genre) == ("serie", "medical")
return [{"source": "french_stream", "source_id": "9", "title": "STAT - Saison 5",
"media_type": "serie"}]
monkeypatch.setattr(discover, "browse_anime", fake_browse_anime)
monkeypatch.setattr(discover, "browse_serie_film", fake_browse_serie_film)
r = await client.get(
"/api/discover/browse", params={"type": "anime", "genre": "comedy"}, cookies=admin_cookies
)
assert r.json()["items"][0]["title"] == "Nichijou"
r = await client.get(
"/api/discover/browse", params={"type": "serie", "genre": "medical"}, cookies=admin_cookies
)
assert r.json()["items"][0]["title"] == "STAT - Saison 5"
# Préférence animés : le parcours séries renvoie vide sans même scraper
await client.put("/auth/preferences", json={"content_preference": "anime"}, cookies=admin_cookies)
r = await client.get(
"/api/discover/browse", params={"type": "serie", "genre": "medical"}, cookies=admin_cookies
)
assert r.json()["items"] == []
async def test_api_genres_catalog(client, admin_cookies):
"""Catalogue de genres : animés + séries/films French-Stream en mode « les deux »."""
r = await client.get("/api/discover/genres", cookies=admin_cookies)
assert r.status_code == 200
catalog = r.json()
assert {"anime", "serie", "film"} <= set(catalog)
assert {"key": "thriller", "label": "Thriller"} in catalog["serie"]
assert any(g["key"] == "comedies" for g in catalog["film"])
async def test_discover_page_renders(client, admin_cookies):
r = await client.get("/discover", cookies=admin_cookies)
assert r.status_code == 200
+47
View File
@@ -6,6 +6,7 @@ import pytest
from aiohttp import web
from app.config import get_settings
from app.db import db
from app.services.downloads import download_manager as dm
PAYLOAD = b"x" * 500_000
@@ -102,3 +103,49 @@ async def test_cancel(file_server, manager):
await dm.cancel(d["id"])
data = await dm.get(d["id"])
assert data["status"] in ("cancelled", "done") # course possible si déjà fini
async def test_delete_keeps_file(file_server, manager):
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Anime X - E1 (VOSTFR)")
data = await _wait_status(d["id"], {"done", "failed"})
assert data["status"] == "done", data.get("error")
path = get_settings().download_dir / data["file_path"]
assert path.is_file()
assert await dm.delete(d["id"]) is True
assert await dm.get(d["id"]) is None
assert path.is_file() # le fichier vidéo est conservé
async def test_delete_with_file(file_server, manager):
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Anime X - E2 (VOSTFR)")
data = await _wait_status(d["id"], {"done", "failed"})
path = get_settings().download_dir / data["file_path"]
assert await dm.delete(d["id"], delete_file=True) is True
assert await dm.get(d["id"]) is None
assert not path.exists()
assert await dm.delete(d["id"]) is False # déjà supprimé → introuvable
async def test_delete_active(file_server, manager):
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Anime X - E3")
filename = d["file_path"]
assert await dm.delete(d["id"]) is True
assert await dm.get(d["id"]) is None
download_dir = get_settings().download_dir
assert not (download_dir / filename).exists()
assert not (download_dir / (filename + ".part")).exists()
async def test_series_subfolder_and_scan(file_server, manager):
"""Les épisodes sont rangés dans un sous-dossier par animé, restaurés au scan."""
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Anime Z - E7 (VOSTFR)")
data = await _wait_status(d["id"], {"done", "failed"})
assert data["status"] == "done", data.get("error")
assert data["file_path"] == "Anime Z/Anime Z - S01E07 (VOSTFR).mp4"
assert (get_settings().download_dir / data["file_path"]).read_bytes() == PAYLOAD
# DB vidée + redémarrage → le fichier en sous-dossier est restauré tel quel
await db.execute("DELETE FROM downloads")
await dm.stop()
await dm.start()
items = await dm.list_all()
paths = [i["file_path"] for i in items] # d'autres fichiers de tests peuvent trainer
assert "Anime Z/Anime Z - S01E07 (VOSTFR).mp4" in paths
+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": []}
+143
View File
@@ -0,0 +1,143 @@
"""API compatible qBittorrent : flux complet côté Sonarr (add → suivi → import → delete)."""
import asyncio
from urllib.parse import urlencode
import pytest
from aiohttp import web
from app.config import get_settings
from app.scrapers.base import Episode, VideoLink
from app.services.downloads import download_manager as dm
from app.services.settings import get_torznab_apikey
from app.services.torznab import build_stub
from app.services.torznab import torznab as torznab_svc
PAYLOAD = b"q" * 200_000
def _make_stub(base: str) -> tuple[bytes, str]:
announce = (
"http://ohm:8777/torznab/api?"
+ urlencode({"source": "fake", "sid": "42", "season": 1, "ep": 2, "series": "Frieren"})
)
return build_stub(announce, "Frieren S01E02")
def test_stub_deterministic():
stub, infohash = _make_stub("x")
stub2, infohash2 = _make_stub("x")
assert stub == stub2 and infohash == infohash2 and len(infohash) == 40
@pytest.fixture
async def file_server():
async def handle(request: web.Request) -> web.StreamResponse:
return web.Response(body=PAYLOAD)
app = web.Application()
app.router.add_get("/video.mp4", handle)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
yield f"http://127.0.0.1:{port}"
await runner.cleanup()
@pytest.fixture
async def manager():
await dm.start()
yield dm
await dm.stop()
async def test_qbit_sonarr_flow(client, file_server, manager, monkeypatch):
apikey = await get_torznab_apikey()
# -- auth : mauvais mot de passe refusé, bon mot de passe accepté
r = await client.post("/api/v2/auth/login", data={"username": "ohm", "password": "mauvais"})
assert r.status_code == 403
r = await client.post("/api/v2/auth/login", data={"username": "ohm", "password": apikey})
assert r.text == "Ok."
# -- les endpoints nécessitent la session SID
r = await client.post("/api/v2/auth/login", data={"username": "ohm", "password": apikey})
sid = r.cookies["SID"]
client.cookies.clear()
r = await client.get("/api/v2/app/version")
assert r.status_code == 403
client.cookies.set("SID", sid)
r = await client.get("/api/v2/app/version")
assert r.status_code == 200 and r.text.startswith("v")
r = await client.get("/api/v2/app/webapiVersion")
assert r.status_code == 200
# -- le scraping est simulé : l'épisode pointe vers notre serveur local
async def fake_episodes(scraper, source_id):
return [Episode(number=2, title="Épisode 2", url="http://fake/ep2", season=1)]
async def fake_resolve(scraper, episode_url):
return VideoLink(url=f"{file_server}/video.mp4", hoster="fake")
class _FakeScraper:
name = "fake"
monkeypatch.setattr("app.scrapers.base.get_source", lambda name: _FakeScraper())
monkeypatch.setattr(torznab_svc, "_episodes_of", fake_episodes)
monkeypatch.setattr(torznab_svc, "_resolve_video", fake_resolve)
# -- Sonarr pousse le .torrent de service reçu de l'indexeur
stub, infohash = _make_stub(file_server)
r = await client.post(
"/api/v2/torrents/add",
files={"torrents": ("ohm.torrent", stub, "application/x-bittorrent")},
)
assert r.text == "Ok."
# -- suivi : présent avec le bon infohash, progresse jusqu'à « terminé »
item = None
for _ in range(150):
items = (await client.get("/api/v2/torrents/info")).json()
item = next((i for i in items if i["hash"] == infohash), None)
if item and item["state"] == "pausedUP":
break
await asyncio.sleep(0.1)
assert item is not None, "téléchargement invisible dans torrents/info"
assert item["state"] == "pausedUP"
assert item["progress"] == 1
assert item["name"] == "Frieren S01E02"
assert item["content_path"].startswith("/downloads/Frieren S01/Frieren S01E02.mp4")
# -- properties : le chemin que Sonarr importera
r = await client.get("/api/v2/torrents/properties", params={"hash": infohash})
assert r.status_code == 200
props = r.json()
assert props["content_path"] == item["content_path"]
assert props["completion_date"] > 0
# -- le fichier est bien là où Sonarr l'attend
host_path = get_settings().download_dir / item["content_path"].removeprefix("/downloads/")
assert host_path.read_bytes() == PAYLOAD
# -- retrait après import : file vidée, fichier supprimé
r = await client.post(
"/api/v2/torrents/delete", data={"hashes": infohash, "deleteFiles": "true"}
)
assert r.text == "Ok."
items = (await client.get("/api/v2/torrents/info")).json()
assert items == []
assert not host_path.exists()
async def test_qbit_add_rejects_garbage(client):
apikey = await get_torznab_apikey()
await client.post("/api/v2/auth/login", data={"username": "ohm", "password": apikey})
r = await client.post(
"/api/v2/torrents/add",
files={"torrents": ("ohm.torrent", b"n'importe quoi", "application/x-bittorrent")},
)
assert r.text == "Fals."
assert (await client.get("/api/v2/torrents/info")).json() == []
+27 -8
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
async def test_torznab_search_requires_query(client, apikey):
async def test_torznab_search_without_query_serves_latest(client, apikey, fake_source):
# Sans q : flux RSS des nouveautés (RSS sync / test Sonarr), pas une erreur 400.
async def fake_latest():
return [
SearchResult(
source="fake",
source_id="frieren-1",
title="Frieren",
url="https://fake.example/frieren",
)
]
fake_source.latest = fake_latest
r = await client.get("/torznab/api", params={"t": "search", "apikey": apikey})
assert r.status_code == 400
assert r.status_code == 200
root = ET.fromstring(r.text)
items = root.findall(".//item")
assert len(items) == 1
# seul le dernier épisode de la nouveauté est publié (S02E01 > S01E02 > S01E01)
assert items[0].find("title").text == "Frieren S02E01 VOSTFR WEB-DL"
# ---------------------------------------------------------------- grab
@@ -138,10 +155,12 @@ async def test_torznab_search_requires_query(client, apikey):
class _FakeManager:
def __init__(self) -> None:
self.calls: list[tuple[str, str, str]] = []
self.calls: list[tuple[str, str | None]] = []
async def enqueue(self, video_url: str, page_url: str, title: str) -> dict:
self.calls.append((video_url, page_url, title))
async def enqueue(
self, video_url: str, page_url: str, title: str, source_key: str | None = None
) -> dict:
self.calls.append((video_url, source_key))
return {"id": 7, "title": title, "status": "pending"}
@@ -172,9 +191,9 @@ async def test_torznab_download_enqueues_and_returns_torrent(client, apikey, fak
assert r.status_code == 200
assert r.headers["content-type"].startswith("application/x-bittorrent")
assert r.content.startswith(b"d") and b"OhmStreaming" in r.content # bencode valide
assert manager.calls == [
("https://cdn.example/video.mp4", "https://fake.example/ep2", "Frieren S01E02")
]
video_url, source_key = manager.calls[0]
assert video_url == "https://cdn.example/video.mp4"
assert source_key and source_key.startswith("sonarr:") and video_url in source_key
async def test_torznab_download_rejects_bad_key(client):
+27 -50
View File
@@ -3,8 +3,8 @@
import httpx
import pytest
from app.config import get_settings
from app.services import update as update_service
from app.services.settings import get_update_config
from app.version import get_version
@@ -15,6 +15,12 @@ def reset_update_cache():
update_service.invalidate_cache()
def _patch_gitea(monkeypatch, url: str = "https://git.example", repo: str = "roman/ohm") -> None:
settings = get_settings()
monkeypatch.setattr(settings, "gitea_url", url)
monkeypatch.setattr(settings, "gitea_repo", repo)
# ---------------------------------------------------------------- semver / version
@@ -41,18 +47,9 @@ def test_get_version_env_override(monkeypatch):
# ---------------------------------------------------------------- détection Gitea
async def test_fetch_latest_without_repo():
from app.services.settings import set_update_config
await set_update_config("https://git.example", "", "tok")
assert await update_service.fetch_latest_version(force=True) is None
async def test_fetch_latest_public_repo_no_token(monkeypatch):
"""Dépôt public : pas de jeton → appel sans en-tête Authorization."""
from app.services.settings import set_update_config
await set_update_config("https://git.example", "roman/ohm", "")
async def test_fetch_latest_no_auth_header(monkeypatch):
"""Dépôt public : appel sans en-tête Authorization."""
_patch_gitea(monkeypatch)
class FakeResponse:
def raise_for_status(self) -> None:
@@ -73,17 +70,20 @@ async def test_fetch_latest_public_repo_no_token(monkeypatch):
async def test_fetch_latest_picks_highest_semver(monkeypatch):
"""Avec jeton : en-tête Authorization + plus haut semver retenu, puis cache."""
from app.services.settings import set_update_config
await set_update_config("https://git.example", "roman/ohm", "tok")
"""Plus haut semver retenu + patchnote du tag, puis cache."""
_patch_gitea(monkeypatch)
class FakeResponse:
def raise_for_status(self) -> None:
pass
def json(self) -> list[dict]:
return [{"name": "v0.1.0"}, {"name": "v0.2.3"}, {"name": "v1.0.0-rc"}, {"name": "divers"}]
return [
{"name": "v0.1.0", "message": "ancien"},
{"name": "v0.2.3", "message": "correctifs\n"},
{"name": "v1.0.0-rc"},
{"name": "divers"},
]
calls: list[tuple[str, dict]] = []
@@ -92,21 +92,18 @@ async def test_fetch_latest_picks_highest_semver(monkeypatch):
return FakeResponse()
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
latest = await update_service.fetch_latest_version(force=True)
status = await update_service.status()
assert latest == "v0.2.3"
assert calls == [
("https://git.example/api/v1/repos/roman/ohm/tags?limit=20", {"Authorization": "token tok"})
]
assert status["latest"] == "v0.2.3"
assert status["notes"] == "correctifs"
assert calls == [("https://git.example/api/v1/repos/roman/ohm/tags?limit=20", {})]
# puis servi par le cache (plus d'appel réseau)
assert await update_service.fetch_latest_version() == "v0.2.3"
assert len(calls) == 1
async def test_fetch_latest_network_error_degrades(monkeypatch):
from app.services.settings import set_update_config
await set_update_config("https://git.example", "roman/ohm", "tok")
_patch_gitea(monkeypatch)
async def fake_get(self: httpx.AsyncClient, url: str, headers: dict | None = None):
raise httpx.ConnectError("injoignable")
@@ -148,33 +145,13 @@ async def test_update_status_defaults(client, admin_cookies, monkeypatch):
r = await client.get("/api/admin/update", cookies=admin_cookies)
assert r.status_code == 200
data = r.json()
assert data["configured"] is True and data["docker"] is False # repo par défaut présent
assert data["latest"] is None and data["update_available"] is False
assert data["config"]["gitea_url"] == "https://git.lanro.eu"
assert data["config"]["repo"] == "Roman/ohm_streaming"
assert data["docker"] is False and data["notes"] is None
async def test_update_save_config_normalizes(client, admin_cookies):
r = await client.put(
"/api/admin/update",
json={"gitea_url": "https://git.example/", "repo": "/roman/ohm/", "token": " tok "},
cookies=admin_cookies,
)
assert r.status_code == 200
assert await get_update_config() == {
"gitea_url": "https://git.example",
"repo": "roman/ohm",
"token": "tok",
}
async def test_update_check_degrades_gracefully(client, admin_cookies):
# Gitea configuré mais injoignable : le check répond quand même (latest=None)
await client.put(
"/api/admin/update",
json={"gitea_url": "https://git.inexistant", "repo": "roman/ohm", "token": "tok"},
cookies=admin_cookies,
)
async def test_update_check_degrades_gracefully(client, admin_cookies, monkeypatch):
# Gitea injoignable : le check répond quand même (latest=None)
_patch_gitea(monkeypatch, url="https://git.inexistant", repo="roman/ohm")
r = await client.post("/api/admin/update/check", cookies=admin_cookies)
assert r.status_code == 200 and r.json()["latest"] is None
+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]]
name = "ohm-stream"
version = "0.1.0"
version = "0.2.0"
source = { virtual = "." }
dependencies = [
{ name = "aiosqlite" },