c129cb99fc
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
423 lines
15 KiB
JavaScript
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();
|
|
})();
|