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>
|
</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
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user