feat(arr): render subtitle settings in /settings

Adds wanted languages, translation engine (restricted to
available_engines), remote-command timeout, and an ordered
enable/disable list per provider with daily budgets, wired to
the existing GET/PUT /api/settings/subtitles from #198.
This commit is contained in:
Miguel Palhas
2026-08-25 05:08:58 +01:00
parent e0e7ddf6de
commit 6d665e7c2f
3 changed files with 283 additions and 2 deletions
+11
View File
@@ -468,6 +468,17 @@
</p> </p>
<ul class="deck-rows" id="rows-policies"></ul> <ul class="deck-rows" id="rows-policies"></ul>
</section> </section>
<section class="deck-group" id="group-subtitles" hidden aria-labelledby="label-settings-subtitles">
<header class="deck-head">
<h3 class="deck-label" id="label-settings-subtitles">subtitles</h3>
</header>
<p class="queue-note readout dim">
provider credentials, translator keys and the remote-command template
live in config/env (§10) and are never shown here.
</p>
<div id="subtitles-body"></div>
</section>
</main> </main>
<!-- <!--
+230 -2
View File
@@ -35,6 +35,22 @@ export interface RootInput {
const RESOLUTIONS = ["2160p", "1080p", "720p"] as const; const RESOLUTIONS = ["2160p", "1080p", "720p"] as const;
const SOURCES = ["Remux", "BluRay", "WEB-DL", "WEBRip", "HDTV"] 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;
/** 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> { async function errorDetail(response: Response): Promise<string> {
try { try {
@@ -61,6 +77,18 @@ async function fetchRoots(): Promise<Root[]> {
return (await response.json()) as Root[]; 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 }; type SaveOutcome = { ok: true; json: unknown } | { ok: false; detail: string };
async function sendJson(url: string, method: string, body: unknown): Promise<SaveOutcome> { async function sendJson(url: string, method: string, body: unknown): Promise<SaveOutcome> {
@@ -237,9 +265,12 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
const rootsRows = must<HTMLUListElement>("#rows-roots"); const rootsRows = must<HTMLUListElement>("#rows-roots");
const policiesSection = must<HTMLElement>("#group-policies"); const policiesSection = must<HTMLElement>("#group-policies");
const policiesRows = must<HTMLUListElement>("#rows-policies"); const policiesRows = must<HTMLUListElement>("#rows-policies");
const subtitlesSection = must<HTMLElement>("#group-subtitles");
const subtitlesBody = must<HTMLElement>("#subtitles-body");
let roots: Root[] = []; let roots: Root[] = [];
let policies: PolicyDoc[] = []; let policies: PolicyDoc[] = [];
let subtitleSettings: SubtitleSettings | null = null;
// guards a stale fetch from painting over a newer view // guards a stale fetch from painting over a newer view
let sequence = 0; let sequence = 0;
@@ -254,11 +285,12 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
} }
function clearGroups() { function clearGroups() {
for (const section of [rootsSection, policiesSection]) { for (const section of [rootsSection, policiesSection, subtitlesSection]) {
section.hidden = true; section.hidden = true;
} }
rootsRows.replaceChildren(); rootsRows.replaceChildren();
policiesRows.replaceChildren(); policiesRows.replaceChildren();
subtitlesBody.replaceChildren();
summary.textContent = ""; summary.textContent = "";
} }
@@ -267,12 +299,17 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
const ticket = sequence; const ticket = sequence;
setStatus("reading settings…"); setStatus("reading settings…");
try { try {
const [fetchedRoots, fetchedPolicies] = await Promise.all([fetchRoots(), fetchPolicies()]); const [fetchedRoots, fetchedPolicies, fetchedSubtitleSettings] = await Promise.all([
fetchRoots(),
fetchPolicies(),
fetchSubtitleSettings(),
]);
if (ticket !== sequence) { if (ticket !== sequence) {
return; return;
} }
roots = fetchedRoots; roots = fetchedRoots;
policies = fetchedPolicies; policies = fetchedPolicies;
subtitleSettings = fetchedSubtitleSettings;
} catch (error) { } catch (error) {
if (ticket !== sequence) { if (ticket !== sequence) {
return; return;
@@ -296,6 +333,10 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
for (const policy of policies) { for (const policy of policies) {
policiesRows.append(policyRow(policy)); policiesRows.append(policyRow(policy));
} }
if (subtitleSettings) {
subtitlesSection.hidden = false;
subtitlesBody.append(subtitlesPanel(subtitleSettings));
}
} }
/* -- roots -------------------------------------------------------------- */ /* -- roots -------------------------------------------------------------- */
@@ -700,6 +741,193 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
return panel; 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));
panel.append(grid);
if (!hasEngines) {
panel.append(
el(
"p",
"add-note readout dim",
"no translation engine is compiled into this binary — pick is unavailable",
),
);
}
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),
};
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 ---------------------------------------------------------------- */ /* -- shell ---------------------------------------------------------------- */
function open() { function open() {
+42
View File
@@ -2115,3 +2115,45 @@ select.form-input {
.band-num { .band-num {
width: 4.5rem; width: 4.5rem;
} }
/* ---- subtitle settings (§15, issue #204) ------------------------------ */
.subtitle-panel .field-label {
display: block;
margin: var(--space-4) 0 var(--space-2);
}
.provider-list {
margin: 0 0 var(--space-3);
padding: 0;
list-style: none;
}
.provider-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-1);
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
}
.provider-toggle {
accent-color: var(--accent);
}
.provider-name {
flex: 1;
min-width: 0;
}
.provider-budget {
width: 5.5rem;
}
.provider-move {
min-width: 3rem;
}
.provider-move:disabled {
visibility: hidden;
}