diff --git a/README.md b/README.md index 97b3093..5d76053 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,21 @@ regarder et télécharger des animes et séries VOSTFR/VF. Le déploiement officiel passe par Docker Compose : l'image (ffmpeg inclus) est hébergée sur le **registre privé du Gitea** — rien n'est publié publiquement. +### Installation guidée (recommandée) + +```bash +git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming +./scripts/install.sh +``` + +Le script vérifie Docker, demande la **destination des épisodes** (dossier dédié +recommandé — voir « Bibliothèque Plex/Sonarr » ci-dessous), le port, génère les +secrets, branche le montage et démarre. Non interactif aussi : +`./scripts/install.sh --dir /srv/animes --port 8777 --skip-login`. + +### À la main + +```bash # 1. Récupérer le projet puis se connecter au registre privé (compte Gitea avec accès lecture) git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming docker login git.lanro.eu @@ -22,7 +37,27 @@ docker compose up -d Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**. Les données (`data/`, `downloads/`) sont montées en volumes : elles survivent aux -mises à jour. Port hôte modifiable via `OHM_PORT` dans `.env`. + + +### Bibliothèque Plex / Sonarr existante + +Ohm peut déposer ses épisodes directement dans ton serveur de média : + +1. **Crée un dossier dédié** (recommandé : hors de la racine Sonarr, ex. + `/plex_videos/ohm`) et monte-le à la place de `./downloads` : + `- /plex_videos/ohm:/downloads` — c'est ce que fait `scripts/install.sh`. +2. **Ajoute ce dossier à Plex** comme dossier d'une bibliothèque (ou comme + dossier supplémentaire de ta bibliothèque animes). Les épisodes arrivent + rangés par animé : `One Piece/One Piece - E1010 (VOSTFR).mp4` — le nom du + dossier sert de série, le fichier d'épisode. +3. **Sonarr n'est pas touché** : l'entrypoint du conteneur ne prend possession + (`chown`) que d'un dossier de téléchargements **vide** ; une bibliothèque + existante et ses droits restent intacts. Pour piloter Ohm depuis Sonarr ou + Prowlarr, voir l'API Torznab plus bas. + +Si tu pointes `/downloads` sur un dossier **non vide**, Ohm adopte les fichiers +présents (ils apparaissent dans sa bibliothèque interne) et conserve les droits +existants — assure-toi juste que l'uid 1000 du conteneur peut y écrire. ### Mettre à jour @@ -152,6 +187,6 @@ app/ ## Tests ```bash -uv run pytest # 82 tests +uv run pytest # 83 tests uv run ruff check . # lint ``` diff --git a/app/services/downloads.py b/app/services/downloads.py index 23a1b46..37693cd 100644 --- a/app/services/downloads.py +++ b/app/services/downloads.py @@ -63,6 +63,19 @@ 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 + class DownloadManager: """File d'attente de téléchargements, injectée dans les routes via app.state.""" @@ -117,25 +130,29 @@ class DownloadManager: download_dir = get_settings().download_dir rows = await db.fetchall("SELECT file_path FROM downloads WHERE file_path IS NOT NULL") known = {row["file_path"] for row in rows} - for path in download_dir.iterdir(): - if path.is_file() and path.suffix != ".part" and path.name not in known: - size = path.stat().st_size - await db.execute( - "INSERT INTO downloads " - "(source_key, video_url, page_url, title, file_path, status, " - " total_bytes, downloaded_bytes) " - "VALUES (?, ?, ?, ?, ?, 'done', ?, ?)", - ( - f"file:{path.name}", - "", - "", - path.stem, - path.name, - size, - size, - ), - ) - logger.info("Fichier restauré depuis le disque : %s", path.name) + for path in download_dir.rglob("*"): + if not path.is_file() or path.suffix == ".part": + continue + rel_path = path.relative_to(download_dir).as_posix() + if rel_path in known: + continue + size = path.stat().st_size + await db.execute( + "INSERT INTO downloads " + "(source_key, video_url, page_url, title, file_path, status, " + " total_bytes, downloaded_bytes) " + "VALUES (?, ?, ?, ?, ?, 'done', ?, ?)", + ( + f"file:{rel_path}", + "", + "", + path.stem, + rel_path, + size, + size, + ), + ) + logger.info("Fichier restauré depuis le disque : %s", rel_path) # ------------------------------------------------------------ API publique @@ -152,10 +169,12 @@ class DownloadManager: return self._to_dict(existing, duplicate=True) filename = sanitize_filename(title) + self._guess_extension(video_url) + 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) @@ -234,7 +253,11 @@ class DownloadManager: if row["status"] in ACTIVE_STATUSES: await self.cancel(download_id) if delete_file and row["file_path"]: - (get_settings().download_dir / row["file_path"]).unlink(missing_ok=True) + 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) @@ -324,6 +347,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 @@ -377,6 +401,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 diff --git a/docker-compose.yml b/docker-compose.yml index cd160c7..87f0538 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,10 @@ # Déploiement OhmStreaming — https://git.lanro.eu/Roman/ohm_streaming # -# 1. docker login git.lanro.eu (compte Gitea avec accès lecture au repo) -# 2. cp .env.example .env && $EDITOR .env (OHM_SECRET_KEY + WATCHTOWER_TOKEN obligatoires) -# 3. docker compose up -d +# Installation guidée (recommandée) : ./scripts/install.sh +# Ou à la main : +# 1. docker login git.lanro.eu (compte Gitea avec accès lecture au repo) +# 2. cp .env.example .env && $EDITOR .env (OHM_SECRET_KEY + WATCHTOWER_TOKEN obligatoires) +# 3. docker compose up -d # # Mise à jour : page Admin → « Mise à jour », ou manuellement : # docker compose pull && docker compose up -d @@ -29,10 +31,12 @@ services: OHM_WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:?WATCHTOWER_TOKEN manquant — voir .env.example} volumes: - ./data:/data # base SQLite, persiste entre les mises à jour - - ./downloads:/downloads # bibliothèque téléchargée - labels: - # Watchtower ne touche que les conteneurs portant ce label - - com.centurylinklabs.watchtower.enable=true + # Bibliothèque d'épisodes : ./downloads par défaut, ou un dossier de ton + # serveur (script d'install ou à la main). Un dossier VIDE est chowné au + # démarrage par l'entrypoint ; un dossier existant (biblio Plex/Sonarr) + # n'est JAMAIS modifié — les épisodes arrivent rangés par animé : + # /plex_videos/ohm/One Piece/One Piece - E12 (VOSTFR).mp4 + - ./downloads:/downloads # Compagnon de mise à jour : recrée le conteneur ohm quand l'admin le demande # (API HTTP interne uniquement, aucun port publié sur l'hôte) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh old mode 100644 new mode 100755 index d26d3ec..188db59 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,10 +1,17 @@ #!/bin/sh -# Corrige les permissions des volumes (créés par Docker en root au premier up) -# puis bascule sur l'utilisateur non-root « ohm ». +# Prépare les volumes puis bascule sur l'utilisateur non-root « ohm ». +# +# /data (base SQLite) : toujours à nous → chown récursif. +# /downloads : peut être un montage sur une bibliothèque média existante +# (Plex/Sonarr…). On ne prend possession QUE d'un dossier vide — jamais +# d'une bibliothèque déjà remplie, dont les droits doivent rester intacts. set -e if [ "$(id -u)" = "0" ]; then - chown -R ohm:ohm /data /downloads 2>/dev/null || true + chown -R ohm:ohm /data 2>/dev/null || true + if [ -z "$(ls -A /downloads 2>/dev/null)" ]; then + chown -R ohm:ohm /downloads 2>/dev/null || true + fi exec gosu ohm:ohm "$@" fi diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..30d736f --- /dev/null +++ b/scripts/install.sh @@ -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 /raw/branch/main/scripts/install.sh | bash +# +# Options non interactives : --dir --port --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 — 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/null || true + fi + echo "${answer:-$2}" +} + +rand_hex() { # rand_hex + 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 </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:-}" "$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é : / - E12 (VOSTFR).mp4${RESET}\n\n" "$HOST_DIR" +printf " ${DIM}Commandes : docker compose logs -f · stop · pull && up -d (mise à jour)${RESET}\n" diff --git a/tests/test_downloads.py b/tests/test_downloads.py index 2d84e33..30fd302 100644 --- a/tests/test_downloads.py +++ b/tests/test_downloads.py @@ -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 @@ -132,3 +133,19 @@ async def test_delete_active(file_server, manager): 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 - E7 (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 - E7 (VOSTFR).mp4" in paths