feat(audio): route feedpak full-mix natively under exclusive output (#824)

Song playback runs through the renderer, which WASAPI-exclusive (and
ASIO) output silences. Route single-mix feedpaks (stem-less
original_audio packs AND single-stem packs) onto the engine's backing
transport when the output device type is exclusive-style, and migrate
back to HTML5 when it isn't. Extends /api/audio-local-path to resolve
/api/sloppak/.../file/... URLs via the same containment guards as
serve_sloppak_file. Multi-stem packs stay on the WebAudio path
(Phase 2). Includes [feedpak-route] transition-gated diagnostics
logging.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
OmikronApex
2026-07-09 22:18:00 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 845255e404
commit 1b3178037b
5 changed files with 362 additions and 49 deletions
+54 -29
View File
@@ -12941,53 +12941,57 @@ _extract_cache = {} # filename -> (tmp_dir, song, timestamp)
_extract_cache_lock = threading.Lock() _extract_cache_lock = threading.Lock()
@app.get("/api/sloppak/{filename:path}/file/{rel_path:path}") def _resolve_sloppak_local_file(filename: str, rel_path: str):
def serve_sloppak_file(filename: str, rel_path: str): """Resolve a file inside a sloppak to its on-disk path.
"""Serve a file from inside a sloppak (stems, cover, etc.)."""
Applies the same containment guards as ``serve_sloppak_file``. Returns the
resolved ``Path`` on success, or an ``(error, status)`` tuple on failure so
callers can produce their endpoint-appropriate response.
"""
dlc = _get_dlc_dir() dlc = _get_dlc_dir()
if not dlc: if not dlc:
return JSONResponse({"error": "not configured"}, 404) return ("not configured", 404)
# `filename` is an attacker-controlled `:path` param. Contain it under # `filename` is caller-controlled. Contain it under DLC_DIR before it
# DLC_DIR before it reaches the resolver, which does a bare # reaches the resolver (see serve_sloppak_file for the traversal rationale).
# `dlc_root / filename`. Without this, `../../../etc` escapes the root
# and the rel_path guard below validates `target` against the already-
# escaped `src`, which trivially passes — yielding arbitrary file reads
# (e.g. /api/sloppak/../../../../etc/file/passwd). Mirrors the guard
# `get_song_art` applies to the same filename param.
resolved = _resolve_dlc_path(dlc, filename) resolved = _resolve_dlc_path(dlc, filename)
if resolved is None: if resolved is None:
return JSONResponse({"error": "forbidden"}, 403) return ("forbidden", 403)
# Confine the endpoint to actual sloppak bundles. Without this, a # Confine to actual sloppak bundles — otherwise any plain subdirectory
# contained-but-non-sloppak `filename` (e.g. `.` → DLC_DIR itself, or # would become a read-any-file-under-DLC_DIR source.
# any plain subdirectory) would make `resolve_source_dir` hand back a
# directory and turn this into a read-any-file-under-DLC_DIR endpoint.
# Mirrors get_song_art's `is_sloppak` dispatch.
if not sloppak_mod.is_sloppak(resolved): if not sloppak_mod.is_sloppak(resolved):
return JSONResponse({"error": "not found"}, 404) return ("not found", 404)
# Canonicalise the cache key against the resolved path so equivalent # Canonicalise the cache key against the resolved path so equivalent URL
# URL forms of the same sloppak (e.g. `A/../B/x.sloppak` vs # forms of the same sloppak converge on one _source_cache entry.
# `B/x.sloppak`) converge on one `_source_cache` entry instead of
# fragmenting / re-unpacking — mirrors get_song_info's keying.
try: try:
filename = resolved.relative_to(dlc.resolve()).as_posix() filename = resolved.relative_to(dlc.resolve()).as_posix()
except ValueError: except ValueError:
# safe_join already proved containment, so this is unreachable in # safe_join already proved containment; fail closed regardless.
# practice; fail closed rather than fall back to the raw param. return ("forbidden", 403)
return JSONResponse({"error": "forbidden"}, 403)
src = sloppak_mod.get_cached_source_dir(filename) src = sloppak_mod.get_cached_source_dir(filename)
if src is None: if src is None:
try: try:
src = sloppak_mod.resolve_source_dir(filename, dlc, SLOPPAK_CACHE_DIR) src = sloppak_mod.resolve_source_dir(filename, dlc, SLOPPAK_CACHE_DIR)
except Exception: except Exception:
return JSONResponse({"error": "not found"}, 404) return ("not found", 404)
# Prevent path traversal within the sloppak. # Prevent path traversal within the sloppak.
target = (src / rel_path).resolve() target = (src / rel_path).resolve()
try: try:
target.relative_to(src.resolve()) target.relative_to(src.resolve())
except ValueError: except ValueError:
return JSONResponse({"error": "forbidden"}, 403) return ("forbidden", 403)
if not target.exists() or not target.is_file(): if not target.exists() or not target.is_file():
return JSONResponse({"error": "not found"}, 404) return ("not found", 404)
return target
@app.get("/api/sloppak/{filename:path}/file/{rel_path:path}")
def serve_sloppak_file(filename: str, rel_path: str):
"""Serve a file from inside a sloppak (stems, cover, etc.)."""
result = _resolve_sloppak_local_file(filename, rel_path)
if isinstance(result, tuple):
error, status = result
return JSONResponse({"error": error}, status)
target = result
ext = target.suffix.lower() ext = target.suffix.lower()
mt = { mt = {
".ogg": "audio/ogg", ".opus": "audio/ogg", ".oga": "audio/ogg", ".ogg": "audio/ogg", ".opus": "audio/ogg", ".oga": "audio/ogg",
@@ -13910,13 +13914,19 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
@app.get("/api/audio-local-path") @app.get("/api/audio-local-path")
def audio_local_path(url: str, request: Request): def audio_local_path(url: str, request: Request):
"""Return absolute local filesystem path for an /audio/… URL (Electron desktop only). """Return absolute local filesystem path for a song URL (Electron desktop only).
Accepts ``/audio/<path>`` where ``<path>`` may include subdirectory segments Accepts ``/audio/<path>`` where ``<path>`` may include subdirectory segments
no scheme, no host, no query string, no fragment. The resolved path must stay no scheme, no host, no query string, no fragment. The resolved path must stay
inside AUDIO_CACHE_DIR or STATIC_DIR; ``..`` traversal, backslashes, and inside AUDIO_CACHE_DIR or STATIC_DIR; ``..`` traversal, backslashes, and
absolute ``filename`` values are rejected. absolute ``filename`` values are rejected.
Also accepts ``/api/sloppak/<filename>/file/<rel>`` (percent-encoded, as
emitted by the highway song payload) and resolves it to the unpacked
sloppak cache file via the same containment guards as
``serve_sloppak_file`` this lets the desktop engine play a feedpak
full-mix natively under WASAPI-exclusive output.
This endpoint returns a raw filesystem path and is intended exclusively for This endpoint returns a raw filesystem path and is intended exclusively for
the Electron desktop process (which runs on loopback). Requests from non- the Electron desktop process (which runs on loopback). Requests from non-
loopback clients are rejected with 403. loopback clients are rejected with 403.
@@ -13929,6 +13939,21 @@ def audio_local_path(url: str, request: Request):
is_loopback = client_host == "localhost" is_loopback = client_host == "localhost"
if not is_loopback: if not is_loopback:
return JSONResponse({"error": "forbidden"}, status_code=403) return JSONResponse({"error": "forbidden"}, status_code=403)
# Sloppak in-pack file (feedpak full-mix): /api/sloppak/<fn>/file/<rel>.
# Both segments arrive percent-encoded (built with urllib quote() in the
# highway payload); decode before handing to the shared resolver, which
# re-applies all containment guards on the decoded values.
slop_match = re.fullmatch(r"/api/sloppak/([^?#]+)/file/([^?#]+)", url)
if slop_match:
from urllib.parse import unquote
result = _resolve_sloppak_local_file(
unquote(slop_match.group(1)), unquote(slop_match.group(2))
)
if isinstance(result, tuple):
error, status = result
return JSONResponse({"error": error}, status_code=status)
return JSONResponse({"path": str(result)})
# Accept only simple /audio/<filename> — no scheme, no host, no query/fragment # Accept only simple /audio/<filename> — no scheme, no host, no query/fragment
if not re.fullmatch(r"/audio/[^?#]+", url): if not re.fullmatch(r"/audio/[^?#]+", url):
return JSONResponse({"error": "invalid url"}, status_code=400) return JSONResponse({"error": "invalid url"}, status_code=400)
+77 -10
View File
@@ -4864,6 +4864,47 @@ window.jucePlayer = jucePlayer;
// (a network blip on /api/audio-local-path, an isAudioRunning() race // (a network blip on /api/audio-local-path, an isAudioRunning() race
// during a device restart) are deliberately NOT memoised so they retry. // during a device restart) are deliberately NOT memoised so they retry.
let _rerouteRejectedUrl = null; let _rerouteRejectedUrl = null;
// Exclusive-style output backends silence every other client on the
// endpoint — including our own <audio> element. The share mode IS the
// JUCE output device type: "Windows Audio (Exclusive Mode)" is a
// hardcoded, unlocalised JUCE type name; ASIO drivers typically hold
// the endpoint exclusively too. "Windows Audio (Low Latency Mode)" is
// shared and must NOT match.
function _isExclusiveOutputType(t) {
return t === 'Windows Audio (Exclusive Mode)' || t === 'ASIO';
}
// [feedpak-route] diagnostics: log the raw outputType string once per
// value change (this runs on a 350ms poll — logging every tick would
// flood the diagnostics buffer).
let _loggedOutputType;
async function _outputIsExclusive() {
if (typeof juceApi.getCurrentDevice !== 'function') {
if (_loggedOutputType !== '<no-getCurrentDevice>') {
_loggedOutputType = '<no-getCurrentDevice>';
console.warn('[feedpak-route] juceApi.getCurrentDevice missing — cannot detect exclusive output');
}
return false;
}
try {
const dev = await juceApi.getCurrentDevice();
const t = dev?.outputType || dev?.type || '';
const excl = _isExclusiveOutputType(t);
if (t !== _loggedOutputType) {
_loggedOutputType = t;
console.log('[feedpak-route] outputType=', JSON.stringify(t), '→ exclusive=', excl);
}
return excl;
} catch (e) {
if (_loggedOutputType !== '<getCurrentDevice-failed>') {
_loggedOutputType = '<getCurrentDevice-failed>';
console.warn('[feedpak-route] getCurrentDevice failed:', e);
}
return false;
}
}
// highway.js's initial song-load routing consults this for the same
// feedpak-under-exclusive decision the watcher makes below.
window._juceOutputIsExclusive = _outputIsExclusive;
// Returns true when window._currentSongAudio no longer references the exact // Returns true when window._currentSongAudio no longer references the exact
// snapshot object captured at reroute entry — i.e. the song was swapped (or // snapshot object captured at reroute entry — i.e. the song was swapped (or
// cleared) mid-flight. Staleness is detected by object-reference identity, // cleared) mid-flight. Staleness is detected by object-reference identity,
@@ -4904,8 +4945,12 @@ window.jucePlayer = jucePlayer;
audio.pause(); audio.pause();
try { try {
const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(url)}`); const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(url)}`);
if (!res.ok) throw new Error('HTTP ' + res.status); if (!res.ok) {
console.warn('[feedpak-route] audio-local-path HTTP', res.status, 'for', url);
throw new Error('HTTP ' + res.status);
}
const { path } = await res.json(); const { path } = await res.json();
console.log('[feedpak-route] audio-local-path resolved:', (typeof path === 'string' && path.split(/[\\/]/).pop()) || '<missing>');
if (_isStale(songAudio)) return 'stale'; // song changed mid-fetch if (_isStale(songAudio)) return 'stale'; // song changed mid-fetch
const ok = await juceApi.loadBackingTrack(path); const ok = await juceApi.loadBackingTrack(path);
if (ok === false) { if (ok === false) {
@@ -5123,8 +5168,12 @@ window.jucePlayer = jucePlayer;
async function _reevaluateJuceRouting() { async function _reevaluateJuceRouting() {
if (_rerouteInFlight) return; if (_rerouteInFlight) return;
const songAudio = window._currentSongAudio; const songAudio = window._currentSongAudio;
// Only /audio/ songs are JUCE-routable; sloppak stems stay on HTML5. // /audio/ songs are always JUCE-routable. A feedpak full-mix
if (!songAudio || !songAudio.juceEligible) return; // (single-mix pack, no stems) is routable ONLY under an
// exclusive-style output — in shared mode it must stay on HTML5 so
// the stem mixer / WebAudio path keeps working. Sloppak stem URLs
// are never routable (per-stem mix can't ride a single transport).
if (!songAudio || (!songAudio.juceEligible && !songAudio.feedpakFullMix)) return;
// Don't race highway.js's own initial song-load routing: it owns // Don't race highway.js's own initial song-load routing: it owns
// _juceMode until _juceRoutingPromise settles. Re-running our switch // _juceMode until _juceRoutingPromise settles. Re-running our switch
// concurrently would double-call loadBackingTrack for the same URL. // concurrently would double-call loadBackingTrack for the same URL.
@@ -5142,13 +5191,30 @@ window.jucePlayer = jucePlayer;
try { running = await juceApi.isAudioRunning(); } try { running = await juceApi.isAudioRunning(); }
catch (_) { return; } catch (_) { return; }
if (_isStale(songAudio)) return; // song changed during IPC if (_isStale(songAudio)) return; // song changed during IPC
if (!!running === !!window._juceMode) return; // routing already consistent // Eligibility is evaluated per tick, not snapshotted at song load:
// the output share mode can change mid-song (device switch in the
const wantJuce = running && !window._juceMode; // Audio Engine panel), and a feedpak full-mix must follow it —
// exclusive → ride the engine; back to shared → return to HTML5.
let eligible = !!songAudio.juceEligible;
if (!eligible && songAudio.feedpakFullMix && running) {
eligible = await _outputIsExclusive();
if (_isStale(songAudio)) return; // song changed during IPC
}
const wantJuce = !!(running && eligible);
// [feedpak-route] diagnostics: one line per decision change (the
// watcher polls at 350ms; steady state must not spam the buffer).
const _decision = 'running=' + running + ' eligible=' + eligible
+ ' feedpakFullMix=' + !!songAudio.feedpakFullMix
+ ' juceMode=' + !!window._juceMode + ' url=' + songAudio.url;
if (_decision !== window._lastFeedpakRouteDecision) {
window._lastFeedpakRouteDecision = _decision;
console.log('[feedpak-route] watcher:', _decision);
}
if (wantJuce === !!window._juceMode) return; // routing already consistent
// Don't keep retrying a track JUCE explicitly rejected. // Don't keep retrying a track JUCE explicitly rejected.
if (wantJuce && songAudio.url === _rerouteRejectedUrl) return; if (wantJuce && songAudio.url === _rerouteRejectedUrl) return;
if (running) { if (wantJuce) {
const outcome = await _switchHtml5ToJuce(songAudio); const outcome = await _switchHtml5ToJuce(songAudio);
// Memoise ONLY an explicit hard JUCE reject. A successful // Memoise ONLY an explicit hard JUCE reject. A successful
// switch clears the memo; a 'stale' abort (song changed // switch clears the memo; a 'stale' abort (song changed
@@ -5163,9 +5229,10 @@ window.jucePlayer = jucePlayer;
// outcome === 'stale': leave _rerouteRejectedUrl as-is. // outcome === 'stale': leave _rerouteRejectedUrl as-is.
} else { } else {
await _switchJuceToHtml5(songAudio); await _switchJuceToHtml5(songAudio);
// The engine just stopped. Clear any hard-reject memo so a // The engine stopped (or a feedpak's output left exclusive
// later engine restart re-evaluates the track at least once — // mode). Clear any hard-reject memo so a later engine restart
// the rejection may have been a transient device/decoder state. // or mode change re-evaluates the track at least once — the
// rejection may have been a transient device/decoder state.
_rerouteRejectedUrl = null; _rerouteRejectedUrl = null;
} }
} catch (e) { } catch (e) {
+58 -5
View File
@@ -3409,21 +3409,57 @@ function createHighway() {
if (msg.audio_url) { if (msg.audio_url) {
const audio = document.getElementById('audio'); const audio = document.getElementById('audio');
const audioFilename = msg.audio_url.split('/').pop(); const audioFilename = msg.audio_url.split('/').pop();
// Only attempt JUCE routing for /audio/ URLs — sloppak stems // /audio/ URLs are always JUCE-routable. A feedpak full-mix
// (/api/sloppak/…) are not resolvable via audio-local-path. // (single-mix pack: original audio, no stems) is routable too,
// but ONLY under an exclusive-style output — the actual
// exclusive check happens at routing time (app.js watcher /
// the async block below), not here, because the share mode
// can change while the song is loaded. Sloppak stem URLs are
// never routable.
const isAudioUrl = msg.audio_url.startsWith('/audio/'); const isAudioUrl = msg.audio_url.startsWith('/audio/');
// "Full mix" covers BOTH single-mix pack shapes:
// - stem-less packs (original_audio: in the manifest,
// audio_url == original_audio_url), and
// - single-stem packs (stems: [full.ogg] only) — the server
// puts the full mix in the stems list, has_original_audio
// is false, and audio_url points at the one stem. With one
// stem there is no per-stem mix to preserve, so routing it
// natively loses nothing. Real multi-stem (>1) stays out
// until Phase 2.
const isFeedpakFullMix = !isAudioUrl
&& msg.audio_url.startsWith('/api/sloppak/')
&& ((!!msg.has_original_audio && !msg.has_stems)
|| (msg.stems || []).length === 1);
// Record the loaded song's audio so app.js can re-route it // Record the loaded song's audio so app.js can re-route it
// between the HTML5 and JUCE paths if the audio engine is // between the HTML5 and JUCE paths if the audio engine is
// started/stopped after the song is already loaded. Set this // started/stopped after the song is already loaded. Set this
// unconditionally (not just on reload): when alreadyLoaded is // unconditionally (not just on reload): when alreadyLoaded is
// true the watcher must still see correct, current metadata. // true the watcher must still see correct, current metadata.
window._currentSongAudio = { url: msg.audio_url, juceEligible: isAudioUrl }; window._currentSongAudio = {
url: msg.audio_url,
juceEligible: isAudioUrl,
feedpakFullMix: isFeedpakFullMix,
};
const alreadyLoaded = window._juceMode const alreadyLoaded = window._juceMode
? window._juceAudioUrl === msg.audio_url ? window._juceAudioUrl === msg.audio_url
: (audio.src && audio.src.includes(audioFilename)); : (audio.src && audio.src.includes(audioFilename));
// [feedpak-route] diagnostics: every eligibility input in one
// line — shows up in the exported diagnostics bundle. If
// has_stems is true the pack is multi-stem and Phase 1
// deliberately does not route it (Phase 2 work).
console.log('[feedpak-route] song-load:',
'url=', msg.audio_url,
'isAudioUrl=', isAudioUrl,
'isFeedpakFullMix=', isFeedpakFullMix,
'has_stems=', !!msg.has_stems,
'stems=', (msg.stems || []).length,
'has_original_audio=', !!msg.has_original_audio,
'format=', msg.format,
'alreadyLoaded=', alreadyLoaded,
'juceApi=', !!window.feedBackDesktop?.audio);
if (!alreadyLoaded) { if (!alreadyLoaded) {
const juceApi = window.feedBackDesktop?.audio; const juceApi = window.feedBackDesktop?.audio;
if (isAudioUrl && juceApi) { if ((isAudioUrl || isFeedpakFullMix) && juceApi) {
// Run JUCE routing off the critical message-processing chain // Run JUCE routing off the critical message-processing chain
// so subsequent notes/chords/ready messages aren't blocked // so subsequent notes/chords/ready messages aren't blocked
// waiting for IPC + HTTP round-trips. The 'ready' handler // waiting for IPC + HTTP round-trips. The 'ready' handler
@@ -3466,7 +3502,24 @@ function createHighway() {
clearTimeout(barrierTimer); clearTimeout(barrierTimer);
if (gen !== _wsGen) return; // navigated away during the wait if (gen !== _wsGen) return; // navigated away during the wait
} }
if (await juceApi.isAudioRunning()) { // Feedpak full-mix rides the engine ONLY under an
// exclusive-style output (shared mode falls through to
// the HTML5 fallback below, keeping the WebAudio path
// fully working). /audio/ songs route whenever the
// engine runs, as before. If the share mode changes
// later, the app.js watcher re-evaluates and migrates.
let routeToJuce = await juceApi.isAudioRunning();
console.log('[feedpak-route] initial-load: engineRunning=', routeToJuce);
if (routeToJuce) {
if (gen !== _wsGen) return; // stale
if (isFeedpakFullMix) {
const exclFn = window._juceOutputIsExclusive;
routeToJuce = !!(await exclFn?.());
console.log('[feedpak-route] initial-load: feedpak exclusive check →',
routeToJuce, '(predicate installed=', typeof exclFn === 'function', ')');
}
}
if (routeToJuce) {
if (gen !== _wsGen) return; // stale if (gen !== _wsGen) return; // stale
const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(audioUrl)}`); const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(audioUrl)}`);
if (!res.ok) throw new Error('HTTP ' + res.status); if (!res.ok) throw new Error('HTTP ' + res.status);
+83 -1
View File
@@ -40,7 +40,7 @@ function extractWatcherIIFE(src) {
// Build a sandbox with fakes and run the watcher IIFE inside it. Returns the // Build a sandbox with fakes and run the watcher IIFE inside it. Returns the
// sandbox so tests can drive window._reevaluateJuceRouting and inspect state. // sandbox so tests can drive window._reevaluateJuceRouting and inspect state.
function makeSandbox({ isAudioRunning, loadBackingTrack }) { function makeSandbox({ isAudioRunning, loadBackingTrack, outputType = 'Windows Audio' }) {
const calls = { loadBackingTrack: [], jucePlay: 0, jucePause: 0, audioPlay: 0 }; const calls = { loadBackingTrack: [], jucePlay: 0, jucePause: 0, audioPlay: 0 };
const audio = { const audio = {
@@ -65,6 +65,7 @@ function makeSandbox({ isAudioRunning, loadBackingTrack }) {
const juceApi = { const juceApi = {
isAudioRunning: () => Promise.resolve(isAudioRunning()), isAudioRunning: () => Promise.resolve(isAudioRunning()),
loadBackingTrack: (p) => { calls.loadBackingTrack.push(p); return Promise.resolve(loadBackingTrack()); }, loadBackingTrack: (p) => { calls.loadBackingTrack.push(p); return Promise.resolve(loadBackingTrack()); },
getCurrentDevice: () => Promise.resolve({ outputType: typeof outputType === 'function' ? outputType() : outputType }),
getBackingDuration: () => Promise.resolve(180), getBackingDuration: () => Promise.resolve(180),
seekBacking: () => Promise.resolve(), seekBacking: () => Promise.resolve(),
startBacking: () => Promise.resolve(), startBacking: () => Promise.resolve(),
@@ -152,6 +153,87 @@ test('non-JUCE-eligible song (sloppak stems) is never rerouted', async () => {
assert.equal(sb.__calls.loadBackingTrack.length, 0); assert.equal(sb.__calls.loadBackingTrack.length, 0);
}); });
test('feedpak full-mix + exclusive output → migrates to JUCE', async () => {
const sb = makeSandbox({
isAudioRunning: () => true,
loadBackingTrack: () => true,
outputType: 'Windows Audio (Exclusive Mode)',
});
sb.window._juceMode = false;
sb.window._currentSongAudio = {
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
juceEligible: false,
feedpakFullMix: true,
};
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, true, 'feedpak full-mix rides the engine under exclusive output');
assert.equal(sb.__calls.loadBackingTrack.length, 1);
});
test('feedpak full-mix + ASIO output → migrates to JUCE', async () => {
const sb = makeSandbox({
isAudioRunning: () => true,
loadBackingTrack: () => true,
outputType: 'ASIO',
});
sb.window._juceMode = false;
sb.window._currentSongAudio = {
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
juceEligible: false,
feedpakFullMix: true,
};
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, true, 'ASIO is exclusive-style; feedpak rides the engine');
});
test('feedpak full-mix + shared output → stays on HTML5 (stem mixer untouched)', async () => {
for (const shared of ['Windows Audio', 'Windows Audio (Low Latency Mode)', 'DirectSound']) {
const sb = makeSandbox({
isAudioRunning: () => true,
loadBackingTrack: () => true,
outputType: shared,
});
sb.window._juceMode = false;
sb.window._currentSongAudio = {
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
juceEligible: false,
feedpakFullMix: true,
};
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, false, `stays on HTML5 for shared type "${shared}"`);
assert.equal(sb.__calls.loadBackingTrack.length, 0);
}
});
test('feedpak on JUCE + output leaves exclusive mode → migrates back to HTML5', async () => {
let type = 'Windows Audio (Exclusive Mode)';
const sb = makeSandbox({
isAudioRunning: () => true,
loadBackingTrack: () => true,
outputType: () => type,
});
const url = '/api/sloppak/song.sloppak/file/stems/full.ogg';
sb.window._juceMode = true;
sb.window._juceAudioUrl = url;
sb.window._currentSongAudio = { url, juceEligible: false, feedpakFullMix: true };
// Still exclusive: routing is consistent, no switch.
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, true, 'consistent while exclusive');
// Device switched to shared mid-song: must return to HTML5.
type = 'Windows Audio';
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, false, 'returned to HTML5 after leaving exclusive mode');
assert.equal(sb.audio.src, url, 'HTML5 element re-pointed at the song');
});
test('JUCE hard-reject is memoised → not retried on the next poll', async () => { test('JUCE hard-reject is memoised → not retried on the next poll', async () => {
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => false }); const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => false });
sb.window._juceMode = false; sb.window._juceMode = false;
+90 -4
View File
@@ -103,12 +103,98 @@ def test_returns_404_for_nonexistent_file(client_and_server):
assert "error" in r.json() assert "error" in r.json()
# ── rejected non-/audio/ inputs ─────────────────────────────────────────────── # ── sloppak URLs (feedpak full-mix, desktop exclusive-mode routing) ──────────
def test_rejects_sloppak_url(client_and_server): @pytest.fixture()
def dlc_client(tmp_path, monkeypatch):
"""Loopback TestClient with a temp DLC_DIR for sloppak resolution."""
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "cfg"))
monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1")
sys.modules.pop("server", None)
server = importlib.import_module("server")
# Module-level sloppak source-dir cache survives re-import; clear it so a
# prior test's filename key can't shadow this test's temp DLC_DIR.
server.sloppak_mod._source_cache.clear()
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
monkeypatch.setattr(server, "startup_scan", lambda: None)
static_tmp = tmp_path / "static"
static_tmp.mkdir()
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
tc = TestClient(server.app, client=("127.0.0.1", 50000))
try:
yield tc, server, dlc
finally:
tc.close()
meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
def _make_sloppak(dlc, name="song.sloppak"):
"""Create a minimal directory-form sloppak with a full-mix file."""
pak = dlc / name
(pak / "stems").mkdir(parents=True)
(pak / "stems" / "full.ogg").write_bytes(b"OggS-fake")
return pak
def test_sloppak_url_resolves_to_local_path(dlc_client):
tc, _server, dlc = dlc_client
pak = _make_sloppak(dlc)
r = tc.get(
"/api/audio-local-path",
params={"url": "/api/sloppak/song.sloppak/file/stems/full.ogg"},
)
assert r.status_code == 200, r.text
assert r.json()["path"] == str((pak / "stems" / "full.ogg").resolve())
def test_sloppak_url_percent_encoded_segments_decode(dlc_client):
tc, _server, dlc = dlc_client
_make_sloppak(dlc, name="My Song.sloppak")
r = tc.get(
"/api/audio-local-path",
params={"url": "/api/sloppak/My%20Song.sloppak/file/stems/full.ogg"},
)
assert r.status_code == 200, r.text
def test_sloppak_url_rel_traversal_is_403(dlc_client):
tc, _server, dlc = dlc_client
_make_sloppak(dlc)
(dlc / "secret.txt").write_text("top secret")
r = tc.get(
"/api/audio-local-path",
params={"url": "/api/sloppak/song.sloppak/file/..%2Fsecret.txt"},
)
assert r.status_code == 403, r.text
def test_sloppak_url_filename_traversal_is_403(dlc_client):
tc, _server, _dlc = dlc_client
r = tc.get(
"/api/audio-local-path",
params={"url": "/api/sloppak/..%2F..%2F..%2Fetc/file/passwd"},
)
assert r.status_code == 403, r.text
def test_sloppak_url_without_dlc_configured_is_404(client_and_server):
"""No DLC_DIR in the base fixture — resolver reports 'not configured'."""
client, _ = client_and_server client, _ = client_and_server
r = client.get("/api/audio-local-path", params={"url": "/api/sloppak/mysong/file/stems/full.ogg"}) r = client.get(
assert r.status_code == 400 "/api/audio-local-path",
params={"url": "/api/sloppak/mysong/file/stems/full.ogg"},
)
assert r.status_code == 404
# ── rejected non-/audio/ inputs ───────────────────────────────────────────────
def test_rejects_empty_url(client_and_server): def test_rejects_empty_url(client_and_server):