Просмотр исходного кода

Resolve Music archiving to conform to JFN expected file struct

ArtyomV2X 1 месяц назад
Родитель
Сommit
c7b5b0f16b
2 измененных файлов с 58 добавлено и 12 удалено
  1. 20 6
      web/app.py
  2. 38 6
      web/templates/index.html

+ 20 - 6
web/app.py

@@ -305,11 +305,17 @@ def resolve_output_dir(job: dict) -> Path:
         out_dir = MEDIA_DIR / "Shows" / folder / f"Season {season:02d}"
 
     elif mtype == "music":
-        artist = sanitize(job.get("artist") or job.get("author") or "Unknown Artist")
-        album  = sanitize(job.get("album") or "")
-        out_dir = MEDIA_DIR / "Music" / artist
+        # Jellyfin identifies music from embedded tags, not the folder path — it
+        # only requires that each album live in its own folder. So we drop the
+        # artist folder and use Music/<Album>/. Tracks with no album go into a
+        # per-artist "<Artist> - Singles" folder, keeping the Music root clean
+        # and giving loose tracks an album-like grouping in the library.
+        album = sanitize(job.get("album") or "")
         if album:
-            out_dir = out_dir / album
+            out_dir = MEDIA_DIR / "Music" / album
+        else:
+            artist = sanitize(job.get("artist") or job.get("author") or "Unknown Artist")
+            out_dir = MEDIA_DIR / "Music" / f"{artist} - Singles"
 
     else:
         raise ValueError(f"Unknown media_type: {mtype!r}")
@@ -751,9 +757,17 @@ def archive():
     }
     save_job(jobs[job_id])
 
+    # Build the response snapshot BEFORE starting the worker. A very fast job
+    # (cached file, instant error) could otherwise run to completion between the
+    # thread start and the snapshot, returning a half-written or already-done
+    # state that races the client's own polling. Snapshotting first guarantees
+    # the response is a clean, consistent "queued" view; the subsequent progress
+    # and completion are delivered through normal polling.
+    response = {"job_id": job_id, **jobs_snapshot()}
+
     Thread(target=run_download, args=(job_id,), daemon=True).start()
     log.info(f"Job {job_id} queued: {url}")
-    return jsonify({"job_id": job_id, **jobs_snapshot()})
+    return jsonify(response)
 
 
 @app.route("/api/jobs")
@@ -806,4 +820,4 @@ def clear_finished_jobs():
 
 
 if __name__ == "__main__":
-    app.run(host="0.0.0.0", port=PORT, debug=False)
+    app.run(host="0.0.0.0", port=PORT, debug=False)

+ 38 - 6
web/templates/index.html

@@ -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='') {