Files
arr/web/src/settings.ts
T
Miguel Palhas a45d8ff86a style(arr): give the OpenAI endpoint its own row
Five fields wrapped the model onto a line of its own and clipped the base
URL placeholder. Paired under one label, the way the translator budgets
already are, and the base URL gets the width it needs.
2026-08-25 08:33:47 +01:00

1021 lines
33 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;
/** DESIGN.md §15: OpenSubtitles and Podnapisi, no cargo feature gate. */
const PROVIDERS = ["opensubtitles", "podnapisi"] as const;
/** The runtime-editable half of subtitle config (§15) — issue #198's shape. */
export interface SubtitleSettings {
wanted_languages: string[];
providers_enabled: string[];
translation_engine: string | null;
provider_daily_budgets: Record<string, number>;
translator_daily_budgets: Record<string, number>;
remote_command_timeout_seconds: number;
/**
* Where the OpenAI-compatible backend points and which model it names
* (§15, #220). `null` on either means the backend's own default — that
* backend is anything speaking the shape, `llama.cpp` included, so both
* are settings rather than deployment facts. Neither is a secret; the API
* key stays in the environment.
*/
openai_base_url: string | null;
openai_model: string | null;
/** Engines this binary compiled in — `translation_engine` is always one of these, or null. */
available_engines: string[];
}
export type SubtitleSettingsInput = Omit<SubtitleSettings, "available_engines">;
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[];
}
async function fetchSubtitleSettings(): Promise<SubtitleSettings> {
const response = await fetch("/api/settings/subtitles");
if (!response.ok) {
throw new Error(await errorDetail(response));
}
return (await response.json()) as SubtitleSettings;
}
function saveSubtitleSettings(input: SubtitleSettingsInput): Promise<SaveOutcome> {
return sendJson("/api/settings/subtitles", "PUT", input);
}
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. Shared with the series detail rows — one confirmation
* idiom for row-level destruction, not one per page.
*/
export 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(views: { hide: () => void }[], goHome: () => 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");
const subtitlesSection = must<HTMLElement>("#group-subtitles");
const subtitlesBody = must<HTMLElement>("#subtitles-body");
let roots: Root[] = [];
let policies: PolicyDoc[] = [];
let subtitleSettings: SubtitleSettings | null = null;
// 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, subtitlesSection]) {
section.hidden = true;
}
rootsRows.replaceChildren();
policiesRows.replaceChildren();
subtitlesBody.replaceChildren();
summary.textContent = "";
}
async function load() {
sequence += 1;
const ticket = sequence;
setStatus("reading settings…");
try {
const [fetchedRoots, fetchedPolicies, fetchedSubtitleSettings] = await Promise.all([
fetchRoots(),
fetchPolicies(),
fetchSubtitleSettings(),
]);
if (ticket !== sequence) {
return;
}
roots = fetchedRoots;
policies = fetchedPolicies;
subtitleSettings = fetchedSubtitleSettings;
} 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));
}
if (subtitleSettings) {
subtitlesSection.hidden = false;
subtitlesBody.append(subtitlesPanel(subtitleSettings));
}
}
/* -- 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;
}
/* -- subtitles ------------------------------------------------------------ */
/**
* One row per known provider, checkbox to enable, a daily-budget number,
* and up/down controls — list order is the priority order the API stores
* in `providers_enabled`. Disabled providers stay in the list, at
* whatever position they were left, so re-enabling one keeps its spot.
*/
function providerRow(
provider: string,
enabled: boolean,
budget: number,
): { item: HTMLLIElement; enabledInput: HTMLInputElement; budgetInput: HTMLInputElement } {
const item = el("li", "provider-row");
item.dataset.provider = provider;
const enabledInput = document.createElement("input");
enabledInput.type = "checkbox";
enabledInput.className = "provider-toggle";
enabledInput.checked = enabled;
enabledInput.setAttribute("aria-label", `enable ${provider}`);
const name = el("span", "row-title provider-name", provider);
const budgetInput = numberControl(budget);
budgetInput.classList.add("provider-budget");
budgetInput.setAttribute("aria-label", `${provider} daily budget`);
const up = el("button", "control control-quiet readout provider-move", "up");
up.type = "button";
up.setAttribute("aria-label", `move ${provider} up`);
const down = el("button", "control control-quiet readout provider-move", "down");
down.type = "button";
down.setAttribute("aria-label", `move ${provider} down`);
up.addEventListener("click", () => {
const previous = item.previousElementSibling;
if (previous) {
previous.before(item);
refreshProviderEdges();
}
});
down.addEventListener("click", () => {
const next = item.nextElementSibling;
if (next) {
next.after(item);
refreshProviderEdges();
}
});
item.append(enabledInput, name, budgetInput, up, down);
return { item, enabledInput, budgetInput };
}
let refreshProviderEdges: () => void = () => {};
function subtitlesPanel(settings: SubtitleSettings): HTMLElement {
const panel = el("div", "add-panel subtitle-panel");
const grid = el("div", "form-grid");
const wanted = textControl(settings.wanted_languages.join(", "), "pt-PT, en");
grid.append(field("wanted languages", wanted));
const hasEngines = settings.available_engines.length > 0;
const engineSelect = selectControl(
["none", ...settings.available_engines],
settings.translation_engine ?? "none",
);
engineSelect.disabled = !hasEngines;
grid.append(field("translation engine", engineSelect));
const timeout = numberControl(settings.remote_command_timeout_seconds);
grid.append(field("remote command timeout (s)", timeout));
const hasOpenAi = settings.available_engines.includes("openai");
const openAiBaseUrl = textControl(
settings.openai_base_url ?? "",
"optional — e.g. http://127.0.0.1:8080/v1",
);
const openAiModel = textControl(settings.openai_model ?? "", "optional — e.g. gpt-4o-mini");
openAiBaseUrl.setAttribute("aria-label", "openai base url");
openAiModel.setAttribute("aria-label", "openai model");
panel.append(grid);
if (!hasEngines) {
panel.append(
el(
"p",
"add-note readout dim",
"no translation engine is compiled into this binary — pick is unavailable",
),
);
}
// Their own labelled row rather than two more cells in the grid above:
// paired, they belong to one engine, and a base URL needs the width.
if (hasOpenAi) {
panel.append(
el("p", "field-label readout dim", "openai-compatible endpoint — blank means its default"),
);
const openAiGrid = el("div", "form-grid");
openAiGrid.append(field("base url", openAiBaseUrl), field("model", openAiModel));
panel.append(openAiGrid);
}
const translatorInputs: Record<string, HTMLInputElement> = {};
if (hasEngines) {
panel.append(el("p", "field-label readout dim", "translator daily budgets"));
const translatorGrid = el("div", "form-grid");
for (const engine of settings.available_engines) {
const input = numberControl(settings.translator_daily_budgets[engine] ?? 0);
input.setAttribute("aria-label", `${engine} daily budget`);
translatorInputs[engine] = input;
translatorGrid.append(field(engine, input));
}
panel.append(translatorGrid);
}
panel.append(el("p", "field-label readout dim", "providers — enabled, and in priority order"));
const enabledProviders = new Set(settings.providers_enabled);
const order = [
...settings.providers_enabled,
...PROVIDERS.filter((provider) => !enabledProviders.has(provider)),
];
const list = el("ul", "provider-list");
const rows: Record<string, { enabledInput: HTMLInputElement; budgetInput: HTMLInputElement }> =
{};
for (const provider of order) {
const { item, enabledInput, budgetInput } = providerRow(
provider,
enabledProviders.has(provider),
settings.provider_daily_budgets[provider] ?? 0,
);
rows[provider] = { enabledInput, budgetInput };
list.append(item);
}
refreshProviderEdges = () => {
const items = [...list.children];
items.forEach((item, index) => {
const up = item.querySelector<HTMLButtonElement>("button:nth-of-type(1)");
const down = item.querySelector<HTMLButtonElement>("button:nth-of-type(2)");
if (up) up.disabled = index === 0;
if (down) down.disabled = index === items.length - 1;
});
};
refreshProviderEdges();
panel.append(list);
const note = el("p", "add-note readout");
note.setAttribute("role", "status");
note.hidden = true;
const save = el("button", "control readout", "save subtitles");
save.type = "button";
save.addEventListener("click", () => {
const providerOrder = [...list.children].map(
(item) => (item as HTMLElement).dataset.provider ?? "",
);
const providersEnabled = providerOrder.filter(
(provider) => rows[provider]?.enabledInput.checked,
);
const providerBudgets: Record<string, number> = {};
for (const provider of providerOrder) {
const value = Number(rows[provider]?.budgetInput.value ?? 0);
if (value > 0) {
providerBudgets[provider] = value;
}
}
const translatorBudgets: Record<string, number> = {};
for (const [engine, input] of Object.entries(translatorInputs)) {
const value = Number(input.value);
if (value > 0) {
translatorBudgets[engine] = value;
}
}
save.disabled = true;
note.hidden = false;
delete note.dataset.tone;
note.textContent = "saving…";
const input: SubtitleSettingsInput = {
wanted_languages: listValue(wanted),
providers_enabled: providersEnabled,
translation_engine: engineSelect.value === "none" ? null : engineSelect.value,
provider_daily_budgets: providerBudgets,
translator_daily_budgets: translatorBudgets,
remote_command_timeout_seconds: Number(timeout.value),
// An emptied field is "use the backend's default", which the API
// stores as NULL.
openai_base_url: hasOpenAi ? openAiBaseUrl.value.trim() || null : settings.openai_base_url,
openai_model: hasOpenAi ? openAiModel.value.trim() || null : settings.openai_model,
};
void saveSubtitleSettings(input).then((outcome) => {
if (outcome.ok) {
void load();
return;
}
save.disabled = false;
note.hidden = false;
note.dataset.tone = "fault";
note.textContent = outcome.detail;
});
});
const actions = el("div", "add-actions");
actions.append(save, note);
panel.append(actions);
return panel;
}
/* -- shell ---------------------------------------------------------------- */
function open() {
for (const sibling of views) {
sibling.hide();
}
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();
goHome();
nav.focus();
}
nav.addEventListener("click", () => {
if (view.hidden) {
navigate({ kind: "settings" });
open();
} else {
navigate({ kind: "library" });
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: "library" });
close();
}
},
true,
);
return { hide, open };
}