b1acc5b5b3
* 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 <>
239 lines
7.3 KiB
JavaScript
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();
|
|
}
|