Installation guidée + intégration bibliothèque Plex/Sonarr

- scripts/install.sh : premier setup interactif (destination des épisodes,
  port, secrets auto-générés, montage, démarrage) — options --dir/--port/
  --skip-login pour une install non interactive
- Épisodes rangés par animé sur le disque : <Animé>/<Animé> - E12 (VOSTFR).mp4
  (meilleur parsing Plex, suppression du dossier quand il se vide)
- Entrypoint : chown récursif uniquement si le dossier de téléchargements est
  vide — une bibliothèque Plex/Sonarr existante n'est jamais modifiée
- Scan disque récursif (sous-dossiers par animé restaurés)
- README : installation guidée + section « Bibliothèque Plex / Sonarr »
This commit is contained in:
Roman
2026-09-22 12:38:18 +00:00
parent 9500a84a5e
commit 1f326d34dd
6 changed files with 294 additions and 33 deletions
+37 -2
View File
@@ -8,6 +8,21 @@ regarder et télécharger des animes et séries VOSTFR/VF.
Le déploiement officiel passe par Docker Compose : l'image (ffmpeg inclus) est
hébergée sur le **registre privé du Gitea** — rien n'est publié publiquement.
### Installation guidée (recommandée)
```bash
git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming
./scripts/install.sh
```
Le script vérifie Docker, demande la **destination des épisodes** (dossier dédié
recommandé — voir « Bibliothèque Plex/Sonarr » ci-dessous), le port, génère les
secrets, branche le montage et démarre. Non interactif aussi :
`./scripts/install.sh --dir /srv/animes --port 8777 --skip-login`.
### À la main
```bash
# 1. Récupérer le projet puis se connecter au registre privé (compte Gitea avec accès lecture)
git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming
docker login git.lanro.eu
@@ -22,7 +37,27 @@ docker compose up -d
Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**.
Les données (`data/`, `downloads/`) sont montées en volumes : elles survivent aux
mises à jour. Port hôte modifiable via `OHM_PORT` dans `.env`.
### Bibliothèque Plex / Sonarr existante
Ohm peut déposer ses épisodes directement dans ton serveur de média :
1. **Crée un dossier dédié** (recommandé : hors de la racine Sonarr, ex.
`/plex_videos/ohm`) et monte-le à la place de `./downloads` :
`- /plex_videos/ohm:/downloads` — c'est ce que fait `scripts/install.sh`.
2. **Ajoute ce dossier à Plex** comme dossier d'une bibliothèque (ou comme
dossier supplémentaire de ta bibliothèque animes). Les épisodes arrivent
rangés par animé : `One Piece/One Piece - E1010 (VOSTFR).mp4` — le nom du
dossier sert de série, le fichier d'épisode.
3. **Sonarr n'est pas touché** : l'entrypoint du conteneur ne prend possession
(`chown`) que d'un dossier de téléchargements **vide** ; une bibliothèque
existante et ses droits restent intacts. Pour piloter Ohm depuis Sonarr ou
Prowlarr, voir l'API Torznab plus bas.
Si tu pointes `/downloads` sur un dossier **non vide**, Ohm adopte les fichiers
présents (ils apparaissent dans sa bibliothèque interne) et conserve les droits
existants — assure-toi juste que l'uid 1000 du conteneur peut y écrire.
### Mettre à jour
@@ -152,6 +187,6 @@ app/
## Tests
```bash
uv run pytest # 82 tests
uv run pytest # 83 tests
uv run ruff check . # lint
```
+32 -7
View File
@@ -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,8 +130,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,16 +143,16 @@ 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
@@ -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
+8 -4
View File
@@ -1,5 +1,7 @@
# 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
@@ -29,10 +31,12 @@ services:
OHM_WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:?WATCHTOWER_TOKEN manquant — voir .env.example}
volumes:
- ./data:/data # base SQLite, persiste entre les mises à jour
- ./downloads:/downloads # bibliothèque téléchargée
labels:
# Watchtower ne touche que les conteneurs portant ce label
- com.centurylinklabs.watchtower.enable=true
# Bibliothèque d'épisodes : ./downloads par défaut, ou un dossier de ton
# serveur (script d'install ou à la main). Un dossier VIDE est chowné au
# démarrage par l'entrypoint ; un dossier existant (biblio Plex/Sonarr)
# n'est JAMAIS modifié — les épisodes arrivent rangés par animé :
# /plex_videos/ohm/One Piece/One Piece - E12 (VOSTFR).mp4
- ./downloads:/downloads
# Compagnon de mise à jour : recrée le conteneur ohm quand l'admin le demande
# (API HTTP interne uniquement, aucun port publié sur l'hôte)
Regular → Executable
+10 -3
View File
@@ -1,10 +1,17 @@
#!/bin/sh
# Corrige les permissions des volumes (créés par Docker en root au premier up)
# puis bascule sur l'utilisateur non-root « ohm ».
# Prépare les volumes puis bascule sur l'utilisateur non-root « ohm ».
#
# /data (base SQLite) : toujours à nous → chown récursif.
# /downloads : peut être un montage sur une bibliothèque média existante
# (Plex/Sonarr…). On ne prend possession QUE d'un dossier vide — jamais
# d'une bibliothèque déjà remplie, dont les droits doivent rester intacts.
set -e
if [ "$(id -u)" = "0" ]; then
chown -R ohm:ohm /data /downloads 2>/dev/null || true
chown -R ohm:ohm /data 2>/dev/null || true
if [ -z "$(ls -A /downloads 2>/dev/null)" ]; then
chown -R ohm:ohm /downloads 2>/dev/null || true
fi
exec gosu ohm:ohm "$@"
fi
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# Installation guidée d'Ohm Stream Downloader (premier setup).
#
# bash scripts/install.sh (depuis un clone du dépôt)
# curl -fsSL <gitea>/raw/branch/main/scripts/install.sh | bash
#
# Options non interactives : --dir <chemin> --port <n> --skip-login
# (les valeurs passées en option court-circuitent les questions)
#
# Pose les questions (destination des animés, port), génère les secrets,
# écrit .env, branche le montage Docker, démarre et attend le service.
# ---------------------------------------------------------------------------
set -euo pipefail
REGISTRY_HOST="git.lanro.eu"
REPO_URL="https://${REGISTRY_HOST}/Roman/ohm_streaming.git"
COMPOSE_FILE="docker-compose.yml"
BOLD="\033[1m"; DIM="\033[2m"; GREEN="\033[32m"; YELLOW="\033[33m"; RED="\033[31m"; RESET="\033[0m"
step() { printf "\n${BOLD}▸ %s${RESET}\n" "$1"; }
ok() { printf " ${GREEN}✔${RESET} %s\n" "$1"; }
warn() { printf " ${YELLOW}⚠${RESET} %s\n" "$1"; }
die() { printf " ${RED}✖ %s${RESET}\n" "$1" >&2; exit 1; }
ARG_DIR="" ARG_PORT="" ARG_SKIP_LOGIN=0
while [ $# -gt 0 ]; do
case "$1" in
--dir) ARG_DIR="$2"; shift 2;;
--port) ARG_PORT="$2"; shift 2;;
--skip-login) ARG_SKIP_LOGIN=1; shift;;
*) shift;;
esac
done
ask() { # ask <question> <défaut> — lit /dev/tty pour rester compatible curl|bash
local answer=""
if [ -r /dev/tty ]; then
read -r -p "$(printf ' %s ${DIM}[%s]${RESET} ' "$1" "$2")" answer </dev/tty 2>/dev/null || true
fi
echo "${answer:-$2}"
}
rand_hex() { # rand_hex <nb octets>
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex "$1"
else
head -c "$1" /dev/urandom | od -An -tx1 | tr -d ' \n'
fi
}
printf "${BOLD}⛩ Ohm Stream Downloader — installation${RESET}\n"
printf "${DIM}Télécharge et streame des animes — Docker + Watchtower${RESET}\n"
# ---------------------------------------------------------------- prérequis
step "Vérification des prérequis"
command -v docker >/dev/null 2>&1 || die "Docker n'est pas installé. → curl -fsSL https://get.docker.com | sh"
docker compose version >/dev/null 2>&1 || die "Le plugin Docker Compose manque (paquet docker-compose-plugin)."
ok "Docker + Compose présents"
# Depuis un clone ? sinon on clone (permet curl … | bash)
if [ ! -f "$COMPOSE_FILE" ]; then
INSTALL_DIR="$(ask "Dossier d'installation" "$HOME/ohm_streaming")"
if [ -e "$INSTALL_DIR" ]; then
[ -f "$INSTALL_DIR/$COMPOSE_FILE" ] || die "$INSTALL_DIR existe déjà (et n'est pas une installation Ohm)."
step "Mise à jour du clone existant"
git -C "$INSTALL_DIR" pull --ff-only || warn "git pull a échoué, on continue avec la version locale."
else
step "Récupération du projet"
command -v git >/dev/null 2>&1 || die "git est requis pour cloner $REPO_URL"
git clone "$REPO_URL" "$INSTALL_DIR"
fi
cd "$INSTALL_DIR"
fi
ok "Projet prêt dans $(pwd)"
# ---------------------------------------------------------------- registre privé
step "Accès au registre privé ($REGISTRY_HOST)"
if [ "$ARG_SKIP_LOGIN" = "1" ]; then
warn "--skip-login : connexion au registre ignorée"
elif docker login "$REGISTRY_HOST" 2>/dev/null; then
ok "Connecté au registre"
else
warn "Pas encore connecté — un compte Gitea avec accès lecture au dépôt est requis."
if [ "$(ask "Se connecter maintenant (docker login) ?" "o")" = "o" ]; then
docker login "$REGISTRY_HOST" || die "docker login a échoué — relance le script après t'être connecté."
ok "Connecté au registre"
else
die "Sans docker login, l'image ne pourra pas être tirée."
fi
fi
# ---------------------------------------------------------------- destination des épisodes
step "Destination des épisodes téléchargés"
if [ -n "$ARG_DIR" ]; then
HOST_DIR="$ARG_DIR"
else
printf " 1) ${BOLD}Dossier dédié « ohm »${RESET} (recommandé) : Ohm crée/utilise un dossier\n\
à part, à ajouter à ta bibliothèque Plex. Tes dossiers Sonarr/Plex\n\
existants ne sont JAMAIS modifiés.\n\
2) Dossier de ton choix (ex. un sous-dossier de ta biblio d'animes).\n\
S'il n'est pas vide, Ohm importera aussi les fichiers déjà présents.\n"
MODE="$(ask "Choix (1/2)" "1")"
if [ "$MODE" = "1" ]; then
HOST_DIR="$(ask "Chemin du dossier dédié" "/opt/ohm/animes")"
else
HOST_DIR="$(ask "Chemin du dossier de téléchargement" "/plex_videos/anime/ohm")"
fi
fi
if [ -e "$HOST_DIR" ] && [ -n "$(ls -A "$HOST_DIR" 2>/dev/null)" ]; then
warn "$HOST_DIR n'est pas vide : Ohm l'adoptera tel quel (droits existants conservés)."
warn "Le conteneur (uid 1000) doit pouvoir y écrire."
fi
if ! mkdir -p "$HOST_DIR" 2>/dev/null; then
printf ' sudo mkdir -p %q && sudo chown 1000:1000 %q\n' "$HOST_DIR" "$HOST_DIR" >&2
die "Impossible de créer $HOST_DIR — crée-le avec sudo puis relance."
fi
ok "Épisodes → $HOST_DIR (un dossier vide est pris en charge automatiquement)"
# ---------------------------------------------------------------- port + secrets
step "Configuration"
PORT="${ARG_PORT:-$(ask "Port HTTP exposé" "8777")}"
step "Écriture de .env"
if [ -f .env ]; then
cp .env ".env.backup.$(date +%s)"
warn ".env existant sauvegardé"
fi
SECRET_KEY="$(rand_hex 32)"
WT_TOKEN="$(rand_hex 24)"
cat > .env <<EOF
# Généré par scripts/install.sh le $(date '+%Y-%m-%d %H:%M')
OHM_SECRET_KEY=${SECRET_KEY}
WATCHTOWER_TOKEN=${WT_TOKEN}
OHM_PORT=${PORT}
EOF
chmod 600 .env
ok "Secrets générés (OHM_SECRET_KEY, WATCHTOWER_TOKEN)"
# ---------------------------------------------------------------- compose : montage téléchargements
step "Configuration du montage Docker"
cp "$COMPOSE_FILE" "$COMPOSE_FILE.dist"
if sed -i "s|^\([[:space:]]*- \)\./downloads:/downloads.*$|\1${HOST_DIR}:/downloads|" "$COMPOSE_FILE" \
&& grep -q -- "${HOST_DIR}:/downloads" "$COMPOSE_FILE"; then
ok "/downloads → $HOST_DIR (original conservé en $COMPOSE_FILE.dist)"
else
mv "$COMPOSE_FILE.dist" "$COMPOSE_FILE"
die "Montage introuvable dans $COMPOSE_FILE — édite la ligne ./downloads:/downloads à la main."
fi
# ---------------------------------------------------------------- démarrage
step "Démarrage (docker compose up -d)"
docker compose up -d || die "Démarrage échoué — logs : docker compose logs"
step "Attente du démarrage"
HEALTH_URL="http://127.0.0.1:${PORT}/health"
for i in $(seq 1 30); do
if curl -sf "$HEALTH_URL" >/dev/null 2>&1 || wget -q -O /dev/null "$HEALTH_URL" 2>/dev/null; then
break
fi
sleep 2
if [ "$i" = "30" ]; then docker compose logs --tail 30 ohm; die "Le service ne répond pas sur $HEALTH_URL"; fi
done
ok "Service en ligne"
HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
printf "\n${BOLD}${GREEN}⛩ Ohm Stream est prêt !${RESET}\n\n"
printf " URL : ${BOLD}http://%s:%s${RESET}\n" "${HOST_IP:-<ip-serveur>}" "$PORT"
printf " Épisodes : %s\n" "$HOST_DIR"
printf " 1er compte : ${BOLD}le compte créé en premier devient administrateur${RESET}\n\n"
printf " ${DIM}Plex : ajoute %s comme dossier d'une bibliothèque (ou comme dossier\n supplémentaire de ta bibliothèque animes) — les épisodes arrivent rangés par\n animé : <Animé>/<Animé> - E12 (VOSTFR).mp4${RESET}\n\n" "$HOST_DIR"
printf " ${DIM}Commandes : docker compose logs -f · stop · pull && up -d (mise à jour)${RESET}\n"
+17
View File
@@ -6,6 +6,7 @@ import pytest
from aiohttp import web
from app.config import get_settings
from app.db import db
from app.services.downloads import download_manager as dm
PAYLOAD = b"x" * 500_000
@@ -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