From 9f8e1e6f617f32acd22b7c6dfd831f1289ad30ca Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 20 Jul 2026 20:08:58 -0400 Subject: [PATCH] Resolve in-pack audio on backend Move audio member selection logic from frontend to backend. The frontend was probing multiple candidate audio files with error/retry logic, often resulting in 404s. Now the backend resolves the best available audio file (preview.ogg, stems/full.ogg, or any stem) during metadata extraction and includes it in song metadata as `audio_member`. The frontend makes a single, guaranteed request instead of multiple probes. Simplifies the preview flow and improves reliability. Signed-off-by: Kyle --- plugins/folder_library/routes.py | 35 ++++++++++++++++++++++++++++- plugins/folder_library/screen.js | 38 ++++++++++++++------------------ 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/plugins/folder_library/routes.py b/plugins/folder_library/routes.py index 6163f9c..9e17efa 100644 --- a/plugins/folder_library/routes.py +++ b/plugins/folder_library/routes.py @@ -11,6 +11,7 @@ from fastapi import APIRouter, Request from fastapi.responses import JSONResponse import shutil import re +import zipfile # ── Pure, testable helpers ───────────────────────────────────────────────── @@ -114,6 +115,37 @@ def setup(app, context): sloppak = dlc / "sloppak" return sloppak if sloppak.exists() else dlc + def _audio_member(p: Path): + """Best in-pack audio file for hover-preview, as a pack-relative path. + + Prefers a dedicated preview clip, then a full mix, then any stem — so the + frontend can preview with a single request instead of probing (and 404ing) + a hardcoded path. Returns None when the pack carries no audio. + """ + prefer = ("preview.ogg", "stems/full.ogg", "stems/audio.mp3", + "stems/audio.ogg", "audio.mp3", "audio.ogg", "full.ogg") + audio_ext = (".ogg", ".mp3", ".opus", ".m4a", ".oga") + try: + if p.is_dir(): + names = ["/".join(f.relative_to(p).parts) for f in p.rglob("*") if f.is_file()] + else: + with zipfile.ZipFile(p) as z: + names = z.namelist() + except Exception: + return None + nameset = set(names) + for m in prefer: + if m in nameset: + return m + for n in names: # any stem audio + low = n.lower() + if low.startswith("stems/") and low.endswith(audio_ext): + return n + for n in names: # any audio at all + if n.lower().endswith(audio_ext): + return n + return None + def _meta(p: Path, dlc: Path) -> dict: # filename and added are always computed fresh — they change when files move. try: @@ -136,7 +168,8 @@ def setup(app, context): # Cache miss — run the expensive extract. m = {"title": None, "artist": None, "album": None, "duration": None, - "year": None, "tuning": None, "arrangements": [], "stems": [], "lyrics": False} + "year": None, "tuning": None, "arrangements": [], "stems": [], "lyrics": False, + "audio_member": _audio_member(p)} try: raw = context["extract_meta"](p) if raw: diff --git a/plugins/folder_library/screen.js b/plugins/folder_library/screen.js index e2e804d..9495c63 100644 --- a/plugins/folder_library/screen.js +++ b/plugins/folder_library/screen.js @@ -743,11 +743,13 @@ function createFolderSurface(cfg) { var _HOVER_PREVIEW_DELAY_MS = 500; var _previewIndHost = null; // art element currently showing the indicator - // Pack audio layout varies: a dedicated preview.ogg (most songs), a single - // stems/full.ogg mix, or tutorials' stems/audio.mp3 — try in that order. - function _previewCandidates(song) { - var base = '/api/sloppak/' + song.filename.split('/').map(encodeURIComponent).join('/') + '/file/'; - return [base + 'preview.ogg', base + 'stems/full.ogg', base + 'stems/audio.mp3']; + // The backend resolves the correct in-pack audio member (song.audio_member), + // so we preview with a single request — no probing / 404s. Null = no audio. + function _previewUrl(song) { + if (!song || !song.audio_member) return null; + var enc = song.filename.split('/').map(encodeURIComponent).join('/'); + var mem = song.audio_member.split('/').map(encodeURIComponent).join('/'); + return '/api/sloppak/' + enc + '/file/' + mem; } function _ensurePreviewStyle() { @@ -808,24 +810,16 @@ function createFolderSurface(cfg) { // full-track fallbacks are fine from the start. (Seeking by a fraction // of song.duration broke playback whenever the offset landed past the // end of a short preview clip.) - var urls = _previewCandidates(song); - var a = _previewEl(); - var seq = ++_previewSeq; - var idx = 0; + var url = _previewUrl(song); + if (!url) return; // pack carries no previewable audio + var a = _previewEl(); + var seq = ++_previewSeq; _showIndicator(host); - function _tryNext() { - if (seq !== _previewSeq) return; - if (idx >= urls.length) { _clearIndicator(); return; } // no playable member - a.src = urls[idx++]; - a.load(); - } - a.onerror = function () { if (seq === _previewSeq) _tryNext(); }; // candidate 404/decode → next - a.onloadedmetadata = function () { - if (seq !== _previewSeq) return; - a.play().catch(function () {}); - }; - a.onplaying = function () { if (seq === _previewSeq) _markIndicatorPlaying(); }; - _tryNext(); + a.onerror = function () { if (seq === _previewSeq) _clearIndicator(); }; + a.onloadedmetadata = function () { if (seq === _previewSeq) a.play().catch(function () {}); }; + a.onplaying = function () { if (seq === _previewSeq) _markIndicatorPlaying(); }; + a.src = url; + a.load(); } function _armHoverPreview(el, song, host) { el.addEventListener('mouseenter', function () {