Initial commit: MKV → AVI web app (Docker + Keycloak OIDC)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 00:28:51 +02:00
commit fd2e0689bf
15 changed files with 1328 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
media/
output/
testmedia/
.env
.env.*
!.env.example
!.env.prod.example
__pycache__/
*.pyc
.git/
.DS_Store
+23
View File
@@ -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
+27
View File
@@ -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).
+16
View File
@@ -0,0 +1,16 @@
# Données locales
output/
media/
testmedia/
# Secrets / env
.env
.env.prod
.env.*.local
# Python
__pycache__/
*.pyc
# OS
.DS_Store
+22
View File
@@ -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", "*"]
+174
View File
@@ -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` `<IP_HOTE>` `8080` | SSL (Let's Encrypt) · Force SSL · Websockets |
| `mkv2avi.maisongrondin.fr` | `http` `<IP_HOTE>` `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
```
+443
View File
@@ -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")
+187
View File
@@ -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 = `<li class="empty">Rien trouvé.</li>`;
return;
}
for (const f of data.files) {
const li = document.createElement("li");
li.className = "file";
const dir = f.dir && f.dir !== "." ? `<span class="dir">${f.dir}/</span>` : "";
li.innerHTML = `
<div class="file-main">
<div class="file-name">${dir}${f.name}</div>
<div class="file-meta">${human(f.size)}</div>
</div>
<div class="file-actions">
<a class="btn ghost" href="/api/download?path=${encodeURIComponent(f.path)}">⬇ Télécharger</a>
<button class="btn primary conv">🎬 Convertir en AVI</button>
</div>`;
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(`<span class="tag hdr">HDR → tone-mapping</span>`);
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 = `<option value="0">(aucune piste audio détectée)</option>`;
}
} catch (e) {
info.innerHTML = `<span class="err">Impossible d'analyser : ${e.message}</span>`;
}
}
$("#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 = `<a class="btn primary" href="/api/result/${j.id}">⬇ Télécharger l'AVI</a>
<button class="btn ghost small del">✕</button>`;
} else if (j.status === "error") {
right = `<button class="btn ghost small del">✕</button>`;
} else {
right = `<button class="btn ghost small del">Annuler</button>`;
}
const statusTxt = {
queued: "En attente…", running: "Conversion…", done: `Terminé · ${human(j.size)}`,
error: "Échec", }[j.status] || j.status;
li.innerHTML = `
<div class="job-head">
<span class="job-name">${j.source}</span>
<span class="job-status">${statusTxt}${j.hdr ? " · HDR" : ""}</span>
</div>
<div class="bar"><div class="bar-fill" style="width:${j.progress || 0}%"></div></div>
<div class="job-actions">${right}</div>`;
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 = `<li class="empty">Aucune conversion pour l'instant.</li>`;
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();
+62
View File
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MKV → AVI</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<header>
<div class="header-inner">
<div>
<h1>🎬 MKV → AVI</h1>
<p class="sub">Convertir des films (4K/HDR) en AVI Xvid pour vieux lecteurs</p>
</div>
<div id="user" class="user hidden">
<span id="user-name" class="user-name"></span>
<a class="btn ghost small" href="/logout">Déconnexion</a>
</div>
</div>
</header>
<main>
<section class="panel">
<div class="search">
<input id="search" type="search" placeholder="Rechercher un fichier dans le dossier média…" autocomplete="off" />
<span id="count" class="count"></span>
</div>
<ul id="files" class="files"></ul>
</section>
<section class="panel">
<h2>Conversions</h2>
<ul id="jobs" class="jobs"><li class="empty">Aucune conversion pour l'instant.</li></ul>
</section>
</main>
<!-- Modale d'options de conversion -->
<div id="modal" class="modal hidden">
<div class="modal-box">
<h3 id="modal-title">Convertir en AVI</h3>
<div id="modal-info" class="info"></div>
<label>Définition
<select id="opt-res">
<option value="720">Compatibilité max — 720 px (vieux lecteurs)</option>
<option value="1280">720p — 1280 px</option>
<option value="original">Originale (pas de redimensionnement)</option>
</select>
</label>
<label>Piste audio
<select id="opt-audio"></select>
</label>
<div class="modal-actions">
<button id="modal-cancel" class="btn ghost">Annuler</button>
<button id="modal-start" class="btn primary">🎬 Lancer la conversion</button>
</div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>
+110
View File
@@ -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); }
+87
View File
@@ -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:
+90
View File
@@ -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://<IP_HOTE>:8080
# mkv2avi.maisongrondin.fr -> http://<IP_HOTE>: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:
+27
View File
@@ -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
+44
View File
@@ -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 }
]
}
]
}
+5
View File
@@ -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