Compare commits
9
Commits
v0.1.0
...
65761885ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65761885ba | ||
|
|
7399f4b781 | ||
|
|
5fedc4f9d7 | ||
|
|
853b4e0866 | ||
|
|
4e1faf4cd9 | ||
|
|
1f326d34dd | ||
|
|
9500a84a5e | ||
|
|
affc97c527 | ||
|
|
9148b5fb6a |
@@ -0,0 +1,20 @@
|
||||
# Contexte de build minimal : ni secrets, ni données, ni caches
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.example
|
||||
.venv
|
||||
.plasma
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
data/
|
||||
downloads/
|
||||
tests/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
docker-compose.yml.example
|
||||
README.md
|
||||
Projet_descriptions.md
|
||||
scripts/
|
||||
+10
-1
@@ -1,4 +1,4 @@
|
||||
# Copier en .env et adapter. Toutes les variables sont préfixées OHM_.
|
||||
# Copier en .env et adapter. Toutes les variables applicatives sont préfixées OHM_.
|
||||
|
||||
# OBLIGATOIRE en production : clé de signature des tokens (32+ caractères)
|
||||
OHM_SECRET_KEY=change-me-in-production
|
||||
@@ -23,3 +23,12 @@ OHM_SECRET_KEY=change-me-in-production
|
||||
# OHM_REFRESH_TOKEN_TTL_DAYS=30
|
||||
|
||||
# OHM_DEBUG=false
|
||||
|
||||
# ── Déploiement Docker (docker-compose.yml) ────────────────────────────────
|
||||
# Secret partagé entre OhmStreaming et Watchtower pour déclencher les mises
|
||||
# à jour depuis la page Admin. OBLIGATOIRE en Docker.
|
||||
# Générer : openssl rand -hex 24
|
||||
WATCHTOWER_TOKEN=change-me-watchtower
|
||||
|
||||
# Port hôte exposé par docker compose (défaut 8777)
|
||||
# OHM_PORT=8777
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Étape 1 — dépendances Python via uv (cache couche par couche)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
|
||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||
WORKDIR /opt/ohm
|
||||
|
||||
# D'abord les métadonnées seules : couche réutilisable tant que uv.lock ne bouge pas
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev --no-install-project --no-cache
|
||||
|
||||
# Puis le code
|
||||
COPY app ./app
|
||||
RUN uv sync --frozen --no-dev --no-cache
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Étape 2 — image d'exécution minimale
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM python:3.13-slim-bookworm
|
||||
|
||||
# ffmpeg (téléchargements HLS), ca-certificates (scraping HTTPS),
|
||||
# gosu (bascule utilisateur non-root dans l'entrypoint)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Utilisateur non-root
|
||||
RUN useradd --create-home --uid 1000 ohm
|
||||
|
||||
WORKDIR /opt/ohm
|
||||
COPY --from=builder --chown=ohm:ohm /opt/ohm/.venv ./.venv
|
||||
COPY --chown=ohm:ohm app ./app
|
||||
COPY --chown=ohm:ohm docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
ENV PATH="/opt/ohm/.venv/bin:$PATH" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
# Chemins montés en volumes par docker-compose
|
||||
OHM_DATA_DIR=/data \
|
||||
OHM_DOWNLOAD_DIR=/downloads \
|
||||
OHM_DATABASE_PATH=/data/ohm.db
|
||||
|
||||
# Version cuite dans l'image par scripts/release.sh (build-arg VERSION)
|
||||
ARG VERSION=dev
|
||||
ENV OHM_VERSION=${VERSION}
|
||||
|
||||
RUN mkdir -p /data /downloads && chown -R ohm:ohm /opt/ohm /data /downloads
|
||||
# Root par défaut : l'entrypoint chown les volumes puis passe en « ohm »
|
||||
|
||||
EXPOSE 8777
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8777/health', timeout=4)"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8777"]
|
||||
@@ -3,12 +3,86 @@
|
||||
Application web **auto-hébergée** (homelab) : centre de contrôle unique pour découvrir,
|
||||
regarder et télécharger des animes et séries VOSTFR/VF.
|
||||
|
||||
> MVP — Phase 1 : recherche multi-sources, fiches enrichies, streaming, gestionnaire
|
||||
> de téléchargements temps réel, bibliothèque locale, favoris, administration.
|
||||
> Phase 2 : intégration Sonarr/Prowlarr — OhmStreaming est un **indexeur Torznab**
|
||||
> et personnalise « Pour toi » avec vos téléchargements Sonarr.
|
||||
## Déploiement (Docker) — recommandé
|
||||
|
||||
## Démarrage rapide
|
||||
Le déploiement officiel passe par Docker Compose : l'image (ffmpeg inclus) est
|
||||
hébergée sur le **registre privé du Gitea** — rien n'est publié publiquement.
|
||||
|
||||
### Installation guidée (recommandée)
|
||||
|
||||
```bash
|
||||
git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming
|
||||
./scripts/install.sh
|
||||
```
|
||||
|
||||
Le script vérifie Docker, demande la **destination des épisodes** (dossier dédié
|
||||
recommandé — voir « Bibliothèque Plex/Sonarr » ci-dessous), le port, génère les
|
||||
secrets, branche le montage et démarre. Non interactif aussi :
|
||||
`./scripts/install.sh --dir /srv/animes --port 8777 --skip-login`.
|
||||
|
||||
### À la main
|
||||
|
||||
```bash
|
||||
# 1. Récupérer le projet puis se connecter au registre privé (compte Gitea avec accès lecture)
|
||||
git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming
|
||||
docker login git.lanro.eu
|
||||
|
||||
# 2. Configuration locale
|
||||
cp .env.example .env
|
||||
# → OHM_SECRET_KEY (openssl rand -hex 32) et WATCHTOWER_TOKEN (openssl rand -hex 24) obligatoires
|
||||
|
||||
# 3. Démarrage
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**.
|
||||
Les données (`data/`, `downloads/`) sont montées en volumes : elles survivent aux
|
||||
|
||||
|
||||
### Bibliothèque Plex / Sonarr existante
|
||||
|
||||
Ohm peut déposer ses épisodes directement dans ton serveur de média :
|
||||
|
||||
1. **Crée un dossier dédié** (recommandé : hors de la racine Sonarr, ex.
|
||||
`/plex_videos/ohm`) et monte-le à la place de `./downloads` :
|
||||
`- /plex_videos/ohm:/downloads` — c'est ce que fait `scripts/install.sh`.
|
||||
2. **Ajoute ce dossier à Plex** comme dossier d'une bibliothèque (ou comme
|
||||
dossier supplémentaire de ta bibliothèque animes). Les épisodes arrivent
|
||||
rangés par animé : `One Piece/One Piece - E1010 (VOSTFR).mp4` — le nom du
|
||||
dossier sert de série, le fichier d'épisode.
|
||||
3. **Sonarr n'est pas touché** : l'entrypoint du conteneur ne prend possession
|
||||
(`chown`) que d'un dossier de téléchargements **vide** ; une bibliothèque
|
||||
existante et ses droits restent intacts. Pour piloter Ohm depuis Sonarr ou
|
||||
Prowlarr, voir l'API Torznab plus bas.
|
||||
|
||||
Si tu pointes `/downloads` sur un dossier **non vide**, Ohm adopte les fichiers
|
||||
présents (ils apparaissent dans sa bibliothèque interne) et conserve les droits
|
||||
existants — assure-toi juste que l'uid 1000 du conteneur peut y écrire.
|
||||
|
||||
### Mettre à jour
|
||||
|
||||
**Depuis l'interface** (déploiement Docker) : page **Admin → Mise à jour** —
|
||||
configurer une fois le dépôt Gitea + un jeton d'accès (droit lecture), puis
|
||||
« Vérifier » et « ⬆ Mettre à jour maintenant ». Watchtower tire la nouvelle
|
||||
image et recrée le conteneur : quelques secondes d'indisponibilité, les pages
|
||||
ouvertes se reconnectent et rechargent automatiquement.
|
||||
|
||||
**En ligne de commande** (toujours possible) :
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
### Publier une version (mainteneur)
|
||||
|
||||
```bash
|
||||
./scripts/release.sh 0.2.0
|
||||
```
|
||||
|
||||
Bump de version, commit + tag git, build de l'image et push vers
|
||||
`git.lanro.eu/roman/ohm_streaming` (tags `0.2.0` et `latest`).
|
||||
|
||||
## Démarrage rapide (développement)
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
@@ -16,7 +90,8 @@ uv sync
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8777
|
||||
```
|
||||
|
||||
Serveur persistant : `tmux new-session -d -s ohm 'cd ~/Développement/ohm_streaming && uv run uvicorn app.main:app --host 0.0.0.0 --port 8777'`
|
||||
Serveur persistant en dev : préférer Docker (voir plus haut) ; sinon
|
||||
`tmux new-session -d -s ohm 'uv run uvicorn app.main:app --host 0.0.0.0 --port 8777'`.
|
||||
|
||||
Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**.
|
||||
|
||||
@@ -30,13 +105,18 @@ Variables d'environnement (préfixe `OHM_`, voir `.env.example`) :
|
||||
| `OHM_DOWNLOAD_DIR` | `./downloads` | Dossier des fichiers téléchargés |
|
||||
| `OHM_DATABASE_PATH` | `./data/ohm.db` | Base SQLite |
|
||||
| `OHM_MAX_PARALLEL_DOWNLOADS` | `3` | Téléchargements simultanés |
|
||||
| `OHM_WATCHTOWER_URL` | *(vide)* | URL Watchtower pour la mise à jour (réglé par docker-compose) |
|
||||
| `OHM_WATCHTOWER_TOKEN` | *(vide)* | Jeton partagé Watchtower (réglé par docker-compose) |
|
||||
| `OHM_VERSION` | *(pyproject)* | Version affichée — cuite dans l'image Docker au build |
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- **Recherche unifiée** sur plusieurs sources (Vostfree, French-Manga) — chaque source
|
||||
est un module interchangeable activable/désactivable à chaud (page Admin).
|
||||
- **Recherche unifiée** sur plusieurs sources (Vostfree, French-Manga, VoirAnime) — chaque source
|
||||
est un module interchangeable activable/désactivable à chaud (page Admin), dont l'URL
|
||||
est modifiable à la volée (utile si un site change de domaine).
|
||||
- **Extraction en 2 niveaux** : page d'épisode → lecteurs embarqués → URL directe
|
||||
(Sibnet, SendVid, VidMoly, Uqload, Vidzy, Luluvdo).
|
||||
(Sibnet, SendVid, VidMoly, Uqload, Vidzy, Luluvdo ; VoirAnime résout via son
|
||||
endpoint « prepare » : MP4 direct ou HLS relayé par le proxy du site).
|
||||
- **Proxy vidéo intégré** (`/api/proxy`) : contourne les protections (tokens liés à l'IP, Referer/UA obligatoires), réécrit les playlists HLS.
|
||||
- **Streaming HLS** via hls.js ; **téléchargement HLS** via ffmpeg (remux mp4, progression temps réel).
|
||||
- **Métadonnées enrichies** via Kitsu (synopsis, genres, note, images) avec cache 72 h.
|
||||
@@ -54,9 +134,10 @@ Variables d'environnement (préfixe `OHM_`, voir `.env.example`) :
|
||||
|
||||
## Intégration Sonarr / Prowlarr (*arr)
|
||||
|
||||
OhmStreaming expose une **API Torznab** : la suite *arr le voit comme un indexeur
|
||||
de plus, et chaque grab Sonarr déclenche l'extraction + le téléchargement dans
|
||||
la file interne (les épisodes arrivent dans la bibliothèque OhmStreaming).
|
||||
OhmStreaming expose une **API Torznab** (indexeur) **et une API compatible
|
||||
qBittorrent** (client de téléchargement) : Sonarr peut lui déléguer toute la
|
||||
chaîne — recherche, téléchargement, puis **import et renommage automatiques**
|
||||
dans la bibliothèque Sonarr.
|
||||
|
||||
### 1. OhmStreaming comme indexeur
|
||||
|
||||
@@ -71,13 +152,34 @@ Dans **Admin → Intégrations Sonarr / Prowlarr**, copier :
|
||||
puis synchroniser vers Sonarr.
|
||||
- **Sonarr** (direct) : Settings → Indexers → Add → *Torznab* → coller URL + clé.
|
||||
Catégories : TV (5000) / Anime (5070).
|
||||
- **Client de téléchargement** : « Torrent Blackhole » — Sonarr enregistre le
|
||||
`.torrent` de service tandis qu'OhmStreaming télécharge réellement l'épisode
|
||||
(extraction embed → HLS/HTTP → mp4 dans `downloads/`).
|
||||
|
||||
Endpoints : `t=caps`, `t=tvsearch` (q, season, ep), `t=search` — auth par
|
||||
`?apikey=` ou en-tête `X-Api-Key`.
|
||||
|
||||
### 2. OhmStreaming comme client de téléchargement (recommandé)
|
||||
|
||||
Dans Sonarr : **Settings → Download Clients → Add → qBittorrent** :
|
||||
|
||||
| Champ | Valeur |
|
||||
|---|---|
|
||||
| Host | `http://<hote-ohm>:8777` |
|
||||
| Username | `ohm` |
|
||||
| Password | la clé API Torznab (Admin → Intégrations) |
|
||||
|
||||
Le flux complet devient : Sonarr grab → Ohm télécharge (progression visible
|
||||
dans la file Sonarr) → Sonarr **importe, renomme et range** l'épisode dans sa
|
||||
bibliothèque selon ses propres règles → le « torrent » est retiré de la file
|
||||
Ohm (fichier inclu si « Remove Completed » est coché).
|
||||
|
||||
**Chemin d'accès** : Ohm annonce les fichiers sous `/downloads/<Animé>/…`
|
||||
(chemin conteneur). Si Sonarr tourne dans Docker sans ce montage, ajouter un
|
||||
*Remote Path Mapping* : hôte = `<hote-ohm>`, distant = `/downloads`, local =
|
||||
le dossier hôte monté (ex. `/plex_videos/ohm`).
|
||||
|
||||
Variante minimale sans import : client « Torrent Blackhole » — Sonarr pose le
|
||||
`.torrent` de service et l'épisode reste dans la bibliothèque OhmStreaming
|
||||
seulement (pas d'import/renommage Sonarr).
|
||||
|
||||
### 2. « Pour toi » personnalisé par Sonarr
|
||||
|
||||
Toujours dans **Admin → Intégrations**, renseigner l'URL Sonarr
|
||||
@@ -97,7 +199,7 @@ app/
|
||||
├── scrapers/
|
||||
│ ├── base.py # contrats SourceScraper/HosterExtractor + registres
|
||||
│ ├── configs/ # sélecteurs YAML externalisés (réparer sans coder)
|
||||
│ ├── sources/ # vostfree, french_manga
|
||||
│ ├── sources/ # vostfree, french_manga, voiranime
|
||||
├── services/ # downloads, kitsu, discover, sonarr, torznab, settings
|
||||
└── templates/ + static/ # UI htmx + Alpine.js, thème sombre
|
||||
```
|
||||
@@ -108,6 +210,6 @@ app/
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
uv run pytest # 61 tests
|
||||
uv run pytest # 86 tests
|
||||
uv run ruff check . # lint
|
||||
```
|
||||
|
||||
@@ -34,6 +34,10 @@ class Settings(BaseSettings):
|
||||
kitsu_base_url: str = "https://kitsu.io/api/edge"
|
||||
metadata_cache_ttl_hours: int = 72
|
||||
|
||||
# Mise à jour (déploiement Docker — Watchtower compagnon)
|
||||
watchtower_url: str = ""
|
||||
watchtower_token: str = ""
|
||||
|
||||
def ensure_dirs(self) -> None:
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.download_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
+17
-2
@@ -9,10 +9,23 @@ from fastapi.staticfiles import StaticFiles
|
||||
from app.config import BASE_DIR, get_settings
|
||||
from app.db import db
|
||||
from app.logging_config import setup_logging
|
||||
from app.routers import admin, auth, discover, downloads, library, pages, proxy, search, torznab
|
||||
from app.routers import (
|
||||
admin,
|
||||
auth,
|
||||
discover,
|
||||
downloads,
|
||||
library,
|
||||
pages,
|
||||
proxy,
|
||||
qbit,
|
||||
search,
|
||||
system,
|
||||
torznab,
|
||||
)
|
||||
from app.scrapers.http import close_client
|
||||
from app.services.discover import discover as discover_service
|
||||
from app.services.downloads import download_manager
|
||||
from app.services.settings import apply_source_base_urls
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,6 +43,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
settings = get_settings()
|
||||
setup_logging(settings.debug)
|
||||
await db.connect()
|
||||
await apply_source_base_urls()
|
||||
await download_manager.start()
|
||||
warmup = asyncio.create_task(discover_service.latest())
|
||||
warmup.add_done_callback(_log_task_error)
|
||||
@@ -45,7 +59,8 @@ def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
app = FastAPI(title=settings.app_name, lifespan=lifespan)
|
||||
app.include_router(torznab.router)
|
||||
|
||||
app.include_router(qbit.router)
|
||||
app.include_router(system.router)
|
||||
app.include_router(pages.router)
|
||||
app.include_router(pages.protected)
|
||||
|
||||
|
||||
+96
-9
@@ -1,7 +1,8 @@
|
||||
"""Administration : utilisateurs, activation des sources, santé."""
|
||||
"""Administration : utilisateurs, activation des sources, santé, mises à jour."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
@@ -9,17 +10,29 @@ from pydantic import BaseModel
|
||||
from app import auth
|
||||
from app.db import db
|
||||
from app.routers.auth import AdminUser
|
||||
from app.scrapers.base import ScrapeError, all_sources, get_source, import_all_scrapers
|
||||
from app.scrapers.base import (
|
||||
ScrapeError,
|
||||
SourceScraper,
|
||||
all_sources,
|
||||
get_source,
|
||||
import_all_scrapers,
|
||||
)
|
||||
from app.services.discover import discover
|
||||
from app.services.settings import (
|
||||
get_sonarr_config,
|
||||
get_source_health,
|
||||
get_torznab_apikey,
|
||||
is_source_enabled,
|
||||
reset_torznab_apikey,
|
||||
set_sonarr_config,
|
||||
set_source_base_url,
|
||||
set_source_enabled,
|
||||
set_source_health,
|
||||
set_update_config,
|
||||
)
|
||||
from app.services.sonarr import sonarr
|
||||
from app.services.update import UpdateError, fetch_latest_version, trigger_update
|
||||
from app.services.update import status as update_status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -92,26 +105,62 @@ class SourceToggle(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
def _source_or_404(name: str) -> SourceScraper:
|
||||
try:
|
||||
return get_source(name)
|
||||
except ScrapeError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/sources/{name}/toggle")
|
||||
async def toggle_source(name: str, payload: SourceToggle, admin: AdminUser) -> dict:
|
||||
get_source(name) # 404 implicite si inconnue
|
||||
_source_or_404(name)
|
||||
await set_source_enabled(name, payload.enabled)
|
||||
return {"name": name, "enabled": payload.enabled}
|
||||
|
||||
class SourceUrlUpdate(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
@router.put("/sources/{name}/url")
|
||||
async def update_source_url(name: str, payload: SourceUrlUpdate, admin: AdminUser) -> dict:
|
||||
"""Change l'URL d'une source (ex. le site a changé de domaine) ; vide = défaut."""
|
||||
source = _source_or_404(name)
|
||||
default = type(source).base_url
|
||||
url = payload.url.strip().rstrip("/")
|
||||
if url and url != default:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
raise HTTPException(422, "URL invalide — format attendu : https://domaine.tld")
|
||||
await set_source_base_url(name, url)
|
||||
else:
|
||||
url = default
|
||||
await set_source_base_url(name, None)
|
||||
source.base_url = url
|
||||
logger.info("URL de la source %s : %s", name, url)
|
||||
return {
|
||||
"name": name,
|
||||
"base_url": url,
|
||||
"default_base_url": default,
|
||||
"overridden": url != default,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sources/{name}/health")
|
||||
async def health_check(name: str, admin: AdminUser) -> dict:
|
||||
"""Test de santé manuel : la source doit répondre à une recherche simple."""
|
||||
source = get_source(name)
|
||||
source = _source_or_404(name)
|
||||
try:
|
||||
results = await asyncio.wait_for(source.search("naruto"), timeout=30)
|
||||
healthy = len(results) > 0
|
||||
detail = f"{len(results)} résultats"
|
||||
healthy, detail = len(results) > 0, f"{len(results)} résultats"
|
||||
except (ScrapeError, TimeoutError) as exc:
|
||||
healthy = False
|
||||
detail = str(exc)[:200]
|
||||
healthy, detail = False, str(exc)[:200]
|
||||
logger.error("Health check %s KO : %s", name, exc)
|
||||
return {"name": name, "healthy": healthy, "detail": detail}
|
||||
except Exception as exc:
|
||||
healthy, detail = False, f"Erreur inattendue : {exc}"[:200]
|
||||
logger.exception("Health check %s : erreur inattendue", name)
|
||||
state = await set_source_health(name, healthy, detail)
|
||||
return {"name": name, **state}
|
||||
|
||||
|
||||
@router.get("/sources")
|
||||
@@ -121,7 +170,10 @@ async def sources_status(admin: AdminUser) -> list[dict]:
|
||||
"name": s.name,
|
||||
"label": s.label,
|
||||
"base_url": s.base_url,
|
||||
"default_base_url": type(s).base_url,
|
||||
"overridden": s.base_url != type(s).base_url,
|
||||
"enabled": await is_source_enabled(s.name),
|
||||
"health": await get_source_health(s.name),
|
||||
}
|
||||
for s in all_sources()
|
||||
]
|
||||
@@ -166,3 +218,38 @@ async def save_sonarr(payload: SonarrConfig, admin: AdminUser) -> dict:
|
||||
@router.post("/integrations/sonarr/test")
|
||||
async def test_sonarr(admin: AdminUser) -> dict:
|
||||
return await sonarr.test_connection()
|
||||
|
||||
# ---------------------------------------------------------------- mise à jour logicielle
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -64,6 +64,13 @@ async def cancel(download_id: int) -> dict:
|
||||
await download_manager.cancel(download_id)
|
||||
return {"ok": True}
|
||||
|
||||
@router.delete("/{download_id}")
|
||||
async def delete_download(download_id: int, delete_file: bool = False) -> dict:
|
||||
"""Retire une tâche de la file ; delete_file=true efface aussi le fichier."""
|
||||
if not await download_manager.delete(download_id, delete_file=delete_file):
|
||||
raise HTTPException(404, detail="Téléchargement introuvable")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/cancel-all")
|
||||
async def cancel_all() -> dict:
|
||||
@@ -81,7 +88,7 @@ async def events() -> EventSourceResponse:
|
||||
|
||||
async def stream() -> AsyncIterator[dict]:
|
||||
yield {"data": json.dumps({"type": "snapshot", "items": await download_manager.list_all()})}
|
||||
async for update in download_manager.subscribe():
|
||||
yield {"data": json.dumps({"type": "update", "item": update}, default=str)}
|
||||
async for message in download_manager.subscribe():
|
||||
yield {"data": json.dumps(message, default=str)}
|
||||
|
||||
return EventSourceResponse(stream())
|
||||
|
||||
@@ -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.")
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Endpoints système publics : version (utilisée par le frontend pour détecter
|
||||
une mise à jour et recharger la page automatiquement)."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.config import get_settings
|
||||
from app.version import get_version
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["system"])
|
||||
|
||||
|
||||
@router.get("/version")
|
||||
async def version() -> dict[str, str]:
|
||||
return {"name": get_settings().app_name, "version": get_version()}
|
||||
+25
-10
@@ -6,12 +6,13 @@ des sessions utilisateurs — aucun cookie requis.
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Request, Response
|
||||
|
||||
from app.scrapers.base import ScrapeError
|
||||
from app.services.settings import get_torznab_apikey
|
||||
from app.services.torznab import torrent_stub, torznab
|
||||
from app.services.torznab import build_stub, torznab
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,10 +58,9 @@ async def torznab_api(
|
||||
|
||||
if t in ("tvsearch", "search"):
|
||||
if not q or len(q) < 2:
|
||||
releases = await torznab.latest_releases()
|
||||
return Response(
|
||||
torznab.error_xml(200, "Paramètre q requis"),
|
||||
media_type="application/xml",
|
||||
status_code=400,
|
||||
torznab.results_xml(base, key, releases), media_type="application/rss+xml"
|
||||
)
|
||||
try:
|
||||
releases = await torznab.tvsearch(q, season=season, ep=ep)
|
||||
@@ -92,10 +92,12 @@ async def torznab_download(
|
||||
series: str | None = None,
|
||||
apikey: str | None = None,
|
||||
) -> Response:
|
||||
"""Grab : Sonarr récupère le « .torrent » ; OhmStreaming télécharge l'épisode.
|
||||
"""Grab : Sonarr récupère le « .torrent » ; Ohm télécharge l'épisode.
|
||||
|
||||
Le flux retourne un .torrent de service (blackhole-friendly) pendant que
|
||||
l'épisode réel entre dans la file de téléchargements interne.
|
||||
Le flux retourne un .torrent de service : avec un client « Torrent
|
||||
Blackhole » c'est un simple accusé de réception ; avec le client
|
||||
qBittumber (l'API /api/v2 d'Ohm), Sonarr le renvoie et le grab est
|
||||
rejoué/dédupliqué, puis suivi comme un téléchargement classique.
|
||||
"""
|
||||
error = await _auth_error(request, apikey)
|
||||
if error is not None:
|
||||
@@ -106,8 +108,17 @@ async def torznab_download(
|
||||
media_type="application/xml",
|
||||
status_code=400,
|
||||
)
|
||||
name = f"{series} S{season:02d}E{ep:02d}"
|
||||
announce = (
|
||||
_base_url(request)
|
||||
+ "/torznab/api?"
|
||||
+ urlencode(
|
||||
{"source": source, "sid": sid, "season": season, "ep": ep, "series": series}
|
||||
)
|
||||
)
|
||||
stub, infohash = build_stub(announce, name)
|
||||
try:
|
||||
result = await torznab.grab(source, sid, season, ep, series)
|
||||
result = await torznab.grab(source, sid, season, ep, series, sonarr_hash=infohash)
|
||||
logger.info("Torznab grab OK : %s → download %s", series, result.get("id"))
|
||||
except ScrapeError as exc:
|
||||
logger.error("Torznab grab KO : %s", exc)
|
||||
@@ -116,9 +127,13 @@ async def torznab_download(
|
||||
media_type="application/xml",
|
||||
status_code=502,
|
||||
)
|
||||
stub = torrent_stub(_base_url(request) + "/torznab/api", f"{series} S{season:02d}E{ep:02d}")
|
||||
return Response(
|
||||
stub,
|
||||
media_type="application/x-bittorrent",
|
||||
headers={"Content-Disposition": f'attachment; filename="ohm-{series.replace("/", "-")}-S{season:02d}E{ep:02d}.torrent"'},
|
||||
headers={
|
||||
"Content-Disposition": (
|
||||
f'attachment; filename="ohm-{series.replace("/", "-")}'
|
||||
f"-S{season:02d}E{ep:02d}.torrent"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ class Episode:
|
||||
title: str | None
|
||||
url: str # page de l'épisode chez la source
|
||||
season: int = 1
|
||||
|
||||
version: str | None = None # langue ("vf" / "vostfr") quand la source la distingue
|
||||
|
||||
@dataclass
|
||||
class TitleDetails:
|
||||
|
||||
@@ -0,0 +1,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
@@ -41,15 +41,26 @@ async def fetch(
|
||||
*,
|
||||
referer: str | None = None,
|
||||
retries: int = 2,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""GET d'une page avec retries ; lève ScrapeError en cas d'échec définitif."""
|
||||
headers = {"Referer": referer} if referer else {}
|
||||
request_headers = dict(headers) if headers else {}
|
||||
if referer:
|
||||
request_headers.setdefault("Referer", referer)
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
response = await get_client().get(url, headers=headers)
|
||||
response = await get_client().get(url, headers=request_headers)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code
|
||||
if 400 <= status < 500 and status not in (408, 429):
|
||||
raise ScrapeError(f"Échec de récupération de {url} : HTTP {status} (définitif)") from exc
|
||||
last_error = exc
|
||||
logger.warning("fetch %s — HTTP %d, tentative %d/%d", url, status, attempt + 1, retries + 1)
|
||||
if attempt < retries:
|
||||
await asyncio.sleep(1.0 * (attempt + 1))
|
||||
except (httpx.HTTPError, httpx.InvalidURL) as exc:
|
||||
last_error = exc
|
||||
logger.warning("fetch %s — tentative %d/%d : %s", url, attempt + 1, retries + 1, exc)
|
||||
|
||||
@@ -259,27 +259,26 @@ class FrenchMangaScraper(SourceScraper):
|
||||
if season_match:
|
||||
season = int(season_match.group(1))
|
||||
|
||||
numbers: set[float] = set()
|
||||
info = data.get("info") or {}
|
||||
episodes: list[Episode] = []
|
||||
for version in versions:
|
||||
numbers: set[float] = set()
|
||||
for number_text in data.get(version) or {}:
|
||||
try:
|
||||
numbers.add(float(number_text))
|
||||
except ValueError:
|
||||
logger.warning("french_manga : numéro d'épisode invalide %r", number_text)
|
||||
|
||||
info = data.get("info") or {}
|
||||
episodes: list[Episode] = []
|
||||
for number in sorted(numbers):
|
||||
number_text = str(int(number)) if number.is_integer() else str(number)
|
||||
episode_info = info.get(number_text) or info.get(str(int(number)))
|
||||
label = episode_info.get("title") if isinstance(episode_info, dict) else None
|
||||
version = next((v for v in versions if number_text in (data.get(v) or {})), versions[0])
|
||||
episodes.append(
|
||||
Episode(
|
||||
number=number,
|
||||
title=label,
|
||||
url=f"{page_url}#{config['episodes']['fragment_prefix']}={version}-{number_text}",
|
||||
season=season,
|
||||
version=version,
|
||||
)
|
||||
)
|
||||
if not episodes:
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Source VoirAnime (voiranime.xyz) — plateforme VODSPHERE, animes VOSTFR.
|
||||
|
||||
Faits structurels (vérifiés en live) :
|
||||
- Recherche : GET /catalogue?q=<q> → `article.catalogue-card` (lien `a.catalogue-poster`,
|
||||
poster et titre dans `img[src]` / `img[alt]`).
|
||||
- Fiche : /catalogue/<slug> — titre `h1`, poster `img[alt^="Affiche de"]`, compteur
|
||||
d'épisodes dans `.media-detail-meta span` (« 1192 épisodes »), synopsis `.media-detail-story p`.
|
||||
- Épisodes : `a.detail-episode-card` avec `data-season` et libellé `<small>S1 · E1</small>`.
|
||||
- Lecteur : /lecteur/<content>/<episode> — attributs data-prepare-url (résolution AJAX)
|
||||
et data-fallback-source-url (source suivante) ; en fin de chaîne le fallback repointe
|
||||
vers une source déjà vue → dédoublonnage par id de source obligatoire.
|
||||
- Accueil : carrousels #carousel-new (nouveautés) et #carousel-added (ajouts), 24 cartes.
|
||||
- La résolution d'un lien direct se fait dans l'extracteur hoster dédié (voir
|
||||
app/scrapers/hosters/voiranime.py) : cette source ne renvoie que les URLs prepare.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from urllib.parse import quote_plus, urljoin
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
from app.scrapers.base import (
|
||||
Episode,
|
||||
ScrapeError,
|
||||
SearchResult,
|
||||
SourceScraper,
|
||||
TitleDetails,
|
||||
register_source,
|
||||
)
|
||||
from app.scrapers.config_loader import load_scraper_config
|
||||
from app.scrapers.http import fetch_soup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"search": {
|
||||
"endpoint": "/catalogue?q={query}",
|
||||
"result": "article.catalogue-card",
|
||||
"link": "a.catalogue-poster",
|
||||
},
|
||||
"details": {
|
||||
"title": "h1",
|
||||
"poster": 'img[alt^="Affiche de"]',
|
||||
"meta_count": ".media-detail-meta span",
|
||||
"synopsis": ".media-detail-story p",
|
||||
},
|
||||
"episodes": {
|
||||
"card": "a.detail-episode-card",
|
||||
},
|
||||
"player": {
|
||||
"root": "[data-prepare-url]",
|
||||
"prepare_attr": "data-prepare-url",
|
||||
"fallback_attr": "data-fallback-source-url",
|
||||
"max_sources": 4,
|
||||
},
|
||||
"latest": {
|
||||
"path": "/",
|
||||
"carousels": ("#carousel-new", "#carousel-added"),
|
||||
"item": "article.content-card",
|
||||
"link": "a.card-poster",
|
||||
},
|
||||
}
|
||||
|
||||
_SLUG_RE = re.compile(r"/catalogue/([a-z0-9-]+)", re.IGNORECASE)
|
||||
_EPISODE_LABEL_RE = re.compile(r"S\s*(\d+)\s*·\s*E\s*(\d+(?:[.,]\d+)?)", re.IGNORECASE)
|
||||
_EPISODE_COUNT_RE = re.compile(r"(\d+)\s*épisodes", re.IGNORECASE)
|
||||
_SOURCE_ID_RE = re.compile(r"/lecteur/prepare/(\d+)")
|
||||
|
||||
_FALLBACK_SOURCE_RE = re.compile(r"[?&]source=(\d+)")
|
||||
def _merged_config() -> dict:
|
||||
merged = deepcopy(DEFAULT_CONFIG)
|
||||
for key, values in load_scraper_config("voiranime").items():
|
||||
if isinstance(values, dict) and isinstance(merged.get(key), dict):
|
||||
merged[key].update(values)
|
||||
else:
|
||||
merged[key] = values
|
||||
return merged
|
||||
|
||||
|
||||
@register_source
|
||||
class VoirAnimeScraper(SourceScraper):
|
||||
name = "voiranime"
|
||||
label = "VoirAnime"
|
||||
base_url = "https://voiranime.xyz"
|
||||
media_types = ("anime",)
|
||||
|
||||
# ------------------------------------------------------------- helpers
|
||||
|
||||
def _title_url(self, source_id: str) -> str:
|
||||
return f"{self.base_url}/catalogue/{source_id}"
|
||||
|
||||
@staticmethod
|
||||
def _slug_from_url(url: str) -> str | None:
|
||||
match = _SLUG_RE.search(url)
|
||||
return match.group(1) if match else None
|
||||
|
||||
@staticmethod
|
||||
def _text(element: Tag | None) -> str:
|
||||
return element.get_text(" ", strip=True) if element else ""
|
||||
|
||||
def _result_from_card(self, link: Tag) -> SearchResult | None:
|
||||
href = link.get("href")
|
||||
if not href:
|
||||
return None
|
||||
source_id = self._slug_from_url(str(href))
|
||||
if not source_id:
|
||||
return None
|
||||
img = link.select_one("img")
|
||||
title = (img.get("alt") if img else None) or source_id.replace("-", " ")
|
||||
image = img.get("src") if img else None
|
||||
return SearchResult(
|
||||
source=self.name,
|
||||
source_id=source_id,
|
||||
title=str(title).strip(),
|
||||
url=self._title_url(source_id),
|
||||
image_url=str(image) if image else None,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------- search
|
||||
|
||||
async def search(self, query: str) -> list[SearchResult]:
|
||||
config = _merged_config()
|
||||
search_cfg = config["search"]
|
||||
url = self.base_url + search_cfg["endpoint"].format(query=quote_plus(query))
|
||||
soup = await fetch_soup(url)
|
||||
results: list[SearchResult] = []
|
||||
seen: set[str] = set()
|
||||
for card in soup.select(search_cfg["result"]):
|
||||
link = card.select_one(search_cfg["link"])
|
||||
if link is None:
|
||||
logger.warning("voiranime : carte de résultat sans lien, ignorée")
|
||||
continue
|
||||
result = self._result_from_card(link)
|
||||
if result and result.source_id not in seen:
|
||||
seen.add(result.source_id)
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
# ------------------------------------------------------------- détails
|
||||
|
||||
async def get_details(self, source_id: str) -> TitleDetails:
|
||||
config = _merged_config()
|
||||
details_cfg = config["details"]
|
||||
url = self._title_url(source_id)
|
||||
soup = await fetch_soup(url)
|
||||
|
||||
title = self._text(soup.select_one(details_cfg["title"])) or source_id.replace("-", " ")
|
||||
poster = soup.select_one(details_cfg["poster"])
|
||||
image = str(poster["src"]) if poster and poster.get("src") else None
|
||||
synopsis = self._text(soup.select_one(details_cfg["synopsis"])) or None
|
||||
|
||||
episode_count: int | None = None
|
||||
count_el = soup.select_one(details_cfg["meta_count"])
|
||||
if count_el:
|
||||
match = _EPISODE_COUNT_RE.search(self._text(count_el))
|
||||
if match:
|
||||
episode_count = int(match.group(1))
|
||||
|
||||
episodes = self._parse_episodes(soup, config)
|
||||
return TitleDetails(
|
||||
source=self.name,
|
||||
source_id=source_id,
|
||||
title=title,
|
||||
url=url,
|
||||
synopsis=synopsis,
|
||||
image_url=image,
|
||||
episode_count=episode_count if episode_count is not None else len(episodes),
|
||||
episodes=episodes,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------- épisodes
|
||||
|
||||
async def list_episodes(self, source_id: str) -> list[Episode]:
|
||||
config = _merged_config()
|
||||
soup = await fetch_soup(self._title_url(source_id))
|
||||
return self._parse_episodes(soup, config)
|
||||
|
||||
def _parse_episodes(self, soup: BeautifulSoup, config: dict) -> list[Episode]:
|
||||
episodes: list[Episode] = []
|
||||
for card in soup.select(config["episodes"]["card"]):
|
||||
href = card.get("href")
|
||||
if not href:
|
||||
continue
|
||||
label = self._text(card)
|
||||
match = _EPISODE_LABEL_RE.search(label)
|
||||
if not match:
|
||||
logger.warning("voiranime : libellé d'épisode illisible : %r", label[:60])
|
||||
continue
|
||||
episodes.append(
|
||||
Episode(
|
||||
number=float(match.group(2).replace(",", ".")),
|
||||
title=None,
|
||||
url=urljoin(self.base_url + "/", str(href)),
|
||||
season=int(match.group(1)),
|
||||
)
|
||||
)
|
||||
return episodes
|
||||
|
||||
# ------------------------------------------------------------- lecteurs
|
||||
|
||||
async def extract_embed_links(self, episode_url: str) -> list[str]:
|
||||
"""Parcourt la chaîne Lecteur 1 → 2 → … et renvoie les URLs prepare absolues."""
|
||||
config = _merged_config()
|
||||
player_cfg = config["player"]
|
||||
prepare_urls: list[str] = []
|
||||
seen_sources: set[str] = set()
|
||||
page_url = episode_url
|
||||
for _ in range(int(player_cfg["max_sources"])):
|
||||
soup = await fetch_soup(page_url)
|
||||
root = soup.select_one(player_cfg["root"])
|
||||
if root is None:
|
||||
break
|
||||
prepare = root.get(player_cfg["prepare_attr"])
|
||||
if not prepare:
|
||||
break
|
||||
prepare_url = urljoin(self.base_url + "/", str(prepare))
|
||||
match = _SOURCE_ID_RE.search(prepare_url)
|
||||
source_key = match.group(1) if match else prepare_url
|
||||
if source_key in seen_sources:
|
||||
break
|
||||
seen_sources.add(source_key)
|
||||
prepare_urls.append(prepare_url)
|
||||
fallback = root.get(player_cfg["fallback_attr"])
|
||||
if not fallback:
|
||||
break
|
||||
page_url = urljoin(self.base_url + "/", str(fallback))
|
||||
next_source = _FALLBACK_SOURCE_RE.search(page_url)
|
||||
if next_source and next_source.group(1) in seen_sources:
|
||||
break
|
||||
if not prepare_urls:
|
||||
raise ScrapeError(f"voiranime : aucun lecteur trouvé sur {episode_url}")
|
||||
return prepare_urls
|
||||
|
||||
# ------------------------------------------------------------- découverte
|
||||
|
||||
async def latest(self) -> list[SearchResult]:
|
||||
"""Nouveautés + ajouts récents — carrousels de la page d'accueil."""
|
||||
config = _merged_config()
|
||||
latest_cfg = config["latest"]
|
||||
soup = await fetch_soup(self.base_url + latest_cfg["path"])
|
||||
results: list[SearchResult] = []
|
||||
seen: set[str] = set()
|
||||
for carousel in latest_cfg["carousels"]:
|
||||
section = soup.select_one(carousel)
|
||||
if section is None:
|
||||
logger.debug("voiranime : carrousel %s absent de l'accueil", carousel)
|
||||
continue
|
||||
for card in section.select(latest_cfg["item"]):
|
||||
link = card.select_one(latest_cfg["link"])
|
||||
if link is None:
|
||||
continue
|
||||
result = self._result_from_card(link)
|
||||
if result and result.source_id not in seen:
|
||||
seen.add(result.source_id)
|
||||
results.append(result)
|
||||
return results
|
||||
@@ -85,6 +85,7 @@ _SLUG_RE = re.compile(r"([^/]+)\.html?$")
|
||||
_NUMBER_RE = re.compile(r"(\d+(?:[.,]\d+)?)")
|
||||
_SEASON_RE = re.compile(r"Saison\s*:?\s*(\d+)", re.IGNORECASE)
|
||||
_SAFE_FRAGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
_VERSION_RE = re.compile(r"\b(vostfr|vf)\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def _merged_config() -> dict:
|
||||
@@ -246,8 +247,18 @@ class VostfreeScraper(SourceScraper):
|
||||
return int(season_match.group(1))
|
||||
return 1
|
||||
|
||||
def _detect_version(self, soup: BeautifulSoup, page_url: str) -> str | None:
|
||||
config = _merged_config()
|
||||
title_el = soup.select_one(config["details"]["title"])
|
||||
for text in (self._text(title_el), page_url):
|
||||
match = _VERSION_RE.search(text)
|
||||
if match:
|
||||
return match.group(1).lower()
|
||||
return None
|
||||
|
||||
def _parse_episodes(self, soup: BeautifulSoup, page_url: str, season: int) -> list[Episode]:
|
||||
config = _merged_config()
|
||||
version = self._detect_version(soup, page_url)
|
||||
options = soup.select(config["episodes"]["option"])
|
||||
entries: list[tuple[str, str]] = []
|
||||
seen_ids: set[str] = set()
|
||||
@@ -274,6 +285,7 @@ class VostfreeScraper(SourceScraper):
|
||||
title=label or f"Episode {index}",
|
||||
url=f"{page_url}#{button_id}" if button_id else page_url,
|
||||
season=season,
|
||||
version=version,
|
||||
)
|
||||
)
|
||||
return episodes
|
||||
|
||||
+95
-12
@@ -63,6 +63,46 @@ def sanitize_filename(name: str) -> str:
|
||||
return name[:150]
|
||||
|
||||
|
||||
_SERIES_RE = re.compile(r"^(.*?)[\s\-–—]*(?:épisode|episode|ep|e)\s*\d", re.IGNORECASE)
|
||||
|
||||
|
||||
def series_dirname(title: str) -> str | None:
|
||||
"""Nom du sous-dossier d'un titre (« One Piece - E12 » → « One Piece »).
|
||||
|
||||
Range les épisodes par animé sur le disque (bien meilleur parsing Plex).
|
||||
None si aucun marqueur d'épisode détecté → fichier à plat.
|
||||
"""
|
||||
match = _SERIES_RE.match(title)
|
||||
base = sanitize_filename(match.group(1).strip()) if match else ""
|
||||
return base or None
|
||||
|
||||
|
||||
_EPISODE_RE = re.compile(
|
||||
r"^(?P<series>.+?)\s*-\s*(?:Saison\s+(?P<season>\d+)\s*-\s*)?"
|
||||
r"E(?P<ep>\d+(?:[.,]\d+)?)(?P<tail>\s*\([^)]*\))?\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def plex_filename(title: str, extension: str) -> str | None:
|
||||
"""Nom de fichier compatible Plex (« S01E09 ») pour un titre d'épisode.
|
||||
|
||||
Les scrapers produisent « Série - Saison 1 - E9 (VF) » ; le scanner Plex
|
||||
exige « Série - S01E09 (VF) » (les épisodes 1-9 sont sinon ignorés).
|
||||
None si le titre ne suit pas le format épisode → nommage inchangé.
|
||||
"""
|
||||
match = _EPISODE_RE.match(title)
|
||||
if not match:
|
||||
return None
|
||||
series = match.group("series")
|
||||
season = int(match.group("season") or 1)
|
||||
ep_raw = match.group("ep").replace(",", ".")
|
||||
ep = float(ep_raw)
|
||||
ep_label = f"{int(ep):02d}" if ep.is_integer() else ep_raw
|
||||
tail = match.group("tail") or ""
|
||||
return sanitize_filename(f"{series} - S{season:02d}E{ep_label}{tail}") + extension
|
||||
|
||||
|
||||
class DownloadManager:
|
||||
"""File d'attente de téléchargements, injectée dans les routes via app.state."""
|
||||
|
||||
@@ -117,8 +157,12 @@ class DownloadManager:
|
||||
download_dir = get_settings().download_dir
|
||||
rows = await db.fetchall("SELECT file_path FROM downloads WHERE file_path IS NOT NULL")
|
||||
known = {row["file_path"] for row in rows}
|
||||
for path in download_dir.iterdir():
|
||||
if path.is_file() and path.suffix != ".part" and path.name not in known:
|
||||
for path in download_dir.rglob("*"):
|
||||
if not path.is_file() or path.suffix == ".part":
|
||||
continue
|
||||
rel_path = path.relative_to(download_dir).as_posix()
|
||||
if rel_path in known:
|
||||
continue
|
||||
size = path.stat().st_size
|
||||
await db.execute(
|
||||
"INSERT INTO downloads "
|
||||
@@ -126,22 +170,28 @@ class DownloadManager:
|
||||
" total_bytes, downloaded_bytes) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'done', ?, ?)",
|
||||
(
|
||||
f"file:{path.name}",
|
||||
f"file:{rel_path}",
|
||||
"",
|
||||
"",
|
||||
path.stem,
|
||||
path.name,
|
||||
rel_path,
|
||||
size,
|
||||
size,
|
||||
),
|
||||
)
|
||||
logger.info("Fichier restauré depuis le disque : %s", path.name)
|
||||
logger.info("Fichier restauré depuis le disque : %s", rel_path)
|
||||
|
||||
# ------------------------------------------------------------ API publique
|
||||
|
||||
async def enqueue(self, video_url: str, page_url: str, title: str) -> dict:
|
||||
"""Ajoute un téléchargement ; retourne la tâche existante si doublon actif."""
|
||||
source_key = video_url
|
||||
async def enqueue(
|
||||
self, video_url: str, page_url: str, title: str, source_key: str | None = None
|
||||
) -> dict:
|
||||
"""Ajoute un téléchargement ; retourne la tâche existante si doublon actif.
|
||||
|
||||
source_key : clé de déduplication (par défaut l'URL vidéo). Les grabs
|
||||
Sonarr utilisent « sonarr:<infohash>|<url> » pour rester suivis.
|
||||
"""
|
||||
source_key = source_key or video_url
|
||||
existing = await db.fetchone(
|
||||
f"SELECT * FROM downloads WHERE source_key = ? AND status IN "
|
||||
f"({','.join('?' * len(ACTIVE_STATUSES))})",
|
||||
@@ -151,11 +201,14 @@ class DownloadManager:
|
||||
logger.info("Anti-doublon : %s déjà en file (id=%s)", title, existing["id"])
|
||||
return self._to_dict(existing, duplicate=True)
|
||||
|
||||
filename = sanitize_filename(title) + self._guess_extension(video_url)
|
||||
extension = self._guess_extension(video_url)
|
||||
filename = plex_filename(title, extension) or sanitize_filename(title) + extension
|
||||
series = series_dirname(title)
|
||||
file_path = f"{series}/{filename}" if series else filename
|
||||
cursor = await db.execute(
|
||||
"INSERT INTO downloads (source_key, video_url, page_url, title, file_path) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(source_key, video_url, page_url, title, filename),
|
||||
(source_key, video_url, page_url, title, file_path),
|
||||
)
|
||||
download_id = cursor.lastrowid
|
||||
await self._queue.put(download_id)
|
||||
@@ -223,7 +276,29 @@ class DownloadManager:
|
||||
)
|
||||
return cursor.rowcount or 0
|
||||
|
||||
async def list_all(self, limit: int = 200) -> list[dict]:
|
||||
async def delete(self, download_id: int, delete_file: bool = False) -> bool:
|
||||
"""Supprime une tâche de la file (annulée avant si active).
|
||||
|
||||
delete_file=True efface aussi le fichier téléchargé du disque.
|
||||
"""
|
||||
row = await db.fetchone("SELECT * FROM downloads WHERE id = ?", (download_id,))
|
||||
if row is None:
|
||||
return False
|
||||
if row["status"] in ACTIVE_STATUSES:
|
||||
await self.cancel(download_id)
|
||||
if delete_file and row["file_path"]:
|
||||
target = get_settings().download_dir / row["file_path"]
|
||||
target.unlink(missing_ok=True)
|
||||
if target.parent != get_settings().download_dir:
|
||||
with contextlib.suppress(OSError): # dossier de l'animé vide → retiré
|
||||
target.parent.rmdir()
|
||||
self._part_path(row["file_path"]).unlink(missing_ok=True)
|
||||
await db.execute("DELETE FROM downloads WHERE id = ?", (download_id,))
|
||||
await self._emit_removed(download_id)
|
||||
logger.info("Téléchargement supprimé (id=%s, fichier=%s)", download_id, delete_file)
|
||||
return True
|
||||
|
||||
async def list_all(self, limit: int = 2000) -> list[dict]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM downloads ORDER BY "
|
||||
"CASE status WHEN 'downloading' THEN 0 WHEN 'pending' THEN 1 WHEN 'paused' THEN 2 "
|
||||
@@ -251,9 +326,15 @@ class DownloadManager:
|
||||
data = await self.get(download_id)
|
||||
if data is None:
|
||||
return
|
||||
self._publish({"type": "update", "item": data})
|
||||
|
||||
async def _emit_removed(self, download_id: int) -> None:
|
||||
self._publish({"type": "removed", "id": download_id})
|
||||
|
||||
def _publish(self, message: dict) -> None:
|
||||
for queue in self._listeners:
|
||||
with contextlib.suppress(asyncio.QueueFull):
|
||||
queue.put_nowait(data)
|
||||
queue.put_nowait(message)
|
||||
|
||||
# ------------------------------------------------------------ worker interne
|
||||
|
||||
@@ -300,6 +381,7 @@ class DownloadManager:
|
||||
"""Téléchargement HTTP direct avec reprise via Range."""
|
||||
video_url, file_path = row["video_url"], row["file_path"]
|
||||
target = get_settings().download_dir / file_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
part = self._part_path(file_path)
|
||||
downloaded = part.stat().st_size if part.exists() else 0
|
||||
|
||||
@@ -353,6 +435,7 @@ class DownloadManager:
|
||||
"""
|
||||
video_url, file_path = row["video_url"], row["file_path"]
|
||||
target = get_settings().download_dir / file_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
part = self._part_path(file_path)
|
||||
part.unlink(missing_ok=True) # pas de reprise partielle en HLS
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Paramètres persistés en DB (activation des sources, réglages UI…)."""
|
||||
"""Paramètres persistés en DB (activation des sources, santé, réglages UI…)."""
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.db import db
|
||||
|
||||
@@ -35,6 +36,48 @@ async def set_source_enabled(name: str, enabled: bool) -> None:
|
||||
await set_setting(f"source:{name}:enabled", enabled)
|
||||
logger.info("Source %s %s", name, "activée" if enabled else "désactivée")
|
||||
|
||||
|
||||
async def get_source_health(name: str) -> dict | None:
|
||||
"""Dernier état de santé connu d'une source (None si jamais testée)."""
|
||||
value = await get_setting(f"source:{name}:health")
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
async def set_source_health(name: str, healthy: bool, detail: str) -> dict:
|
||||
state = {
|
||||
"healthy": healthy,
|
||||
"detail": detail,
|
||||
"checked_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
await set_setting(f"source:{name}:health", state)
|
||||
return state
|
||||
|
||||
|
||||
async def get_source_base_url(name: str) -> str | None:
|
||||
"""URL personnalisée d'une source (None = valeur par défaut du code)."""
|
||||
value = await get_setting(f"source:{name}:base_url")
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
async def set_source_base_url(name: str, url: str | None) -> None:
|
||||
"""Persiste l'URL personnalisée (None/'' → retour à la valeur par défaut)."""
|
||||
key = f"source:{name}:base_url"
|
||||
if url:
|
||||
await set_setting(key, url)
|
||||
else:
|
||||
await db.execute("DELETE FROM settings WHERE key = ?", (key,))
|
||||
|
||||
|
||||
async def apply_source_base_urls() -> None:
|
||||
"""Applique les URL personnalisées aux instances de sources (au démarrage)."""
|
||||
from app.scrapers.base import all_sources
|
||||
|
||||
for source in all_sources():
|
||||
override = await get_source_base_url(source.name)
|
||||
if override and override != type(source).base_url:
|
||||
source.base_url = override
|
||||
logger.info("URL personnalisée pour %s : %s", source.name, override)
|
||||
|
||||
# ---------------------------------------------------------------- intégrations *arr
|
||||
|
||||
TORZNAB_APIKEY_KEY = "torznab:apikey"
|
||||
@@ -70,3 +113,24 @@ async def set_sonarr_config(url: str, apikey: str) -> None:
|
||||
await set_setting(SONARR_URL_KEY, url.rstrip("/"))
|
||||
await set_setting(SONARR_APIKEY_KEY, apikey.strip())
|
||||
logger.info("Configuration Sonarr enregistrée (%s)", url)
|
||||
|
||||
# ---------------------------------------------------------------- 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)
|
||||
|
||||
@@ -50,6 +50,7 @@ class SonarrService:
|
||||
base_url=url,
|
||||
timeout=_REQUEST_TIMEOUT,
|
||||
headers={"X-Api-Key": apikey},
|
||||
verify=False, # reverse proxy swizzin : certificat auto-signé (LAN uniquement)
|
||||
)
|
||||
|
||||
async def _get(self, path: str, params: dict | None = None) -> dict | list | None:
|
||||
|
||||
+88
-9
@@ -13,7 +13,9 @@ Formats :
|
||||
- `t=search` → recherche libre (Prowlarr, recherche manuelle)
|
||||
"""
|
||||
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
@@ -83,19 +85,59 @@ def _bencode(value) -> bytes:
|
||||
|
||||
def torrent_stub(announce_url: str, name: str) -> bytes:
|
||||
"""Fichier .torrent minimal (le vrai téléchargement est fait par OhmStreaming)."""
|
||||
return _bencode(
|
||||
return build_stub(announce_url, name)[0]
|
||||
|
||||
|
||||
def build_stub(announce_url: str, name: str) -> tuple[bytes, str]:
|
||||
"""Fichier .torrent de service + infohash SHA-1 (identité côté Sonarr).
|
||||
|
||||
L'announce embarque les paramètres du grab (source, sid, season, ep, series) :
|
||||
quand Sonarr renvoie ce .torrent à l'API compatible qBittorrent d'Ohm,
|
||||
le grab est rejoué à l'identique.
|
||||
"""
|
||||
info = {"name": name + ".mp4", "length": 0, "piece length": 32768, "pieces": b"\x00" * 20}
|
||||
data = _bencode(
|
||||
{
|
||||
"announce": announce_url,
|
||||
"created by": "OhmStreaming",
|
||||
"comment": name,
|
||||
"info": {
|
||||
"name": name + ".mp4",
|
||||
"length": 0,
|
||||
"piece length": 32768,
|
||||
"pieces": b"\x00" * 20,
|
||||
},
|
||||
"info": info,
|
||||
}
|
||||
)
|
||||
return data, hashlib.sha1(_bencode(info)).hexdigest()
|
||||
|
||||
|
||||
def bdecode(data: bytes):
|
||||
"""Décode un flux bencode (les clés dict reviennent en bytes)."""
|
||||
|
||||
def _parse(offset: int) -> tuple[object, int]:
|
||||
char = data[offset : offset + 1]
|
||||
if char == b"i":
|
||||
end = data.index(b"e", offset)
|
||||
return int(data[offset + 1 : end]), end + 1
|
||||
if char in (b"d", b"l"):
|
||||
is_dict = char == b"d"
|
||||
items: dict | list = {} if is_dict else []
|
||||
offset += 1
|
||||
while data[offset : offset + 1] != b"e":
|
||||
first, offset = _parse(offset)
|
||||
if is_dict:
|
||||
second, offset = _parse(offset)
|
||||
items[first] = second
|
||||
else:
|
||||
items.append(first)
|
||||
return items, offset + 1
|
||||
if char.isdigit():
|
||||
colon = data.index(b":", offset)
|
||||
length = int(data[offset:colon])
|
||||
start = colon + 1
|
||||
return data[start : start + length], start + length
|
||||
raise ValueError(f"bencode invalide à l'octet {offset}")
|
||||
|
||||
value, end = _parse(0)
|
||||
if end != len(data):
|
||||
raise ValueError("données après la fin du flux bencode")
|
||||
return value
|
||||
|
||||
|
||||
class TorznabService:
|
||||
@@ -127,6 +169,31 @@ class TorznabService:
|
||||
"""Recherche libre : tous les épisodes des fiches trouvées."""
|
||||
return await self.tvsearch(q)
|
||||
|
||||
async def latest_releases(self) -> list[Release]:
|
||||
"""Flux RSS (Sonarr) : dernier épisode de chaque nouveauté du catalogue."""
|
||||
releases: list[Release] = []
|
||||
for source in await self._enabled_sources():
|
||||
try:
|
||||
latest = await asyncio.wait_for(source.latest(), timeout=_SEARCH_TIMEOUT)
|
||||
except (ScrapeError, TimeoutError) as exc:
|
||||
logger.warning("Torznab : nouveautés %s KO : %s", source.name, exc)
|
||||
continue
|
||||
for result in latest[:_MAX_SERIES_PER_SOURCE]:
|
||||
try:
|
||||
episodes = await self._episodes_of(source, result.source_id)
|
||||
except (ScrapeError, TimeoutError):
|
||||
continue
|
||||
whole = [e for e in episodes if e.number == int(e.number)]
|
||||
if not whole:
|
||||
continue
|
||||
newest = max(whole, key=lambda e: (e.season, e.number))
|
||||
releases.extend(
|
||||
self._releases_for(result.title, source, result.source_id, [newest], None, None)
|
||||
)
|
||||
if len(releases) >= _MAX_RELEASES:
|
||||
return releases[:_MAX_RELEASES]
|
||||
return releases
|
||||
|
||||
async def _search_source(self, source: SourceScraper, q: str):
|
||||
try:
|
||||
return await asyncio.wait_for(source.search(q), timeout=_SEARCH_TIMEOUT)
|
||||
@@ -179,11 +246,22 @@ class TorznabService:
|
||||
|
||||
# ------------------------------------------------------------ grab
|
||||
|
||||
async def grab(self, source: str, source_id: str, season: int, ep: int, series: str) -> dict:
|
||||
async def grab(
|
||||
self,
|
||||
source: str,
|
||||
source_id: str,
|
||||
season: int,
|
||||
ep: int,
|
||||
series: str,
|
||||
sonarr_hash: str | None = None,
|
||||
) -> dict:
|
||||
"""Résout l'épisode (embed → vidéo directe) puis l'ajoute à la file interne.
|
||||
|
||||
Retourne le dict du téléchargement (existant si doublon actif).
|
||||
Lève ScrapeError si introuvable ou qu'aucun hébergeur n'a répondu.
|
||||
sonarr_hash : infohash du .torrent de service — les téléchargements
|
||||
Sonarr sont préfixés « sonarr:<hash>| » pour rester suivis via l'API
|
||||
compatible qBittorrent.
|
||||
"""
|
||||
from app.scrapers.base import get_source
|
||||
|
||||
@@ -202,7 +280,8 @@ class TorznabService:
|
||||
|
||||
link = await self._resolve_video(scraper, match.url)
|
||||
title = f"{series} S{season:02d}E{ep:02d}"
|
||||
result = await download_manager.enqueue(link.url, match.url, title)
|
||||
key = f"sonarr:{sonarr_hash}|{link.url}" if sonarr_hash else link.url
|
||||
result = await download_manager.enqueue(link.url, match.url, title, source_key=key)
|
||||
if link.is_hls or link.headers.get("Referer"):
|
||||
result["note"] = "HLS/proxy : OhmStreaming gère le téléchargement via ffmpeg"
|
||||
logger.info("Torznab grab %s → download id=%s", title, result.get("id"))
|
||||
|
||||
@@ -0,0 +1,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"}
|
||||
@@ -147,6 +147,7 @@ button { font-family: inherit; }
|
||||
.btn-sm { padding: 0.4rem 0.85rem; font-size: 0.82rem; }
|
||||
.btn-danger { background: #3d1114; color: var(--danger); border: 1px solid #5c1a1e; }
|
||||
.btn-danger:hover { background: #4d1518; filter: none; }
|
||||
.btn-accent { background: var(--accent); color: #fff; border: none; font-weight: 600; }
|
||||
|
||||
/* ------------------------------------------------------------ grille de posters */
|
||||
|
||||
@@ -343,7 +344,46 @@ button { font-family: inherit; }
|
||||
.status-downloading { background: rgba(229, 9, 20, 0.16); color: #ff6b73; }
|
||||
.status-done { background: rgba(70, 211, 105, 0.14); color: var(--success); }
|
||||
.status-failed, .status-cancelled { background: rgba(255, 92, 92, 0.14); color: var(--danger); }
|
||||
|
||||
.dl-groups { display: flex; flex-direction: column; gap: 0.9rem; }
|
||||
.dl-group { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); }
|
||||
.dl-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem 1.1rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.dl-group-head:hover { background: var(--surface-2); }
|
||||
.dl-chevron { color: var(--text-dim); font-size: 0.72rem; transition: transform 0.2s; }
|
||||
.dl-chevron.open { transform: rotate(90deg); }
|
||||
.dl-group-title { font-weight: 700; font-size: 1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dl-group-meta { display: flex; gap: 0.6rem; font-size: 0.78rem; font-weight: 600; white-space: nowrap; }
|
||||
.dl-group-meta .m-downloading { color: #ff6b73; }
|
||||
.dl-group-meta .m-pending { color: var(--warning); }
|
||||
.dl-group-meta .m-done { color: var(--success); }
|
||||
.dl-group-meta .m-failed { color: var(--danger); }
|
||||
.dl-group-size { font-size: 0.8rem; color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
||||
.dl-group-body { display: flex; flex-direction: column; gap: 0.4rem; padding: 0.75rem; border-top: 1px solid var(--border); }
|
||||
.dl-group-body .dl-row { border-color: transparent; background: transparent; padding: 0.55rem 0.7rem; }
|
||||
.dl-group-body .dl-row:hover { background: var(--surface-2); border-color: var(--border); }
|
||||
.dl-del-wrap { position: relative; }
|
||||
.dl-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 0.35rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.45rem;
|
||||
box-shadow: var(--shadow);
|
||||
white-space: nowrap;
|
||||
z-index: 20;
|
||||
}
|
||||
/* ------------------------------------------------------------ lecteur */
|
||||
|
||||
.player-wrap {
|
||||
@@ -652,3 +692,53 @@ button { font-family: inherit; }
|
||||
.search-bar { flex-direction: column; }
|
||||
.search-bar .btn { justify-content: center; }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------- bandeau mise à jour (update-watcher.js) */
|
||||
.update-banner {
|
||||
position: fixed;
|
||||
inset: auto 0 1.2rem 0;
|
||||
margin: 0 auto;
|
||||
width: max-content;
|
||||
max-width: 90vw;
|
||||
padding: 0.7rem 1.2rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(20, 20, 24, 0.96);
|
||||
border: 1px solid var(--accent);
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.update-banner .spin { display: inline-block; animation: update-spin 1s linear infinite; }
|
||||
@keyframes update-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ------------------------------------------------------------ filtre VF/VOSTFR */
|
||||
|
||||
.version-filter {
|
||||
display: inline-flex;
|
||||
gap: 0.2rem;
|
||||
background: var(--surface-2);
|
||||
padding: 0.2rem;
|
||||
border-radius: 99px;
|
||||
margin-left: 0.8rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.version-tab {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.28rem 0.75rem;
|
||||
border-radius: 99px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.version-tab:hover { color: var(--text); }
|
||||
.version-tab.active { background: var(--accent); color: #fff; }
|
||||
.badge-version { background: rgba(90, 200, 250, 0.14); color: #7ec8f5; }
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/* Surveille la disponibilité du serveur et détecte les changements de version.
|
||||
* Pendant une mise à jour Docker (quelques secondes d'indisponibilité), affiche
|
||||
* un bandeau « Mise à jour en cours » puis recharge la page automatiquement
|
||||
* quand le service revient avec une nouvelle version.
|
||||
* Un serveur qui RÉPOND est considéré comme disponible (même 404 : instance
|
||||
* antérieure à l'endpoint /api/version) — seul un échec réseau (connexion
|
||||
* refusée, timeout) signifie « en cours de redémarrage ». */
|
||||
(() => {
|
||||
const POLL_MS = 8000;
|
||||
const KEY_UPDATING = 'ohm-updating';
|
||||
|
||||
let initialVersion = null;
|
||||
let failures = 0;
|
||||
let banner = null;
|
||||
let done = false;
|
||||
|
||||
const isUpdating = () => sessionStorage.getItem(KEY_UPDATING) === '1';
|
||||
|
||||
function showBanner() {
|
||||
if (banner) return;
|
||||
banner = document.createElement('div');
|
||||
banner.className = 'update-banner';
|
||||
banner.innerHTML = '<span class="spin">⟳</span> Mise à jour en cours — reconnexion automatique…';
|
||||
document.body.appendChild(banner);
|
||||
}
|
||||
|
||||
function hideBanner() {
|
||||
if (banner) banner.remove();
|
||||
banner = null;
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (document.hidden || done) return;
|
||||
let version = null;
|
||||
let up = false;
|
||||
try {
|
||||
const r = await fetch('/api/version', { cache: 'no-store' });
|
||||
up = true; // le serveur a répondu : il est vivant
|
||||
if (r.ok) version = (await r.json()).version ?? null;
|
||||
} catch {
|
||||
up = false; // connexion refusée / réseau coupé : serveur injoignable
|
||||
}
|
||||
|
||||
if (!up) {
|
||||
failures += 1;
|
||||
if (isUpdating() || failures >= 2) showBanner();
|
||||
return;
|
||||
}
|
||||
|
||||
failures = 0;
|
||||
hideBanner();
|
||||
if (version !== null && initialVersion === null) initialVersion = version;
|
||||
if (isUpdating() || (version !== null && version !== initialVersion)) {
|
||||
done = true;
|
||||
sessionStorage.removeItem(KEY_UPDATING);
|
||||
setTimeout(() => location.reload(), 1000); // laisser le serveur finir de démarrer
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) poll();
|
||||
});
|
||||
|
||||
if (isUpdating()) showBanner();
|
||||
poll();
|
||||
setInterval(poll, POLL_MS);
|
||||
})();
|
||||
+145
-11
@@ -26,12 +26,23 @@
|
||||
<template x-for="s in sources" :key="s.name">
|
||||
<tr>
|
||||
<td><strong x-text="s.label"></strong> <span style="color:var(--text-dim);font-size:0.8rem" x-text="'(' + s.name + ')'"></span></td>
|
||||
<td style="font-size:0.82rem;color:var(--text-dim)" x-text="s.base_url"></td>
|
||||
<td>
|
||||
<div style="display:flex;gap:0.35rem;align-items:center;flex-wrap:wrap">
|
||||
<input class="input" x-model="s._url" :placeholder="s.default_base_url"
|
||||
style="min-width:230px;font-size:0.8rem;padding:0.3rem 0.5rem"
|
||||
@change="s._urlDirty = s._url.trim() !== s.base_url">
|
||||
<button class="btn btn-sm btn-ghost" @click="saveUrl(s)" :disabled="!s._urlDirty || s._saving"
|
||||
x-text="s._saving ? '…' : 'Enregistrer'"></button>
|
||||
<button class="btn btn-sm btn-ghost" x-show="s.overridden" @click="resetUrl(s)"
|
||||
:disabled="s._saving" title="Restaurer l'URL par défaut">↺</button>
|
||||
</div>
|
||||
</td>
|
||||
<td><div class="toggle" :class="s.enabled && 'on'" @click="toggle(s)"></div></td>
|
||||
<td>
|
||||
<span x-show="s.health == null" style="color:var(--text-dim)">—</span>
|
||||
<span x-show="s.health === true" style="color:var(--success)">✔ OK</span>
|
||||
<span x-show="s.health === false" style="color:var(--danger)" :title="s.healthDetail">✖ KO</span>
|
||||
<span x-show="!s.health" style="color:var(--text-dim)">—</span>
|
||||
<span x-show="s.health" :style="s.health?.healthy ? 'color:var(--success)' : 'color:var(--danger)'"
|
||||
:title="s.health ? s.health.detail + (s.health.checked_at ? ' — vérifié le ' + new Date(s.health.checked_at).toLocaleString('fr-FR') : '') : ''"
|
||||
x-text="s.health?.healthy ? '✔ OK' : '✖ KO'"></span>
|
||||
</td>
|
||||
<td><button class="btn btn-sm btn-ghost" @click="healthCheck(s)" :disabled="s._checking" x-text="s._checking ? '…' : 'Tester'"></button></td>
|
||||
</tr>
|
||||
@@ -48,6 +59,12 @@
|
||||
Ajoutez un indexeur « Torznab » dans Prowlarr ou Sonarr avec cette URL et cette clé —
|
||||
les épisodes grabés par Sonarr entrent directement dans la file de téléchargements OhmStreaming.
|
||||
</p>
|
||||
<p style="color:var(--text-dim);font-size:0.85rem;margin:0.6rem 0 0">
|
||||
<strong>Encore mieux — client de téléchargement :</strong> dans Sonarr, ajoutez un client
|
||||
<em>qBittorrent</em> pointant vers OhmStreaming (URL <code>http://<hote-ohm>:8777</code>,
|
||||
utilisateur <code>ohm</code>, mot de passe = la clé API ci-dessus). Les épisodes seront
|
||||
suivis en temps réel puis importés et renommés par Sonarr dans votre bibliothèque.
|
||||
</p>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
@@ -84,6 +101,38 @@
|
||||
:style="integrations.testResult?.ok ? 'color:var(--success)' : 'color:var(--danger)'"
|
||||
x-text="integrations.testResult?.detail"></p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>🔄 Mise à jour</h2>
|
||||
<p style="color:var(--text-dim);font-size:0.85rem;margin:0 0 0.6rem">
|
||||
Version installée : <strong x-text="upd.current || '—'"></strong>
|
||||
<span x-show="upd.update_available" class="badge" style="background:var(--accent);color:#fff"
|
||||
x-text="(upd.latest || '?') + ' disponible'"></span>
|
||||
<span x-show="upd.latest && !upd.update_available" style="color:var(--success)">✔ à jour</span>
|
||||
</p>
|
||||
|
||||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
|
||||
<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 && docker compose up -d</code> sinon).
|
||||
</p>
|
||||
</div>
|
||||
+
|
||||
|
||||
<div class="panel">
|
||||
<h2>👥 Utilisateurs</h2>
|
||||
@@ -121,6 +170,11 @@
|
||||
function adminPage() {
|
||||
return {
|
||||
users: [], sources: [], stats: {}, forbidden: false,
|
||||
upd: {
|
||||
current: '', latest: null, update_available: false, configured: false, docker: false,
|
||||
config: { gitea_url: '', repo: '', token: '' },
|
||||
_dirty: false, _checking: false, _applying: false,
|
||||
},
|
||||
integrations: {
|
||||
torznab: { apikey: '', endpoint: '' }, sonarr: { url: '', apikey: '' },
|
||||
_regen: false, _testing: false, testResult: null,
|
||||
@@ -132,7 +186,7 @@ function adminPage() {
|
||||
]);
|
||||
if (u.status === 403) { this.forbidden = true; return; }
|
||||
this.users = await u.json();
|
||||
this.sources = await s.json();
|
||||
this.sources = (await s.json()).map((x) => ({ ...x, _url: x.base_url, _urlDirty: false, _saving: false }));
|
||||
this.stats = await st.json();
|
||||
const itg = await fetch('/api/admin/integrations');
|
||||
if (itg.ok) {
|
||||
@@ -140,8 +194,10 @@ function adminPage() {
|
||||
this.integrations.torznab = data.torznab;
|
||||
this.integrations.sonarr = { ...data.sonarr, _dirty: false };
|
||||
}
|
||||
const upd = await fetch('/api/admin/update');
|
||||
if (upd.ok) this.applyUpdateData(await upd.json());
|
||||
this.checkAllSources();
|
||||
},
|
||||
|
||||
copy(value) {
|
||||
navigator.clipboard.writeText(value).then(() => toast('✔ Copié'));
|
||||
},
|
||||
@@ -170,13 +226,53 @@ function adminPage() {
|
||||
if (res.ok) s.enabled = !s.enabled;
|
||||
},
|
||||
|
||||
async healthCheck(s) {
|
||||
s._checking = true;
|
||||
const res = await fetch(`/api/admin/sources/${s.name}/health`, { method: 'POST' });
|
||||
async saveUrl(s) {
|
||||
s._saving = true;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/sources/${s.name}/url`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: s._url }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
s.health = data.healthy; s.healthDetail = data.detail;
|
||||
s.base_url = data.base_url;
|
||||
s.overridden = data.overridden;
|
||||
s._url = data.base_url;
|
||||
s._urlDirty = false;
|
||||
toast('✔ URL enregistrée — test de la source…');
|
||||
this.healthCheck(s);
|
||||
} else {
|
||||
toast('✖ ' + ((await res.json()).detail || 'Erreur'));
|
||||
}
|
||||
} catch {
|
||||
toast('✖ Erreur réseau');
|
||||
} finally {
|
||||
s._saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async resetUrl(s) {
|
||||
s._url = '';
|
||||
await this.saveUrl(s);
|
||||
},
|
||||
|
||||
async healthCheck(s, notify = true) {
|
||||
s._checking = true;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/sources/${s.name}/health`, { method: 'POST' });
|
||||
const data = res.ok ? await res.json() : { healthy: false, detail: `Erreur serveur (${res.status})` };
|
||||
s.health = { healthy: data.healthy, detail: data.detail, checked_at: data.checked_at };
|
||||
if (notify) toast(`${data.healthy ? '✔' : '✖'} ${s.label} : ${data.detail}`);
|
||||
} catch {
|
||||
s.health = { healthy: false, detail: 'Erreur réseau', checked_at: null };
|
||||
if (notify) toast(`✖ ${s.label} : erreur réseau`);
|
||||
} finally {
|
||||
s._checking = false;
|
||||
toast(data.healthy ? `✔ ${s.label} : ${data.detail}` : `✖ ${s.label} : ${data.detail}`);
|
||||
}
|
||||
},
|
||||
|
||||
checkAllSources() {
|
||||
return Promise.allSettled(this.sources.filter(s => s.enabled).map(s => this.healthCheck(s, false)));
|
||||
},
|
||||
|
||||
async post(url) { await fetch(url, { method: 'POST' }); await this.load(); },
|
||||
@@ -188,6 +284,44 @@ function adminPage() {
|
||||
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) {
|
||||
if (!confirm(`Supprimer le compte « ${u.username} » ?`)) return;
|
||||
await fetch('/api/admin/users/' + u.id, { method: 'DELETE' });
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/update-watcher.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+129
-19
@@ -3,18 +3,18 @@
|
||||
{% block title %}Téléchargements — Ohm Stream{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="downloadsPage()" x-init="connect()">
|
||||
<div style="display:flex;align-items:center;gap:0.8rem;margin-bottom:1.6rem">
|
||||
<div x-data="downloadsPage()" x-init="connect()" @click.outside="confirmDel = null">
|
||||
<div style="display:flex;align-items:center;gap:0.8rem;margin-bottom:1.6rem;flex-wrap:wrap">
|
||||
<div>
|
||||
<h1 class="page-title">Téléchargements</h1>
|
||||
<p class="page-sub" style="margin-bottom:0">
|
||||
<span x-text="items.length"></span> tâche(s) —
|
||||
<span x-text="groups.length"></span> animé(s) · <span x-text="items.length"></span> épisode(s) —
|
||||
<span :style="connected ? 'color:var(--success)' : 'color:var(--danger)'"
|
||||
x-text="connected ? '● temps réel' : '○ reconnecté…'"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn btn-ghost btn-sm" @click="action('clear-finished')">🧹 Nettoyer</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="clearFinished()">🧹 Nettoyer</button>
|
||||
<button class="btn btn-danger btn-sm" @click="action('cancel-all')">✖ Tout annuler</button>
|
||||
</div>
|
||||
|
||||
@@ -22,12 +22,38 @@
|
||||
<div class="empty-state"><div class="big">📭</div>Aucun téléchargement. Lance une recherche !</div>
|
||||
</template>
|
||||
|
||||
<div class="episode-list">
|
||||
<template x-for="d in items" :key="d.id">
|
||||
<div class="dl-groups">
|
||||
<template x-for="g in groups" :key="g.name">
|
||||
<div class="dl-group">
|
||||
<div class="dl-group-head" @click="toggle(g)">
|
||||
<span class="dl-chevron" :class="{ open: isOpen(g) }">▶</span>
|
||||
<span class="dl-group-title" x-text="g.name"></span>
|
||||
<span class="dl-group-meta">
|
||||
<span class="m-downloading" x-show="g.counts.downloading"
|
||||
x-text="'⬇ ' + g.counts.downloading"></span>
|
||||
<span class="m-pending" x-show="g.counts.pending + g.counts.paused"
|
||||
x-text="'⏳ ' + (g.counts.pending + g.counts.paused)"></span>
|
||||
<span class="m-done" x-show="g.counts.done" x-text="'✔ ' + g.counts.done"></span>
|
||||
<span class="m-failed" x-show="g.counts.failed + g.counts.cancelled"
|
||||
x-text="'✖ ' + (g.counts.failed + g.counts.cancelled)"></span>
|
||||
</span>
|
||||
<div style="flex:1"></div>
|
||||
<span class="dl-group-size" x-show="g.bytes" x-text="fmtBytes(g.bytes)"></span>
|
||||
<div class="dl-actions" @click.stop>
|
||||
<button class="btn btn-sm btn-ghost" title="Retirer les terminés de ce groupe"
|
||||
x-show="g.counts.done + g.counts.failed + g.counts.cancelled"
|
||||
@click="cleanGroup(g)">🧹</button>
|
||||
<button class="btn btn-sm btn-danger" title="Annuler les téléchargements actifs de ce groupe"
|
||||
x-show="g.counts.active" @click="cancelGroup(g)">✖</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dl-group-body" x-show="isOpen(g)" x-cloak>
|
||||
<template x-for="d in g.items" :key="d.id">
|
||||
<div class="dl-row">
|
||||
<div class="dl-head">
|
||||
<span class="status-pill" :class="'status-' + d.status" x-text="d.status_label"></span>
|
||||
<span class="dl-title" x-text="d.title"></span>
|
||||
<span class="dl-title" x-text="d._label || d.title"></span>
|
||||
<div class="dl-actions">
|
||||
<template x-if="d.status === 'downloading'">
|
||||
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/pause')">⏸</button>
|
||||
@@ -44,6 +70,17 @@
|
||||
<template x-if="d.status === 'done'">
|
||||
<a class="btn btn-sm" :href="'/watch/' + d.id">▶ Regarder</a>
|
||||
</template>
|
||||
<div class="dl-del-wrap">
|
||||
<button class="btn btn-sm btn-ghost" title="Supprimer"
|
||||
@click.stop="confirmDel = confirmDel === d.id ? null : d.id">🗑</button>
|
||||
<div class="dl-menu" x-show="confirmDel === d.id" x-cloak>
|
||||
<button class="btn btn-sm" @click="remove(d, false)"
|
||||
x-text="d.status === 'done' ? 'Liste seulement' : 'Supprimer'"></button>
|
||||
<template x-if="d.status === 'done'">
|
||||
<button class="btn btn-sm btn-danger" @click="remove(d, true)">Liste + fichier</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.9rem">
|
||||
@@ -64,6 +101,9 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
@@ -71,6 +111,8 @@
|
||||
function downloadsPage() {
|
||||
return {
|
||||
items: [], connected: false, es: null,
|
||||
confirmDel: null, // id de l'épisode dont le menu 🗑 est ouvert
|
||||
toggled: {}, // choix manuel de repli/dépliage par groupe
|
||||
|
||||
connect() {
|
||||
this.es = new EventSource('/api/downloads/events');
|
||||
@@ -78,29 +120,97 @@ function downloadsPage() {
|
||||
this.es.onerror = () => this.connected = false;
|
||||
this.es.onmessage = (ev) => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === 'snapshot') this.items = msg.items;
|
||||
else if (msg.type === 'update') this.upsert(msg.item);
|
||||
if (msg.type === 'snapshot') this.items = msg.items.map(d => this.decorate(d));
|
||||
else if (msg.type === 'update') this.upsert(this.decorate(msg.item));
|
||||
else if (msg.type === 'removed') this.items = this.items.filter(d => d.id !== msg.id);
|
||||
};
|
||||
},
|
||||
|
||||
// Découpe « Anime - E12 (VOSTFR) » → base « Anime », numéro, libellé d'épisode
|
||||
decorate(d) {
|
||||
const m = d.title.match(/^(.*?)[\s\-–—]*(?:épisode|episode|ep|e)\s*(\d+(?:\.\d+)?)(.*)$/i);
|
||||
if (m && m[1].trim()) {
|
||||
d._base = m[1].trim();
|
||||
d._num = parseFloat(m[2]);
|
||||
d._label = ('E' + m[2] + ' ' + (m[3] || '')).replace(/\s+/g, ' ').trim();
|
||||
} else {
|
||||
d._base = d.title;
|
||||
d._num = null;
|
||||
d._label = '';
|
||||
}
|
||||
return d;
|
||||
},
|
||||
|
||||
get groups() {
|
||||
const map = new Map();
|
||||
for (const d of this.items) {
|
||||
if (!map.has(d._base)) map.set(d._base, []);
|
||||
map.get(d._base).push(d);
|
||||
}
|
||||
const groups = [];
|
||||
for (const [name, list] of map) {
|
||||
const c = { downloading: 0, pending: 0, paused: 0, done: 0, failed: 0, cancelled: 0 };
|
||||
for (const d of list) c[d.status]++;
|
||||
c.active = c.downloading + c.pending + c.paused;
|
||||
list.sort((a, b) => (a._num ?? Infinity) - (b._num ?? Infinity) || a.id - b.id);
|
||||
groups.push({
|
||||
name, items: list, counts: c,
|
||||
bytes: list.reduce((s, d) => s + (d.status === 'done' ? d.total_bytes || 0 : 0), 0),
|
||||
});
|
||||
}
|
||||
const prio = g => g.counts.downloading ? 0 : g.counts.active ? 1 : 2;
|
||||
groups.sort((a, b) => prio(a) - prio(b) || a.name.localeCompare(b.name, 'fr'));
|
||||
return groups;
|
||||
},
|
||||
|
||||
isOpen(g) {
|
||||
const t = this.toggled[g.name];
|
||||
return t !== undefined ? t : g.counts.active > 0;
|
||||
},
|
||||
|
||||
toggle(g) {
|
||||
this.toggled[g.name] = !this.isOpen(g);
|
||||
},
|
||||
|
||||
upsert(item) {
|
||||
const i = this.items.findIndex(d => d.id === item.id);
|
||||
if (i >= 0) this.items[i] = item;
|
||||
else this.items.unshift(item);
|
||||
this.sort();
|
||||
},
|
||||
|
||||
sort() {
|
||||
const order = { downloading: 0, pending: 1, paused: 2 };
|
||||
this.items.sort((a, b) => (order[a.status] ?? 3) - (order[b.status] ?? 3));
|
||||
else {
|
||||
this.items.unshift(item);
|
||||
delete this.toggled[item._base]; // nouveau groupe/épisode → retour au comportement par défaut
|
||||
}
|
||||
},
|
||||
|
||||
async action(path) {
|
||||
await fetch('/api/downloads/' + path, { method: 'POST' });
|
||||
if (path === 'clear-finished') {
|
||||
},
|
||||
|
||||
async remove(d, deleteFile) {
|
||||
this.confirmDel = null;
|
||||
this.items = this.items.filter(i => i.id !== d.id);
|
||||
await fetch('/api/downloads/' + d.id + (deleteFile ? '?delete_file=true' : ''), { method: 'DELETE' });
|
||||
toast(deleteFile ? '🗑 Épisode et fichier supprimés' : '🗑 Retiré de la liste');
|
||||
},
|
||||
|
||||
async clearFinished() {
|
||||
await this.action('clear-finished');
|
||||
const res = await fetch('/api/downloads');
|
||||
this.items = await res.json();
|
||||
}
|
||||
this.items = (await res.json()).map(d => this.decorate(d));
|
||||
},
|
||||
|
||||
async cleanGroup(g) {
|
||||
const ids = g.items
|
||||
.filter(d => ['done', 'failed', 'cancelled'].includes(d.status))
|
||||
.map(d => d.id);
|
||||
this.items = this.items.filter(d => !ids.includes(d.id));
|
||||
await Promise.all(ids.map(id => fetch('/api/downloads/' + id, { method: 'DELETE' })));
|
||||
toast('🧹 ' + ids.length + ' épisode(s) retiré(s) : ' + g.name);
|
||||
},
|
||||
|
||||
async cancelGroup(g) {
|
||||
const actives = g.items.filter(d => ['pending', 'downloading', 'paused'].includes(d.status));
|
||||
await Promise.all(actives.map(d => this.action(d.id + '/cancel')));
|
||||
toast('✖ ' + actives.length + ' téléchargement(s) annulé(s) : ' + g.name);
|
||||
},
|
||||
|
||||
fmtBytes(n) {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="title-stats">
|
||||
<span x-show="details.rating"><span class="rating-star">★</span> <strong x-text="details.rating"></strong>/10</span>
|
||||
<span x-show="details.year">Année : <strong x-text="details.year"></strong></span>
|
||||
<span><strong x-text="details.episodes.length"></strong> épisode(s)</span>
|
||||
<span><strong x-text="uniqueEpisodeCount"></strong> épisode(s)</span>
|
||||
</div>
|
||||
<div class="title-actions">
|
||||
<button class="btn" @click="downloadSeason" :disabled="seasonJob.running">
|
||||
@@ -43,11 +43,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Épisodes</h2>
|
||||
<h2 class="section-title">Épisodes
|
||||
<span class="version-filter" x-show="epVersions.length" x-cloak>
|
||||
<template x-for="v in epVersions" :key="v">
|
||||
<button class="version-tab" :class="{ active: versionFilter === v }"
|
||||
@click="versionFilter = v" x-text="v.toUpperCase()"></button>
|
||||
</template>
|
||||
<button class="version-tab" :class="{ active: versionFilter === 'all' }"
|
||||
@click="versionFilter = 'all'">Tout</button>
|
||||
</span>
|
||||
</h2>
|
||||
<div class="episode-list">
|
||||
<template x-for="ep in details.episodes" :key="ep.number">
|
||||
<template x-for="ep in filteredEpisodes" :key="ep.number + '-' + (ep.version || '')">
|
||||
<div class="episode-row">
|
||||
<span class="episode-num" x-text="'E' + ep.number"></span>
|
||||
<span class="badge badge-version" x-show="ep.version" x-text="ep.version.toUpperCase()"></span>
|
||||
<span class="episode-name" x-text="ep.title || `Épisode ${ep.number}`"></span>
|
||||
<button class="btn btn-sm btn-ghost" @click="streamEpisode(ep)" :disabled="ep._busy">▶ Stream</button>
|
||||
<button class="btn btn-sm" @click="downloadEpisode(ep)" :disabled="ep._busy">⬇</button>
|
||||
@@ -76,6 +86,23 @@ function titlePage() {
|
||||
details: null, loading: true, error: null, favorite: false,
|
||||
player: { active: false, hls: null },
|
||||
seasonJob: { running: false, done: 0, total: 0 },
|
||||
versionFilter: 'all',
|
||||
|
||||
get epVersions() {
|
||||
if (!this.details) return [];
|
||||
return [...new Set(this.details.episodes.map(e => e.version).filter(Boolean))];
|
||||
},
|
||||
|
||||
get filteredEpisodes() {
|
||||
if (!this.details) return [];
|
||||
if (this.versionFilter === 'all') return this.details.episodes;
|
||||
return this.details.episodes.filter(e => e.version === this.versionFilter);
|
||||
},
|
||||
|
||||
get uniqueEpisodeCount() {
|
||||
if (!this.details) return 0;
|
||||
return new Set(this.details.episodes.map(e => e.number)).size;
|
||||
},
|
||||
|
||||
async load(source, sourceId) {
|
||||
this.source = source; this.sourceId = sourceId;
|
||||
@@ -84,6 +111,7 @@ function titlePage() {
|
||||
if (!res.ok) throw new Error((await res.json()).detail || 'Erreur ' + res.status);
|
||||
this.details = await res.json();
|
||||
this.details.episodes.forEach(e => e._busy = false);
|
||||
this.versionFilter = this.epVersions.includes('vostfr') ? 'vostfr' : 'all';
|
||||
document.title = this.details.title + ' — Ohm Stream';
|
||||
} catch (e) { this.error = e.message; }
|
||||
finally { this.loading = false; }
|
||||
@@ -98,7 +126,10 @@ function titlePage() {
|
||||
return data.links[0];
|
||||
},
|
||||
|
||||
epLabel(ep) { return `${this.details.title} - E${ep.number}`; },
|
||||
epLabel(ep) {
|
||||
const version = ep.version ? ` (${ep.version.toUpperCase()})` : '';
|
||||
return `${this.details.title} - E${ep.number}${version}`;
|
||||
},
|
||||
|
||||
// Lecture via le proxy serveur (tokens liés à l'IP, Referer obligatoire)
|
||||
openPlayer(link) {
|
||||
@@ -148,8 +179,8 @@ function titlePage() {
|
||||
},
|
||||
|
||||
async downloadSeason() {
|
||||
this.seasonJob = { running: true, done: 0, total: this.details.episodes.length };
|
||||
for (const ep of this.details.episodes) {
|
||||
this.seasonJob = { running: true, done: 0, total: this.filteredEpisodes.length };
|
||||
for (const ep of this.filteredEpisodes) {
|
||||
await this.downloadEpisode(ep);
|
||||
this.seasonJob.done++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Version de l'application.
|
||||
|
||||
Source de vérité : `version` de pyproject.toml (lue via les métadonnées du paquet
|
||||
installé par uv). Dans l'image Docker, `OHM_VERSION` est cuit au build par
|
||||
scripts/release.sh et prime sur tout.
|
||||
"""
|
||||
|
||||
import importlib.metadata
|
||||
import os
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
v = os.environ.get("OHM_VERSION")
|
||||
if v:
|
||||
return v
|
||||
try:
|
||||
return importlib.metadata.version("ohm-stream")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return "dev"
|
||||
@@ -0,0 +1,57 @@
|
||||
# Déploiement OhmStreaming — https://git.lanro.eu/Roman/ohm_streaming
|
||||
#
|
||||
# Installation guidée (recommandée) : ./scripts/install.sh
|
||||
# Ou à la main :
|
||||
# 1. docker login git.lanro.eu (compte Gitea avec accès lecture au repo)
|
||||
# 2. cp .env.example .env && $EDITOR .env (OHM_SECRET_KEY + WATCHTOWER_TOKEN obligatoires)
|
||||
# 3. docker compose up -d
|
||||
#
|
||||
# Mise à jour : page Admin → « Mise à jour », ou manuellement :
|
||||
# docker compose pull && docker compose up -d
|
||||
|
||||
services:
|
||||
ohm:
|
||||
image: git.lanro.eu/roman/ohm_streaming:latest
|
||||
# Build local (dev) : décommenter ces lignes et commenter « image: » ci-dessus
|
||||
# build:
|
||||
# context: .
|
||||
# args:
|
||||
# VERSION: dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${OHM_PORT:-8777}:8777"
|
||||
env_file: .env
|
||||
environment:
|
||||
# Chemins internes (volumes ci-dessous) — ne pas changer
|
||||
OHM_DATA_DIR: /data
|
||||
OHM_DOWNLOAD_DIR: /downloads
|
||||
OHM_DATABASE_PATH: /data/ohm.db
|
||||
# Watchtower compagnon sur le réseau interne compose
|
||||
OHM_WATCHTOWER_URL: http://watchtower:8080
|
||||
OHM_WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:?WATCHTOWER_TOKEN manquant — voir .env.example}
|
||||
volumes:
|
||||
- ./data:/data # base SQLite, persiste entre les mises à jour
|
||||
# Bibliothèque d'épisodes : ./downloads par défaut, ou un dossier de ton
|
||||
# serveur (script d'install ou à la main). Un dossier VIDE est chowné au
|
||||
# démarrage par l'entrypoint ; un dossier existant (biblio Plex/Sonarr)
|
||||
# n'est JAMAIS modifié — les épisodes arrivent rangés par animé :
|
||||
# /plex_videos/ohm/One Piece/One Piece - E12 (VOSTFR).mp4
|
||||
- ./downloads:/downloads
|
||||
|
||||
# Compagnon de mise à jour : recrée le conteneur ohm quand l'admin le demande
|
||||
# (API HTTP interne uniquement, aucun port publié sur l'hôte)
|
||||
watchtower:
|
||||
image: containrrr/watchtower
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
# Pas de vérification périodique : uniquement sur appel de l'admin
|
||||
WATCHTOWER_HTTP_API_UPDATE: "true"
|
||||
WATCHTOWER_HTTP_API_TOKEN: ${WATCHTOWER_TOKEN:?WATCHTOWER_TOKEN manquant — voir .env.example}
|
||||
# Ne mettre à jour que les conteneurs étiquetés (ohm), pas watchtower lui-même
|
||||
WATCHTOWER_LABEL_ENABLE: "true"
|
||||
# Compatibilité Docker récent (l'image watchtower négocie une API trop ancienne)
|
||||
DOCKER_API_VERSION: "1.44"
|
||||
# Supprimer les anciennes images après mise à jour
|
||||
WATCHTOWER_CLEANUP: "true"
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
# Prépare les volumes puis bascule sur l'utilisateur non-root « ohm ».
|
||||
#
|
||||
# /data (base SQLite) : toujours à nous → chown récursif.
|
||||
# /downloads : peut être un montage sur une bibliothèque média existante
|
||||
# (Plex/Sonarr…). On ne prend possession QUE d'un dossier vide — jamais
|
||||
# d'une bibliothèque déjà remplie, dont les droits doivent rester intacts.
|
||||
set -e
|
||||
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
chown -R ohm:ohm /data 2>/dev/null || true
|
||||
if [ -z "$(ls -A /downloads 2>/dev/null)" ]; then
|
||||
chown -R ohm:ohm /downloads 2>/dev/null || true
|
||||
fi
|
||||
exec gosu ohm:ohm "$@"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bash
|
||||
# ---------------------------------------------------------------------------
|
||||
# Installation guidée d'Ohm Stream Downloader (premier setup).
|
||||
#
|
||||
# bash scripts/install.sh (depuis un clone du dépôt)
|
||||
# curl -fsSL <gitea>/raw/branch/main/scripts/install.sh | bash
|
||||
#
|
||||
# Options non interactives : --dir <chemin> --port <n> --skip-login
|
||||
# (les valeurs passées en option court-circuitent les questions)
|
||||
#
|
||||
# Pose les questions (destination des animés, port), génère les secrets,
|
||||
# écrit .env, branche le montage Docker, démarre et attend le service.
|
||||
# ---------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY_HOST="git.lanro.eu"
|
||||
REPO_URL="https://${REGISTRY_HOST}/Roman/ohm_streaming.git"
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
|
||||
BOLD="\033[1m"; DIM="\033[2m"; GREEN="\033[32m"; YELLOW="\033[33m"; RED="\033[31m"; RESET="\033[0m"
|
||||
step() { printf "\n${BOLD}▸ %s${RESET}\n" "$1"; }
|
||||
ok() { printf " ${GREEN}✔${RESET} %s\n" "$1"; }
|
||||
warn() { printf " ${YELLOW}⚠${RESET} %s\n" "$1"; }
|
||||
die() { printf " ${RED}✖ %s${RESET}\n" "$1" >&2; exit 1; }
|
||||
|
||||
ARG_DIR="" ARG_PORT="" ARG_SKIP_LOGIN=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dir) ARG_DIR="$2"; shift 2;;
|
||||
--port) ARG_PORT="$2"; shift 2;;
|
||||
--skip-login) ARG_SKIP_LOGIN=1; shift;;
|
||||
*) shift;;
|
||||
esac
|
||||
done
|
||||
|
||||
ask() { # ask <question> <défaut> — lit /dev/tty pour rester compatible curl|bash
|
||||
local answer=""
|
||||
if [ -r /dev/tty ]; then
|
||||
read -r -p "$(printf ' %s ${DIM}[%s]${RESET} ' "$1" "$2")" answer </dev/tty 2>/dev/null || true
|
||||
fi
|
||||
echo "${answer:-$2}"
|
||||
}
|
||||
|
||||
rand_hex() { # rand_hex <nb octets>
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
openssl rand -hex "$1"
|
||||
else
|
||||
head -c "$1" /dev/urandom | od -An -tx1 | tr -d ' \n'
|
||||
fi
|
||||
}
|
||||
|
||||
printf "${BOLD}⛩ Ohm Stream Downloader — installation${RESET}\n"
|
||||
printf "${DIM}Télécharge et streame des animes — Docker + Watchtower${RESET}\n"
|
||||
|
||||
# ---------------------------------------------------------------- prérequis
|
||||
step "Vérification des prérequis"
|
||||
command -v docker >/dev/null 2>&1 || die "Docker n'est pas installé. → curl -fsSL https://get.docker.com | sh"
|
||||
docker compose version >/dev/null 2>&1 || die "Le plugin Docker Compose manque (paquet docker-compose-plugin)."
|
||||
ok "Docker + Compose présents"
|
||||
|
||||
# Depuis un clone ? sinon on clone (permet curl … | bash)
|
||||
if [ ! -f "$COMPOSE_FILE" ]; then
|
||||
INSTALL_DIR="$(ask "Dossier d'installation" "$HOME/ohm_streaming")"
|
||||
if [ -e "$INSTALL_DIR" ]; then
|
||||
[ -f "$INSTALL_DIR/$COMPOSE_FILE" ] || die "$INSTALL_DIR existe déjà (et n'est pas une installation Ohm)."
|
||||
step "Mise à jour du clone existant"
|
||||
git -C "$INSTALL_DIR" pull --ff-only || warn "git pull a échoué, on continue avec la version locale."
|
||||
else
|
||||
step "Récupération du projet"
|
||||
command -v git >/dev/null 2>&1 || die "git est requis pour cloner $REPO_URL"
|
||||
git clone "$REPO_URL" "$INSTALL_DIR"
|
||||
fi
|
||||
cd "$INSTALL_DIR"
|
||||
fi
|
||||
ok "Projet prêt dans $(pwd)"
|
||||
|
||||
# ---------------------------------------------------------------- registre privé
|
||||
step "Accès au registre privé ($REGISTRY_HOST)"
|
||||
if [ "$ARG_SKIP_LOGIN" = "1" ]; then
|
||||
warn "--skip-login : connexion au registre ignorée"
|
||||
elif docker login "$REGISTRY_HOST" 2>/dev/null; then
|
||||
ok "Connecté au registre"
|
||||
else
|
||||
warn "Pas encore connecté — un compte Gitea avec accès lecture au dépôt est requis."
|
||||
if [ "$(ask "Se connecter maintenant (docker login) ?" "o")" = "o" ]; then
|
||||
docker login "$REGISTRY_HOST" || die "docker login a échoué — relance le script après t'être connecté."
|
||||
ok "Connecté au registre"
|
||||
else
|
||||
die "Sans docker login, l'image ne pourra pas être tirée."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- destination des épisodes
|
||||
step "Destination des épisodes téléchargés"
|
||||
if [ -n "$ARG_DIR" ]; then
|
||||
HOST_DIR="$ARG_DIR"
|
||||
else
|
||||
printf " 1) ${BOLD}Dossier dédié « ohm »${RESET} (recommandé) : Ohm crée/utilise un dossier\n\
|
||||
à part, à ajouter à ta bibliothèque Plex. Tes dossiers Sonarr/Plex\n\
|
||||
existants ne sont JAMAIS modifiés.\n\
|
||||
2) Dossier de ton choix (ex. un sous-dossier de ta biblio d'animes).\n\
|
||||
S'il n'est pas vide, Ohm importera aussi les fichiers déjà présents.\n"
|
||||
MODE="$(ask "Choix (1/2)" "1")"
|
||||
if [ "$MODE" = "1" ]; then
|
||||
HOST_DIR="$(ask "Chemin du dossier dédié" "/opt/ohm/animes")"
|
||||
else
|
||||
HOST_DIR="$(ask "Chemin du dossier de téléchargement" "/plex_videos/anime/ohm")"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -e "$HOST_DIR" ] && [ -n "$(ls -A "$HOST_DIR" 2>/dev/null)" ]; then
|
||||
warn "$HOST_DIR n'est pas vide : Ohm l'adoptera tel quel (droits existants conservés)."
|
||||
warn "Le conteneur (uid 1000) doit pouvoir y écrire."
|
||||
fi
|
||||
if ! mkdir -p "$HOST_DIR" 2>/dev/null; then
|
||||
printf ' sudo mkdir -p %q && sudo chown 1000:1000 %q\n' "$HOST_DIR" "$HOST_DIR" >&2
|
||||
die "Impossible de créer $HOST_DIR — crée-le avec sudo puis relance."
|
||||
fi
|
||||
ok "Épisodes → $HOST_DIR (un dossier vide est pris en charge automatiquement)"
|
||||
|
||||
# ---------------------------------------------------------------- port + secrets
|
||||
step "Configuration"
|
||||
PORT="${ARG_PORT:-$(ask "Port HTTP exposé" "8777")}"
|
||||
|
||||
step "Écriture de .env"
|
||||
if [ -f .env ]; then
|
||||
cp .env ".env.backup.$(date +%s)"
|
||||
warn ".env existant sauvegardé"
|
||||
fi
|
||||
SECRET_KEY="$(rand_hex 32)"
|
||||
WT_TOKEN="$(rand_hex 24)"
|
||||
cat > .env <<EOF
|
||||
# Généré par scripts/install.sh le $(date '+%Y-%m-%d %H:%M')
|
||||
OHM_SECRET_KEY=${SECRET_KEY}
|
||||
WATCHTOWER_TOKEN=${WT_TOKEN}
|
||||
OHM_PORT=${PORT}
|
||||
EOF
|
||||
chmod 600 .env
|
||||
ok "Secrets générés (OHM_SECRET_KEY, WATCHTOWER_TOKEN)"
|
||||
|
||||
# ---------------------------------------------------------------- compose : montage téléchargements
|
||||
step "Configuration du montage Docker"
|
||||
cp "$COMPOSE_FILE" "$COMPOSE_FILE.dist"
|
||||
if sed -i "s|^\([[:space:]]*- \)\./downloads:/downloads.*$|\1${HOST_DIR}:/downloads|" "$COMPOSE_FILE" \
|
||||
&& grep -q -- "${HOST_DIR}:/downloads" "$COMPOSE_FILE"; then
|
||||
ok "/downloads → $HOST_DIR (original conservé en $COMPOSE_FILE.dist)"
|
||||
else
|
||||
mv "$COMPOSE_FILE.dist" "$COMPOSE_FILE"
|
||||
die "Montage introuvable dans $COMPOSE_FILE — édite la ligne ./downloads:/downloads à la main."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- démarrage
|
||||
step "Démarrage (docker compose up -d)"
|
||||
docker compose up -d || die "Démarrage échoué — logs : docker compose logs"
|
||||
|
||||
step "Attente du démarrage"
|
||||
HEALTH_URL="http://127.0.0.1:${PORT}/health"
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "$HEALTH_URL" >/dev/null 2>&1 || wget -q -O /dev/null "$HEALTH_URL" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
if [ "$i" = "30" ]; then docker compose logs --tail 30 ohm; die "Le service ne répond pas sur $HEALTH_URL"; fi
|
||||
done
|
||||
ok "Service en ligne"
|
||||
|
||||
HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||
printf "\n${BOLD}${GREEN}⛩ Ohm Stream est prêt !${RESET}\n\n"
|
||||
printf " URL : ${BOLD}http://%s:%s${RESET}\n" "${HOST_IP:-<ip-serveur>}" "$PORT"
|
||||
printf " Épisodes : %s\n" "$HOST_DIR"
|
||||
printf " 1er compte : ${BOLD}le compte créé en premier devient administrateur${RESET}\n\n"
|
||||
printf " ${DIM}Plex : ajoute %s comme dossier d'une bibliothèque (ou comme dossier\n supplémentaire de ta bibliothèque animes) — les épisodes arrivent rangés par\n animé : <Animé>/<Animé> - E12 (VOSTFR).mp4${RESET}\n\n" "$HOST_DIR"
|
||||
printf " ${DIM}Commandes : docker compose logs -f · stop · pull && up -d (mise à jour)${RESET}\n"
|
||||
Executable
+44
@@ -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 »."
|
||||
@@ -38,3 +38,25 @@ async def database() -> AsyncIterator[None]:
|
||||
await db.execute(f"DELETE FROM {table}")
|
||||
yield
|
||||
await db.close()
|
||||
|
||||
# ---------------------------------------------------------------- client HTTP partagé
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncClient:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_cookies(client: AsyncClient):
|
||||
"""Premier compte créé → administrateur, cookies de session."""
|
||||
r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"})
|
||||
assert r.status_code == 303
|
||||
return r.cookies
|
||||
|
||||
+98
-18
@@ -1,25 +1,8 @@
|
||||
"""Tests d'intégration API (auth, downloads, favoris, streaming, admin)."""
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import db
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncClient:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_cookies(client: AsyncClient):
|
||||
r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"})
|
||||
assert r.status_code == 303
|
||||
return r.cookies
|
||||
from app.scrapers.base import ScrapeError, SearchResult
|
||||
|
||||
|
||||
async def test_health(client):
|
||||
@@ -86,6 +69,21 @@ async def test_stream_range(client, admin_cookies):
|
||||
)
|
||||
assert r.status_code == 416
|
||||
|
||||
async def test_delete_download(client, admin_cookies):
|
||||
path = get_settings().download_dir / "del1.mp4"
|
||||
path.write_bytes(b"data" * 100)
|
||||
cursor = await db.execute(
|
||||
"INSERT INTO downloads (source_key, video_url, page_url, title, file_path, status, "
|
||||
"total_bytes, downloaded_bytes) "
|
||||
"VALUES ('k2', 'u', 'p', 'Anime Y - E9 (VOSTFR)', 'del1.mp4', 'done', 400, 400)"
|
||||
)
|
||||
download_id = cursor.lastrowid
|
||||
r = await client.delete(f"/api/downloads/{download_id}?delete_file=true", cookies=admin_cookies)
|
||||
assert r.status_code == 200 and r.json() == {"ok": True}
|
||||
assert not path.exists()
|
||||
r = await client.delete(f"/api/downloads/{download_id}", cookies=admin_cookies)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_admin_required(client):
|
||||
await client.post("/auth/register", data={"username": "admin2", "password": "secret123"})
|
||||
@@ -121,3 +119,85 @@ async def test_source_toggle(client, admin_cookies):
|
||||
states = {s["name"]: s["enabled"] for s in r.json()}
|
||||
assert states["vostfree"] is False
|
||||
assert states["french_manga"] is True
|
||||
|
||||
|
||||
async def test_source_health(client, admin_cookies, monkeypatch):
|
||||
# source inconnue → 404 (pas de 500)
|
||||
r = await client.post("/api/admin/sources/inconnue/health", cookies=admin_cookies)
|
||||
assert r.status_code == 404
|
||||
|
||||
# état initial : aucune santé connue
|
||||
r = await client.get("/api/admin/sources", cookies=admin_cookies)
|
||||
assert all(s["health"] is None for s in r.json())
|
||||
|
||||
async def ok_search(self, query: str) -> list[SearchResult]:
|
||||
return [SearchResult(source=self.name, source_id="x", title="X", url="http://x")]
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.vostfree.VostfreeScraper.search", ok_search)
|
||||
r = await client.post("/api/admin/sources/vostfree/health", cookies=admin_cookies)
|
||||
data = r.json()
|
||||
assert data["healthy"] is True and data["detail"] == "1 résultats" and data["checked_at"]
|
||||
|
||||
# résultat persisté et exposé par GET /sources
|
||||
r = await client.get("/api/admin/sources", cookies=admin_cookies)
|
||||
health = {s["name"]: s["health"] for s in r.json()}
|
||||
assert health["vostfree"]["healthy"] is True
|
||||
assert health["french_manga"] is None
|
||||
|
||||
async def failing_search(self, query: str) -> list[SearchResult]:
|
||||
raise ScrapeError("site indisponible")
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.vostfree.VostfreeScraper.search", failing_search)
|
||||
r = await client.post("/api/admin/sources/vostfree/health", cookies=admin_cookies)
|
||||
data = r.json()
|
||||
assert r.status_code == 200 and data["healthy"] is False and data["detail"] == "site indisponible"
|
||||
|
||||
async def broken_search(self, query: str) -> list[SearchResult]:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.vostfree.VostfreeScraper.search", broken_search)
|
||||
r = await client.post("/api/admin/sources/vostfree/health", cookies=admin_cookies)
|
||||
data = r.json()
|
||||
assert r.status_code == 200 and data["healthy"] is False and "boom" in data["detail"]
|
||||
|
||||
|
||||
async def test_source_toggle_unknown(client, admin_cookies):
|
||||
r = await client.post(
|
||||
"/api/admin/sources/inconnue/toggle", json={"enabled": True}, cookies=admin_cookies
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_source_url_override(client, admin_cookies):
|
||||
from app.scrapers.base import get_source
|
||||
|
||||
r = await client.get("/api/admin/sources", cookies=admin_cookies)
|
||||
src = next(s for s in r.json() if s["name"] == "vostfree")
|
||||
default = src["default_base_url"]
|
||||
assert src["base_url"] == default and src["overridden"] is False
|
||||
|
||||
# source inconnue → 404
|
||||
r = await client.put("/api/admin/sources/inconnue/url", json={"url": "https://x.org"}, cookies=admin_cookies)
|
||||
assert r.status_code == 404
|
||||
|
||||
# URL invalide → 422
|
||||
r = await client.put("/api/admin/sources/vostfree/url", json={"url": "pas-une-url"}, cookies=admin_cookies)
|
||||
assert r.status_code == 422
|
||||
|
||||
# changement de domaine (slash final toléré) → appliqué à l'instance + persisté
|
||||
r = await client.put("/api/admin/sources/vostfree/url", json={"url": "https://exemple.org/"},
|
||||
cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["base_url"] == "https://exemple.org" and data["overridden"] is True
|
||||
assert get_source("vostfree").base_url == "https://exemple.org"
|
||||
|
||||
r = await client.get("/api/admin/sources", cookies=admin_cookies)
|
||||
src = next(s for s in r.json() if s["name"] == "vostfree")
|
||||
assert src["base_url"] == "https://exemple.org" and src["overridden"] is True
|
||||
|
||||
# URL vide → retour au défaut du code
|
||||
r = await client.put("/api/admin/sources/vostfree/url", json={"url": ""}, cookies=admin_cookies)
|
||||
data = r.json()
|
||||
assert data["base_url"] == default and data["overridden"] is False
|
||||
assert get_source("vostfree").base_url == default
|
||||
|
||||
+70
-1
@@ -1,16 +1,19 @@
|
||||
"""Tests unitaires : format interne, sanitisation, auth, registres scrapers."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app import auth
|
||||
from app.scrapers import http as http_module
|
||||
from app.scrapers.base import (
|
||||
ScrapeError,
|
||||
decode_internal_url,
|
||||
encode_internal_url,
|
||||
get_source,
|
||||
import_all_scrapers,
|
||||
resolve_hoster,
|
||||
)
|
||||
from app.services.downloads import sanitize_filename
|
||||
from app.services.downloads import plex_filename, sanitize_filename
|
||||
|
||||
# ------------------------------------------------------------ format interne
|
||||
|
||||
@@ -52,6 +55,32 @@ def test_sanitize_no_traversal():
|
||||
assert sanitize_filename("../../etc/passwd") == "etc passwd"
|
||||
|
||||
|
||||
# ------------------------------------------------------------ nommage Plex
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("title", "expected"),
|
||||
[
|
||||
(
|
||||
"The Eminence in Shadow - Saison 1 - E9 (VF)",
|
||||
"The Eminence in Shadow - S01E09 (VF).mp4",
|
||||
),
|
||||
(
|
||||
"The Eminence in Shadow - Saison 1 - E10 (VF)",
|
||||
"The Eminence in Shadow - S01E10 (VF).mp4",
|
||||
),
|
||||
("Anime X - E1 (VOSTFR)", "Anime X - S01E01 (VOSTFR).mp4"),
|
||||
("One Piece - E1122", "One Piece - S01E1122.mp4"),
|
||||
("Série - saison 2 - e3 (vf)", "Série - S02E03 (vf).mp4"),
|
||||
("Episode Test 1", None),
|
||||
("Mon Titre", None),
|
||||
("Series S01E05 VOSTFR WEB-DL", None),
|
||||
],
|
||||
)
|
||||
def test_plex_filename(title, expected):
|
||||
assert plex_filename(title, ".mp4") == expected
|
||||
|
||||
|
||||
# ------------------------------------------------------------ auth
|
||||
|
||||
|
||||
@@ -107,3 +136,43 @@ def test_hoster_resolution():
|
||||
assert resolve_hoster("https://vidmoly.to/embed-x.html").name == "vidmoly"
|
||||
assert resolve_hoster("https://sendvid.com/embed/x").name == "sendvid"
|
||||
assert resolve_hoster("https://inconnu.example.com/v/1") is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------ client HTTP
|
||||
|
||||
|
||||
class _StatusClient:
|
||||
"""Fake client HTTP : sert une série de codes statut puis un corps 200."""
|
||||
|
||||
def __init__(self, statuses: list[int]):
|
||||
self._statuses = statuses
|
||||
self.calls = 0
|
||||
|
||||
async def get(self, url, headers=None):
|
||||
self.calls += 1
|
||||
request = httpx.Request("GET", url)
|
||||
status = self._statuses.pop(0) if self._statuses else 200
|
||||
response = httpx.Response(status, request=request, text="ok")
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
|
||||
async def test_fetch_fails_fast_on_definitive_4xx(monkeypatch):
|
||||
client = _StatusClient([422])
|
||||
monkeypatch.setattr("app.scrapers.http.get_client", lambda: client)
|
||||
with pytest.raises(ScrapeError, match="422"):
|
||||
await http_module.fetch("https://site.example/lecteur/prepare/1?content=2&episode=3")
|
||||
assert client.calls == 1 # aucun retry sur erreur définitive
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [429, 500, 503])
|
||||
async def test_fetch_retries_transitory_errors(monkeypatch, status):
|
||||
client = _StatusClient([status, 200])
|
||||
monkeypatch.setattr("app.scrapers.http.get_client", lambda: client)
|
||||
|
||||
async def no_sleep(_delay):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("app.scrapers.http.asyncio.sleep", no_sleep)
|
||||
assert await http_module.fetch("https://site.example/page", retries=2) == "ok"
|
||||
assert client.calls == 2
|
||||
|
||||
@@ -6,6 +6,7 @@ import pytest
|
||||
from aiohttp import web
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import db
|
||||
from app.services.downloads import download_manager as dm
|
||||
|
||||
PAYLOAD = b"x" * 500_000
|
||||
@@ -102,3 +103,49 @@ async def test_cancel(file_server, manager):
|
||||
await dm.cancel(d["id"])
|
||||
data = await dm.get(d["id"])
|
||||
assert data["status"] in ("cancelled", "done") # course possible si déjà fini
|
||||
|
||||
async def test_delete_keeps_file(file_server, manager):
|
||||
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Anime X - E1 (VOSTFR)")
|
||||
data = await _wait_status(d["id"], {"done", "failed"})
|
||||
assert data["status"] == "done", data.get("error")
|
||||
path = get_settings().download_dir / data["file_path"]
|
||||
assert path.is_file()
|
||||
assert await dm.delete(d["id"]) is True
|
||||
assert await dm.get(d["id"]) is None
|
||||
assert path.is_file() # le fichier vidéo est conservé
|
||||
|
||||
|
||||
async def test_delete_with_file(file_server, manager):
|
||||
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Anime X - E2 (VOSTFR)")
|
||||
data = await _wait_status(d["id"], {"done", "failed"})
|
||||
path = get_settings().download_dir / data["file_path"]
|
||||
assert await dm.delete(d["id"], delete_file=True) is True
|
||||
assert await dm.get(d["id"]) is None
|
||||
assert not path.exists()
|
||||
assert await dm.delete(d["id"]) is False # déjà supprimé → introuvable
|
||||
|
||||
|
||||
async def test_delete_active(file_server, manager):
|
||||
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Anime X - E3")
|
||||
filename = d["file_path"]
|
||||
assert await dm.delete(d["id"]) is True
|
||||
assert await dm.get(d["id"]) is None
|
||||
download_dir = get_settings().download_dir
|
||||
assert not (download_dir / filename).exists()
|
||||
assert not (download_dir / (filename + ".part")).exists()
|
||||
|
||||
async def test_series_subfolder_and_scan(file_server, manager):
|
||||
"""Les épisodes sont rangés dans un sous-dossier par animé, restaurés au scan."""
|
||||
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Anime Z - E7 (VOSTFR)")
|
||||
data = await _wait_status(d["id"], {"done", "failed"})
|
||||
assert data["status"] == "done", data.get("error")
|
||||
assert data["file_path"] == "Anime Z/Anime Z - S01E07 (VOSTFR).mp4"
|
||||
assert (get_settings().download_dir / data["file_path"]).read_bytes() == PAYLOAD
|
||||
|
||||
# DB vidée + redémarrage → le fichier en sous-dossier est restauré tel quel
|
||||
await db.execute("DELETE FROM downloads")
|
||||
await dm.stop()
|
||||
await dm.start()
|
||||
items = await dm.list_all()
|
||||
paths = [i["file_path"] for i in items] # d'autres fichiers de tests peuvent trainer
|
||||
assert "Anime Z/Anime Z - S01E07 (VOSTFR).mp4" in paths
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""API compatible qBittorrent : flux complet côté Sonarr (add → suivi → import → delete)."""
|
||||
|
||||
import asyncio
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
|
||||
from app.config import get_settings
|
||||
from app.scrapers.base import Episode, VideoLink
|
||||
from app.services.downloads import download_manager as dm
|
||||
from app.services.settings import get_torznab_apikey
|
||||
from app.services.torznab import build_stub
|
||||
from app.services.torznab import torznab as torznab_svc
|
||||
|
||||
PAYLOAD = b"q" * 200_000
|
||||
|
||||
|
||||
def _make_stub(base: str) -> tuple[bytes, str]:
|
||||
announce = (
|
||||
"http://ohm:8777/torznab/api?"
|
||||
+ urlencode({"source": "fake", "sid": "42", "season": 1, "ep": 2, "series": "Frieren"})
|
||||
)
|
||||
return build_stub(announce, "Frieren S01E02")
|
||||
|
||||
|
||||
def test_stub_deterministic():
|
||||
stub, infohash = _make_stub("x")
|
||||
stub2, infohash2 = _make_stub("x")
|
||||
assert stub == stub2 and infohash == infohash2 and len(infohash) == 40
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def file_server():
|
||||
async def handle(request: web.Request) -> web.StreamResponse:
|
||||
return web.Response(body=PAYLOAD)
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get("/video.mp4", handle)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def manager():
|
||||
await dm.start()
|
||||
yield dm
|
||||
await dm.stop()
|
||||
|
||||
|
||||
async def test_qbit_sonarr_flow(client, file_server, manager, monkeypatch):
|
||||
apikey = await get_torznab_apikey()
|
||||
|
||||
# -- auth : mauvais mot de passe refusé, bon mot de passe accepté
|
||||
r = await client.post("/api/v2/auth/login", data={"username": "ohm", "password": "mauvais"})
|
||||
assert r.status_code == 403
|
||||
r = await client.post("/api/v2/auth/login", data={"username": "ohm", "password": apikey})
|
||||
assert r.text == "Ok."
|
||||
|
||||
# -- les endpoints nécessitent la session SID
|
||||
r = await client.post("/api/v2/auth/login", data={"username": "ohm", "password": apikey})
|
||||
sid = r.cookies["SID"]
|
||||
client.cookies.clear()
|
||||
r = await client.get("/api/v2/app/version")
|
||||
assert r.status_code == 403
|
||||
client.cookies.set("SID", sid)
|
||||
|
||||
r = await client.get("/api/v2/app/version")
|
||||
assert r.status_code == 200 and r.text.startswith("v")
|
||||
r = await client.get("/api/v2/app/webapiVersion")
|
||||
assert r.status_code == 200
|
||||
|
||||
# -- le scraping est simulé : l'épisode pointe vers notre serveur local
|
||||
async def fake_episodes(scraper, source_id):
|
||||
return [Episode(number=2, title="Épisode 2", url="http://fake/ep2", season=1)]
|
||||
|
||||
async def fake_resolve(scraper, episode_url):
|
||||
return VideoLink(url=f"{file_server}/video.mp4", hoster="fake")
|
||||
|
||||
class _FakeScraper:
|
||||
name = "fake"
|
||||
|
||||
monkeypatch.setattr("app.scrapers.base.get_source", lambda name: _FakeScraper())
|
||||
monkeypatch.setattr(torznab_svc, "_episodes_of", fake_episodes)
|
||||
monkeypatch.setattr(torznab_svc, "_resolve_video", fake_resolve)
|
||||
|
||||
# -- Sonarr pousse le .torrent de service reçu de l'indexeur
|
||||
stub, infohash = _make_stub(file_server)
|
||||
r = await client.post(
|
||||
"/api/v2/torrents/add",
|
||||
files={"torrents": ("ohm.torrent", stub, "application/x-bittorrent")},
|
||||
)
|
||||
assert r.text == "Ok."
|
||||
|
||||
# -- suivi : présent avec le bon infohash, progresse jusqu'à « terminé »
|
||||
item = None
|
||||
for _ in range(150):
|
||||
items = (await client.get("/api/v2/torrents/info")).json()
|
||||
item = next((i for i in items if i["hash"] == infohash), None)
|
||||
if item and item["state"] == "pausedUP":
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
assert item is not None, "téléchargement invisible dans torrents/info"
|
||||
assert item["state"] == "pausedUP"
|
||||
assert item["progress"] == 1
|
||||
assert item["name"] == "Frieren S01E02"
|
||||
assert item["content_path"].startswith("/downloads/Frieren S01/Frieren S01E02.mp4")
|
||||
|
||||
# -- properties : le chemin que Sonarr importera
|
||||
r = await client.get("/api/v2/torrents/properties", params={"hash": infohash})
|
||||
assert r.status_code == 200
|
||||
props = r.json()
|
||||
assert props["content_path"] == item["content_path"]
|
||||
assert props["completion_date"] > 0
|
||||
|
||||
# -- le fichier est bien là où Sonarr l'attend
|
||||
host_path = get_settings().download_dir / item["content_path"].removeprefix("/downloads/")
|
||||
assert host_path.read_bytes() == PAYLOAD
|
||||
|
||||
# -- retrait après import : file vidée, fichier supprimé
|
||||
r = await client.post(
|
||||
"/api/v2/torrents/delete", data={"hashes": infohash, "deleteFiles": "true"}
|
||||
)
|
||||
assert r.text == "Ok."
|
||||
items = (await client.get("/api/v2/torrents/info")).json()
|
||||
assert items == []
|
||||
assert not host_path.exists()
|
||||
|
||||
|
||||
async def test_qbit_add_rejects_garbage(client):
|
||||
apikey = await get_torznab_apikey()
|
||||
await client.post("/api/v2/auth/login", data={"username": "ohm", "password": apikey})
|
||||
r = await client.post(
|
||||
"/api/v2/torrents/add",
|
||||
files={"torrents": ("ohm.torrent", b"n'importe quoi", "application/x-bittorrent")},
|
||||
)
|
||||
assert r.text == "Fals."
|
||||
assert (await client.get("/api/v2/torrents/info")).json() == []
|
||||
+27
-8
@@ -128,9 +128,26 @@ async def test_torznab_tvsearch_all_episodes(client, apikey, fake_source):
|
||||
assert "Frieren S02E01 VOSTFR WEB-DL" in titles
|
||||
|
||||
|
||||
async def test_torznab_search_requires_query(client, apikey):
|
||||
async def test_torznab_search_without_query_serves_latest(client, apikey, fake_source):
|
||||
# Sans q : flux RSS des nouveautés (RSS sync / test Sonarr), pas une erreur 400.
|
||||
async def fake_latest():
|
||||
return [
|
||||
SearchResult(
|
||||
source="fake",
|
||||
source_id="frieren-1",
|
||||
title="Frieren",
|
||||
url="https://fake.example/frieren",
|
||||
)
|
||||
]
|
||||
|
||||
fake_source.latest = fake_latest
|
||||
r = await client.get("/torznab/api", params={"t": "search", "apikey": apikey})
|
||||
assert r.status_code == 400
|
||||
assert r.status_code == 200
|
||||
root = ET.fromstring(r.text)
|
||||
items = root.findall(".//item")
|
||||
assert len(items) == 1
|
||||
# seul le dernier épisode de la nouveauté est publié (S02E01 > S01E02 > S01E01)
|
||||
assert items[0].find("title").text == "Frieren S02E01 VOSTFR WEB-DL"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- grab
|
||||
@@ -138,10 +155,12 @@ async def test_torznab_search_requires_query(client, apikey):
|
||||
|
||||
class _FakeManager:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str, str]] = []
|
||||
self.calls: list[tuple[str, str | None]] = []
|
||||
|
||||
async def enqueue(self, video_url: str, page_url: str, title: str) -> dict:
|
||||
self.calls.append((video_url, page_url, title))
|
||||
async def enqueue(
|
||||
self, video_url: str, page_url: str, title: str, source_key: str | None = None
|
||||
) -> dict:
|
||||
self.calls.append((video_url, source_key))
|
||||
return {"id": 7, "title": title, "status": "pending"}
|
||||
|
||||
|
||||
@@ -172,9 +191,9 @@ async def test_torznab_download_enqueues_and_returns_torrent(client, apikey, fak
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("application/x-bittorrent")
|
||||
assert r.content.startswith(b"d") and b"OhmStreaming" in r.content # bencode valide
|
||||
assert manager.calls == [
|
||||
("https://cdn.example/video.mp4", "https://fake.example/ep2", "Frieren S01E02")
|
||||
]
|
||||
video_url, source_key = manager.calls[0]
|
||||
assert video_url == "https://cdn.example/video.mp4"
|
||||
assert source_key and source_key.startswith("sonarr:") and video_url in source_key
|
||||
|
||||
|
||||
async def test_torznab_download_rejects_bad_key(client):
|
||||
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Tests du scraper VoirAnime (source + extracteur prepare) sur fixtures HTML réelles."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.scrapers.base import ScrapeError, get_source, import_all_scrapers, resolve_hoster
|
||||
from app.scrapers.hosters.voiranime import VoirAnimeExtractor
|
||||
from app.scrapers.sources.voiranime import VoirAnimeScraper
|
||||
|
||||
# ------------------------------------------------------------ fixtures HTML
|
||||
|
||||
SEARCH_HTML = """
|
||||
<div class="catalogue-grid">
|
||||
<article class="catalogue-card catalogue-media-card">
|
||||
<a href="/catalogue/one-piece" class="catalogue-poster">
|
||||
<img src="https://img.example/one-piece.jpg" alt="one piece">
|
||||
</a>
|
||||
</article>
|
||||
<article class="catalogue-card catalogue-media-card">
|
||||
<a href="/catalogue/one-punch-man" class="catalogue-poster">
|
||||
<img src="https://img.example/opm.jpg" alt="one punch man">
|
||||
</a>
|
||||
</article>
|
||||
<article class="catalogue-card catalogue-media-card">
|
||||
<a href="/catalogue/one-outs" class="catalogue-poster">
|
||||
<img src="/assets/img/placeholders/content-placeholder.svg" alt="one outs">
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
"""
|
||||
|
||||
DETAILS_HTML = """
|
||||
<h1>solo leveling</h1>
|
||||
<div class="media-detail-meta"><span>26 épisodes</span></div>
|
||||
<img src="https://img.example/solo.jpg" alt="Affiche de solo leveling">
|
||||
<div class="media-detail-story">
|
||||
<span class="media-detail-section-label">Synopsis</span>
|
||||
<p> Sung Jinwoo, chasseur le plus faible. </p>
|
||||
</div>
|
||||
<a href="/lecteur/965/29054" class="detail-episode-card" data-season="1">
|
||||
<small>S1 · E1</small><span>Episode 1</span>
|
||||
</a>
|
||||
<a href="/lecteur/965/29055" class="detail-episode-card" data-season="1">
|
||||
<small>S1 · E2</small><span>Episode 2</span>
|
||||
</a>
|
||||
<a href="/lecteur/965/29060" class="detail-episode-card" data-season="2">
|
||||
<small>S2 · E1</small><span>Episode 1</span>
|
||||
</a>
|
||||
<a href="/lecteur/965/29061" class="detail-episode-card" data-season="2">
|
||||
<small>S2 · E2.5</small><span>Episode 2.5</span>
|
||||
</a>
|
||||
"""
|
||||
|
||||
PLAYER_PAGE_1 = """
|
||||
<main class="tv-player" data-player
|
||||
data-content-id="1626" data-episode-id="46416" data-source-id="169261"
|
||||
data-prepare-url="/lecteur/prepare/169261?content=1626&episode=46416"
|
||||
data-fallback-source-url="/lecteur/1626/46416?lang=vostfr&source=169262&previous_source=169261">
|
||||
</main>
|
||||
"""
|
||||
|
||||
# fin de chaîne : le fallback repointe vers la source 1 (aller-retour observé en live)
|
||||
PLAYER_PAGE_2 = """
|
||||
<main class="tv-player" data-player
|
||||
data-content-id="1626" data-episode-id="46416" data-source-id="169262"
|
||||
data-prepare-url="/lecteur/prepare/169262?content=1626&episode=46416"
|
||||
data-fallback-source-url="/lecteur/1626/46416?lang=vostfr&source=169261&previous_source=169262">
|
||||
</main>
|
||||
"""
|
||||
|
||||
HOME_HTML = """
|
||||
<div class="carousel" id="carousel-new">
|
||||
<article class="content-card">
|
||||
<a href="/catalogue/you-and-i-are-polar-opposites" class="card-poster">
|
||||
<img src="https://img.example/polar.jpg" alt="you and i are polar opposites">
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
<div class="carousel" id="carousel-added">
|
||||
<article class="content-card">
|
||||
<a href="/catalogue/you-and-i-are-polar-opposites" class="card-poster">
|
||||
<img src="https://img.example/polar.jpg" alt="you and i are polar opposites">
|
||||
</a>
|
||||
</article>
|
||||
<article class="content-card">
|
||||
<a href="/catalogue/solo-leveling" class="card-poster">
|
||||
<img src="https://img.example/solo.jpg" alt="solo leveling">
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _soup(html: str):
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
return BeautifulSoup(html, "lxml")
|
||||
|
||||
|
||||
def _prepare_payload(url: str, referer: str) -> str:
|
||||
raw = json.dumps({"url": url, "referer": referer, "kind": "file"}).encode()
|
||||
return base64.b64encode(raw).decode()
|
||||
|
||||
|
||||
# ------------------------------------------------------------ source
|
||||
|
||||
|
||||
async def test_search_parses_catalogue_cards(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
assert "q=one+piece" in url
|
||||
return _soup(SEARCH_HTML)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
|
||||
results = await VoirAnimeScraper().search("one piece")
|
||||
assert [r.source_id for r in results] == ["one-piece", "one-punch-man", "one-outs"]
|
||||
assert results[0].title == "one piece"
|
||||
assert results[0].url == "https://voiranime.xyz/catalogue/one-piece"
|
||||
assert results[0].image_url == "https://img.example/one-piece.jpg"
|
||||
assert results[2].image_url == "/assets/img/placeholders/content-placeholder.svg"
|
||||
|
||||
|
||||
async def test_get_details_and_episodes(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
assert url.endswith("/catalogue/solo-leveling")
|
||||
return _soup(DETAILS_HTML)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
|
||||
scraper = VoirAnimeScraper()
|
||||
details = await scraper.get_details("solo-leveling")
|
||||
assert details.title == "solo leveling"
|
||||
assert details.episode_count == 26
|
||||
assert details.image_url == "https://img.example/solo.jpg"
|
||||
assert details.synopsis.startswith("Sung Jinwoo")
|
||||
|
||||
episodes = details.episodes
|
||||
assert len(episodes) == 4
|
||||
assert (episodes[0].season, episodes[0].number) == (1, 1.0)
|
||||
assert (episodes[2].season, episodes[2].number) == (2, 1.0)
|
||||
assert (episodes[3].season, episodes[3].number) == (2, 2.5)
|
||||
assert episodes[0].url == "https://voiranime.xyz/lecteur/965/29054"
|
||||
|
||||
standalone = await scraper.list_episodes("solo-leveling")
|
||||
assert len(standalone) == len(episodes)
|
||||
|
||||
|
||||
async def test_extract_embed_links_follows_fallback_chain(monkeypatch):
|
||||
pages = {
|
||||
"https://voiranime.xyz/lecteur/1626/46416": PLAYER_PAGE_1,
|
||||
"https://voiranime.xyz/lecteur/1626/46416?lang=vostfr&source=169262&previous_source=169261": PLAYER_PAGE_2,
|
||||
}
|
||||
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
return _soup(pages[url])
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
|
||||
urls = await VoirAnimeScraper().extract_embed_links("https://voiranime.xyz/lecteur/1626/46416")
|
||||
assert urls == [
|
||||
"https://voiranime.xyz/lecteur/prepare/169261?content=1626&episode=46416",
|
||||
"https://voiranime.xyz/lecteur/prepare/169262?content=1626&episode=46416",
|
||||
]
|
||||
|
||||
|
||||
async def test_extract_embed_links_no_player_raises(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
return _soup("<html><body>pas de lecteur</body></html>")
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
|
||||
with pytest.raises(ScrapeError):
|
||||
await VoirAnimeScraper().extract_embed_links("https://voiranime.xyz/lecteur/1/2")
|
||||
|
||||
|
||||
async def test_latest_merges_carousels_dedup(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
assert url == "https://voiranime.xyz/"
|
||||
return _soup(HOME_HTML)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
|
||||
results = await VoirAnimeScraper().latest()
|
||||
assert [r.source_id for r in results] == ["you-and-i-are-polar-opposites", "solo-leveling"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------ extracteur hoster
|
||||
|
||||
|
||||
async def test_hoster_decodes_prepare_payload(monkeypatch):
|
||||
payload = _prepare_payload(
|
||||
"https://video.sibnet.ru/v/abc123/6235096.mp4", "https://video.sibnet.ru/"
|
||||
)
|
||||
|
||||
async def fake_fetch(url, **kwargs):
|
||||
assert "X-Requested-With" in kwargs.get("headers", {})
|
||||
assert kwargs.get("referer") == "https://voiranime.xyz/lecteur/1626/46416"
|
||||
return json.dumps(
|
||||
{"success": True, "media_type": "mp4", "stream_url": f"/proxy/media?payload={payload}"}
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.hosters.voiranime.fetch", fake_fetch)
|
||||
link = await VoirAnimeExtractor().extract(
|
||||
"https://voiranime.xyz/lecteur/prepare/169261?content=1626&episode=46416"
|
||||
)
|
||||
assert link.url == "https://video.sibnet.ru/v/abc123/6235096.mp4"
|
||||
assert link.hoster == "voiranime"
|
||||
assert link.headers == {"Referer": "https://video.sibnet.ru/"}
|
||||
assert link.is_hls is False
|
||||
|
||||
|
||||
async def test_hoster_marks_hls(monkeypatch):
|
||||
payload = _prepare_payload("https://cdn.example/hls/master.m3u8", "https://cdn.example/")
|
||||
|
||||
async def fake_fetch(url, **kwargs):
|
||||
return json.dumps(
|
||||
{"success": True, "media_type": "hls", "stream_url": f"/proxy/media?payload={payload}"}
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.scrapers.hosters.voiranime.fetch", fake_fetch)
|
||||
link = await VoirAnimeExtractor().extract("https://voiranime.xyz/lecteur/prepare/1?content=2&episode=3")
|
||||
# HLS signé pour le backend du site → on sert l'URL proxy du site, pas le CDN
|
||||
assert link.url == f"https://voiranime.xyz/proxy/media?payload={payload}"
|
||||
assert link.is_hls is True
|
||||
assert link.headers == {"Referer": "https://voiranime.xyz/"}
|
||||
|
||||
|
||||
async def test_hoster_prepare_failure_raises(monkeypatch):
|
||||
async def fake_fetch(url, **kwargs):
|
||||
return json.dumps({"success": False, "error": "Flux extrait inaccessible"})
|
||||
|
||||
monkeypatch.setattr("app.scrapers.hosters.voiranime.fetch", fake_fetch)
|
||||
with pytest.raises(ScrapeError, match="Flux extrait inaccessible"):
|
||||
await VoirAnimeExtractor().extract("https://voiranime.xyz/lecteur/prepare/1?content=2&episode=3")
|
||||
|
||||
|
||||
def test_source_and_hoster_registered():
|
||||
import_all_scrapers()
|
||||
assert get_source("voiranime").label == "VoirAnime"
|
||||
extractor = resolve_hoster("https://voiranime.xyz/lecteur/prepare/169261?content=1&episode=2")
|
||||
assert extractor is not None and extractor.name == "voiranime"
|
||||
Reference in New Issue
Block a user