9 Commits
Author SHA1 Message Date
Roman 65761885ba Corrections : test Torznab périmé et retries sur erreurs définitives
- test_torznab_search_requires_query attendait un 400 mais le commit
  853b4e0 a volontairement fait servir les nouveautés quand q est
  absent (RSS sync Sonarr) — le test valide désormais ce comportement.
- fetch() ne retente plus les 4xx définitifs (sauf 408/429) : un
  lecteur VoirAnime en échec 422 coûtait 2 requêtes et 3 s de pauses
  avant de basculer sur le lecteur suivant.
- 4 nouveaux tests unitaires du client HTTP (fail-fast + retries).
2026-09-23 12:42:24 +00:00
Roman 7399f4b781 Ajout de la source VoirAnime (voiranime.xyz)
- Scraper catalogue : recherche /catalogue?q=, fiches + épisodes
  (cartes S·E, multi-saisons), chaîne de lecteurs prepare/fallback
  avec dédoublonnage par id de source, latest via carrousels accueil.
- Extracteur hoster : endpoint prepare (AJAX) → MP4 direct décodé du
  payload (Referer d'origine) ou HLS relayé par le proxy du site
  (CDN signés pour leur backend → accès direct en 403).
- fetch() accepte désormais des en-têtes additionnels (X-Requested-With).
- 9 tests sur fixtures HTML réelles ; README mis à jour.
2026-09-23 10:51:29 +00:00
roman 5fedc4f9d7 Nommage Plex SxxExx des fichiers téléchargés
Le scanner Plex ignorait les épisodes 1-9 : le pattern « Saison 1 - E9 »
n'est reconnu qu'avec un numéro d'épisode à 2 chiffres. Les titres
« Série - Saison N - EX (VF) » sont désormais renommés en
« Série - S01E09 (VF) » à l'ajout dans la file (plex_filename).
2026-09-23 09:30:31 +00:00
roman 853b4e0866 Compatibilité Sonarr : flux RSS Torznab, catégories qBittorrent, save_path, TLS auto-signé
- torznab : tvsearch sans q sert les nouveautés (RSS sync + test Sonarr)
  au lieu d'une erreur 400 — ajoute TorznabService.latest_releases()
- qbit : POST /torrents/createCategory + GET /torrents/categories stateful
  (Sonarr crée sa catégorie tv-sonarr puis la revérifie)
- qbit : /app/preferences expose save_path=/downloads (health Sonarr)
- sonarr : verify=False sur httpx — reverse proxy swizzin en certificat
  auto-signé (usage LAN)
2026-09-22 18:25:03 +00:00
Roman 4e1faf4cd9 Ohm comme client de téléchargement Sonarr (API compatible qBittorrent)
- /api/v2/* : login SID (mot de passe = clé Torznab), app/version,
  torrents/info (progression temps réel), properties (content_path),
  add (rejoue le grab encodé dans le .torrent de service, dédupliqué
  par infohash SHA-1), delete (± fichiers), pause/resume
- Les grabs Sonarr sont marqués « sonarr:<hash>| » dans source_key →
  suivis de bout en bout : Sonarr importe, renomme et range les épisodes
  dans sa bibliothèque, puis retire le torrent de la file Ohm
- L'indexeur Torznab embarque les paramètres du grab dans l'announce
- README : nouveau mode « client de téléchargement » recommandé (Remote
  Path Mapping documenté), blackhole en variante minimale
- 3 nouveaux tests (flux complet add → suivi → import → delete)
2026-09-22 16:42:12 +00:00
Roman 1f326d34dd Installation guidée + intégration bibliothèque Plex/Sonarr
- scripts/install.sh : premier setup interactif (destination des épisodes,
  port, secrets auto-générés, montage, démarrage) — options --dir/--port/
  --skip-login pour une install non interactive
- Épisodes rangés par animé sur le disque : <Animé>/<Animé> - E12 (VOSTFR).mp4
  (meilleur parsing Plex, suppression du dossier quand il se vide)
- Entrypoint : chown récursif uniquement si le dossier de téléchargements est
  vide — une bibliothèque Plex/Sonarr existante n'est jamais modifiée
- Scan disque récursif (sous-dossiers par animé restaurés)
- README : installation guidée + section « Bibliothèque Plex / Sonarr »
2026-09-22 12:38:18 +00:00
Roman 9500a84a5e Téléchargements : suppression par épisode et regroupement par animé
- DELETE /api/downloads/{id} (?delete_file=true pour effacer aussi le fichier)
- Événement SSE « removed » → toutes les pages ouvertes se synchronisent
- Page Téléchargements groupée par animé : groupes repliables (dépliés si
  actifs), compteurs par statut, taille totale, tri par numéro d'épisode
- Actions par groupe : 🧹 retirer les terminés, ✖ annuler les actifs
- Limite de liste 200 → 2000 tâches
2026-09-22 12:17:27 +00:00
Roman affc97c527 Choix de la langue VF/VOSTFR sur la fiche titre
- Episode : nouveau champ version (vf/vostfr)
- French-Manga : épisodes listés pour chaque langue disponible
  (VOSTFR puis VF) au lieu d'un choix automatique, fragment
  #ep=<version>-<num> déjà résolu par extract_embed_links
- Vostfree : langue déduite du titre h1 ou du slug de la fiche
- UI : filtre VOSTFR/VF/Tout (défaut VOSTFR), badge par épisode,
  téléchargement de saison limité à la langue filtrée, compteur
  d'épisodes unique, version incluse dans le nom de fichier
2026-09-22 11:58:10 +00:00
Roman 9148b5fb6a Mise à jour automatique (Gitea + Watchtower) et conteneurisation Docker
- Détection de version via l'API Gitea, application via Watchtower
- Router système, UI admin (intégrations), bandeau de mise à jour
- Dockerfile multi-étapes, docker-compose, scripts/release.sh
- Version centralisée dans app/version.py (pyproject ou OHM_VERSION au build)
2026-09-22 11:58:03 +00:00
41 changed files with 3063 additions and 194 deletions
+20
View File
@@ -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
View File
@@ -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) # OBLIGATOIRE en production : clé de signature des tokens (32+ caractères)
OHM_SECRET_KEY=change-me-in-production 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_REFRESH_TOKEN_TTL_DAYS=30
# OHM_DEBUG=false # 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
View File
@@ -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"]
+119 -17
View File
@@ -3,12 +3,86 @@
Application web **auto-hébergée** (homelab) : centre de contrôle unique pour découvrir, Application web **auto-hébergée** (homelab) : centre de contrôle unique pour découvrir,
regarder et télécharger des animes et séries VOSTFR/VF. regarder et télécharger des animes et séries VOSTFR/VF.
> MVP — Phase 1 : recherche multi-sources, fiches enrichies, streaming, gestionnaire ## Déploiement (Docker) — recommandé
> 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é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 ```bash
uv sync uv sync
@@ -16,7 +90,8 @@ uv sync
uv run uvicorn app.main:app --host 0.0.0.0 --port 8777 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**. Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**.
@@ -30,13 +105,18 @@ Variables d'environnement (préfixe `OHM_`, voir `.env.example`) :
| `OHM_DOWNLOAD_DIR` | `./downloads` | Dossier des fichiers téléchargés | | `OHM_DOWNLOAD_DIR` | `./downloads` | Dossier des fichiers téléchargés |
| `OHM_DATABASE_PATH` | `./data/ohm.db` | Base SQLite | | `OHM_DATABASE_PATH` | `./data/ohm.db` | Base SQLite |
| `OHM_MAX_PARALLEL_DOWNLOADS` | `3` | Téléchargements simultanés | | `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 ## Fonctionnalités
- **Recherche unifiée** sur plusieurs sources (Vostfree, French-Manga) — chaque source - **Recherche unifiée** sur plusieurs sources (Vostfree, French-Manga, VoirAnime) — chaque source
est un module interchangeable activable/désactivable à chaud (page Admin). 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 - **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).
- **Proxy vidéo intégré** (`/api/proxy`) : contourne les protections (tokens liés à l'IP, Referer/UA obligatoires), réécrit les playlists HLS. - **Proxy vidéo intégré** (`/api/proxy`) : contourne les protections (tokens liés à l'IP, Referer/UA obligatoires), réécrit les playlists HLS.
- **Streaming HLS** via hls.js ; **téléchargement HLS** via ffmpeg (remux mp4, progression temps réel). - **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. - **Métadonnées enrichies** via Kitsu (synopsis, genres, note, images) avec cache 72 h.
@@ -54,9 +134,10 @@ Variables d'environnement (préfixe `OHM_`, voir `.env.example`) :
## Intégration Sonarr / Prowlarr (*arr) ## Intégration Sonarr / Prowlarr (*arr)
OhmStreaming expose une **API Torznab** : la suite *arr le voit comme un indexeur OhmStreaming expose une **API Torznab** (indexeur) **et une API compatible
de plus, et chaque grab Sonarr déclenche l'extraction + le téléchargement dans qBittorrent** (client de téléchargement) : Sonarr peut lui déléguer toute la
la file interne (les épisodes arrivent dans la bibliothèque OhmStreaming). chaîne — recherche, téléchargement, puis **import et renommage automatiques**
dans la bibliothèque Sonarr.
### 1. OhmStreaming comme indexeur ### 1. OhmStreaming comme indexeur
@@ -71,13 +152,34 @@ Dans **Admin → Intégrations Sonarr / Prowlarr**, copier :
puis synchroniser vers Sonarr. puis synchroniser vers Sonarr.
- **Sonarr** (direct) : Settings → Indexers → Add → *Torznab* → coller URL + clé. - **Sonarr** (direct) : Settings → Indexers → Add → *Torznab* → coller URL + clé.
Catégories : TV (5000) / Anime (5070). 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 Endpoints : `t=caps`, `t=tvsearch` (q, season, ep), `t=search` — auth par
`?apikey=` ou en-tête `X-Api-Key`. `?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 ### 2. « Pour toi » personnalisé par Sonarr
Toujours dans **Admin → Intégrations**, renseigner l'URL Sonarr Toujours dans **Admin → Intégrations**, renseigner l'URL Sonarr
@@ -97,7 +199,7 @@ app/
├── scrapers/ ├── scrapers/
│ ├── base.py # contrats SourceScraper/HosterExtractor + registres │ ├── base.py # contrats SourceScraper/HosterExtractor + registres
│ ├── configs/ # sélecteurs YAML externalisés (réparer sans coder) │ ├── configs/ # sélecteurs YAML externalisés (réparer sans coder)
│ ├── sources/ # vostfree, french_manga │ ├── sources/ # vostfree, french_manga, voiranime
├── services/ # downloads, kitsu, discover, sonarr, torznab, settings ├── services/ # downloads, kitsu, discover, sonarr, torznab, settings
└── templates/ + static/ # UI htmx + Alpine.js, thème sombre └── templates/ + static/ # UI htmx + Alpine.js, thème sombre
``` ```
@@ -108,6 +210,6 @@ app/
## Tests ## Tests
```bash ```bash
uv run pytest # 61 tests uv run pytest # 86 tests
uv run ruff check . # lint uv run ruff check . # lint
``` ```
+4
View File
@@ -34,6 +34,10 @@ class Settings(BaseSettings):
kitsu_base_url: str = "https://kitsu.io/api/edge" kitsu_base_url: str = "https://kitsu.io/api/edge"
metadata_cache_ttl_hours: int = 72 metadata_cache_ttl_hours: int = 72
# Mise à jour (déploiement Docker — Watchtower compagnon)
watchtower_url: str = ""
watchtower_token: str = ""
def ensure_dirs(self) -> None: def ensure_dirs(self) -> None:
self.data_dir.mkdir(parents=True, exist_ok=True) self.data_dir.mkdir(parents=True, exist_ok=True)
self.download_dir.mkdir(parents=True, exist_ok=True) self.download_dir.mkdir(parents=True, exist_ok=True)
+17 -2
View File
@@ -9,10 +9,23 @@ from fastapi.staticfiles import StaticFiles
from app.config import BASE_DIR, get_settings from app.config import BASE_DIR, get_settings
from app.db import db from app.db import db
from app.logging_config import setup_logging 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.scrapers.http import close_client
from app.services.discover import discover as discover_service from app.services.discover import discover as discover_service
from app.services.downloads import download_manager from app.services.downloads import download_manager
from app.services.settings import apply_source_base_urls
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,6 +43,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = get_settings() settings = get_settings()
setup_logging(settings.debug) setup_logging(settings.debug)
await db.connect() await db.connect()
await apply_source_base_urls()
await download_manager.start() await download_manager.start()
warmup = asyncio.create_task(discover_service.latest()) warmup = asyncio.create_task(discover_service.latest())
warmup.add_done_callback(_log_task_error) warmup.add_done_callback(_log_task_error)
@@ -45,7 +59,8 @@ def create_app() -> FastAPI:
settings = get_settings() settings = get_settings()
app = FastAPI(title=settings.app_name, lifespan=lifespan) app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.include_router(torznab.router) app.include_router(torznab.router)
app.include_router(qbit.router)
app.include_router(system.router)
app.include_router(pages.router) app.include_router(pages.router)
app.include_router(pages.protected) app.include_router(pages.protected)
+96 -9
View File
@@ -1,7 +1,8 @@
"""Administration : utilisateurs, activation des sources, santé.""" """Administration : utilisateurs, activation des sources, santé, mises à jour."""
import asyncio import asyncio
import logging import logging
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel
@@ -9,17 +10,29 @@ from pydantic import BaseModel
from app import auth from app import auth
from app.db import db from app.db import db
from app.routers.auth import AdminUser 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.discover import discover
from app.services.settings import ( from app.services.settings import (
get_sonarr_config, get_sonarr_config,
get_source_health,
get_torznab_apikey, get_torznab_apikey,
is_source_enabled, is_source_enabled,
reset_torznab_apikey, reset_torznab_apikey,
set_sonarr_config, set_sonarr_config,
set_source_base_url,
set_source_enabled, set_source_enabled,
set_source_health,
set_update_config,
) )
from app.services.sonarr import sonarr from app.services.sonarr import sonarr
from app.services.update import UpdateError, fetch_latest_version, trigger_update
from app.services.update import status as update_status
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -92,26 +105,62 @@ class SourceToggle(BaseModel):
enabled: bool 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") @router.post("/sources/{name}/toggle")
async def toggle_source(name: str, payload: SourceToggle, admin: AdminUser) -> dict: 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) await set_source_enabled(name, payload.enabled)
return {"name": name, "enabled": 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") @router.post("/sources/{name}/health")
async def health_check(name: str, admin: AdminUser) -> dict: async def health_check(name: str, admin: AdminUser) -> dict:
"""Test de santé manuel : la source doit répondre à une recherche simple.""" """Test de santé manuel : la source doit répondre à une recherche simple."""
source = get_source(name) source = _source_or_404(name)
try: try:
results = await asyncio.wait_for(source.search("naruto"), timeout=30) results = await asyncio.wait_for(source.search("naruto"), timeout=30)
healthy = len(results) > 0 healthy, detail = len(results) > 0, f"{len(results)} résultats"
detail = f"{len(results)} résultats"
except (ScrapeError, TimeoutError) as exc: except (ScrapeError, TimeoutError) as exc:
healthy = False healthy, detail = False, str(exc)[:200]
detail = str(exc)[:200]
logger.error("Health check %s KO : %s", name, exc) 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") @router.get("/sources")
@@ -121,7 +170,10 @@ async def sources_status(admin: AdminUser) -> list[dict]:
"name": s.name, "name": s.name,
"label": s.label, "label": s.label,
"base_url": s.base_url, "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), "enabled": await is_source_enabled(s.name),
"health": await get_source_health(s.name),
} }
for s in all_sources() for s in all_sources()
] ]
@@ -166,3 +218,38 @@ async def save_sonarr(payload: SonarrConfig, admin: AdminUser) -> dict:
@router.post("/integrations/sonarr/test") @router.post("/integrations/sonarr/test")
async def test_sonarr(admin: AdminUser) -> dict: async def test_sonarr(admin: AdminUser) -> dict:
return await sonarr.test_connection() return await sonarr.test_connection()
# ---------------------------------------------------------------- mise à jour logicielle
class UpdateConfig(BaseModel):
gitea_url: str
repo: str
token: str
@router.get("/update")
async def get_update(admin: AdminUser) -> dict:
"""Version courante, dernière version disponible et configuration Gitea."""
return await update_status()
@router.put("/update")
async def save_update(payload: UpdateConfig, admin: AdminUser) -> dict:
await set_update_config(payload.gitea_url, payload.repo, payload.token)
return await update_status()
@router.post("/update/check")
async def check_update(admin: AdminUser) -> dict:
"""Force la re-vérification de la dernière version (ignore le cache)."""
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
+9 -2
View File
@@ -64,6 +64,13 @@ async def cancel(download_id: int) -> dict:
await download_manager.cancel(download_id) await download_manager.cancel(download_id)
return {"ok": True} 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") @router.post("/cancel-all")
async def cancel_all() -> dict: async def cancel_all() -> dict:
@@ -81,7 +88,7 @@ async def events() -> EventSourceResponse:
async def stream() -> AsyncIterator[dict]: async def stream() -> AsyncIterator[dict]:
yield {"data": json.dumps({"type": "snapshot", "items": await download_manager.list_all()})} yield {"data": json.dumps({"type": "snapshot", "items": await download_manager.list_all()})}
async for update in download_manager.subscribe(): async for message in download_manager.subscribe():
yield {"data": json.dumps({"type": "update", "item": update}, default=str)} yield {"data": json.dumps(message, default=str)}
return EventSourceResponse(stream()) return EventSourceResponse(stream())
+263
View File
@@ -0,0 +1,263 @@
"""API compatible qBittorrent Web API v2 — Ohm comme client de téléchargement Sonarr.
Sonarr sait piloter un qBittorrent ; Ohm implémente le sous-set suffisant pour
être vu comme un client de téléchargement « torrent » :
- login (`/auth/login`, mot de passe = clé API Torznab) + session SID
- `torrents/add` : Sonarr renvoie le .torrent de service servi par l'indexeur
Torznab → le grab est rejoué (dédupliqué par infohash) dans la file interne
- `torrents/info` / `properties` : progression temps réel, `content_path`
pointant vers le fichier dans /downloads (mapper en chemin hôte côté Sonarr
via « Remote Path Mapping » si besoin)
- `torrents/delete` : retrait de la file, avec ou sans le fichier
- `pause`/`resume` : branchés sur le gestionnaire de téléchargements
"""
import hashlib
import logging
import secrets
import time
from datetime import UTC, datetime
from urllib.parse import parse_qsl
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile
from app.db import db
from app.scrapers.base import ScrapeError
from app.services.downloads import download_manager
from app.services.settings import get_torznab_apikey
from app.services.torznab import _bencode, bdecode, torznab
logger = logging.getLogger(__name__)
def _plain(text: str) -> Response:
"""qBittorrent répond en texte brut, pas en JSON."""
return Response(text, media_type="text/plain")
router = APIRouter(tags=["qbit"])
_OPTIONAL_FILE = File(None)
_SID_TTL = 3600.0
_sessions: dict[str, float] = {}
_QBIT_VERSION = "v4.6.0"
_WEBAPI_VERSION = "2.9.3"
# status Ohm → état qBittorrent (noms compris par Sonarr v3/v4)
_QBIT_STATES = {
"pending": "queuedDL",
"downloading": "downloading",
"paused": "pausedDL",
"done": "pausedUP", # terminé → Sonarr importe
"failed": "error",
"cancelled": "error",
}
_PREFIX = "sonarr:"
async def _require_sid(request: Request) -> None:
sid = request.cookies.get("SID")
if not sid or _sessions.get(sid, 0.0) < time.time():
_sessions.pop(sid, None)
raise HTTPException(403, "Session invalide — (re)connecte-toi via /api/v2/auth/login")
@router.post("/api/v2/auth/login")
async def login(username: str = Form(""), password: str = Form("")) -> Response:
if password != await get_torznab_apikey():
logger.warning("Login qBittorrent refusé (utilisateur %r)", username)
raise HTTPException(403, "Fails.")
sid = secrets.token_hex(16)
_sessions[sid] = time.time() + _SID_TTL
response = _plain("Ok.")
response.set_cookie("SID", sid, httponly=True)
logger.info("Client qBittorrent authentifié (utilisateur %r)", username)
return response
@router.get("/api/v2/app/version", dependencies=[Depends(_require_sid)])
async def app_version() -> Response:
return _plain(_QBIT_VERSION)
@router.get("/api/v2/app/webapiVersion", dependencies=[Depends(_require_sid)])
async def webapi_version() -> Response:
return _plain(_WEBAPI_VERSION)
@router.get("/api/v2/app/preferences", dependencies=[Depends(_require_sid)])
async def app_preferences() -> dict:
return {"save_path": "/downloads"}
@router.get("/api/v2/transfer/info", dependencies=[Depends(_require_sid)])
async def transfer_info() -> dict:
return {"dl_info_speed": 0, "dl_info_data": 0, "up_info_speed": 0, "up_info_data": 0}
# ---------------------------------------------------------------- torrents
def _ts(sqlite_dt: str | None) -> int:
if not sqlite_dt:
return -1
try:
return int(
datetime.strptime(sqlite_dt, "%Y-%m-%d %H:%M:%S")
.replace(tzinfo=UTC)
.timestamp()
)
except ValueError:
return -1
async def _sonarr_rows() -> dict[str, dict]:
"""Téléchargements d'origine Sonarr, indexés par infohash (dernier par hash)."""
rows = await db.fetchall(
f"SELECT * FROM downloads WHERE source_key LIKE '{_PREFIX}%' ORDER BY id"
)
by_hash: dict[str, dict] = {}
for row in rows:
prefix_end = row["source_key"].find("|")
infohash = row["source_key"][len(_PREFIX) : prefix_end]
by_hash[infohash] = dict(row) # le plus grand id écrase les précédents
return by_hash
def _content_path(row: dict) -> str:
return "/downloads/" + (row["file_path"] or row["title"])
@router.get("/api/v2/torrents/info", dependencies=[Depends(_require_sid)])
async def torrents_info() -> list[dict]:
items = []
for infohash, row in (await _sonarr_rows()).items():
live = await download_manager.get(row["id"])
downloaded = live["downloaded_bytes"]
total = live["total_bytes"]
items.append(
{
"hash": infohash,
"name": row["title"],
"state": _QBIT_STATES.get(row["status"], "error"),
"progress": round(downloaded / total, 4) if total else 0.0,
"dlspeed": live["speed_bps"],
"eta": live["eta_seconds"] or 0,
"total_size": total or 0,
"completed": downloaded,
"amount_left": max(0, (total or 0) - downloaded),
"category": "",
"tags": "",
"save_path": _content_path(row).rsplit("/", 1)[0],
"content_path": _content_path(row),
"added_on": _ts(row["created_at"]),
"completion_on": _ts(row["updated_at"]) if row["status"] == "done" else -1,
"ratio": 1,
"num_seeds": 0,
}
)
return items
@router.get("/api/v2/torrents/properties", dependencies=[Depends(_require_sid)])
async def torrents_properties(hash: str) -> dict:
rows = await _sonarr_rows()
row = rows.get(hash.lower())
if row is None:
raise HTTPException(404, "Torrent introuvable")
live = await download_manager.get(row["id"])
return {
"name": row["title"],
"content_path": _content_path(row),
"save_path": _content_path(row).rsplit("/", 1)[0],
"total_size": live["total_bytes"] or 0,
"total_downloaded": live["downloaded_bytes"],
"addition_date": _ts(row["created_at"]),
"completion_date": _ts(row["updated_at"]) if row["status"] == "done" else -1,
"seeding_time": 0,
"share_ratio": 1,
}
_categories: dict[str, dict] = {}
@router.get("/api/v2/torrents/categories", dependencies=[Depends(_require_sid)])
async def torrents_categories() -> dict:
return _categories
@router.post("/api/v2/torrents/createCategory", dependencies=[Depends(_require_sid)])
async def torrents_create_category(category: str = Form(""), savePath: str = Form("")) -> Response:
if category:
_categories[category] = {"name": category, "savePath": savePath}
return _plain("Ok.")
@router.get("/api/v2/torrents/tags", dependencies=[Depends(_require_sid)])
async def torrents_tags() -> list:
return []
@router.post("/api/v2/torrents/setCategory", dependencies=[Depends(_require_sid)])
async def torrents_set_category() -> Response:
return _plain("Ok.")
async def _add_stub(stub: bytes) -> Response:
"""Rejoue le grab encodé dans un .torrent de service."""
try:
parsed = bdecode(stub)
info = parsed[b"info"]
announce = parsed[b"announce"].decode()
params = dict(parse_qsl(announce.split("?", 1)[1]))
infohash = hashlib.sha1(_bencode(info)).hexdigest()
await torznab.grab(
params["source"],
params["sid"],
int(params["season"]),
int(params["ep"]),
params["series"],
sonarr_hash=infohash,
)
except (ValueError, KeyError, ScrapeError) as exc:
logger.error("Ajout qBittorrent refusé : %s", exc)
return _plain("Fals.")
return _plain("Ok.")
@router.post("/api/v2/torrents/add", dependencies=[Depends(_require_sid)])
async def torrents_add(torrents: UploadFile | None = _OPTIONAL_FILE) -> Response:
if torrents is None or not torrents.filename:
return _plain("Fals.")
return await _add_stub(await torrents.read())
async def _ids_for_hashes(hashes: str) -> list[int]:
wanted = {h.lower() for h in hashes.split("|") if h}
rows = await _sonarr_rows()
return [row["id"] for infohash, row in rows.items() if infohash in wanted]
@router.post("/api/v2/torrents/delete", dependencies=[Depends(_require_sid)])
async def torrents_delete(
hashes: str = Form(...), deleteFiles: str = Form("false")
) -> Response:
for download_id in await _ids_for_hashes(hashes):
await download_manager.delete(download_id, delete_file=deleteFiles == "true")
return _plain("Ok.")
@router.post("/api/v2/torrents/pause", dependencies=[Depends(_require_sid)])
async def torrents_pause(hashes: str = Form(...)) -> Response:
for download_id in await _ids_for_hashes(hashes):
await download_manager.pause(download_id)
return _plain("Ok.")
@router.post("/api/v2/torrents/resume", dependencies=[Depends(_require_sid)])
async def torrents_resume(hashes: str = Form(...)) -> Response:
for download_id in await _ids_for_hashes(hashes):
await download_manager.resume(download_id)
return _plain("Ok.")
+14
View File
@@ -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
View File
@@ -6,12 +6,13 @@ des sessions utilisateurs — aucun cookie requis.
import logging import logging
import secrets import secrets
from urllib.parse import urlencode
from fastapi import APIRouter, Request, Response from fastapi import APIRouter, Request, Response
from app.scrapers.base import ScrapeError from app.scrapers.base import ScrapeError
from app.services.settings import get_torznab_apikey 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__) logger = logging.getLogger(__name__)
@@ -57,10 +58,9 @@ async def torznab_api(
if t in ("tvsearch", "search"): if t in ("tvsearch", "search"):
if not q or len(q) < 2: if not q or len(q) < 2:
releases = await torznab.latest_releases()
return Response( return Response(
torznab.error_xml(200, "Paramètre q requis"), torznab.results_xml(base, key, releases), media_type="application/rss+xml"
media_type="application/xml",
status_code=400,
) )
try: try:
releases = await torznab.tvsearch(q, season=season, ep=ep) releases = await torznab.tvsearch(q, season=season, ep=ep)
@@ -92,10 +92,12 @@ async def torznab_download(
series: str | None = None, series: str | None = None,
apikey: str | None = None, apikey: str | None = None,
) -> Response: ) -> 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 Le flux retourne un .torrent de service : avec un client « Torrent
l'épisode réel entre dans la file de téléchargements interne. 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) error = await _auth_error(request, apikey)
if error is not None: if error is not None:
@@ -106,8 +108,17 @@ async def torznab_download(
media_type="application/xml", media_type="application/xml",
status_code=400, 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: 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")) logger.info("Torznab grab OK : %s → download %s", series, result.get("id"))
except ScrapeError as exc: except ScrapeError as exc:
logger.error("Torznab grab KO : %s", exc) logger.error("Torznab grab KO : %s", exc)
@@ -116,9 +127,13 @@ async def torznab_download(
media_type="application/xml", media_type="application/xml",
status_code=502, status_code=502,
) )
stub = torrent_stub(_base_url(request) + "/torznab/api", f"{series} S{season:02d}E{ep:02d}")
return Response( return Response(
stub, stub,
media_type="application/x-bittorrent", 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"
)
},
) )
+1 -1
View File
@@ -38,7 +38,7 @@ class Episode:
title: str | None title: str | None
url: str # page de l'épisode chez la source url: str # page de l'épisode chez la source
season: int = 1 season: int = 1
version: str | None = None # langue ("vf" / "vostfr") quand la source la distingue
@dataclass @dataclass
class TitleDetails: class TitleDetails:
+111
View File
@@ -0,0 +1,111 @@
"""Extracteur VoirAnime — endpoint « prepare » → URL lisible (directe ou proxy site).
Chaque source d'un épisode voiranime.xyz se résout via
`/lecteur/prepare/<source_id>?content=..&episode=..` (AJAX : en-têtes Accept JSON et
X-Requested-With obligatoires) qui renvoie :
`{"success": true, "media_type": "mp4"|"hls", "stream_url": "/proxy/media?payload=<b64>"}`.
Le payload base64 est un JSON `{"url": ..., "referer": ..., "kind": ...}` où `url` est
l'URL directe chez l'hébergeur d'origine. Deux cas (vérifiés en live) :
- mp4 : l'URL d'origine est libre d'accès avec son Referer (Sibnet…) → on la retourne
directement (téléchargement/Range natifs, sans double saut).
- hls : les CDN utilisés (SmoothPre & co) signent leurs playlists pour le backend du
site — accès direct 403. Le proxy `/proxy/media?payload=…` du site, lui, sert la
playlist ET ses segments (chemins re-hostés) → on retourne l'URL proxy absolue.
"""
import base64
import binascii
import json
import logging
from urllib.parse import parse_qs, urlparse
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
from app.scrapers.http import fetch
logger = logging.getLogger(__name__)
@register_hoster
class VoirAnimeExtractor(HosterExtractor):
name = "voiranime"
domains = ("voiranime.xyz",)
async def extract(self, embed_url: str) -> VideoLink:
origin = self._origin_of(embed_url)
raw = await fetch(
embed_url,
referer=self._referer_for(embed_url),
headers={
"Accept": "application/json",
"X-Requested-With": "XMLHttpRequest",
},
)
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise ScrapeError(f"voiranime : réponse prepare invalide : {raw[:120]}") from exc
if not data.get("success"):
raise ScrapeError(f"voiranime : {data.get('error') or 'échec de préparation du lecteur'}")
media_type = str(data.get("media_type") or "").lower()
stream_url = str(data.get("stream_url") or "")
if media_type == "hls":
if not stream_url:
raise ScrapeError(f"voiranime : flux HLS sans stream_url dans {embed_url}")
return VideoLink(
url=f"{origin}/{stream_url.lstrip('/')}",
hoster=self.name,
headers={"Referer": f"{origin}/"},
is_hls=True,
)
payload = self._extract_payload(stream_url)
url = str(payload.get("url") or "")
if not url:
raise ScrapeError(f"voiranime : payload sans URL de flux dans {embed_url}")
referer = str(payload.get("referer") or "")
return VideoLink(
url=url,
hoster=self.name,
headers={"Referer": referer} if referer else {},
is_hls=url.split("?")[0].endswith(".m3u8"),
)
@staticmethod
def _origin_of(embed_url: str) -> str:
parsed = urlparse(embed_url)
return f"{parsed.scheme}://{parsed.netloc}"
@staticmethod
def _referer_for(embed_url: str) -> str:
"""Reconstruit la page lecteur d'où provient la requête prepare."""
origin = VoirAnimeExtractor._origin_of(embed_url)
query = parse_qs(urlparse(embed_url).query)
content = query.get("content", [""])[0]
episode = query.get("episode", [""])[0]
if content and episode:
return f"{origin}/lecteur/{content}/{episode}"
return f"{origin}/"
@staticmethod
def _extract_payload(stream_url: str) -> dict:
payload_b64 = parse_qs(urlparse(stream_url).query).get("payload", [""])[0]
if not payload_b64:
raise ScrapeError(f"voiranime : pas de payload dans stream_url ({stream_url[:80]})")
padded = payload_b64 + "=" * (-len(payload_b64) % 4)
try:
decoded = base64.b64decode(padded)
except (binascii.Error, ValueError):
try:
decoded = base64.urlsafe_b64decode(padded)
except (binascii.Error, ValueError) as exc:
raise ScrapeError(f"voiranime : payload illisible : {payload_b64[:60]}") from exc
try:
data = json.loads(decoded)
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise ScrapeError(f"voiranime : payload non-JSON : {payload_b64[:60]}") from exc
if not isinstance(data, dict):
raise ScrapeError("voiranime : payload inattendu (pas un objet JSON)")
return data
+13 -2
View File
@@ -41,15 +41,26 @@ async def fetch(
*, *,
referer: str | None = None, referer: str | None = None,
retries: int = 2, retries: int = 2,
headers: dict[str, str] | None = None,
) -> str: ) -> str:
"""GET d'une page avec retries ; lève ScrapeError en cas d'échec définitif.""" """GET d'une page avec retries ; lève ScrapeError en cas d'échec définitif."""
headers = {"Referer": referer} if referer else {} request_headers = dict(headers) if headers else {}
if referer:
request_headers.setdefault("Referer", referer)
last_error: Exception | None = None last_error: Exception | None = None
for attempt in range(retries + 1): for attempt in range(retries + 1):
try: try:
response = await get_client().get(url, headers=headers) response = await get_client().get(url, headers=request_headers)
response.raise_for_status() response.raise_for_status()
return response.text return response.text
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
if 400 <= status < 500 and status not in (408, 429):
raise ScrapeError(f"Échec de récupération de {url} : HTTP {status} (définitif)") from exc
last_error = exc
logger.warning("fetch %s — HTTP %d, tentative %d/%d", url, status, attempt + 1, retries + 1)
if attempt < retries:
await asyncio.sleep(1.0 * (attempt + 1))
except (httpx.HTTPError, httpx.InvalidURL) as exc: except (httpx.HTTPError, httpx.InvalidURL) as exc:
last_error = exc last_error = exc
logger.warning("fetch %s — tentative %d/%d : %s", url, attempt + 1, retries + 1, exc) logger.warning("fetch %s — tentative %d/%d : %s", url, attempt + 1, retries + 1, exc)
+4 -5
View File
@@ -259,27 +259,26 @@ class FrenchMangaScraper(SourceScraper):
if season_match: if season_match:
season = int(season_match.group(1)) season = int(season_match.group(1))
numbers: set[float] = set() info = data.get("info") or {}
episodes: list[Episode] = []
for version in versions: for version in versions:
numbers: set[float] = set()
for number_text in data.get(version) or {}: for number_text in data.get(version) or {}:
try: try:
numbers.add(float(number_text)) numbers.add(float(number_text))
except ValueError: except ValueError:
logger.warning("french_manga : numéro d'épisode invalide %r", number_text) 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): for number in sorted(numbers):
number_text = str(int(number)) if number.is_integer() else str(number) number_text = str(int(number)) if number.is_integer() else str(number)
episode_info = info.get(number_text) or info.get(str(int(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 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( episodes.append(
Episode( Episode(
number=number, number=number,
title=label, title=label,
url=f"{page_url}#{config['episodes']['fragment_prefix']}={version}-{number_text}", url=f"{page_url}#{config['episodes']['fragment_prefix']}={version}-{number_text}",
season=season, season=season,
version=version,
) )
) )
if not episodes: if not episodes:
+258
View File
@@ -0,0 +1,258 @@
"""Source VoirAnime (voiranime.xyz) — plateforme VODSPHERE, animes VOSTFR.
Faits structurels (vérifiés en live) :
- Recherche : GET /catalogue?q=<q> → `article.catalogue-card` (lien `a.catalogue-poster`,
poster et titre dans `img[src]` / `img[alt]`).
- Fiche : /catalogue/<slug> — titre `h1`, poster `img[alt^="Affiche de"]`, compteur
d'épisodes dans `.media-detail-meta span` (« 1192 épisodes »), synopsis `.media-detail-story p`.
- Épisodes : `a.detail-episode-card` avec `data-season` et libellé `<small>S1 · E1</small>`.
- Lecteur : /lecteur/<content>/<episode> — attributs data-prepare-url (résolution AJAX)
et data-fallback-source-url (source suivante) ; en fin de chaîne le fallback repointe
vers une source déjà vue → dédoublonnage par id de source obligatoire.
- Accueil : carrousels #carousel-new (nouveautés) et #carousel-added (ajouts), 24 cartes.
- La résolution d'un lien direct se fait dans l'extracteur hoster dédié (voir
app/scrapers/hosters/voiranime.py) : cette source ne renvoie que les URLs prepare.
"""
import logging
import re
from copy import deepcopy
from urllib.parse import quote_plus, urljoin
from bs4 import BeautifulSoup, Tag
from app.scrapers.base import (
Episode,
ScrapeError,
SearchResult,
SourceScraper,
TitleDetails,
register_source,
)
from app.scrapers.config_loader import load_scraper_config
from app.scrapers.http import fetch_soup
logger = logging.getLogger(__name__)
DEFAULT_CONFIG: dict = {
"search": {
"endpoint": "/catalogue?q={query}",
"result": "article.catalogue-card",
"link": "a.catalogue-poster",
},
"details": {
"title": "h1",
"poster": 'img[alt^="Affiche de"]',
"meta_count": ".media-detail-meta span",
"synopsis": ".media-detail-story p",
},
"episodes": {
"card": "a.detail-episode-card",
},
"player": {
"root": "[data-prepare-url]",
"prepare_attr": "data-prepare-url",
"fallback_attr": "data-fallback-source-url",
"max_sources": 4,
},
"latest": {
"path": "/",
"carousels": ("#carousel-new", "#carousel-added"),
"item": "article.content-card",
"link": "a.card-poster",
},
}
_SLUG_RE = re.compile(r"/catalogue/([a-z0-9-]+)", re.IGNORECASE)
_EPISODE_LABEL_RE = re.compile(r"S\s*(\d+)\s*·\s*E\s*(\d+(?:[.,]\d+)?)", re.IGNORECASE)
_EPISODE_COUNT_RE = re.compile(r"(\d+)\s*épisodes", re.IGNORECASE)
_SOURCE_ID_RE = re.compile(r"/lecteur/prepare/(\d+)")
_FALLBACK_SOURCE_RE = re.compile(r"[?&]source=(\d+)")
def _merged_config() -> dict:
merged = deepcopy(DEFAULT_CONFIG)
for key, values in load_scraper_config("voiranime").items():
if isinstance(values, dict) and isinstance(merged.get(key), dict):
merged[key].update(values)
else:
merged[key] = values
return merged
@register_source
class VoirAnimeScraper(SourceScraper):
name = "voiranime"
label = "VoirAnime"
base_url = "https://voiranime.xyz"
media_types = ("anime",)
# ------------------------------------------------------------- helpers
def _title_url(self, source_id: str) -> str:
return f"{self.base_url}/catalogue/{source_id}"
@staticmethod
def _slug_from_url(url: str) -> str | None:
match = _SLUG_RE.search(url)
return match.group(1) if match else None
@staticmethod
def _text(element: Tag | None) -> str:
return element.get_text(" ", strip=True) if element else ""
def _result_from_card(self, link: Tag) -> SearchResult | None:
href = link.get("href")
if not href:
return None
source_id = self._slug_from_url(str(href))
if not source_id:
return None
img = link.select_one("img")
title = (img.get("alt") if img else None) or source_id.replace("-", " ")
image = img.get("src") if img else None
return SearchResult(
source=self.name,
source_id=source_id,
title=str(title).strip(),
url=self._title_url(source_id),
image_url=str(image) if image else None,
)
# ------------------------------------------------------------- search
async def search(self, query: str) -> list[SearchResult]:
config = _merged_config()
search_cfg = config["search"]
url = self.base_url + search_cfg["endpoint"].format(query=quote_plus(query))
soup = await fetch_soup(url)
results: list[SearchResult] = []
seen: set[str] = set()
for card in soup.select(search_cfg["result"]):
link = card.select_one(search_cfg["link"])
if link is None:
logger.warning("voiranime : carte de résultat sans lien, ignorée")
continue
result = self._result_from_card(link)
if result and result.source_id not in seen:
seen.add(result.source_id)
results.append(result)
return results
# ------------------------------------------------------------- détails
async def get_details(self, source_id: str) -> TitleDetails:
config = _merged_config()
details_cfg = config["details"]
url = self._title_url(source_id)
soup = await fetch_soup(url)
title = self._text(soup.select_one(details_cfg["title"])) or source_id.replace("-", " ")
poster = soup.select_one(details_cfg["poster"])
image = str(poster["src"]) if poster and poster.get("src") else None
synopsis = self._text(soup.select_one(details_cfg["synopsis"])) or None
episode_count: int | None = None
count_el = soup.select_one(details_cfg["meta_count"])
if count_el:
match = _EPISODE_COUNT_RE.search(self._text(count_el))
if match:
episode_count = int(match.group(1))
episodes = self._parse_episodes(soup, config)
return TitleDetails(
source=self.name,
source_id=source_id,
title=title,
url=url,
synopsis=synopsis,
image_url=image,
episode_count=episode_count if episode_count is not None else len(episodes),
episodes=episodes,
)
# ------------------------------------------------------------- épisodes
async def list_episodes(self, source_id: str) -> list[Episode]:
config = _merged_config()
soup = await fetch_soup(self._title_url(source_id))
return self._parse_episodes(soup, config)
def _parse_episodes(self, soup: BeautifulSoup, config: dict) -> list[Episode]:
episodes: list[Episode] = []
for card in soup.select(config["episodes"]["card"]):
href = card.get("href")
if not href:
continue
label = self._text(card)
match = _EPISODE_LABEL_RE.search(label)
if not match:
logger.warning("voiranime : libellé d'épisode illisible : %r", label[:60])
continue
episodes.append(
Episode(
number=float(match.group(2).replace(",", ".")),
title=None,
url=urljoin(self.base_url + "/", str(href)),
season=int(match.group(1)),
)
)
return episodes
# ------------------------------------------------------------- lecteurs
async def extract_embed_links(self, episode_url: str) -> list[str]:
"""Parcourt la chaîne Lecteur 1 → 2 → … et renvoie les URLs prepare absolues."""
config = _merged_config()
player_cfg = config["player"]
prepare_urls: list[str] = []
seen_sources: set[str] = set()
page_url = episode_url
for _ in range(int(player_cfg["max_sources"])):
soup = await fetch_soup(page_url)
root = soup.select_one(player_cfg["root"])
if root is None:
break
prepare = root.get(player_cfg["prepare_attr"])
if not prepare:
break
prepare_url = urljoin(self.base_url + "/", str(prepare))
match = _SOURCE_ID_RE.search(prepare_url)
source_key = match.group(1) if match else prepare_url
if source_key in seen_sources:
break
seen_sources.add(source_key)
prepare_urls.append(prepare_url)
fallback = root.get(player_cfg["fallback_attr"])
if not fallback:
break
page_url = urljoin(self.base_url + "/", str(fallback))
next_source = _FALLBACK_SOURCE_RE.search(page_url)
if next_source and next_source.group(1) in seen_sources:
break
if not prepare_urls:
raise ScrapeError(f"voiranime : aucun lecteur trouvé sur {episode_url}")
return prepare_urls
# ------------------------------------------------------------- découverte
async def latest(self) -> list[SearchResult]:
"""Nouveautés + ajouts récents — carrousels de la page d'accueil."""
config = _merged_config()
latest_cfg = config["latest"]
soup = await fetch_soup(self.base_url + latest_cfg["path"])
results: list[SearchResult] = []
seen: set[str] = set()
for carousel in latest_cfg["carousels"]:
section = soup.select_one(carousel)
if section is None:
logger.debug("voiranime : carrousel %s absent de l'accueil", carousel)
continue
for card in section.select(latest_cfg["item"]):
link = card.select_one(latest_cfg["link"])
if link is None:
continue
result = self._result_from_card(link)
if result and result.source_id not in seen:
seen.add(result.source_id)
results.append(result)
return results
+12
View File
@@ -85,6 +85,7 @@ _SLUG_RE = re.compile(r"([^/]+)\.html?$")
_NUMBER_RE = re.compile(r"(\d+(?:[.,]\d+)?)") _NUMBER_RE = re.compile(r"(\d+(?:[.,]\d+)?)")
_SEASON_RE = re.compile(r"Saison\s*:?\s*(\d+)", re.IGNORECASE) _SEASON_RE = re.compile(r"Saison\s*:?\s*(\d+)", re.IGNORECASE)
_SAFE_FRAGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$") _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: def _merged_config() -> dict:
@@ -246,8 +247,18 @@ class VostfreeScraper(SourceScraper):
return int(season_match.group(1)) return int(season_match.group(1))
return 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]: def _parse_episodes(self, soup: BeautifulSoup, page_url: str, season: int) -> list[Episode]:
config = _merged_config() config = _merged_config()
version = self._detect_version(soup, page_url)
options = soup.select(config["episodes"]["option"]) options = soup.select(config["episodes"]["option"])
entries: list[tuple[str, str]] = [] entries: list[tuple[str, str]] = []
seen_ids: set[str] = set() seen_ids: set[str] = set()
@@ -274,6 +285,7 @@ class VostfreeScraper(SourceScraper):
title=label or f"Episode {index}", title=label or f"Episode {index}",
url=f"{page_url}#{button_id}" if button_id else page_url, url=f"{page_url}#{button_id}" if button_id else page_url,
season=season, season=season,
version=version,
) )
) )
return episodes return episodes
+95 -12
View File
@@ -63,6 +63,46 @@ def sanitize_filename(name: str) -> str:
return name[:150] 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: class DownloadManager:
"""File d'attente de téléchargements, injectée dans les routes via app.state.""" """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 download_dir = get_settings().download_dir
rows = await db.fetchall("SELECT file_path FROM downloads WHERE file_path IS NOT NULL") rows = await db.fetchall("SELECT file_path FROM downloads WHERE file_path IS NOT NULL")
known = {row["file_path"] for row in rows} known = {row["file_path"] for row in rows}
for path in download_dir.iterdir(): for path in download_dir.rglob("*"):
if path.is_file() and path.suffix != ".part" and path.name not in known: 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 size = path.stat().st_size
await db.execute( await db.execute(
"INSERT INTO downloads " "INSERT INTO downloads "
@@ -126,22 +170,28 @@ class DownloadManager:
" total_bytes, downloaded_bytes) " " total_bytes, downloaded_bytes) "
"VALUES (?, ?, ?, ?, ?, 'done', ?, ?)", "VALUES (?, ?, ?, ?, ?, 'done', ?, ?)",
( (
f"file:{path.name}", f"file:{rel_path}",
"", "",
"", "",
path.stem, path.stem,
path.name, rel_path,
size, size,
size, size,
), ),
) )
logger.info("Fichier restauré depuis le disque : %s", path.name) logger.info("Fichier restauré depuis le disque : %s", rel_path)
# ------------------------------------------------------------ API publique # ------------------------------------------------------------ API publique
async def enqueue(self, video_url: str, page_url: str, title: str) -> dict: async def enqueue(
"""Ajoute un téléchargement ; retourne la tâche existante si doublon actif.""" self, video_url: str, page_url: str, title: str, source_key: str | None = None
source_key = video_url ) -> 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( existing = await db.fetchone(
f"SELECT * FROM downloads WHERE source_key = ? AND status IN " f"SELECT * FROM downloads WHERE source_key = ? AND status IN "
f"({','.join('?' * len(ACTIVE_STATUSES))})", 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"]) logger.info("Anti-doublon : %s déjà en file (id=%s)", title, existing["id"])
return self._to_dict(existing, duplicate=True) 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( cursor = await db.execute(
"INSERT INTO downloads (source_key, video_url, page_url, title, file_path) " "INSERT INTO downloads (source_key, video_url, page_url, title, file_path) "
"VALUES (?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?)",
(source_key, video_url, page_url, title, filename), (source_key, video_url, page_url, title, file_path),
) )
download_id = cursor.lastrowid download_id = cursor.lastrowid
await self._queue.put(download_id) await self._queue.put(download_id)
@@ -223,7 +276,29 @@ class DownloadManager:
) )
return cursor.rowcount or 0 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( rows = await db.fetchall(
"SELECT * FROM downloads ORDER BY " "SELECT * FROM downloads ORDER BY "
"CASE status WHEN 'downloading' THEN 0 WHEN 'pending' THEN 1 WHEN 'paused' THEN 2 " "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) data = await self.get(download_id)
if data is None: if data is None:
return 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: for queue in self._listeners:
with contextlib.suppress(asyncio.QueueFull): with contextlib.suppress(asyncio.QueueFull):
queue.put_nowait(data) queue.put_nowait(message)
# ------------------------------------------------------------ worker interne # ------------------------------------------------------------ worker interne
@@ -300,6 +381,7 @@ class DownloadManager:
"""Téléchargement HTTP direct avec reprise via Range.""" """Téléchargement HTTP direct avec reprise via Range."""
video_url, file_path = row["video_url"], row["file_path"] video_url, file_path = row["video_url"], row["file_path"]
target = get_settings().download_dir / file_path target = get_settings().download_dir / file_path
target.parent.mkdir(parents=True, exist_ok=True)
part = self._part_path(file_path) part = self._part_path(file_path)
downloaded = part.stat().st_size if part.exists() else 0 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"] video_url, file_path = row["video_url"], row["file_path"]
target = get_settings().download_dir / file_path target = get_settings().download_dir / file_path
target.parent.mkdir(parents=True, exist_ok=True)
part = self._part_path(file_path) part = self._part_path(file_path)
part.unlink(missing_ok=True) # pas de reprise partielle en HLS part.unlink(missing_ok=True) # pas de reprise partielle en HLS
+65 -1
View File
@@ -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 json
import logging import logging
import secrets import secrets
from datetime import UTC, datetime
from app.db import db 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) await set_setting(f"source:{name}:enabled", enabled)
logger.info("Source %s %s", name, "activée" if enabled else "désactivée") 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 # ---------------------------------------------------------------- intégrations *arr
TORZNAB_APIKEY_KEY = "torznab:apikey" TORZNAB_APIKEY_KEY = "torznab:apikey"
@@ -70,3 +113,24 @@ async def set_sonarr_config(url: str, apikey: str) -> None:
await set_setting(SONARR_URL_KEY, url.rstrip("/")) await set_setting(SONARR_URL_KEY, url.rstrip("/"))
await set_setting(SONARR_APIKEY_KEY, apikey.strip()) await set_setting(SONARR_APIKEY_KEY, apikey.strip())
logger.info("Configuration Sonarr enregistrée (%s)", url) logger.info("Configuration Sonarr enregistrée (%s)", url)
# ---------------------------------------------------------------- mise à jour logicielle
UPDATE_GITEA_URL_KEY = "update:gitea_url"
UPDATE_REPO_KEY = "update:repo"
UPDATE_TOKEN_KEY = "update:token"
async def get_update_config() -> dict[str, str]:
return {
"gitea_url": await get_setting(UPDATE_GITEA_URL_KEY, "https://git.lanro.eu"),
"repo": await get_setting(UPDATE_REPO_KEY, "Roman/ohm_streaming"),
"token": await get_setting(UPDATE_TOKEN_KEY, ""),
}
async def set_update_config(gitea_url: str, repo: str, token: str) -> None:
await set_setting(UPDATE_GITEA_URL_KEY, gitea_url.rstrip("/"))
await set_setting(UPDATE_REPO_KEY, repo.strip().strip("/"))
await set_setting(UPDATE_TOKEN_KEY, token.strip())
logger.info("Configuration de mise à jour enregistrée (%s)", repo)
+1
View File
@@ -50,6 +50,7 @@ class SonarrService:
base_url=url, base_url=url,
timeout=_REQUEST_TIMEOUT, timeout=_REQUEST_TIMEOUT,
headers={"X-Api-Key": apikey}, 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: async def _get(self, path: str, params: dict | None = None) -> dict | list | None:
+88 -9
View File
@@ -13,7 +13,9 @@ Formats :
- `t=search` → recherche libre (Prowlarr, recherche manuelle) - `t=search` → recherche libre (Prowlarr, recherche manuelle)
""" """
import asyncio import asyncio
import hashlib
import logging import logging
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@@ -83,19 +85,59 @@ def _bencode(value) -> bytes:
def torrent_stub(announce_url: str, name: str) -> bytes: def torrent_stub(announce_url: str, name: str) -> bytes:
"""Fichier .torrent minimal (le vrai téléchargement est fait par OhmStreaming).""" """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, "announce": announce_url,
"created by": "OhmStreaming", "created by": "OhmStreaming",
"comment": name, "comment": name,
"info": { "info": info,
"name": name + ".mp4",
"length": 0,
"piece length": 32768,
"pieces": b"\x00" * 20,
},
} }
) )
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: class TorznabService:
@@ -127,6 +169,31 @@ class TorznabService:
"""Recherche libre : tous les épisodes des fiches trouvées.""" """Recherche libre : tous les épisodes des fiches trouvées."""
return await self.tvsearch(q) 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): async def _search_source(self, source: SourceScraper, q: str):
try: try:
return await asyncio.wait_for(source.search(q), timeout=_SEARCH_TIMEOUT) return await asyncio.wait_for(source.search(q), timeout=_SEARCH_TIMEOUT)
@@ -179,11 +246,22 @@ class TorznabService:
# ------------------------------------------------------------ grab # ------------------------------------------------------------ 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. """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). 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. 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 from app.scrapers.base import get_source
@@ -202,7 +280,8 @@ class TorznabService:
link = await self._resolve_video(scraper, match.url) link = await self._resolve_video(scraper, match.url)
title = f"{series} S{season:02d}E{ep:02d}" 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"): if link.is_hls or link.headers.get("Referer"):
result["note"] = "HLS/proxy : OhmStreaming gère le téléchargement via ffmpeg" 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")) logger.info("Torznab grab %s → download id=%s", title, result.get("id"))
+113
View File
@@ -0,0 +1,113 @@
"""Mises à jour logicielles.
- Détection : dernier tag semver du dépôt Gitea via son API (jeton requis, repo privé).
- 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.services.settings import get_update_config
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}
class UpdateError(Exception):
"""Erreur de mise à jour (config absente, 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 (cache 5 min). None si non configuré ou erreur."""
now = time.monotonic()
latest_cache = _cache["latest"]
if not force and latest_cache and now - float(_cache["checked_at"]) < _CACHE_TTL:
return str(latest_cache) # type: ignore[arg-type]
config = await get_update_config()
if not config["repo"]:
return None
url = f"{config['gitea_url']}/api/v1/repos/{config['repo']}/tags?limit=20"
# Jeton requis uniquement pour un dépôt privé (public en lecture : inutile)
headers = {"Authorization": f"token {config['token']}"} if config["token"] else {}
try:
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
resp = await client.get(url, headers=headers)
resp.raise_for_status()
tags = [t["name"] for t in resp.json() if parse_tag(t.get("name", ""))]
except (httpx.HTTPError, ValueError, KeyError) as exc:
logger.warning("Vérification de mise à jour impossible : %s", exc)
return None
latest = max(tags, key=parse_tag) if tags else None # type: ignore[arg-type]
_cache.update(checked_at=now, latest=latest)
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)
async def status() -> dict[str, object]:
"""État complet : version courante, dernière dispo, config."""
config = await get_update_config()
current = get_version()
latest = await fetch_latest_version()
return {
"current": current,
"latest": latest,
"update_available": bool(latest and is_newer(latest, current)),
"configured": bool(config["repo"]),
"docker": bool(get_settings().watchtower_url),
"config": config,
}
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"}
+91 -1
View File
@@ -147,6 +147,7 @@ button { font-family: inherit; }
.btn-sm { padding: 0.4rem 0.85rem; font-size: 0.82rem; } .btn-sm { padding: 0.4rem 0.85rem; font-size: 0.82rem; }
.btn-danger { background: #3d1114; color: var(--danger); border: 1px solid #5c1a1e; } .btn-danger { background: #3d1114; color: var(--danger); border: 1px solid #5c1a1e; }
.btn-danger:hover { background: #4d1518; filter: none; } .btn-danger:hover { background: #4d1518; filter: none; }
.btn-accent { background: var(--accent); color: #fff; border: none; font-weight: 600; }
/* ------------------------------------------------------------ grille de posters */ /* ------------------------------------------------------------ grille de posters */
@@ -343,7 +344,46 @@ button { font-family: inherit; }
.status-downloading { background: rgba(229, 9, 20, 0.16); color: #ff6b73; } .status-downloading { background: rgba(229, 9, 20, 0.16); color: #ff6b73; }
.status-done { background: rgba(70, 211, 105, 0.14); color: var(--success); } .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); } .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 */ /* ------------------------------------------------------------ lecteur */
.player-wrap { .player-wrap {
@@ -652,3 +692,53 @@ button { font-family: inherit; }
.search-bar { flex-direction: column; } .search-bar { flex-direction: column; }
.search-bar .btn { justify-content: center; } .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; }
+67
View File
@@ -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);
})();
+145 -11
View File
@@ -26,12 +26,23 @@
<template x-for="s in sources" :key="s.name"> <template x-for="s in sources" :key="s.name">
<tr> <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><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><div class="toggle" :class="s.enabled && 'on'" @click="toggle(s)"></div></td>
<td> <td>
<span x-show="s.health == null" style="color:var(--text-dim)">—</span> <span x-show="!s.health" style="color:var(--text-dim)">—</span>
<span x-show="s.health === true" style="color:var(--success)">✔ OK</span> <span x-show="s.health" :style="s.health?.healthy ? 'color:var(--success)' : 'color:var(--danger)'"
<span x-show="s.health === false" style="color:var(--danger)" :title="s.healthDetail">✖ KO</span> :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>
<td><button class="btn btn-sm btn-ghost" @click="healthCheck(s)" :disabled="s._checking" x-text="s._checking ? '…' : 'Tester'"></button></td> <td><button class="btn btn-sm btn-ghost" @click="healthCheck(s)" :disabled="s._checking" x-text="s._checking ? '…' : 'Tester'"></button></td>
</tr> </tr>
@@ -48,6 +59,12 @@
Ajoutez un indexeur « Torznab » dans Prowlarr ou Sonarr avec cette URL et cette clé — 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. les épisodes grabés par Sonarr entrent directement dans la file de téléchargements OhmStreaming.
</p> </p>
<p style="color:var(--text-dim);font-size:0.85rem;margin:0.6rem 0 0">
<strong>Encore mieux — client de téléchargement :</strong> dans Sonarr, ajoutez un client
<em>qBittorrent</em> pointant vers OhmStreaming (URL <code>http://&lt;hote-ohm&gt;:8777</code>,
utilisateur <code>ohm</code>, mot de passe = la clé API ci-dessus). Les épisodes seront
suivis en temps réel puis importés et renommés par Sonarr dans votre bibliothèque.
</p>
<table class="table"> <table class="table">
<tbody> <tbody>
<tr> <tr>
@@ -84,6 +101,38 @@
:style="integrations.testResult?.ok ? 'color:var(--success)' : 'color:var(--danger)'" :style="integrations.testResult?.ok ? 'color:var(--success)' : 'color:var(--danger)'"
x-text="integrations.testResult?.detail"></p> x-text="integrations.testResult?.detail"></p>
</div> </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">
<input class="input" x-model="upd.config.gitea_url" placeholder="https://git.lanro.eu"
style="flex:2;min-width:220px" @change="upd._dirty = true">
<input class="input" x-model="upd.config.repo" placeholder="Roman/ohm_streaming"
style="flex:1;min-width:180px" @change="upd._dirty = true">
<input class="input" x-model="upd.config.token" type="password" placeholder="Jeton d'accès Gitea"
style="flex:1;min-width:180px" @change="upd._dirty = true">
<button class="btn btn-sm" @click="saveUpdate()" :disabled="!upd._dirty">Enregistrer</button>
<button class="btn btn-sm btn-ghost" @click="checkUpdate()" :disabled="upd._checking"
x-text="upd._checking ? '…' : 'Vérifier'"></button>
<button class="btn btn-sm btn-accent" x-show="upd.update_available" @click="applyUpdate()"
:disabled="upd._applying" x-text="upd._applying ? 'Mise à jour…' : '⬆ Mettre à jour maintenant'"></button>
</div>
<p x-show="!upd.configured" style="margin:0.5rem 0 0;font-size:0.85rem;color:var(--text-dim)">
Renseignez le dépôt Gitea pour activer la détection des mises à jour.
Jeton d'accès (droit « lecture ») nécessaire uniquement si le dépôt est privé.
</p>
<p x-show="upd.configured && !upd.docker" style="margin:0.5rem 0 0;font-size:0.85rem;color:var(--text-dim)">
⚠ Mise à jour automatique disponible uniquement en déploiement Docker
(<code>docker compose pull &amp;&amp; docker compose up -d</code> sinon).
</p>
</div>
+
<div class="panel"> <div class="panel">
<h2>👥 Utilisateurs</h2> <h2>👥 Utilisateurs</h2>
@@ -121,6 +170,11 @@
function adminPage() { function adminPage() {
return { return {
users: [], sources: [], stats: {}, forbidden: false, users: [], sources: [], stats: {}, forbidden: false,
upd: {
current: '', latest: null, update_available: false, configured: false, docker: false,
config: { gitea_url: '', repo: '', token: '' },
_dirty: false, _checking: false, _applying: false,
},
integrations: { integrations: {
torznab: { apikey: '', endpoint: '' }, sonarr: { url: '', apikey: '' }, torznab: { apikey: '', endpoint: '' }, sonarr: { url: '', apikey: '' },
_regen: false, _testing: false, testResult: null, _regen: false, _testing: false, testResult: null,
@@ -132,7 +186,7 @@ function adminPage() {
]); ]);
if (u.status === 403) { this.forbidden = true; return; } if (u.status === 403) { this.forbidden = true; return; }
this.users = await u.json(); 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(); this.stats = await st.json();
const itg = await fetch('/api/admin/integrations'); const itg = await fetch('/api/admin/integrations');
if (itg.ok) { if (itg.ok) {
@@ -140,8 +194,10 @@ function adminPage() {
this.integrations.torznab = data.torznab; this.integrations.torznab = data.torznab;
this.integrations.sonarr = { ...data.sonarr, _dirty: false }; 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) { copy(value) {
navigator.clipboard.writeText(value).then(() => toast('✔ Copié')); navigator.clipboard.writeText(value).then(() => toast('✔ Copié'));
}, },
@@ -170,13 +226,53 @@ function adminPage() {
if (res.ok) s.enabled = !s.enabled; if (res.ok) s.enabled = !s.enabled;
}, },
async healthCheck(s) { async saveUrl(s) {
s._checking = true; s._saving = true;
const res = await fetch(`/api/admin/sources/${s.name}/health`, { method: 'POST' }); 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(); 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; 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(); }, async post(url) { await fetch(url, { method: 'POST' }); await this.load(); },
@@ -188,6 +284,44 @@ function adminPage() {
this.integrations._testing = false; this.integrations._testing = false;
}, },
applyUpdateData(data) {
this.upd.current = data.current;
this.upd.latest = data.latest;
this.upd.update_available = data.update_available;
this.upd.configured = data.configured;
this.upd.docker = data.docker;
this.upd.config = { ...data.config, _dirty: false };
},
async saveUpdate() {
const res = await fetch('/api/admin/update', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.upd.config),
});
if (res.ok) { this.applyUpdateData(await res.json()); toast('✔ Configuration enregistrée'); }
},
async checkUpdate() {
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) { async del(u) {
if (!confirm(`Supprimer le compte « ${u.username} » ?`)) return; if (!confirm(`Supprimer le compte « ${u.username} » ?`)) return;
await fetch('/api/admin/users/' + u.id, { method: 'DELETE' }); await fetch('/api/admin/users/' + u.id, { method: 'DELETE' });
+1
View File
@@ -32,6 +32,7 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>
<script src="/static/js/update-watcher.js"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>
+129 -19
View File
@@ -3,18 +3,18 @@
{% block title %}Téléchargements — Ohm Stream{% endblock %} {% block title %}Téléchargements — Ohm Stream{% endblock %}
{% block content %} {% block content %}
<div x-data="downloadsPage()" x-init="connect()"> <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"> <div style="display:flex;align-items:center;gap:0.8rem;margin-bottom:1.6rem;flex-wrap:wrap">
<div> <div>
<h1 class="page-title">Téléchargements</h1> <h1 class="page-title">Téléchargements</h1>
<p class="page-sub" style="margin-bottom:0"> <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)'" <span :style="connected ? 'color:var(--success)' : 'color:var(--danger)'"
x-text="connected ? '● temps réel' : '○ reconnecté…'"></span> x-text="connected ? '● temps réel' : '○ reconnecté…'"></span>
</p> </p>
</div> </div>
<div style="flex:1"></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> <button class="btn btn-danger btn-sm" @click="action('cancel-all')">✖ Tout annuler</button>
</div> </div>
@@ -22,12 +22,38 @@
<div class="empty-state"><div class="big">📭</div>Aucun téléchargement. Lance une recherche !</div> <div class="empty-state"><div class="big">📭</div>Aucun téléchargement. Lance une recherche !</div>
</template> </template>
<div class="episode-list"> <div class="dl-groups">
<template x-for="d in items" :key="d.id"> <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-row">
<div class="dl-head"> <div class="dl-head">
<span class="status-pill" :class="'status-' + d.status" x-text="d.status_label"></span> <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"> <div class="dl-actions">
<template x-if="d.status === 'downloading'"> <template x-if="d.status === 'downloading'">
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/pause')">⏸</button> <button class="btn btn-sm btn-ghost" @click="action(d.id + '/pause')">⏸</button>
@@ -44,6 +70,17 @@
<template x-if="d.status === 'done'"> <template x-if="d.status === 'done'">
<a class="btn btn-sm" :href="'/watch/' + d.id">▶ Regarder</a> <a class="btn btn-sm" :href="'/watch/' + d.id">▶ Regarder</a>
</template> </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> </div>
<div style="display:flex;align-items:center;gap:0.9rem"> <div style="display:flex;align-items:center;gap:0.9rem">
@@ -64,6 +101,9 @@
</template> </template>
</div> </div>
</div> </div>
</template>
</div>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
@@ -71,6 +111,8 @@
function downloadsPage() { function downloadsPage() {
return { return {
items: [], connected: false, es: null, 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() { connect() {
this.es = new EventSource('/api/downloads/events'); this.es = new EventSource('/api/downloads/events');
@@ -78,29 +120,97 @@ function downloadsPage() {
this.es.onerror = () => this.connected = false; this.es.onerror = () => this.connected = false;
this.es.onmessage = (ev) => { this.es.onmessage = (ev) => {
const msg = JSON.parse(ev.data); const msg = JSON.parse(ev.data);
if (msg.type === 'snapshot') this.items = msg.items; if (msg.type === 'snapshot') this.items = msg.items.map(d => this.decorate(d));
else if (msg.type === 'update') this.upsert(msg.item); 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) { upsert(item) {
const i = this.items.findIndex(d => d.id === item.id); const i = this.items.findIndex(d => d.id === item.id);
if (i >= 0) this.items[i] = item; if (i >= 0) this.items[i] = item;
else this.items.unshift(item); else {
this.sort(); this.items.unshift(item);
}, delete this.toggled[item._base]; // nouveau groupe/épisode → retour au comportement par défaut
}
sort() {
const order = { downloading: 0, pending: 1, paused: 2 };
this.items.sort((a, b) => (order[a.status] ?? 3) - (order[b.status] ?? 3));
}, },
async action(path) { async action(path) {
await fetch('/api/downloads/' + path, { method: 'POST' }); 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'); 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) { fmtBytes(n) {
+37 -6
View File
@@ -28,7 +28,7 @@
<div class="title-stats"> <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.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 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>
<div class="title-actions"> <div class="title-actions">
<button class="btn" @click="downloadSeason" :disabled="seasonJob.running"> <button class="btn" @click="downloadSeason" :disabled="seasonJob.running">
@@ -43,11 +43,21 @@
</div> </div>
</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"> <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"> <div class="episode-row">
<span class="episode-num" x-text="'E' + ep.number"></span> <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> <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 btn-ghost" @click="streamEpisode(ep)" :disabled="ep._busy">▶ Stream</button>
<button class="btn btn-sm" @click="downloadEpisode(ep)" :disabled="ep._busy">⬇</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, details: null, loading: true, error: null, favorite: false,
player: { active: false, hls: null }, player: { active: false, hls: null },
seasonJob: { running: false, done: 0, total: 0 }, 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) { async load(source, sourceId) {
this.source = source; this.sourceId = 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); if (!res.ok) throw new Error((await res.json()).detail || 'Erreur ' + res.status);
this.details = await res.json(); this.details = await res.json();
this.details.episodes.forEach(e => e._busy = false); this.details.episodes.forEach(e => e._busy = false);
this.versionFilter = this.epVersions.includes('vostfr') ? 'vostfr' : 'all';
document.title = this.details.title + ' — Ohm Stream'; document.title = this.details.title + ' — Ohm Stream';
} catch (e) { this.error = e.message; } } catch (e) { this.error = e.message; }
finally { this.loading = false; } finally { this.loading = false; }
@@ -98,7 +126,10 @@ function titlePage() {
return data.links[0]; 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) // Lecture via le proxy serveur (tokens liés à l'IP, Referer obligatoire)
openPlayer(link) { openPlayer(link) {
@@ -148,8 +179,8 @@ function titlePage() {
}, },
async downloadSeason() { async downloadSeason() {
this.seasonJob = { running: true, done: 0, total: this.details.episodes.length }; this.seasonJob = { running: true, done: 0, total: this.filteredEpisodes.length };
for (const ep of this.details.episodes) { for (const ep of this.filteredEpisodes) {
await this.downloadEpisode(ep); await this.downloadEpisode(ep);
this.seasonJob.done++; this.seasonJob.done++;
} }
+19
View File
@@ -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"
+57
View File
@@ -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"
+18
View File
@@ -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 "$@"
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# Installation guidée d'Ohm Stream Downloader (premier setup).
#
# bash scripts/install.sh (depuis un clone du dépôt)
# curl -fsSL <gitea>/raw/branch/main/scripts/install.sh | bash
#
# Options non interactives : --dir <chemin> --port <n> --skip-login
# (les valeurs passées en option court-circuitent les questions)
#
# Pose les questions (destination des animés, port), génère les secrets,
# écrit .env, branche le montage Docker, démarre et attend le service.
# ---------------------------------------------------------------------------
set -euo pipefail
REGISTRY_HOST="git.lanro.eu"
REPO_URL="https://${REGISTRY_HOST}/Roman/ohm_streaming.git"
COMPOSE_FILE="docker-compose.yml"
BOLD="\033[1m"; DIM="\033[2m"; GREEN="\033[32m"; YELLOW="\033[33m"; RED="\033[31m"; RESET="\033[0m"
step() { printf "\n${BOLD}▸ %s${RESET}\n" "$1"; }
ok() { printf " ${GREEN}✔${RESET} %s\n" "$1"; }
warn() { printf " ${YELLOW}⚠${RESET} %s\n" "$1"; }
die() { printf " ${RED}✖ %s${RESET}\n" "$1" >&2; exit 1; }
ARG_DIR="" ARG_PORT="" ARG_SKIP_LOGIN=0
while [ $# -gt 0 ]; do
case "$1" in
--dir) ARG_DIR="$2"; shift 2;;
--port) ARG_PORT="$2"; shift 2;;
--skip-login) ARG_SKIP_LOGIN=1; shift;;
*) shift;;
esac
done
ask() { # ask <question> <défaut> — lit /dev/tty pour rester compatible curl|bash
local answer=""
if [ -r /dev/tty ]; then
read -r -p "$(printf ' %s ${DIM}[%s]${RESET} ' "$1" "$2")" answer </dev/tty 2>/dev/null || true
fi
echo "${answer:-$2}"
}
rand_hex() { # rand_hex <nb octets>
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex "$1"
else
head -c "$1" /dev/urandom | od -An -tx1 | tr -d ' \n'
fi
}
printf "${BOLD}⛩ Ohm Stream Downloader — installation${RESET}\n"
printf "${DIM}Télécharge et streame des animes — Docker + Watchtower${RESET}\n"
# ---------------------------------------------------------------- prérequis
step "Vérification des prérequis"
command -v docker >/dev/null 2>&1 || die "Docker n'est pas installé. → curl -fsSL https://get.docker.com | sh"
docker compose version >/dev/null 2>&1 || die "Le plugin Docker Compose manque (paquet docker-compose-plugin)."
ok "Docker + Compose présents"
# Depuis un clone ? sinon on clone (permet curl … | bash)
if [ ! -f "$COMPOSE_FILE" ]; then
INSTALL_DIR="$(ask "Dossier d'installation" "$HOME/ohm_streaming")"
if [ -e "$INSTALL_DIR" ]; then
[ -f "$INSTALL_DIR/$COMPOSE_FILE" ] || die "$INSTALL_DIR existe déjà (et n'est pas une installation Ohm)."
step "Mise à jour du clone existant"
git -C "$INSTALL_DIR" pull --ff-only || warn "git pull a échoué, on continue avec la version locale."
else
step "Récupération du projet"
command -v git >/dev/null 2>&1 || die "git est requis pour cloner $REPO_URL"
git clone "$REPO_URL" "$INSTALL_DIR"
fi
cd "$INSTALL_DIR"
fi
ok "Projet prêt dans $(pwd)"
# ---------------------------------------------------------------- registre privé
step "Accès au registre privé ($REGISTRY_HOST)"
if [ "$ARG_SKIP_LOGIN" = "1" ]; then
warn "--skip-login : connexion au registre ignorée"
elif docker login "$REGISTRY_HOST" 2>/dev/null; then
ok "Connecté au registre"
else
warn "Pas encore connecté — un compte Gitea avec accès lecture au dépôt est requis."
if [ "$(ask "Se connecter maintenant (docker login) ?" "o")" = "o" ]; then
docker login "$REGISTRY_HOST" || die "docker login a échoué — relance le script après t'être connecté."
ok "Connecté au registre"
else
die "Sans docker login, l'image ne pourra pas être tirée."
fi
fi
# ---------------------------------------------------------------- destination des épisodes
step "Destination des épisodes téléchargés"
if [ -n "$ARG_DIR" ]; then
HOST_DIR="$ARG_DIR"
else
printf " 1) ${BOLD}Dossier dédié « ohm »${RESET} (recommandé) : Ohm crée/utilise un dossier\n\
à part, à ajouter à ta bibliothèque Plex. Tes dossiers Sonarr/Plex\n\
existants ne sont JAMAIS modifiés.\n\
2) Dossier de ton choix (ex. un sous-dossier de ta biblio d'animes).\n\
S'il n'est pas vide, Ohm importera aussi les fichiers déjà présents.\n"
MODE="$(ask "Choix (1/2)" "1")"
if [ "$MODE" = "1" ]; then
HOST_DIR="$(ask "Chemin du dossier dédié" "/opt/ohm/animes")"
else
HOST_DIR="$(ask "Chemin du dossier de téléchargement" "/plex_videos/anime/ohm")"
fi
fi
if [ -e "$HOST_DIR" ] && [ -n "$(ls -A "$HOST_DIR" 2>/dev/null)" ]; then
warn "$HOST_DIR n'est pas vide : Ohm l'adoptera tel quel (droits existants conservés)."
warn "Le conteneur (uid 1000) doit pouvoir y écrire."
fi
if ! mkdir -p "$HOST_DIR" 2>/dev/null; then
printf ' sudo mkdir -p %q && sudo chown 1000:1000 %q\n' "$HOST_DIR" "$HOST_DIR" >&2
die "Impossible de créer $HOST_DIR — crée-le avec sudo puis relance."
fi
ok "Épisodes → $HOST_DIR (un dossier vide est pris en charge automatiquement)"
# ---------------------------------------------------------------- port + secrets
step "Configuration"
PORT="${ARG_PORT:-$(ask "Port HTTP exposé" "8777")}"
step "Écriture de .env"
if [ -f .env ]; then
cp .env ".env.backup.$(date +%s)"
warn ".env existant sauvegardé"
fi
SECRET_KEY="$(rand_hex 32)"
WT_TOKEN="$(rand_hex 24)"
cat > .env <<EOF
# Généré par scripts/install.sh le $(date '+%Y-%m-%d %H:%M')
OHM_SECRET_KEY=${SECRET_KEY}
WATCHTOWER_TOKEN=${WT_TOKEN}
OHM_PORT=${PORT}
EOF
chmod 600 .env
ok "Secrets générés (OHM_SECRET_KEY, WATCHTOWER_TOKEN)"
# ---------------------------------------------------------------- compose : montage téléchargements
step "Configuration du montage Docker"
cp "$COMPOSE_FILE" "$COMPOSE_FILE.dist"
if sed -i "s|^\([[:space:]]*- \)\./downloads:/downloads.*$|\1${HOST_DIR}:/downloads|" "$COMPOSE_FILE" \
&& grep -q -- "${HOST_DIR}:/downloads" "$COMPOSE_FILE"; then
ok "/downloads → $HOST_DIR (original conservé en $COMPOSE_FILE.dist)"
else
mv "$COMPOSE_FILE.dist" "$COMPOSE_FILE"
die "Montage introuvable dans $COMPOSE_FILE — édite la ligne ./downloads:/downloads à la main."
fi
# ---------------------------------------------------------------- démarrage
step "Démarrage (docker compose up -d)"
docker compose up -d || die "Démarrage échoué — logs : docker compose logs"
step "Attente du démarrage"
HEALTH_URL="http://127.0.0.1:${PORT}/health"
for i in $(seq 1 30); do
if curl -sf "$HEALTH_URL" >/dev/null 2>&1 || wget -q -O /dev/null "$HEALTH_URL" 2>/dev/null; then
break
fi
sleep 2
if [ "$i" = "30" ]; then docker compose logs --tail 30 ohm; die "Le service ne répond pas sur $HEALTH_URL"; fi
done
ok "Service en ligne"
HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
printf "\n${BOLD}${GREEN}⛩ Ohm Stream est prêt !${RESET}\n\n"
printf " URL : ${BOLD}http://%s:%s${RESET}\n" "${HOST_IP:-<ip-serveur>}" "$PORT"
printf " Épisodes : %s\n" "$HOST_DIR"
printf " 1er compte : ${BOLD}le compte créé en premier devient administrateur${RESET}\n\n"
printf " ${DIM}Plex : ajoute %s comme dossier d'une bibliothèque (ou comme dossier\n supplémentaire de ta bibliothèque animes) — les épisodes arrivent rangés par\n animé : <Animé>/<Animé> - E12 (VOSTFR).mp4${RESET}\n\n" "$HOST_DIR"
printf " ${DIM}Commandes : docker compose logs -f · stop · pull && up -d (mise à jour)${RESET}\n"
+44
View File
@@ -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
# Option : OHM_REGISTRY=git.lanro.eu/roman/ohm_streaming (défaut) pour viser un autre registre.
set -euo pipefail
VERSION="${1:?Usage : ./scripts/release.sh <version> (ex. 0.2.0)}"
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 "$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 »."
+22
View File
@@ -38,3 +38,25 @@ async def database() -> AsyncIterator[None]:
await db.execute(f"DELETE FROM {table}") await db.execute(f"DELETE FROM {table}")
yield yield
await db.close() 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
View File
@@ -1,25 +1,8 @@
"""Tests d'intégration API (auth, downloads, favoris, streaming, admin).""" """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.config import get_settings
from app.db import db from app.db import db
from app.main import app from app.scrapers.base import ScrapeError, SearchResult
@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
async def test_health(client): async def test_health(client):
@@ -86,6 +69,21 @@ async def test_stream_range(client, admin_cookies):
) )
assert r.status_code == 416 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): async def test_admin_required(client):
await client.post("/auth/register", data={"username": "admin2", "password": "secret123"}) 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()} states = {s["name"]: s["enabled"] for s in r.json()}
assert states["vostfree"] is False assert states["vostfree"] is False
assert states["french_manga"] is True 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
View File
@@ -1,16 +1,19 @@
"""Tests unitaires : format interne, sanitisation, auth, registres scrapers.""" """Tests unitaires : format interne, sanitisation, auth, registres scrapers."""
import httpx
import pytest import pytest
from app import auth from app import auth
from app.scrapers import http as http_module
from app.scrapers.base import ( from app.scrapers.base import (
ScrapeError,
decode_internal_url, decode_internal_url,
encode_internal_url, encode_internal_url,
get_source, get_source,
import_all_scrapers, import_all_scrapers,
resolve_hoster, resolve_hoster,
) )
from app.services.downloads import sanitize_filename from app.services.downloads import plex_filename, sanitize_filename
# ------------------------------------------------------------ format interne # ------------------------------------------------------------ format interne
@@ -52,6 +55,32 @@ def test_sanitize_no_traversal():
assert sanitize_filename("../../etc/passwd") == "etc passwd" 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 # ------------------------------------------------------------ auth
@@ -107,3 +136,43 @@ def test_hoster_resolution():
assert resolve_hoster("https://vidmoly.to/embed-x.html").name == "vidmoly" assert resolve_hoster("https://vidmoly.to/embed-x.html").name == "vidmoly"
assert resolve_hoster("https://sendvid.com/embed/x").name == "sendvid" assert resolve_hoster("https://sendvid.com/embed/x").name == "sendvid"
assert resolve_hoster("https://inconnu.example.com/v/1") is None assert resolve_hoster("https://inconnu.example.com/v/1") is None
# ------------------------------------------------------------ client HTTP
class _StatusClient:
"""Fake client HTTP : sert une série de codes statut puis un corps 200."""
def __init__(self, statuses: list[int]):
self._statuses = statuses
self.calls = 0
async def get(self, url, headers=None):
self.calls += 1
request = httpx.Request("GET", url)
status = self._statuses.pop(0) if self._statuses else 200
response = httpx.Response(status, request=request, text="ok")
response.raise_for_status()
return response
async def test_fetch_fails_fast_on_definitive_4xx(monkeypatch):
client = _StatusClient([422])
monkeypatch.setattr("app.scrapers.http.get_client", lambda: client)
with pytest.raises(ScrapeError, match="422"):
await http_module.fetch("https://site.example/lecteur/prepare/1?content=2&episode=3")
assert client.calls == 1 # aucun retry sur erreur définitive
@pytest.mark.parametrize("status", [429, 500, 503])
async def test_fetch_retries_transitory_errors(monkeypatch, status):
client = _StatusClient([status, 200])
monkeypatch.setattr("app.scrapers.http.get_client", lambda: client)
async def no_sleep(_delay):
return None
monkeypatch.setattr("app.scrapers.http.asyncio.sleep", no_sleep)
assert await http_module.fetch("https://site.example/page", retries=2) == "ok"
assert client.calls == 2
+47
View File
@@ -6,6 +6,7 @@ import pytest
from aiohttp import web from aiohttp import web
from app.config import get_settings from app.config import get_settings
from app.db import db
from app.services.downloads import download_manager as dm from app.services.downloads import download_manager as dm
PAYLOAD = b"x" * 500_000 PAYLOAD = b"x" * 500_000
@@ -102,3 +103,49 @@ async def test_cancel(file_server, manager):
await dm.cancel(d["id"]) await dm.cancel(d["id"])
data = await dm.get(d["id"]) data = await dm.get(d["id"])
assert data["status"] in ("cancelled", "done") # course possible si déjà fini 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
+143
View File
@@ -0,0 +1,143 @@
"""API compatible qBittorrent : flux complet côté Sonarr (add → suivi → import → delete)."""
import asyncio
from urllib.parse import urlencode
import pytest
from aiohttp import web
from app.config import get_settings
from app.scrapers.base import Episode, VideoLink
from app.services.downloads import download_manager as dm
from app.services.settings import get_torznab_apikey
from app.services.torznab import build_stub
from app.services.torznab import torznab as torznab_svc
PAYLOAD = b"q" * 200_000
def _make_stub(base: str) -> tuple[bytes, str]:
announce = (
"http://ohm:8777/torznab/api?"
+ urlencode({"source": "fake", "sid": "42", "season": 1, "ep": 2, "series": "Frieren"})
)
return build_stub(announce, "Frieren S01E02")
def test_stub_deterministic():
stub, infohash = _make_stub("x")
stub2, infohash2 = _make_stub("x")
assert stub == stub2 and infohash == infohash2 and len(infohash) == 40
@pytest.fixture
async def file_server():
async def handle(request: web.Request) -> web.StreamResponse:
return web.Response(body=PAYLOAD)
app = web.Application()
app.router.add_get("/video.mp4", handle)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
yield f"http://127.0.0.1:{port}"
await runner.cleanup()
@pytest.fixture
async def manager():
await dm.start()
yield dm
await dm.stop()
async def test_qbit_sonarr_flow(client, file_server, manager, monkeypatch):
apikey = await get_torznab_apikey()
# -- auth : mauvais mot de passe refusé, bon mot de passe accepté
r = await client.post("/api/v2/auth/login", data={"username": "ohm", "password": "mauvais"})
assert r.status_code == 403
r = await client.post("/api/v2/auth/login", data={"username": "ohm", "password": apikey})
assert r.text == "Ok."
# -- les endpoints nécessitent la session SID
r = await client.post("/api/v2/auth/login", data={"username": "ohm", "password": apikey})
sid = r.cookies["SID"]
client.cookies.clear()
r = await client.get("/api/v2/app/version")
assert r.status_code == 403
client.cookies.set("SID", sid)
r = await client.get("/api/v2/app/version")
assert r.status_code == 200 and r.text.startswith("v")
r = await client.get("/api/v2/app/webapiVersion")
assert r.status_code == 200
# -- le scraping est simulé : l'épisode pointe vers notre serveur local
async def fake_episodes(scraper, source_id):
return [Episode(number=2, title="Épisode 2", url="http://fake/ep2", season=1)]
async def fake_resolve(scraper, episode_url):
return VideoLink(url=f"{file_server}/video.mp4", hoster="fake")
class _FakeScraper:
name = "fake"
monkeypatch.setattr("app.scrapers.base.get_source", lambda name: _FakeScraper())
monkeypatch.setattr(torznab_svc, "_episodes_of", fake_episodes)
monkeypatch.setattr(torznab_svc, "_resolve_video", fake_resolve)
# -- Sonarr pousse le .torrent de service reçu de l'indexeur
stub, infohash = _make_stub(file_server)
r = await client.post(
"/api/v2/torrents/add",
files={"torrents": ("ohm.torrent", stub, "application/x-bittorrent")},
)
assert r.text == "Ok."
# -- suivi : présent avec le bon infohash, progresse jusqu'à « terminé »
item = None
for _ in range(150):
items = (await client.get("/api/v2/torrents/info")).json()
item = next((i for i in items if i["hash"] == infohash), None)
if item and item["state"] == "pausedUP":
break
await asyncio.sleep(0.1)
assert item is not None, "téléchargement invisible dans torrents/info"
assert item["state"] == "pausedUP"
assert item["progress"] == 1
assert item["name"] == "Frieren S01E02"
assert item["content_path"].startswith("/downloads/Frieren S01/Frieren S01E02.mp4")
# -- properties : le chemin que Sonarr importera
r = await client.get("/api/v2/torrents/properties", params={"hash": infohash})
assert r.status_code == 200
props = r.json()
assert props["content_path"] == item["content_path"]
assert props["completion_date"] > 0
# -- le fichier est bien là où Sonarr l'attend
host_path = get_settings().download_dir / item["content_path"].removeprefix("/downloads/")
assert host_path.read_bytes() == PAYLOAD
# -- retrait après import : file vidée, fichier supprimé
r = await client.post(
"/api/v2/torrents/delete", data={"hashes": infohash, "deleteFiles": "true"}
)
assert r.text == "Ok."
items = (await client.get("/api/v2/torrents/info")).json()
assert items == []
assert not host_path.exists()
async def test_qbit_add_rejects_garbage(client):
apikey = await get_torznab_apikey()
await client.post("/api/v2/auth/login", data={"username": "ohm", "password": apikey})
r = await client.post(
"/api/v2/torrents/add",
files={"torrents": ("ohm.torrent", b"n'importe quoi", "application/x-bittorrent")},
)
assert r.text == "Fals."
assert (await client.get("/api/v2/torrents/info")).json() == []
+27 -8
View File
@@ -128,9 +128,26 @@ async def test_torznab_tvsearch_all_episodes(client, apikey, fake_source):
assert "Frieren S02E01 VOSTFR WEB-DL" in titles assert "Frieren S02E01 VOSTFR WEB-DL" in titles
async def test_torznab_search_requires_query(client, apikey): async def test_torznab_search_without_query_serves_latest(client, apikey, fake_source):
# Sans q : flux RSS des nouveautés (RSS sync / test Sonarr), pas une erreur 400.
async def fake_latest():
return [
SearchResult(
source="fake",
source_id="frieren-1",
title="Frieren",
url="https://fake.example/frieren",
)
]
fake_source.latest = fake_latest
r = await client.get("/torznab/api", params={"t": "search", "apikey": apikey}) r = await client.get("/torznab/api", params={"t": "search", "apikey": apikey})
assert r.status_code == 400 assert r.status_code == 200
root = ET.fromstring(r.text)
items = root.findall(".//item")
assert len(items) == 1
# seul le dernier épisode de la nouveauté est publié (S02E01 > S01E02 > S01E01)
assert items[0].find("title").text == "Frieren S02E01 VOSTFR WEB-DL"
# ---------------------------------------------------------------- grab # ---------------------------------------------------------------- grab
@@ -138,10 +155,12 @@ async def test_torznab_search_requires_query(client, apikey):
class _FakeManager: class _FakeManager:
def __init__(self) -> None: 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: async def enqueue(
self.calls.append((video_url, page_url, title)) 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"} 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.status_code == 200
assert r.headers["content-type"].startswith("application/x-bittorrent") assert r.headers["content-type"].startswith("application/x-bittorrent")
assert r.content.startswith(b"d") and b"OhmStreaming" in r.content # bencode valide assert r.content.startswith(b"d") and b"OhmStreaming" in r.content # bencode valide
assert manager.calls == [ video_url, source_key = manager.calls[0]
("https://cdn.example/video.mp4", "https://fake.example/ep2", "Frieren S01E02") 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): async def test_torznab_download_rejects_bad_key(client):
+184
View File
@@ -0,0 +1,184 @@
"""Mises à jour : semver, détection Gitea (API tags), déclenchement Watchtower, endpoints."""
import httpx
import pytest
from app.services import update as update_service
from app.services.settings import get_update_config
from app.version import get_version
@pytest.fixture(autouse=True)
def reset_update_cache():
update_service.invalidate_cache()
yield
update_service.invalidate_cache()
# ---------------------------------------------------------------- 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_without_repo():
from app.services.settings import set_update_config
await set_update_config("https://git.example", "", "tok")
assert await update_service.fetch_latest_version(force=True) is None
async def test_fetch_latest_public_repo_no_token(monkeypatch):
"""Dépôt public : pas de jeton → appel sans en-tête Authorization."""
from app.services.settings import set_update_config
await set_update_config("https://git.example", "roman/ohm", "")
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):
"""Avec jeton : en-tête Authorization + plus haut semver retenu, puis cache."""
from app.services.settings import set_update_config
await set_update_config("https://git.example", "roman/ohm", "tok")
class FakeResponse:
def raise_for_status(self) -> None:
pass
def json(self) -> list[dict]:
return [{"name": "v0.1.0"}, {"name": "v0.2.3"}, {"name": "v1.0.0-rc"}, {"name": "divers"}]
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)
latest = await update_service.fetch_latest_version(force=True)
assert latest == "v0.2.3"
assert calls == [
("https://git.example/api/v1/repos/roman/ohm/tags?limit=20", {"Authorization": "token tok"})
]
# puis servi par le cache (plus d'appel réseau)
assert await update_service.fetch_latest_version() == "v0.2.3"
assert len(calls) == 1
async def test_fetch_latest_network_error_degrades(monkeypatch):
from app.services.settings import set_update_config
await set_update_config("https://git.example", "roman/ohm", "tok")
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["configured"] is True and data["docker"] is False # repo par défaut présent
assert data["latest"] is None and data["update_available"] is False
assert data["config"]["gitea_url"] == "https://git.lanro.eu"
assert data["config"]["repo"] == "Roman/ohm_streaming"
async def test_update_save_config_normalizes(client, admin_cookies):
r = await client.put(
"/api/admin/update",
json={"gitea_url": "https://git.example/", "repo": "/roman/ohm/", "token": " tok "},
cookies=admin_cookies,
)
assert r.status_code == 200
assert await get_update_config() == {
"gitea_url": "https://git.example",
"repo": "roman/ohm",
"token": "tok",
}
async def test_update_check_degrades_gracefully(client, admin_cookies):
# Gitea configuré mais injoignable : le check répond quand même (latest=None)
await client.put(
"/api/admin/update",
json={"gitea_url": "https://git.inexistant", "repo": "roman/ohm", "token": "tok"},
cookies=admin_cookies,
)
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
+238
View File
@@ -0,0 +1,238 @@
"""Tests du scraper VoirAnime (source + extracteur prepare) sur fixtures HTML réelles."""
import base64
import json
import pytest
from app.scrapers.base import ScrapeError, get_source, import_all_scrapers, resolve_hoster
from app.scrapers.hosters.voiranime import VoirAnimeExtractor
from app.scrapers.sources.voiranime import VoirAnimeScraper
# ------------------------------------------------------------ fixtures HTML
SEARCH_HTML = """
<div class="catalogue-grid">
<article class="catalogue-card catalogue-media-card">
<a href="/catalogue/one-piece" class="catalogue-poster">
<img src="https://img.example/one-piece.jpg" alt="one piece">
</a>
</article>
<article class="catalogue-card catalogue-media-card">
<a href="/catalogue/one-punch-man" class="catalogue-poster">
<img src="https://img.example/opm.jpg" alt="one punch man">
</a>
</article>
<article class="catalogue-card catalogue-media-card">
<a href="/catalogue/one-outs" class="catalogue-poster">
<img src="/assets/img/placeholders/content-placeholder.svg" alt="one outs">
</a>
</article>
</div>
"""
DETAILS_HTML = """
<h1>solo leveling</h1>
<div class="media-detail-meta"><span>26 épisodes</span></div>
<img src="https://img.example/solo.jpg" alt="Affiche de solo leveling">
<div class="media-detail-story">
<span class="media-detail-section-label">Synopsis</span>
<p> Sung Jinwoo, chasseur le plus faible. </p>
</div>
<a href="/lecteur/965/29054" class="detail-episode-card" data-season="1">
<small>S1 · E1</small><span>Episode 1</span>
</a>
<a href="/lecteur/965/29055" class="detail-episode-card" data-season="1">
<small>S1 · E2</small><span>Episode 2</span>
</a>
<a href="/lecteur/965/29060" class="detail-episode-card" data-season="2">
<small>S2 · E1</small><span>Episode 1</span>
</a>
<a href="/lecteur/965/29061" class="detail-episode-card" data-season="2">
<small>S2 · E2.5</small><span>Episode 2.5</span>
</a>
"""
PLAYER_PAGE_1 = """
<main class="tv-player" data-player
data-content-id="1626" data-episode-id="46416" data-source-id="169261"
data-prepare-url="/lecteur/prepare/169261?content=1626&amp;episode=46416"
data-fallback-source-url="/lecteur/1626/46416?lang=vostfr&amp;source=169262&amp;previous_source=169261">
</main>
"""
# fin de chaîne : le fallback repointe vers la source 1 (aller-retour observé en live)
PLAYER_PAGE_2 = """
<main class="tv-player" data-player
data-content-id="1626" data-episode-id="46416" data-source-id="169262"
data-prepare-url="/lecteur/prepare/169262?content=1626&amp;episode=46416"
data-fallback-source-url="/lecteur/1626/46416?lang=vostfr&amp;source=169261&amp;previous_source=169262">
</main>
"""
HOME_HTML = """
<div class="carousel" id="carousel-new">
<article class="content-card">
<a href="/catalogue/you-and-i-are-polar-opposites" class="card-poster">
<img src="https://img.example/polar.jpg" alt="you and i are polar opposites">
</a>
</article>
</div>
<div class="carousel" id="carousel-added">
<article class="content-card">
<a href="/catalogue/you-and-i-are-polar-opposites" class="card-poster">
<img src="https://img.example/polar.jpg" alt="you and i are polar opposites">
</a>
</article>
<article class="content-card">
<a href="/catalogue/solo-leveling" class="card-poster">
<img src="https://img.example/solo.jpg" alt="solo leveling">
</a>
</article>
</div>
"""
def _soup(html: str):
from bs4 import BeautifulSoup
return BeautifulSoup(html, "lxml")
def _prepare_payload(url: str, referer: str) -> str:
raw = json.dumps({"url": url, "referer": referer, "kind": "file"}).encode()
return base64.b64encode(raw).decode()
# ------------------------------------------------------------ source
async def test_search_parses_catalogue_cards(monkeypatch):
async def fake_fetch_soup(url, **kwargs):
assert "q=one+piece" in url
return _soup(SEARCH_HTML)
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
results = await VoirAnimeScraper().search("one piece")
assert [r.source_id for r in results] == ["one-piece", "one-punch-man", "one-outs"]
assert results[0].title == "one piece"
assert results[0].url == "https://voiranime.xyz/catalogue/one-piece"
assert results[0].image_url == "https://img.example/one-piece.jpg"
assert results[2].image_url == "/assets/img/placeholders/content-placeholder.svg"
async def test_get_details_and_episodes(monkeypatch):
async def fake_fetch_soup(url, **kwargs):
assert url.endswith("/catalogue/solo-leveling")
return _soup(DETAILS_HTML)
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
scraper = VoirAnimeScraper()
details = await scraper.get_details("solo-leveling")
assert details.title == "solo leveling"
assert details.episode_count == 26
assert details.image_url == "https://img.example/solo.jpg"
assert details.synopsis.startswith("Sung Jinwoo")
episodes = details.episodes
assert len(episodes) == 4
assert (episodes[0].season, episodes[0].number) == (1, 1.0)
assert (episodes[2].season, episodes[2].number) == (2, 1.0)
assert (episodes[3].season, episodes[3].number) == (2, 2.5)
assert episodes[0].url == "https://voiranime.xyz/lecteur/965/29054"
standalone = await scraper.list_episodes("solo-leveling")
assert len(standalone) == len(episodes)
async def test_extract_embed_links_follows_fallback_chain(monkeypatch):
pages = {
"https://voiranime.xyz/lecteur/1626/46416": PLAYER_PAGE_1,
"https://voiranime.xyz/lecteur/1626/46416?lang=vostfr&source=169262&previous_source=169261": PLAYER_PAGE_2,
}
async def fake_fetch_soup(url, **kwargs):
return _soup(pages[url])
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
urls = await VoirAnimeScraper().extract_embed_links("https://voiranime.xyz/lecteur/1626/46416")
assert urls == [
"https://voiranime.xyz/lecteur/prepare/169261?content=1626&episode=46416",
"https://voiranime.xyz/lecteur/prepare/169262?content=1626&episode=46416",
]
async def test_extract_embed_links_no_player_raises(monkeypatch):
async def fake_fetch_soup(url, **kwargs):
return _soup("<html><body>pas de lecteur</body></html>")
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
with pytest.raises(ScrapeError):
await VoirAnimeScraper().extract_embed_links("https://voiranime.xyz/lecteur/1/2")
async def test_latest_merges_carousels_dedup(monkeypatch):
async def fake_fetch_soup(url, **kwargs):
assert url == "https://voiranime.xyz/"
return _soup(HOME_HTML)
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
results = await VoirAnimeScraper().latest()
assert [r.source_id for r in results] == ["you-and-i-are-polar-opposites", "solo-leveling"]
# ------------------------------------------------------------ extracteur hoster
async def test_hoster_decodes_prepare_payload(monkeypatch):
payload = _prepare_payload(
"https://video.sibnet.ru/v/abc123/6235096.mp4", "https://video.sibnet.ru/"
)
async def fake_fetch(url, **kwargs):
assert "X-Requested-With" in kwargs.get("headers", {})
assert kwargs.get("referer") == "https://voiranime.xyz/lecteur/1626/46416"
return json.dumps(
{"success": True, "media_type": "mp4", "stream_url": f"/proxy/media?payload={payload}"}
)
monkeypatch.setattr("app.scrapers.hosters.voiranime.fetch", fake_fetch)
link = await VoirAnimeExtractor().extract(
"https://voiranime.xyz/lecteur/prepare/169261?content=1626&episode=46416"
)
assert link.url == "https://video.sibnet.ru/v/abc123/6235096.mp4"
assert link.hoster == "voiranime"
assert link.headers == {"Referer": "https://video.sibnet.ru/"}
assert link.is_hls is False
async def test_hoster_marks_hls(monkeypatch):
payload = _prepare_payload("https://cdn.example/hls/master.m3u8", "https://cdn.example/")
async def fake_fetch(url, **kwargs):
return json.dumps(
{"success": True, "media_type": "hls", "stream_url": f"/proxy/media?payload={payload}"}
)
monkeypatch.setattr("app.scrapers.hosters.voiranime.fetch", fake_fetch)
link = await VoirAnimeExtractor().extract("https://voiranime.xyz/lecteur/prepare/1?content=2&episode=3")
# HLS signé pour le backend du site → on sert l'URL proxy du site, pas le CDN
assert link.url == f"https://voiranime.xyz/proxy/media?payload={payload}"
assert link.is_hls is True
assert link.headers == {"Referer": "https://voiranime.xyz/"}
async def test_hoster_prepare_failure_raises(monkeypatch):
async def fake_fetch(url, **kwargs):
return json.dumps({"success": False, "error": "Flux extrait inaccessible"})
monkeypatch.setattr("app.scrapers.hosters.voiranime.fetch", fake_fetch)
with pytest.raises(ScrapeError, match="Flux extrait inaccessible"):
await VoirAnimeExtractor().extract("https://voiranime.xyz/lecteur/prepare/1?content=2&episode=3")
def test_source_and_hoster_registered():
import_all_scrapers()
assert get_source("voiranime").label == "VoirAnime"
extractor = resolve_hoster("https://voiranime.xyz/lecteur/prepare/169261?content=1&episode=2")
assert extractor is not None and extractor.name == "voiranime"