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