Files
mediamark/ui/static/app.js
T
unkin-agent c129cb99fc
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Add the initial mediamark app
Single Go binary serving the API and an embedded keyboard-first UI for
promoting fafflix titles into the cheeztv kids tree via hardlinks.

- internal/library: hardlink sync, idempotent re-runs, drift reporting,
  strict single-path-element name validation as the traversal guard
- internal/arr: minimal sonarr/radarr v3 client with a 60s list cache and
  a key-brokered poster proxy
- internal/auth: server-side Authentik group enforcement on every route
- internal/server: library JSON API, art proxy, health probes, SPA
- ui: two-tile landing page, fuzzy-filtered title list, detail panel
- Makefile, Dockerfile, .woodpecker pipelines, pre-commit config
2026-08-29 21:27:09 +10:00

423 lines
15 KiB
JavaScript

/* Mediamark SPA — keyboard-first library marking. No build step, no CDNs. */
(function () {
"use strict";
var SECTIONS = {
movies: { label: "Movies", key: "m" },
tvseries: { label: "TV Series", key: "t" }
};
var state = {
view: "home",
section: null,
titles: [],
filtered: [],
selected: 0,
detail: null,
homeTile: 0
};
var el = {};
["view-home", "view-section", "view-detail", "search", "search-count", "section-title",
"title-list", "list-empty", "detail-title", "detail-poster", "detail-noposter",
"detail-facts", "detail-overview", "detail-size", "detail-files", "detail-marked",
"detail-toggle", "detail-sync", "detail-back", "toast", "keyhint-text", "home-tiles"
].forEach(function (id) { el[id] = document.getElementById(id); });
/* ---------- fzf-ish subsequence matcher ----------
Scores a subsequence match: contiguous runs and word-boundary hits score
higher, and an earlier first match wins ties. Returns null for no match. */
function fuzzy(needle, haystack) {
if (!needle) return { score: 0, positions: [] };
var n = needle.toLowerCase(), h = haystack.toLowerCase();
var positions = [], score = 0, run = 0, hi = 0;
for (var ni = 0; ni < n.length; ni++) {
var ch = n.charAt(ni);
if (ch === " ") { run = 0; continue; }
var found = -1;
for (var j = hi; j < h.length; j++) {
if (h.charAt(j) === ch) { found = j; break; }
}
if (found < 0) return null;
positions.push(found);
score += 1;
if (found === hi && ni > 0) { run++; score += 4 + run; } else { run = 0; }
var prev = found > 0 ? h.charAt(found - 1) : "";
if (found === 0) score += 8;
else if (prev === " " || prev === "-" || prev === "." || prev === "(" || prev === ":") score += 6;
hi = found + 1;
}
score -= positions[0] * 0.1;
score -= (h.length - n.length) * 0.01;
return { score: score, positions: positions };
}
function highlight(text, positions) {
if (!positions || !positions.length) return document.createTextNode(text);
var frag = document.createDocumentFragment(), at = 0;
for (var i = 0; i < positions.length; i++) {
var p = positions[i];
if (p < at) continue;
if (p > at) frag.appendChild(document.createTextNode(text.slice(at, p)));
var m = document.createElement("mark");
m.textContent = text.charAt(p);
frag.appendChild(m);
at = p + 1;
}
if (at < text.length) frag.appendChild(document.createTextNode(text.slice(at)));
return frag;
}
/* ---------- helpers ---------- */
function humanSize(bytes) {
if (!bytes) return "0 B";
var units = ["B", "KiB", "MiB", "GiB", "TiB"], i = 0, v = bytes;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return (i === 0 ? v : v.toFixed(v < 10 ? 2 : 1)) + " " + units[i];
}
var toastTimer = null;
function toast(msg, isErr) {
el.toast.textContent = msg;
el.toast.className = "toast" + (isErr ? " err" : "");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { el.toast.className = "toast hidden"; }, 3200);
}
function api(method, path) {
return fetch(path, { method: method, headers: { Accept: "application/json" } })
.then(function (res) {
return res.json().catch(function () { return {}; }).then(function (body) {
if (!res.ok) throw new Error(body.error || ("request failed (" + res.status + ")"));
return body;
});
});
}
function displayName(t) { return (t.arr && t.arr.title) || t.name; }
/* ---------- rendering ---------- */
function show(view) {
state.view = view;
el["view-home"].classList.toggle("hidden", view !== "home");
el["view-section"].classList.toggle("hidden", view !== "section");
el["view-detail"].classList.toggle("hidden", view !== "detail");
el["keyhint-text"].textContent =
view === "home" ? "m movies · t tv series · ←/→ select · enter open"
: view === "section" ? "type to filter · ↑/↓ select · enter details · m toggle cheeztv · esc back"
: "m toggle cheeztv · esc back";
}
function applyFilter() {
var q = el.search.value.trim();
var scored = [];
for (var i = 0; i < state.titles.length; i++) {
var t = state.titles[i];
var r = fuzzy(q, displayName(t));
if (!r && q) r = fuzzy(q, t.name);
if (r) scored.push({ t: t, score: r.score, positions: q ? r.positions : [] });
}
if (q) scored.sort(function (a, b) { return b.score - a.score; });
state.filtered = scored;
if (state.selected >= scored.length) state.selected = Math.max(0, scored.length - 1);
renderList();
}
function renderList() {
el["title-list"].textContent = "";
el["list-empty"].classList.toggle("hidden", state.filtered.length > 0);
el["search-count"].textContent = state.filtered.length + " / " + state.titles.length;
state.filtered.forEach(function (row, idx) {
var t = row.t;
var a = document.createElement("a");
a.className = "title-row" + (idx === state.selected ? " selected" : "") + (t.marked ? " marked" : "");
a.href = "#/" + state.section + "/" + encodeURIComponent(t.name);
a.setAttribute("role", "option");
a.dataset.index = String(idx);
var art = document.createElement("div");
art.className = "title-art";
if (t.artUrl) {
var img = document.createElement("img");
img.src = t.artUrl;
img.alt = "";
img.loading = "lazy";
img.className = "poster";
art.appendChild(img);
} else {
art.appendChild(document.createTextNode("?"));
art.classList.add("poster-placeholder");
}
a.appendChild(art);
var main = document.createElement("div");
main.className = "title-main";
var name = document.createElement("div");
name.className = "title-name";
name.appendChild(highlight(displayName(t), row.positions));
main.appendChild(name);
var sub = document.createElement("div");
sub.className = "title-sub muted";
var bits = [];
if (t.arr && t.arr.year) bits.push(t.arr.year);
if (t.arr && t.arr.status) bits.push(t.arr.status);
bits.push(humanSize(t.sizeBytes));
sub.textContent = bits.join(" · ");
main.appendChild(sub);
a.appendChild(main);
var badges = document.createElement("div");
badges.className = "title-badges";
if (t.marked) badges.appendChild(badge("cheeztv", "badge-marked"));
if (t.needsSync) badges.appendChild(badge("needs sync", "badge-sync"));
a.appendChild(badges);
el["title-list"].appendChild(a);
});
}
function badge(text, cls) {
var s = document.createElement("span");
s.className = "badge " + cls;
s.textContent = text;
return s;
}
// Arrowing moves focus out of the search box and onto the list, so the
// single-letter shortcuts (m) are unambiguous; typing any other character
// hands focus straight back to the search box.
function moveSelection(delta) {
if (!state.filtered.length) return;
state.selected = Math.min(state.filtered.length - 1, Math.max(0, state.selected + delta));
renderList();
el["title-list"].focus({ preventScroll: true });
var sel = el["title-list"].querySelector(".title-row.selected");
if (sel && sel.scrollIntoView) sel.scrollIntoView({ block: "nearest" });
}
function currentTitle() {
if (state.view === "detail") return state.detail;
var row = state.filtered[state.selected];
return row ? row.t : null;
}
/* ---------- data ---------- */
function loadSection(section) {
state.section = section;
el["section-title"].textContent = SECTIONS[section].label;
document.title = "Mediamark — " + SECTIONS[section].label;
el["title-list"].textContent = "";
return api("GET", "/api/library/" + section).then(function (body) {
state.titles = body.titles || [];
state.selected = 0;
applyFilter();
}).catch(function (e) {
toast(e.message, true);
state.titles = [];
applyFilter();
});
}
function mergeTitle(updated) {
for (var i = 0; i < state.titles.length; i++) {
if (state.titles[i].name === updated.name) {
["marked", "needsSync", "sizeBytes", "fileCount", "unlinkedFiles"].forEach(function (k) {
state.titles[i][k] = updated[k];
});
if (state.detail && state.detail.name === updated.name) state.detail = state.titles[i];
return state.titles[i];
}
}
return null;
}
function toggleMark(t) {
if (!t) return;
var wantMark = !t.marked;
var prev = { marked: t.marked, needsSync: t.needsSync };
t.marked = wantMark; // optimistic
t.needsSync = false;
renderList();
if (state.view === "detail") renderDetail();
api("POST", "/api/library/" + state.section + "/" + encodeURIComponent(t.name) + "/" + (wantMark ? "mark" : "unmark"))
.then(function (body) {
mergeTitle(body.title);
var s = body.sync || {};
toast(wantMark
? "Marked " + displayName(t) + " — " + (s.linked || 0) + " linked, " + (s.unchanged || 0) + " already there"
: "Removed " + displayName(t) + " from cheeztv");
renderList();
if (state.view === "detail") renderDetail();
})
.catch(function (e) {
t.marked = prev.marked;
t.needsSync = prev.needsSync;
renderList();
if (state.view === "detail") renderDetail();
toast(e.message, true);
});
}
function syncTitle(t) {
api("POST", "/api/library/" + state.section + "/" + encodeURIComponent(t.name) + "/mark")
.then(function (body) {
mergeTitle(body.title);
var s = body.sync || {};
toast("Synced " + displayName(t) + " — " + (s.linked || 0) + " new, " + (s.replaced || 0) + " replaced");
renderList();
renderDetail();
})
.catch(function (e) { toast(e.message, true); });
}
/* ---------- detail ---------- */
function renderDetail() {
var t = state.detail;
if (!t) return;
el["detail-title"].textContent = displayName(t);
el["detail-back"].href = "#/" + state.section;
var facts = [];
if (t.arr && t.arr.year) facts.push(t.arr.year);
if (t.arr && t.arr.status) facts.push(t.arr.status);
facts.push(SECTIONS[state.section].label);
el["detail-facts"].textContent = facts.join(" · ");
el["detail-overview"].textContent = (t.arr && t.arr.overview) || "No overview available for this title.";
el["detail-size"].textContent = humanSize(t.sizeBytes);
el["detail-files"].textContent = String(t.fileCount);
el["detail-marked"].textContent = t.marked
? (t.needsSync ? "marked (" + t.unlinkedFiles + " files unsynced)" : "marked")
: "not marked";
if (t.artUrl) {
el["detail-poster"].src = t.artUrl;
el["detail-poster"].classList.remove("hidden");
el["detail-noposter"].classList.add("hidden");
} else {
el["detail-poster"].classList.add("hidden");
el["detail-noposter"].classList.remove("hidden");
}
el["detail-toggle"].textContent = t.marked ? "Remove from cheeztv" : "Add to cheeztv";
el["detail-toggle"].className = "btn " + (t.marked ? "btn-danger btn-lg" : "btn-primary btn-lg");
el["detail-sync"].classList.toggle("hidden", !t.needsSync);
}
function openDetail(section, name) {
var go = state.section === section && state.titles.length
? Promise.resolve()
: loadSection(section);
return go.then(function () {
var found = null;
for (var i = 0; i < state.titles.length; i++) {
if (state.titles[i].name === name) { found = state.titles[i]; break; }
}
if (!found) { toast("Title not found", true); location.hash = "#/" + section; return; }
state.detail = found;
show("detail");
renderDetail();
});
}
/* ---------- routing ---------- */
function route() {
var parts = (location.hash || "#/").replace(/^#\/?/, "").split("/");
var section = parts[0];
if (!SECTIONS[section]) {
show("home");
document.title = "Mediamark";
focusTile();
return;
}
if (parts.length > 1 && parts[1]) {
openDetail(section, decodeURIComponent(parts[1]));
return;
}
show("section");
var reload = state.section === section && state.titles.length
? Promise.resolve(applyFilter())
: loadSection(section);
reload.then(function () { el.search.focus(); el.search.select(); });
}
function tiles() { return Array.prototype.slice.call(el["home-tiles"].querySelectorAll(".app-tile")); }
function focusTile() {
var ts = tiles();
ts.forEach(function (t, i) { t.classList.toggle("selected", i === state.homeTile); });
}
/* ---------- keyboard ---------- */
document.addEventListener("keydown", function (ev) {
if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
var typingInSearch = ev.target === el.search;
if (state.view === "home") {
var ts = tiles();
if (ev.key === "m") { location.hash = "#/movies"; ev.preventDefault(); return; }
if (ev.key === "t") { location.hash = "#/tvseries"; ev.preventDefault(); return; }
if (ev.key === "ArrowRight" || ev.key === "ArrowDown") {
state.homeTile = Math.min(ts.length - 1, state.homeTile + 1); focusTile(); ev.preventDefault(); return;
}
if (ev.key === "ArrowLeft" || ev.key === "ArrowUp") {
state.homeTile = Math.max(0, state.homeTile - 1); focusTile(); ev.preventDefault(); return;
}
if (ev.key === "Enter") { ts[state.homeTile].click(); ev.preventDefault(); }
return;
}
if (ev.key === "Escape") {
if (state.view === "detail") { location.hash = "#/" + state.section; }
else if (typingInSearch && el.search.value) { el.search.value = ""; applyFilter(); }
else { location.hash = "#/"; }
ev.preventDefault();
return;
}
if (state.view === "detail") {
if (ev.key === "m") { toggleMark(state.detail); ev.preventDefault(); }
return;
}
// Section view.
if (ev.key === "ArrowDown") { moveSelection(1); ev.preventDefault(); return; }
if (ev.key === "ArrowUp") { moveSelection(-1); ev.preventDefault(); return; }
if (ev.key === "Enter") {
var t = currentTitle();
if (t) location.hash = "#/" + state.section + "/" + encodeURIComponent(t.name);
ev.preventDefault();
return;
}
if (ev.key === "m" && !typingInSearch) { toggleMark(currentTitle()); ev.preventDefault(); return; }
if (!typingInSearch && ev.key.length === 1) {
el.search.focus();
el.search.value += ev.key;
state.selected = 0;
applyFilter();
ev.preventDefault();
}
});
el.search.addEventListener("input", function () { state.selected = 0; applyFilter(); });
el["title-list"].addEventListener("click", function (ev) {
var row = ev.target.closest ? ev.target.closest(".title-row") : null;
if (row) state.selected = Number(row.dataset.index);
});
el["home-tiles"].addEventListener("mouseover", function (ev) {
var tile = ev.target.closest ? ev.target.closest(".app-tile") : null;
if (!tile) return;
state.homeTile = tiles().indexOf(tile);
focusTile();
});
el["detail-toggle"].addEventListener("click", function () { toggleMark(state.detail); });
el["detail-sync"].addEventListener("click", function () { syncTitle(state.detail); });
window.addEventListener("hashchange", route);
route();
})();