From 35c0d0ea0d7a28e8e8998c6375eccb44a11d42ae Mon Sep 17 00:00:00 2001 From: "K. O. A." Date: Sun, 19 Jul 2026 11:45:16 -0400 Subject: [PATCH] Pass per-stem name/description through to the stems payloads (#1013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feedpak 1.16.0 (spec §5.3) added two OPTIONAL presentational fields to a stems[] entry: `name` (display label, Readers fall back to the id) and `description` (free text). The server dropped both while normalizing manifest stems, so no client could ever display them. Pass them through at the one place stem descriptors are built (sloppak.load_song) and let both payload builders — the WS `ready` stems list and the REST `/api/song/{f}?stems=1` preload list, which are pinned against each other by test — carry them forward. Omit-when-absent, so a stem without the fields does not grow null keys; non-string or blank values are dropped rather than surfaced. No behaviour change for existing packs or clients: the fields are additive and every consumer that reads {id,url,default} keeps working unchanged. The stems plugin / stem mixer display work lands separately. Signed-off-by: topkoa Co-authored-by: Claude Fable 5 --- lib/routers/song.py | 3 ++- lib/routers/ws_highway.py | 4 +++- lib/sloppak.py | 12 ++++++++++-- tests/test_song_info_stems.py | 30 +++++++++++++++++++++++++++++- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/lib/routers/song.py b/lib/routers/song.py index aa6644e..13a49f8 100644 --- a/lib/routers/song.py +++ b/lib/routers/song.py @@ -868,7 +868,8 @@ def _playable_stems_payload(filename: str, dlc) -> dict: return { "stems": [ - {"id": s["id"], "url": _url(s["file"]), "default": s["default"]} + {"id": s["id"], "url": _url(s["file"]), "default": s["default"], + **{k: s[k] for k in ("name", "description") if k in s}} for s in loaded.stems ], "full_mix_url": _url(loaded.full_mix) if loaded.full_mix else None, diff --git a/lib/routers/ws_highway.py b/lib/routers/ws_highway.py index 62cd152..7950a6c 100644 --- a/lib/routers/ws_highway.py +++ b/lib/routers/ws_highway.py @@ -368,7 +368,9 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, q_fn = quote(filename, safe="") for s in loaded_slop.stems: url = f"/api/sloppak/{q_fn}/file/{quote(s['file'])}" - stems_payload.append({"id": s["id"], "url": url, "default": s["default"]}) + stems_payload.append( + {"id": s["id"], "url": url, "default": s["default"], + **{k: s[k] for k in ("name", "description") if k in s}}) # Full-mix URL (served by the same /api/sloppak/.../file/ endpoint). if loaded_slop is not None and loaded_slop.full_mix: full_mix_url = ( diff --git a/lib/sloppak.py b/lib/sloppak.py index 838882c..dd806c4 100644 --- a/lib/sloppak.py +++ b/lib/sloppak.py @@ -1114,11 +1114,19 @@ def load_song( sfile = str(s.get("file", "")) if not sid or not sfile: continue - stems.append({ + entry = { "id": sid, "file": sfile, "default": stem_default_on(s.get("default", True)), - }) + } + # Optional presentational fields (feedpak 1.16.0, spec §5.3). Omitted — + # not None — when absent, so payload builders can pass entries through + # without every stem growing null keys. + for key in ("name", "description"): + val = s.get(key) + if isinstance(val, str) and val.strip(): + entry[key] = val + stems.append(entry) # The complete mixdown is a stem (spec §5.3), but it is not a *layer*: lift # it out so that no consumer of `stems` — the mixer, the library's stem diff --git a/tests/test_song_info_stems.py b/tests/test_song_info_stems.py index dad4365..fa9c207 100644 --- a/tests/test_song_info_stems.py +++ b/tests/test_song_info_stems.py @@ -59,7 +59,8 @@ def _ws_payload(tmp_path, pak): return { "stems": [ {"id": s["id"], "url": f"/api/sloppak/{q}/file/{quote(s['file'])}", - "default": s["default"]} + "default": s["default"], + **{k: s[k] for k in ("name", "description") if k in s}} for s in loaded.stems ], "full_mix_url": f"/api/sloppak/{q}/file/{quote(loaded.full_mix)}" if loaded.full_mix else None, @@ -128,6 +129,33 @@ def test_rest_matches_the_ws_for_a_single_full_pack(tmp_path): assert rest["full_mix_url"] is None +def test_stem_name_and_description_pass_through(tmp_path): + """feedpak 1.16.0 per-stem `name`/`description` (spec §5.3) reach the payload. + + Presentational, so the rule is passthrough-or-omit: a stem that carries the + fields keeps them, a stem that doesn't must NOT grow null keys, and + non-string / blank values are dropped rather than surfaced. + """ + pak = _pak(tmp_path, [ + {"id": "guitar", "file": "stems/guitar.ogg", "name": "Rhythm Guitar"}, + {"id": "click", "file": "stems/click.ogg", "name": "Click", + "description": "Metronome click with 4-count lead-in.", "default": "off"}, + {"id": "bass", "file": "stems/bass.ogg"}, + {"id": "junk", "file": "stems/junk.ogg", "name": 7, "description": " "}, + ], name="Labelled.feedpak") + + rest = _payload(tmp_path, pak) + assert rest == _ws_payload(tmp_path, pak) + by_id = {s["id"]: s for s in rest["stems"]} + assert by_id["guitar"]["name"] == "Rhythm Guitar" + assert "description" not in by_id["guitar"] + assert by_id["click"]["name"] == "Click" + assert by_id["click"]["description"] == "Metronome click with 4-count lead-in." + assert by_id["click"]["default"] is False + assert "name" not in by_id["bass"] and "description" not in by_id["bass"] + assert "name" not in by_id["junk"] and "description" not in by_id["junk"] + + def test_a_broken_pack_yields_an_empty_list_not_an_error(tmp_path): # Preloading is an optimisation: an unreadable pack must fall back to the # normal WS-driven path, never break the song-info request.