Files
arr/web/src/settings.ts
T
Miguel Palhas 71c2741d08 feat(web): settings view for roots and policies
Own route beside library and queues. Roots edit kind, audience, path
and their policy; policies get a form over the real fields — size
bands as number inputs, score and source weights as numbers,
resolution preference and audio/HDR rules as validated lists. Delete
arms before firing; a 409 from a referenced policy or occupied root
surfaces its message.
2026-08-23 15:14:34 +01:00

760 lines
23 KiB
TypeScript
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.
// 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<string, number>;
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<string> {
try {
const body = (await response.json()) as { error?: string };
return body.error ?? `http ${response.status}`;
} catch {
return `http ${response.status}`;
}
}
async function fetchPolicies(): Promise<PolicyDoc[]> {
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<Root[]> {
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<SaveOutcome> {
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<PolicyDoc, "id">): Promise<SaveOutcome> {
return id === null
? sendJson("/api/policies", "POST", policy)
: sendJson(`/api/policies/${id}`, "PUT", policy);
}
function saveRoot(id: number | null, root: RootInput): Promise<SaveOutcome> {
return id === null
? sendJson("/api/roots", "POST", root)
: sendJson(`/api/roots/${id}`, "PUT", root);
}
async function deleteRow(kind: "policies" | "roots", id: number): Promise<string | null> {
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<T extends Element>(selector: string): T {
const element = document.querySelector<T>(selector);
if (!element) {
throw new Error(`settings markup is missing ${selector}`);
}
return element;
}
function el<K extends keyof HTMLElementTagNameMap>(
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<HTMLElement>("#settings");
const deck = must<HTMLElement>("#deck");
const nav = must<HTMLButtonElement>("#nav-settings");
const summary = must<HTMLElement>("#settings-summary");
const status = must<HTMLElement>("#settings-status");
const rootsSection = must<HTMLElement>("#group-roots");
const rootsRows = must<HTMLUListElement>("#rows-roots");
const policiesSection = must<HTMLElement>("#group-policies");
const policiesRows = must<HTMLUListElement>("#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<PolicyDoc, "id"> {
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<PolicyDoc, "id"> = 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<SourceKey, HTMLInputElement>;
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<WeightKey, HTMLInputElement>;
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<HTMLElement>("[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<HTMLDivElement>(".band-line")) {
const [floor, target, penalty] = [...line.querySelectorAll<HTMLInputElement>("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<PolicyDoc, "id"> = {
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<HTMLButtonElement>("#root-add").addEventListener("click", () => {
rootsSection.append(rootPanel(null));
});
must<HTMLButtonElement>("#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 };
}