|
|
@@ -598,7 +598,8 @@ function updatePathPreview() {
|
|
|
path = `${root}/Movies/${base}.mkv`;
|
|
|
|
|
|
} else if (currentType === 'music') {
|
|
|
- // Music/<Artist>/<Album>/[NN - ]<Track>.<ext> (album & number optional)
|
|
|
+ // Music/<Album>/[NN - ]<Track>.<ext>, or Music/<Artist> - Singles/... when
|
|
|
+ // no album is given. Mirrors resolve_output_dir() in app.py.
|
|
|
const artist = sanitize(getArtistName());
|
|
|
const album = sanitize(document.getElementById('album').value || '');
|
|
|
const track = document.getElementById('track').value;
|
|
|
@@ -606,7 +607,7 @@ function updatePathPreview() {
|
|
|
const fmtEl = document.getElementById('format');
|
|
|
const ext = (fmtEl && fmtEl.value) || 'm4a';
|
|
|
const trackPrefix = track ? `${String(track).padStart(2,'0')} - ` : '';
|
|
|
- const dir = album ? `${root}/Music/${artist}/${album}` : `${root}/Music/${artist}`;
|
|
|
+ const dir = album ? `${root}/Music/${album}` : `${root}/Music/${artist} - Singles`;
|
|
|
path = `${dir}/${trackPrefix}${title}.${ext}`;
|
|
|
|
|
|
} else { // series
|
|
|
@@ -685,6 +686,10 @@ async function submitJob() {
|
|
|
await loadBrowseData(); // refresh folder lists after potential new folder
|
|
|
applySnapshot(data); // the new job is in data.jobs with a fresh version
|
|
|
startPolling();
|
|
|
+ // A fast job may already have advanced past the queued snapshot above.
|
|
|
+ // Kick an immediate refresh so we catch running/done state without waiting
|
|
|
+ // for the first poll interval.
|
|
|
+ loadJobs();
|
|
|
} catch(e) {
|
|
|
toast(e.message, 'error');
|
|
|
} finally {
|
|
|
@@ -802,9 +807,11 @@ function renderJobsList(jobs) {
|
|
|
}
|
|
|
list.replaceChildren(frag);
|
|
|
|
|
|
- // Poll only when work is in progress
|
|
|
+ // Poll only when work is in progress. When jobs exist but none are active,
|
|
|
+ // coast through a short grace period before stopping (see requestStopPolling)
|
|
|
+ // so a just-completed job's final state can't strand the queue.
|
|
|
const hasActive = jobs.some(j => j.status === 'running' || j.status === 'queued');
|
|
|
- if (hasActive) startPolling(); else stopPolling();
|
|
|
+ if (hasActive) startPolling(); else requestStopPolling();
|
|
|
}
|
|
|
|
|
|
function renderJob(j) {
|
|
|
@@ -895,8 +902,33 @@ async function clearFinished() {
|
|
|
}
|
|
|
|
|
|
// ── Polling ───────────────────────────────────────────────────────────────────
|
|
|
-function startPolling() { if (!pollTimer) pollTimer = setInterval(loadJobs, 2500); }
|
|
|
-function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
|
|
|
+// `stopPolling` doesn't halt immediately. When no job is active we keep polling
|
|
|
+// for a few extra cycles (the grace period). This protects against a race where
|
|
|
+// a fast job's snapshot arrives showing everything done, polling stops, but a
|
|
|
+// later state change (or an out-of-order response) would otherwise be missed —
|
|
|
+// which manifested as completed jobs vanishing from the queue.
|
|
|
+let pollGrace = 0;
|
|
|
+const POLL_GRACE_CYCLES = 3;
|
|
|
+
|
|
|
+function startPolling() {
|
|
|
+ pollGrace = 0;
|
|
|
+ if (!pollTimer) pollTimer = setInterval(loadJobs, 2500);
|
|
|
+}
|
|
|
+
|
|
|
+function requestStopPolling() {
|
|
|
+ // Called by renderJobsList when nothing is active. Coast for a few cycles
|
|
|
+ // before actually stopping, so late updates still land.
|
|
|
+ if (pollGrace < POLL_GRACE_CYCLES) {
|
|
|
+ pollGrace += 1;
|
|
|
+ } else {
|
|
|
+ stopPolling();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function stopPolling() {
|
|
|
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
|
|
+ pollGrace = 0;
|
|
|
+}
|
|
|
|
|
|
// ── Toast ─────────────────────────────────────────────────────────────────────
|
|
|
function toast(msg, type='') {
|