From fd2e0689bfce8930b57bed0d4d401b90f8142f04 Mon Sep 17 00:00:00 2001 From: grondinalexandre Date: Sat, 4 Jul 2026 00:28:51 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20MKV=20=E2=86=92=20AVI=20web?= =?UTF-8?q?=20app=20(Docker=20+=20Keycloak=20OIDC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App FastAPI : recherche dans un dossier média, download original ou conversion en AVI Xvid (tone-mapping HDR→SDR, audio FR auto, MP3 stéréo) - Auth Keycloak OIDC (Authlib) optionnelle - 3 composes : app seule, stack local (keycloak.local), production (NPM) Co-Authored-By: Claude Opus 4.8 --- .dockerignore | 11 + .env.example | 23 ++ .env.prod.example | 27 +++ .gitignore | 16 ++ Dockerfile | 22 ++ README.md | 174 ++++++++++++++ app/main.py | 443 ++++++++++++++++++++++++++++++++++++ app/static/app.js | 187 +++++++++++++++ app/static/index.html | 62 +++++ app/static/style.css | 110 +++++++++ docker-compose.keycloak.yml | 87 +++++++ docker-compose.prod.yml | 90 ++++++++ docker-compose.yml | 27 +++ keycloak/realm-mkv2avi.json | 44 ++++ requirements.txt | 5 + 15 files changed, 1328 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .env.prod.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/main.py create mode 100644 app/static/app.js create mode 100644 app/static/index.html create mode 100644 app/static/style.css create mode 100644 docker-compose.keycloak.yml create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 keycloak/realm-mkv2avi.json create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a8a0627 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +media/ +output/ +testmedia/ +.env +.env.* +!.env.example +!.env.prod.example +__pycache__/ +*.pyc +.git/ +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7bc420f --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Copier en .env et adapter : cp .env.example .env + +# Dossiers +MEDIA_DIR=/Volumes/data/films # dossier des films (monté en lecture seule) +OUTPUT_DIR=./output # où atterrissent les AVI convertis + +# --- Authentification Keycloak (OIDC) --- +# Laisser OIDC_ISSUER vide pour DÉSACTIVER l'auth (dev local). +# Issuer = URL du realm (sans slash final) +OIDC_ISSUER=https://keycloak.mondomaine.fr/realms/mon-realm +OIDC_CLIENT_ID=mkv2avi +OIDC_CLIENT_SECRET=colle-le-secret-du-client-ici + +# Optionnel : forcer l'URL de callback (sinon déduite de la requête) +# Doit être déclarée dans "Valid redirect URIs" du client Keycloak. +OIDC_REDIRECT_URI=https://mkv2avi.mondomaine.fr/auth/callback +# Optionnel : où revenir après déconnexion (à déclarer dans "Valid post logout redirect URIs") +OIDC_POST_LOGOUT_REDIRECT_URI=https://mkv2avi.mondomaine.fr/ + +# Secret de signature des cookies (générer : openssl rand -hex 32) +SESSION_SECRET=change-moi-avec-openssl-rand-hex-32 +# Mettre true si l'app est servie en HTTPS (derrière Nginx Proxy Manager en TLS) +SESSION_HTTPS_ONLY=true diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..453ad6c --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,27 @@ +# Config PRODUCTION — copier en .env.prod puis remplir, JAMAIS committer .env.prod +# cp .env.prod.example .env.prod +# docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build + +# --- Dossiers --- +MEDIA_DIR=/mnt/pool-r440/films # dataset des films (monté en lecture seule) +OUTPUT_DIR=/mnt/pool-r440/mkv2avi-out # dataset de sortie des AVI + +# --- Ports publiés (ce que NPM va cibler sur l'IP de l'hôte) --- +KC_HTTP_PORT=8080 +APP_HTTP_PORT=8090 + +# --- Secrets à générer (openssl rand -hex 32) --- +OIDC_CLIENT_SECRET=REMPLACER_par_le_secret_du_client_keycloak +SESSION_SECRET=REMPLACER_par_openssl_rand_hex_32 + +# --- Comptes Keycloak / base --- +KC_ADMIN=admin +KC_ADMIN_PASSWORD=REMPLACER_mot_de_passe_admin_fort +KC_DB_PASSWORD=REMPLACER_mot_de_passe_postgres_fort + +# --- Conversion --- +MAX_WORKERS=1 + +# ⚠️ IMPORTANT : le secret du client dans keycloak/realm-mkv2avi.json ("secret") +# doit être identique à OIDC_CLIENT_SECRET ci-dessus. Change les deux ensemble +# (ou régénère le secret dans la console Keycloak après le 1er démarrage). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90937bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Données locales +output/ +media/ +testmedia/ + +# Secrets / env +.env +.env.prod +.env.*.local + +# Python +__pycache__/ +*.pyc + +# OS +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4d58cea --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# ffmpeg/ffprobe statiques complets (incluent zscale/zimg pour le tone-mapping HDR) +FROM mwader/static-ffmpeg:7.1 AS ffmpeg + +FROM python:3.12-slim +COPY --from=ffmpeg /ffmpeg /usr/local/bin/ffmpeg +COPY --from=ffmpeg /ffprobe /usr/local/bin/ffprobe + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app/ /app/ + +ENV MEDIA_DIR=/media \ + OUTPUT_DIR=/output \ + MAX_WORKERS=1 + +EXPOSE 8000 +VOLUME ["/media", "/output"] + +# --proxy-headers : respecte X-Forwarded-Proto/Host derrière Nginx Proxy Manager +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", \ + "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..324d531 --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ +# 🎬 MKV → AVI + +Petite application web (Docker) pour parcourir un dossier média, **télécharger** un +fichier d'origine ou le **convertir en AVI Xvid** lisible par les vieux lecteurs DivX/DVD. + +La conversion reprend le pipeline qui a fait ses preuves : + +- vidéo **Xvid** (`mpeg4 -vtag xvid`, qualité `qscale 4`) ; +- audio **MP3 stéréo** (downmix 5.1 → 2.0) ; +- sélection auto de la **piste française** en évitant l'audiodescription ; +- **tone-mapping HDR → SDR** propre via `zscale` (les sources 4K HDR10/DV/HLG + sont détectées automatiquement) ; +- redimensionnement au choix (720 px « max compatibilité », 1280 px, ou original). + +> ℹ️ Le décodage est **logiciel** : dans un conteneur, l'accélération matérielle +> (VideoToolbox sur Mac, QSV/NVENC) n'est pas disponible. Comptez plusieurs minutes +> à quelques dizaines de minutes par film selon le CPU. + +## Démarrage rapide + +```bash +# Dossier contenant vos films (monté en lecture seule) +export MEDIA_DIR=/chemin/vers/mes/films +# Où écrire les AVI convertis (optionnel, défaut ./output) +export OUTPUT_DIR=$PWD/output + +docker compose up -d --build +``` + +Puis ouvrir **http://localhost:8080**. + +- Rechercher un fichier dans le champ de recherche. +- **⬇ Télécharger** : récupère le fichier d'origine. +- **🎬 Convertir en AVI** : choisir définition + piste audio → suivre la progression → + **⬇ Télécharger l'AVI** une fois terminé. + +Les fichiers convertis restent aussi dans le dossier `OUTPUT_DIR`. + +## Authentification Keycloak (OIDC) + +L'app peut exiger une connexion Keycloak (flow **Authorization Code**, session par +cookie). Elle est **désactivée tant que `OIDC_ISSUER` est vide** — pratique en dev local. +Une fois configurée, **tout utilisateur authentifié** du realm a accès ; login/logout +et nom de l'utilisateur sont gérés dans l'app. + +### 1. Créer le client dans Keycloak +- **Clients → Create client** : type `OpenID Connect`, Client ID `mkv2avi`. +- **Client authentication : On** (client confidentiel) → un *secret* est généré + (onglet **Credentials**). +- **Valid redirect URIs** : `https://mkv2avi.mondomaine.fr/auth/callback` +- **Valid post logout redirect URIs** : `https://mkv2avi.mondomaine.fr/` +- **Web origins** : `+` (ou l'URL exacte). + +### 2. Renseigner les variables +```bash +cp .env.example .env +# puis éditer .env : +# OIDC_ISSUER=https://keycloak.mondomaine.fr/realms/mon-realm +# OIDC_CLIENT_ID=mkv2avi +# OIDC_CLIENT_SECRET=...(onglet Credentials) +# SESSION_SECRET=$(openssl rand -hex 32) +# SESSION_HTTPS_ONLY=true # si servi en HTTPS via Nginx Proxy Manager +docker compose up -d --build +``` + +> Derrière **Nginx Proxy Manager** (TLS) : uvicorn tourne avec `--proxy-headers`, donc +> l'URL de callback est déduite en `https`. En cas de souci, forcez-la avec +> `OIDC_REDIRECT_URI`. Activez `SESSION_HTTPS_ONLY=true` pour des cookies `Secure`. + +### Stack tout-en-un (Keycloak inclus) + +Pour tester/déployer **Keycloak + PostgreSQL + l'app** d'un coup, un compose dédié +avec un realm pré-importé (client `mkv2avi` + utilisateur de test) est fourni : + +```bash +# 1. Rendre Keycloak résolvable sous le même nom côté navigateur ET côté conteneur +echo "127.0.0.1 keycloak.local" | sudo tee -a /etc/hosts + +# 2. (optionnel) dossier de films à convertir +export MEDIA_DIR=/Volumes/data/films + +# 3. Démarrer le stack +docker compose -f docker-compose.keycloak.yml up -d --build +``` + +| Service | URL | Identifiants | +|---------|-----|--------------| +| App MKV → AVI | http://localhost:8090 | `alex` / `alex` | +| Console Keycloak | http://localhost:8080 | `admin` / `admin` | + +Le realm `mkv2avi` (`keycloak/realm-mkv2avi.json`) contient déjà le client confidentiel +(`secret : mkv2avi-secret-change-me`) et un utilisateur. **Change les secrets/mots de +passe avant tout usage réel** (variables `OIDC_CLIENT_SECRET`, `SESSION_SECRET`, +`KC_ADMIN_PASSWORD`, `KC_DB_PASSWORD`, et le `secret` dans le realm JSON). + +> **Pourquoi `keycloak.local` ?** L'URL de Keycloak (l'*issuer*) doit être **identique** +> depuis le navigateur et depuis le conteneur de l'app. L'alias réseau Docker +> `keycloak.local` + la ligne `/etc/hosts` garantissent cette cohérence. En production +> derrière Nginx Proxy Manager, on remplace tout ça par le vrai domaine +> (`KC_HOSTNAME` + `OIDC_ISSUER` = `https://auth.mondomaine.fr/realms/mkv2avi`). + +## Production derrière Nginx Proxy Manager + +Fichier `docker-compose.prod.yml` : Keycloak en **mode production** (`start`), TLS +terminé par NPM. Domaines : app = `https://mkv2avi.maisongrondin.fr`, +Keycloak = `https://auth.mkv2avi.maisongrondin.fr`. + +### 1. Secrets +```bash +cp .env.prod.example .env.prod +# générer : openssl rand -hex 32 (pour OIDC_CLIENT_SECRET et SESSION_SECRET) +# OIDC_CLIENT_SECRET doit être identique au "secret" du client dans +# keycloak/realm-mkv2avi.json (ou régénéré ensuite dans la console Keycloak). +``` + +### 2. Démarrage +```bash +docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build +``` +Les services publient sur l'hôte : Keycloak `:8080`, app `:8090`. + +### 3. Deux Proxy Hosts dans NPM +| Domaine | Forward vers | Options | +|---------|--------------|---------| +| `auth.mkv2avi.maisongrondin.fr` | `http` `` `8080` | SSL (Let's Encrypt) · Force SSL · Websockets | +| `mkv2avi.maisongrondin.fr` | `http` `` `8090` | SSL · Force SSL · Websockets | + +Pour l'app, ajoute cette **Advanced / Custom Nginx Configuration** (téléchargements de +gros fichiers sans coupure — même logique que ton fix Nextcloud) : +```nginx +proxy_buffering off; +proxy_request_buffering off; +client_max_body_size 0; +proxy_read_timeout 3600s; +proxy_send_timeout 3600s; +``` + +### 4. Points de vigilance +- **DNS interne** : le conteneur `mkv2avi` doit résoudre `auth.mkv2avi.maisongrondin.fr` + (il valide les tokens via cette URL). Si ton réseau ne fait pas de *hairpin NAT*, + décommente le bloc `extra_hosts` du compose en pointant vers l'IP de NPM/hôte. +- **Keycloak** sert en HTTP interne (`KC_HTTP_ENABLED=true`) et fait confiance aux + en-têtes `X-Forwarded-*` de NPM (`KC_PROXY_HEADERS=xforwarded`) ; l'issuer reste + figé sur l'URL publique (`KC_HOSTNAME`). +- Change **tous** les mots de passe/secrets par défaut avant exposition. + +## Déploiement sur TrueNAS SCALE + +Utilisable comme *Custom App* (Docker) : montez le dataset des films sur `/media` +(en lecture seule) et un dataset de sortie sur `/output`, exposez le port `8000`. + +## Variables d'environnement + +| Variable | Défaut | Rôle | +|---------------|-----------|----------------------------------------------| +| `MEDIA_DIR` | `/media` | Dossier parcouru (monté en lecture seule) | +| `OUTPUT_DIR` | `/output` | Dossier des AVI convertis | +| `MAX_WORKERS` | `1` | Conversions simultanées (1 = en série) | +| `MAX_RESULTS` | `400` | Nb max de fichiers listés par recherche | +| `OIDC_ISSUER` | *(vide)* | URL du realm Keycloak (vide = auth off) | +| `OIDC_CLIENT_ID` | *(vide)* | Client ID Keycloak | +| `OIDC_CLIENT_SECRET` | *(vide)* | Secret du client confidentiel | +| `OIDC_REDIRECT_URI` | *(auto)* | Force l'URL de callback si besoin | +| `SESSION_SECRET` | *(à changer)* | Clé de signature des cookies de session | +| `SESSION_HTTPS_ONLY` | `false` | Cookies `Secure` (mettre `true` en HTTPS) | + +## Développement local (sans Docker) + +```bash +pip install -r requirements.txt +# ffmpeg avec le filtre zscale requis pour le HDR (sinon HDR non géré) +MEDIA_DIR=/chemin/films OUTPUT_DIR=./output \ + uvicorn main:app --app-dir app --reload --port 8000 +``` diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..f73d9f3 --- /dev/null +++ b/app/main.py @@ -0,0 +1,443 @@ +""" +MKV -> AVI Web UI +Petit service FastAPI : parcourir/rechercher un dossier média monté, +télécharger un fichier d'origine ou le convertir en AVI (Xvid + MP3) +avec tone-mapping HDR->SDR pour les vieux lecteurs. +""" +import json +import os +import re +import shutil +import subprocess +import threading +import time +import uuid +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from urllib.parse import quote + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel +from starlette.middleware.sessions import SessionMiddleware + +# --------------------------------------------------------------------------- # +# Configuration (via variables d'environnement) +# --------------------------------------------------------------------------- # +MEDIA_DIR = Path(os.environ.get("MEDIA_DIR", "/media")).resolve() +OUTPUT_DIR = Path(os.environ.get("OUTPUT_DIR", "/output")).resolve() +FFMPEG = os.environ.get("FFMPEG", shutil.which("ffmpeg") or "ffmpeg") +FFPROBE = os.environ.get("FFPROBE", shutil.which("ffprobe") or "ffprobe") +MAX_WORKERS = int(os.environ.get("MAX_WORKERS", "1")) +MAX_RESULTS = int(os.environ.get("MAX_RESULTS", "400")) + +# --- Authentification OIDC / Keycloak (activée seulement si configurée) --- +OIDC_ISSUER = os.environ.get("OIDC_ISSUER", "").rstrip("/") +OIDC_CLIENT_ID = os.environ.get("OIDC_CLIENT_ID", "") +OIDC_CLIENT_SECRET = os.environ.get("OIDC_CLIENT_SECRET", "") +OIDC_REDIRECT_URI = os.environ.get("OIDC_REDIRECT_URI", "") +OIDC_POST_LOGOUT = os.environ.get("OIDC_POST_LOGOUT_REDIRECT_URI", "") +SESSION_SECRET = os.environ.get("SESSION_SECRET", "dev-insecure-change-me") +SESSION_HTTPS_ONLY = os.environ.get("SESSION_HTTPS_ONLY", "false").lower() in ("1", "true", "yes") +OIDC_ENABLED = bool(OIDC_ISSUER and OIDC_CLIENT_ID) + +VIDEO_EXTS = {".mkv", ".mp4", ".m4v", ".avi", ".mov", ".ts", ".m2ts", ".wmv", ".webm", ".mpg", ".mpeg", ".flv"} +HDR_TRANSFERS = {"smpte2084", "arib-std-b67"} # PQ (HDR10/DV) et HLG + +OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + +app = FastAPI(title="MKV → AVI") + +_executor = ThreadPoolExecutor(max_workers=MAX_WORKERS) +_jobs: dict[str, dict] = {} +_jobs_lock = threading.Lock() +STATIC_DIR = Path(__file__).parent / "static" + + +# --------------------------------------------------------------------------- # +# Authentification OIDC (Keycloak) — optionnelle +# --------------------------------------------------------------------------- # +oauth = None +if OIDC_ENABLED: + from authlib.integrations.starlette_client import OAuth + + oauth = OAuth() + oauth.register( + name="keycloak", + server_metadata_url=f"{OIDC_ISSUER}/.well-known/openid-configuration", + client_id=OIDC_CLIENT_ID, + client_secret=OIDC_CLIENT_SECRET, + client_kwargs={"scope": "openid profile email"}, + ) + +AUTH_PUBLIC = {"/login", "/auth/callback", "/logout", "/health", "/favicon.ico"} + + +@app.middleware("http") +async def _auth_guard(request: Request, call_next): + """Exige une session valide, sauf routes publiques et assets statiques.""" + if not OIDC_ENABLED: + return await call_next(request) + path = request.url.path + if path in AUTH_PUBLIC or path.startswith("/static/"): + return await call_next(request) + if request.session.get("user"): + return await call_next(request) + if path.startswith("/api/"): + return JSONResponse({"detail": "Non authentifié."}, status_code=401) + return RedirectResponse(url="/login") + + +# SessionMiddleware ajouté APRÈS le garde => il l'enveloppe (session dispo dans le garde) +app.add_middleware( + SessionMiddleware, + secret_key=SESSION_SECRET, + same_site="lax", + https_only=SESSION_HTTPS_ONLY, +) + + +@app.get("/health") +def health(): + return {"ok": True, "auth": OIDC_ENABLED} + + +@app.get("/api/me") +def me(request: Request): + if not OIDC_ENABLED: + return {"auth": False} + return {"auth": True, "user": request.session.get("user")} + + +@app.get("/login") +async def login(request: Request): + if not OIDC_ENABLED: + return RedirectResponse("/") + redirect_uri = OIDC_REDIRECT_URI or str(request.url_for("auth_callback")) + return await oauth.keycloak.authorize_redirect(request, redirect_uri) + + +@app.get("/auth/callback", name="auth_callback") +async def auth_callback(request: Request): + if not OIDC_ENABLED: + return RedirectResponse("/") + try: + token = await oauth.keycloak.authorize_access_token(request) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=401, detail=f"Échec de l'authentification : {exc}") + info = token.get("userinfo") or {} + request.session["user"] = { + "name": info.get("name") or info.get("preferred_username") or info.get("email") or "Utilisateur", + "email": info.get("email"), + } + request.session["id_token"] = token.get("id_token") + return RedirectResponse("/") + + +@app.get("/logout") +async def logout(request: Request): + id_token = request.session.get("id_token") + request.session.clear() + if OIDC_ENABLED: + meta = await oauth.keycloak.load_server_metadata() + end = meta.get("end_session_endpoint") + if end: + post = OIDC_POST_LOGOUT or str(request.base_url) + url = f"{end}?post_logout_redirect_uri={quote(post, safe='')}" + if id_token: + url += f"&id_token_hint={id_token}" + return RedirectResponse(url) + return RedirectResponse("/") + + +# --------------------------------------------------------------------------- # +# Utilitaires +# --------------------------------------------------------------------------- # +def _safe(base: Path, rel: str) -> Path: + """Résout un chemin relatif en interdisant de sortir de `base`.""" + p = (base / rel).resolve() + if p != base and base not in p.parents: + raise HTTPException(status_code=400, detail="Chemin en dehors du dossier autorisé.") + return p + + +def _sanitize(name: str) -> str: + name = re.sub(r'[/\\:*?"<>|]+', "_", name).strip() + return name or "sortie" + + +def probe(path: Path) -> dict: + """Renvoie durée, HDR ?, dimensions et pistes audio d'un fichier.""" + cmd = [FFPROBE, "-v", "error", "-print_format", "json", + "-show_format", "-show_streams", str(path)] + out = subprocess.run(cmd, capture_output=True, text=True) + if out.returncode != 0: + raise HTTPException(status_code=422, detail=f"Lecture impossible : {out.stderr.strip()[:300]}") + data = json.loads(out.stdout or "{}") + duration = float(data.get("format", {}).get("duration", 0) or 0) + + hdr = False + width = height = None + audio = [] + a_index = 0 + for st in data.get("streams", []): + if st.get("codec_type") == "video" and width is None: + width = st.get("width") + height = st.get("height") + if st.get("color_transfer") in HDR_TRANSFERS: + hdr = True + elif st.get("codec_type") == "audio": + tags = st.get("tags", {}) or {} + audio.append({ + "a_index": a_index, + "lang": tags.get("language", "und"), + "title": tags.get("title", ""), + "codec": st.get("codec_name", ""), + "channels": st.get("channels"), + }) + a_index += 1 + + return {"duration": duration, "hdr": hdr, "width": width, "height": height, "audio": audio} + + +def _looks_like_ad(title: str) -> bool: + t = title.lower() + return "audiodescription" in t or "audio description" in t or re.search(r"\bad\b", t) is not None or "descript" in t + + +def default_audio(audio: list[dict]) -> int: + """Choisit la 1re piste FR non-audiodescription, sinon 1re FR, sinon 1re piste.""" + fr = [a for a in audio if a["lang"] in ("fre", "fra", "fr")] + for a in fr: + if not _looks_like_ad(a["title"]): + return a["a_index"] + if fr: + return fr[0]["a_index"] + return audio[0]["a_index"] if audio else 0 + + +def build_vf(hdr: bool, resolution: str) -> str: + """Chaîne de filtres : redimensionnement + (tone-mapping HDR->SDR si besoin).""" + parts: list[str] = [] + if resolution == "720": + parts.append("scale=720:-2") + elif resolution == "1280": + parts.append("scale=1280:-2") + # "original" : pas de scale + + if hdr: + parts += [ + "zscale=t=linear:npl=100", + "format=gbrpf32le", + "zscale=p=bt709", + "tonemap=tonemap=hable:desat=0", + "zscale=t=bt709:m=bt709:r=tv", + "format=yuv420p", + ] + else: + parts.append("format=yuv420p") + return ",".join(parts) + + +# --------------------------------------------------------------------------- # +# Conversion (exécutée dans un thread worker) +# --------------------------------------------------------------------------- # +def _set(job_id: str, **kw): + with _jobs_lock: + if job_id in _jobs: + _jobs[job_id].update(kw) + + +def run_conversion(job_id: str, src: Path, a_index: int, resolution: str, hdr: bool, duration: float): + out_name = _sanitize(src.stem) + ".avi" + out_path = OUTPUT_DIR / out_name + # évite d'écraser une conversion précédente + i = 1 + while out_path.exists(): + out_path = OUTPUT_DIR / f"{_sanitize(src.stem)} ({i}).avi" + i += 1 + + vf = build_vf(hdr, resolution) + log_path = OUTPUT_DIR / f".{job_id}.log" + + cmd = [ + FFMPEG, "-y", "-hide_banner", "-nostats", + "-i", str(src), + "-map", "0:v:0", "-map", f"0:a:{a_index}", + "-vf", vf, + "-c:v", "mpeg4", "-vtag", "xvid", "-qscale:v", "4", + "-c:a", "libmp3lame", "-q:a", "4", "-ac", "2", + "-metadata", f"title={src.stem}", + "-progress", "pipe:1", + str(out_path), + ] + + _set(job_id, status="running", output_name=out_path.name, started=time.time()) + try: + with open(log_path, "w") as logf: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=logf, text=True) + with _jobs_lock: + _jobs[job_id]["_proc"] = proc + for line in proc.stdout: + line = line.strip() + if line.startswith("out_time_us=") and duration > 0: + val = line.split("=", 1)[1] + if val.isdigit(): + pct = min(99.0, (int(val) / 1_000_000) / duration * 100) + _set(job_id, progress=round(pct, 1)) + elif line == "progress=end": + _set(job_id, progress=100.0) + proc.wait() + + if proc.returncode == 0 and out_path.exists(): + _set(job_id, status="done", progress=100.0, + size=out_path.stat().st_size, output_path=str(out_path), finished=time.time()) + else: + tail = "" + try: + tail = log_path.read_text()[-500:] + except OSError: + pass + if out_path.exists(): + out_path.unlink(missing_ok=True) + _set(job_id, status="error", error=tail or f"ffmpeg code {proc.returncode}") + except Exception as exc: # noqa: BLE001 + _set(job_id, status="error", error=str(exc)) + finally: + with _jobs_lock: + _jobs.get(job_id, {}).pop("_proc", None) + log_path.unlink(missing_ok=True) + + +# --------------------------------------------------------------------------- # +# API +# --------------------------------------------------------------------------- # +class ConvertBody(BaseModel): + path: str + resolution: str = "720" # "720" | "1280" | "original" + audio: int | None = None # index de piste audio (a:N) + + +@app.get("/api/browse") +def browse(q: str = ""): + q = q.lower().strip() + results = [] + for root, _dirs, files in os.walk(MEDIA_DIR): + for fn in files: + if Path(fn).suffix.lower() not in VIDEO_EXTS: + continue + full = Path(root) / fn + rel = str(full.relative_to(MEDIA_DIR)) + if q and q not in rel.lower(): + continue + try: + stt = full.stat() + except OSError: + continue + results.append({"path": rel, "name": fn, "dir": str(Path(rel).parent), + "size": stt.st_size, "mtime": stt.st_mtime}) + if len(results) >= MAX_RESULTS: + break + if len(results) >= MAX_RESULTS: + break + results.sort(key=lambda r: r["name"].lower()) + return {"count": len(results), "truncated": len(results) >= MAX_RESULTS, "files": results} + + +@app.get("/api/probe") +def api_probe(path: str): + src = _safe(MEDIA_DIR, path) + if not src.is_file(): + raise HTTPException(status_code=404, detail="Fichier introuvable.") + info = probe(src) + info["default_audio"] = default_audio(info["audio"]) + return info + + +@app.get("/api/download") +def download(path: str): + src = _safe(MEDIA_DIR, path) + if not src.is_file(): + raise HTTPException(status_code=404, detail="Fichier introuvable.") + return FileResponse(src, filename=src.name, media_type="application/octet-stream") + + +@app.post("/api/convert") +def convert(body: ConvertBody): + src = _safe(MEDIA_DIR, body.path) + if not src.is_file(): + raise HTTPException(status_code=404, detail="Fichier introuvable.") + if body.resolution not in ("720", "1280", "original"): + raise HTTPException(status_code=400, detail="Résolution invalide.") + + info = probe(src) + a_index = body.audio if body.audio is not None else default_audio(info["audio"]) + + job_id = uuid.uuid4().hex[:12] + with _jobs_lock: + _jobs[job_id] = { + "id": job_id, "source": src.name, "status": "queued", + "progress": 0.0, "resolution": body.resolution, "hdr": info["hdr"], + "created": time.time(), + } + _executor.submit(run_conversion, job_id, src, a_index, body.resolution, info["hdr"], info["duration"]) + return {"job_id": job_id} + + +def _public_job(j: dict) -> dict: + return {k: v for k, v in j.items() if not k.startswith("_")} + + +@app.get("/api/jobs") +def jobs(): + with _jobs_lock: + items = [_public_job(j) for j in _jobs.values()] + items.sort(key=lambda j: j.get("created", 0), reverse=True) + return {"jobs": items} + + +@app.get("/api/jobs/{job_id}") +def job(job_id: str): + with _jobs_lock: + j = _jobs.get(job_id) + if not j: + raise HTTPException(status_code=404, detail="Job inconnu.") + return _public_job(j) + + +@app.delete("/api/jobs/{job_id}") +def delete_job(job_id: str): + with _jobs_lock: + j = _jobs.get(job_id) + if not j: + raise HTTPException(status_code=404, detail="Job inconnu.") + proc = j.get("_proc") + if proc and j.get("status") == "running": + proc.terminate() + outp = j.get("output_path") + _jobs.pop(job_id, None) + if outp and Path(outp).exists() and j.get("status") != "done": + Path(outp).unlink(missing_ok=True) + return {"ok": True} + + +@app.get("/api/result/{job_id}") +def result(job_id: str): + with _jobs_lock: + j = _jobs.get(job_id) + if not j or j.get("status") != "done" or not j.get("output_path"): + raise HTTPException(status_code=404, detail="Résultat indisponible.") + p = Path(j["output_path"]) + if not p.is_file(): + raise HTTPException(status_code=404, detail="Fichier de sortie introuvable.") + return FileResponse(p, filename=p.name, media_type="video/x-msvideo") + + +@app.get("/") +def index(): + return FileResponse(STATIC_DIR / "index.html") + + +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..c9b383e --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,187 @@ +const $ = (s) => document.querySelector(s); +const filesEl = $("#files"); +const jobsEl = $("#jobs"); +const countEl = $("#count"); +const searchEl = $("#search"); +const modal = $("#modal"); + +let currentFile = null; // fichier sélectionné pour conversion +let searchTimer = null; + +// ---------- Helpers ---------- +function human(bytes) { + if (!bytes && bytes !== 0) return ""; + const u = ["o", "Ko", "Mo", "Go", "To"]; + let i = 0, n = bytes; + while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } + return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`; +} +const langName = { fre: "FR", fra: "FR", fr: "FR", eng: "EN", en: "EN", und: "?" }; + +// ---------- Recherche / listing ---------- +// Redirige vers la connexion si la session a expiré +function checkAuth(r) { + if (r.status === 401) { window.location = "/login"; return false; } + return true; +} + +async function loadFiles(q = "") { + const r = await fetch(`/api/browse?q=${encodeURIComponent(q)}`); + if (!checkAuth(r)) return; + const data = await r.json(); + countEl.textContent = data.count + ? `${data.count} fichier${data.count > 1 ? "s" : ""}${data.truncated ? "+" : ""}` + : "aucun résultat"; + filesEl.innerHTML = ""; + if (!data.files.length) { + filesEl.innerHTML = `
  • Rien trouvé.
  • `; + return; + } + for (const f of data.files) { + const li = document.createElement("li"); + li.className = "file"; + const dir = f.dir && f.dir !== "." ? `${f.dir}/` : ""; + li.innerHTML = ` +
    +
    ${dir}${f.name}
    +
    ${human(f.size)}
    +
    +
    + ⬇ Télécharger + +
    `; + li.querySelector(".conv").addEventListener("click", () => openModal(f)); + filesEl.appendChild(li); + } +} + +searchEl.addEventListener("input", () => { + clearTimeout(searchTimer); + searchTimer = setTimeout(() => loadFiles(searchEl.value), 250); +}); + +// ---------- Modale d'options ---------- +async function openModal(file) { + currentFile = file; + $("#modal-title").textContent = file.name; + const info = $("#modal-info"); + const audioSel = $("#opt-audio"); + info.textContent = "Analyse du fichier…"; + audioSel.innerHTML = ""; + modal.classList.remove("hidden"); + + try { + const r = await fetch(`/api/probe?path=${encodeURIComponent(file.path)}`); + if (!r.ok) throw new Error((await r.json()).detail || "erreur"); + const p = await r.json(); + const badges = []; + if (p.width) badges.push(`${p.width}×${p.height}`); + if (p.hdr) badges.push(`HDR → tone-mapping`); + badges.push(`${Math.round(p.duration / 60)} min`); + info.innerHTML = badges.join(" · "); + + p.audio.forEach((a) => { + const opt = document.createElement("option"); + const lang = langName[a.lang] || a.lang.toUpperCase(); + const ch = a.channels ? `${a.channels}ch` : ""; + opt.value = a.a_index; + opt.textContent = `#${a.a_index} · ${lang} · ${a.codec} ${ch}${a.title ? " — " + a.title : ""}`; + if (a.a_index === p.default_audio) opt.selected = true; + audioSel.appendChild(opt); + }); + if (!p.audio.length) { + audioSel.innerHTML = ``; + } + } catch (e) { + info.innerHTML = `Impossible d'analyser : ${e.message}`; + } +} + +$("#modal-cancel").addEventListener("click", () => modal.classList.add("hidden")); +modal.addEventListener("click", (e) => { if (e.target === modal) modal.classList.add("hidden"); }); + +$("#modal-start").addEventListener("click", async () => { + if (!currentFile) return; + const body = { + path: currentFile.path, + resolution: $("#opt-res").value, + audio: parseInt($("#opt-audio").value || "0", 10), + }; + modal.classList.add("hidden"); + const r = await fetch("/api/convert", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (r.ok) { await refreshJobs(); } + else { alert("Erreur : " + ((await r.json()).detail || r.status)); } +}); + +// ---------- Jobs / progression ---------- +function jobRow(j) { + const li = document.createElement("li"); + li.className = `job ${j.status}`; + let right = ""; + if (j.status === "done") { + right = `⬇ Télécharger l'AVI + `; + } else if (j.status === "error") { + right = ``; + } else { + right = ``; + } + const statusTxt = { + queued: "En attente…", running: "Conversion…", done: `Terminé · ${human(j.size)}`, + error: "Échec", }[j.status] || j.status; + li.innerHTML = ` +
    + ${j.source} + ${statusTxt}${j.hdr ? " · HDR" : ""} +
    +
    +
    ${right}
    `; + if (j.status === "error") { + const d = document.createElement("div"); + d.className = "job-err"; + d.textContent = (j.error || "").split("\n").filter(Boolean).slice(-1)[0] || "Erreur inconnue"; + li.appendChild(d); + } + const del = li.querySelector(".del"); + if (del) del.addEventListener("click", async () => { + await fetch(`/api/jobs/${j.id}`, { method: "DELETE" }); + refreshJobs(); + }); + return li; +} + +async function refreshJobs() { + const r = await fetch("/api/jobs"); + if (!checkAuth(r)) return; + const { jobs } = await r.json(); + jobsEl.innerHTML = ""; + if (!jobs.length) { + jobsEl.innerHTML = `
  • Aucune conversion pour l'instant.
  • `; + return; + } + jobs.forEach((j) => jobsEl.appendChild(jobRow(j))); +} + +// Poll régulier des jobs +setInterval(refreshJobs, 1500); + +// ---------- Utilisateur connecté ---------- +async function loadUser() { + try { + const r = await fetch("/api/me"); + const d = await r.json(); + if (d.auth && d.user) { + $("#user-name").textContent = `👤 ${d.user.name}`; + $("#user").classList.remove("hidden"); + } + } catch { /* ignore */ } +} + +// Init +loadUser(); +loadFiles(); +refreshJobs(); diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..e4cb115 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,62 @@ + + + + + + MKV → AVI + + + +
    +
    +
    +

    🎬 MKV → AVI

    +

    Convertir des films (4K/HDR) en AVI Xvid pour vieux lecteurs

    +
    + +
    +
    + +
    +
    + +
      +
      + +
      +

      Conversions

      +
      • Aucune conversion pour l'instant.
      +
      +
      + + + + + + + diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..91304fd --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,110 @@ +:root { + --bg: #0f1216; + --panel: #171b21; + --panel-2: #1e242c; + --border: #2a323c; + --text: #e6e9ee; + --muted: #8a94a2; + --accent: #e6483d; + --accent-2: #3d7de6; + --ok: #38b26a; + --err: #e2544a; +} +* { box-sizing: border-box; } +body { + margin: 0; background: var(--bg); color: var(--text); + font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} +header { + padding: 22px 28px; border-bottom: 1px solid var(--border); + background: linear-gradient(180deg, #191d24, #12151a); +} +header h1 { margin: 0; font-size: 22px; } +header .sub { margin: 4px 0 0; color: var(--muted); font-size: 13px; } +.header-inner { + max-width: 960px; margin: 0 auto; display: flex; + align-items: center; justify-content: space-between; gap: 16px; +} +.user { display: flex; align-items: center; gap: 10px; } +.user.hidden { display: none; } +.user-name { color: var(--text); font-size: 13px; white-space: nowrap; } +main { + max-width: 960px; margin: 0 auto; padding: 24px 20px; + display: grid; gap: 22px; +} +.panel { + background: var(--panel); border: 1px solid var(--border); + border-radius: 12px; padding: 18px; +} +.panel h2 { margin: 0 0 12px; font-size: 15px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; } + +.search { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } +.search input { + flex: 1; padding: 12px 14px; border-radius: 9px; + border: 1px solid var(--border); background: var(--panel-2); + color: var(--text); font-size: 15px; outline: none; +} +.search input:focus { border-color: var(--accent-2); } +.count { color: var(--muted); font-size: 13px; white-space: nowrap; } + +.files, .jobs { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.empty { color: var(--muted); padding: 14px; text-align: center; } + +.file { + display: flex; align-items: center; justify-content: space-between; gap: 14px; + padding: 12px 14px; background: var(--panel-2); + border: 1px solid var(--border); border-radius: 9px; +} +.file-main { min-width: 0; } +.file-name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.file-name .dir { color: var(--muted); font-weight: 400; } +.file-meta { color: var(--muted); font-size: 12px; margin-top: 2px; } +.file-actions { display: flex; gap: 8px; flex-shrink: 0; } + +.btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 8px 13px; border-radius: 8px; border: 1px solid var(--border); + background: var(--panel); color: var(--text); font-size: 13px; + cursor: pointer; text-decoration: none; white-space: nowrap; +} +.btn:hover { border-color: #3a4552; } +.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn.primary:hover { filter: brightness(1.08); } +.btn.ghost { background: transparent; } +.btn.small { padding: 5px 9px; font-size: 12px; } + +/* Jobs */ +.job { padding: 12px 14px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 9px; } +.job-head { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 8px; } +.job-name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.job-status { color: var(--muted); font-size: 13px; white-space: nowrap; } +.job.done .job-status { color: var(--ok); } +.job.error .job-status { color: var(--err); } +.bar { height: 8px; background: #0d1116; border-radius: 6px; overflow: hidden; } +.bar-fill { height: 100%; background: linear-gradient(90deg, var(--accent-2), var(--ok)); transition: width .4s ease; } +.job.done .bar-fill { background: var(--ok); } +.job.error .bar-fill { background: var(--err); } +.job-actions { margin-top: 10px; display: flex; gap: 8px; } +.job-err { margin-top: 8px; color: var(--err); font-size: 12px; font-family: ui-monospace, monospace; word-break: break-word; } + +/* Modale */ +.modal { + position: fixed; inset: 0; background: rgba(0,0,0,.6); + display: flex; align-items: center; justify-content: center; padding: 20px; z-index: 50; +} +.modal.hidden { display: none; } +.modal-box { + width: 100%; max-width: 460px; background: var(--panel); + border: 1px solid var(--border); border-radius: 14px; padding: 22px; +} +.modal-box h3 { margin: 0 0 6px; font-size: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.modal .info { color: var(--muted); font-size: 13px; margin-bottom: 16px; } +.modal label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 14px; } +.modal select { + width: 100%; margin-top: 6px; padding: 10px; border-radius: 8px; + background: var(--panel-2); border: 1px solid var(--border); color: var(--text); font-size: 14px; +} +.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; } +.tag { padding: 2px 8px; border-radius: 20px; font-size: 11px; } +.tag.hdr { background: rgba(61,125,230,.18); color: #7ea6f0; } +.err { color: var(--err); } diff --git a/docker-compose.keycloak.yml b/docker-compose.keycloak.yml new file mode 100644 index 0000000..e2c6cef --- /dev/null +++ b/docker-compose.keycloak.yml @@ -0,0 +1,87 @@ +# Stack tout-en-un : PostgreSQL + Keycloak (realm pré-importé) + l'app MKV→AVI. +# +# docker compose -f docker-compose.keycloak.yml up -d --build +# +# ⚠️ Ajouter d'abord cette ligne à /etc/hosts (pour que le navigateur ET le +# conteneur voient Keycloak sous le même nom) : +# 127.0.0.1 keycloak.local +# +# Accès : app -> http://localhost:8090 (login: alex / alex) +# admin Keycloak -> http://localhost:8080 (admin / admin) + +name: mkv2avi-stack + +services: + db: + image: postgres:16-alpine + container_name: mkv2avi-db + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: ${KC_DB_PASSWORD:-keycloak} + volumes: + - kc-db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"] + interval: 5s + timeout: 5s + retries: 12 + restart: unless-stopped + + keycloak: + image: quay.io/keycloak/keycloak:26.0 + container_name: mkv2avi-keycloak + command: ["start-dev", "--import-realm"] + environment: + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://db:5432/keycloak + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: ${KC_DB_PASSWORD:-keycloak} + KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN:-admin} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:-admin} + # URL fixe : identique côté navigateur (via /etc/hosts) et côté app (via alias réseau) + KC_HOSTNAME: http://keycloak.local:8080 + KC_HTTP_ENABLED: "true" + KC_HEALTH_ENABLED: "true" + volumes: + - ./keycloak/realm-mkv2avi.json:/opt/keycloak/data/import/realm-mkv2avi.json:ro + ports: + - "8080:8080" + networks: + default: + aliases: + - keycloak.local + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '200 OK'"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 40s + restart: unless-stopped + + mkv2avi: + build: . + image: mkv2avi:latest + container_name: mkv2avi + ports: + - "8090:8000" + volumes: + - ${MEDIA_DIR:-./media}:/media:ro + - ${OUTPUT_DIR:-./output}:/output + environment: + - MAX_WORKERS=1 + - OIDC_ISSUER=http://keycloak.local:8080/realms/mkv2avi + - OIDC_CLIENT_ID=mkv2avi + - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-mkv2avi-secret-change-me} + - SESSION_SECRET=${SESSION_SECRET:-dev-insecure-change-me} + - SESSION_HTTPS_ONLY=false + depends_on: + keycloak: + condition: service_healthy + restart: unless-stopped + +volumes: + kc-db: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..fc1c1b1 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,90 @@ +# Variante PRODUCTION : PostgreSQL + Keycloak (mode production) + app, +# derrière Nginx Proxy Manager (TLS terminé par NPM). +# +# cp .env.prod.example .env.prod # puis renseigner les secrets +# docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build +# +# NPM (2 Proxy Hosts, forward en http vers l'IP de l'hôte) : +# auth.mkv2avi.maisongrondin.fr -> http://:8080 +# mkv2avi.maisongrondin.fr -> http://:8090 +# → SSL Let's Encrypt + Force SSL + Websockets Support activés sur les deux. + +name: mkv2avi + +services: + db: + image: postgres:16-alpine + container_name: mkv2avi-db + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: ${KC_DB_PASSWORD:?KC_DB_PASSWORD manquant} + volumes: + - kc-db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"] + interval: 5s + timeout: 5s + retries: 12 + restart: unless-stopped + + keycloak: + image: quay.io/keycloak/keycloak:26.0 + container_name: mkv2avi-keycloak + command: ["start", "--import-realm"] + environment: + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://db:5432/keycloak + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: ${KC_DB_PASSWORD:?KC_DB_PASSWORD manquant} + KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN:-admin} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:?KC_ADMIN_PASSWORD manquant} + # URL publique (issuer) — NPM termine le TLS, Keycloak sert en HTTP en interne + KC_HOSTNAME: https://auth.mkv2avi.maisongrondin.fr + KC_HTTP_ENABLED: "true" + KC_PROXY_HEADERS: xforwarded + KC_HEALTH_ENABLED: "true" + volumes: + - ./keycloak/realm-mkv2avi.json:/opt/keycloak/data/import/realm-mkv2avi.json:ro + ports: + - "${KC_HTTP_PORT:-8080}:8080" + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '200 OK'"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 60s + restart: unless-stopped + + mkv2avi: + build: . + image: mkv2avi:latest + container_name: mkv2avi + environment: + - MAX_WORKERS=${MAX_WORKERS:-1} + - OIDC_ISSUER=https://auth.mkv2avi.maisongrondin.fr/realms/mkv2avi + - OIDC_CLIENT_ID=mkv2avi + - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:?OIDC_CLIENT_SECRET manquant} + - OIDC_REDIRECT_URI=https://mkv2avi.maisongrondin.fr/auth/callback + - OIDC_POST_LOGOUT_REDIRECT_URI=https://mkv2avi.maisongrondin.fr/ + - SESSION_SECRET=${SESSION_SECRET:?SESSION_SECRET manquant} + - SESSION_HTTPS_ONLY=true + volumes: + - ${MEDIA_DIR:?MEDIA_DIR manquant}:/media:ro + - ${OUTPUT_DIR:-./output}:/output + ports: + - "${APP_HTTP_PORT:-8090}:8000" + depends_on: + keycloak: + condition: service_healthy + # Repli si le conteneur ne résout pas le domaine public (hairpin NAT) : + # décommente en remplaçant par l'IP de NPM/hôte. + # extra_hosts: + # - "auth.mkv2avi.maisongrondin.fr:192.168.1.200" + restart: unless-stopped + +volumes: + kc-db: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ca196c2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +services: + mkv2avi: + build: . + image: mkv2avi:latest + container_name: mkv2avi + ports: + - "8080:8000" + volumes: + # Dossier média à parcourir (monté en lecture seule = originaux protégés) + - ${MEDIA_DIR:-./media}:/media:ro + # Dossier de sortie des AVI convertis + - ${OUTPUT_DIR:-./output}:/output + environment: + # Nombre de conversions simultanées (1 = en série, recommandé sans GPU) + - MAX_WORKERS=1 + # --- Authentification Keycloak (laisser vide = auth désactivée) --- + - OIDC_ISSUER=${OIDC_ISSUER:-} + - OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-} + - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-} + # URL de retour ; laisser vide pour la déduire automatiquement + - OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-} + - OIDC_POST_LOGOUT_REDIRECT_URI=${OIDC_POST_LOGOUT_REDIRECT_URI:-} + # Secret de signature des cookies de session (À CHANGER) + - SESSION_SECRET=${SESSION_SECRET:-dev-insecure-change-me} + # true si servi en HTTPS (derrière NPM en TLS) + - SESSION_HTTPS_ONLY=${SESSION_HTTPS_ONLY:-false} + restart: unless-stopped diff --git a/keycloak/realm-mkv2avi.json b/keycloak/realm-mkv2avi.json new file mode 100644 index 0000000..bb93fce --- /dev/null +++ b/keycloak/realm-mkv2avi.json @@ -0,0 +1,44 @@ +{ + "realm": "mkv2avi", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "loginTheme": "keycloak", + "clients": [ + { + "clientId": "mkv2avi", + "name": "MKV → AVI", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "secret": "mkv2avi-secret-change-me", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "redirectUris": [ + "http://localhost:8090/*", + "https://mkv2avi.maisongrondin.fr/*" + ], + "webOrigins": ["+"], + "attributes": { + "post.logout.redirect.uris": "http://localhost:8090/*##https://mkv2avi.maisongrondin.fr/*" + }, + "defaultClientScopes": ["web-origins", "profile", "roles", "email"], + "fullScopeAllowed": true + } + ], + "users": [ + { + "username": "alex", + "enabled": true, + "emailVerified": true, + "email": "alex@example.com", + "firstName": "Alex", + "lastName": "Grondin", + "credentials": [ + { "type": "password", "value": "alex", "temporary": false } + ] + } + ] +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..071d4e8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +Authlib==1.3.2 +httpx==0.28.1 +itsdangerous==2.2.0