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")
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
Reference in New Issue
Block a user