From 5a0b62599dcd3b5020c5fc4b88e2e29214f3b9e2 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sun, 28 Jun 2026 22:08:44 +0200 Subject: [PATCH] feat(highway): show feedpak author/editor credits on song load (#629) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the feedpak manifest `authors` list (spec §5.4) on the highway: a credits card ("Charted by Azure") shown over the highway when a song loads, riding the count-in / a ~3s hold and dismissed when playback starts. Gated to fresh feedpak plays only (minigames, loose/archive, arrangement switches, seeks, replays excluded). Includes a 12s backstop so the overlay never lingers if playback fails to start. Closes #628. Reviewed by Codex (3 passes, converged). Verified locally: pytest 9/9, node --test 23/23, headless-browser end-to-end. --- server.py | 36 ++++++ static/app.js | 144 ++++++++++++++++++++++- static/highway.js | 7 ++ static/style.css | 90 +++++++++++++++ tests/js/song_credits_overlay.test.js | 133 +++++++++++++++++++++ tests/test_highway_ws_authors.py | 159 ++++++++++++++++++++++++++ 6 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 tests/js/song_credits_overlay.test.js create mode 100644 tests/test_highway_ws_authors.py diff --git a/server.py b/server.py index 6625b1b..0bd1791 100644 --- a/server.py +++ b/server.py @@ -2898,6 +2898,35 @@ def _sanitized_song_offset(song) -> float: return v if math.isfinite(v) else 0.0 +def _sanitize_authors(manifest: dict | None) -> list[dict]: + """Extract a display-safe contributor list from a feedpak manifest. + + The feedpak spec (§5.4) defines an OPTIONAL top-level `authors` list of + objects `{name (required), role?, email?, url?}`. We surface only `name` + and `role` to the highway — contact fields (email/url) are intentionally + dropped from the on-screen credits. Malformed entries (non-dict, missing / + blank name) are skipped; absent / non-list `authors` yields `[]`. + """ + if not isinstance(manifest, dict): + return [] + raw = manifest.get("authors") + if not isinstance(raw, list): + return [] + out: list[dict] = [] + for entry in raw: + if not isinstance(entry, dict): + continue + name = entry.get("name") + if not isinstance(name, str) or not name.strip(): + continue + role = entry.get("role") + out.append({ + "name": name.strip(), + "role": role.strip() if isinstance(role, str) and role.strip() else None, + }) + return out + + def _stat_for_cache(f: Path) -> tuple[float, int]: """Return (mtime, size) for cache freshness checks. @@ -7304,6 +7333,13 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, # and break the frontend's song_info parsing. "offset": _sanitized_song_offset(song) if is_loose else 0.0, "format": "sloppak" if is_slop else ("loose" if is_loose else "archive"), + # Feedpak contributor credits (manifest `authors:`, spec §5.4) — + # name + role only, shown on the highway when a song is loaded. + # Only sloppak/feedpak packs carry a manifest; loose/archive + # sources get []. The frontend uses a non-empty list as the gate + # for the credits overlay, so minigames / synthetic highway uses + # (no manifest) never trigger it. + "authors": _sanitize_authors(loaded_slop.manifest) if (is_slop and loaded_slop is not None) else [], "stems": stems_payload, # Full-mix audio (sloppak `original_audio:`) served alongside the # separate `stems`. The stems plugin plays this single file while diff --git a/static/app.js b/static/app.js index 64ca346..60031cd 100644 --- a/static/app.js +++ b/static/app.js @@ -5993,11 +5993,45 @@ let _pendingAutostart = false; window.feedBack.on('song:ready', () => { if (!_pendingAutostart) return; _pendingAutostart = false; - if (!_autoplayExitEnabled() || isPlaying) return; + if (isPlaying) return; + // Feedpak contributor credits: only real feedpak plays carry authors + // (loose/archive and minigames get []), so a non-empty list is the gate. + // Shown over the highway and dismissed the moment real playback begins + // (song:play). This fresh-load path is the only place it fires — + // arrangement switches / seeks / manual replays never arm _pendingAutostart, + // and minigames never get here. Decoupled from autoplay below so credits + // show on load even when autoplay-exit is disabled. + const authors = (window.feedBack.currentSong && window.feedBack.currentSong.authors) || []; + if (authors.length) { + showSongCreditsOverlay(authors); + _creditsHideOnPlay = () => { _creditsHideOnPlay = null; hideSongCreditsOverlay(); }; + window.feedBack.on('song:play', _creditsHideOnPlay, { once: true }); + } + // Autoplay-exit disabled: don't auto-start. Still let the credits dwell a + // couple seconds on the freshly-loaded song, then clear them (they also + // clear early if the user manually presses Play, via _creditsHideOnPlay). + if (!_autoplayExitEnabled()) { + if (authors.length) _creditsTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_HOLD_MS); + return; + } // "Countdown before song": play a 4-beat count-in, then start. Otherwise // reuse the Play button's start path directly (handles HTML5 + _juceMode). if (_countdownBeforeSongEnabled()) { + // The count-in (~2.5s) gives the credits their on-screen dwell. Promise.resolve(startSongCountIn()).catch((err) => console.warn('[app] song count-in failed:', err)); + } else if (authors.length) { + // No count-in window — hold the credits a couple seconds, then start. + // _cancelCountIn() and changeArrangement() both clear _creditsTimer, so + // a teardown / arrangement switch during the hold cancels this play. + _creditsTimer = setTimeout(() => { + _creditsTimer = null; + // If playback doesn't actually start (e.g. HTML5 autoplay rejection), + // song:play never fires — clear the credits promptly rather than + // waiting for the backstop. On success the song:play listener owns it. + Promise.resolve(togglePlay()) + .then(() => { if (!isPlaying) hideSongCreditsOverlay(); }) + .catch((err) => { console.warn('[app] autoplay failed:', err); hideSongCreditsOverlay(); }); + }, _CREDITS_HOLD_MS); } else { Promise.resolve(togglePlay()).catch((err) => console.warn('[app] autoplay failed:', err)); } @@ -6395,6 +6429,11 @@ let _arrBusyTimeout = null; async function changeArrangement(index) { if (currentFilename) { + // Tear down any pending fresh-load credits before switching: the + // no-count-in hold timer would otherwise fire togglePlay() against the + // incoming (still-loading) arrangement. hideSongCreditsOverlay() clears + // the timer, the song:play listener, and the overlay node. + hideSongCreditsOverlay(); window.feedBack.emit('song:arrangement-changed', { filename: currentFilename, arrangement: index }); const wasPlaying = isPlaying; const time = _audioTime(); @@ -9285,10 +9324,27 @@ let _countOverlay = null; let _countInGen = 0; let _countInTimer = null; let _countInRaf = 0; +// Feedpak credits overlay (manifest `authors:`, spec §5.4): shown on the +// highway when a song is loaded, alongside the count-in. Torn down together +// with the count-in via _cancelCountIn(). +let _creditsOverlay = null; +let _creditsTimer = null; +let _creditsHideOnPlay = null; +let _creditsMaxTimer = null; +const _CREDITS_HOLD_MS = 3000; +// Backstop: the overlay's primary dismiss is song:play, but playback can fail +// to start without emitting it (HTML5 autoplay rejection, JUCE start failure, +// a count-in handoff that never plays). This hard cap guarantees the credits +// never linger over the highway. Generous enough to outlast a normal count-in. +const _CREDITS_MAX_MS = 12000; function _cancelCountIn() { _countInGen++; _countingIn = false; hideCountOverlay(); + // The credits overlay rides the count-in lifecycle (and its no-count-in + // hold timer), so a teardown — leaving the player, loading another song — + // must clear it too, or it lingers on the next screen. + hideSongCreditsOverlay(); if (_countInTimer) { clearTimeout(_countInTimer); _countInTimer = null; } if (_countInRaf) { cancelAnimationFrame(_countInRaf); _countInRaf = 0; } } @@ -9306,6 +9362,92 @@ function hideCountOverlay() { if (_countOverlay) { _countOverlay.remove(); _countOverlay = null; } } +// Map a feedpak author `role` to a friendly " by" credit line. The +// recommended vocabulary is from feedpak spec §5.4; unknown roles are +// title-cased ("foo" → "Foo by"); a missing role shows the bare name. +const _CREDIT_ROLE_VERBS = { + charter: 'Charted by', + transcriber: 'Transcribed by', + arranger: 'Arranged by', + editor: 'Edited by', + mixer: 'Mixed by', + engineer: 'Engineered by', + proofreader: 'Proofread by', +}; + +function _creditLineLabel(role) { + if (!role) return ''; + const key = String(role).trim().toLowerCase(); + if (_CREDIT_ROLE_VERBS[key]) return _CREDIT_ROLE_VERBS[key]; + return key.charAt(0).toUpperCase() + key.slice(1) + ' by'; +} + +// Show the feedpak contributor credits over the highway. `authors` is the +// sanitized [{name, role}] list from window.feedBack.currentSong.authors. +// Anchored to the lower third (bottom-center) so it never collides with the +// vertically-centered count-in number, and pointer-events-none so it never +// intercepts clicks. No-op when there are no contributors to show. +function showSongCreditsOverlay(authors) { + if (!Array.isArray(authors) || authors.length === 0) return; + if (!_creditsOverlay) { + _creditsOverlay = document.createElement('div'); + _creditsOverlay.className = 'song-credits-overlay'; + document.body.appendChild(_creditsOverlay); + } + // Build via DOM + textContent — author names are untrusted pack data and + // must never be interpolated as HTML. + _creditsOverlay.replaceChildren(); + const card = document.createElement('div'); + card.className = 'song-credits-card'; + + const eyebrow = document.createElement('div'); + eyebrow.className = 'song-credits-eyebrow'; + eyebrow.textContent = 'Credits'; + card.appendChild(eyebrow); + + const title = (window.feedBack && window.feedBack.currentSong + && window.feedBack.currentSong.title) || ''; + if (title) { + const heading = document.createElement('div'); + heading.className = 'song-credits-heading'; + heading.textContent = title; + card.appendChild(heading); + } + + for (const a of authors) { + if (!a || !a.name) continue; + const row = document.createElement('div'); + row.className = 'song-credits-line'; + const label = _creditLineLabel(a.role); + if (label) { + const lab = document.createElement('span'); + lab.className = 'song-credits-role'; + lab.textContent = label + ' '; + row.appendChild(lab); + } + const nm = document.createElement('span'); + nm.className = 'song-credits-name'; + nm.textContent = a.name; + row.appendChild(nm); + card.appendChild(row); + } + _creditsOverlay.appendChild(card); + // Arm the backstop so the overlay self-clears even if playback never starts + // / never emits song:play. song:play (or any teardown) clears it earlier. + if (_creditsMaxTimer) clearTimeout(_creditsMaxTimer); + _creditsMaxTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_MAX_MS); +} + +function hideSongCreditsOverlay() { + if (_creditsTimer) { clearTimeout(_creditsTimer); _creditsTimer = null; } + if (_creditsMaxTimer) { clearTimeout(_creditsMaxTimer); _creditsMaxTimer = null; } + if (_creditsHideOnPlay) { + window.feedBack.off('song:play', _creditsHideOnPlay); + _creditsHideOnPlay = null; + } + if (_creditsOverlay) { _creditsOverlay.remove(); _creditsOverlay = null; } +} + async function startCountIn(opts = {}) { if (_countingIn) return; _countingIn = true; diff --git a/static/highway.js b/static/highway.js index 66231e9..58e5dbc 100644 --- a/static/highway.js +++ b/static/highway.js @@ -3530,6 +3530,13 @@ function createHighway() { // matchesArrangement on this rather than the // arrangement name. hasNotation: Boolean(msg.has_notation), + // Feedpak contributor credits (manifest + // `authors:`, spec §5.4): [{name, role}]. + // Only real feedpak plays carry these; loose/ + // archive sources and synthetic highway uses + // (minigames) get []. app.js shows a credits + // overlay on song load when this is non-empty. + authors: Array.isArray(msg.authors) ? msg.authors : [], }; window.feedBack.emit('song:loaded', window.feedBack.currentSong); } diff --git a/static/style.css b/static/style.css index d7903aa..951118c 100644 --- a/static/style.css +++ b/static/style.css @@ -863,3 +863,93 @@ html { scroll-behavior: smooth; } box-shadow: 0 0 0 2px rgba(64, 128, 224, 0.7); border-radius: 0.25rem; } + +/* Feedpak contributor credits shown over the highway when a song loads + (manifest `authors:`, spec §5.4). Anchored to the upper third so it sits + ABOVE the vertically-centered count-in number; click-through. */ +.song-credits-overlay { + position: fixed; + left: 0; + right: 0; + top: 15%; + /* Above the modal layer (z-[200], incl. the "Loading audio" backdrop) and + the count-in number (z-[100]) so the credits stay prominent through the + whole load → count-in → play window. */ + z-index: 205; + display: flex; + justify-content: center; + pointer-events: none; + animation: song-credits-fade-in 0.45s cubic-bezier(0.16, 1, 0.3, 1); +} + +.song-credits-card { + position: relative; + min-width: 16rem; + max-width: min(90vw, 34rem); + padding: 1.4rem 2.5rem 1.5rem; + text-align: center; + background: + radial-gradient(120% 140% at 50% 0%, rgb(56 78 130 / 0.45) 0%, transparent 60%), + linear-gradient(165deg, rgb(23 30 48 / 0.92) 0%, rgb(11 15 26 / 0.94) 100%); + border: 1px solid rgb(129 140 248 / 0.28); + border-radius: 1rem; + box-shadow: + 0 18px 50px rgb(0 0 0 / 0.55), + 0 0 0 1px rgb(0 0 0 / 0.35), + inset 0 1px 0 rgb(255 255 255 / 0.07); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +/* Accent bar across the top edge of the card. */ +.song-credits-card::before { + content: ""; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 3.25rem; + height: 3px; + border-radius: 0 0 3px 3px; + background: linear-gradient(90deg, #38bdf8, #818cf8); + box-shadow: 0 0 12px rgb(99 102 241 / 0.7); +} + +.song-credits-eyebrow { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.22em; + text-transform: uppercase; + color: rgb(165 180 252 / 0.9); + margin-bottom: 0.4rem; +} + +.song-credits-heading { + font-size: 1.3rem; + font-weight: 800; + color: #f8fafc; + margin-bottom: 0.7rem; + letter-spacing: 0.01em; + text-shadow: 0 1px 8px rgb(0 0 0 / 0.5); +} + +.song-credits-line { + font-size: 1.1rem; + line-height: 1.55; + color: #e2e8f0; +} + +.song-credits-role { + color: rgb(148 163 184 / 0.95); + font-weight: 500; +} + +.song-credits-name { + font-weight: 700; + color: #ffffff; +} + +@keyframes song-credits-fade-in { + from { opacity: 0; transform: translateY(-12px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} diff --git a/tests/js/song_credits_overlay.test.js b/tests/js/song_credits_overlay.test.js new file mode 100644 index 0000000..e08909d --- /dev/null +++ b/tests/js/song_credits_overlay.test.js @@ -0,0 +1,133 @@ +// Verify the feedpak credits overlay helpers in app.js: +// - _creditLineLabel() role → friendly " by" label +// - showSongCreditsOverlay() builds an XSS-safe card; no-op on empty list +// - hideSongCreditsOverlay() removes the overlay element +// +// Same isolation strategy as autoplay_exit.test.js — extract the functions +// from app.js by brace-matching and run them in a vm sandbox with a fake DOM. + +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'); + +const { extractFunction } = require('./test_utils'); + +const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js'); +const SRC = fs.readFileSync(APP_JS, 'utf8'); + +// Minimal fake DOM element: records className, children, and textContent. +// Setting textContent clears children (matching real DOM) so we can assert +// names were set via textContent (not innerHTML) — the XSS-safety contract. +function makeEl() { + return { + className: '', + children: [], + _text: '', + set textContent(v) { this._text = String(v); this.children = []; }, + get textContent() { return this._text; }, + appendChild(c) { this.children.push(c); return c; }, + replaceChildren() { this.children = []; }, + remove() { this.removed = true; }, + }; +} + +function allText(node) { + let s = node._text || ''; + for (const c of node.children) s += allText(c); + return s; +} + +function buildSandbox(currentSong) { + const body = makeEl(); + const sandbox = { + document: { body, createElement: () => makeEl() }, + window: { feedBack: { currentSong, off() {} } }, + setTimeout: () => 1, + clearTimeout: () => {}, + }; + vm.createContext(sandbox); + const preamble = ` + let _creditsOverlay = null; + let _creditsTimer = null; + let _creditsHideOnPlay = null; + let _creditsMaxTimer = null; + const _CREDITS_MAX_MS = 12000; + const _CREDIT_ROLE_VERBS = ${JSON.stringify({ + charter: 'Charted by', transcriber: 'Transcribed by', + arranger: 'Arranged by', editor: 'Edited by', mixer: 'Mixed by', + engineer: 'Engineered by', proofreader: 'Proofread by', + })}; + `; + vm.runInContext( + preamble + + extractFunction(SRC, 'function _creditLineLabel(') + '\n' + + extractFunction(SRC, 'function showSongCreditsOverlay(') + '\n' + + extractFunction(SRC, 'function hideSongCreditsOverlay(') + '\n' + + 'globalThis._creditLineLabel = _creditLineLabel;' + + 'globalThis.showSongCreditsOverlay = showSongCreditsOverlay;' + + 'globalThis.hideSongCreditsOverlay = hideSongCreditsOverlay;' + + 'globalThis._getOverlay = () => _creditsOverlay;', + sandbox, + ); + return sandbox; +} + +test('_creditLineLabel maps known roles, title-cases unknown, blanks empty', () => { + const s = buildSandbox({}); + assert.equal(s._creditLineLabel('charter'), 'Charted by'); + assert.equal(s._creditLineLabel('Editor'), 'Edited by'); // case-insensitive + assert.equal(s._creditLineLabel('mixer'), 'Mixed by'); + assert.equal(s._creditLineLabel('luthier'), 'Luthier by'); // unknown → title-cased + assert.equal(s._creditLineLabel(null), ''); // no role → bare name + assert.equal(s._creditLineLabel(''), ''); +}); + +test('showSongCreditsOverlay builds a card with heading + credit lines', () => { + const s = buildSandbox({ title: 'My Song' }); + s.showSongCreditsOverlay([ + { name: 'Azure', role: 'charter' }, + { name: 'Bob Lee', role: 'editor' }, + { name: 'Solo', role: null }, + ]); + const overlay = s._getOverlay(); + assert.ok(overlay, 'overlay created'); + assert.equal(overlay.className, 'song-credits-overlay'); + assert.equal(s.document.body.children.length, 1); + const text = allText(overlay); + assert.match(text, /My Song/); // heading is the song title + assert.match(text, /Charted by/); + assert.match(text, /Azure/); + assert.match(text, /Edited by/); + assert.match(text, /Bob Lee/); + assert.match(text, /Solo/); // role-less entry still shows the name +}); + +test('showSongCreditsOverlay sets names via textContent (XSS-safe)', () => { + const s = buildSandbox({ title: 'T' }); + s.showSongCreditsOverlay([{ name: '', role: 'charter' }]); + const overlay = s._getOverlay(); + // The raw string survives verbatim as text — proving it was never parsed + // as HTML (no innerHTML interpolation anywhere on the path). + assert.match(allText(overlay), //); +}); + +test('showSongCreditsOverlay is a no-op for empty / non-array input', () => { + const s = buildSandbox({ title: 'T' }); + s.showSongCreditsOverlay([]); + assert.equal(s._getOverlay(), null); + s.showSongCreditsOverlay(undefined); + assert.equal(s._getOverlay(), null); + assert.equal(s.document.body.children.length, 0); +}); + +test('hideSongCreditsOverlay removes the overlay', () => { + const s = buildSandbox({ title: 'T' }); + s.showSongCreditsOverlay([{ name: 'Azure', role: 'charter' }]); + const overlay = s._getOverlay(); + assert.ok(overlay); + s.hideSongCreditsOverlay(); + assert.equal(overlay.removed, true); + assert.equal(s._getOverlay(), null); +}); diff --git a/tests/test_highway_ws_authors.py b/tests/test_highway_ws_authors.py new file mode 100644 index 0000000..3de3f2c --- /dev/null +++ b/tests/test_highway_ws_authors.py @@ -0,0 +1,159 @@ +"""Tests for feedpak contributor credits on the highway. + +Covers the `_sanitize_authors` helper (unit) and the `song_info` WebSocket +frame carrying the manifest `authors` list end-to-end (integration). The +frontend uses a non-empty `authors` list to gate a credits overlay shown when +a song loads, so loose/archive/synthetic plays must surface `[]`. +""" + +from __future__ import annotations + +import importlib +import json +import sys + +import pytest +import yaml +from fastapi.testclient import TestClient + + +# ── _sanitize_authors unit tests ──────────────────────────────────────────── + + +@pytest.fixture() +def server_mod(monkeypatch, tmp_path): + monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc")) + (tmp_path / "dlc").mkdir() + sys.modules.pop("server", None) + mod = importlib.import_module("server") + yield mod + conn = getattr(getattr(mod, "meta_db", None), "conn", None) + if conn is not None: + conn.close() + + +def test_sanitize_authors_valid(server_mod): + out = server_mod._sanitize_authors( + { + "authors": [ + {"name": "Azure", "role": "charter", "email": "a@b.c", "url": "x"}, + {"name": "Bob Lee", "role": "editor"}, + {"name": "Solo"}, + ] + } + ) + # name + role only; email/url dropped; missing role → None. + assert out == [ + {"name": "Azure", "role": "charter"}, + {"name": "Bob Lee", "role": "editor"}, + {"name": "Solo", "role": None}, + ] + + +def test_sanitize_authors_skips_malformed(server_mod): + out = server_mod._sanitize_authors( + { + "authors": [ + {"name": ""}, # blank name → skipped + {"name": " "}, # whitespace name → skipped + {"role": "mixer"}, # no name → skipped + "not-a-dict", # non-dict → skipped + {"name": " Kept ", "role": " arranger "}, # trimmed + ] + } + ) + assert out == [{"name": "Kept", "role": "arranger"}] + + +@pytest.mark.parametrize("manifest", [None, {}, {"authors": None}, {"authors": "x"}, "nope"]) +def test_sanitize_authors_absent_or_nonlist(server_mod, manifest): + assert server_mod._sanitize_authors(manifest) == [] + + +# ── song_info WS integration ──────────────────────────────────────────────── + + +def _write_sloppak(dlc_root, *, authors): + pak = dlc_root / "authortest.sloppak" + pak.mkdir() + (pak / "arrangements").mkdir() + (pak / "arrangements" / "lead.json").write_text( + json.dumps( + { + "notes": [], + "chords": [], + "anchors": [], + "handshapes": [], + "templates": [], + "beats": [{"time": 0.0, "measure": 1}], + "sections": [{"name": "intro", "number": 1, "time": 0.0}], + } + ) + ) + manifest = { + "title": "Author Test", + "artist": "Tester", + "album": "", + "year": 2026, + "duration": 10.0, + "arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}], + "stems": [], + } + if authors is not None: + manifest["authors"] = authors + (pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False)) + return pak + + +@pytest.fixture() +def make_client(tmp_path, monkeypatch): + def _make(): + monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc")) + monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1") + sys.modules.pop("server", None) + server = importlib.import_module("server") + monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None) + monkeypatch.setattr(server, "startup_scan", lambda: None) + monkeypatch.setattr(server, "SLOPPAK_CACHE_DIR", tmp_path / "cache") + return server + + (tmp_path / "dlc").mkdir() + yield _make + server = sys.modules.get("server") + conn = getattr(getattr(server, "meta_db", None), "conn", None) + if conn is not None: + conn.close() + + +def _song_info(client, path): + with client.websocket_connect(path) as ws: + for _ in range(200): + msg = ws.receive_json() + if msg.get("error"): + raise AssertionError(f"WS error frame: {msg}") + if msg.get("type") == "song_info": + return msg + if msg.get("type") == "ready": + break + raise AssertionError("no song_info frame received") + + +def test_song_info_carries_authors(make_client): + server = make_client() + _write_sloppak( + server._get_dlc_dir(), + authors=[{"name": "Azure", "role": "charter", "email": "a@b.c"}], + ) + with TestClient(server.app) as client: + info = _song_info(client, "/ws/highway/authortest.sloppak?arrangement=0") + assert info["authors"] == [{"name": "Azure", "role": "charter"}] + + +def test_song_info_authors_empty_when_absent(make_client): + server = make_client() + _write_sloppak(server._get_dlc_dir(), authors=None) + with TestClient(server.app) as client: + info = _song_info(client, "/ws/highway/authortest.sloppak?arrangement=0") + assert info["authors"] == []