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 <kyle.j.t@live.co.uk>
This commit is contained in:
Kyle
2026-07-23 00:51:13 -04:00
parent 28bfa6ae0b
commit 9f8e1e6f61
2 changed files with 50 additions and 23 deletions
+34 -1
View File
@@ -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:
+16 -22
View File
@@ -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 () {