mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 10:04:29 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a732523e7a | ||
|
|
1b3178037b | ||
|
|
845255e404 | ||
|
|
ef2093f8ad | ||
|
|
ec63235ecd |
@@ -12941,53 +12941,57 @@ _extract_cache = {} # filename -> (tmp_dir, song, timestamp)
|
||||
_extract_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
@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.)."""
|
||||
def _resolve_sloppak_local_file(filename: str, rel_path: str):
|
||||
"""Resolve a file inside a sloppak to its on-disk path.
|
||||
|
||||
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()
|
||||
if not dlc:
|
||||
return JSONResponse({"error": "not configured"}, 404)
|
||||
# `filename` is an attacker-controlled `:path` param. Contain it under
|
||||
# DLC_DIR before it reaches the resolver, which does a bare
|
||||
# `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.
|
||||
return ("not configured", 404)
|
||||
# `filename` is caller-controlled. Contain it under DLC_DIR before it
|
||||
# reaches the resolver (see serve_sloppak_file for the traversal rationale).
|
||||
resolved = _resolve_dlc_path(dlc, filename)
|
||||
if resolved is None:
|
||||
return JSONResponse({"error": "forbidden"}, 403)
|
||||
# Confine the endpoint to actual sloppak bundles. Without this, a
|
||||
# contained-but-non-sloppak `filename` (e.g. `.` → DLC_DIR itself, or
|
||||
# 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.
|
||||
return ("forbidden", 403)
|
||||
# Confine to actual sloppak bundles — otherwise any plain subdirectory
|
||||
# would become a read-any-file-under-DLC_DIR source.
|
||||
if not sloppak_mod.is_sloppak(resolved):
|
||||
return JSONResponse({"error": "not found"}, 404)
|
||||
# Canonicalise the cache key against the resolved path so equivalent
|
||||
# URL forms of the same sloppak (e.g. `A/../B/x.sloppak` vs
|
||||
# `B/x.sloppak`) converge on one `_source_cache` entry instead of
|
||||
# fragmenting / re-unpacking — mirrors get_song_info's keying.
|
||||
return ("not found", 404)
|
||||
# Canonicalise the cache key against the resolved path so equivalent URL
|
||||
# forms of the same sloppak converge on one _source_cache entry.
|
||||
try:
|
||||
filename = resolved.relative_to(dlc.resolve()).as_posix()
|
||||
except ValueError:
|
||||
# safe_join already proved containment, so this is unreachable in
|
||||
# practice; fail closed rather than fall back to the raw param.
|
||||
return JSONResponse({"error": "forbidden"}, 403)
|
||||
# safe_join already proved containment; fail closed regardless.
|
||||
return ("forbidden", 403)
|
||||
src = sloppak_mod.get_cached_source_dir(filename)
|
||||
if src is None:
|
||||
try:
|
||||
src = sloppak_mod.resolve_source_dir(filename, dlc, SLOPPAK_CACHE_DIR)
|
||||
except Exception:
|
||||
return JSONResponse({"error": "not found"}, 404)
|
||||
return ("not found", 404)
|
||||
# Prevent path traversal within the sloppak.
|
||||
target = (src / rel_path).resolve()
|
||||
try:
|
||||
target.relative_to(src.resolve())
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "forbidden"}, 403)
|
||||
return ("forbidden", 403)
|
||||
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()
|
||||
mt = {
|
||||
".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")
|
||||
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 —
|
||||
no scheme, no host, no query string, no fragment. The resolved path must stay
|
||||
inside AUDIO_CACHE_DIR or STATIC_DIR; ``..`` traversal, backslashes, and
|
||||
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
|
||||
the Electron desktop process (which runs on loopback). Requests from non-
|
||||
loopback clients are rejected with 403.
|
||||
@@ -13929,6 +13939,21 @@ def audio_local_path(url: str, request: Request):
|
||||
is_loopback = client_host == "localhost"
|
||||
if not is_loopback:
|
||||
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
|
||||
if not re.fullmatch(r"/audio/[^?#]+", url):
|
||||
return JSONResponse({"error": "invalid url"}, status_code=400)
|
||||
|
||||
+280
-10
@@ -4864,6 +4864,47 @@ window.jucePlayer = jucePlayer;
|
||||
// (a network blip on /api/audio-local-path, an isAudioRunning() race
|
||||
// during a device restart) are deliberately NOT memoised so they retry.
|
||||
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
|
||||
// snapshot object captured at reroute entry — i.e. the song was swapped (or
|
||||
// cleared) mid-flight. Staleness is detected by object-reference identity,
|
||||
@@ -4904,8 +4945,12 @@ window.jucePlayer = jucePlayer;
|
||||
audio.pause();
|
||||
try {
|
||||
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();
|
||||
console.log('[feedpak-route] audio-local-path resolved:', (typeof path === 'string' && path.split(/[\\/]/).pop()) || '<missing>');
|
||||
if (_isStale(songAudio)) return 'stale'; // song changed mid-fetch
|
||||
const ok = await juceApi.loadBackingTrack(path);
|
||||
if (ok === false) {
|
||||
@@ -5123,8 +5168,12 @@ window.jucePlayer = jucePlayer;
|
||||
async function _reevaluateJuceRouting() {
|
||||
if (_rerouteInFlight) return;
|
||||
const songAudio = window._currentSongAudio;
|
||||
// Only /audio/ songs are JUCE-routable; sloppak stems stay on HTML5.
|
||||
if (!songAudio || !songAudio.juceEligible) return;
|
||||
// /audio/ songs are always JUCE-routable. A feedpak full-mix
|
||||
// (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
|
||||
// _juceMode until _juceRoutingPromise settles. Re-running our switch
|
||||
// concurrently would double-call loadBackingTrack for the same URL.
|
||||
@@ -5142,13 +5191,30 @@ window.jucePlayer = jucePlayer;
|
||||
try { running = await juceApi.isAudioRunning(); }
|
||||
catch (_) { return; }
|
||||
if (_isStale(songAudio)) return; // song changed during IPC
|
||||
if (!!running === !!window._juceMode) return; // routing already consistent
|
||||
|
||||
const wantJuce = running && !window._juceMode;
|
||||
// Eligibility is evaluated per tick, not snapshotted at song load:
|
||||
// the output share mode can change mid-song (device switch in the
|
||||
// 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.
|
||||
if (wantJuce && songAudio.url === _rerouteRejectedUrl) return;
|
||||
|
||||
if (running) {
|
||||
if (wantJuce) {
|
||||
const outcome = await _switchHtml5ToJuce(songAudio);
|
||||
// Memoise ONLY an explicit hard JUCE reject. A successful
|
||||
// switch clears the memo; a 'stale' abort (song changed
|
||||
@@ -5163,9 +5229,10 @@ window.jucePlayer = jucePlayer;
|
||||
// outcome === 'stale': leave _rerouteRejectedUrl as-is.
|
||||
} else {
|
||||
await _switchJuceToHtml5(songAudio);
|
||||
// The engine just stopped. Clear any hard-reject memo so a
|
||||
// later engine restart re-evaluates the track at least once —
|
||||
// the rejection may have been a transient device/decoder state.
|
||||
// The engine stopped (or a feedpak's output left exclusive
|
||||
// mode). Clear any hard-reject memo so a later engine restart
|
||||
// or mode change re-evaluates the track at least once — the
|
||||
// rejection may have been a transient device/decoder state.
|
||||
_rerouteRejectedUrl = null;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -5197,6 +5264,209 @@ window.jucePlayer = jucePlayer;
|
||||
}, 350);
|
||||
})();
|
||||
|
||||
// Renderer-audio bus feeder (desktop Phase 2): when the engine holds the
|
||||
// output endpoint in an exclusive-style mode, Chromium cannot reach the
|
||||
// device, so any song audio still played by the renderer goes silent. The
|
||||
// Phase 1 watcher above already migrates what a single-file transport can
|
||||
// carry (loose /audio/ songs, feedpak full-mixes) onto the native backing
|
||||
// transport. This feeder covers the rest — the stems plugin's multi-stem
|
||||
// WebAudio graph, plus <audio>-element songs the native transport could not
|
||||
// take (e.g. a codec loadBackingTrack rejected).
|
||||
//
|
||||
// Mechanism: capture the renderer-side master with an AudioWorklet tap,
|
||||
// re-point the owning AudioContext at a null sink so it keeps rendering
|
||||
// without a device, and push ~10 ms chunks over IPC into the engine's
|
||||
// renderer bus, where they are mixed into the exclusive output like a
|
||||
// backing track (~10-20 ms added latency on song audio only; the guitar
|
||||
// monitoring path is untouched). Validated by the fix12 tester spike:
|
||||
// null-sink rendering works, clocks hold (drift → 0), no overflow.
|
||||
//
|
||||
// Docker sphere: window.feedBackDesktop is undefined → this whole block is
|
||||
// inert. Shared-mode desktop: the bus stays disabled (no double audio) and
|
||||
// captured contexts keep/regain their default sink.
|
||||
(function _installRendererBusFeeder() {
|
||||
const api = window.feedBackDesktop?.audio;
|
||||
if (!api || typeof api.setRendererBus !== 'function'
|
||||
|| typeof api.pushRendererAudio !== 'function') return;
|
||||
|
||||
const TAP_WORKLET = `
|
||||
class FeedbackBusTap extends AudioWorkletProcessor {
|
||||
process(inputs) {
|
||||
const inp = inputs[0];
|
||||
if (inp && inp[0]) {
|
||||
const L = inp[0], R = inp[1] || inp[0];
|
||||
const out = new Float32Array(L.length * 2);
|
||||
for (let i = 0; i < L.length; i++) { out[i*2] = L[i]; out[i*2+1] = R[i]; }
|
||||
this.port.postMessage(out, [out.buffer]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('feedback-bus-tap', FeedbackBusTap);
|
||||
`;
|
||||
const _tapModuleUrl = URL.createObjectURL(new Blob([TAP_WORKLET], { type: 'application/javascript' }));
|
||||
const _tapModuleLoaded = new WeakSet(); // AudioContexts with the module added
|
||||
|
||||
// One tap per captured graph. `active` gates the push (the worklet keeps
|
||||
// running when inactive — it's silent bookkeeping, not audio).
|
||||
function _makeTap(ctx) {
|
||||
const state = { node: null, active: false, batch: [], batchFrames: 0 };
|
||||
state.attach = async (sourceNode) => {
|
||||
if (!_tapModuleLoaded.has(ctx)) {
|
||||
await ctx.audioWorklet.addModule(_tapModuleUrl);
|
||||
_tapModuleLoaded.add(ctx);
|
||||
}
|
||||
if (!state.node) {
|
||||
state.node = new AudioWorkletNode(ctx, 'feedback-bus-tap', { numberOfInputs: 1, channelCount: 2 });
|
||||
const BATCH = Math.round(ctx.sampleRate / 100); // ~10 ms
|
||||
state.node.port.onmessage = (e) => {
|
||||
if (!state.active) { state.batch = []; state.batchFrames = 0; return; }
|
||||
state.batch.push(e.data);
|
||||
state.batchFrames += e.data.length / 2;
|
||||
if (state.batchFrames >= BATCH) {
|
||||
const merged = new Float32Array(state.batchFrames * 2);
|
||||
let o = 0;
|
||||
for (const c of state.batch) { merged.set(c, o); o += c.length; }
|
||||
api.pushRendererAudio(merged, ctx.sampleRate);
|
||||
state.batch = []; state.batchFrames = 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
sourceNode.connect(state.node);
|
||||
// No onward connection: the tap is a sink-side observer; audibility
|
||||
// in shared mode comes from the graph's own destination path.
|
||||
};
|
||||
state.detach = (sourceNode) => {
|
||||
state.active = false;
|
||||
state.batch = []; state.batchFrames = 0;
|
||||
if (state.node && sourceNode) {
|
||||
try { sourceNode.disconnect(state.node); } catch (_) { /* already gone */ }
|
||||
}
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
// ── Core <audio> element capture ─────────────────────────────────────────
|
||||
// createMediaElementSource permanently reroutes the element into its
|
||||
// context, so it is created lazily — only the first time an exclusive
|
||||
// device actually needs it — and never torn down. From then on the element
|
||||
// always plays through _elCtx; sink toggling routes it to the speakers
|
||||
// (shared mode) or the null sink + bus (exclusive mode).
|
||||
let _elCtx = null, _elSource = null, _elTap = null;
|
||||
async function _ensureElementCapture() {
|
||||
if (_elCtx) return;
|
||||
const el = document.getElementById('audio');
|
||||
if (!el) throw new Error('no core audio element');
|
||||
_elCtx = new AudioContext();
|
||||
_elSource = _elCtx.createMediaElementSource(el);
|
||||
_elSource.connect(_elCtx.destination);
|
||||
_elTap = _makeTap(_elCtx);
|
||||
await _elTap.attach(_elSource);
|
||||
}
|
||||
|
||||
// ── Engagement state machine ─────────────────────────────────────────────
|
||||
// 'off' | 'element' | 'stems'
|
||||
let _mode = 'off';
|
||||
let _stemsGraph = null; // { context, masterNode } snapshot while engaged
|
||||
let _stemsTap = null;
|
||||
const _stemsTaps = new WeakMap(); // context → tap (stems ctx is reused across songs)
|
||||
let _busy = false;
|
||||
|
||||
async function _setSink(ctx, exclusive) {
|
||||
if (typeof ctx.setSinkId !== 'function') throw new Error('setSinkId unsupported');
|
||||
await ctx.setSinkId(exclusive ? { type: 'none' } : '');
|
||||
if (ctx.state !== 'running') await ctx.resume().catch(() => {});
|
||||
}
|
||||
|
||||
async function _disengage() {
|
||||
if (_mode === 'off') return;
|
||||
const prev = _mode;
|
||||
_mode = 'off';
|
||||
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
|
||||
if (prev === 'element' && _elCtx) {
|
||||
_elTap.active = false;
|
||||
await _setSink(_elCtx, false).catch(() => {});
|
||||
} else if (prev === 'stems' && _stemsGraph) {
|
||||
if (_stemsTap) _stemsTap.detach(_stemsGraph.masterNode);
|
||||
await _setSink(_stemsGraph.context, false).catch(() => {});
|
||||
_stemsGraph = null; _stemsTap = null;
|
||||
}
|
||||
console.log('[renderer-bus] disengaged (' + prev + ')');
|
||||
}
|
||||
|
||||
async function _engageStems(graph) {
|
||||
await _setSink(graph.context, true);
|
||||
let tap = _stemsTaps.get(graph.context);
|
||||
if (!tap) { tap = _makeTap(graph.context); _stemsTaps.set(graph.context, tap); }
|
||||
await tap.attach(graph.masterNode);
|
||||
await api.setRendererBus(true, 1.0);
|
||||
tap.active = true;
|
||||
_stemsGraph = graph; _stemsTap = tap;
|
||||
_mode = 'stems';
|
||||
console.log('[renderer-bus] engaged: stems graph → engine bus');
|
||||
}
|
||||
|
||||
async function _engageElement() {
|
||||
await _ensureElementCapture();
|
||||
await _setSink(_elCtx, true);
|
||||
await api.setRendererBus(true, 1.0);
|
||||
_elTap.active = true;
|
||||
_mode = 'element';
|
||||
console.log('[renderer-bus] engaged: <audio> element → engine bus');
|
||||
}
|
||||
|
||||
async function _reevaluate() {
|
||||
if (_busy) return;
|
||||
_busy = true;
|
||||
try {
|
||||
let running = false, exclusive = false;
|
||||
try {
|
||||
running = await api.isAudioRunning();
|
||||
} catch (_) { /* engine unreachable → treat as not running */ }
|
||||
if (running) {
|
||||
// Reuse the Phase 1 predicate installed by the routing watcher
|
||||
// (getCurrentDevice + exclusive-type check with change-logged
|
||||
// diagnostics). Fail closed if it is somehow absent.
|
||||
exclusive = !!(await window._juceOutputIsExclusive?.());
|
||||
}
|
||||
|
||||
// The stems plugin publishes its live graph while a multi-stem
|
||||
// song is loaded (and removes it on teardown).
|
||||
const stems = (window.feedBack || window.slopsmith)?.stems?.audioGraph || null;
|
||||
// Element songs: a song is loaded, it is NOT riding the native
|
||||
// transport (Phase 1 owns those), and the stems graph is not the
|
||||
// player. Covers native-transport rejects (codec) in exclusive
|
||||
// mode — without this they would be silent.
|
||||
const songAudio = window._currentSongAudio;
|
||||
const elementSong = !!songAudio && !window._juceMode && !stems;
|
||||
|
||||
let want = 'off';
|
||||
if (running && exclusive) {
|
||||
if (stems) want = 'stems';
|
||||
else if (elementSong) want = 'element';
|
||||
}
|
||||
|
||||
const stemsGraphChanged = _mode === 'stems' && stems !== _stemsGraph;
|
||||
if (want !== _mode || stemsGraphChanged) {
|
||||
await _disengage();
|
||||
if (want === 'stems') await _engageStems(stems);
|
||||
else if (want === 'element') await _engageElement();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[renderer-bus] reevaluate failed (will retry):', e);
|
||||
_mode = 'off';
|
||||
} finally {
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Same cadence/rationale as the routing watcher above. Also re-check on
|
||||
// visibility return so a device switch made while hidden is reconciled.
|
||||
setInterval(() => { if (!document.hidden) void _reevaluate(); }, 500);
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) void _reevaluate(); });
|
||||
window._reevaluateRendererBus = _reevaluate;
|
||||
})();
|
||||
|
||||
// Desktop JUCE backing uses an empty <audio> element; plugins such as Section Map
|
||||
// still seek via audio.currentTime / pause / play. Mirror those onto jucePlayer
|
||||
// while _juceMode is active. Same-tick pause+seek coalesce into a single seek
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
const SCHEMA = 'feedBack.audio_effects.diagnostics.v1';
|
||||
const PLAN_SCHEMA = 'feedBack.audio_effects.chain_plan.v1';
|
||||
// Pre-rebrand plugins (rig_builder <= 2.9.x) still send the old schema id — accept it as an alias.
|
||||
const LEGACY_PLAN_SCHEMA = 'slopsmith.audio_effects.chain_plan.v1';
|
||||
const OWNER_ID = 'core.audio.effects';
|
||||
const DEFAULT_ROUTE_KEY = 'desktop-main';
|
||||
const DEFAULT_TIMEOUT_MS = 2000;
|
||||
@@ -734,7 +736,7 @@
|
||||
const errors = [];
|
||||
const source = _plainObject(rawPlan);
|
||||
const schema = _string(source.schema || source.version, PLAN_SCHEMA);
|
||||
if (schema !== PLAN_SCHEMA && schema !== '1') errors.push('Unsupported chain plan schema');
|
||||
if (schema !== PLAN_SCHEMA && schema !== LEGACY_PLAN_SCHEMA && schema !== '1') errors.push('Unsupported chain plan schema');
|
||||
const planRoute = _safeRoute(source.routeKey || source.route || routeKey);
|
||||
if (planRoute !== routeKey) errors.push('Chain plan route does not match selected route');
|
||||
const providerId = _safeId(source.providerId || provider.providerId, provider.providerId);
|
||||
|
||||
+58
-5
@@ -3409,21 +3409,57 @@ function createHighway() {
|
||||
if (msg.audio_url) {
|
||||
const audio = document.getElementById('audio');
|
||||
const audioFilename = msg.audio_url.split('/').pop();
|
||||
// Only attempt JUCE routing for /audio/ URLs — sloppak stems
|
||||
// (/api/sloppak/…) are not resolvable via audio-local-path.
|
||||
// /audio/ URLs are always JUCE-routable. A feedpak full-mix
|
||||
// (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/');
|
||||
// "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
|
||||
// between the HTML5 and JUCE paths if the audio engine is
|
||||
// started/stopped after the song is already loaded. Set this
|
||||
// unconditionally (not just on reload): when alreadyLoaded is
|
||||
// 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
|
||||
? window._juceAudioUrl === msg.audio_url
|
||||
: (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) {
|
||||
const juceApi = window.feedBackDesktop?.audio;
|
||||
if (isAudioUrl && juceApi) {
|
||||
if ((isAudioUrl || isFeedpakFullMix) && juceApi) {
|
||||
// Run JUCE routing off the critical message-processing chain
|
||||
// so subsequent notes/chords/ready messages aren't blocked
|
||||
// waiting for IPC + HTTP round-trips. The 'ready' handler
|
||||
@@ -3466,7 +3502,24 @@ function createHighway() {
|
||||
clearTimeout(barrierTimer);
|
||||
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
|
||||
const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(audioUrl)}`);
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
|
||||
@@ -40,7 +40,7 @@ function extractWatcherIIFE(src) {
|
||||
|
||||
// Build a sandbox with fakes and run the watcher IIFE inside it. Returns the
|
||||
// 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 audio = {
|
||||
@@ -65,6 +65,7 @@ function makeSandbox({ isAudioRunning, loadBackingTrack }) {
|
||||
const juceApi = {
|
||||
isAudioRunning: () => Promise.resolve(isAudioRunning()),
|
||||
loadBackingTrack: (p) => { calls.loadBackingTrack.push(p); return Promise.resolve(loadBackingTrack()); },
|
||||
getCurrentDevice: () => Promise.resolve({ outputType: typeof outputType === 'function' ? outputType() : outputType }),
|
||||
getBackingDuration: () => Promise.resolve(180),
|
||||
seekBacking: () => 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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => false });
|
||||
sb.window._juceMode = false;
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// Behavioral tests for the renderer-audio bus feeder in static/app.js.
|
||||
//
|
||||
// The feeder (an IIFE, `_installRendererBusFeeder`) captures renderer-side
|
||||
// song audio (stems-plugin WebAudio master, or the core <audio> element) and
|
||||
// pushes it into the desktop engine's renderer bus while the output device is
|
||||
// exclusive-style — the Phase 2 path for audio the native backing transport
|
||||
// cannot carry. These tests extract that IIFE from source and exercise
|
||||
// `window._reevaluateRendererBus` against fakes, covering: stems engagement
|
||||
// under exclusive output, disengagement on return to shared mode, inertness
|
||||
// in shared mode / while the native transport owns the song, and the
|
||||
// element-capture fallback.
|
||||
|
||||
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 APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
function extractFeederIIFE(src) {
|
||||
const marker = '(function _installRendererBusFeeder() {';
|
||||
const start = src.indexOf(marker);
|
||||
assert.ok(start !== -1, 'feeder IIFE not found in app.js');
|
||||
const openBrace = src.indexOf('{', start);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
while (i < src.length && depth > 0) {
|
||||
const ch = src[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
assert.ok(depth === 0, 'unbalanced braces in feeder IIFE');
|
||||
const tail = src.slice(i, i + 5);
|
||||
assert.match(tail, /^\)\(\)/, 'feeder IIFE not immediately invoked');
|
||||
return src.slice(start, i) + ')();';
|
||||
}
|
||||
|
||||
function makeFakeContext(sampleRate = 48000) {
|
||||
const ctx = {
|
||||
sampleRate,
|
||||
state: 'running',
|
||||
sinkIdCalls: [],
|
||||
destination: { isDestination: true },
|
||||
setSinkId(v) { this.sinkIdCalls.push(v); return Promise.resolve(); },
|
||||
resume() { this.state = 'running'; return Promise.resolve(); },
|
||||
audioWorklet: { addModule: () => Promise.resolve() },
|
||||
createMediaElementSource(el) {
|
||||
this.mediaSourceEl = el;
|
||||
return { connect() {}, disconnect() {} };
|
||||
},
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {}) {
|
||||
const calls = { setRendererBus: [], pushRendererAudio: [] };
|
||||
|
||||
const api = {
|
||||
isAudioRunning: () => Promise.resolve(isAudioRunning()),
|
||||
setRendererBus: (en, g) => { calls.setRendererBus.push([en, g]); return Promise.resolve(); },
|
||||
pushRendererAudio: (buf, rate) => { calls.pushRendererAudio.push([buf.length, rate]); },
|
||||
};
|
||||
|
||||
class FakeWorkletNode {
|
||||
constructor() { this.port = { onmessage: null }; }
|
||||
connect() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const sandbox = {
|
||||
console: { log() {}, warn() {}, error() {} },
|
||||
URL: { createObjectURL: () => 'blob:tap', revokeObjectURL() {} },
|
||||
Blob: class { constructor() {} },
|
||||
AudioWorkletNode: FakeWorkletNode,
|
||||
AudioContext: function () { const c = makeFakeContext(); sandbox.__createdContexts.push(c); return c; },
|
||||
WeakSet, WeakMap, Promise, Float32Array, Math,
|
||||
setInterval: () => 0,
|
||||
document: {
|
||||
hidden: false,
|
||||
addEventListener() {},
|
||||
getElementById: () => sandbox.__audioEl,
|
||||
},
|
||||
__createdContexts: [],
|
||||
__audioEl: { id: 'audio' },
|
||||
__calls: calls,
|
||||
window: null,
|
||||
};
|
||||
sandbox.window = {
|
||||
feedBackDesktop: { audio: api },
|
||||
_juceOutputIsExclusive: () => Promise.resolve(exclusive()),
|
||||
_juceMode: false,
|
||||
_currentSongAudio: null,
|
||||
feedBack: { stems: {} },
|
||||
};
|
||||
sandbox.globalThis = sandbox;
|
||||
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(extractFeederIIFE(src), sandbox);
|
||||
assert.equal(typeof sandbox.window._reevaluateRendererBus, 'function',
|
||||
'feeder must expose window._reevaluateRendererBus');
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
function makeStemsGraph() {
|
||||
return {
|
||||
context: makeFakeContext(),
|
||||
masterNode: { connect() {}, disconnect() {} },
|
||||
};
|
||||
}
|
||||
|
||||
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
||||
assert.equal(graph.context.sinkIdCalls.at(-1)?.type, 'none', 'stems ctx re-pointed at null sink');
|
||||
});
|
||||
|
||||
test('output returns to shared → bus disabled, sink restored', async () => {
|
||||
let excl = true;
|
||||
const sb = makeSandbox({ exclusive: () => excl });
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
excl = false;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [false, 0], 'bus disabled');
|
||||
assert.equal(graph.context.sinkIdCalls.at(-1), '', 'default sink restored');
|
||||
});
|
||||
|
||||
test('stems graph + shared output → feeder stays off (no double audio)', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => false });
|
||||
sb.window.feedBack.stems.audioGraph = makeStemsGraph();
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.equal(sb.__calls.setRendererBus.length, 0, 'bus never touched in shared mode');
|
||||
});
|
||||
|
||||
test('element song + exclusive → element captured into bus', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
|
||||
sb.window._juceMode = false;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.equal(sb.__createdContexts.length, 1, 'capture context created');
|
||||
assert.equal(sb.__createdContexts[0].mediaSourceEl, sb.__audioEl, 'element source captured');
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
||||
});
|
||||
|
||||
test('song riding the native transport (_juceMode) → feeder stays off', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg' };
|
||||
sb.window._juceMode = true;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.equal(sb.__calls.setRendererBus.length, 0, 'native transport owns the song');
|
||||
assert.equal(sb.__createdContexts.length, 0, 'no capture context created');
|
||||
});
|
||||
|
||||
test('stems graph replaced mid-engagement → re-engages on the new graph', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
const g1 = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = g1;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
const g2 = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = g2;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.equal(g2.context.sinkIdCalls.at(-1)?.type, 'none', 'new graph null-sinked');
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 're-enabled for new graph');
|
||||
});
|
||||
|
||||
test('engine stops → bus disabled', async () => {
|
||||
let running = true;
|
||||
const sb = makeSandbox({ isAudioRunning: () => running, exclusive: () => true });
|
||||
sb.window.feedBack.stems.audioGraph = makeStemsGraph();
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
running = false;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [false, 0], 'bus disabled after engine stop');
|
||||
});
|
||||
@@ -103,12 +103,98 @@ def test_returns_404_for_nonexistent_file(client_and_server):
|
||||
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
|
||||
r = client.get("/api/audio-local-path", params={"url": "/api/sloppak/mysong/file/stems/full.ogg"})
|
||||
assert r.status_code == 400
|
||||
r = client.get(
|
||||
"/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):
|
||||
|
||||
Reference in New Issue
Block a user