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:
@@ -468,6 +468,17 @@
|
||||
</p>
|
||||
<ul class="deck-rows" id="rows-policies"></ul>
|
||||
</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>
|
||||
|
||||
<!--
|
||||
|
||||
+230
-2
@@ -35,6 +35,22 @@ export interface RootInput {
|
||||
|
||||
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;
|
||||
/** 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 {
|
||||
@@ -61,6 +77,18 @@ async function fetchRoots(): Promise<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 };
|
||||
|
||||
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 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;
|
||||
|
||||
@@ -254,11 +285,12 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
|
||||
}
|
||||
|
||||
function clearGroups() {
|
||||
for (const section of [rootsSection, policiesSection]) {
|
||||
for (const section of [rootsSection, policiesSection, subtitlesSection]) {
|
||||
section.hidden = true;
|
||||
}
|
||||
rootsRows.replaceChildren();
|
||||
policiesRows.replaceChildren();
|
||||
subtitlesBody.replaceChildren();
|
||||
summary.textContent = "";
|
||||
}
|
||||
|
||||
@@ -267,12 +299,17 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
|
||||
const ticket = sequence;
|
||||
setStatus("reading settings…");
|
||||
try {
|
||||
const [fetchedRoots, fetchedPolicies] = await Promise.all([fetchRoots(), fetchPolicies()]);
|
||||
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;
|
||||
@@ -296,6 +333,10 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
|
||||
for (const policy of policies) {
|
||||
policiesRows.append(policyRow(policy));
|
||||
}
|
||||
if (subtitleSettings) {
|
||||
subtitlesSection.hidden = false;
|
||||
subtitlesBody.append(subtitlesPanel(subtitleSettings));
|
||||
}
|
||||
}
|
||||
|
||||
/* -- roots -------------------------------------------------------------- */
|
||||
@@ -700,6 +741,193 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
|
||||
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 ---------------------------------------------------------------- */
|
||||
|
||||
function open() {
|
||||
|
||||
@@ -2115,3 +2115,45 @@ select.form-input {
|
||||
.band-num {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user