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:
+443
@@ -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")
|
||||
Reference in New Issue
Block a user