Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7e178eecf | ||
|
|
12bdc8a7ef | ||
|
|
eae97f9129 | ||
|
|
fed61adc56 | ||
|
|
d5fa546dd7 | ||
|
|
c45f1b79fd | ||
|
|
d747c574b8 | ||
|
|
248ec60d35 | ||
|
|
79a44297fd | ||
|
|
89d9a98f80 | ||
|
|
28f1bbde31 | ||
|
|
65761885ba | ||
|
|
7399f4b781 | ||
|
|
5fedc4f9d7 | ||
|
|
853b4e0866 | ||
|
|
4e1faf4cd9 | ||
|
|
1f326d34dd | ||
|
|
9500a84a5e | ||
|
|
affc97c527 | ||
|
|
9148b5fb6a |
@@ -0,0 +1,20 @@
|
||||
# Contexte de build minimal : ni secrets, ni données, ni caches
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.example
|
||||
.venv
|
||||
.plasma
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
data/
|
||||
downloads/
|
||||
tests/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
docker-compose.yml.example
|
||||
README.md
|
||||
Projet_descriptions.md
|
||||
scripts/
|
||||
+10
-1
@@ -1,4 +1,4 @@
|
||||
# Copier en .env et adapter. Toutes les variables sont préfixées OHM_.
|
||||
# Copier en .env et adapter. Toutes les variables applicatives sont préfixées OHM_.
|
||||
|
||||
# OBLIGATOIRE en production : clé de signature des tokens (32+ caractères)
|
||||
OHM_SECRET_KEY=change-me-in-production
|
||||
@@ -23,3 +23,12 @@ OHM_SECRET_KEY=change-me-in-production
|
||||
# OHM_REFRESH_TOKEN_TTL_DAYS=30
|
||||
|
||||
# OHM_DEBUG=false
|
||||
|
||||
# ── Déploiement Docker (docker-compose.yml) ────────────────────────────────
|
||||
# Secret partagé entre OhmStreaming et Watchtower pour déclencher les mises
|
||||
# à jour depuis la page Admin. OBLIGATOIRE en Docker.
|
||||
# Générer : openssl rand -hex 24
|
||||
WATCHTOWER_TOKEN=change-me-watchtower
|
||||
|
||||
# Port hôte exposé par docker compose (défaut 8777)
|
||||
# OHM_PORT=8777
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Étape 1 — dépendances Python via uv (cache couche par couche)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
|
||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||
WORKDIR /opt/ohm
|
||||
|
||||
# D'abord les métadonnées seules : couche réutilisable tant que uv.lock ne bouge pas
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev --no-install-project --no-cache
|
||||
|
||||
# Puis le code
|
||||
COPY app ./app
|
||||
RUN uv sync --frozen --no-dev --no-cache
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Étape 2 — image d'exécution minimale
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM python:3.13-slim-bookworm
|
||||
|
||||
# ffmpeg (téléchargements HLS), ca-certificates (scraping HTTPS),
|
||||
# gosu (bascule utilisateur non-root dans l'entrypoint)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Utilisateur non-root
|
||||
RUN useradd --create-home --uid 1000 ohm
|
||||
|
||||
WORKDIR /opt/ohm
|
||||
COPY --from=builder --chown=ohm:ohm /opt/ohm/.venv ./.venv
|
||||
COPY --chown=ohm:ohm app ./app
|
||||
COPY --chown=ohm:ohm docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
ENV PATH="/opt/ohm/.venv/bin:$PATH" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
# Chemins montés en volumes par docker-compose
|
||||
OHM_DATA_DIR=/data \
|
||||
OHM_DOWNLOAD_DIR=/downloads \
|
||||
OHM_DATABASE_PATH=/data/ohm.db
|
||||
|
||||
# Version cuite dans l'image par scripts/release.sh (build-arg VERSION)
|
||||
ARG VERSION=dev
|
||||
ENV OHM_VERSION=${VERSION}
|
||||
|
||||
RUN mkdir -p /data /downloads && chown -R ohm:ohm /opt/ohm /data /downloads
|
||||
# Root par défaut : l'entrypoint chown les volumes puis passe en « ohm »
|
||||
|
||||
EXPOSE 8777
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8777/health', timeout=4)"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8777"]
|
||||
@@ -3,12 +3,86 @@
|
||||
Application web **auto-hébergée** (homelab) : centre de contrôle unique pour découvrir,
|
||||
regarder et télécharger des animes et séries VOSTFR/VF.
|
||||
|
||||
> MVP — Phase 1 : recherche multi-sources, fiches enrichies, streaming, gestionnaire
|
||||
> de téléchargements temps réel, bibliothèque locale, favoris, administration.
|
||||
> Phase 2 : intégration Sonarr/Prowlarr — OhmStreaming est un **indexeur Torznab**
|
||||
> et personnalise « Pour toi » avec vos téléchargements Sonarr.
|
||||
## Déploiement (Docker) — recommandé
|
||||
|
||||
## Démarrage rapide
|
||||
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
|
||||
|
||||
# 2. Configuration locale
|
||||
cp .env.example .env
|
||||
# → OHM_SECRET_KEY (openssl rand -hex 32) et WATCHTOWER_TOKEN (openssl rand -hex 24) obligatoires
|
||||
|
||||
# 3. Démarrage
|
||||
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
|
||||
|
||||
|
||||
### 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
|
||||
|
||||
**Depuis l'interface** (déploiement Docker) : page **Admin → Mise à jour** —
|
||||
configurer une fois le dépôt Gitea + un jeton d'accès (droit lecture), puis
|
||||
« Vérifier » et « ⬆ Mettre à jour maintenant ». Watchtower tire la nouvelle
|
||||
image et recrée le conteneur : quelques secondes d'indisponibilité, les pages
|
||||
ouvertes se reconnectent et rechargent automatiquement.
|
||||
|
||||
**En ligne de commande** (toujours possible) :
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
### Publier une version (mainteneur)
|
||||
|
||||
```bash
|
||||
./scripts/release.sh 0.2.0
|
||||
```
|
||||
|
||||
Bump de version, commit + tag git, build de l'image et push vers
|
||||
`git.lanro.eu/roman/ohm_streaming` (tags `0.2.0` et `latest`).
|
||||
|
||||
## Démarrage rapide (développement)
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
@@ -16,7 +90,8 @@ uv sync
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8777
|
||||
```
|
||||
|
||||
Serveur persistant : `tmux new-session -d -s ohm 'cd ~/Développement/ohm_streaming && uv run uvicorn app.main:app --host 0.0.0.0 --port 8777'`
|
||||
Serveur persistant en dev : préférer Docker (voir plus haut) ; sinon
|
||||
`tmux new-session -d -s ohm 'uv run uvicorn app.main:app --host 0.0.0.0 --port 8777'`.
|
||||
|
||||
Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**.
|
||||
|
||||
@@ -30,33 +105,44 @@ Variables d'environnement (préfixe `OHM_`, voir `.env.example`) :
|
||||
| `OHM_DOWNLOAD_DIR` | `./downloads` | Dossier des fichiers téléchargés |
|
||||
| `OHM_DATABASE_PATH` | `./data/ohm.db` | Base SQLite |
|
||||
| `OHM_MAX_PARALLEL_DOWNLOADS` | `3` | Téléchargements simultanés |
|
||||
| `OHM_WATCHTOWER_URL` | *(vide)* | URL Watchtower pour la mise à jour (réglé par docker-compose) |
|
||||
| `OHM_WATCHTOWER_TOKEN` | *(vide)* | Jeton partagé Watchtower (réglé par docker-compose) |
|
||||
| `OHM_VERSION` | *(pyproject)* | Version affichée — cuite dans l'image Docker au build |
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- **Recherche unifiée** sur plusieurs sources (Vostfree, French-Manga) — chaque source
|
||||
est un module interchangeable activable/désactivable à chaud (page Admin).
|
||||
- **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
|
||||
|
||||
@@ -71,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
|
||||
@@ -97,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
|
||||
```
|
||||
@@ -108,6 +215,6 @@ app/
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
uv run pytest # 61 tests
|
||||
uv run pytest # 132 tests
|
||||
uv run ruff check . # lint
|
||||
```
|
||||
|
||||
+26
-4
@@ -25,6 +25,10 @@ class User:
|
||||
username: str
|
||||
is_admin: bool
|
||||
is_active: bool
|
||||
content_preference: str = "both" # anime | serie | both
|
||||
|
||||
|
||||
VALID_CONTENT_PREFERENCES = ("anime", "serie", "both")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- mots de passe
|
||||
@@ -56,7 +60,8 @@ async def create_user(username: str, password: str) -> User:
|
||||
|
||||
async def authenticate(username: str, password: str) -> User | None:
|
||||
row = await db.fetchone(
|
||||
"SELECT id, username, password_hash, is_admin, is_active FROM users WHERE username = ?",
|
||||
"SELECT id, username, password_hash, is_admin, is_active, content_preference "
|
||||
"FROM users WHERE username = ?",
|
||||
(username.strip(),),
|
||||
)
|
||||
if row is None or not verify_password(password, row["password_hash"]):
|
||||
@@ -66,18 +71,35 @@ async def authenticate(username: str, password: str) -> User | None:
|
||||
logger.warning("Compte désactivé : %r", username)
|
||||
return None
|
||||
return User(
|
||||
id=row["id"], username=row["username"], is_admin=bool(row["is_admin"]), is_active=True
|
||||
id=row["id"],
|
||||
username=row["username"],
|
||||
is_admin=bool(row["is_admin"]),
|
||||
is_active=True,
|
||||
content_preference=row["content_preference"],
|
||||
)
|
||||
|
||||
|
||||
async def get_user(user_id: int) -> User | None:
|
||||
row = await db.fetchone(
|
||||
"SELECT id, username, is_admin, is_active FROM users WHERE id = ?", (user_id,)
|
||||
"SELECT id, username, is_admin, is_active, content_preference FROM users WHERE id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
if row is None or not row["is_active"]:
|
||||
return None
|
||||
return User(
|
||||
id=row["id"], username=row["username"], is_admin=bool(row["is_admin"]), is_active=True
|
||||
id=row["id"],
|
||||
username=row["username"],
|
||||
is_admin=bool(row["is_admin"]),
|
||||
is_active=True,
|
||||
content_preference=row["content_preference"],
|
||||
)
|
||||
|
||||
|
||||
async def set_content_preference(user_id: int, preference: str) -> None:
|
||||
if preference not in VALID_CONTENT_PREFERENCES:
|
||||
raise ValueError(f"Préférence invalide : {preference!r}")
|
||||
await db.execute(
|
||||
"UPDATE users SET content_preference = ? WHERE id = ?", (preference, user_id)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,13 @@ class Settings(BaseSettings):
|
||||
kitsu_base_url: str = "https://kitsu.io/api/edge"
|
||||
metadata_cache_ttl_hours: int = 72
|
||||
|
||||
# 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 = ""
|
||||
|
||||
def ensure_dirs(self) -> None:
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.download_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -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()
|
||||
|
||||
+18
-3
@@ -9,10 +9,23 @@ from fastapi.staticfiles import StaticFiles
|
||||
from app.config import BASE_DIR, get_settings
|
||||
from app.db import db
|
||||
from app.logging_config import setup_logging
|
||||
from app.routers import admin, auth, discover, downloads, library, pages, proxy, search, torznab
|
||||
from app.routers import (
|
||||
admin,
|
||||
auth,
|
||||
discover,
|
||||
downloads,
|
||||
library,
|
||||
pages,
|
||||
proxy,
|
||||
qbit,
|
||||
search,
|
||||
system,
|
||||
torznab,
|
||||
)
|
||||
from app.scrapers.http import close_client
|
||||
from app.services.discover import discover as discover_service
|
||||
from app.services.downloads import download_manager
|
||||
from app.services.settings import apply_source_base_urls
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,8 +43,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
settings = get_settings()
|
||||
setup_logging(settings.debug)
|
||||
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
|
||||
@@ -45,7 +59,8 @@ 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)
|
||||
|
||||
|
||||
+84
-9
@@ -1,7 +1,8 @@
|
||||
"""Administration : utilisateurs, activation des sources, santé."""
|
||||
"""Administration : utilisateurs, activation des sources, santé, mises à jour."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
@@ -9,17 +10,28 @@ from pydantic import BaseModel
|
||||
from app import auth
|
||||
from app.db import db
|
||||
from app.routers.auth import AdminUser
|
||||
from app.scrapers.base import ScrapeError, all_sources, get_source, import_all_scrapers
|
||||
from app.scrapers.base import (
|
||||
ScrapeError,
|
||||
SourceScraper,
|
||||
all_sources,
|
||||
get_source,
|
||||
import_all_scrapers,
|
||||
)
|
||||
from app.services.discover import discover
|
||||
from app.services.settings import (
|
||||
get_sonarr_config,
|
||||
get_source_health,
|
||||
get_torznab_apikey,
|
||||
is_source_enabled,
|
||||
reset_torznab_apikey,
|
||||
set_sonarr_config,
|
||||
set_source_base_url,
|
||||
set_source_enabled,
|
||||
set_source_health,
|
||||
)
|
||||
from app.services.sonarr import sonarr
|
||||
from app.services.update import UpdateError, fetch_latest_version, trigger_update
|
||||
from app.services.update import status as update_status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -92,26 +104,62 @@ class SourceToggle(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
def _source_or_404(name: str) -> SourceScraper:
|
||||
try:
|
||||
return get_source(name)
|
||||
except ScrapeError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/sources/{name}/toggle")
|
||||
async def toggle_source(name: str, payload: SourceToggle, admin: AdminUser) -> dict:
|
||||
get_source(name) # 404 implicite si inconnue
|
||||
_source_or_404(name)
|
||||
await set_source_enabled(name, payload.enabled)
|
||||
return {"name": name, "enabled": payload.enabled}
|
||||
|
||||
class SourceUrlUpdate(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
@router.put("/sources/{name}/url")
|
||||
async def update_source_url(name: str, payload: SourceUrlUpdate, admin: AdminUser) -> dict:
|
||||
"""Change l'URL d'une source (ex. le site a changé de domaine) ; vide = défaut."""
|
||||
source = _source_or_404(name)
|
||||
default = type(source).base_url
|
||||
url = payload.url.strip().rstrip("/")
|
||||
if url and url != default:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
raise HTTPException(422, "URL invalide — format attendu : https://domaine.tld")
|
||||
await set_source_base_url(name, url)
|
||||
else:
|
||||
url = default
|
||||
await set_source_base_url(name, None)
|
||||
source.base_url = url
|
||||
logger.info("URL de la source %s : %s", name, url)
|
||||
return {
|
||||
"name": name,
|
||||
"base_url": url,
|
||||
"default_base_url": default,
|
||||
"overridden": url != default,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sources/{name}/health")
|
||||
async def health_check(name: str, admin: AdminUser) -> dict:
|
||||
"""Test de santé manuel : la source doit répondre à une recherche simple."""
|
||||
source = get_source(name)
|
||||
source = _source_or_404(name)
|
||||
try:
|
||||
results = await asyncio.wait_for(source.search("naruto"), timeout=30)
|
||||
healthy = len(results) > 0
|
||||
detail = f"{len(results)} résultats"
|
||||
healthy, detail = len(results) > 0, f"{len(results)} résultats"
|
||||
except (ScrapeError, TimeoutError) as exc:
|
||||
healthy = False
|
||||
detail = str(exc)[:200]
|
||||
healthy, detail = False, str(exc)[:200]
|
||||
logger.error("Health check %s KO : %s", name, exc)
|
||||
return {"name": name, "healthy": healthy, "detail": detail}
|
||||
except Exception as exc:
|
||||
healthy, detail = False, f"Erreur inattendue : {exc}"[:200]
|
||||
logger.exception("Health check %s : erreur inattendue", name)
|
||||
state = await set_source_health(name, healthy, detail)
|
||||
return {"name": name, **state}
|
||||
|
||||
|
||||
@router.get("/sources")
|
||||
@@ -121,7 +169,10 @@ async def sources_status(admin: AdminUser) -> list[dict]:
|
||||
"name": s.name,
|
||||
"label": s.label,
|
||||
"base_url": s.base_url,
|
||||
"default_base_url": type(s).base_url,
|
||||
"overridden": s.base_url != type(s).base_url,
|
||||
"enabled": await is_source_enabled(s.name),
|
||||
"health": await get_source_health(s.name),
|
||||
}
|
||||
for s in all_sources()
|
||||
]
|
||||
@@ -166,3 +217,27 @@ async def save_sonarr(payload: SonarrConfig, admin: AdminUser) -> dict:
|
||||
@router.post("/integrations/sonarr/test")
|
||||
async def test_sonarr(admin: AdminUser) -> dict:
|
||||
return await sonarr.test_connection()
|
||||
|
||||
# ---------------------------------------------------------------- mise à jour logicielle
|
||||
|
||||
|
||||
@router.get("/update")
|
||||
async def get_update(admin: AdminUser) -> dict:
|
||||
"""Version courante, dernière version disponible et patchnote."""
|
||||
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)."""
|
||||
await fetch_latest_version(force=True)
|
||||
return await update_status()
|
||||
|
||||
|
||||
@router.post("/update/apply")
|
||||
async def apply_update(admin: AdminUser) -> dict:
|
||||
"""Déclenche la mise à jour via Watchtower (le conteneur est recréé)."""
|
||||
try:
|
||||
return await trigger_update()
|
||||
except UpdateError as exc:
|
||||
raise HTTPException(502, str(exc)) from exc
|
||||
|
||||
+20
-2
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response, status
|
||||
from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app import auth
|
||||
@@ -107,4 +107,22 @@ async def logout(user: CurrentUser) -> RedirectResponse:
|
||||
|
||||
@router.get("/me")
|
||||
async def me(user: CurrentUser) -> dict:
|
||||
return {"id": user.id, "username": user.username, "is_admin": user.is_admin}
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"is_admin": user.is_admin,
|
||||
"content_preference": user.content_preference,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/preferences")
|
||||
async def update_preferences(
|
||||
user: CurrentUser,
|
||||
content_preference: Annotated[str, Body(embed=True)],
|
||||
) -> dict:
|
||||
"""Préférence de contenus : animés, séries, ou les deux."""
|
||||
try:
|
||||
await auth.set_content_preference(user.id, content_preference)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, detail=str(exc)) from exc
|
||||
return {"content_preference": content_preference}
|
||||
|
||||
+60
-5
@@ -1,4 +1,4 @@
|
||||
"""Découverte : nouveautés des sources, incontournables, recommandations."""
|
||||
"""Découverte : nouveautés par type, incontournables, recommandations, exploration."""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
@@ -6,7 +6,9 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.routers.auth import CurrentUser, current_user
|
||||
from app.services.discover import discover
|
||||
from app.routers.search import _allowed_media_types
|
||||
from app.scrapers.base import ScrapeError, get_source
|
||||
from app.services.discover import ANIME_GENRES, discover
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -20,8 +22,61 @@ async def get_discover(
|
||||
must_watch_limit: Annotated[int, Query(ge=1, le=20)] = 20,
|
||||
for_you_limit: Annotated[int, Query(ge=1, le=20)] = 20,
|
||||
) -> dict:
|
||||
"""Les trois sections de découverte en une requête (sections vides si source KO)."""
|
||||
"""Rails de découverte en une requête (sections vides si source KO).
|
||||
|
||||
« latest_anime » et « latest_serie » (séries + films réels) sont des rails
|
||||
indépendants, filtrés par la préférence de contenu du compte.
|
||||
"""
|
||||
allowed = _allowed_media_types(user.content_preference)
|
||||
rails = await discover.latest_by_type(latest_limit)
|
||||
latest_anime = rails["anime"] if "anime" in allowed else []
|
||||
latest_serie = (rails["serie"] + rails["film"]) if {"serie", "film"} & allowed else []
|
||||
# Incontournables et Pour toi sont issus de Kitsu (catalogue animés) :
|
||||
# en mode séries, elles n'ont pas de sens — on ne les calcule même pas.
|
||||
if "anime" in allowed:
|
||||
must_watch = await discover.must_watch(must_watch_limit)
|
||||
for_you = await discover.for_you(user.id, for_you_limit)
|
||||
latest = await discover.latest(latest_limit)
|
||||
return {"latest": latest, "must_watch": must_watch, "for_you": for_you}
|
||||
else:
|
||||
must_watch = []
|
||||
for_you = {"based_on": [], "items": []}
|
||||
return {
|
||||
"latest_anime": latest_anime,
|
||||
"latest_serie": latest_serie,
|
||||
"must_watch": must_watch,
|
||||
"for_you": for_you,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/discover/genres")
|
||||
async def get_genres(user: CurrentUser) -> dict:
|
||||
"""Catalogue des genres parcourables (page Explorer), selon la préférence."""
|
||||
allowed = _allowed_media_types(user.content_preference)
|
||||
catalog: dict[str, list[dict]] = {}
|
||||
if "anime" in allowed:
|
||||
catalog["anime"] = [{"key": key, "label": label} for key, label in ANIME_GENRES.items()]
|
||||
try:
|
||||
french_stream = get_source("french_stream")
|
||||
available = french_stream.browse_catalog()
|
||||
except ScrapeError:
|
||||
available = {}
|
||||
for kind in ("serie", "film"):
|
||||
if kind in allowed and kind in available:
|
||||
catalog[kind] = [
|
||||
{"key": key, "label": label} for key, label in available[kind].items()
|
||||
]
|
||||
return catalog
|
||||
|
||||
|
||||
@router.get("/discover/browse")
|
||||
async def get_browse(
|
||||
user: CurrentUser,
|
||||
type: Annotated[str, Query(pattern="^(anime|serie|film)$")],
|
||||
genre: Annotated[str, Query(min_length=1, max_length=40)],
|
||||
limit: Annotated[int, Query(ge=1, le=40)] = 24,
|
||||
) -> dict:
|
||||
"""Titres d'un genre : animés via Kitsu, séries/films via French-Stream."""
|
||||
if type not in _allowed_media_types(user.content_preference):
|
||||
return {"items": []} # hors préférence du compte
|
||||
if type == "anime":
|
||||
return {"items": await discover.browse_anime(genre, limit)}
|
||||
return {"items": await discover.browse_serie_film(type, genre, limit)}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -7,7 +7,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.routers.auth import current_user
|
||||
from app.routers.auth import CurrentUser, current_user
|
||||
from app.scrapers.base import (
|
||||
ScrapeError,
|
||||
SourceScraper,
|
||||
@@ -27,13 +27,35 @@ router = APIRouter(prefix="/api", tags=["search"], dependencies=[Depends(current
|
||||
import_all_scrapers()
|
||||
kitsu = KitsuService()
|
||||
|
||||
# Types de médias gardés selon la préférence. Les films réels (French-Stream)
|
||||
# accompagnent les séries — le mode animés reste sur l'animation.
|
||||
_PREFERENCE_MEDIA_TYPES = {
|
||||
"anime": {"anime"},
|
||||
"serie": {"serie", "film"},
|
||||
"both": {"anime", "serie", "film"},
|
||||
}
|
||||
|
||||
|
||||
def _allowed_media_types(preference: str) -> set[str]:
|
||||
return _PREFERENCE_MEDIA_TYPES.get(preference, _PREFERENCE_MEDIA_TYPES["both"])
|
||||
|
||||
|
||||
async def enabled_sources(allowed: set[str] | None = None) -> list[SourceScraper]:
|
||||
"""Sources activées, limitées à celles pouvant servir les types de médias autorisés."""
|
||||
sources = []
|
||||
for source in all_sources():
|
||||
if not await is_source_enabled(source.name):
|
||||
continue
|
||||
if allowed is not None and not set(source.media_types) & allowed:
|
||||
continue
|
||||
sources.append(source)
|
||||
return sources
|
||||
|
||||
|
||||
async def enabled_sources() -> list[SourceScraper]:
|
||||
sources = []
|
||||
for source in all_sources():
|
||||
if await is_source_enabled(source.name):
|
||||
sources.append(source)
|
||||
return sources
|
||||
|
||||
|
||||
@router.get("/sources")
|
||||
@@ -52,9 +74,10 @@ async def list_sources() -> list[dict]:
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def search(q: Annotated[str, Query(min_length=2)]) -> dict:
|
||||
async def search(q: Annotated[str, Query(min_length=2)], user: CurrentUser) -> dict:
|
||||
"""Recherche unifiée : une requête interroge toutes les sources activées."""
|
||||
sources = await enabled_sources()
|
||||
allowed = _allowed_media_types(user.content_preference)
|
||||
sources = await enabled_sources(allowed)
|
||||
|
||||
async def safe_search(source: SourceScraper) -> tuple[list, str | None]:
|
||||
try:
|
||||
@@ -66,6 +89,7 @@ async def search(q: Annotated[str, Query(min_length=2)]) -> dict:
|
||||
|
||||
outcomes = await asyncio.gather(*(safe_search(s) for s in sources))
|
||||
results = [item for items, _ in outcomes for item in items]
|
||||
results = [r for r in results if r.get("media_type", "anime") in allowed]
|
||||
failed = [name for _, name in outcomes if name]
|
||||
return {"query": q, "count": len(results), "results": results, "failed_sources": failed}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Endpoints système publics : version (utilisée par le frontend pour détecter
|
||||
une mise à jour et recharger la page automatiquement)."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.config import get_settings
|
||||
from app.version import get_version
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["system"])
|
||||
|
||||
|
||||
@router.get("/version")
|
||||
async def version() -> dict[str, str]:
|
||||
return {"name": get_settings().app_name, "version": get_version()}
|
||||
+25
-10
@@ -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"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ class Episode:
|
||||
title: str | None
|
||||
url: str # page de l'épisode chez la source
|
||||
season: int = 1
|
||||
|
||||
version: str | None = None # langue ("vf" / "vostfr") quand la source la distingue
|
||||
|
||||
@dataclass
|
||||
class TitleDetails:
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Sélecteurs/endpoints French-Stream (french-stream.lat, DataLife Engine) — surchargeables
|
||||
# sans toucher au code. Structure vérifiée en live (films & séries VF/VOSTFR).
|
||||
|
||||
endpoints:
|
||||
search: "/engine/ajax/search.php" # POST query=<q>&page=1
|
||||
episodes: "/ep-data.php?id={newsid}&format=js" # JSON {vf, vostfr, vo, info}
|
||||
film: "/engine/ajax/film_api.php?id={newsid}" # JSON {players: {hoster: {version: url}}}
|
||||
|
||||
search:
|
||||
item: "div.search-item" # bloc résultat (lien dans onclick="location.href='...'")
|
||||
title: ".search-title"
|
||||
poster: ".search-poster img"
|
||||
|
||||
details:
|
||||
title: "h1#s-title"
|
||||
synopsis: "div.fdesc" # le boilerplate p.desc-text est retiré
|
||||
synopsis_boilerplate: "p.desc-text"
|
||||
genres: ".facts .genres"
|
||||
year: ".facts .release"
|
||||
poster_serie: ".fposter img"
|
||||
poster_film: "#film-data" # attribut data-affiche
|
||||
serie_marker: "#serie-config" # présent = fiche série (sinon film)
|
||||
|
||||
latest:
|
||||
path: "/series/" # mix films/séries ; « Saison » dans le titre = série
|
||||
item: "div.short"
|
||||
link: "a.short-poster" # href = fiche (/index.php?newsid=N), alt = titre
|
||||
image: "img"
|
||||
|
||||
browse: # parcours par genre (page Explorer) — chemins vérifiés en live
|
||||
serie: # pages /<genre>-series-/ (9 genres exposés par le site)
|
||||
aventure: {path: "/aventure-series-/", label: "Aventure"}
|
||||
familles: {path: "/familles-series-/", label: "Famille"}
|
||||
fantastique: {path: "/fantastique-series-/", label: "Fantastique"}
|
||||
judiciaire: {path: "/judiciare-series-/", label: "Judiciaire"} # coquille du site
|
||||
medical: {path: "/medical-series-/", label: "Médical"}
|
||||
romance: {path: "/romance-series-/", label: "Romance"}
|
||||
science-fiction: {path: "/science-fiction-series-/", label: "Science-Fiction"}
|
||||
thriller: {path: "/thriller-series-/", label: "Thriller"}
|
||||
western: {path: "/western-series-/", label: "Western"}
|
||||
film: # pages /films/<genre>/
|
||||
actions: {path: "/films/actions/", label: "Action"}
|
||||
animations: {path: "/films/animations/", label: "Animation"}
|
||||
aventures: {path: "/films/aventures/", label: "Aventure"}
|
||||
biopics: {path: "/films/biopics/", label: "Biopic"}
|
||||
comedies: {path: "/films/comedies/", label: "Comédie"}
|
||||
cultes: {path: "/films/cultes/", label: "Culte"}
|
||||
documentaires: {path: "/films/documentaires/", label: "Documentaire"}
|
||||
drames: {path: "/films/drames/", label: "Drame"}
|
||||
epouvante-horreurs: {path: "/films/epouvante-horreurs/", label: "Épouvante-Horreur"}
|
||||
espionnages: {path: "/films/espionnages/", label: "Espionnage"}
|
||||
familles: {path: "/films/familles/", label: "Famille"}
|
||||
fantastiques: {path: "/films/fantastiques/", label: "Fantastique"}
|
||||
guerres: {path: "/films/guerres/", label: "Guerre"}
|
||||
historiques: {path: "/films/historiques/", label: "Historique"}
|
||||
policiers: {path: "/films/policiers/", label: "Policier"}
|
||||
romances: {path: "/films/romances/", label: "Romance"}
|
||||
science-fictions: {path: "/films/science-fictions/", label: "Science-Fiction"}
|
||||
thrillers: {path: "/films/thrillers/", label: "Thriller"}
|
||||
westerns: {path: "/films/westerns/", label: "Western"}
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Extracteur Uqload (uqload.to/.co/.com/.io) — page embed → mp4/m3u8.
|
||||
"""Extracteur Uqload (uqload.to/.co/.com/.io/.vc) — page embed → mp4/m3u8.
|
||||
|
||||
La page embed contient un jwplayer configuré dans du JS packé
|
||||
(p,a,c,k,e,d) : `sources:[{file:"https://.../master.m3u8?..."}]`.
|
||||
@@ -24,7 +24,7 @@ _PATTERNS = (
|
||||
@register_hoster
|
||||
class UqloadExtractor(HosterExtractor):
|
||||
name = "uqload"
|
||||
domains = ("uqload.to", "uqload.co", "uqload.com", "uqload.io")
|
||||
domains = ("uqload.to", "uqload.co", "uqload.com", "uqload.io", "uqload.vc")
|
||||
|
||||
async def extract(self, embed_url: str) -> VideoLink:
|
||||
html = await fetch(embed_url)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Extracteur Vidzy (vidzy.org) — page embed videojs → m3u8.
|
||||
"""Extracteur Vidzy (vidzy.org/.cc/.live) — page embed videojs → m3u8.
|
||||
|
||||
La page embed ne contient pas l'URL vidéo en clair : le script videojs appelle
|
||||
une fonction de décodage inline `atob(s)` + reverse + XOR, avec une graine
|
||||
@@ -45,7 +45,7 @@ def _decode(b64: str, hostname: str) -> str | None:
|
||||
@register_hoster
|
||||
class VidzyExtractor(HosterExtractor):
|
||||
name = "vidzy"
|
||||
domains = ("vidzy.org",)
|
||||
domains = ("vidzy.org", "vidzy.cc", "vidzy.live")
|
||||
|
||||
async def extract(self, embed_url: str) -> VideoLink:
|
||||
html = await fetch(embed_url)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Extracteur VoirAnime — endpoint « prepare » → URL lisible (directe ou proxy site).
|
||||
|
||||
Chaque source d'un épisode voiranime.xyz se résout via
|
||||
`/lecteur/prepare/<source_id>?content=..&episode=..` (AJAX : en-têtes Accept JSON et
|
||||
X-Requested-With obligatoires) qui renvoie :
|
||||
`{"success": true, "media_type": "mp4"|"hls", "stream_url": "/proxy/media?payload=<b64>"}`.
|
||||
|
||||
Le payload base64 est un JSON `{"url": ..., "referer": ..., "kind": ...}` où `url` est
|
||||
l'URL directe chez l'hébergeur d'origine. Deux cas (vérifiés en live) :
|
||||
- mp4 : l'URL d'origine est libre d'accès avec son Referer (Sibnet…) → on la retourne
|
||||
directement (téléchargement/Range natifs, sans double saut).
|
||||
- hls : les CDN utilisés (SmoothPre & co) signent leurs playlists pour le backend du
|
||||
site — accès direct 403. Le proxy `/proxy/media?payload=…` du site, lui, sert la
|
||||
playlist ET ses segments (chemins re-hostés) → on retourne l'URL proxy absolue.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
|
||||
from app.scrapers.http import fetch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_hoster
|
||||
class VoirAnimeExtractor(HosterExtractor):
|
||||
name = "voiranime"
|
||||
domains = ("voiranime.xyz",)
|
||||
|
||||
async def extract(self, embed_url: str) -> VideoLink:
|
||||
origin = self._origin_of(embed_url)
|
||||
raw = await fetch(
|
||||
embed_url,
|
||||
referer=self._referer_for(embed_url),
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ScrapeError(f"voiranime : réponse prepare invalide : {raw[:120]}") from exc
|
||||
if not data.get("success"):
|
||||
raise ScrapeError(f"voiranime : {data.get('error') or 'échec de préparation du lecteur'}")
|
||||
|
||||
media_type = str(data.get("media_type") or "").lower()
|
||||
stream_url = str(data.get("stream_url") or "")
|
||||
|
||||
if media_type == "hls":
|
||||
if not stream_url:
|
||||
raise ScrapeError(f"voiranime : flux HLS sans stream_url dans {embed_url}")
|
||||
return VideoLink(
|
||||
url=f"{origin}/{stream_url.lstrip('/')}",
|
||||
hoster=self.name,
|
||||
headers={"Referer": f"{origin}/"},
|
||||
is_hls=True,
|
||||
)
|
||||
|
||||
payload = self._extract_payload(stream_url)
|
||||
url = str(payload.get("url") or "")
|
||||
if not url:
|
||||
raise ScrapeError(f"voiranime : payload sans URL de flux dans {embed_url}")
|
||||
referer = str(payload.get("referer") or "")
|
||||
return VideoLink(
|
||||
url=url,
|
||||
hoster=self.name,
|
||||
headers={"Referer": referer} if referer else {},
|
||||
is_hls=url.split("?")[0].endswith(".m3u8"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _origin_of(embed_url: str) -> str:
|
||||
parsed = urlparse(embed_url)
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
@staticmethod
|
||||
def _referer_for(embed_url: str) -> str:
|
||||
"""Reconstruit la page lecteur d'où provient la requête prepare."""
|
||||
origin = VoirAnimeExtractor._origin_of(embed_url)
|
||||
query = parse_qs(urlparse(embed_url).query)
|
||||
content = query.get("content", [""])[0]
|
||||
episode = query.get("episode", [""])[0]
|
||||
if content and episode:
|
||||
return f"{origin}/lecteur/{content}/{episode}"
|
||||
return f"{origin}/"
|
||||
|
||||
@staticmethod
|
||||
def _extract_payload(stream_url: str) -> dict:
|
||||
payload_b64 = parse_qs(urlparse(stream_url).query).get("payload", [""])[0]
|
||||
if not payload_b64:
|
||||
raise ScrapeError(f"voiranime : pas de payload dans stream_url ({stream_url[:80]})")
|
||||
padded = payload_b64 + "=" * (-len(payload_b64) % 4)
|
||||
try:
|
||||
decoded = base64.b64decode(padded)
|
||||
except (binascii.Error, ValueError):
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(padded)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise ScrapeError(f"voiranime : payload illisible : {payload_b64[:60]}") from exc
|
||||
try:
|
||||
data = json.loads(decoded)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
raise ScrapeError(f"voiranime : payload non-JSON : {payload_b64[:60]}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise ScrapeError("voiranime : payload inattendu (pas un objet JSON)")
|
||||
return data
|
||||
+18
-3
@@ -41,15 +41,30 @@ async def fetch(
|
||||
*,
|
||||
referer: str | None = None,
|
||||
retries: int = 2,
|
||||
headers: dict[str, str] | None = None,
|
||||
data: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""GET d'une page avec retries ; lève ScrapeError en cas d'échec définitif."""
|
||||
headers = {"Referer": referer} if referer else {}
|
||||
"""GET (ou POST si `data` est fourni) avec retries ; lève ScrapeError en cas d'échec définitif."""
|
||||
request_headers = dict(headers) if headers else {}
|
||||
if referer:
|
||||
request_headers.setdefault("Referer", referer)
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
response = await get_client().get(url, headers=headers)
|
||||
if data is None:
|
||||
response = await get_client().get(url, headers=request_headers)
|
||||
else:
|
||||
response = await get_client().post(url, headers=request_headers, data=data)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code
|
||||
if 400 <= status < 500 and status not in (408, 429):
|
||||
raise ScrapeError(f"Échec de récupération de {url} : HTTP {status} (définitif)") from exc
|
||||
last_error = exc
|
||||
logger.warning("fetch %s — HTTP %d, tentative %d/%d", url, status, attempt + 1, retries + 1)
|
||||
if attempt < retries:
|
||||
await asyncio.sleep(1.0 * (attempt + 1))
|
||||
except (httpx.HTTPError, httpx.InvalidURL) as exc:
|
||||
last_error = exc
|
||||
logger.warning("fetch %s — tentative %d/%d : %s", url, attempt + 1, retries + 1, exc)
|
||||
|
||||
@@ -259,27 +259,26 @@ class FrenchMangaScraper(SourceScraper):
|
||||
if season_match:
|
||||
season = int(season_match.group(1))
|
||||
|
||||
numbers: set[float] = set()
|
||||
info = data.get("info") or {}
|
||||
episodes: list[Episode] = []
|
||||
for version in versions:
|
||||
numbers: set[float] = set()
|
||||
for number_text in data.get(version) or {}:
|
||||
try:
|
||||
numbers.add(float(number_text))
|
||||
except ValueError:
|
||||
logger.warning("french_manga : numéro d'épisode invalide %r", number_text)
|
||||
|
||||
info = data.get("info") or {}
|
||||
episodes: list[Episode] = []
|
||||
for number in sorted(numbers):
|
||||
number_text = str(int(number)) if number.is_integer() else str(number)
|
||||
episode_info = info.get(number_text) or info.get(str(int(number)))
|
||||
label = episode_info.get("title") if isinstance(episode_info, dict) else None
|
||||
version = next((v for v in versions if number_text in (data.get(v) or {})), versions[0])
|
||||
episodes.append(
|
||||
Episode(
|
||||
number=number,
|
||||
title=label,
|
||||
url=f"{page_url}#{config['episodes']['fragment_prefix']}={version}-{number_text}",
|
||||
season=season,
|
||||
version=version,
|
||||
)
|
||||
)
|
||||
if not episodes:
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
"""Source French-Stream (french-stream.lat) — films & séries VF/VOSTFR, moteur DataLife Engine.
|
||||
|
||||
Faits structurels (vérifiés en live) :
|
||||
- Recherche : POST /engine/ajax/search.php (query, page) → blocs `div.search-item`
|
||||
(lien dans onclick="location.href='...'", poster `.search-poster img`).
|
||||
- Fiche : `/index.php?newsid=<id>` (ou la jolie URL `/<id>-<slug>.html`).
|
||||
`source_id` = newsid numérique.
|
||||
- Séries : une fiche par saison (« Titre - Saison N »). Épisodes via
|
||||
GET /ep-data.php?id=<newsid>&format=js → JSON
|
||||
`{"vf": {"1": {"vidzy": url, "uqload": url, ...}}, "vostfr": {...}, "info": {...}}`.
|
||||
- Films : lecteurs via GET /engine/ajax/film_api.php?id=<newsid> → JSON
|
||||
`{"players": {"vidzy": {"default": url, "vff": url, "vostfr": url, ...}}}`.
|
||||
- URL d'épisode interne : `<fiche>#vf-3` (série) ou `<fiche>#film` (film).
|
||||
- Nouveautés : /series/ → blocs `div.short` (mix films/séries, « Saison » dans le
|
||||
titre = série).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from copy import deepcopy
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
from app.scrapers.base import (
|
||||
Episode,
|
||||
ScrapeError,
|
||||
SearchResult,
|
||||
SourceScraper,
|
||||
TitleDetails,
|
||||
register_source,
|
||||
)
|
||||
from app.scrapers.config_loader import load_scraper_config
|
||||
from app.scrapers.http import fetch, fetch_soup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"endpoints": {
|
||||
"search": "/engine/ajax/search.php",
|
||||
"episodes": "/ep-data.php?id={newsid}&format=js",
|
||||
"film": "/engine/ajax/film_api.php?id={newsid}",
|
||||
},
|
||||
"search": {
|
||||
"item": "div.search-item",
|
||||
"title": ".search-title",
|
||||
"poster": ".search-poster img",
|
||||
},
|
||||
"details": {
|
||||
"title": "h1#s-title",
|
||||
"synopsis": "div.fdesc",
|
||||
"synopsis_boilerplate": "p.desc-text",
|
||||
"genres": ".facts .genres",
|
||||
"year": ".facts .release",
|
||||
"poster_serie": ".fposter img",
|
||||
"poster_film": "#film-data",
|
||||
"serie_marker": "#serie-config",
|
||||
},
|
||||
"latest": {
|
||||
"path": "/series/",
|
||||
"item": "div.short",
|
||||
"link": "a.short-poster",
|
||||
"image": "img",
|
||||
},
|
||||
# Parcours par genre (page Explorer) : type → clé → {path, label}.
|
||||
# Chemins vérifiés en live : films = /films/<genre>/, séries = /<genre>-series-/.
|
||||
"browse": {
|
||||
"serie": {
|
||||
"aventure": {"path": "/aventure-series-/", "label": "Aventure"},
|
||||
"familles": {"path": "/familles-series-/", "label": "Famille"},
|
||||
"fantastique": {"path": "/fantastique-series-/", "label": "Fantastique"},
|
||||
"judiciaire": {"path": "/judiciare-series-/", "label": "Judiciaire"}, # coquille du site
|
||||
"medical": {"path": "/medical-series-/", "label": "Médical"},
|
||||
"romance": {"path": "/romance-series-/", "label": "Romance"},
|
||||
"science-fiction": {"path": "/science-fiction-series-/", "label": "Science-Fiction"},
|
||||
"thriller": {"path": "/thriller-series-/", "label": "Thriller"},
|
||||
"western": {"path": "/western-series-/", "label": "Western"},
|
||||
},
|
||||
"film": {
|
||||
"actions": {"path": "/films/actions/", "label": "Action"},
|
||||
"animations": {"path": "/films/animations/", "label": "Animation"},
|
||||
"aventures": {"path": "/films/aventures/", "label": "Aventure"},
|
||||
"biopics": {"path": "/films/biopics/", "label": "Biopic"},
|
||||
"comedies": {"path": "/films/comedies/", "label": "Comédie"},
|
||||
"cultes": {"path": "/films/cultes/", "label": "Culte"},
|
||||
"documentaires": {"path": "/films/documentaires/", "label": "Documentaire"},
|
||||
"drames": {"path": "/films/drames/", "label": "Drame"},
|
||||
"epouvante-horreurs": {"path": "/films/epouvante-horreurs/", "label": "Épouvante-Horreur"},
|
||||
"espionnages": {"path": "/films/espionnages/", "label": "Espionnage"},
|
||||
"familles": {"path": "/films/familles/", "label": "Famille"},
|
||||
"fantastiques": {"path": "/films/fantastiques/", "label": "Fantastique"},
|
||||
"guerres": {"path": "/films/guerres/", "label": "Guerre"},
|
||||
"historiques": {"path": "/films/historiques/", "label": "Historique"},
|
||||
"policiers": {"path": "/films/policiers/", "label": "Policier"},
|
||||
"romances": {"path": "/films/romances/", "label": "Romance"},
|
||||
"science-fictions": {"path": "/films/science-fictions/", "label": "Science-Fiction"},
|
||||
"thrillers": {"path": "/films/thrillers/", "label": "Thriller"},
|
||||
"westerns": {"path": "/films/westerns/", "label": "Western"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_NEWSID_RE = re.compile(r"(?:newsid=|/)(\d+)(?:-[^/]*)?\.?html?$|(?:newsid=)(\d+)")
|
||||
_ONCLICK_URL_RE = re.compile(r"location\.href='([^']+)'")
|
||||
_SEASON_RE = re.compile(r"saison\s*:?\s*(\d+)", re.IGNORECASE)
|
||||
_YEAR_RE = re.compile(r"(\d{4})")
|
||||
_FRAGMENT_RE = re.compile(r"^(vf|vostfr|vo)-(\d+(?:\.\d+)?)$")
|
||||
_VERSION_ORDER = {"vf": 0, "vostfr": 1, "vo": 2}
|
||||
|
||||
|
||||
def _merged_config() -> dict:
|
||||
merged = deepcopy(DEFAULT_CONFIG)
|
||||
for key, values in load_scraper_config("french_stream").items():
|
||||
if isinstance(values, dict) and isinstance(merged.get(key), dict):
|
||||
merged[key].update(values)
|
||||
else:
|
||||
merged[key] = values
|
||||
return merged
|
||||
|
||||
|
||||
def _newsid_from_url(url: str) -> str | None:
|
||||
match = _NEWSID_RE.search(url)
|
||||
if not match:
|
||||
return None
|
||||
return match.group(1) or match.group(2)
|
||||
|
||||
|
||||
def _media_type(title: str, url: str) -> str:
|
||||
if _SEASON_RE.search(title) or "-saison-" in url:
|
||||
return "serie"
|
||||
return "film"
|
||||
|
||||
|
||||
@register_source
|
||||
class FrenchStreamScraper(SourceScraper):
|
||||
name = "french_stream"
|
||||
label = "French-Stream"
|
||||
base_url = "https://french-stream.lat"
|
||||
media_types = ("serie", "film")
|
||||
|
||||
# ------------------------------------------------------------- helpers
|
||||
|
||||
def _title_url(self, source_id: str) -> str:
|
||||
return f"{self.base_url}/index.php?newsid={source_id}"
|
||||
|
||||
@staticmethod
|
||||
def _text(element: Tag | None) -> str:
|
||||
return element.get_text(" ", strip=True) if element else ""
|
||||
|
||||
def _is_serie(self, soup: BeautifulSoup, title: str, url: str) -> bool:
|
||||
config = _merged_config()
|
||||
if soup.select_one(config["details"]["serie_marker"]):
|
||||
return True
|
||||
return _media_type(title, url) == "serie"
|
||||
|
||||
# ------------------------------------------------------------- search
|
||||
|
||||
async def search(self, query: str) -> list[SearchResult]:
|
||||
config = _merged_config()
|
||||
url = f"{self.base_url}{config['endpoints']['search']}"
|
||||
html = await fetch(
|
||||
url, referer=f"{self.base_url}/", data={"query": query, "page": "1"}
|
||||
)
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
results: list[SearchResult] = []
|
||||
for block in soup.select(config["search"]["item"]):
|
||||
onclick = block.get("onclick", "")
|
||||
url_match = _ONCLICK_URL_RE.search(onclick)
|
||||
if not url_match:
|
||||
logger.warning("french_stream : résultat sans lien, ignoré")
|
||||
continue
|
||||
href = url_match.group(1)
|
||||
source_id = _newsid_from_url(href)
|
||||
if not source_id:
|
||||
logger.warning("french_stream : newsid introuvable dans %s", href)
|
||||
continue
|
||||
title = self._text(block.select_one(config["search"]["title"]))
|
||||
image = block.select_one(config["search"]["poster"])
|
||||
results.append(
|
||||
SearchResult(
|
||||
source=self.name,
|
||||
source_id=source_id,
|
||||
title=title,
|
||||
url=f"{self.base_url}{href}" if href.startswith("/") else href,
|
||||
image_url=image.get("src") if image else None,
|
||||
media_type=_media_type(title, href),
|
||||
)
|
||||
)
|
||||
logger.info("french_stream : %d résultats pour %r", len(results), query)
|
||||
return results
|
||||
|
||||
# ------------------------------------------------------------- latest
|
||||
|
||||
async def latest(self) -> list[SearchResult]:
|
||||
"""Ajouts récents — page /series/ (mix films & séries)."""
|
||||
config = _merged_config()
|
||||
soup = await fetch_soup(f"{self.base_url}{config['latest']['path']}")
|
||||
results = self._short_blocks(soup)
|
||||
logger.info("french_stream : %d nouveautés récupérées", len(results))
|
||||
return results
|
||||
|
||||
async def browse(self, media_type: str, category: str) -> list[SearchResult]:
|
||||
"""Parcours par genre — pages /films/<genre>/ et /<genre>-series-/.
|
||||
|
||||
``media_type`` (« serie » | « film ») et ``category`` (clé du catalogue
|
||||
YAML, ex. « thriller »). Le type est forcé sur les résultats : une page
|
||||
genre séries ne liste que des séries, une page genre films que des films.
|
||||
"""
|
||||
catalog = _merged_config().get("browse", {}).get(media_type, {})
|
||||
entry = catalog.get(category)
|
||||
if entry is None:
|
||||
raise ScrapeError(f"Catégorie inconnue : {media_type}/{category}")
|
||||
soup = await fetch_soup(f"{self.base_url}{entry['path']}")
|
||||
results = self._short_blocks(soup, force_type=media_type)
|
||||
logger.info("french_stream : %d titres dans %s/%s", len(results), media_type, category)
|
||||
return results
|
||||
|
||||
def _short_blocks(self, soup: BeautifulSoup, force_type: str | None = None) -> list[SearchResult]:
|
||||
"""Bloc `div.short` → SearchResult (structure partagée nouveautés/genres)."""
|
||||
config = _merged_config()
|
||||
latest_cfg = config["latest"]
|
||||
results: list[SearchResult] = []
|
||||
for block in soup.select(latest_cfg["item"]):
|
||||
link = block.select_one(latest_cfg["link"])
|
||||
href = link.get("href") if link else None
|
||||
if not href:
|
||||
continue
|
||||
source_id = _newsid_from_url(href)
|
||||
if not source_id:
|
||||
logger.warning("french_stream : newsid introuvable dans %s", href)
|
||||
continue
|
||||
image = link.select_one(latest_cfg["image"])
|
||||
title = link.get("alt") or self._text(link)
|
||||
results.append(
|
||||
SearchResult(
|
||||
source=self.name,
|
||||
source_id=source_id,
|
||||
title=title,
|
||||
url=f"{self.base_url}{href}" if href.startswith("/") else href,
|
||||
image_url=image.get("src") if image else None,
|
||||
media_type=force_type or _media_type(title, href),
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
def browse_catalog(self) -> dict[str, dict[str, str]]:
|
||||
"""Catalogue des genres parcourables : ``{type: {clé: libellé}}``."""
|
||||
catalog = _merged_config().get("browse", {})
|
||||
return {
|
||||
media_type: {key: entry.get("label", key) for key, entry in genres.items()}
|
||||
for media_type, genres in catalog.items()
|
||||
}
|
||||
|
||||
|
||||
async def genres_of_page(self, page_url: str) -> list[str]:
|
||||
"""Genres (libellés FR) de la fiche pointée par l'URL d'un téléchargement.
|
||||
|
||||
Utilisé par les recommandations « Pour toi » pour les séries/films,
|
||||
absents de Kitsu (et donc sans signal de genre sans Sonarr).
|
||||
"""
|
||||
if self.base_url.split("//")[-1].split("/")[0] not in page_url:
|
||||
return [] # fiche d'une autre source (animé) : rien à lire ici
|
||||
source_id = _newsid_from_url(page_url)
|
||||
try:
|
||||
details = await self.get_details(source_id)
|
||||
except ScrapeError:
|
||||
return []
|
||||
return details.genres or []
|
||||
# ------------------------------------------------------------- details
|
||||
|
||||
async def get_details(self, source_id: str) -> TitleDetails:
|
||||
config = _merged_config()
|
||||
details_cfg = config["details"]
|
||||
url = self._title_url(source_id)
|
||||
soup = await fetch_soup(url)
|
||||
|
||||
title = " ".join(self._text(soup.select_one(details_cfg["title"])).split())
|
||||
if not title:
|
||||
raise ScrapeError(f"french_stream : fiche introuvable pour {source_id} ({url})")
|
||||
|
||||
synopsis_el = soup.select_one(details_cfg["synopsis"])
|
||||
if synopsis_el is not None:
|
||||
for boilerplate in synopsis_el.select(details_cfg["synopsis_boilerplate"]):
|
||||
boilerplate.decompose()
|
||||
synopsis = self._text(synopsis_el) or None
|
||||
|
||||
genres_el = soup.select_one(details_cfg["genres"])
|
||||
if genres_el and genres_el.select("a"):
|
||||
genres = [a.get_text(strip=True) for a in genres_el.select("a") if a.get_text(strip=True)]
|
||||
else:
|
||||
genres = [g.strip() for g in self._text(genres_el).split(",") if g.strip()]
|
||||
|
||||
year_match = _YEAR_RE.search(self._text(soup.select_one(details_cfg["year"])))
|
||||
if year_match is None:
|
||||
year_match = _YEAR_RE.search(title)
|
||||
|
||||
poster_el = soup.select_one(details_cfg["poster_serie"])
|
||||
poster = poster_el.get("src") if poster_el else None
|
||||
if not poster:
|
||||
film_data = soup.select_one(details_cfg["poster_film"])
|
||||
poster = film_data.get("data-affiche") if film_data else None
|
||||
|
||||
is_serie = self._is_serie(soup, title, url)
|
||||
episodes = await self._fetch_episodes(source_id, url, title) if is_serie else []
|
||||
|
||||
return TitleDetails(
|
||||
source=self.name,
|
||||
source_id=source_id,
|
||||
title=title,
|
||||
url=url,
|
||||
synopsis=synopsis,
|
||||
image_url=poster,
|
||||
genres=genres,
|
||||
year=int(year_match.group(1)) if year_match else None,
|
||||
episode_count=len(episodes) if is_serie else 1,
|
||||
episodes=episodes if is_serie else [self._film_episode(url)],
|
||||
media_type="serie" if is_serie else "film",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------ episodes
|
||||
|
||||
async def list_episodes(self, source_id: str) -> list[Episode]:
|
||||
url = self._title_url(source_id)
|
||||
soup = await fetch_soup(url)
|
||||
title = " ".join(
|
||||
self._text(soup.select_one(_merged_config()["details"]["title"])).split()
|
||||
)
|
||||
if not self._is_serie(soup, title, url):
|
||||
return [self._film_episode(url)]
|
||||
episodes = await self._fetch_episodes(source_id, url, title)
|
||||
if not episodes:
|
||||
raise ScrapeError(f"french_stream : aucun épisode trouvé pour {source_id} ({url})")
|
||||
return episodes
|
||||
|
||||
@staticmethod
|
||||
def _film_episode(page_url: str) -> Episode:
|
||||
return Episode(number=1, title="Film", url=f"{page_url}#film", season=1)
|
||||
|
||||
async def _fetch_episodes(
|
||||
self, source_id: str, page_url: str, title: str
|
||||
) -> list[Episode]:
|
||||
"""Épisodes d'une saison via l'API JSON du site (versions vf/vostfr/vo)."""
|
||||
config = _merged_config()
|
||||
endpoint = config["endpoints"]["episodes"].format(newsid=source_id)
|
||||
raw = await fetch(f"{self.base_url}{endpoint}", referer=page_url)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ScrapeError(
|
||||
f"french_stream : JSON d'épisodes invalide pour {source_id}"
|
||||
) from exc
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
|
||||
season_match = _SEASON_RE.search(title)
|
||||
season = int(season_match.group(1)) if season_match else 1
|
||||
info = data.get("info") if isinstance(data.get("info"), dict) else {}
|
||||
|
||||
episodes: list[Episode] = []
|
||||
for version, eps in data.items():
|
||||
if version == "info" or not isinstance(eps, dict):
|
||||
continue
|
||||
for number_key in eps:
|
||||
number_match = re.match(r"^(\d+(?:\.\d+)?)$", number_key)
|
||||
if not number_match:
|
||||
continue
|
||||
number = float(number_key)
|
||||
ep_info = info.get(number_key) or info.get(str(int(number))) or {}
|
||||
episodes.append(
|
||||
Episode(
|
||||
number=number,
|
||||
title=ep_info.get("title") if isinstance(ep_info, dict) else None,
|
||||
url=f"{page_url}#{version}-{number_key}",
|
||||
season=season,
|
||||
version=version,
|
||||
)
|
||||
)
|
||||
episodes.sort(
|
||||
key=lambda e: (e.number, _VERSION_ORDER.get(e.version or "", 99))
|
||||
)
|
||||
return episodes
|
||||
|
||||
# -------------------------------------------------------------- embeds
|
||||
|
||||
async def extract_embed_links(self, episode_url: str) -> list[str]:
|
||||
config = _merged_config()
|
||||
page_url, _, fragment = episode_url.partition("#")
|
||||
source_id = _newsid_from_url(page_url)
|
||||
if not source_id:
|
||||
raise ScrapeError(f"french_stream : newsid introuvable dans {episode_url}")
|
||||
|
||||
if fragment == "film":
|
||||
endpoint = config["endpoints"]["film"].format(newsid=source_id)
|
||||
raw = await fetch(f"{self.base_url}{endpoint}", referer=page_url)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ScrapeError(
|
||||
f"french_stream : JSON film invalide pour {source_id}"
|
||||
) from exc
|
||||
links: list[str] = []
|
||||
for hoster_urls in (data.get("players") or {}).values():
|
||||
if not isinstance(hoster_urls, dict):
|
||||
continue
|
||||
for embed in hoster_urls.values():
|
||||
if isinstance(embed, str) and embed.startswith("http") and embed not in links:
|
||||
links.append(embed)
|
||||
else:
|
||||
fragment_match = _FRAGMENT_RE.match(fragment)
|
||||
if not fragment_match:
|
||||
raise ScrapeError(
|
||||
f"french_stream : fragment d'épisode invalide dans {episode_url}"
|
||||
)
|
||||
version, number = fragment_match.group(1), fragment_match.group(2)
|
||||
endpoint = config["endpoints"]["episodes"].format(newsid=source_id)
|
||||
raw = await fetch(f"{self.base_url}{endpoint}", referer=page_url)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ScrapeError(
|
||||
f"french_stream : JSON d'épisodes invalide pour {source_id}"
|
||||
) from exc
|
||||
hosters = (data.get(version) or {}).get(number) or {}
|
||||
links = [u for u in hosters.values() if isinstance(u, str) and u.startswith("http")]
|
||||
|
||||
if not links:
|
||||
raise ScrapeError(f"french_stream : aucun lien embed extrait de {episode_url}")
|
||||
logger.info("french_stream : %d liens embed pour %s", len(links), episode_url)
|
||||
return links
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Source VoirAnime (voiranime.xyz) — plateforme VODSPHERE, animes VOSTFR.
|
||||
|
||||
Faits structurels (vérifiés en live) :
|
||||
- Recherche : GET /catalogue?q=<q> → `article.catalogue-card` (lien `a.catalogue-poster`,
|
||||
poster et titre dans `img[src]` / `img[alt]`).
|
||||
- Fiche : /catalogue/<slug> — titre `h1`, poster `img[alt^="Affiche de"]`, compteur
|
||||
d'épisodes dans `.media-detail-meta span` (« 1192 épisodes »), synopsis `.media-detail-story p`.
|
||||
- Épisodes : `a.detail-episode-card` avec `data-season` et libellé `<small>S1 · E1</small>`.
|
||||
- Lecteur : /lecteur/<content>/<episode> — attributs data-prepare-url (résolution AJAX)
|
||||
et data-fallback-source-url (source suivante) ; en fin de chaîne le fallback repointe
|
||||
vers une source déjà vue → dédoublonnage par id de source obligatoire.
|
||||
- Accueil : carrousels #carousel-new (nouveautés) et #carousel-added (ajouts), 24 cartes.
|
||||
- La résolution d'un lien direct se fait dans l'extracteur hoster dédié (voir
|
||||
app/scrapers/hosters/voiranime.py) : cette source ne renvoie que les URLs prepare.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from urllib.parse import quote_plus, urljoin
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
from app.scrapers.base import (
|
||||
Episode,
|
||||
ScrapeError,
|
||||
SearchResult,
|
||||
SourceScraper,
|
||||
TitleDetails,
|
||||
register_source,
|
||||
)
|
||||
from app.scrapers.config_loader import load_scraper_config
|
||||
from app.scrapers.http import fetch_soup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"search": {
|
||||
"endpoint": "/catalogue?q={query}",
|
||||
"result": "article.catalogue-card",
|
||||
"link": "a.catalogue-poster",
|
||||
},
|
||||
"details": {
|
||||
"title": "h1",
|
||||
"poster": 'img[alt^="Affiche de"]',
|
||||
"meta_count": ".media-detail-meta span",
|
||||
"synopsis": ".media-detail-story p",
|
||||
},
|
||||
"episodes": {
|
||||
"card": "a.detail-episode-card",
|
||||
},
|
||||
"player": {
|
||||
"root": "[data-prepare-url]",
|
||||
"prepare_attr": "data-prepare-url",
|
||||
"fallback_attr": "data-fallback-source-url",
|
||||
"max_sources": 4,
|
||||
},
|
||||
"latest": {
|
||||
"path": "/",
|
||||
"carousels": ("#carousel-new", "#carousel-added"),
|
||||
"item": "article.content-card",
|
||||
"link": "a.card-poster",
|
||||
},
|
||||
}
|
||||
|
||||
_SLUG_RE = re.compile(r"/catalogue/([a-z0-9-]+)", re.IGNORECASE)
|
||||
_EPISODE_LABEL_RE = re.compile(r"S\s*(\d+)\s*·\s*E\s*(\d+(?:[.,]\d+)?)", re.IGNORECASE)
|
||||
_EPISODE_COUNT_RE = re.compile(r"(\d+)\s*épisodes", re.IGNORECASE)
|
||||
_SOURCE_ID_RE = re.compile(r"/lecteur/prepare/(\d+)")
|
||||
|
||||
_FALLBACK_SOURCE_RE = re.compile(r"[?&]source=(\d+)")
|
||||
def _merged_config() -> dict:
|
||||
merged = deepcopy(DEFAULT_CONFIG)
|
||||
for key, values in load_scraper_config("voiranime").items():
|
||||
if isinstance(values, dict) and isinstance(merged.get(key), dict):
|
||||
merged[key].update(values)
|
||||
else:
|
||||
merged[key] = values
|
||||
return merged
|
||||
|
||||
|
||||
@register_source
|
||||
class VoirAnimeScraper(SourceScraper):
|
||||
name = "voiranime"
|
||||
label = "VoirAnime"
|
||||
base_url = "https://voiranime.xyz"
|
||||
media_types = ("anime",)
|
||||
|
||||
# ------------------------------------------------------------- helpers
|
||||
|
||||
def _title_url(self, source_id: str) -> str:
|
||||
return f"{self.base_url}/catalogue/{source_id}"
|
||||
|
||||
@staticmethod
|
||||
def _slug_from_url(url: str) -> str | None:
|
||||
match = _SLUG_RE.search(url)
|
||||
return match.group(1) if match else None
|
||||
|
||||
@staticmethod
|
||||
def _text(element: Tag | None) -> str:
|
||||
return element.get_text(" ", strip=True) if element else ""
|
||||
|
||||
def _result_from_card(self, link: Tag) -> SearchResult | None:
|
||||
href = link.get("href")
|
||||
if not href:
|
||||
return None
|
||||
source_id = self._slug_from_url(str(href))
|
||||
if not source_id:
|
||||
return None
|
||||
img = link.select_one("img")
|
||||
title = (img.get("alt") if img else None) or source_id.replace("-", " ")
|
||||
image = img.get("src") if img else None
|
||||
return SearchResult(
|
||||
source=self.name,
|
||||
source_id=source_id,
|
||||
title=str(title).strip(),
|
||||
url=self._title_url(source_id),
|
||||
image_url=str(image) if image else None,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------- search
|
||||
|
||||
async def search(self, query: str) -> list[SearchResult]:
|
||||
config = _merged_config()
|
||||
search_cfg = config["search"]
|
||||
url = self.base_url + search_cfg["endpoint"].format(query=quote_plus(query))
|
||||
soup = await fetch_soup(url)
|
||||
results: list[SearchResult] = []
|
||||
seen: set[str] = set()
|
||||
for card in soup.select(search_cfg["result"]):
|
||||
link = card.select_one(search_cfg["link"])
|
||||
if link is None:
|
||||
logger.warning("voiranime : carte de résultat sans lien, ignorée")
|
||||
continue
|
||||
result = self._result_from_card(link)
|
||||
if result and result.source_id not in seen:
|
||||
seen.add(result.source_id)
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
# ------------------------------------------------------------- détails
|
||||
|
||||
async def get_details(self, source_id: str) -> TitleDetails:
|
||||
config = _merged_config()
|
||||
details_cfg = config["details"]
|
||||
url = self._title_url(source_id)
|
||||
soup = await fetch_soup(url)
|
||||
|
||||
title = self._text(soup.select_one(details_cfg["title"])) or source_id.replace("-", " ")
|
||||
poster = soup.select_one(details_cfg["poster"])
|
||||
image = str(poster["src"]) if poster and poster.get("src") else None
|
||||
synopsis = self._text(soup.select_one(details_cfg["synopsis"])) or None
|
||||
|
||||
episode_count: int | None = None
|
||||
count_el = soup.select_one(details_cfg["meta_count"])
|
||||
if count_el:
|
||||
match = _EPISODE_COUNT_RE.search(self._text(count_el))
|
||||
if match:
|
||||
episode_count = int(match.group(1))
|
||||
|
||||
episodes = self._parse_episodes(soup, config)
|
||||
return TitleDetails(
|
||||
source=self.name,
|
||||
source_id=source_id,
|
||||
title=title,
|
||||
url=url,
|
||||
synopsis=synopsis,
|
||||
image_url=image,
|
||||
episode_count=episode_count if episode_count is not None else len(episodes),
|
||||
episodes=episodes,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------- épisodes
|
||||
|
||||
async def list_episodes(self, source_id: str) -> list[Episode]:
|
||||
config = _merged_config()
|
||||
soup = await fetch_soup(self._title_url(source_id))
|
||||
return self._parse_episodes(soup, config)
|
||||
|
||||
def _parse_episodes(self, soup: BeautifulSoup, config: dict) -> list[Episode]:
|
||||
episodes: list[Episode] = []
|
||||
for card in soup.select(config["episodes"]["card"]):
|
||||
href = card.get("href")
|
||||
if not href:
|
||||
continue
|
||||
label = self._text(card)
|
||||
match = _EPISODE_LABEL_RE.search(label)
|
||||
if not match:
|
||||
logger.warning("voiranime : libellé d'épisode illisible : %r", label[:60])
|
||||
continue
|
||||
episodes.append(
|
||||
Episode(
|
||||
number=float(match.group(2).replace(",", ".")),
|
||||
title=None,
|
||||
url=urljoin(self.base_url + "/", str(href)),
|
||||
season=int(match.group(1)),
|
||||
)
|
||||
)
|
||||
return episodes
|
||||
|
||||
# ------------------------------------------------------------- lecteurs
|
||||
|
||||
async def extract_embed_links(self, episode_url: str) -> list[str]:
|
||||
"""Parcourt la chaîne Lecteur 1 → 2 → … et renvoie les URLs prepare absolues."""
|
||||
config = _merged_config()
|
||||
player_cfg = config["player"]
|
||||
prepare_urls: list[str] = []
|
||||
seen_sources: set[str] = set()
|
||||
page_url = episode_url
|
||||
for _ in range(int(player_cfg["max_sources"])):
|
||||
soup = await fetch_soup(page_url)
|
||||
root = soup.select_one(player_cfg["root"])
|
||||
if root is None:
|
||||
break
|
||||
prepare = root.get(player_cfg["prepare_attr"])
|
||||
if not prepare:
|
||||
break
|
||||
prepare_url = urljoin(self.base_url + "/", str(prepare))
|
||||
match = _SOURCE_ID_RE.search(prepare_url)
|
||||
source_key = match.group(1) if match else prepare_url
|
||||
if source_key in seen_sources:
|
||||
break
|
||||
seen_sources.add(source_key)
|
||||
prepare_urls.append(prepare_url)
|
||||
fallback = root.get(player_cfg["fallback_attr"])
|
||||
if not fallback:
|
||||
break
|
||||
page_url = urljoin(self.base_url + "/", str(fallback))
|
||||
next_source = _FALLBACK_SOURCE_RE.search(page_url)
|
||||
if next_source and next_source.group(1) in seen_sources:
|
||||
break
|
||||
if not prepare_urls:
|
||||
raise ScrapeError(f"voiranime : aucun lecteur trouvé sur {episode_url}")
|
||||
return prepare_urls
|
||||
|
||||
# ------------------------------------------------------------- découverte
|
||||
|
||||
async def latest(self) -> list[SearchResult]:
|
||||
"""Nouveautés + ajouts récents — carrousels de la page d'accueil."""
|
||||
config = _merged_config()
|
||||
latest_cfg = config["latest"]
|
||||
soup = await fetch_soup(self.base_url + latest_cfg["path"])
|
||||
results: list[SearchResult] = []
|
||||
seen: set[str] = set()
|
||||
for carousel in latest_cfg["carousels"]:
|
||||
section = soup.select_one(carousel)
|
||||
if section is None:
|
||||
logger.debug("voiranime : carrousel %s absent de l'accueil", carousel)
|
||||
continue
|
||||
for card in section.select(latest_cfg["item"]):
|
||||
link = card.select_one(latest_cfg["link"])
|
||||
if link is None:
|
||||
continue
|
||||
result = self._result_from_card(link)
|
||||
if result and result.source_id not in seen:
|
||||
seen.add(result.source_id)
|
||||
results.append(result)
|
||||
return results
|
||||
@@ -85,6 +85,7 @@ _SLUG_RE = re.compile(r"([^/]+)\.html?$")
|
||||
_NUMBER_RE = re.compile(r"(\d+(?:[.,]\d+)?)")
|
||||
_SEASON_RE = re.compile(r"Saison\s*:?\s*(\d+)", re.IGNORECASE)
|
||||
_SAFE_FRAGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
_VERSION_RE = re.compile(r"\b(vostfr|vf)\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def _merged_config() -> dict:
|
||||
@@ -246,8 +247,18 @@ class VostfreeScraper(SourceScraper):
|
||||
return int(season_match.group(1))
|
||||
return 1
|
||||
|
||||
def _detect_version(self, soup: BeautifulSoup, page_url: str) -> str | None:
|
||||
config = _merged_config()
|
||||
title_el = soup.select_one(config["details"]["title"])
|
||||
for text in (self._text(title_el), page_url):
|
||||
match = _VERSION_RE.search(text)
|
||||
if match:
|
||||
return match.group(1).lower()
|
||||
return None
|
||||
|
||||
def _parse_episodes(self, soup: BeautifulSoup, page_url: str, season: int) -> list[Episode]:
|
||||
config = _merged_config()
|
||||
version = self._detect_version(soup, page_url)
|
||||
options = soup.select(config["episodes"]["option"])
|
||||
entries: list[tuple[str, str]] = []
|
||||
seen_ids: set[str] = set()
|
||||
@@ -274,6 +285,7 @@ class VostfreeScraper(SourceScraper):
|
||||
title=label or f"Episode {index}",
|
||||
url=f"{page_url}#{button_id}" if button_id else page_url,
|
||||
season=season,
|
||||
version=version,
|
||||
)
|
||||
)
|
||||
return episodes
|
||||
|
||||
+188
-27
@@ -30,6 +30,7 @@ from app.scrapers.base import (
|
||||
ScrapeError,
|
||||
SourceScraper,
|
||||
all_sources,
|
||||
get_source,
|
||||
import_all_scrapers,
|
||||
)
|
||||
from app.services.kitsu import KitsuService, normalize_title
|
||||
@@ -44,11 +45,56 @@ import_all_scrapers()
|
||||
_MAX_HISTORY_TITLES = 12 # titres récents analysés (téléchargements + favoris)
|
||||
_MAX_GENRES = 4 # genres retenus pour la requête Kitsu
|
||||
_KITSU_PAGE_MAX = 20 # limite dure de l'API Kitsu (page[limit] > 20 → 400)
|
||||
_ENRICH_CONCURRENCY = 6 # enrichissements Kitsu parallèles max (nouveautés)
|
||||
# (les TTL des sections suivent, plus bas)
|
||||
|
||||
# Genres animés proposés à l'exploration — slugs des catégories Kitsu officielles.
|
||||
ANIME_GENRES: dict[str, str] = {
|
||||
"action": "Action",
|
||||
"adventure": "Aventure",
|
||||
"comedy": "Comédie",
|
||||
"drama": "Drame",
|
||||
"fantasy": "Fantasy",
|
||||
"science-fiction": "Science-Fiction",
|
||||
"romance": "Romance",
|
||||
"slice-of-life": "Tranche de vie",
|
||||
"sports": "Sport",
|
||||
"supernatural": "Surnaturel",
|
||||
"mystery": "Mystère",
|
||||
"psychological": "Psychologique",
|
||||
"horror": "Horreur",
|
||||
"mecha": "Mecha",
|
||||
"isekai": "Isekai",
|
||||
"music": "Musique",
|
||||
}
|
||||
|
||||
# Genres des fiches séries/films (French-Stream, libellés FR, slugifiés) →
|
||||
# catégorie Kitsu équivalente. Les genres sans équivalent (Médical, Western…)
|
||||
# sont ignorés : mieux vaut un signal incomplet qu'une catégorie inexistante.
|
||||
_GENRE_FR_TO_KITSU: dict[str, str] = {
|
||||
"action": "Action",
|
||||
"aventure": "Adventure",
|
||||
"aventures": "Adventure",
|
||||
"comedie": "Comedy",
|
||||
"comedies": "Comedy",
|
||||
"drame": "Drama",
|
||||
"drames": "Drama",
|
||||
"epouvante-horreur": "Horror",
|
||||
"horreur": "Horror",
|
||||
"fantastique": "Fantasy",
|
||||
"fantastiques": "Fantasy",
|
||||
"romance": "Romance",
|
||||
"romances": "Romance",
|
||||
"science-fiction": "Science Fiction",
|
||||
"science-fictions": "Science Fiction",
|
||||
"surnaturel": "Supernatural",
|
||||
"thriller": "Thriller",
|
||||
"thrillers": "Thriller",
|
||||
}
|
||||
|
||||
_LATEST_TTL_SECONDS = 600 # nouveautés : re-scrape au bout de 10 min
|
||||
_MUST_WATCH_TTL_SECONDS = 21600 # incontournables : quasi statique, 6 h
|
||||
_FOR_YOU_TTL_SECONDS = 3600 # recommandations : 1 h (l'historique évolue lentement)
|
||||
_ENRICH_CONCURRENCY = 6 # enrichissements Kitsu parallèles max (nouveautés)
|
||||
|
||||
|
||||
class _TTLCache:
|
||||
@@ -92,14 +138,18 @@ class DiscoverService:
|
||||
|
||||
# ------------------------------------------------------------ nouveautés
|
||||
|
||||
async def latest(self, limit: int = 24) -> list[dict]:
|
||||
"""Nouveautés toutes sources confondues, triées par date de sortie réelle.
|
||||
async def latest_by_type(self, limit: int = 24) -> dict[str, list[dict]]:
|
||||
"""Nouveautés par type de média : ``{"anime": [...], "serie": [...], "film": [...]}``.
|
||||
|
||||
Les « récemment ajoutés » de chaque source sont fusionnés (doublons retirés),
|
||||
enrichis via Kitsu (date de début, statut de diffusion) puis triés du plus
|
||||
récent au plus ancien — ce qui sort / vient de sortir en premier.
|
||||
Les « récemment ajoutés » de chaque source activée sont fusionnés (doublons
|
||||
retirés) puis répartis en rails indépendants — chaque type garde sa place,
|
||||
aucun n'évince les autres. Le rail animés est enrichi via Kitsu (date de
|
||||
sortie, statut) et trié du plus récent au plus ancien ; les séries et films
|
||||
réels, absents de Kitsu, gardent l'ordre du site (déjà « récents d'abord »).
|
||||
Chaque rail est tronqué à ``limit``.
|
||||
"""
|
||||
cached = self._cache.get(f"latest:{limit}")
|
||||
cache_key = f"latest_by_type:{limit}"
|
||||
cached = self._cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached # type: ignore[return-value]
|
||||
|
||||
@@ -113,17 +163,28 @@ class DiscoverService:
|
||||
existing = merged.get(key)
|
||||
if existing is None or (not existing.get("image_url") and item.get("image_url")):
|
||||
merged[key] = item
|
||||
|
||||
by_type: dict[str, list[dict]] = {"anime": [], "serie": [], "film": []}
|
||||
for item in merged.values():
|
||||
by_type.setdefault(item.get("media_type", "anime"), []).append(item)
|
||||
|
||||
# Enrichissement Kitsu limité aux animés (seul catalogue couvert) —
|
||||
# inutile de bombarder l'API pour des titres qui n'y figurent pas.
|
||||
semaphore = asyncio.Semaphore(_ENRICH_CONCURRENCY)
|
||||
|
||||
async def bounded(item: dict) -> dict:
|
||||
async with semaphore:
|
||||
return await self._with_release_info(item)
|
||||
|
||||
enriched = await asyncio.gather(*(bounded(item) for item in merged.values()))
|
||||
result = sorted(enriched, key=lambda it: it.get("start_date") or "", reverse=True)
|
||||
result = result[:limit]
|
||||
self._cache.set(f"latest:{limit}", result, _LATEST_TTL_SECONDS)
|
||||
return result
|
||||
enriched = await asyncio.gather(*(bounded(item) for item in by_type["anime"]))
|
||||
by_type["anime"] = sorted(
|
||||
enriched, key=lambda it: it.get("start_date") or "", reverse=True
|
||||
)[:limit]
|
||||
for kind in ("serie", "film"):
|
||||
by_type[kind] = by_type[kind][:limit]
|
||||
|
||||
self._cache.set(cache_key, by_type, _LATEST_TTL_SECONDS)
|
||||
return by_type
|
||||
|
||||
async def _latest_of(self, source: SourceScraper) -> list[dict] | None:
|
||||
"""Items latest() d'une source, aplatis avec les infos de source ([] si KO)."""
|
||||
@@ -164,6 +225,46 @@ class DiscoverService:
|
||||
self._cache.set(key, items, _MUST_WATCH_TTL_SECONDS)
|
||||
return items
|
||||
|
||||
|
||||
async def browse_serie_film(self, media_type: str, genre: str, limit: int = 24) -> list[dict]:
|
||||
"""Séries/films d'un genre French-Stream, aplatis comme les nouveautés.
|
||||
|
||||
Liste vide si la catégorie est inconnue ou la source en échec.
|
||||
"""
|
||||
key = f"browse_fs:{media_type}:{genre}:{limit}"
|
||||
cached = self._cache.get(key)
|
||||
if cached is not None:
|
||||
return cached # type: ignore[return-value]
|
||||
try:
|
||||
scraper = get_source("french_stream")
|
||||
results = await scraper.browse(media_type, genre)
|
||||
except ScrapeError as exc:
|
||||
logger.warning("Parcours %s/%s indisponible : %s", media_type, genre, exc)
|
||||
return []
|
||||
items = [
|
||||
{**dataclasses.asdict(r), "source": scraper.name, "label": scraper.label}
|
||||
for r in results
|
||||
][:limit]
|
||||
self._cache.set(key, items, _LATEST_TTL_SECONDS)
|
||||
return items
|
||||
async def browse_anime(self, genre: str, limit: int = _KITSU_PAGE_MAX) -> list[dict]:
|
||||
"""Animés populaires d'une catégorie Kitsu (genre = slug du catalogue).
|
||||
|
||||
Liste vide si le genre est inconnu ou si Kitsu échoue (dégradation gracieuse).
|
||||
"""
|
||||
limit = min(limit, _KITSU_PAGE_MAX)
|
||||
if genre not in ANIME_GENRES:
|
||||
return []
|
||||
key = f"browse_anime:{genre}:{limit}"
|
||||
cached = self._cache.get(key)
|
||||
if cached is not None:
|
||||
return cached # type: ignore[return-value]
|
||||
items = await self._kitsu_anime(
|
||||
{"filter[categories]": genre, "sort": "-userCount", "page[limit]": limit}
|
||||
)
|
||||
self._cache.set(key, items, _FOR_YOU_TTL_SECONDS)
|
||||
return items
|
||||
|
||||
# ------------------------------------------------------------ pour toi
|
||||
|
||||
async def for_you(self, user_id: int, limit: int = _KITSU_PAGE_MAX) -> dict:
|
||||
@@ -179,49 +280,109 @@ class DiscoverService:
|
||||
if cached is not None:
|
||||
return cached # type: ignore[return-value]
|
||||
|
||||
owned, favorite_genres = await self._owned(user_id)
|
||||
owned, favorite_genres, series_pages = await self._owned(user_id)
|
||||
genre_counts = await self._genres_from_downloads(owned)
|
||||
for genre, count in favorite_genres.items():
|
||||
genre_counts[genre] = genre_counts.get(genre, 0) + count
|
||||
if series_pages:
|
||||
# Séries/films téléchargés : genres lus sur leur fiche source (sans Sonarr)
|
||||
for genre, count in (await self._genres_from_series_pages(series_pages)).items():
|
||||
genre_counts[genre] = genre_counts.get(genre, 0) + count
|
||||
sonarr_owned, sonarr_genres = await sonarr.profile()
|
||||
for genre, count in sonarr_genres.items():
|
||||
genre_counts[genre] = genre_counts.get(genre, 0) + count
|
||||
|
||||
if not (owned or favorite_genres or sonarr_owned):
|
||||
# Aucun historique : proposition d'amorçage plutôt qu'une section muette
|
||||
return {"based_on": [], "items": [], "cold_start": True}
|
||||
owned |= sonarr_owned
|
||||
|
||||
if not genre_counts:
|
||||
result: dict = {"based_on": [], "items": []}
|
||||
self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS)
|
||||
return result
|
||||
|
||||
top_genres = sorted(genre_counts, key=genre_counts.get, reverse=True)[:_MAX_GENRES]
|
||||
slugs = [category_slug(genre) for genre in top_genres]
|
||||
items = await self._kitsu_anime(
|
||||
{
|
||||
"filter[categories]": ",".join(slugs),
|
||||
"sort": "-userCount",
|
||||
"page[limit]": limit, # le déjà-possédé est filtré après
|
||||
}
|
||||
# Une requête par genre (Kitsu cumule les catégories en ET : quatre genres
|
||||
# ensemble ne laissent que quelques titres — parfois le déjà-possédé !).
|
||||
# Fusion entrelacée : chaque genre contribue, doublons retirés.
|
||||
pools = [
|
||||
list(pool)
|
||||
for pool in await asyncio.gather(*(
|
||||
self._kitsu_anime(
|
||||
{"filter[categories]": category_slug(g), "sort": "-userCount", "page[limit]": limit}
|
||||
)
|
||||
kept = [item for item in items if item["title"] and item["title"].casefold() not in owned]
|
||||
for g in top_genres
|
||||
))
|
||||
if pool
|
||||
]
|
||||
candidates: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
while pools:
|
||||
for pool in pools[:]:
|
||||
item = pool.pop(0)
|
||||
key = str(item.get("kitsu_id") or item.get("title", "").casefold())
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
candidates.append(item)
|
||||
if not pool:
|
||||
pools.remove(pool)
|
||||
kept = [
|
||||
item for item in candidates
|
||||
if item["title"] and normalize_title(item["title"]).casefold() not in owned
|
||||
][:limit]
|
||||
result = {"based_on": top_genres, "items": kept}
|
||||
self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS)
|
||||
return result
|
||||
|
||||
async def _genres_from_series_pages(self, pages: dict[str, str]) -> dict[str, int]:
|
||||
"""Genres Kitsu des séries/films téléchargés, lus sur leur fiche source.
|
||||
|
||||
Les libellés français des fiches sont convertis vers les catégories Kitsu
|
||||
(anglais) ; les genres sans équivalent sont ignorés.
|
||||
"""
|
||||
if not pages:
|
||||
return {}
|
||||
try:
|
||||
scraper = get_source("french_stream")
|
||||
except ScrapeError:
|
||||
return {}
|
||||
counts: dict[str, int] = {}
|
||||
for page_url in list(pages.values())[:_MAX_HISTORY_TITLES]:
|
||||
for genre_fr in await scraper.genres_of_page(page_url):
|
||||
canonical = _GENRE_FR_TO_KITSU.get(category_slug(genre_fr))
|
||||
if canonical:
|
||||
counts[canonical] = counts.get(canonical, 0) + 1
|
||||
return counts
|
||||
|
||||
def invalidate_for_you(self) -> None:
|
||||
"""Recommandations recalculées au prochain appel (réglages Sonarr modifiés)."""
|
||||
self._cache.clear("for_you:")
|
||||
|
||||
async def _owned(self, user_id: int) -> tuple[set[str], dict[str, int]]:
|
||||
"""Titres possédés (normalisés) + genres directement connus via les favoris."""
|
||||
async def _owned(self, user_id: int) -> tuple[set[str], dict[str, int], dict[str, str]]:
|
||||
"""Titres possédés (normalisés), genres des favoris, fiches des téléchargements.
|
||||
|
||||
``series_pages`` relie un titre possédé à l'URL de sa fiche chez la source
|
||||
(séries/films French-Stream) : sans Sonarr, c'est la seule source de genres.
|
||||
"""
|
||||
rows = await db.fetchall(
|
||||
"SELECT DISTINCT title FROM downloads ORDER BY created_at DESC LIMIT ?",
|
||||
"SELECT DISTINCT title, page_url FROM downloads ORDER BY created_at DESC LIMIT ?",
|
||||
(_MAX_HISTORY_TITLES,),
|
||||
)
|
||||
fav_rows = await db.fetchall(
|
||||
"SELECT payload FROM favorites WHERE user_id = ? ORDER BY created_at DESC LIMIT ?",
|
||||
(user_id, _MAX_HISTORY_TITLES),
|
||||
)
|
||||
owned = {normalize_title(row["title"]).casefold() for row in rows}
|
||||
owned.discard("")
|
||||
owned: set[str] = set()
|
||||
series_pages: dict[str, str] = {}
|
||||
for row in rows:
|
||||
key = normalize_title(row["title"]).casefold()
|
||||
if not key:
|
||||
continue
|
||||
owned.add(key)
|
||||
page_url = row["page_url"] or ""
|
||||
if page_url and key not in series_pages:
|
||||
series_pages[key] = page_url
|
||||
genre_counts: dict[str, int] = {}
|
||||
for row in fav_rows:
|
||||
try:
|
||||
@@ -231,7 +392,7 @@ class DiscoverService:
|
||||
for genre in payload.get("genres") or []:
|
||||
if isinstance(genre, str) and genre.strip():
|
||||
genre_counts[genre.strip()] = genre_counts.get(genre.strip(), 0) + 1
|
||||
return owned, genre_counts
|
||||
return owned, genre_counts, series_pages
|
||||
|
||||
async def _genres_from_downloads(self, titles: set[str]) -> dict[str, int]:
|
||||
"""Genres Kitsu des titres téléchargés (cache DB puis recherche)."""
|
||||
|
||||
+99
-12
@@ -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,8 +157,12 @@ 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:
|
||||
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 "
|
||||
@@ -126,22 +170,28 @@ class DownloadManager:
|
||||
" total_bytes, downloaded_bytes) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'done', ?, ?)",
|
||||
(
|
||||
f"file:{path.name}",
|
||||
f"file:{rel_path}",
|
||||
"",
|
||||
"",
|
||||
path.stem,
|
||||
path.name,
|
||||
rel_path,
|
||||
size,
|
||||
size,
|
||||
),
|
||||
)
|
||||
logger.info("Fichier restauré depuis le disque : %s", path.name)
|
||||
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
|
||||
|
||||
|
||||
+14
-3
@@ -21,15 +21,26 @@ logger = logging.getLogger(__name__)
|
||||
_NOISE_WORDS_RE = re.compile(
|
||||
r"\b(?:VOSTFR\d*|VOST|VF[IV]?|TRUEFRENCH|FRENCH|MULTI|SUBFR?)\b", re.IGNORECASE
|
||||
)
|
||||
_TRAILING_SEASON_RE = re.compile(r"[\s\-–—:.]*\s*(?:saison|season)\s*\d+\s*$", re.IGNORECASE)
|
||||
_TRAILING_CODE_RE = re.compile(r"[\s\-–—:.]*\s*S\d+(?:E\d+)?\s*$", re.IGNORECASE)
|
||||
_TRAILING_SEASON_RE = re.compile(r"[\s\-–—:.]*\b(?:saison|season)\s*\d+\s*$", re.IGNORECASE)
|
||||
_TRAILING_CODE_RE = re.compile(r"[\s\-–—:.]*\bS\d+(?:\s*E\d+)?\s*$", re.IGNORECASE)
|
||||
_TRAILING_EPISODE_RE = re.compile(r"[\s\-–—:.]*\b(?:e|ep|episode)\s*\d+\s*$", re.IGNORECASE)
|
||||
|
||||
|
||||
def normalize_title(title: str) -> str:
|
||||
"""Nettoie un titre de scraping avant recherche Kitsu (bruit, saison, tirets)."""
|
||||
"""Nettoie un titre de scraping avant comparaison/recherche Kitsu.
|
||||
|
||||
Retire le bruit (VF/VOSTFR…), les marqueurs de saison/épisode en fin de titre
|
||||
(« - Saison 1 - E3 », « S1 E1 ») — appliqués en boucle : un titre canonique
|
||||
émergera identique d'un téléchargement (« Titre - Saison 1 - E3 ») ou d'une
|
||||
fiche Kitsu (« Titre »), condition de l'exclusion du déjà-possédé.
|
||||
"""
|
||||
cleaned = _NOISE_WORDS_RE.sub(" ", title)
|
||||
previous = None
|
||||
while previous != cleaned:
|
||||
previous = cleaned
|
||||
cleaned = _TRAILING_SEASON_RE.sub("", cleaned)
|
||||
cleaned = _TRAILING_CODE_RE.sub("", cleaned)
|
||||
cleaned = _TRAILING_EPISODE_RE.sub("", cleaned)
|
||||
cleaned = re.sub(r"\s*[-–—_]+\s*", " ", cleaned)
|
||||
cleaned = re.sub(r"\s+", " ", cleaned)
|
||||
return cleaned.strip(" -–—:.")
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Paramètres persistés en DB (activation des sources, réglages UI…)."""
|
||||
"""Paramètres persistés en DB (activation des sources, santé, réglages UI…)."""
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.db import db
|
||||
|
||||
@@ -35,6 +36,48 @@ async def set_source_enabled(name: str, enabled: bool) -> None:
|
||||
await set_setting(f"source:{name}:enabled", enabled)
|
||||
logger.info("Source %s %s", name, "activée" if enabled else "désactivée")
|
||||
|
||||
|
||||
async def get_source_health(name: str) -> dict | None:
|
||||
"""Dernier état de santé connu d'une source (None si jamais testée)."""
|
||||
value = await get_setting(f"source:{name}:health")
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
async def set_source_health(name: str, healthy: bool, detail: str) -> dict:
|
||||
state = {
|
||||
"healthy": healthy,
|
||||
"detail": detail,
|
||||
"checked_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
await set_setting(f"source:{name}:health", state)
|
||||
return state
|
||||
|
||||
|
||||
async def get_source_base_url(name: str) -> str | None:
|
||||
"""URL personnalisée d'une source (None = valeur par défaut du code)."""
|
||||
value = await get_setting(f"source:{name}:base_url")
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
async def set_source_base_url(name: str, url: str | None) -> None:
|
||||
"""Persiste l'URL personnalisée (None/'' → retour à la valeur par défaut)."""
|
||||
key = f"source:{name}:base_url"
|
||||
if url:
|
||||
await set_setting(key, url)
|
||||
else:
|
||||
await db.execute("DELETE FROM settings WHERE key = ?", (key,))
|
||||
|
||||
|
||||
async def apply_source_base_urls() -> None:
|
||||
"""Applique les URL personnalisées aux instances de sources (au démarrage)."""
|
||||
from app.scrapers.base import all_sources
|
||||
|
||||
for source in all_sources():
|
||||
override = await get_source_base_url(source.name)
|
||||
if override and override != type(source).base_url:
|
||||
source.base_url = override
|
||||
logger.info("URL personnalisée pour %s : %s", source.name, override)
|
||||
|
||||
# ---------------------------------------------------------------- intégrations *arr
|
||||
|
||||
TORZNAB_APIKEY_KEY = "torznab:apikey"
|
||||
@@ -70,3 +113,4 @@ async def set_sonarr_config(url: str, apikey: str) -> None:
|
||||
await set_setting(SONARR_URL_KEY, url.rstrip("/"))
|
||||
await set_setting(SONARR_APIKEY_KEY, apikey.strip())
|
||||
logger.info("Configuration Sonarr enregistrée (%s)", url)
|
||||
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Mises à jour logicielles.
|
||||
|
||||
- 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é.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from app.version import get_version
|
||||
|
||||
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, "notes": None}
|
||||
|
||||
|
||||
class UpdateError(Exception):
|
||||
"""Erreur de mise à jour (Gitea injoignable, Watchtower KO)."""
|
||||
|
||||
|
||||
def parse_tag(tag: str) -> tuple[int, int, int] | None:
|
||||
"""'v0.2.1' → (0, 2, 1) ; None si le tag n'est pas un semver strict."""
|
||||
m = _TAG_RE.match(tag.strip())
|
||||
return tuple(int(g) for g in m.groups()) if m else None # type: ignore[return-value]
|
||||
|
||||
|
||||
def is_newer(latest: str, current: str) -> bool:
|
||||
a, b = parse_tag(latest), parse_tag(current)
|
||||
if a is None or b is None:
|
||||
return False
|
||||
return a > b
|
||||
|
||||
|
||||
async def fetch_latest_version(*, force: bool = False) -> str | None:
|
||||
"""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]
|
||||
|
||||
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)
|
||||
resp.raise_for_status()
|
||||
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, 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, notes=None)
|
||||
|
||||
|
||||
async def status() -> dict[str, object]:
|
||||
"""É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)),
|
||||
"docker": bool(get_settings().watchtower_url),
|
||||
}
|
||||
|
||||
|
||||
async def trigger_update() -> dict[str, str]:
|
||||
"""Demande à Watchtower de recréer le conteneur avec la dernière image.
|
||||
|
||||
Le conteneur courant (donc cette requête) disparaît quelques secondes après :
|
||||
la réponse est renvoyée immédiatement, le frontend gère la reconnexion.
|
||||
"""
|
||||
settings = get_settings()
|
||||
if not settings.watchtower_url:
|
||||
raise UpdateError(
|
||||
"Watchtower non configuré — mise à jour disponible uniquement en déploiement Docker"
|
||||
)
|
||||
headers = {}
|
||||
if settings.watchtower_token:
|
||||
headers["Authorization"] = f"Bearer {settings.watchtower_token}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(f"{settings.watchtower_url}/v1/update", headers=headers)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
raise UpdateError(f"Watchtower injoignable : {exc}") from exc
|
||||
logger.info("Mise à jour déclenchée via Watchtower (version courante %s)", get_version())
|
||||
return {"status": "started"}
|
||||
+145
-1
@@ -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; }
|
||||
@@ -147,6 +155,7 @@ button { font-family: inherit; }
|
||||
.btn-sm { padding: 0.4rem 0.85rem; font-size: 0.82rem; }
|
||||
.btn-danger { background: #3d1114; color: var(--danger); border: 1px solid #5c1a1e; }
|
||||
.btn-danger:hover { background: #4d1518; filter: none; }
|
||||
.btn-accent { background: var(--accent); color: #fff; border: none; font-weight: 600; }
|
||||
|
||||
/* ------------------------------------------------------------ grille de posters */
|
||||
|
||||
@@ -343,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 {
|
||||
@@ -504,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;
|
||||
@@ -652,3 +746,53 @@ button { font-family: inherit; }
|
||||
.search-bar { flex-direction: column; }
|
||||
.search-bar .btn { justify-content: center; }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------- bandeau mise à jour (update-watcher.js) */
|
||||
.update-banner {
|
||||
position: fixed;
|
||||
inset: auto 0 1.2rem 0;
|
||||
margin: 0 auto;
|
||||
width: max-content;
|
||||
max-width: 90vw;
|
||||
padding: 0.7rem 1.2rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(20, 20, 24, 0.96);
|
||||
border: 1px solid var(--accent);
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.update-banner .spin { display: inline-block; animation: update-spin 1s linear infinite; }
|
||||
@keyframes update-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ------------------------------------------------------------ filtre VF/VOSTFR */
|
||||
|
||||
.version-filter {
|
||||
display: inline-flex;
|
||||
gap: 0.2rem;
|
||||
background: var(--surface-2);
|
||||
padding: 0.2rem;
|
||||
border-radius: 99px;
|
||||
margin-left: 0.8rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.version-tab {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.28rem 0.75rem;
|
||||
border-radius: 99px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.version-tab:hover { color: var(--text); }
|
||||
.version-tab.active { background: var(--accent); color: #fff; }
|
||||
.badge-version { background: rgba(90, 200, 250, 0.14); color: #7ec8f5; }
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/* Surveille la disponibilité du serveur et détecte les changements de version.
|
||||
* Pendant une mise à jour Docker (quelques secondes d'indisponibilité), affiche
|
||||
* un bandeau « Mise à jour en cours » puis recharge la page automatiquement
|
||||
* quand le service revient avec une nouvelle version.
|
||||
* Un serveur qui RÉPOND est considéré comme disponible (même 404 : instance
|
||||
* antérieure à l'endpoint /api/version) — seul un échec réseau (connexion
|
||||
* refusée, timeout) signifie « en cours de redémarrage ». */
|
||||
(() => {
|
||||
const POLL_MS = 8000;
|
||||
const KEY_UPDATING = 'ohm-updating';
|
||||
|
||||
let initialVersion = null;
|
||||
let failures = 0;
|
||||
let banner = null;
|
||||
let done = false;
|
||||
|
||||
const isUpdating = () => sessionStorage.getItem(KEY_UPDATING) === '1';
|
||||
|
||||
function showBanner() {
|
||||
if (banner) return;
|
||||
banner = document.createElement('div');
|
||||
banner.className = 'update-banner';
|
||||
banner.innerHTML = '<span class="spin">⟳</span> Mise à jour en cours — reconnexion automatique…';
|
||||
document.body.appendChild(banner);
|
||||
}
|
||||
|
||||
function hideBanner() {
|
||||
if (banner) banner.remove();
|
||||
banner = null;
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (document.hidden || done) return;
|
||||
let version = null;
|
||||
let up = false;
|
||||
try {
|
||||
const r = await fetch('/api/version', { cache: 'no-store' });
|
||||
up = true; // le serveur a répondu : il est vivant
|
||||
if (r.ok) version = (await r.json()).version ?? null;
|
||||
} catch {
|
||||
up = false; // connexion refusée / réseau coupé : serveur injoignable
|
||||
}
|
||||
|
||||
if (!up) {
|
||||
failures += 1;
|
||||
if (isUpdating() || failures >= 2) showBanner();
|
||||
return;
|
||||
}
|
||||
|
||||
failures = 0;
|
||||
hideBanner();
|
||||
if (version !== null && initialVersion === null) initialVersion = version;
|
||||
if (isUpdating() || (version !== null && version !== initialVersion)) {
|
||||
done = true;
|
||||
sessionStorage.removeItem(KEY_UPDATING);
|
||||
setTimeout(() => location.reload(), 1000); // laisser le serveur finir de démarrer
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) poll();
|
||||
});
|
||||
|
||||
if (isUpdating()) showBanner();
|
||||
poll();
|
||||
setInterval(poll, POLL_MS);
|
||||
})();
|
||||
+130
-11
@@ -26,12 +26,23 @@
|
||||
<template x-for="s in sources" :key="s.name">
|
||||
<tr>
|
||||
<td><strong x-text="s.label"></strong> <span style="color:var(--text-dim);font-size:0.8rem" x-text="'(' + s.name + ')'"></span></td>
|
||||
<td style="font-size:0.82rem;color:var(--text-dim)" x-text="s.base_url"></td>
|
||||
<td>
|
||||
<div style="display:flex;gap:0.35rem;align-items:center;flex-wrap:wrap">
|
||||
<input class="input" x-model="s._url" :placeholder="s.default_base_url"
|
||||
style="min-width:230px;font-size:0.8rem;padding:0.3rem 0.5rem"
|
||||
@change="s._urlDirty = s._url.trim() !== s.base_url">
|
||||
<button class="btn btn-sm btn-ghost" @click="saveUrl(s)" :disabled="!s._urlDirty || s._saving"
|
||||
x-text="s._saving ? '…' : 'Enregistrer'"></button>
|
||||
<button class="btn btn-sm btn-ghost" x-show="s.overridden" @click="resetUrl(s)"
|
||||
:disabled="s._saving" title="Restaurer l'URL par défaut">↺</button>
|
||||
</div>
|
||||
</td>
|
||||
<td><div class="toggle" :class="s.enabled && 'on'" @click="toggle(s)"></div></td>
|
||||
<td>
|
||||
<span x-show="s.health == null" style="color:var(--text-dim)">—</span>
|
||||
<span x-show="s.health === true" style="color:var(--success)">✔ OK</span>
|
||||
<span x-show="s.health === false" style="color:var(--danger)" :title="s.healthDetail">✖ KO</span>
|
||||
<span x-show="!s.health" style="color:var(--text-dim)">—</span>
|
||||
<span x-show="s.health" :style="s.health?.healthy ? 'color:var(--success)' : 'color:var(--danger)'"
|
||||
:title="s.health ? s.health.detail + (s.health.checked_at ? ' — vérifié le ' + new Date(s.health.checked_at).toLocaleString('fr-FR') : '') : ''"
|
||||
x-text="s.health?.healthy ? '✔ OK' : '✖ KO'"></span>
|
||||
</td>
|
||||
<td><button class="btn btn-sm btn-ghost" @click="healthCheck(s)" :disabled="s._checking" x-text="s._checking ? '…' : 'Tester'"></button></td>
|
||||
</tr>
|
||||
@@ -48,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://<hote-ohm>: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>
|
||||
@@ -84,6 +101,33 @@
|
||||
:style="integrations.testResult?.ok ? 'color:var(--success)' : 'color:var(--danger)'"
|
||||
x-text="integrations.testResult?.detail"></p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>🔄 Mise à jour</h2>
|
||||
<p style="color:var(--text-dim);font-size:0.85rem;margin:0 0 0.6rem">
|
||||
Version installée : <strong x-text="upd.current || '—'"></strong>
|
||||
<span x-show="upd.update_available" class="badge" style="background:var(--accent);color:#fff"
|
||||
x-text="(upd.latest || '?') + ' disponible'"></span>
|
||||
<span x-show="upd.latest && !upd.update_available" style="color:var(--success)">✔ à jour</span>
|
||||
</p>
|
||||
|
||||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
|
||||
<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>
|
||||
<details x-show="upd.notes" style="margin:0.6rem 0 0">
|
||||
<summary style="cursor:pointer;font-size:0.85rem;color:var(--text-dim)">
|
||||
Patchnote <span x-text="upd.latest"></span>
|
||||
</summary>
|
||||
<pre style="white-space:pre-wrap;font-size:0.82rem;margin:0.5rem 0 0;color:var(--text-dim)"
|
||||
x-text="upd.notes"></pre>
|
||||
</details>
|
||||
<p x-show="!upd.docker" style="margin:0.5rem 0 0;font-size:0.85rem;color:var(--text-dim)">
|
||||
⚠ Mise à jour automatique disponible uniquement en déploiement Docker
|
||||
(<code>docker compose pull && docker compose up -d</code> sinon).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>👥 Utilisateurs</h2>
|
||||
@@ -121,6 +165,10 @@
|
||||
function adminPage() {
|
||||
return {
|
||||
users: [], sources: [], stats: {}, forbidden: false,
|
||||
upd: {
|
||||
current: '', latest: null, notes: null, update_available: false, docker: false,
|
||||
_checking: false, _applying: false,
|
||||
},
|
||||
integrations: {
|
||||
torznab: { apikey: '', endpoint: '' }, sonarr: { url: '', apikey: '' },
|
||||
_regen: false, _testing: false, testResult: null,
|
||||
@@ -132,7 +180,7 @@ function adminPage() {
|
||||
]);
|
||||
if (u.status === 403) { this.forbidden = true; return; }
|
||||
this.users = await u.json();
|
||||
this.sources = await s.json();
|
||||
this.sources = (await s.json()).map((x) => ({ ...x, _url: x.base_url, _urlDirty: false, _saving: false }));
|
||||
this.stats = await st.json();
|
||||
const itg = await fetch('/api/admin/integrations');
|
||||
if (itg.ok) {
|
||||
@@ -140,8 +188,10 @@ function adminPage() {
|
||||
this.integrations.torznab = data.torznab;
|
||||
this.integrations.sonarr = { ...data.sonarr, _dirty: false };
|
||||
}
|
||||
const upd = await fetch('/api/admin/update');
|
||||
if (upd.ok) this.applyUpdateData(await upd.json());
|
||||
this.checkAllSources();
|
||||
},
|
||||
|
||||
copy(value) {
|
||||
navigator.clipboard.writeText(value).then(() => toast('✔ Copié'));
|
||||
},
|
||||
@@ -170,13 +220,53 @@ function adminPage() {
|
||||
if (res.ok) s.enabled = !s.enabled;
|
||||
},
|
||||
|
||||
async healthCheck(s) {
|
||||
s._checking = true;
|
||||
const res = await fetch(`/api/admin/sources/${s.name}/health`, { method: 'POST' });
|
||||
async saveUrl(s) {
|
||||
s._saving = true;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/sources/${s.name}/url`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: s._url }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
s.health = data.healthy; s.healthDetail = data.detail;
|
||||
s.base_url = data.base_url;
|
||||
s.overridden = data.overridden;
|
||||
s._url = data.base_url;
|
||||
s._urlDirty = false;
|
||||
toast('✔ URL enregistrée — test de la source…');
|
||||
this.healthCheck(s);
|
||||
} else {
|
||||
toast('✖ ' + ((await res.json()).detail || 'Erreur'));
|
||||
}
|
||||
} catch {
|
||||
toast('✖ Erreur réseau');
|
||||
} finally {
|
||||
s._saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async resetUrl(s) {
|
||||
s._url = '';
|
||||
await this.saveUrl(s);
|
||||
},
|
||||
|
||||
async healthCheck(s, notify = true) {
|
||||
s._checking = true;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/sources/${s.name}/health`, { method: 'POST' });
|
||||
const data = res.ok ? await res.json() : { healthy: false, detail: `Erreur serveur (${res.status})` };
|
||||
s.health = { healthy: data.healthy, detail: data.detail, checked_at: data.checked_at };
|
||||
if (notify) toast(`${data.healthy ? '✔' : '✖'} ${s.label} : ${data.detail}`);
|
||||
} catch {
|
||||
s.health = { healthy: false, detail: 'Erreur réseau', checked_at: null };
|
||||
if (notify) toast(`✖ ${s.label} : erreur réseau`);
|
||||
} finally {
|
||||
s._checking = false;
|
||||
toast(data.healthy ? `✔ ${s.label} : ${data.detail}` : `✖ ${s.label} : ${data.detail}`);
|
||||
}
|
||||
},
|
||||
|
||||
checkAllSources() {
|
||||
return Promise.allSettled(this.sources.filter(s => s.enabled).map(s => this.healthCheck(s, false)));
|
||||
},
|
||||
|
||||
async post(url) { await fetch(url, { method: 'POST' }); await this.load(); },
|
||||
@@ -188,6 +278,35 @@ function adminPage() {
|
||||
this.integrations._testing = false;
|
||||
},
|
||||
|
||||
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.docker = data.docker;
|
||||
},
|
||||
|
||||
async checkUpdate() {
|
||||
this.upd._checking = true;
|
||||
const res = await fetch('/api/admin/update/check', { method: 'POST' });
|
||||
if (res.ok) this.applyUpdateData(await res.json());
|
||||
this.upd._checking = false;
|
||||
if (this.upd.update_available) toast('⬆ ' + this.upd.latest + ' disponible');
|
||||
},
|
||||
|
||||
async applyUpdate() {
|
||||
if (!confirm('Mettre à jour maintenant ? Le service sera indisponible quelques secondes.')) return;
|
||||
this.upd._applying = true;
|
||||
sessionStorage.setItem('ohm-updating', '1');
|
||||
const res = await fetch('/api/admin/update/apply', { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
sessionStorage.removeItem('ohm-updating');
|
||||
const detail = (await res.json()).detail || 'Erreur';
|
||||
toast('✖ ' + detail); this.upd._applying = false;
|
||||
}
|
||||
// sinon : le conteneur est recréé, update-watcher.js affiche le bandeau et recharge la page
|
||||
},
|
||||
|
||||
async del(u) {
|
||||
if (!confirm(`Supprimer le compte « ${u.username} » ?`)) return;
|
||||
await fetch('/api/admin/users/' + u.id, { method: 'DELETE' });
|
||||
|
||||
+10
-3
@@ -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,7 +37,8 @@
|
||||
<main class="main">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/app.js?v={{ asset_v }}"></script>
|
||||
<script src="/static/js/update-watcher.js?v={{ asset_v }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+171
-15
@@ -4,10 +4,61 @@
|
||||
|
||||
{% block content %}
|
||||
<h1 class="page-title">Découvrir</h1>
|
||||
<p class="page-sub">Nouveautés de tes sources, incontournables et suggestions basées sur tes téléchargements.</p>
|
||||
<p class="page-sub">Explore par genre, nouveautés de tes sources, incontournables et suggestions basées sur tes téléchargements.</p>
|
||||
|
||||
<div x-data="discoverPage()" x-init="load()" x-cloak>
|
||||
|
||||
<!-- ------------------------------------------------ Explorer par genre -->
|
||||
<section class="rail-section explore-section" x-show="!loading && types.length > 0">
|
||||
<h2 class="rail-title">🎭 Explorer
|
||||
<span class="rail-source" x-text="exploreSubtitle"></span>
|
||||
</h2>
|
||||
<div class="explore-filters">
|
||||
<div class="chip-row" role="tablist" aria-label="Type de contenu">
|
||||
<template x-for="t in types" :key="t">
|
||||
<button class="genre-chip" :class="{ active: exploreType === t }" role="tab"
|
||||
:aria-selected="exploreType === t" @click="setType(t)"
|
||||
x-text="typeLabels[t]"></button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="chip-row" role="tablist" aria-label="Genre" x-show="currentGenres.length > 0">
|
||||
<template x-for="g in currentGenres" :key="g.key">
|
||||
<button class="genre-chip" :class="{ active: exploreGenre === g.key }" role="tab"
|
||||
:aria-selected="exploreGenre === g.key" @click="setGenre(g.key)"
|
||||
x-text="g.label"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rail-wrap" x-show="!exploreLoading">
|
||||
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
|
||||
@click="scrollRail($el, -1)">‹</button>
|
||||
<div class="rail">
|
||||
<template x-for="(item, i) in exploreItems" :key="'ex' + i + (item.source_id || item.kitsu_id || item.title)">
|
||||
<a class="card rail-card" :style="`--i:${i}`" :href="exploreHref(item)"
|
||||
:title="item.source ? item.title : `Rechercher « ${item.title} »`">
|
||||
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
|
||||
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
|
||||
<span class="card-chip" x-show="item.label" x-text="item.label"></span>
|
||||
<span class="card-chip" x-show="!item.label && item.rating" x-text="item.rating ? '★ ' + item.rating : ''"></span>
|
||||
<div class="card-overlay">
|
||||
<div class="card-title" x-text="item.title"></div>
|
||||
<div class="card-meta">
|
||||
<span class="badge badge-type" x-text="item.start_date ? item.start_date.slice(0, 4) : (item.year || '')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
<button class="rail-nav rail-next" type="button" aria-label="Défiler à droite"
|
||||
@click="scrollRail($el, 1)">›</button>
|
||||
</div>
|
||||
<div class="explore-skel" x-show="exploreLoading">
|
||||
<div class="rail rail-skel">
|
||||
<template x-for="i in 8" :key="'sk' + i"><div class="rail-card skel skel-card"></div></template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------ Skeletons de chargement -->
|
||||
<div x-show="loading">
|
||||
<section class="rail-section" x-data="{ n: 8 }">
|
||||
@@ -28,14 +79,14 @@
|
||||
<div class="login-error" x-text="error"></div>
|
||||
</template>
|
||||
|
||||
<!-- ------------------------------------------------ Nouveautés -->
|
||||
<section class="rail-section" x-show="!loading && latest.length > 0">
|
||||
<h2 class="rail-title">🆕 Nouveautés <span class="rail-source">de tes sources, triées par date de sortie</span></h2>
|
||||
<!-- ------------------------------------------------ Nouveautés animés -->
|
||||
<section class="rail-section" x-show="!loading && latestAnime.length > 0">
|
||||
<h2 class="rail-title">🆕 Nouveautés animés <span class="rail-source">triées par date de sortie</span></h2>
|
||||
<div class="rail-wrap">
|
||||
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
|
||||
@click="scrollRail($el, -1)">‹</button>
|
||||
<div class="rail">
|
||||
<template x-for="(item, i) in latest" :key="item.source + item.source_id">
|
||||
<template x-for="(item, i) in latestAnime" :key="item.source + item.source_id">
|
||||
<a class="card rail-card" :style="`--i:${i}`" :href="`/title/${item.source}/${encodeURIComponent(item.source_id)}`">
|
||||
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
|
||||
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
|
||||
@@ -56,9 +107,33 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------ Nouveautés séries & films -->
|
||||
<section class="rail-section" x-show="!loading && latestSerie.length > 0">
|
||||
<h2 class="rail-title">🆕 Nouveautés séries & films <span class="rail-source">ajouts récents de French-Stream</span></h2>
|
||||
<div class="rail-wrap">
|
||||
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
|
||||
@click="scrollRail($el, -1)">‹</button>
|
||||
<div class="rail">
|
||||
<template x-for="(item, i) in latestSerie" :key="item.source + item.source_id">
|
||||
<a class="card rail-card" :style="`--i:${i}`" :href="`/title/${item.source}/${encodeURIComponent(item.source_id)}`">
|
||||
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
|
||||
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
|
||||
<span class="card-chip" x-text="item.label"></span>
|
||||
<span class="card-year" x-text="item.media_type === 'film' ? 'Film' : ''"></span>
|
||||
<div class="card-overlay">
|
||||
<div class="card-title" x-text="item.title"></div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
<button class="rail-nav rail-next" type="button" aria-label="Défiler à droite"
|
||||
@click="scrollRail($el, 1)">›</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------ Incontournables -->
|
||||
<section class="rail-section" x-show="!loading && mustWatch.length > 0">
|
||||
<h2 class="rail-title">🔥 Incontournables <span class="rail-source">les classiques les mieux notés</span></h2>
|
||||
<h2 class="rail-title">🔥 Incontournables animés <span class="rail-source">les classiques les mieux notés</span></h2>
|
||||
<div class="rail-wrap">
|
||||
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
|
||||
@click="scrollRail($el, -1)">‹</button>
|
||||
@@ -81,11 +156,26 @@
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------ Pour toi -->
|
||||
<section class="rail-section" x-show="!loading && forYou.items.length > 0">
|
||||
<section class="rail-section" x-show="!loading && (forYou.items.length > 0 || forYou.cold_start)">
|
||||
<h2 class="rail-title">✨ Pour toi
|
||||
<span class="rail-source" x-show="forYou.based_on.length"
|
||||
x-text="forYou.based_on.length ? 'parce que tu aimes : ' + forYou.based_on.join(', ') : ''"></span>
|
||||
</h2>
|
||||
|
||||
|
||||
<!-- Aucun historique : carte d'amorçage au lieu d'une section muette -->
|
||||
<div class="cta-card" x-show="!loading && forYou.cold_start">
|
||||
<div class="cta-emoji">🧭</div>
|
||||
<div class="cta-body">
|
||||
<div class="cta-title">On ne te connaît pas encore…</div>
|
||||
<p>Télécharge tes premiers animés ou séries, ou ajoute des favoris :
|
||||
« Pour toi » s'ajustera automatiquement à tes genres préférés.</p>
|
||||
<div class="cta-actions">
|
||||
<a class="cta-btn" href="/">Chercher un premier titre</a>
|
||||
<a class="cta-btn ghost" href="/discover">Explorer par genre</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rail-wrap">
|
||||
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
|
||||
@click="scrollRail($el, -1)">‹</button>
|
||||
@@ -107,7 +197,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template x-if="!loading && !error && latest.length === 0 && mustWatch.length === 0 && forYou.items.length === 0">
|
||||
<template x-if="!loading && !error && latestAnime.length === 0 && latestSerie.length === 0 && mustWatch.length === 0 && forYou.items.length === 0 && exploreItems.length === 0">
|
||||
<div class="empty-state"><div class="big">🏜️</div>Rien à afficher pour le moment — réessaie plus tard.</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -117,23 +207,89 @@
|
||||
<script>
|
||||
function discoverPage() {
|
||||
return {
|
||||
latest: [], mustWatch: [], forYou: { based_on: [], items: [] },
|
||||
latestAnime: [], latestSerie: [], mustWatch: [], forYou: { based_on: [], items: [] },
|
||||
genres: {}, exploreType: null, exploreGenre: null,
|
||||
exploreItems: [], exploreLoading: false,
|
||||
loading: true, error: null,
|
||||
typeLabels: { anime: '⛩ Animés', serie: '📺 Séries', film: '🎬 Films' },
|
||||
|
||||
get types() { return Object.keys(this.genres); },
|
||||
get currentGenres() { return this.genres[this.exploreType] || []; },
|
||||
get exploreSubtitle() {
|
||||
const genre = (this.currentGenres.find(g => g.key === this.exploreGenre) || {}).label;
|
||||
return genre ? `${this.typeLabels[this.exploreType] || ''} · ${genre}` : 'choisis un genre';
|
||||
},
|
||||
|
||||
scrollRail(el, dir) {
|
||||
const rail = el.closest('.rail-wrap').querySelector('.rail');
|
||||
rail.scrollBy({ left: dir * rail.clientWidth * 0.85, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
exploreHref(item) {
|
||||
return item.source
|
||||
? `/title/${item.source}/${encodeURIComponent(item.source_id)}`
|
||||
: `/?q=${encodeURIComponent(item.title)}`;
|
||||
},
|
||||
|
||||
setType(type) {
|
||||
this.exploreType = type;
|
||||
this.exploreGenre = (this.genres[type][0] || {}).key || null;
|
||||
this.browse();
|
||||
},
|
||||
|
||||
setGenre(genre) {
|
||||
this.exploreGenre = genre;
|
||||
this.browse();
|
||||
},
|
||||
|
||||
async browse() {
|
||||
if (!this.exploreType || !this.exploreGenre) return;
|
||||
this.exploreLoading = true;
|
||||
const params = new URLSearchParams({ type: this.exploreType, genre: this.exploreGenre });
|
||||
const url = `${location.pathname}?${params}`;
|
||||
history.replaceState(null, '', url); // état partageable
|
||||
try {
|
||||
const res = await fetch(`/api/discover/browse?${params}`);
|
||||
if (!res.ok) throw new Error('Erreur ' + res.status);
|
||||
this.exploreItems = (await res.json()).items;
|
||||
} catch (e) {
|
||||
this.exploreItems = [];
|
||||
console.error('Explorer :', e);
|
||||
} finally {
|
||||
this.exploreLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async load() {
|
||||
this.loading = true; this.error = null;
|
||||
try {
|
||||
const res = await fetch('/api/discover');
|
||||
if (!res.ok) throw new Error('Erreur ' + res.status);
|
||||
const data = await res.json();
|
||||
this.latest = data.latest;
|
||||
const [discoverRes, genresRes] = await Promise.all([
|
||||
fetch('/api/discover'), fetch('/api/discover/genres'),
|
||||
]);
|
||||
if (!discoverRes.ok) throw new Error('Erreur ' + discoverRes.status);
|
||||
const data = await discoverRes.json();
|
||||
this.latestAnime = data.latest_anime;
|
||||
this.latestSerie = data.latest_serie;
|
||||
this.mustWatch = data.must_watch;
|
||||
this.forYou = data.for_you;
|
||||
} catch (e) { this.error = e.message; }
|
||||
finally { this.loading = false; }
|
||||
this.genres = await genresRes.json();
|
||||
|
||||
// État initial : paramètres d'URL (?t=&g=) sinon premier type/genre dispo
|
||||
const params = new URLSearchParams(location.search);
|
||||
let type = params.get('t');
|
||||
if (!this.genres[type]) type = this.types[0];
|
||||
let genre = params.get('g');
|
||||
if (!(this.genres[type] || []).some(g => g.key === genre)) {
|
||||
genre = ((this.genres[type] || [])[0] || {}).key;
|
||||
}
|
||||
this.exploreType = type || null;
|
||||
this.exploreGenre = genre || null;
|
||||
await this.browse();
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+129
-19
@@ -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,12 +22,38 @@
|
||||
<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-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 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.title"></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>
|
||||
@@ -44,6 +70,17 @@
|
||||
<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">
|
||||
@@ -64,6 +101,9 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
@@ -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') {
|
||||
},
|
||||
|
||||
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();
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="title-stats">
|
||||
<span x-show="details.rating"><span class="rating-star">★</span> <strong x-text="details.rating"></strong>/10</span>
|
||||
<span x-show="details.year">Année : <strong x-text="details.year"></strong></span>
|
||||
<span><strong x-text="details.episodes.length"></strong> épisode(s)</span>
|
||||
<span><strong x-text="uniqueEpisodeCount"></strong> épisode(s)</span>
|
||||
</div>
|
||||
<div class="title-actions">
|
||||
<button class="btn" @click="downloadSeason" :disabled="seasonJob.running">
|
||||
@@ -43,11 +43,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Épisodes</h2>
|
||||
<h2 class="section-title">Épisodes
|
||||
<span class="version-filter" x-show="epVersions.length" x-cloak>
|
||||
<template x-for="v in epVersions" :key="v">
|
||||
<button class="version-tab" :class="{ active: versionFilter === v }"
|
||||
@click="versionFilter = v" x-text="v.toUpperCase()"></button>
|
||||
</template>
|
||||
<button class="version-tab" :class="{ active: versionFilter === 'all' }"
|
||||
@click="versionFilter = 'all'">Tout</button>
|
||||
</span>
|
||||
</h2>
|
||||
<div class="episode-list">
|
||||
<template x-for="ep in details.episodes" :key="ep.number">
|
||||
<template x-for="ep in filteredEpisodes" :key="ep.number + '-' + (ep.version || '')">
|
||||
<div class="episode-row">
|
||||
<span class="episode-num" x-text="'E' + ep.number"></span>
|
||||
<span class="badge badge-version" x-show="ep.version" x-text="ep.version.toUpperCase()"></span>
|
||||
<span class="episode-name" x-text="ep.title || `Épisode ${ep.number}`"></span>
|
||||
<button class="btn btn-sm btn-ghost" @click="streamEpisode(ep)" :disabled="ep._busy">▶ Stream</button>
|
||||
<button class="btn btn-sm" @click="downloadEpisode(ep)" :disabled="ep._busy">⬇</button>
|
||||
@@ -76,6 +86,23 @@ function titlePage() {
|
||||
details: null, loading: true, error: null, favorite: false,
|
||||
player: { active: false, hls: null },
|
||||
seasonJob: { running: false, done: 0, total: 0 },
|
||||
versionFilter: 'all',
|
||||
|
||||
get epVersions() {
|
||||
if (!this.details) return [];
|
||||
return [...new Set(this.details.episodes.map(e => e.version).filter(Boolean))];
|
||||
},
|
||||
|
||||
get filteredEpisodes() {
|
||||
if (!this.details) return [];
|
||||
if (this.versionFilter === 'all') return this.details.episodes;
|
||||
return this.details.episodes.filter(e => e.version === this.versionFilter);
|
||||
},
|
||||
|
||||
get uniqueEpisodeCount() {
|
||||
if (!this.details) return 0;
|
||||
return new Set(this.details.episodes.map(e => e.number)).size;
|
||||
},
|
||||
|
||||
async load(source, sourceId) {
|
||||
this.source = source; this.sourceId = sourceId;
|
||||
@@ -84,6 +111,7 @@ function titlePage() {
|
||||
if (!res.ok) throw new Error((await res.json()).detail || 'Erreur ' + res.status);
|
||||
this.details = await res.json();
|
||||
this.details.episodes.forEach(e => e._busy = false);
|
||||
this.versionFilter = this.epVersions.includes('vostfr') ? 'vostfr' : 'all';
|
||||
document.title = this.details.title + ' — Ohm Stream';
|
||||
} catch (e) { this.error = e.message; }
|
||||
finally { this.loading = false; }
|
||||
@@ -98,7 +126,10 @@ function titlePage() {
|
||||
return data.links[0];
|
||||
},
|
||||
|
||||
epLabel(ep) { return `${this.details.title} - E${ep.number}`; },
|
||||
epLabel(ep) {
|
||||
const version = ep.version ? ` (${ep.version.toUpperCase()})` : '';
|
||||
return `${this.details.title} - E${ep.number}${version}`;
|
||||
},
|
||||
|
||||
// Lecture via le proxy serveur (tokens liés à l'IP, Referer obligatoire)
|
||||
openPlayer(link) {
|
||||
@@ -148,8 +179,8 @@ function titlePage() {
|
||||
},
|
||||
|
||||
async downloadSeason() {
|
||||
this.seasonJob = { running: true, done: 0, total: this.details.episodes.length };
|
||||
for (const ep of this.details.episodes) {
|
||||
this.seasonJob = { running: true, done: 0, total: this.filteredEpisodes.length };
|
||||
for (const ep of this.filteredEpisodes) {
|
||||
await this.downloadEpisode(ep);
|
||||
this.seasonJob.done++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Version de l'application.
|
||||
|
||||
Source de vérité : `version` de pyproject.toml (lue via les métadonnées du paquet
|
||||
installé par uv). Dans l'image Docker, `OHM_VERSION` est cuit au build par
|
||||
scripts/release.sh et prime sur tout.
|
||||
"""
|
||||
|
||||
import importlib.metadata
|
||||
import os
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
v = os.environ.get("OHM_VERSION")
|
||||
if v:
|
||||
return v
|
||||
try:
|
||||
return importlib.metadata.version("ohm-stream")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return "dev"
|
||||
@@ -0,0 +1,57 @@
|
||||
# Déploiement OhmStreaming — https://git.lanro.eu/Roman/ohm_streaming
|
||||
#
|
||||
# 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
|
||||
|
||||
services:
|
||||
ohm:
|
||||
image: git.lanro.eu/roman/ohm_streaming:latest
|
||||
# Build local (dev) : décommenter ces lignes et commenter « image: » ci-dessus
|
||||
# build:
|
||||
# context: .
|
||||
# args:
|
||||
# VERSION: dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${OHM_PORT:-8777}:8777"
|
||||
env_file: .env
|
||||
environment:
|
||||
# Chemins internes (volumes ci-dessous) — ne pas changer
|
||||
OHM_DATA_DIR: /data
|
||||
OHM_DOWNLOAD_DIR: /downloads
|
||||
OHM_DATABASE_PATH: /data/ohm.db
|
||||
# Watchtower compagnon sur le réseau interne compose
|
||||
OHM_WATCHTOWER_URL: http://watchtower:8080
|
||||
OHM_WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:?WATCHTOWER_TOKEN manquant — voir .env.example}
|
||||
volumes:
|
||||
- ./data:/data # base SQLite, persiste entre les mises à jour
|
||||
# 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)
|
||||
watchtower:
|
||||
image: containrrr/watchtower
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
# Pas de vérification périodique : uniquement sur appel de l'admin
|
||||
WATCHTOWER_HTTP_API_UPDATE: "true"
|
||||
WATCHTOWER_HTTP_API_TOKEN: ${WATCHTOWER_TOKEN:?WATCHTOWER_TOKEN manquant — voir .env.example}
|
||||
# Ne mettre à jour que les conteneurs étiquetés (ohm), pas watchtower lui-même
|
||||
WATCHTOWER_LABEL_ENABLE: "true"
|
||||
# Compatibilité Docker récent (l'image watchtower négocie une API trop ancienne)
|
||||
DOCKER_API_VERSION: "1.44"
|
||||
# Supprimer les anciennes images après mise à jour
|
||||
WATCHTOWER_CLEANUP: "true"
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
# 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 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
|
||||
|
||||
exec "$@"
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "ohm-stream"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = "Ohm Stream Downloader — centre de contrôle auto-hébergé pour animes et séries VOSTFR"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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 ["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> [patchnote] (ex : 0.2.0)}"
|
||||
REGISTRY="${OHM_REGISTRY:-git.lanro.eu/roman/ohm_streaming}"
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "✖ « $VERSION » n'est pas un semver X.Y.Z"; exit 1; }
|
||||
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "✖ Dépôt sale — committez ou rangez vos modifications avant de publier :"
|
||||
git status --short
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="v$VERSION"
|
||||
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
||||
echo "✖ Le tag $TAG existe déjà"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "▶ 1/3 Version $VERSION dans pyproject.toml"
|
||||
uv version "$VERSION"
|
||||
git add pyproject.toml uv.lock
|
||||
git commit -m "v$VERSION"
|
||||
|
||||
echo "▶ 2/3 Tag et push git"
|
||||
git tag -a "$TAG" -m "${2:-$TAG}"
|
||||
git push origin HEAD
|
||||
git push origin "$TAG"
|
||||
|
||||
echo "▶ 3/3 Image Docker → $REGISTRY"
|
||||
docker build --build-arg VERSION="$VERSION" -t "$REGISTRY:$VERSION" -t "$REGISTRY:latest" .
|
||||
docker push "$REGISTRY:$VERSION"
|
||||
docker push "$REGISTRY:latest"
|
||||
|
||||
echo
|
||||
echo "✔ v$VERSION publiée ($REGISTRY:$VERSION et :latest)"
|
||||
echo " Les instances la détecteront via Admin → Mise à jour → « Vérifier »."
|
||||
@@ -38,3 +38,25 @@ async def database() -> AsyncIterator[None]:
|
||||
await db.execute(f"DELETE FROM {table}")
|
||||
yield
|
||||
await db.close()
|
||||
|
||||
# ---------------------------------------------------------------- client HTTP partagé
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncClient:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_cookies(client: AsyncClient):
|
||||
"""Premier compte créé → administrateur, cookies de session."""
|
||||
r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"})
|
||||
assert r.status_code == 303
|
||||
return r.cookies
|
||||
|
||||
+98
-18
@@ -1,25 +1,8 @@
|
||||
"""Tests d'intégration API (auth, downloads, favoris, streaming, admin)."""
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import db
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncClient:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_cookies(client: AsyncClient):
|
||||
r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"})
|
||||
assert r.status_code == 303
|
||||
return r.cookies
|
||||
from app.scrapers.base import ScrapeError, SearchResult
|
||||
|
||||
|
||||
async def test_health(client):
|
||||
@@ -86,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"})
|
||||
@@ -121,3 +119,85 @@ async def test_source_toggle(client, admin_cookies):
|
||||
states = {s["name"]: s["enabled"] for s in r.json()}
|
||||
assert states["vostfree"] is False
|
||||
assert states["french_manga"] is True
|
||||
|
||||
|
||||
async def test_source_health(client, admin_cookies, monkeypatch):
|
||||
# source inconnue → 404 (pas de 500)
|
||||
r = await client.post("/api/admin/sources/inconnue/health", cookies=admin_cookies)
|
||||
assert r.status_code == 404
|
||||
|
||||
# état initial : aucune santé connue
|
||||
r = await client.get("/api/admin/sources", cookies=admin_cookies)
|
||||
assert all(s["health"] is None for s in r.json())
|
||||
|
||||
async def ok_search(self, query: str) -> list[SearchResult]:
|
||||
return [SearchResult(source=self.name, source_id="x", title="X", url="http://x")]
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.vostfree.VostfreeScraper.search", ok_search)
|
||||
r = await client.post("/api/admin/sources/vostfree/health", cookies=admin_cookies)
|
||||
data = r.json()
|
||||
assert data["healthy"] is True and data["detail"] == "1 résultats" and data["checked_at"]
|
||||
|
||||
# résultat persisté et exposé par GET /sources
|
||||
r = await client.get("/api/admin/sources", cookies=admin_cookies)
|
||||
health = {s["name"]: s["health"] for s in r.json()}
|
||||
assert health["vostfree"]["healthy"] is True
|
||||
assert health["french_manga"] is None
|
||||
|
||||
async def failing_search(self, query: str) -> list[SearchResult]:
|
||||
raise ScrapeError("site indisponible")
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.vostfree.VostfreeScraper.search", failing_search)
|
||||
r = await client.post("/api/admin/sources/vostfree/health", cookies=admin_cookies)
|
||||
data = r.json()
|
||||
assert r.status_code == 200 and data["healthy"] is False and data["detail"] == "site indisponible"
|
||||
|
||||
async def broken_search(self, query: str) -> list[SearchResult]:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.vostfree.VostfreeScraper.search", broken_search)
|
||||
r = await client.post("/api/admin/sources/vostfree/health", cookies=admin_cookies)
|
||||
data = r.json()
|
||||
assert r.status_code == 200 and data["healthy"] is False and "boom" in data["detail"]
|
||||
|
||||
|
||||
async def test_source_toggle_unknown(client, admin_cookies):
|
||||
r = await client.post(
|
||||
"/api/admin/sources/inconnue/toggle", json={"enabled": True}, cookies=admin_cookies
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_source_url_override(client, admin_cookies):
|
||||
from app.scrapers.base import get_source
|
||||
|
||||
r = await client.get("/api/admin/sources", cookies=admin_cookies)
|
||||
src = next(s for s in r.json() if s["name"] == "vostfree")
|
||||
default = src["default_base_url"]
|
||||
assert src["base_url"] == default and src["overridden"] is False
|
||||
|
||||
# source inconnue → 404
|
||||
r = await client.put("/api/admin/sources/inconnue/url", json={"url": "https://x.org"}, cookies=admin_cookies)
|
||||
assert r.status_code == 404
|
||||
|
||||
# URL invalide → 422
|
||||
r = await client.put("/api/admin/sources/vostfree/url", json={"url": "pas-une-url"}, cookies=admin_cookies)
|
||||
assert r.status_code == 422
|
||||
|
||||
# changement de domaine (slash final toléré) → appliqué à l'instance + persisté
|
||||
r = await client.put("/api/admin/sources/vostfree/url", json={"url": "https://exemple.org/"},
|
||||
cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["base_url"] == "https://exemple.org" and data["overridden"] is True
|
||||
assert get_source("vostfree").base_url == "https://exemple.org"
|
||||
|
||||
r = await client.get("/api/admin/sources", cookies=admin_cookies)
|
||||
src = next(s for s in r.json() if s["name"] == "vostfree")
|
||||
assert src["base_url"] == "https://exemple.org" and src["overridden"] is True
|
||||
|
||||
# URL vide → retour au défaut du code
|
||||
r = await client.put("/api/admin/sources/vostfree/url", json={"url": ""}, cookies=admin_cookies)
|
||||
data = r.json()
|
||||
assert data["base_url"] == default and data["overridden"] is False
|
||||
assert get_source("vostfree").base_url == default
|
||||
|
||||
+70
-1
@@ -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
|
||||
|
||||
+177
-19
@@ -154,7 +154,7 @@ async def test_latest_merges_and_sorts_by_release_date(monkeypatch):
|
||||
|
||||
service = DiscoverService()
|
||||
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
|
||||
result = await service.latest(limit=10)
|
||||
result = (await service.latest_by_type(limit=10))["anime"]
|
||||
|
||||
titles = [item["title"] for item in result]
|
||||
assert titles.count("Frieren VOSTFR") + titles.count("Frieren - Saison 1") == 1 # dédoublonné
|
||||
@@ -186,11 +186,52 @@ async def test_latest_orders_by_kitsu_start_date(monkeypatch):
|
||||
|
||||
service = DiscoverService()
|
||||
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
|
||||
result = await service.latest(limit=10)
|
||||
result = (await service.latest_by_type(limit=10))["anime"]
|
||||
assert [item["title"] for item in result] == ["Anime 1", "Anime 0", "Anime 2"]
|
||||
assert result[0]["rating"] is None
|
||||
|
||||
|
||||
async def test_latest_by_type_keeps_every_type(monkeypatch):
|
||||
"""Chaque type garde son rail complet : les animés nombreux ne vicient pas
|
||||
le rail séries (les séries n'existent pas chez Kitsu, donc sans date)."""
|
||||
import app.services.discover as discover_module
|
||||
|
||||
animes = [
|
||||
SearchResult(source="vostfree", source_id=str(n), title=f"Animé {n}", url=f"https://a/{n}")
|
||||
for n in range(1, 31)
|
||||
]
|
||||
series = [
|
||||
SearchResult(
|
||||
source="french_stream", source_id=str(n), title=f"Série {n}",
|
||||
url=f"https://s/{n}", media_type="serie",
|
||||
)
|
||||
for n in range(1, 10)
|
||||
]
|
||||
|
||||
class AnimeSource(_FakeSource):
|
||||
name, label, base_url, media_types = "vostfree", "Vostfree", "https://a", ("anime",)
|
||||
|
||||
class SerieSource(_FakeSource):
|
||||
name, label, base_url, media_types = "french_stream", "French-Stream", "https://s", ("serie", "film")
|
||||
|
||||
monkeypatch.setattr(discover_module, "all_sources", lambda: [AnimeSource(animes), SerieSource(series)])
|
||||
|
||||
async def enabled(name: str) -> bool:
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(discover_module, "is_source_enabled", enabled)
|
||||
|
||||
async def fake_match(title: str):
|
||||
return None # rien chez Kitsu → aucune date de sortie
|
||||
|
||||
service = DiscoverService()
|
||||
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
|
||||
|
||||
rails = await service.latest_by_type(limit=24)
|
||||
assert len(rails["anime"]) == 24 # tronqué à la limite
|
||||
assert len(rails["serie"]) == 9 # les 9 séries intactes, malgré 30 animés
|
||||
assert all(item["media_type"] == "serie" for item in rails["serie"])
|
||||
|
||||
async def test_latest_skips_broken_source_and_uses_cache(monkeypatch):
|
||||
"""Une source en échec disparaît sans erreur, et le TTL évite les re-scrapes."""
|
||||
import app.services.discover as discover_module
|
||||
@@ -214,8 +255,8 @@ async def test_latest_skips_broken_source_and_uses_cache(monkeypatch):
|
||||
monkeypatch.setattr(discover_module, "is_source_enabled", enabled)
|
||||
|
||||
service = DiscoverService()
|
||||
assert await service.latest() == []
|
||||
assert await service.latest() == []
|
||||
assert await service.latest_by_type() == {"anime": [], "serie": [], "film": []}
|
||||
assert await service.latest_by_type() == {"anime": [], "serie": [], "film": []}
|
||||
assert calls["n"] == 1 # deuxième appel servi depuis le cache TTL
|
||||
|
||||
|
||||
@@ -256,16 +297,15 @@ async def history(monkeypatch):
|
||||
return ["Fantasy", "Adventure"] if anime_id == "46474" else []
|
||||
|
||||
async def fake_kitsu_anime(params: dict) -> list[dict]:
|
||||
slugs = frozenset(params["filter[categories]"].split(","))
|
||||
assert params["sort"] == "-userCount"
|
||||
catalog = {
|
||||
frozenset(["fantasy", "adventure", "action", "comedy"]): [
|
||||
# Une catégorie par requête (le cumul Kitsu est un ET trop restrictif)
|
||||
assert "," not in params["filter[categories]"]
|
||||
return [
|
||||
{"title": "Helck", "kitsu_id": "999"},
|
||||
{"title": "Sousou no Frieren", "kitsu_id": "46474"},
|
||||
{"title": "Konosuba", "kitsu_id": "1"},
|
||||
],
|
||||
}
|
||||
return catalog.get(slugs, [])
|
||||
]
|
||||
|
||||
|
||||
service = DiscoverService()
|
||||
service.test_user_id = user_id # pour les assertions du test
|
||||
@@ -284,11 +324,78 @@ async def test_for_you_aggregates_genres_and_excludes_owned(history):
|
||||
assert "Sousou no Frieren" in titles
|
||||
assert "Konosuba" in titles
|
||||
|
||||
async def test_for_you_excludes_owned_with_episode_markers(monkeypatch):
|
||||
"""Régression : « Titre - Saison 1 - E3 » possédé doit exclure le « Titre »
|
||||
canonique recommandé (la normalisation retire saison ET épisode, en boucle)."""
|
||||
cursor = await db.execute(
|
||||
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
||||
("reg-user", "x" * 64),
|
||||
)
|
||||
user_id = cursor.lastrowid
|
||||
await db.execute(
|
||||
"INSERT INTO downloads (source_key, video_url, title, status) VALUES (?,?,?,?)",
|
||||
("kr", "https://v/r", "The Eminence in Shadow - Saison 1 - E3", "done"),
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO favorites (user_id, source, source_id, title, image_url, payload) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(user_id, "vostfree", "r", "Favori R", None, '{"genres": ["Fantasy"]}'),
|
||||
)
|
||||
|
||||
async def test_for_you_empty_without_history(monkeypatch):
|
||||
async def fake_match(title: str):
|
||||
return None # genres apportés par le favori
|
||||
|
||||
async def fake_kitsu_anime(params: dict) -> list[dict]:
|
||||
return [
|
||||
{"title": "The Eminence in Shadow", "kitsu_id": "1"},
|
||||
{"title": "Autre Anime", "kitsu_id": "2"},
|
||||
]
|
||||
|
||||
service = DiscoverService()
|
||||
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
|
||||
monkeypatch.setattr(service, "_kitsu_anime", fake_kitsu_anime)
|
||||
result = await service.for_you(user_id=user_id, limit=10)
|
||||
titles = [item["title"] for item in result["items"]]
|
||||
assert "The Eminence in Shadow" not in titles # possédé (« - Saison 1 - E3 »)
|
||||
assert "Autre Anime" in titles
|
||||
|
||||
async def test_for_you_cold_start_without_history(monkeypatch):
|
||||
"""Aucun téléchargement/favori/Sonarr : amorçage signalé (non caché)."""
|
||||
service = DiscoverService()
|
||||
result = await service.for_you(user_id=42)
|
||||
assert result == {"based_on": [], "items": []}
|
||||
assert result == {"based_on": [], "items": [], "cold_start": True}
|
||||
assert service._cache.get("for_you:42:20") is None # pas de cache : bon marché, change au 1er téléchargement
|
||||
|
||||
async def test_for_you_uses_series_page_genres_without_sonarr(monkeypatch):
|
||||
"""Sans Sonarr, les séries téléchargées donnent leurs genres via leur fiche
|
||||
source (libellés FR convertis en catégories Kitsu)."""
|
||||
cursor = await db.execute(
|
||||
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
||||
("serie-user", "x" * 64),
|
||||
)
|
||||
user_id = cursor.lastrowid
|
||||
await db.execute(
|
||||
"INSERT INTO downloads (source_key, video_url, page_url, title, status) VALUES (?,?,?,?,?)",
|
||||
("k9", "https://v/9", "https://french-stream.lat/15138132-stat-saison-5.html",
|
||||
"STAT - Saison 5", "done"),
|
||||
)
|
||||
|
||||
async def fake_no_match(title: str):
|
||||
return None # série réelle : aucun match Kitsu
|
||||
|
||||
async def fake_genres_of_page(self, page_url: str):
|
||||
assert "15138132" in page_url
|
||||
return ["Drame", "Médical"] # Médical : sans équivalent Kitsu → ignoré
|
||||
|
||||
service = DiscoverService()
|
||||
monkeypatch.setattr(service._kitsu, "search_anime", fake_no_match)
|
||||
monkeypatch.setattr(
|
||||
"app.scrapers.sources.french_stream.FrenchStreamScraper.genres_of_page",
|
||||
fake_genres_of_page,
|
||||
)
|
||||
result = await service.for_you(user_id=user_id, limit=10)
|
||||
assert result["based_on"] == ["Drama"] # « Drame » converti, « Médical » écarté
|
||||
assert result["items"] # des recommandations issues de la catégorie drama
|
||||
|
||||
|
||||
|
||||
@@ -336,11 +443,18 @@ async def test_api_discover_requires_auth(client):
|
||||
|
||||
|
||||
async def test_api_discover_sections(client, admin_cookies, monkeypatch):
|
||||
async def fake_latest(limit: int = 24):
|
||||
return [
|
||||
async def fake_latest_by_type(limit: int = 24):
|
||||
return {
|
||||
"anime": [
|
||||
{"source": "vostfree", "label": "Vostfree", "source_id": "a", "title": "T",
|
||||
"start_date": "2026-01-01", "status": "current", "rating": 8.1},
|
||||
]
|
||||
"media_type": "anime", "start_date": "2026-01-01", "status": "current", "rating": 8.1},
|
||||
],
|
||||
"serie": [
|
||||
{"source": "french_stream", "label": "French-Stream", "source_id": "b",
|
||||
"title": "S", "media_type": "serie"},
|
||||
],
|
||||
"film": [],
|
||||
}
|
||||
|
||||
async def fake_must_watch(limit: int = 20):
|
||||
return [{"kitsu_id": "1", "title": "Attack on Titan", "rating": 8.5}]
|
||||
@@ -348,19 +462,63 @@ async def test_api_discover_sections(client, admin_cookies, monkeypatch):
|
||||
async def fake_for_you(user_id: int, limit: int = 20):
|
||||
return {"based_on": ["Action"], "items": [{"kitsu_id": "2", "title": "X"}]}
|
||||
|
||||
monkeypatch.setattr(discover, "latest", fake_latest)
|
||||
monkeypatch.setattr(discover, "latest_by_type", fake_latest_by_type)
|
||||
monkeypatch.setattr(discover, "must_watch", fake_must_watch)
|
||||
monkeypatch.setattr(discover, "for_you", fake_for_you)
|
||||
|
||||
r = await client.get("/api/discover", cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["latest"][0]["label"] == "Vostfree"
|
||||
assert data["latest"][0]["start_date"] == "2026-01-01"
|
||||
assert data["latest_anime"][0]["label"] == "Vostfree"
|
||||
assert data["latest_anime"][0]["start_date"] == "2026-01-01"
|
||||
assert data["latest_serie"][0]["label"] == "French-Stream"
|
||||
assert data["must_watch"][0]["title"] == "Attack on Titan"
|
||||
assert data["for_you"]["based_on"] == ["Action"]
|
||||
|
||||
|
||||
async def test_api_browse(client, admin_cookies, monkeypatch):
|
||||
"""Explorer : animés via Kitsu, séries via French-Stream, hors préférence → vide."""
|
||||
|
||||
async def fake_browse_anime(genre: str, limit: int = 20):
|
||||
assert genre == "comedy"
|
||||
return [{"kitsu_id": "1", "title": "Nichijou"}]
|
||||
|
||||
async def fake_browse_serie_film(media_type: str, genre: str, limit: int = 24):
|
||||
assert (media_type, genre) == ("serie", "medical")
|
||||
return [{"source": "french_stream", "source_id": "9", "title": "STAT - Saison 5",
|
||||
"media_type": "serie"}]
|
||||
|
||||
monkeypatch.setattr(discover, "browse_anime", fake_browse_anime)
|
||||
monkeypatch.setattr(discover, "browse_serie_film", fake_browse_serie_film)
|
||||
|
||||
r = await client.get(
|
||||
"/api/discover/browse", params={"type": "anime", "genre": "comedy"}, cookies=admin_cookies
|
||||
)
|
||||
assert r.json()["items"][0]["title"] == "Nichijou"
|
||||
|
||||
r = await client.get(
|
||||
"/api/discover/browse", params={"type": "serie", "genre": "medical"}, cookies=admin_cookies
|
||||
)
|
||||
assert r.json()["items"][0]["title"] == "STAT - Saison 5"
|
||||
|
||||
# Préférence animés : le parcours séries renvoie vide sans même scraper
|
||||
await client.put("/auth/preferences", json={"content_preference": "anime"}, cookies=admin_cookies)
|
||||
r = await client.get(
|
||||
"/api/discover/browse", params={"type": "serie", "genre": "medical"}, cookies=admin_cookies
|
||||
)
|
||||
assert r.json()["items"] == []
|
||||
|
||||
|
||||
async def test_api_genres_catalog(client, admin_cookies):
|
||||
"""Catalogue de genres : animés + séries/films French-Stream en mode « les deux »."""
|
||||
r = await client.get("/api/discover/genres", cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
catalog = r.json()
|
||||
assert {"anime", "serie", "film"} <= set(catalog)
|
||||
assert {"key": "thriller", "label": "Thriller"} in catalog["serie"]
|
||||
assert any(g["key"] == "comedies" for g in catalog["film"])
|
||||
|
||||
|
||||
async def test_discover_page_renders(client, admin_cookies):
|
||||
r = await client.get("/discover", cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Tests du scraper French-Stream (séries/films VF-VOSTFR) sur fixtures réelles."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.scrapers.base import ScrapeError, get_source, import_all_scrapers
|
||||
from app.scrapers.sources.french_stream import FrenchStreamScraper
|
||||
|
||||
# ------------------------------------------------------------ fixtures
|
||||
|
||||
SEARCH_HTML = """
|
||||
<div class='search-item' onclick="location.href='/9562-breaking-bad-saison-2-streaming-complet-vf-vostfr.html'">
|
||||
<div class='search-poster'><img src='https://image.tmdb.org/t/p/w400/bb.jpg' alt='Breaking Bad - Saison 2'></div>
|
||||
<div class='search-info'><div class='search-title'>Breaking Bad - Saison 2</div></div>
|
||||
</div>
|
||||
<div class='search-item' onclick="location.href='/1022-inception-streaming-complet-vf-vostfr.html'">
|
||||
<div class='search-poster'><img src='https://image.tmdb.org/t/p/w400/inc.jpg' alt='Inception'></div>
|
||||
<div class='search-info'><div class='search-title'>Inception (2010)</div></div>
|
||||
</div>
|
||||
<div class='search-item'><div class='search-info'><div class='search-title'>Sans lien</div></div></div>
|
||||
"""
|
||||
|
||||
SERIE_DETAILS_HTML = """
|
||||
<div id="film-data" data-newsid="9562" data-affiche="https://image.tmdb.org/t/p/w300/bb.jpg"></div>
|
||||
<div class="fmain"><div class="fcols fx-row"><div class="fmid">
|
||||
<h1 id="s-title">Breaking Bad - Saison 2 </h1>
|
||||
<div class="facts">
|
||||
<span class="certification">-12</span> - <span class="release">2008 -</span>
|
||||
<span class="genres">Drame, Crime</span> - <span class="runtime">48 min</span>
|
||||
</div>
|
||||
<div class="flist clearfix"><div class="fdesc"><p>Walter et Jesse montent leur propre affaire.</p></div></div>
|
||||
</div></div></div>
|
||||
<div class="fleft"><div class="fposter"><img src="https://image.tmdb.org/t/p/w400/bb.jpg" alt="Breaking Bad - Saison 2"></div></div>
|
||||
<div id="serie-config" style="display:none" data-title="Breaking Bad - Saison 2" data-news-id="9562"></div>
|
||||
"""
|
||||
|
||||
FILM_DETAILS_HTML = """
|
||||
<div id="film-data" data-newsid="1022" data-affiche="https://image.tmdb.org/t/p/w300/inc.jpg"></div>
|
||||
<div class="fmain"><div class="fcols fx-row"><div class="fmid">
|
||||
<h1 id="s-title"> Inception - 2010 <span class="tag release_date"></span> </h1>
|
||||
<div class="facts">
|
||||
<span class="genres"><a href="/index.php?do=xfsearch&xf=Action">Action</a><a href="/index.php?do=xfsearch&xf=Science-Fiction">Science-Fiction</a></span>
|
||||
<span class="runtime">- 2h28</span>
|
||||
</div>
|
||||
<div class="fdesc clearfix slice-this" id="s-desc">
|
||||
<p class="desc-text">Résumé du film Inception en streaming complet vf et vostfr hd vod gratuit</p>
|
||||
Dom Cobb est un voleur expérimenté.
|
||||
</div>
|
||||
</div></div></div>
|
||||
"""
|
||||
|
||||
EP_DATA = {
|
||||
"vf": {
|
||||
"1": {"vidzy": "https://vidzy.cc/embed-aaa.html", "uqload": "https://uqload.vc/embed-bbb.html"},
|
||||
"2": {"vidzy": "https://vidzy.cc/embed-ccc.html"},
|
||||
},
|
||||
"vostfr": {
|
||||
"1": {"vidzy": "https://vidzy.cc/embed-ddd.html"},
|
||||
"2": {"vidzy": "https://vidzy.cc/embed-eee.html"},
|
||||
},
|
||||
"vo": {},
|
||||
"info": {
|
||||
"1": {"title": "Traqués", "synopsis": "Walt et Jesse...", "poster": "https://img/ep1.jpg"},
|
||||
"2": {"title": "Chasse à l'homme"},
|
||||
},
|
||||
}
|
||||
|
||||
FILM_API = {
|
||||
"players": {
|
||||
"vidzy": {
|
||||
"default": "https://vidzy.live/embed-fff.html",
|
||||
"vff": "https://vidzy.live/embed-fff.html",
|
||||
"vostfr": "https://vidzy.live/embed-ggg.html",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LATEST_HTML = """
|
||||
<div class="short"><div class="short-in nl">
|
||||
<a class="short-poster img-box with-mask" href="/index.php?newsid=15137274" alt="The Drop - Saison 1">
|
||||
<img src="https://image.tmdb.org/t/p/w300/drop.jpg">
|
||||
</a>
|
||||
</div></div>
|
||||
<div class="short"><div class="short-in nl">
|
||||
<a class="short-poster img-box with-mask" href="/index.php?newsid=15139056" alt="La Rumeur">
|
||||
<img src="https://image.tmdb.org/t/p/w300/rumeur.jpg">
|
||||
</a>
|
||||
</div></div>
|
||||
"""
|
||||
|
||||
|
||||
def _soup(html: str):
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
return BeautifulSoup(html, "lxml")
|
||||
|
||||
|
||||
# ------------------------------------------------------------ source
|
||||
|
||||
|
||||
async def test_search(monkeypatch):
|
||||
async def fake_fetch(url, **kwargs):
|
||||
assert url.endswith("/engine/ajax/search.php")
|
||||
assert kwargs["data"] == {"query": "breaking bad", "page": "1"}
|
||||
return SEARCH_HTML
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch", fake_fetch)
|
||||
results = await FrenchStreamScraper().search("breaking bad")
|
||||
assert len(results) == 2
|
||||
serie, film = results
|
||||
assert serie.source_id == "9562"
|
||||
assert serie.media_type == "serie"
|
||||
assert serie.title == "Breaking Bad - Saison 2"
|
||||
assert serie.url == "https://french-stream.lat/9562-breaking-bad-saison-2-streaming-complet-vf-vostfr.html"
|
||||
assert serie.image_url == "https://image.tmdb.org/t/p/w400/bb.jpg"
|
||||
assert film.media_type == "film"
|
||||
assert film.source_id == "1022"
|
||||
|
||||
|
||||
async def test_latest(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
assert url.endswith("/series/")
|
||||
return _soup(LATEST_HTML)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch_soup", fake_fetch_soup)
|
||||
results = await FrenchStreamScraper().latest()
|
||||
assert [(r.source_id, r.media_type) for r in results] == [
|
||||
("15137274", "serie"),
|
||||
("15139056", "film"),
|
||||
]
|
||||
assert results[0].title == "The Drop - Saison 1"
|
||||
|
||||
|
||||
async def test_browse(monkeypatch):
|
||||
"""Parcours par genre : page du genre scrapée, type forcé sur les résultats."""
|
||||
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
assert url.endswith("/medical-series-/")
|
||||
return _soup(LATEST_HTML)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch_soup", fake_fetch_soup)
|
||||
results = await FrenchStreamScraper().browse("serie", "medical")
|
||||
# LATEST_HTML contient un film sans « Saison » : le type est forcé à serie
|
||||
assert [r.media_type for r in results] == ["serie", "serie"]
|
||||
assert results[0].title == "The Drop - Saison 1"
|
||||
|
||||
|
||||
async def test_browse_unknown_category():
|
||||
with pytest.raises(ScrapeError, match="Catégorie inconnue"):
|
||||
await FrenchStreamScraper().browse("film", "inexistant")
|
||||
|
||||
async def test_get_details_serie(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
assert "newsid=9562" in url
|
||||
return _soup(SERIE_DETAILS_HTML)
|
||||
|
||||
async def fake_fetch(url, **kwargs):
|
||||
assert "ep-data.php?id=9562" in url
|
||||
return json.dumps(EP_DATA)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch_soup", fake_fetch_soup)
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch", fake_fetch)
|
||||
details = await FrenchStreamScraper().get_details("9562")
|
||||
assert details.media_type == "serie"
|
||||
assert details.title == "Breaking Bad - Saison 2"
|
||||
assert details.year == 2008
|
||||
assert details.genres == ["Drame", "Crime"]
|
||||
assert details.synopsis == "Walter et Jesse montent leur propre affaire."
|
||||
assert details.image_url == "https://image.tmdb.org/t/p/w400/bb.jpg"
|
||||
assert details.episode_count == 4
|
||||
vf1 = details.episodes[0]
|
||||
assert (vf1.season, vf1.number, vf1.version, vf1.title) == (2, 1.0, "vf", "Traqués")
|
||||
assert vf1.url.endswith("#vf-1")
|
||||
versions = {(e.number, e.version) for e in details.episodes}
|
||||
assert versions == {(1.0, "vf"), (1.0, "vostfr"), (2.0, "vf"), (2.0, "vostfr")}
|
||||
|
||||
|
||||
async def test_get_details_film(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
return _soup(FILM_DETAILS_HTML)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch_soup", fake_fetch_soup)
|
||||
details = await FrenchStreamScraper().get_details("1022")
|
||||
assert details.media_type == "film"
|
||||
assert details.title == "Inception - 2010"
|
||||
assert details.year == 2010
|
||||
assert details.genres == ["Action", "Science-Fiction"]
|
||||
assert "Résumé du film" not in details.synopsis
|
||||
assert details.episode_count == 1
|
||||
assert details.episodes[0].url.endswith("#film")
|
||||
|
||||
|
||||
async def test_list_episodes_film(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
return _soup(FILM_DETAILS_HTML)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch_soup", fake_fetch_soup)
|
||||
episodes = await FrenchStreamScraper().list_episodes("1022")
|
||||
assert len(episodes) == 1
|
||||
assert episodes[0].number == 1
|
||||
|
||||
|
||||
async def test_extract_embed_links_episode(monkeypatch):
|
||||
async def fake_fetch(url, **kwargs):
|
||||
return json.dumps(EP_DATA)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch", fake_fetch)
|
||||
links = await FrenchStreamScraper().extract_embed_links(
|
||||
"https://french-stream.lat/index.php?newsid=9562#vf-1"
|
||||
)
|
||||
assert links == ["https://vidzy.cc/embed-aaa.html", "https://uqload.vc/embed-bbb.html"]
|
||||
|
||||
|
||||
async def test_extract_embed_links_film_dedup(monkeypatch):
|
||||
async def fake_fetch(url, **kwargs):
|
||||
assert "film_api.php?id=1022" in url
|
||||
return json.dumps(FILM_API)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch", fake_fetch)
|
||||
links = await FrenchStreamScraper().extract_embed_links(
|
||||
"https://french-stream.lat/index.php?newsid=1022#film"
|
||||
)
|
||||
assert links == ["https://vidzy.live/embed-fff.html", "https://vidzy.live/embed-ggg.html"]
|
||||
|
||||
|
||||
async def test_extract_embed_links_bad_fragment():
|
||||
with pytest.raises(ScrapeError):
|
||||
await FrenchStreamScraper().extract_embed_links(
|
||||
"https://french-stream.lat/index.php?newsid=9562#nimportequoi"
|
||||
)
|
||||
|
||||
|
||||
async def test_extract_embed_links_invalid_json(monkeypatch):
|
||||
async def fake_fetch(url, **kwargs):
|
||||
return "<html>oops</html>"
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch", fake_fetch)
|
||||
with pytest.raises(ScrapeError):
|
||||
await FrenchStreamScraper().extract_embed_links(
|
||||
"https://french-stream.lat/index.php?newsid=9562#vf-1"
|
||||
)
|
||||
|
||||
|
||||
async def test_unknown_detail_page(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
return _soup("<html><body>404</body></html>")
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch_soup", fake_fetch_soup)
|
||||
with pytest.raises(ScrapeError):
|
||||
await FrenchStreamScraper().get_details("999999999")
|
||||
|
||||
|
||||
def test_registered():
|
||||
import_all_scrapers()
|
||||
source = get_source("french_stream")
|
||||
assert source.media_types == ("serie", "film")
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Tests de la préférence de contenus par utilisateur (animés / séries / les deux)."""
|
||||
|
||||
from app.scrapers.base import SearchResult
|
||||
|
||||
|
||||
class MixedSource:
|
||||
"""Source factice renvoyant animés, séries et films."""
|
||||
|
||||
name = "mixed"
|
||||
label = "Mixed"
|
||||
base_url = "https://mixed.example"
|
||||
media_types = ("anime", "serie")
|
||||
|
||||
async def search(self, query: str) -> list[SearchResult]:
|
||||
return [
|
||||
SearchResult(source="mixed", source_id="a1", title="Animé X", url="https://mixed.example/a1"),
|
||||
SearchResult(source="mixed", source_id="s1", title="Série Y", url="https://mixed.example/s1", media_type="serie"),
|
||||
SearchResult(source="mixed", source_id="f1", title="Film Z", url="https://mixed.example/f1", media_type="film"),
|
||||
]
|
||||
|
||||
async def latest(self) -> list[SearchResult]:
|
||||
return await self.search("")
|
||||
|
||||
|
||||
class AnimeOnlySource:
|
||||
name = "animeonly"
|
||||
label = "AnimeOnly"
|
||||
base_url = "https://animeonly.example"
|
||||
media_types = ("anime",)
|
||||
|
||||
async def search(self, query: str) -> list[SearchResult]:
|
||||
return [
|
||||
SearchResult(source="animeonly", source_id="a2", title="Animé W", url="https://animeonly.example/a2")
|
||||
]
|
||||
|
||||
async def latest(self) -> list[SearchResult]:
|
||||
return await self.search("")
|
||||
|
||||
|
||||
async def _patch_sources(monkeypatch, *sources):
|
||||
import app.routers.search as search_module
|
||||
|
||||
monkeypatch.setattr(search_module, "all_sources", lambda: list(sources))
|
||||
import app.services.discover as discover_module
|
||||
|
||||
async def enabled(name):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(discover_module, "is_source_enabled", enabled)
|
||||
discover_module.discover._cache.clear()
|
||||
monkeypatch.setattr(discover_module, "all_sources", lambda: list(sources))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- endpoints
|
||||
|
||||
|
||||
async def test_me_exposes_default_preference(client, admin_cookies):
|
||||
r = await client.get("/auth/me", cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["content_preference"] == "both"
|
||||
|
||||
|
||||
async def test_preference_roundtrip(client, admin_cookies):
|
||||
r = await client.put(
|
||||
"/auth/preferences", json={"content_preference": "serie"}, cookies=admin_cookies
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert (await client.get("/auth/me", cookies=admin_cookies)).json()["content_preference"] == "serie"
|
||||
|
||||
|
||||
async def test_preference_rejects_invalid_value(client, admin_cookies):
|
||||
r = await client.put(
|
||||
"/auth/preferences", json={"content_preference": "documentaire"}, cookies=admin_cookies
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- filtrage recherche
|
||||
|
||||
|
||||
async def test_search_both_keeps_everything(client, admin_cookies, monkeypatch):
|
||||
await _patch_sources(monkeypatch, MixedSource(), AnimeOnlySource())
|
||||
r = await client.get("/api/search", params={"q": "test"}, cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["count"] == 4 # animés + série + film
|
||||
|
||||
|
||||
async def test_search_anime_filters_serie(client, admin_cookies, monkeypatch):
|
||||
await _patch_sources(monkeypatch, MixedSource(), AnimeOnlySource())
|
||||
await client.put("/auth/preferences", json={"content_preference": "anime"}, cookies=admin_cookies)
|
||||
r = await client.get("/api/search", params={"q": "test"}, cookies=admin_cookies)
|
||||
types = {x["media_type"] for x in r.json()["results"]}
|
||||
assert types == {"anime"} # le mode animés reste sur l'animation (films réels → côté séries)
|
||||
|
||||
|
||||
async def test_search_serie_skips_anime_sources(client, admin_cookies, monkeypatch):
|
||||
"""Préférence séries : les sources anime-only ne sont même pas interrogées."""
|
||||
await _patch_sources(monkeypatch, MixedSource(), AnimeOnlySource())
|
||||
await client.put("/auth/preferences", json={"content_preference": "serie"}, cookies=admin_cookies)
|
||||
|
||||
queried = []
|
||||
|
||||
class SpySource(AnimeOnlySource):
|
||||
async def search(self, query: str) -> list[SearchResult]:
|
||||
queried.append(self.name)
|
||||
return await super().search(query)
|
||||
|
||||
import app.routers.search as search_module
|
||||
|
||||
monkeypatch.setattr(search_module, "all_sources", lambda: [MixedSource(), SpySource()])
|
||||
|
||||
r = await client.get("/api/search", params={"q": "test"}, cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
assert queried == [] # source anime-only ignorée
|
||||
assert {x["media_type"] for x in r.json()["results"]} == {"serie", "film"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- filtrage découverte
|
||||
|
||||
|
||||
async def test_discover_latest_filtered_by_preference(client, admin_cookies, monkeypatch):
|
||||
"""Rails par type : chaque mode n'expose que ses rails (les films suivent les séries)."""
|
||||
await _patch_sources(monkeypatch, MixedSource())
|
||||
|
||||
r = await client.get("/api/discover", cookies=admin_cookies)
|
||||
data = r.json()
|
||||
assert {i["media_type"] for i in data["latest_anime"]} == {"anime"}
|
||||
assert {i["media_type"] for i in data["latest_serie"]} == {"serie", "film"}
|
||||
|
||||
await client.put("/auth/preferences", json={"content_preference": "serie"}, cookies=admin_cookies)
|
||||
data = (await client.get("/api/discover", cookies=admin_cookies)).json()
|
||||
assert data["latest_anime"] == []
|
||||
assert {i["media_type"] for i in data["latest_serie"]} == {"serie", "film"}
|
||||
|
||||
await client.put("/auth/preferences", json={"content_preference": "anime"}, cookies=admin_cookies)
|
||||
data = (await client.get("/api/discover", cookies=admin_cookies)).json()
|
||||
assert {i["media_type"] for i in data["latest_anime"]} == {"anime"}
|
||||
assert data["latest_serie"] == []
|
||||
|
||||
async def test_discover_hides_kitsu_sections_in_serie_mode(client, admin_cookies, monkeypatch):
|
||||
"""Incontournables et Pour toi (Kitsu = animés) disparaissent en mode séries."""
|
||||
import app.services.discover as discover_module
|
||||
|
||||
await _patch_sources(monkeypatch, MixedSource())
|
||||
|
||||
async def fake_must_watch(limit=20):
|
||||
return [{"kitsu_id": 1, "title": "Animé top"}]
|
||||
|
||||
async def fake_for_you(user_id=0, limit=20):
|
||||
return {"based_on": ["Action"], "items": [{"kitsu_id": 2}]}
|
||||
|
||||
monkeypatch.setattr(discover_module.discover, "must_watch", fake_must_watch)
|
||||
monkeypatch.setattr(discover_module.discover, "for_you", fake_for_you)
|
||||
|
||||
r = await client.get("/api/discover", cookies=admin_cookies)
|
||||
assert len(r.json()["must_watch"]) == 1
|
||||
assert len(r.json()["for_you"]["items"]) == 1
|
||||
|
||||
await client.put("/auth/preferences", json={"content_preference": "serie"}, cookies=admin_cookies)
|
||||
data = (await client.get("/api/discover", cookies=admin_cookies)).json()
|
||||
assert data["must_watch"] == []
|
||||
assert data["for_you"] == {"based_on": [], "items": []}
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Mises à jour : semver, détection Gitea (API tags), déclenchement Watchtower, endpoints."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.config import get_settings
|
||||
from app.services import update as update_service
|
||||
from app.version import get_version
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_update_cache():
|
||||
update_service.invalidate_cache()
|
||||
yield
|
||||
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
|
||||
|
||||
|
||||
def test_parse_tag():
|
||||
assert update_service.parse_tag("v0.2.1") == (0, 2, 1)
|
||||
assert update_service.parse_tag("0.10.3") == (0, 10, 3)
|
||||
assert update_service.parse_tag("v0.2") is None
|
||||
assert update_service.parse_tag("v0.2.1-beta") is None
|
||||
assert update_service.parse_tag("nimporte") is None
|
||||
|
||||
|
||||
def test_is_newer():
|
||||
assert update_service.is_newer("v0.2.0", "0.1.9")
|
||||
assert update_service.is_newer("v1.0.0", "v0.99.99")
|
||||
assert not update_service.is_newer("v0.1.0", "0.1.0")
|
||||
assert not update_service.is_newer("v0.1.0", "dev") # courant non semver → jamais forcé
|
||||
|
||||
|
||||
def test_get_version_env_override(monkeypatch):
|
||||
monkeypatch.setenv("OHM_VERSION", "9.9.9")
|
||||
assert get_version() == "9.9.9"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- détection Gitea
|
||||
|
||||
|
||||
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:
|
||||
pass
|
||||
|
||||
def json(self) -> list[dict]:
|
||||
return [{"name": "v0.3.0"}]
|
||||
|
||||
calls: list[tuple[str, dict]] = []
|
||||
|
||||
async def fake_get(self: httpx.AsyncClient, url: str, headers: dict | None = None) -> FakeResponse:
|
||||
calls.append((url, headers or {}))
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
|
||||
assert await update_service.fetch_latest_version(force=True) == "v0.3.0"
|
||||
assert calls[0][1] == {} # aucun en-tête d'auth
|
||||
|
||||
|
||||
async def test_fetch_latest_picks_highest_semver(monkeypatch):
|
||||
"""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", "message": "ancien"},
|
||||
{"name": "v0.2.3", "message": "correctifs\n"},
|
||||
{"name": "v1.0.0-rc"},
|
||||
{"name": "divers"},
|
||||
]
|
||||
|
||||
calls: list[tuple[str, dict]] = []
|
||||
|
||||
async def fake_get(self: httpx.AsyncClient, url: str, headers: dict | None = None) -> FakeResponse:
|
||||
calls.append((url, headers or {}))
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
|
||||
status = await update_service.status()
|
||||
|
||||
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):
|
||||
_patch_gitea(monkeypatch)
|
||||
|
||||
async def fake_get(self: httpx.AsyncClient, url: str, headers: dict | None = None):
|
||||
raise httpx.ConnectError("injoignable")
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
|
||||
assert await update_service.fetch_latest_version(force=True) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- déclenchement
|
||||
|
||||
|
||||
async def test_trigger_update_requires_watchtower():
|
||||
with pytest.raises(update_service.UpdateError, match="Watchtower"):
|
||||
await update_service.trigger_update()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- endpoints
|
||||
|
||||
|
||||
async def test_version_endpoint_public(client):
|
||||
r = await client.get("/api/version")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["version"] == get_version() and data["name"]
|
||||
|
||||
|
||||
async def test_update_requires_admin(client):
|
||||
r = await client.get("/api/admin/update", follow_redirects=False)
|
||||
assert r.status_code == 303 and r.headers["location"] == "/login"
|
||||
|
||||
|
||||
async def test_update_status_defaults(client, admin_cookies, monkeypatch):
|
||||
# Aucun appel réseau : la détection renvoie None (repo sans tag)
|
||||
async def fake_fetch(*, force: bool = False):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(update_service, "fetch_latest_version", fake_fetch)
|
||||
|
||||
r = await client.get("/api/admin/update", cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["latest"] is None and data["update_available"] is False
|
||||
assert data["docker"] is False and data["notes"] is None
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def test_update_apply_without_docker_is_502(client, admin_cookies):
|
||||
r = await client.post("/api/admin/update/apply", cookies=admin_cookies)
|
||||
assert r.status_code == 502
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Tests du scraper VoirAnime (source + extracteur prepare) sur fixtures HTML réelles."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.scrapers.base import ScrapeError, get_source, import_all_scrapers, resolve_hoster
|
||||
from app.scrapers.hosters.voiranime import VoirAnimeExtractor
|
||||
from app.scrapers.sources.voiranime import VoirAnimeScraper
|
||||
|
||||
# ------------------------------------------------------------ fixtures HTML
|
||||
|
||||
SEARCH_HTML = """
|
||||
<div class="catalogue-grid">
|
||||
<article class="catalogue-card catalogue-media-card">
|
||||
<a href="/catalogue/one-piece" class="catalogue-poster">
|
||||
<img src="https://img.example/one-piece.jpg" alt="one piece">
|
||||
</a>
|
||||
</article>
|
||||
<article class="catalogue-card catalogue-media-card">
|
||||
<a href="/catalogue/one-punch-man" class="catalogue-poster">
|
||||
<img src="https://img.example/opm.jpg" alt="one punch man">
|
||||
</a>
|
||||
</article>
|
||||
<article class="catalogue-card catalogue-media-card">
|
||||
<a href="/catalogue/one-outs" class="catalogue-poster">
|
||||
<img src="/assets/img/placeholders/content-placeholder.svg" alt="one outs">
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
"""
|
||||
|
||||
DETAILS_HTML = """
|
||||
<h1>solo leveling</h1>
|
||||
<div class="media-detail-meta"><span>26 épisodes</span></div>
|
||||
<img src="https://img.example/solo.jpg" alt="Affiche de solo leveling">
|
||||
<div class="media-detail-story">
|
||||
<span class="media-detail-section-label">Synopsis</span>
|
||||
<p> Sung Jinwoo, chasseur le plus faible. </p>
|
||||
</div>
|
||||
<a href="/lecteur/965/29054" class="detail-episode-card" data-season="1">
|
||||
<small>S1 · E1</small><span>Episode 1</span>
|
||||
</a>
|
||||
<a href="/lecteur/965/29055" class="detail-episode-card" data-season="1">
|
||||
<small>S1 · E2</small><span>Episode 2</span>
|
||||
</a>
|
||||
<a href="/lecteur/965/29060" class="detail-episode-card" data-season="2">
|
||||
<small>S2 · E1</small><span>Episode 1</span>
|
||||
</a>
|
||||
<a href="/lecteur/965/29061" class="detail-episode-card" data-season="2">
|
||||
<small>S2 · E2.5</small><span>Episode 2.5</span>
|
||||
</a>
|
||||
"""
|
||||
|
||||
PLAYER_PAGE_1 = """
|
||||
<main class="tv-player" data-player
|
||||
data-content-id="1626" data-episode-id="46416" data-source-id="169261"
|
||||
data-prepare-url="/lecteur/prepare/169261?content=1626&episode=46416"
|
||||
data-fallback-source-url="/lecteur/1626/46416?lang=vostfr&source=169262&previous_source=169261">
|
||||
</main>
|
||||
"""
|
||||
|
||||
# fin de chaîne : le fallback repointe vers la source 1 (aller-retour observé en live)
|
||||
PLAYER_PAGE_2 = """
|
||||
<main class="tv-player" data-player
|
||||
data-content-id="1626" data-episode-id="46416" data-source-id="169262"
|
||||
data-prepare-url="/lecteur/prepare/169262?content=1626&episode=46416"
|
||||
data-fallback-source-url="/lecteur/1626/46416?lang=vostfr&source=169261&previous_source=169262">
|
||||
</main>
|
||||
"""
|
||||
|
||||
HOME_HTML = """
|
||||
<div class="carousel" id="carousel-new">
|
||||
<article class="content-card">
|
||||
<a href="/catalogue/you-and-i-are-polar-opposites" class="card-poster">
|
||||
<img src="https://img.example/polar.jpg" alt="you and i are polar opposites">
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
<div class="carousel" id="carousel-added">
|
||||
<article class="content-card">
|
||||
<a href="/catalogue/you-and-i-are-polar-opposites" class="card-poster">
|
||||
<img src="https://img.example/polar.jpg" alt="you and i are polar opposites">
|
||||
</a>
|
||||
</article>
|
||||
<article class="content-card">
|
||||
<a href="/catalogue/solo-leveling" class="card-poster">
|
||||
<img src="https://img.example/solo.jpg" alt="solo leveling">
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _soup(html: str):
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
return BeautifulSoup(html, "lxml")
|
||||
|
||||
|
||||
def _prepare_payload(url: str, referer: str) -> str:
|
||||
raw = json.dumps({"url": url, "referer": referer, "kind": "file"}).encode()
|
||||
return base64.b64encode(raw).decode()
|
||||
|
||||
|
||||
# ------------------------------------------------------------ source
|
||||
|
||||
|
||||
async def test_search_parses_catalogue_cards(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
assert "q=one+piece" in url
|
||||
return _soup(SEARCH_HTML)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
|
||||
results = await VoirAnimeScraper().search("one piece")
|
||||
assert [r.source_id for r in results] == ["one-piece", "one-punch-man", "one-outs"]
|
||||
assert results[0].title == "one piece"
|
||||
assert results[0].url == "https://voiranime.xyz/catalogue/one-piece"
|
||||
assert results[0].image_url == "https://img.example/one-piece.jpg"
|
||||
assert results[2].image_url == "/assets/img/placeholders/content-placeholder.svg"
|
||||
|
||||
|
||||
async def test_get_details_and_episodes(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
assert url.endswith("/catalogue/solo-leveling")
|
||||
return _soup(DETAILS_HTML)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
|
||||
scraper = VoirAnimeScraper()
|
||||
details = await scraper.get_details("solo-leveling")
|
||||
assert details.title == "solo leveling"
|
||||
assert details.episode_count == 26
|
||||
assert details.image_url == "https://img.example/solo.jpg"
|
||||
assert details.synopsis.startswith("Sung Jinwoo")
|
||||
|
||||
episodes = details.episodes
|
||||
assert len(episodes) == 4
|
||||
assert (episodes[0].season, episodes[0].number) == (1, 1.0)
|
||||
assert (episodes[2].season, episodes[2].number) == (2, 1.0)
|
||||
assert (episodes[3].season, episodes[3].number) == (2, 2.5)
|
||||
assert episodes[0].url == "https://voiranime.xyz/lecteur/965/29054"
|
||||
|
||||
standalone = await scraper.list_episodes("solo-leveling")
|
||||
assert len(standalone) == len(episodes)
|
||||
|
||||
|
||||
async def test_extract_embed_links_follows_fallback_chain(monkeypatch):
|
||||
pages = {
|
||||
"https://voiranime.xyz/lecteur/1626/46416": PLAYER_PAGE_1,
|
||||
"https://voiranime.xyz/lecteur/1626/46416?lang=vostfr&source=169262&previous_source=169261": PLAYER_PAGE_2,
|
||||
}
|
||||
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
return _soup(pages[url])
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
|
||||
urls = await VoirAnimeScraper().extract_embed_links("https://voiranime.xyz/lecteur/1626/46416")
|
||||
assert urls == [
|
||||
"https://voiranime.xyz/lecteur/prepare/169261?content=1626&episode=46416",
|
||||
"https://voiranime.xyz/lecteur/prepare/169262?content=1626&episode=46416",
|
||||
]
|
||||
|
||||
|
||||
async def test_extract_embed_links_no_player_raises(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
return _soup("<html><body>pas de lecteur</body></html>")
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
|
||||
with pytest.raises(ScrapeError):
|
||||
await VoirAnimeScraper().extract_embed_links("https://voiranime.xyz/lecteur/1/2")
|
||||
|
||||
|
||||
async def test_latest_merges_carousels_dedup(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
assert url == "https://voiranime.xyz/"
|
||||
return _soup(HOME_HTML)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
|
||||
results = await VoirAnimeScraper().latest()
|
||||
assert [r.source_id for r in results] == ["you-and-i-are-polar-opposites", "solo-leveling"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------ extracteur hoster
|
||||
|
||||
|
||||
async def test_hoster_decodes_prepare_payload(monkeypatch):
|
||||
payload = _prepare_payload(
|
||||
"https://video.sibnet.ru/v/abc123/6235096.mp4", "https://video.sibnet.ru/"
|
||||
)
|
||||
|
||||
async def fake_fetch(url, **kwargs):
|
||||
assert "X-Requested-With" in kwargs.get("headers", {})
|
||||
assert kwargs.get("referer") == "https://voiranime.xyz/lecteur/1626/46416"
|
||||
return json.dumps(
|
||||
{"success": True, "media_type": "mp4", "stream_url": f"/proxy/media?payload={payload}"}
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.hosters.voiranime.fetch", fake_fetch)
|
||||
link = await VoirAnimeExtractor().extract(
|
||||
"https://voiranime.xyz/lecteur/prepare/169261?content=1626&episode=46416"
|
||||
)
|
||||
assert link.url == "https://video.sibnet.ru/v/abc123/6235096.mp4"
|
||||
assert link.hoster == "voiranime"
|
||||
assert link.headers == {"Referer": "https://video.sibnet.ru/"}
|
||||
assert link.is_hls is False
|
||||
|
||||
|
||||
async def test_hoster_marks_hls(monkeypatch):
|
||||
payload = _prepare_payload("https://cdn.example/hls/master.m3u8", "https://cdn.example/")
|
||||
|
||||
async def fake_fetch(url, **kwargs):
|
||||
return json.dumps(
|
||||
{"success": True, "media_type": "hls", "stream_url": f"/proxy/media?payload={payload}"}
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.hosters.voiranime.fetch", fake_fetch)
|
||||
link = await VoirAnimeExtractor().extract("https://voiranime.xyz/lecteur/prepare/1?content=2&episode=3")
|
||||
# HLS signé pour le backend du site → on sert l'URL proxy du site, pas le CDN
|
||||
assert link.url == f"https://voiranime.xyz/proxy/media?payload={payload}"
|
||||
assert link.is_hls is True
|
||||
assert link.headers == {"Referer": "https://voiranime.xyz/"}
|
||||
|
||||
|
||||
async def test_hoster_prepare_failure_raises(monkeypatch):
|
||||
async def fake_fetch(url, **kwargs):
|
||||
return json.dumps({"success": False, "error": "Flux extrait inaccessible"})
|
||||
|
||||
monkeypatch.setattr("app.scrapers.hosters.voiranime.fetch", fake_fetch)
|
||||
with pytest.raises(ScrapeError, match="Flux extrait inaccessible"):
|
||||
await VoirAnimeExtractor().extract("https://voiranime.xyz/lecteur/prepare/1?content=2&episode=3")
|
||||
|
||||
|
||||
def test_source_and_hoster_registered():
|
||||
import_all_scrapers()
|
||||
assert get_source("voiranime").label == "VoirAnime"
|
||||
extractor = resolve_hoster("https://voiranime.xyz/lecteur/prepare/169261?content=1&episode=2")
|
||||
assert extractor is not None and extractor.name == "voiranime"
|
||||
Reference in New Issue
Block a user