feat(highway): show feedpak author/editor credits on song load (#629)

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.
This commit is contained in:
Byron Gamatos
2026-06-28 22:08:44 +02:00
committed by GitHub
parent 271fedda55
commit 5a0b62599d
6 changed files with 568 additions and 1 deletions
+36
View File
@@ -2898,6 +2898,35 @@ def _sanitized_song_offset(song) -> float:
return v if math.isfinite(v) else 0.0 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]: def _stat_for_cache(f: Path) -> tuple[float, int]:
"""Return (mtime, size) for cache freshness checks. """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. # and break the frontend's song_info parsing.
"offset": _sanitized_song_offset(song) if is_loose else 0.0, "offset": _sanitized_song_offset(song) if is_loose else 0.0,
"format": "sloppak" if is_slop else ("loose" if is_loose else "archive"), "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, "stems": stems_payload,
# Full-mix audio (sloppak `original_audio:`) served alongside the # Full-mix audio (sloppak `original_audio:`) served alongside the
# separate `stems`. The stems plugin plays this single file while # separate `stems`. The stems plugin plays this single file while
+143 -1
View File
@@ -5993,11 +5993,45 @@ let _pendingAutostart = false;
window.feedBack.on('song:ready', () => { window.feedBack.on('song:ready', () => {
if (!_pendingAutostart) return; if (!_pendingAutostart) return;
_pendingAutostart = false; _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 // "Countdown before song": play a 4-beat count-in, then start. Otherwise
// reuse the Play button's start path directly (handles HTML5 + _juceMode). // reuse the Play button's start path directly (handles HTML5 + _juceMode).
if (_countdownBeforeSongEnabled()) { 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)); 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 { } else {
Promise.resolve(togglePlay()).catch((err) => console.warn('[app] autoplay failed:', err)); Promise.resolve(togglePlay()).catch((err) => console.warn('[app] autoplay failed:', err));
} }
@@ -6395,6 +6429,11 @@ let _arrBusyTimeout = null;
async function changeArrangement(index) { async function changeArrangement(index) {
if (currentFilename) { 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 }); window.feedBack.emit('song:arrangement-changed', { filename: currentFilename, arrangement: index });
const wasPlaying = isPlaying; const wasPlaying = isPlaying;
const time = _audioTime(); const time = _audioTime();
@@ -9285,10 +9324,27 @@ let _countOverlay = null;
let _countInGen = 0; let _countInGen = 0;
let _countInTimer = null; let _countInTimer = null;
let _countInRaf = 0; 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() { function _cancelCountIn() {
_countInGen++; _countInGen++;
_countingIn = false; _countingIn = false;
hideCountOverlay(); 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 (_countInTimer) { clearTimeout(_countInTimer); _countInTimer = null; }
if (_countInRaf) { cancelAnimationFrame(_countInRaf); _countInRaf = 0; } if (_countInRaf) { cancelAnimationFrame(_countInRaf); _countInRaf = 0; }
} }
@@ -9306,6 +9362,92 @@ function hideCountOverlay() {
if (_countOverlay) { _countOverlay.remove(); _countOverlay = null; } if (_countOverlay) { _countOverlay.remove(); _countOverlay = null; }
} }
// Map a feedpak author `role` to a friendly "<verb> 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 = {}) { async function startCountIn(opts = {}) {
if (_countingIn) return; if (_countingIn) return;
_countingIn = true; _countingIn = true;
+7
View File
@@ -3530,6 +3530,13 @@ function createHighway() {
// matchesArrangement on this rather than the // matchesArrangement on this rather than the
// arrangement name. // arrangement name.
hasNotation: Boolean(msg.has_notation), 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); window.feedBack.emit('song:loaded', window.feedBack.currentSong);
} }
+90
View File
@@ -863,3 +863,93 @@ html { scroll-behavior: smooth; }
box-shadow: 0 0 0 2px rgba(64, 128, 224, 0.7); box-shadow: 0 0 0 2px rgba(64, 128, 224, 0.7);
border-radius: 0.25rem; 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); }
}
+133
View File
@@ -0,0 +1,133 @@
// Verify the feedpak credits overlay helpers in app.js:
// - _creditLineLabel() role → friendly "<verb> 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: '<img src=x onerror=alert(1)>', 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), /<img src=x onerror=alert\(1\)>/);
});
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);
});
+159
View File
@@ -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"] == []