mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-26 06:42:32 +00:00
Refactor folder_library preview to delegate to song_preview
Remove audio member resolution from folder_library and delegate hover-preview audio to the canonical song_preview plugin endpoint. This separates concerns: folder_library now only builds the preview URL with the filename, while song_preview handles manifest resolution (preview: key, stem fallback) and Range support. Changes: - Remove _audio_member() resolver and audio_member field from pack metadata - Update _previewUrl() to point to /api/plugins/song_preview/audio?file=<filename> - Add _previewMissing cache to avoid re-requesting packs with no preview - Add HEAD probe to check preview availability before playing - Add test coverage for _previewUrl with special character encoding Signed-off-by: Kyle <kyle.j.t@live.co.uk>
This commit is contained in:
parent
ad9ad229c9
commit
ecf559cd6d
@ -153,15 +153,14 @@ Each song object (built by `_meta()`):
|
||||
"added": 1748132400.0,
|
||||
"arrangements": ["Lead", "Rhythm", "Bass"],
|
||||
"stems": ["Drums", "Bass", "Vocals"],
|
||||
"lyrics": true,
|
||||
"audio_member": "preview.ogg"
|
||||
"lyrics": true
|
||||
}
|
||||
```
|
||||
|
||||
- `filename` is the full relative path from the DLC root — pass it directly to `window.playSong()`.
|
||||
- `added` is a Unix timestamp (float, seconds) from `stat().st_mtime` — convert with `new Date(added * 1000)`. Always recomputed fresh (it changes when a file moves), even on a metadata-cache hit.
|
||||
- `arrangements` / `stems` are flat lists of **strings**, even though `extract_meta()` returns them as objects.
|
||||
- `audio_member` is the best in-pack audio file for **hover-preview**, resolved by `_audio_member()` (`preview.ogg` → `stems/full.ogg` → `stems/audio.mp3` → any stem; `null` if the pack has no audio). Fetch it as `/api/sloppak/<filename>/file/<audio_member>` (both path-encoded per segment). Resolving it server-side means the frontend previews with **one** request instead of probing (and 404ing) a hardcoded path. Cached with the rest of the meta.
|
||||
- Hover-preview audio is **not** resolved here — it delegates to the `song_preview` plugin (see Preview on Hover), so the tree carries no audio member.
|
||||
|
||||
### extract_meta returns arrangements/stems as objects, not strings
|
||||
|
||||
@ -335,7 +334,7 @@ Drag-and-drop uses **pointer events** (mousedown/mousemove/mouseup), not the HTM
|
||||
|
||||
Hovering a song for `_HOVER_PREVIEW_DELAY_MS` (800 ms) plays a short audio preview in place — no navigation to the player. Toggled by a play-icon toolbar button (`_injectToolbar`); **on by default** (`_previewHover`, persisted per surface under `<cfg.storePrefix>previewHover` — only an explicit stored `'false'` disables it).
|
||||
|
||||
- **Audio** — a dedicated `_previewAudio` `<audio>` element (never the main player's). Source is `song.audio_member` (backend-resolved; see Song Metadata) fetched from `/api/sloppak/<file>/file/<member>`, played **from 0**. Don't seek by `song.duration` — the member is usually a short `preview.ogg` clip, so a full-song offset lands past its end and nothing plays.
|
||||
- **Audio** — a dedicated `_previewAudio` `<audio>` element (never the main player's), played **from 0**. The URL (`_previewUrl`) **delegates to the `song_preview` plugin** — `/api/plugins/song_preview/audio?file=<filename>` — the canonical preview subsystem. It resolves the clip **strictly** from the pack's manifest `preview:` key (a short baked clip) with Range support; there is **no stem fallback by design** (a full-length stem isn't a preview), so a pack without a baked preview — hand-authored packs, most tutorials — returns **404** and simply doesn't preview until `song_preview`'s backfill generates a clip. Don't resolve pack members here — reading the manifest is the spec-faithful way, guessing filenames (and playing a full-length stem as a "preview") is not. If `song_preview` isn't installed the endpoint 404s too, and either way `onerror` clears the indicator so preview no-ops. Don't seek by `song.duration` — the clip is short, so a full-song offset lands past its end and nothing plays.
|
||||
- **Sequence guard** — `_previewSeq` is bumped on every start/stop, so a slow load that resolves after the pointer has moved on is ignored.
|
||||
- **Drag / click safety** — `mouseenter` skips arming while a drag is in progress (`_dragState` non-null); `mousedown` clears the pending dwell timer **and** `_stopPreview()`s any already-playing one, so grabbing a song to drag (or clicking to play) never leaves a preview running.
|
||||
- **Indicator** — a waveform overlay (`.fl-wf`, 9 bars) over the art, drawn via a one-time injected `<style>` (`_ensurePreviewStyle`). It **fades in** (`fl-in`) to avoid an abrupt pop, and the bars animate only once audio actually fires (the `.playing` class, set on the `playing` event). Perf: bars animate with `transform: scaleY` + `will-change: transform` (GPU-composited, no per-frame JS), only while playing. `_showIndicator` / `_markIndicatorPlaying` / `_clearIndicator` manage it against `_previewIndHost`.
|
||||
|
||||
@ -11,7 +11,6 @@ from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
import shutil
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
|
||||
# ── Pure, testable helpers ─────────────────────────────────────────────────
|
||||
@ -115,37 +114,6 @@ 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:
|
||||
@ -168,8 +136,7 @@ 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,
|
||||
"audio_member": _audio_member(p)}
|
||||
"year": None, "tuning": None, "arrangements": [], "stems": [], "lyrics": False}
|
||||
try:
|
||||
raw = context["extract_meta"](p)
|
||||
if raw:
|
||||
|
||||
@ -742,14 +742,16 @@ function createFolderSurface(cfg) {
|
||||
// calls playSong or touches the main player's <audio> element.
|
||||
var _HOVER_PREVIEW_DELAY_MS = 800; // dwell before preview — long enough that a click/drag doesn't trigger it
|
||||
var _previewIndHost = null; // art element currently showing the indicator
|
||||
var _previewMissing = {}; // filenames song_preview has no preview for (don't re-request)
|
||||
|
||||
// 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.
|
||||
// Delegate to the song_preview plugin (the canonical preview subsystem): it
|
||||
// resolves the clip from the pack's manifest `preview:` key (falling back to
|
||||
// the default stem), serves it with Range support, and backfills a missing
|
||||
// preview.ogg. If song_preview isn't installed the endpoint 404s and the
|
||||
// audio's onerror clears the indicator — preview no-ops, nothing breaks.
|
||||
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;
|
||||
if (!song || !song.filename) return null;
|
||||
return '/api/plugins/song_preview/audio?file=' + encodeURIComponent(song.filename);
|
||||
}
|
||||
|
||||
function _ensurePreviewStyle() {
|
||||
@ -807,20 +809,24 @@ function createFolderSurface(cfg) {
|
||||
}
|
||||
}
|
||||
function _startPreview(song, host) {
|
||||
// Play from 0 — preview.ogg is already a short curated clip, and the
|
||||
// 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 url = _previewUrl(song);
|
||||
if (!url) return; // pack carries no previewable audio
|
||||
if (!url || _previewMissing[song.filename]) return; // no URL, or known no-preview → silent skip
|
||||
var a = _previewEl();
|
||||
var seq = ++_previewSeq;
|
||||
_showIndicator(host);
|
||||
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();
|
||||
// HEAD-probe first: song_preview 404s for packs with no baked preview, and
|
||||
// pointing <audio> at a 404 logs a console error (a fetch 404 is silent).
|
||||
// Remember misses so re-hovering the same song doesn't re-request. Play
|
||||
// from 0 — the clip is already short (don't seek by song.duration).
|
||||
fetch(url, { method: 'HEAD' }).then(function (res) {
|
||||
if (seq !== _previewSeq) return; // hover moved on before it resolved
|
||||
if (!res.ok) { _previewMissing[song.filename] = true; return; }
|
||||
_showIndicator(host);
|
||||
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();
|
||||
}).catch(function () {}); // network error → no-op, no noise
|
||||
}
|
||||
function _armHoverPreview(el, song, host) {
|
||||
el.addEventListener('mouseenter', function () {
|
||||
@ -1838,8 +1844,8 @@ function createFolderSurface(cfg) {
|
||||
init: _init,
|
||||
onScreenChanged: _onScreenChanged,
|
||||
render: _render,
|
||||
// Pure window arithmetic, exposed for tests (no DOM needed).
|
||||
__test: { visibleWindow: _visibleWindow, VIRTUAL_MIN: VIRTUAL_MIN, VIRTUAL_BUFFER: VIRTUAL_BUFFER },
|
||||
// Pure helpers, exposed for tests (no DOM needed).
|
||||
__test: { visibleWindow: _visibleWindow, VIRTUAL_MIN: VIRTUAL_MIN, VIRTUAL_BUFFER: VIRTUAL_BUFFER, previewUrl: _previewUrl },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
71
plugins/folder_library/tests/preview_url.test.js
Normal file
71
plugins/folder_library/tests/preview_url.test.js
Normal file
@ -0,0 +1,71 @@
|
||||
// Hover-preview audio URL (folder_library preview-on-hover).
|
||||
//
|
||||
// The preview delegates to the canonical `song_preview` plugin instead of
|
||||
// resolving pack audio itself: `song_preview` reads the pack's manifest
|
||||
// (`preview:` key -> default stem) and serves the clip with Range support.
|
||||
// So `_previewUrl` just has to build that endpoint's URL from the song's
|
||||
// filename — with the filename URL-encoded as a query value (a '/', '#', '&',
|
||||
// or space in a folder/song name must not break the query). Returns null when
|
||||
// there's nothing to build from.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function load() {
|
||||
const window = {
|
||||
console,
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
addEventListener() {},
|
||||
getElementById() { return null; },
|
||||
querySelector() { return null; },
|
||||
querySelectorAll() { return []; },
|
||||
createElement() { return { style: {}, classList: { add() {}, remove() {}, contains() { return false; } }, addEventListener() {}, appendChild() {} }; },
|
||||
},
|
||||
addEventListener() {},
|
||||
localStorage: { getItem() { return null; }, setItem() {} },
|
||||
performance: { now: () => 0 },
|
||||
setInterval() { return 0; },
|
||||
clearInterval() {},
|
||||
requestAnimationFrame() { return 0; },
|
||||
cancelAnimationFrame() {},
|
||||
getComputedStyle() { return { overflowY: 'visible', paddingTop: '0px', paddingBottom: '0px' }; },
|
||||
innerHeight: 800,
|
||||
encodeURIComponent, // the plugin encodes the filename query value with this
|
||||
};
|
||||
window.window = window;
|
||||
window.globalThis = window;
|
||||
const ctx = vm.createContext(window);
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'), ctx, { filename: 'screen.js' });
|
||||
assert.ok(window.folderLibrary && window.folderLibrary.__test, 'plugin must expose __test');
|
||||
return window.folderLibrary.__test;
|
||||
}
|
||||
|
||||
const { previewUrl } = load();
|
||||
|
||||
test('null when there is no filename to build from', () => {
|
||||
assert.equal(previewUrl(null), null);
|
||||
assert.equal(previewUrl({}), null);
|
||||
assert.equal(previewUrl({ filename: '' }), null);
|
||||
});
|
||||
|
||||
test('delegates to the song_preview endpoint with the filename as a query value', () => {
|
||||
const url = previewUrl({ filename: 'Test/song.sloppak' });
|
||||
assert.equal(url, '/api/plugins/song_preview/audio?file=Test%2Fsong.sloppak');
|
||||
});
|
||||
|
||||
test('encodes the whole filename (slashes, #, &, spaces) as one query value', () => {
|
||||
const url = previewUrl({ filename: 'AC#DC/Back & Forth.sloppak' });
|
||||
// As a query value, '/' is encoded too — song_preview decodes it back to the path.
|
||||
assert.equal(url, '/api/plugins/song_preview/audio?file=AC%23DC%2FBack%20%26%20Forth.sloppak');
|
||||
});
|
||||
|
||||
test('does not read a per-pack audio member (resolution is song_preview\'s job)', () => {
|
||||
// Even if a caller passes a stale audio_member, it must be ignored — the
|
||||
// folder plugin no longer resolves pack audio itself.
|
||||
const url = previewUrl({ filename: 'CH/Song.sloppak', audio_member: 'stems/guitar.ogg' });
|
||||
assert.equal(url, '/api/plugins/song_preview/audio?file=CH%2FSong.sloppak');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user