Files
mkv2avi/app/static/app.js
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

188 lines
6.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();