// Hand-written mirror of arr-api's /api/policies and /api/roots schemas, // plus the settings view over them — same reasoning as search.ts: the // generated client (src/api/) is uncommitted, so CI's tsc cannot see it. import { navigate } from "./router"; import type { Root } from "./search"; /** One policy row: the JSON columns as the API returns them. */ export interface PolicyDoc { id: number; name: string; required_audio: { require: "original_language" } | { require: "any_of"; langs: string[] }; dub_blacklist: string[]; hdr_rules: { dv_profile_reject: string[] }; size_bands: Record< string, { floor_gib: number; target_gib: number; penalty_points_per_gib_over: number } >; resolution_pref: string[]; source_weights: Record; score_weights: { size_at_target: number; source_tier: number; seeder_doubling: number; resolution_step: number; }; } export interface RootInput { kind: string; audience: string; path: string; policy_id: number; } const RESOLUTIONS = ["2160p", "1080p", "720p"] as const; const SOURCES = ["Remux", "BluRay", "WEB-DL", "WEBRip", "HDTV"] as const; async function errorDetail(response: Response): Promise { try { const body = (await response.json()) as { error?: string }; return body.error ?? `http ${response.status}`; } catch { return `http ${response.status}`; } } async function fetchPolicies(): Promise { const response = await fetch("/api/policies"); if (!response.ok) { throw new Error(await errorDetail(response)); } return (await response.json()) as PolicyDoc[]; } async function fetchRoots(): Promise { const response = await fetch("/api/roots"); if (!response.ok) { throw new Error(await errorDetail(response)); } return (await response.json()) as Root[]; } type SaveOutcome = { ok: true; json: unknown } | { ok: false; detail: string }; async function sendJson(url: string, method: string, body: unknown): Promise { try { const response = await fetch(url, { method, headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); if (!response.ok) { return { ok: false, detail: await errorDetail(response) }; } return { ok: true, json: await response.json() }; } catch { return { ok: false, detail: "daemon unreachable" }; } } function savePolicy(id: number | null, policy: Omit): Promise { return id === null ? sendJson("/api/policies", "POST", policy) : sendJson(`/api/policies/${id}`, "PUT", policy); } function saveRoot(id: number | null, root: RootInput): Promise { return id === null ? sendJson("/api/roots", "POST", root) : sendJson(`/api/roots/${id}`, "PUT", root); } async function deleteRow(kind: "policies" | "roots", id: number): Promise { try { const response = await fetch(`/api/${kind}/${id}`, { method: "DELETE" }); if (!response.ok) { return await errorDetail(response); } return null; } catch { return "daemon unreachable"; } } /* ---- form primitives ---------------------------------------------------- */ function must(selector: string): T { const element = document.querySelector(selector); if (!element) { throw new Error(`settings markup is missing ${selector}`); } return element; } function el( tag: K, className: string, text?: string, ): HTMLElementTagNameMap[K] { const node = document.createElement(tag); node.className = className; if (text !== undefined) { node.textContent = text; } return node; } function chip(text: string): HTMLSpanElement { return el("span", "chip readout", text); } function rowTitle(title: string): HTMLElement { const wrap = el("span", "row-id"); wrap.append(el("span", "row-title", title)); return wrap; } function field(labelText: string, control: HTMLElement): HTMLDivElement { const wrap = el("div", "field"); wrap.append(el("span", "field-label readout dim", labelText), control); return wrap; } function textControl(value: string, placeholder = ""): HTMLInputElement { const input = document.createElement("input"); input.type = "text"; input.className = "form-input readout"; input.value = value; input.placeholder = placeholder; input.spellcheck = false; input.autocomplete = "off"; return input; } function numberControl(value: number): HTMLInputElement { const input = document.createElement("input"); input.type = "number"; input.className = "form-input readout form-num"; input.value = String(value); return input; } function selectControl(options: readonly string[], value: string): HTMLSelectElement { const select = document.createElement("select"); select.className = "form-input readout"; for (const option of options) { const item = document.createElement("option"); item.value = option; item.textContent = option; if (option === value) { item.selected = true; } select.append(item); } select.value = value; return select; } /** `a, b, c` ⇄ parts. Empty entries dropped; validation happens at save. */ function listValue(input: HTMLInputElement): string[] { return input.value .split(",") .map((part) => part.trim()) .filter((part) => part !== ""); } /** * First click arms the destructive action, second confirms. The armed state * clears on blur or after a few seconds, so an accidental double click * never deletes. */ function armedDelete(label: string, execute: () => void): HTMLButtonElement { const button = el("button", "control control-quiet readout", label); button.type = "button"; let armed = false; let resetTimer: number | undefined; const disarm = () => { armed = false; window.clearTimeout(resetTimer); delete button.dataset.armed; button.textContent = label; }; button.addEventListener("click", () => { if (armed) { disarm(); button.disabled = true; execute(); return; } armed = true; button.dataset.armed = "true"; button.textContent = `confirm ${label}`; resetTimer = window.setTimeout(disarm, 4000); }); button.addEventListener("blur", () => { if (armed) { disarm(); } }); return button; } interface SettingsView { hide: () => void; open: () => void; } export function settingsMain(board: HTMLElement, views: { hide: () => void }[]): SettingsView { const view = must("#settings"); const deck = must("#deck"); const nav = must("#nav-settings"); const summary = must("#settings-summary"); const status = must("#settings-status"); const rootsSection = must("#group-roots"); const rootsRows = must("#rows-roots"); const policiesSection = must("#group-policies"); const policiesRows = must("#rows-policies"); let roots: Root[] = []; let policies: PolicyDoc[] = []; // guards a stale fetch from painting over a newer view let sequence = 0; function setStatus(text: string | null, tone?: "fault") { status.hidden = text === null; status.textContent = text ?? ""; if (tone) { status.dataset.tone = tone; } else { delete status.dataset.tone; } } function clearGroups() { for (const section of [rootsSection, policiesSection]) { section.hidden = true; } rootsRows.replaceChildren(); policiesRows.replaceChildren(); summary.textContent = ""; } async function load() { sequence += 1; const ticket = sequence; setStatus("reading settings…"); try { const [fetchedRoots, fetchedPolicies] = await Promise.all([fetchRoots(), fetchPolicies()]); if (ticket !== sequence) { return; } roots = fetchedRoots; policies = fetchedPolicies; } catch (error) { if (ticket !== sequence) { return; } clearGroups(); setStatus(`settings unavailable — ${(error as Error).message}`, "fault"); return; } render(); } function render() { clearGroups(); setStatus(null); rootsSection.hidden = false; policiesSection.hidden = false; summary.textContent = `${roots.length} roots · ${policies.length} policies`; for (const root of roots) { rootsRows.append(rootRow(root)); } for (const policy of policies) { policiesRows.append(policyRow(policy)); } } /* -- roots -------------------------------------------------------------- */ /** A delete/edit action pair sharing the row's feedback line. */ function rowActions( item: HTMLLIElement, del: HTMLButtonElement, edit: HTMLButtonElement, ): HTMLParagraphElement { const actions = el("div", "settings-row-actions"); actions.append(edit, del); item.append(actions); const note = el("p", "add-note readout"); note.setAttribute("role", "status"); note.hidden = true; item.append(note); return note; } function rootRow(root: Root): HTMLLIElement { const item = el("li", "queue-item"); const row = el("div", "row"); const chips = el("span", "row-chips"); chips.append(chip(root.kind), chip(root.audience), chip(root.policy_name)); row.append(rowTitle(root.path), chips); item.append(row); const del = armedDelete("delete", () => { void deleteRow("roots", root.id).then((detail) => { if (detail !== null) { note.hidden = false; note.dataset.tone = "fault"; note.textContent = detail; return; } void load(); }); }); const edit = el("button", "control control-quiet readout", "edit"); edit.type = "button"; edit.setAttribute("aria-expanded", "false"); edit.addEventListener("click", () => { const existing = item.querySelector(".edit-panel"); if (existing) { existing.remove(); edit.setAttribute("aria-expanded", "false"); return; } item.append(rootPanel(root)); edit.setAttribute("aria-expanded", "true"); }); const note = rowActions(item, del, edit); return item; } function rootPanel(root: Root | null): HTMLElement { const panel = el("div", "edit-panel add-panel"); const grid = el("div", "form-grid"); const kind = selectControl(["movie", "tv"], root?.kind ?? "movie"); const audience = selectControl(["main", "kids"], root?.audience ?? "main"); const path = textControl(root?.path ?? "", "/mnt/media/…"); const policyId = selectControl( policies.map((policy) => String(policy.id)), String(root?.policy_id ?? policies[0]?.id ?? ""), ); // readable option labels without coupling value → name lookups for (const [index, policy] of policies.entries()) { const option = policyId.options[index]; if (option) { option.textContent = policy.name; } } grid.append( field("kind", kind), field("audience", audience), field("path", path), field("policy", policyId), ); const note = el("p", "add-note readout"); note.setAttribute("role", "status"); note.hidden = true; const save = el("button", "control readout", root === null ? "create root" : "save root"); save.type = "button"; save.addEventListener("click", () => { save.disabled = true; note.hidden = false; delete note.dataset.tone; note.textContent = "saving…"; const input: RootInput = { kind: kind.value, audience: audience.value, path: path.value.trim(), policy_id: Number(policyId.value), }; void saveRoot(root?.id ?? null, input).then((outcome) => { if (outcome.ok) { void load(); return; } save.disabled = false; note.hidden = false; note.dataset.tone = "fault"; note.textContent = outcome.detail; }); }); const cancel = el("button", "control control-quiet readout", "cancel"); cancel.type = "button"; cancel.addEventListener("click", () => { panel.remove(); }); const actions = el("div", "add-actions"); actions.append(save, cancel, note); panel.append(grid, actions); queueMicrotask(() => (root === null ? kind : path).focus()); return panel; } /* -- policies ------------------------------------------------------------ */ function requiredAudioLabel(policy: PolicyDoc): string { return policy.required_audio.require === "original_language" ? "original language" : `any of ${policy.required_audio.langs.join(", ")}`; } function policyRow(policy: PolicyDoc): HTMLLIElement { const item = el("li", "queue-item"); const row = el("div", "row"); const chips = el("span", "row-chips"); chips.append(chip(policy.resolution_pref.join(" › ") || "no resolutions")); chips.append(chip(requiredAudioLabel(policy))); const bandCount = Object.keys(policy.size_bands).length; chips.append(chip(`${bandCount} band${bandCount === 1 ? "" : "s"}`)); row.append(rowTitle(policy.name), chips); item.append(row); const del = armedDelete("delete", () => { void deleteRow("policies", policy.id).then((detail) => { if (detail !== null) { note.hidden = false; note.dataset.tone = "fault"; note.textContent = detail; return; } void load(); }); }); const edit = el("button", "control control-quiet readout", "edit"); edit.type = "button"; edit.setAttribute("aria-expanded", "false"); edit.addEventListener("click", () => { const existing = item.querySelector(".edit-panel"); if (existing) { existing.remove(); edit.setAttribute("aria-expanded", "false"); return; } item.append(policyPanel(policy)); edit.setAttribute("aria-expanded", "true"); }); const note = rowActions(item, del, edit); return item; } /** * Comma-list editor that checks its vocabulary while typing, so an unknown * resolution or profile is named before a round trip. */ function knownList( labelText: string, values: string[], known: readonly string[], invalid: (message: string | null) => void, ): HTMLDivElement { const input = textControl(values.join(", "), known.join(", ")); input.addEventListener("input", () => { const unknown = listValue(input).find((part) => !known.includes(part)); if (unknown !== undefined) { input.dataset.invalid = "true"; invalid(`${labelText}: unknown value '${unknown}'`); } else if (input.dataset.invalid !== undefined) { delete input.dataset.invalid; invalid(null); } }); return field(labelText, input); } function newPolicyDoc(): Omit { return { name: "", required_audio: { require: "original_language" }, dub_blacklist: ["pt-BR"], hdr_rules: { dv_profile_reject: ["5", "7"] }, size_bands: {}, resolution_pref: ["2160p", "1080p"], source_weights: {}, score_weights: { size_at_target: 1000, source_tier: 25, seeder_doubling: 8, resolution_step: 300, }, }; } function policyPanel(policy: PolicyDoc | null): HTMLElement { const current: Omit = policy ?? newPolicyDoc(); const panel = el("div", "edit-panel add-panel"); const grid = el("div", "form-grid"); const invalidate = (message: string | null) => { note.hidden = message === null || note.textContent === ""; if (message !== null) { note.dataset.tone = "fault"; note.textContent = message; } else if (note.dataset.tone === "fault") { note.hidden = true; } }; const note = el("p", "add-note readout"); note.setAttribute("role", "status"); note.hidden = true; const name = textControl(current.name, "policy name"); grid.append(field("name", name)); const audioMode = selectControl( ["original_language", "any_of"], current.required_audio.require, ); const audioLangs = textControl( current.required_audio.require === "any_of" ? current.required_audio.langs.join(", ") : "", "pt-PT, …", ); audioMode.addEventListener("change", () => { audioLangs.disabled = audioMode.value === "original_language"; }); audioLangs.disabled = audioMode.value === "original_language"; const blacklist = textControl(current.dub_blacklist.join(", "), "pt-BR"); grid.append(field("required audio", audioMode), field("required langs", audioLangs)); grid.append(field("dub blacklist", blacklist)); let profilesInput: HTMLInputElement; const profiles = knownList( "rejected dv profiles", current.hdr_rules.dv_profile_reject, ["5", "7", "8"], invalidate, ); profilesInput = profiles.querySelector("input") ?? document.createElement("input"); grid.append(profiles); // one line per resolution: floor, target, penalty — numbers stay numbers for (const resolution of RESOLUTIONS) { const band = current.size_bands[resolution]; const line = el("div", "band-line"); line.dataset.resolution = resolution; line.append(el("span", "field-label readout dim", resolution)); const floor = numberControl(band?.floor_gib ?? 0); const target = numberControl(band?.target_gib ?? 0); const penalty = numberControl(band?.penalty_points_per_gib_over ?? 0); floor.setAttribute("aria-label", `${resolution} floor GiB`); target.setAttribute("aria-label", `${resolution} target GiB`); penalty.setAttribute("aria-label", `${resolution} penalty points per GiB over`); for (const input of [floor, target, penalty]) { input.classList.add("band-num"); line.append(input); } grid.append(line); } let prefInput: HTMLInputElement; const pref = knownList( "resolution preference (first wins)", current.resolution_pref, RESOLUTIONS, invalidate, ); prefInput = pref.querySelector("input") ?? document.createElement("input"); grid.append(pref); const WEIGHT_KEYS = [ "size_at_target", "source_tier", "seeder_doubling", "resolution_step", ] as const; type SourceKey = (typeof SOURCES)[number]; type WeightKey = (typeof WEIGHT_KEYS)[number]; const sourceInputs = {} as Record; for (const source of SOURCES) { const weight = numberControl(current.source_weights[source] ?? 0); weight.setAttribute("aria-label", `${source} source weight`); grid.append(field(`source · ${source.toLowerCase()}`, weight)); sourceInputs[source] = weight; } const scoreInputs = {} as Record; for (const key of WEIGHT_KEYS) { const input = numberControl(current.score_weights[key]); input.setAttribute("aria-label", key.replaceAll("_", " ")); grid.append(field(key.replaceAll("_", " "), input)); scoreInputs[key] = input; } const save = el("button", "control readout", policy === null ? "create policy" : "save policy"); save.type = "button"; save.addEventListener("click", () => { const bad = panel.querySelector("[data-invalid]"); if (bad !== null) { note.hidden = false; note.dataset.tone = "fault"; note.textContent = "fix the highlighted fields first"; return; } const emptyWeight = [...Object.values(sourceInputs), ...Object.values(scoreInputs)].find( (input) => input.value === "", ); if (name.value.trim() === "" || emptyWeight !== undefined) { note.hidden = false; note.dataset.tone = "fault"; note.textContent = "name and every scoring weight are required"; return; } const sizeBands: PolicyDoc["size_bands"] = {}; for (const line of panel.querySelectorAll(".band-line")) { const [floor, target, penalty] = [...line.querySelectorAll("input")]; if (!floor || !target || !penalty) { continue; } sizeBands[line.dataset.resolution ?? ""] = { floor_gib: Number(floor.value), target_gib: Number(target.value), penalty_points_per_gib_over: Number(penalty.value), }; } save.disabled = true; note.hidden = false; delete note.dataset.tone; note.textContent = "saving…"; const payload: Omit = { name: name.value.trim(), required_audio: audioMode.value === "any_of" ? { require: "any_of", langs: listValue(audioLangs) } : { require: "original_language" }, dub_blacklist: listValue(blacklist), hdr_rules: { dv_profile_reject: listValue(profilesInput) }, size_bands: sizeBands, resolution_pref: listValue(prefInput), source_weights: Object.fromEntries( SOURCES.filter((source) => sourceInputs[source].value !== "").map((source) => [ source, Number(sourceInputs[source].value), ]), ), score_weights: Object.fromEntries( WEIGHT_KEYS.map((key) => [key, Number(scoreInputs[key].value)]), ) as PolicyDoc["score_weights"], }; void savePolicy(policy?.id ?? null, payload).then((outcome) => { if (outcome.ok) { void load(); return; } save.disabled = false; note.hidden = false; note.dataset.tone = "fault"; note.textContent = outcome.detail; }); }); const cancel = el("button", "control control-quiet readout", "cancel"); cancel.type = "button"; cancel.addEventListener("click", () => { panel.remove(); }); const actions = el("div", "add-actions"); actions.append(save, cancel, note); panel.append(grid, actions); queueMicrotask(() => name.focus()); return panel; } /* -- shell ---------------------------------------------------------------- */ function open() { for (const sibling of views) { sibling.hide(); } board.hidden = true; deck.hidden = true; view.hidden = false; nav.setAttribute("aria-pressed", "true"); void load(); } function hide() { view.hidden = true; nav.setAttribute("aria-pressed", "false"); sequence += 1; } function close() { hide(); board.hidden = false; nav.focus(); } nav.addEventListener("click", () => { if (view.hidden) { navigate({ kind: "settings" }); open(); } else { navigate({ kind: "board" }); close(); } }); must("#root-add").addEventListener("click", () => { rootsSection.append(rootPanel(null)); }); must("#policy-add").addEventListener("click", () => { policiesSection.append(policyPanel(null)); }); // capture, like the other decks: one Esc steps back one layer window.addEventListener( "keydown", (event) => { if (event.key === "Escape" && !view.hidden) { event.stopImmediatePropagation(); navigate({ kind: "board" }); close(); } }, true, ); return { hide, open }; }