Files
Tha.Les b1acc5b5b3 Add German, Portuguese, and Indonesian translations (#415)
* Add German, Portuguese, and Indonesian translations; fix i18n coverage gaps

Extends the existing English/Polish/Japanese/Simplified Chinese i18n system
to seven languages total. Also fixes several pre-existing i18n coverage gaps
found while auditing: the recent-tracks list, search placeholder, and trash
empty-state were hardcoding English text instead of using the translation
system; a presence-panel legend lacked data-i18n attributes; upload/job/
playlist error toasts were untranslated; and library list content did not
refresh on a live language switch. Widened the settings dropdown to fit the
longest new language name and the device-select to stop truncating longer
translated values.

Bumps the Unraid template pin to 0.13.0.

* Remove unused plural import in job.js

Flagged in PR review: job.js imports plural from i18n.js but never calls
it, only t().

* Native-speaker QA pass on all translations

Fixes real mistranslations (German "schleifen" for loop, "Skala" for musical
scale, Indonesian countdown/count-in mixup, Chinese Alpha badge), grammar bugs
(Polish aria-labels requiring an unavailable grammatical case, singular/plural
adjective agreement in playlist skip messages), inconsistent terminology
within each language, and a stray three-dot ellipsis instead of the single
character used everywhere else.

Converts playlist.skip.* from t() to plural() with proper singular/plural
forms across all seven languages, since Portuguese and Polish adjectives
don't inflect correctly as flat strings.

---------

Co-authored-by: Thales <>
2026-08-22 08:32:51 +01:00

239 lines
7.3 KiB
JavaScript

// queue.js — the import queue as the client sees it.
//
// One EventSource for the whole queue rather than one per job: browsers cap
// concurrent connections per origin at around six on HTTP/1.1 and the studio
// already spends most of that budget fetching stem WAVs, so twenty per-job
// streams would starve audio loading long before hitting any server limit.
//
// This module owns no DOM. It holds the latest snapshot and tells subscribers
// when it changes; catalog.js decides what that looks like.
import { t } from "./i18n.js";
const POLL_MS = 2000;
const MAX_SSE_ATTEMPTS = 6;
let snapshot = { running: null, queued: [], max_pending: 0, capacity_left: 0 };
let source = null;
let pollTimerId = null;
let attempt = 0;
const subscribers = new Set();
const settledSubscribers = new Set();
// Ids seen in the previous frame. A job that disappears has reached a terminal
// state -- done, error or cancelled -- since the snapshot only ever carries the
// running job plus those still waiting.
let lastSeenIds = new Set();
// ─── pure helpers (no DOM, no network -- the testable part) ───────────────────
export function queueCount(snap = snapshot) {
return (snap.running ? 1 : 0) + (snap.queued?.length ?? 0);
}
export function ordinal(n) {
const rem100 = n % 100;
if (rem100 >= 11 && rem100 <= 13) return `${n}th`;
const suffix = { 1: "st", 2: "nd", 3: "rd" }[n % 10] ?? "th";
return `${n}${suffix}`;
}
/** Stage text for the running row. The backend stage already carries a
* percentage during separation ("Separating 42%"), so only append one when it
* does not, rather than rendering "Separating 42% 42%". */
export function runningLabel(job) {
const stage = (job?.stage || t("job.working")).replace(/\.\.\.$/, "");
if (/\d\s*%/.test(stage)) return stage;
const pct = Math.round((job?.progress || 0) * 100);
return pct > 0 ? `${stage} ${pct}%` : stage;
}
/** Per-job view state, keyed by job id, for whoever is drawing rows.
* Position counts the running job, so the first waiting job is 2nd in line. */
export function isPaused(snap = snapshot) {
return !!snap.paused && queueCount(snap) > 0;
}
export async function startQueue() {
try {
const r = await fetch("/api/queue/start", { method: "POST" });
if (r.ok) publish(await r.json());
} catch (e) {
console.warn("[queue] start failed:", e);
}
}
export function queueRowStates(snap = snapshot) {
const rows = new Map();
if (snap.running) {
rows.set(snap.running.job_id, {
state: "running",
label: runningLabel(snap.running),
progress: snap.running.progress || 0,
position: 1,
});
}
const offset = snap.running ? 2 : 1;
const paused = isPaused(snap);
(snap.queued ?? []).forEach((job, i) => {
const place = i + offset;
rows.set(job.job_id, {
state: "waiting",
// A paused queue is not "2nd in line" for anything -- nothing is moving.
// Say so, or the row looks stuck.
label: paused ? "Paused" : `Queued - ${ordinal(place)} in line`,
progress: 0,
position: place,
});
});
return rows;
}
// ─── state ───────────────────────────────────────────────────────────────────
export function getQueueSnapshot() {
return snapshot;
}
export function onQueueChange(fn) {
subscribers.add(fn);
return () => subscribers.delete(fn);
}
/** Called with a job id once it leaves the queue. A background import has no
* per-job SSE stream of its own -- opening one per queued job would exhaust
* the browser's ~6 connections per origin -- so this is how the library learns
* that a job it is not watching has finished. */
export function onJobSettled(fn) {
settledSubscribers.add(fn);
return () => settledSubscribers.delete(fn);
}
export function currentIds(snap = snapshot) {
const ids = new Set();
if (snap.running) ids.add(snap.running.job_id);
for (const job of snap.queued ?? []) ids.add(job.job_id);
return ids;
}
function publish(next) {
snapshot = next;
const ids = currentIds(next);
const settled = [...lastSeenIds].filter((id) => !ids.has(id));
lastSeenIds = ids;
for (const fn of subscribers) {
try { fn(snapshot); } catch (e) { console.warn("[queue] subscriber failed:", e); }
}
for (const id of settled) {
for (const fn of settledSubscribers) {
try { fn(id); } catch (e) { console.warn("[queue] settled subscriber failed:", e); }
}
}
}
export async function refreshQueue() {
try {
const r = await fetch("/api/queue");
if (!r.ok) return;
publish(await r.json());
} catch (e) {
console.warn("[queue] refresh failed:", e);
}
}
/** Move a waiting job so it runs directly after `afterId`, or first when that
* is null. Returns true if the server accepted it.
*
* "After this job" rather than "at index N": the queue moves while the user
* drags, so an index captured at drag start can mean somewhere else by the
* time it lands. A 409 means the job started or finished mid-drag, which is
* not an error worth showing -- the snapshot that follows corrects the view.
*/
export async function reorderQueuedJob(jobId, afterId) {
try {
const r = await fetch("/api/queue/reorder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ job_id: jobId, after: afterId ?? null }),
});
if (r.ok) {
publish(await r.json());
return true;
}
await refreshQueue();
return false;
} catch (e) {
console.warn("[queue] reorder failed:", e);
await refreshQueue();
return false;
}
}
export async function cancelQueuedJob(jobId) {
try {
await fetch(`/api/jobs/${jobId}/cancel`, { method: "POST" });
} catch (e) {
console.warn("[queue] cancel failed:", e);
}
// Do not wait for the next frame: the row should go the moment it is clicked.
await refreshQueue();
}
// ─── transport ───────────────────────────────────────────────────────────────
function startPolling() {
if (pollTimerId) return;
pollTimerId = setInterval(refreshQueue, POLL_MS);
refreshQueue();
}
function stopPolling() {
if (pollTimerId) {
clearInterval(pollTimerId);
pollTimerId = null;
}
}
export function startQueueStream() {
if (source) return;
refreshQueue(); // first paint should not wait for the stream to connect
const open = () => {
const es = new EventSource("/api/queue/events");
source = es;
es.onmessage = (ev) => {
attempt = 0;
stopPolling(); // the stream is healthy again
try {
publish(JSON.parse(ev.data));
} catch (e) {
console.warn("[queue] bad frame:", e);
}
};
es.onerror = () => {
es.close();
source = null;
attempt += 1;
if (attempt > MAX_SSE_ATTEMPTS) {
// Give up on SSE and keep the view alive by polling, same fallback
// shape as the per-job stream in job.js.
startPolling();
return;
}
setTimeout(open, 500 * Math.pow(2, attempt - 1)); // 0.5s .. 16s
};
};
open();
}
export function stopQueueStream() {
if (source) {
source.close();
source = null;
}
stopPolling();
}