Files
stemdeckapp--stemdeck/static/js/job.js
T
Tha.Les 306f2ce913 Portable Windows data dir + auto-clear resolved failure notifications (#402)
* Redirect Windows portable zip cache data to data/ next to the exe

FFmpeg, Demucs models, config, and logs currently write to %LOCALAPPDATA%
regardless of where the zip is extracted, not to the data/ folder the README
already describes. A portable.txt marker, shipped in every future Windows zip,
switches local_data_dir() to the exe-relative data/ folder that packaging
already stages.

Jobs/library data is deliberately left untouched: it stays on its existing
default (~/Documents/StemDeck) and remains relocatable via the existing
Settings -> StemData location picker (#354). Defaulting it into the
exe-adjacent folder was the design in an earlier attempt at this fix, and was
reverted -- that folder is exactly what a user deletes or overwrites thinking
it's disposable.

Fixes #399

* Auto-clear failure notifications once they're resolved

Failure notifications (import/playback/export/update) persist until manually
dismissed, deliberately, from #359 -- so a crash or reload doesn't lose the
evidence needed for a bug report. This adds a second, independent trigger on
top without touching that: a notification also clears once the thing it was
about is actually resolved, while still surviving a plain reload in the
meantime.

- import: clears when a re-import supersedes the failed track, or when the
  track is trashed/purged
- playback: clears when the same track plays back successfully
- export: clears when the same track exports successfully (jobId is
  snapshotted at click time, not read live at settle time, since settling can
  take up to EXPORT_BUSY_MAX_MS and the user may have switched tracks by
  then); log export clears separately, keyed by kind since it has no jobId
- update: clears on the next successful check, which in practice only happens
  on the next app start -- checkForUpdate() has no periodic re-check today

Fixes #401

---------

Co-authored-by: Thales <>
2026-08-21 01:00:21 +01:00

777 lines
28 KiB
JavaScript

import {
form, urlInput, submitBtn, errorEl, jobBox, jobTitleEl, jobStageEl,
jobDetailEl, jobCancelBtn, progressEl, titleEl, bpmChip, keyChip,
eventSource, setEventSource, setCurrentJobId,
foregroundJobId, setForegroundJobId,
audioEngine, multitrack,
selectedStems,
} from "./state.js";
import { destroyPlayer, wireUpAudio, setWaveformLoading, updateFooterTrack } from "./player.js";
import { notifyFailure, dismissFailuresByJobId } from "./notifications.js";
import { stagePhrases } from "./phrases.js";
import { addTrackToLibrary, setCurrentTrack, updateTrackStatus, applyStemPresenceCards } from "./catalog.js";
import { initSections } from "./sections.js";
import { importPlaylist, looksLikePlaylist } from "./playlist.js";
// Playful stage label rotation (Claude-Code-style flair). The backend
// emits truthful stage strings; we surface them in the small #job-detail
// line so progress is debuggable, while #job-stage rotates whimsy.
const ROTATION_MS = 2500;
let phraseTimerId = null;
let lastStatus = null;
let jobPollTimerId = null;
const renderedJobs = new Set();
const jobSources = new Map();
const TERMINAL_STATUSES = new Set(["done", "error", "cancelled"]);
// Last library-visible values written per job, so a 4 Hz progress stream does
// not re-run addTrackToLibrary (a full localStorage write plus a whole-sidebar
// render) for frames that change nothing the sidebar shows. Every frame carries
// the complete job state, so skipping redundant ones loses nothing: the next
// frame that does change status carries the analysis fields too.
const libraryRowKeys = new Map();
function libraryRowKey(state) {
return [state.status, state.title || "", state.thumbnail || ""].join("\u0000");
}
// `processing` here means "a submit is in flight", not "a job is running".
// With a queue the form has to come back the instant the job is accepted, so
// the user can queue the next one.
function setSubmitProcessing(processing) {
submitBtn.disabled = processing;
submitBtn.classList.toggle("loading", processing);
document.querySelector(".strip-sq-process")?.classList.toggle("loading", processing);
const label = submitBtn.querySelector("span");
if (label) label.textContent = processing ? "Processing" : "Process";
}
/** True when audio is loaded in the studio. Either engine counts: the Web Audio
* path sets audioEngine, the streaming path sets multitrack, and destroyPlayer
* clears both. Read at call time so the live bindings are current. */
function studioHasTrack() {
return !!(audioEngine || multitrack);
}
function pickPhrase(status) {
const pool = stagePhrases[status] || stagePhrases.default;
return pool[Math.floor(Math.random() * pool.length)];
}
function setOverlayPhrase(text) {
const el = document.getElementById("waveLoadingPhrase");
if (el) el.textContent = text;
}
function startPhraseRotation(status) {
stopPhraseRotation();
const phrase = pickPhrase(status);
jobStageEl.textContent = phrase;
setOverlayPhrase(phrase);
phraseTimerId = setInterval(() => {
const p = pickPhrase(status);
jobStageEl.textContent = p;
setOverlayPhrase(p);
}, ROTATION_MS);
}
function stopPhraseRotation() {
if (phraseTimerId) {
clearInterval(phraseTimerId);
phraseTimerId = null;
}
jobStageEl.textContent = "";
}
function stopJobPolling() {
if (jobPollTimerId) {
clearInterval(jobPollTimerId);
jobPollTimerId = null;
}
}
// `retry` controls the button: "Try again" sends the user back to the URL field
// to start a fresh import, which is right for an import failure and wrong for
// anything else. Export failures pass retry:false and get a plain Dismiss, since
// the error box has no other way to be cleared.
export function showError(message, detail, { retry = true } = {}) {
delete errorEl.dataset.kind; // see showPlaybackError
errorEl.textContent = "";
const msg = document.createElement("div");
msg.className = "error-msg";
msg.textContent = message;
if (detail) {
// Classified cause from the backend (e.g. "out-of-memory — ..."), shown
// as a muted secondary line so failures are actionable, not opaque.
const detailEl = document.createElement("div");
detailEl.className = "error-detail";
detailEl.textContent = detail;
msg.appendChild(detailEl);
}
const btn = document.createElement("button");
btn.className = "retry-btn";
btn.type = "button";
btn.textContent = retry ? "Try again" : "Dismiss";
btn.addEventListener("click", () => {
errorEl.classList.add("hidden");
if (retry) {
urlInput.focus();
urlInput.select();
}
});
errorEl.append(msg, btn);
errorEl.classList.remove("hidden");
}
function clearImportError() {
delete errorEl.dataset.kind;
errorEl.classList.add("hidden");
errorEl.textContent = "";
}
// Playback failures reuse the import error box, which is the only alert surface
// the studio has. They are tagged so the player can retract its own message when
// the user loads a different track, without wiping an import failure the user
// has not read yet. Always retry:false -- "Try again" sends the user to the URL
// field, which is not what a broken stem file calls for.
export function showPlaybackError(message, detail, context = {}) {
showError(message, detail, { retry: false });
errorEl.dataset.kind = "playback";
// The class of failure #359 was written about: a track that loads and then
// does nothing. Recording it is what makes it reportable.
notifyFailure({ kind: "playback", message, detail, context });
}
export function clearPlaybackError() {
if (errorEl.dataset.kind === "playback") clearImportError();
}
// Playback actually succeeded for this track — clear any stale "playback
// failed" notification for it (#401). Only the playback kind: a successful
// play doesn't mean an unrelated import/export failure for the same track
// is resolved too.
export function resolvePlaybackSuccess(jobId) {
if (jobId) dismissFailuresByJobId(jobId, "playback");
}
// Clear the import chrome (progress box, error, phrase rotation, foreground
// SSE) without touching the studio. Split out of reset() so a submit that goes
// to the back of the queue does not tear down audio the user is playing.
function resetImportUi() {
if (eventSource) {
eventSource.close();
setEventSource(null);
}
stopJobPolling();
stopPhraseRotation();
lastStatus = null;
clearImportError();
jobBox.classList.add("hidden");
jobCancelBtn.classList.add("hidden");
jobTitleEl.textContent = "";
jobStageEl.textContent = "";
jobDetailEl.textContent = "";
progressEl.value = 0;
setSubmitProcessing(false);
setForegroundJobId(null);
}
export function reset() {
resetImportUi();
destroyPlayer();
setCurrentJobId(null);
}
// The running import no longer owns the studio: the user opened another track.
// The job keeps running and its SSE stays connected -- it still updates the
// library row -- it just stops repainting a view that is now showing something
// else. Cancel moves to the queue view, which is why the button goes away.
export function detachForegroundJob() {
if (!foregroundJobId) return;
setForegroundJobId(null);
stopPhraseRotation();
setWaveformLoading(false);
jobBox.classList.add("hidden");
jobCancelBtn.classList.add("hidden");
}
// The analysis cards under the waveform. Split out of applyState so the
// studio-owned DOM lives behind one call: only the job the user is actually
// looking at may write here, and that is far easier to see when it is one
// named function than fifty inline element lookups.
function applyStudioSummary(state) {
const summaryKey = document.getElementById("summary-key");
const summaryBpm = document.getElementById("summary-bpm");
const summaryScale = document.getElementById("summary-scale");
const summaryScaleName = document.getElementById("summary-scale-name");
const summaryConfidence = document.getElementById("summary-confidence");
const summaryConfidenceLabel = document.getElementById("summary-confidence-label");
const summaryLufs = document.getElementById("summary-lufs");
const summaryPeak = document.getElementById("summary-peak");
const summaryDuration = document.getElementById("summary-duration");
if (summaryKey && state.key) summaryKey.textContent = state.key;
if (summaryBpm && state.bpm) summaryBpm.textContent = String(state.bpm);
if (summaryScale && state.scale) summaryScale.textContent = state.scale;
if (summaryScaleName && state.scale) summaryScaleName.textContent = state.scale;
if (summaryLufs && state.lufs != null) summaryLufs.textContent = state.lufs.toFixed(1);
if (summaryPeak && state.peak_db != null) summaryPeak.textContent = `Peak ${state.peak_db.toFixed(1)} dB`;
if (summaryDuration && state.duration) {
const m = Math.floor(state.duration / 60);
const s = Math.floor(state.duration % 60).toString().padStart(2, "0");
summaryDuration.textContent = `${m.toString().padStart(2, "0")}:${s}`;
}
if (summaryConfidence && state.key_confidence != null) {
const confidence = Math.max(0, Math.min(100, Number(state.key_confidence)));
const confSpan = document.createElement("span");
confSpan.textContent = `${confidence}%`;
summaryConfidence.textContent = "";
summaryConfidence.appendChild(confSpan);
summaryConfidence.style.setProperty("--confidence-pct", confidence);
summaryConfidence.classList.remove("hidden");
summaryConfidenceLabel?.classList.remove("hidden");
}
const summaryDr = document.getElementById("summary-dr");
const summaryDrLabel = document.getElementById("summary-dr-label");
const summaryStability = document.getElementById("summary-stability");
const summaryStabilityLabel = document.getElementById("summary-stability-label");
if (summaryDr && state.dynamic_range != null) summaryDr.textContent = String(state.dynamic_range);
if (summaryDrLabel && state.dynamic_range != null) {
const dr = state.dynamic_range;
summaryDrLabel.textContent = dr < 7 ? "Compressed" : dr < 10 ? "Moderate" : dr < 14 ? "High" : "Wide";
}
if (summaryStability && state.tempo_stability != null) {
summaryStability.textContent = `${state.tempo_stability}%`;
summaryStability.className = "meta-card-value" + (state.tempo_stability >= 80 ? " stability-high" : "");
}
if (summaryStabilityLabel && state.tempo_stability != null) {
const s = state.tempo_stability;
summaryStabilityLabel.textContent = s >= 90 ? "Very Stable" : s >= 70 ? "Stable" : s >= 50 ? "Moderate" : "Variable";
}
if (state.stem_presence != null) {
applyStemPresenceCards(state.stem_presence);
}
}
function applyState(state) {
// Everything below the library update writes to DOM the studio owns, and
// only the import the user is actually watching may touch it. A background
// job still gets its library row updated -- that is the point of the queue.
const isForeground = !!state.job_id && state.job_id === foregroundJobId;
if (state.job_id && libraryRowKeys.get(state.job_id) !== libraryRowKey(state)) {
libraryRowKeys.set(state.job_id, libraryRowKey(state));
addTrackToLibrary({
id: state.job_id,
// urlInput only speaks for the foreground job. While a background import
// runs the user may already be typing the next URL in there, and it must
// not end up as some other track's title or source.
title: state.title || (isForeground ? urlInput.value : "") || "Processing track",
channel: state.status === "done" ? "Extracted" : "Processing",
thumb: state.thumbnail,
stems: state.selected_stems || state.stems?.map((stem) => stem.name) || [...selectedStems],
selectedStems: state.selected_stems || [...selectedStems],
audioStems: state.stems || [],
status: state.status,
duration: state.duration,
bpm: state.bpm,
key: state.key,
scale: state.scale,
keyConfidence: state.key_confidence,
lufs: state.lufs,
peakDb: state.peak_db,
stemPresence: state.stem_presence,
sourceUrl: jobSources.get(state.job_id) || (isForeground ? urlInput.value : ""),
createdAt: state.created_at,
});
}
if (state.job_id && TERMINAL_STATUSES.has(state.status)) libraryRowKeys.delete(state.job_id);
// Everything from here down is studio DOM.
if (!isForeground) return;
setCurrentTrack(state.job_id);
if (state.title) {
jobTitleEl.textContent = state.title;
titleEl.textContent = state.title;
}
if (state.bpm) bpmChip.textContent = `${state.bpm} BPM`;
if (state.key) keyChip.textContent = state.key;
if (state.title || state.bpm || state.key || state.thumbnail) {
updateFooterTrack({
title: state.title,
thumbnail: state.thumbnail,
key: state.key,
bpm: state.bpm,
stemCount: state.stems ? state.stems.filter((s) => s.name !== "original").length : null,
});
}
applyStudioSummary(state);
// Stage label is owned by the phrase-rotation timer below; we don't
// overwrite it from each SSE tick. The truthful backend stage goes
// to the small detail line instead.
jobDetailEl.textContent = state.stage || "";
progressEl.value = Math.round((state.progress || 0) * 100);
// Cancel button is visible exactly while the job is in a non-terminal state.
const terminal = TERMINAL_STATUSES.has(state.status);
jobCancelBtn.classList.toggle("hidden", terminal);
if (state.status !== lastStatus) {
if (terminal) stopPhraseRotation();
else startPhraseRotation(state.status);
lastStatus = state.status;
}
// The three terminal branches no longer clear the submit button: it is
// released as soon as the POST returns, so the next import can be queued
// while this one is still running.
if (state.status === "error") {
stopJobPolling();
updateTrackStatus(state.job_id, "error");
setWaveformLoading(false);
showError(state.error || "Unknown error", state.error_detail);
// Also record it: the banner above is transient and the user may well
// dismiss it before deciding to report anything.
notifyFailure({
kind: "import",
message: state.error || "Unknown error",
detail: state.error_detail || null,
context: {
jobId: state.job_id,
stage: state.stage,
device: state.compute_device,
gpuFallback: state.gpu_fallback,
timings: state.stage_timings ? JSON.stringify(state.stage_timings) : null,
},
});
setForegroundJobId(null);
} else if (state.status === "cancelled") {
stopJobPolling();
updateTrackStatus(state.job_id, "cancelled");
setWaveformLoading(false);
jobBox.classList.add("hidden");
setForegroundJobId(null);
} else if (state.status === "done") {
stopJobPolling();
updateTrackStatus(state.job_id, "done");
jobBox.classList.add("hidden");
setForegroundJobId(null);
if (!renderedJobs.has(state.job_id)) {
renderedJobs.add(state.job_id);
wireUpAudio(
state.job_id,
state.stems || [],
state.duration || 0,
state.thumbnail,
state.mix_url ?? null,
state.title || "",
null,
state.has_video ?? false,
);
initSections(state.job_id, state.sections, state.duration || 0);
}
}
}
async function probeJob(jobId) {
const r = await fetch(`/api/jobs/${jobId}`);
if (!r.ok) {
if (r.status === 404) throw new Error("Job no longer exists on the server");
throw new Error(`Job probe failed: ${r.status}`);
}
const s = await r.json();
applyState(s);
return s;
}
function startJobPolling(jobId) {
stopJobPolling();
const tick = async () => {
try {
const s = await probeJob(jobId);
if (TERMINAL_STATUSES.has(s.status)) stopJobPolling();
} catch (err) {
console.warn("[job] REST fallback failed:", err);
}
};
tick();
jobPollTimerId = setInterval(tick, 1000);
}
// Connect (or reconnect) to the SSE stream for a job. On unexpected
// disconnect we probe /api/jobs/{id} to decide: if the job is already
// terminal, accept its final state; otherwise reconnect with backoff.
// Falls back to REST polling only after SSE exhausts its retry budget.
function connectEvents(jobId) {
let attempt = 0;
let stopped = false;
const open = () => {
const es = new EventSource(`/api/jobs/${jobId}/events`);
setEventSource(es);
es.onmessage = (ev) => {
attempt = 0; // any successful frame resets backoff
let s;
try { s = JSON.parse(ev.data); } catch { return; }
// Defer by one tick so synchronous user event handlers (clicks,
// input events) always complete before SSE state is applied.
setTimeout(() => {
applyState(s);
if (TERMINAL_STATUSES.has(s.status)) {
stopped = true;
es.close();
setEventSource(null);
}
}, 0);
};
es.onerror = async () => {
if (stopped) return;
es.close();
setEventSource(null);
// Probe REST once before declaring failure -- handles dev-server
// reloads and brief network blips where the job is actually fine.
try {
const s = await probeJob(jobId);
if (TERMINAL_STATUSES.has(s.status)) {
stopped = true;
return;
}
} catch (err) {
if (err.message === "Job no longer exists on the server") {
stopped = true;
showError(err.message);
setSubmitProcessing(false);
return;
}
// Network down -- fall through to backoff.
}
attempt += 1;
if (attempt > 6) {
// SSE gave up — activate REST polling as the fallback.
startJobPolling(jobId);
return;
}
// 0.5s, 1s, 2s, 4s, 8s, 16s
const delay = 500 * Math.pow(2, attempt - 1);
setTimeout(() => { if (!stopped) open(); }, delay);
};
};
open();
}
async function cancelCurrentJob() {
// The import, not the track in the studio. Reading currentJobId here meant
// that opening another track mid-import pointed Cancel at the wrong job.
const id = foregroundJobId;
if (!id) return;
jobCancelBtn.disabled = true;
jobCancelBtn.textContent = "Cancelling…";
try {
await fetch(`/api/jobs/${id}/cancel`, { method: "POST" });
// The next SSE frame (or the REST probe in connectEvents) will
// surface the cancelled state and hide the button via applyState.
} catch {
/* SSE will reflect the result regardless */
} finally {
jobCancelBtn.disabled = false;
jobCancelBtn.textContent = "Cancel";
}
}
async function postFileJob(file) {
const fd = new FormData();
fd.append("file", file);
fd.append("stems", JSON.stringify([...selectedStems]));
const res = await fetch("/api/jobs", { method: "POST", body: fd });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || res.statusText);
return data.job_id;
}
/** Give an accepted upload its library row straight away, so a batch appears as
* rows the moment each file lands rather than only once every upload is done. */
function registerUploadRow(jobId, file) {
const title = sanitizeFilename(file.name);
const sourceUrl = `local:${title}`;
jobSources.set(jobId, sourceUrl);
addTrackToLibrary({
id: jobId,
title,
channel: "Processing",
thumb: "",
stems: [...selectedStems],
selectedStems: [...selectedStems],
audioStems: [],
status: "queued",
bpm: null,
key: null,
scale: null,
keyConfidence: null,
lufs: null,
peakDb: null,
sourceUrl,
});
}
function sanitizeFilename(name) {
// Strip extension, collapse whitespace, cap at 120 chars — mirrors the
// backend _sanitize_title() so title and sourceUrl match on both sides.
return name
.replace(/\.[^.]+$/, "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 120);
}
// Programmatic URL import — re-uses the full studio/SSE pipeline (same as the
// import form's URL path). Used by the library "Sync again" auto-restore to
// re-download + re-separate a track whose backend audio was swept. Takes over
// the studio like a normal import. Returns the new job id, or null on failure.
export async function importFromUrl(url, { title, stems } = {}) {
if (!url || url.startsWith("local:")) return null; // local files can't auto-restore
reset();
setSubmitProcessing(true);
setWaveformLoading(true, "");
const stemSel = stems?.length ? stems : [...selectedStems];
let jobId;
try {
const res = await fetch("/api/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, stems: stemSel }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || res.statusText);
jobId = data.job_id;
} catch (err) {
showError(`Failed to restore track: ${err.message}`);
setSubmitProcessing(false);
return null;
}
setSubmitProcessing(false);
setCurrentJobId(jobId);
setForegroundJobId(jobId);
jobSources.set(jobId, url);
// Merges into the existing library entry by sourceUrl (replaceTrackId),
// preserving its folder placement; status updates as SSE frames arrive.
addTrackToLibrary({
id: jobId,
title: title || url || "Processing track",
channel: "Processing",
thumb: "",
stems: stemSel,
selectedStems: stemSel,
audioStems: [],
status: "processing",
bpm: null,
key: null,
scale: null,
keyConfidence: null,
lufs: null,
peakDb: null,
sourceUrl: url,
});
setCurrentTrack(jobId);
jobBox.classList.add("hidden");
jobCancelBtn.classList.add("hidden");
startPhraseRotation("queued");
lastStatus = "queued";
connectEvents(jobId);
return jobId;
}
export function wireJobForm() {
jobCancelBtn.addEventListener("click", cancelCurrentJob);
form.addEventListener("submit", async (e) => {
e.preventDefault();
// An import must never take a loaded studio away from the user. With a
// track playing, the new job goes straight to the background: no player
// teardown, no loading overlay, no takeover when it finishes. It reports
// progress on its library row instead.
//
// An import already holding the foreground keeps it, too. Otherwise
// queueing a second track would point the studio overlay at a job that has
// not started, and the first import -- the one about to finish -- would no
// longer be the one that loads.
const background = studioHasTrack() || !!foregroundJobId;
if (background) {
// Deliberately NOT resetImportUi(): that closes the running import's
// event stream and drops its foreground claim, which would leave the job
// about to finish with nothing listening for its completion.
clearImportError();
} else {
reset();
}
setSubmitProcessing(true);
const fileInput = document.getElementById("fileInput");
// Prefer _file cache: browsers (WKWebView, Chromium) silently clear
// fileInput.files after a fetch() submission, breaking re-submits.
const file = fileInput?._file ?? fileInput?.files?.[0] ?? null;
// A playlist is its own flow: expand, confirm the count, then queue every
// track into a folder named after it. Nothing takes the studio.
if (!file && looksLikePlaylist(urlInput.value)) {
const url = urlInput.value;
const queued = await importPlaylist(url, [...selectedStems]);
setSubmitProcessing(false);
if (queued) urlInput.value = "";
return;
}
// Several files dropped at once: upload them one after another. Parallel
// uploads of several 400 MB bodies would thrash memory on both ends, and
// the endpoint takes exactly one file per request anyway. The first one
// takes the studio if it is free, exactly as a single import would; the
// rest queue behind it.
const batch = fileInput?._files ?? null;
if (batch && batch.length > 1) {
if (!background) setWaveformLoading(true, "Uploading…");
let queued = 0;
let failure = null;
for (const item of batch) {
try {
const id = await postFileJob(item);
registerUploadRow(id, item);
queued += 1;
if (queued === 1 && !background) {
setCurrentJobId(id);
setForegroundJobId(id);
setCurrentTrack(id);
jobBox.classList.add("hidden");
jobCancelBtn.classList.add("hidden");
startPhraseRotation("queued");
lastStatus = "queued";
connectEvents(id);
}
} catch (err) {
failure = err;
break; // a full queue will reject the rest too; stop asking
}
}
setSubmitProcessing(false);
fileInput._clear?.();
if (failure) {
if (!queued && !background) {
setWaveformLoading(false);
setForegroundJobId(null);
}
showError(
`Queued ${queued} of ${batch.length} files: ${failure.message}`,
null,
{ retry: false },
);
}
return;
}
const sanitized = file ? sanitizeFilename(file.name) : null;
const sourceUrl = file ? `local:${sanitized}` : urlInput.value;
const displayTitle = sanitized ?? (urlInput.value || "Processing track");
const postUrlText = document.getElementById("post-url-text");
if (postUrlText) postUrlText.textContent = displayTitle;
// Show overlay immediately for both paths. File uploads show "Uploading…"
// in the overlay phrase until the fetch completes and SSE takes over.
// Skipped entirely for a background import -- the overlay covers the
// studio, which is exactly what must not happen here.
if (!background) {
setWaveformLoading(true, file ? "Uploading…" : "");
if (file) {
lastStatus = "queued";
}
}
let fetchInit;
if (file) {
const fd = new FormData();
fd.append("file", file);
fd.append("stems", JSON.stringify([...selectedStems]));
fetchInit = { method: "POST", body: fd };
} else {
fetchInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: urlInput.value,
// Backend uses this to decide whether to ffmpeg-amix a
// "selected stems" track (mix.wav) at the end of the pipeline.
stems: [...selectedStems],
}),
};
}
let jobId;
try {
const res = await fetch("/api/jobs", fetchInit);
const data = await res.json();
if (!res.ok) throw new Error(data.detail || res.statusText);
jobId = data.job_id;
} catch (err) {
if (file) jobBox.classList.add("hidden");
showError(`Failed to start job: ${err.message}`);
setSubmitProcessing(false);
return;
}
// Released here, not when the job finishes: the queue is what the button
// hands off to now, so the form is free again the moment the job exists.
setSubmitProcessing(false);
// The server has the upload; disarm the picker so the next click cannot
// silently import the same file a second time.
if (file) fileInput._clear?.();
jobSources.set(jobId, sourceUrl);
addTrackToLibrary({
id: jobId,
title: displayTitle,
channel: "Processing",
thumb: "",
stems: [...selectedStems],
selectedStems: [...selectedStems],
audioStems: [],
status: "processing",
bpm: null,
key: null,
scale: null,
keyConfidence: null,
lufs: null,
peakDb: null,
sourceUrl,
});
if (background) {
// No per-job stream: opening one per queued import would burn through
// the browser's ~6 connections per origin and starve stem loading. The
// shared queue stream drives the row, and catalog.js completes the
// library entry when the job leaves the queue.
if (postUrlText) postUrlText.textContent = "";
return;
}
setCurrentJobId(jobId);
setForegroundJobId(jobId);
setCurrentTrack(jobId);
// Keep job box hidden, overlay drives the UI. Start phrase rotation now
// that the job exists on the server.
jobBox.classList.add("hidden");
jobCancelBtn.classList.add("hidden");
startPhraseRotation("queued");
lastStatus = "queued";
connectEvents(jobId);
});
}