Files
mkv2avi/app/main.py
T
Alexandre fd2e0689bf 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>
2026-07-04 00:28:51 +02:00

444 lines
15 KiB
Python

"""
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")