Compare commits

..

9 Commits

Author SHA1 Message Date
Jonathan Sykes
7434f2f486 Document Phase 1 verification and retained plain asset URLs 2026-10-07 23:14:14 +08:00
Jonathan Sykes
7c45d098b1 Verify production-client migration and record Phase 1 measurements 2026-10-07 23:12:48 +08:00
Jonathan Sykes
84546eeb7d Group all Glass Stage dependencies under the selected layout 2026-10-07 23:09:04 +08:00
Jonathan Sykes
d18d17cf29 Preserve legacy cache rules and pin service worker bytes at boot 2026-10-07 23:01:55 +08:00
Jonathan Sykes
6cb00d5073 Add per-file asset hashes and a rollback-compatible server manifest 2026-10-07 22:57:41 +08:00
Jonathan Sykes
ad85cb6eb4 Plan phase 1: Add per-file hashes and a backward-compatible manifest 2026-10-07 22:50:02 +08:00
Jonathan Sykes
be1d939afd Merge branch 'codex/prewarm-next'
# Conflicts:
#	frontend/index.html
2026-10-07 22:44:11 +08:00
Jonathan Sykes
d65b9e032a Prewarm upcoming queue streams 2026-10-07 16:30:15 +08:00
Jonathan Sykes
fcd98b6a4d Add queue prewarm candidate selection 2026-10-07 16:30:01 +08:00
30 changed files with 1660 additions and 449 deletions

View File

@@ -1,34 +0,0 @@
# Beta stack (beta.worship.hesed.sbs). Separate from prod on purpose: own named
# volumes (no shared DB or media drive), no hardcoded Traefik labels (Dokploy
# injects the domain routing), no lyrics/piano workers.
services:
ytplayer:
build: .
restart: unless-stopped
volumes:
- ytplayer-beta-data:/app/data
environment:
PORT: "3000"
DB_PATH: "/app/data/ytplayer.db"
MEDIA_DIR: "/app/data/media"
UPLOAD_DIR: "/app/data/uploads"
MEDIA_CACHE_MAX_BYTES: "10737418240"
APP_VERSION: "1.0.0-beta"
ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}"
P2P_ENABLED: "${P2P_ENABLED:-0}"
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:3000/api/version"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
networks:
- dokploy-network
volumes:
ytplayer-beta-data:
driver: local
networks:
dokploy-network:
external: true

View File

@@ -623,8 +623,9 @@ const API = {
// Pre-resolve streams for videos the user is likely to play next (server // Pre-resolve streams for videos the user is likely to play next (server
// /api/streams/warm). Each id is warmed at most once per 20 min per tab. // /api/streams/warm). Each id is warmed at most once per 20 min per tab.
const warmedAt = new Map(); const warmedAt = new Map();
const knownServerCachedIds = new Set();
function warmStreams(ids) { function warmStreams(ids) {
if (!WEB || navigator.onLine === false) return; if (!WEB || navigator.onLine === false || navigator.connection?.saveData) return;
const now = Date.now(); const now = Date.now();
for (const id of ids) { for (const id of ids) {
if (!id || !/^[A-Za-z0-9_-]{11}$/.test(id) || cachedIds.has(id)) continue; if (!id || !/^[A-Za-z0-9_-]{11}$/.test(id) || cachedIds.has(id)) continue;
@@ -634,6 +635,14 @@ function warmStreams(ids) {
} }
} }
function warmNextQueue() {
if (!window.PrewarmNext || queueIndex < 0) return;
warmStreams(window.PrewarmNext.candidates(queue, queueIndex, data.settings, 2, {
offline: cachedIds,
serverCached: knownServerCachedIds,
}));
}
// Resolve a native file path to a URL the WebView can load (Tauri asset // Resolve a native file path to a URL the WebView can load (Tauri asset
// protocol). Returns null on shells without it. // protocol). Returns null on shells without it.
function toAssetUrl(path) { function toAssetUrl(path) {
@@ -644,7 +653,6 @@ function toAssetUrl(path) {
// ---------- State ---------- // ---------- State ----------
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
quality: 'auto', volume: 1, audioOnly: false, autoPreload: true, quality: 'auto', volume: 1, audioOnly: false, autoPreload: true,
jamesDsp: { enabled: false, preset: 'Flat', params: {} },
p2pShare: true, // P2P (docs/p2p-architecture.md): share my saved videos with other devices — ON by default p2pShare: true, // P2P (docs/p2p-architecture.md): share my saved videos with other devices — ON by default
p2pReceive: true, // fetch from other devices when YouTube and the server can't — ON by default p2pReceive: true, // fetch from other devices when YouTube and the server can't — ON by default
saveBeforePlay: false, // download (server-muxed single file) before playing instead of streaming saveBeforePlay: false, // download (server-muxed single file) before playing instead of streaming
@@ -2333,6 +2341,7 @@ const Player = {
if (seq !== this._loadSeq) return; // a newer load took over meanwhile if (seq !== this._loadSeq) return; // a newer load took over meanwhile
if (!res || !res.ok) throw new Error(res?.error || 'Could not load streams'); if (!res || !res.ok) throw new Error(res?.error || 'Could not load streams');
current = res.data; current = res.data;
if (current.serverCached && current.meta?.id) knownServerCachedIds.add(current.meta.id);
// Merge richer metadata we may already have from the list card. // Merge richer metadata we may already have from the list card.
current.meta = { ...videoObj, ...current.meta }; current.meta = { ...videoObj, ...current.meta };
@@ -2659,7 +2668,6 @@ const Player = {
play() { play() {
this._wantsPlaying = true; this._wantsPlaying = true;
if (data.settings.jamesDsp?.enabled) JamesDSP.activate(this.soundEl).catch((e) => JamesDSP.fail(e));
audioContinuity.play(this.master); audioContinuity.play(this.master);
audioContinuity.resume(this.secondary, this.master); audioContinuity.resume(this.secondary, this.master);
}, },
@@ -3016,7 +3024,7 @@ function wirePlayerEvents() {
perfMeasure('ytp:tap-to-play', 'ytp:tap'); perfMeasure('ytp:tap-to-play', 'ytp:tap');
try { performance.clearMarks('ytp:tap'); } catch { /* ignore */ } try { performance.clearMarks('ytp:tap'); } catch { /* ignore */ }
} }
if (Array.isArray(queue) && queueIndex >= 0) warmStreams(queue.slice(queueIndex + 1, queueIndex + 3).map((x) => x && x.id)); warmNextQueue();
showSpinner(false); showSpinner(false);
Player.clearBufferGrace(); Player.clearBufferGrace();
if (Player.secondary && !el.paused && Player.secondary.paused) { if (Player.secondary && !el.paused && Player.secondary.paused) {
@@ -6819,36 +6827,6 @@ const PiP = (() => {
return { toggle, render }; return { toggle, render };
})(); })();
// Optional JamesDSP stage. The module and WASM are fetched only after opt-in.
const JamesDSP = (() => {
const state = { controller: null, importing: null };
async function activate(element = Player.soundEl) {
if (!data.settings.jamesDsp?.enabled || !element) return;
try {
if (!state.importing) state.importing = import('./vendor/jamesdsp/controller.js');
const mod = await state.importing;
if (!state.controller) { state.controller = new mod.JamesDSPController(); state.controller.onError = fail; }
await state.controller.enable(element, data.settings.jamesDsp);
} catch (error) { fail(error); throw error; }
}
function fail(error) {
console.warn('[JamesDSP] Disabled:', error);
data.settings.jamesDsp.enabled = false;
state.controller?.disable();
persist();
const checkbox = $('setJamesDsp'); if (checkbox) checkbox.checked = false;
const note = $('jamesDspStatus'); if (note) note.textContent = `Audio effects unavailable: ${error?.message || error}`;
toast(`Audio effects disabled: ${error?.message || error}`);
}
async function toggle(on) {
data.settings.jamesDsp.enabled = !!on; persist();
if (!on) { await state.controller?.disable(); return; }
await activate(Player.soundEl);
}
async function apply() { if (data.settings.jamesDsp?.enabled) await activate(Player.soundEl); }
return { activate, fail, toggle, apply, get controller() { return state.controller; } };
})();
// ============================================================================ // ============================================================================
// Equalizer — 10-band graphic EQ (Web Audio) // Equalizer — 10-band graphic EQ (Web Audio)
// ============================================================================ // ============================================================================
@@ -8076,6 +8054,7 @@ function playFromList(list, index, source = '', { playFull = false } = {}) {
unshuffledQueue = null; unshuffledQueue = null;
// Play-in-full keeps the list order — a service set plays as arranged. // Play-in-full keeps the list order — a service set plays as arranged.
if (data.settings.shuffle && !playFull) applyShuffle(); if (data.settings.shuffle && !playFull) applyShuffle();
warmNextQueue();
Player.loadVideo(queue[queueIndex]); Player.loadVideo(queue[queueIndex]);
renderUpNext(); renderUpNext();
} }
@@ -9675,19 +9654,6 @@ async function renderSettings() {
</label> </label>
</div> </div>
<div class="set-group" id="jamesDspSettings">
<div class="set-group-title">Audio effects (JamesDSP)</div>
<label class="set-row"><span>Enable JamesDSP<small>Loads the audio processor only when enabled. iOS background playback may be interrupted.</small></span><input id="setJamesDsp" type="checkbox" ${data.settings.jamesDsp?.enabled ? 'checked' : ''} /></label>
<label class="set-row"><span>Preset</span>${sel('setJamesDspPreset', data.settings.jamesDsp?.preset || 'Flat', [['Flat','Flat'],['Bass boost','Bass boost'],['Vocal','Vocal'],['Headphone crossfeed','Headphone crossfeed'],['Loudness','Loudness']])}</label>
<label class="set-row"><span>EQ low band<small>25 Hz · −12 to +12 dB</small></span><input type="range" min="-12" max="12" step="1" value="${data.settings.jamesDsp?.params?.['eq.band0'] ?? 0}" id="setDspEqLow" /></label>
<label class="set-row"><span>EQ mid band<small>1 kHz · −12 to +12 dB</small></span><input type="range" min="-12" max="12" step="1" value="${data.settings.jamesDsp?.params?.['eq.band8'] ?? 0}" id="setDspEqMid" /></label>
<label class="set-row"><span>EQ high band<small>16 kHz · −12 to +12 dB</small></span><input type="range" min="-12" max="12" step="1" value="${data.settings.jamesDsp?.params?.['eq.band14'] ?? 0}" id="setDspEqHigh" /></label>
<label class="set-row"><span>Bass boost<small>0 to 12 dB</small></span><input type="range" min="0" max="12" step="1" value="${data.settings.jamesDsp?.params?.['bass.boost'] ?? 6}" id="setDspBass" /></label>
<label class="set-row"><span>Headphone crossfeed</span><input id="setDspCrossfeed" type="checkbox" ${data.settings.jamesDsp?.params?.['crossfeed.enabled'] ? 'checked' : ''} /></label>
<label class="set-row"><span>Limiter threshold<small>−24 to 0 dB</small></span><input type="range" min="-24" max="0" step="1" value="${data.settings.jamesDsp?.params?.['limiter.threshold'] ?? -0.1}" id="setDspLimiter" /></label>
<small id="jamesDspStatus" class="sp-note">Supported sample rates: 44.1 and 48 kHz.</small>
</div>
<div class="set-group"> <div class="set-group">
<div class="set-group-title">Sharing (peer-to-peer)</div> <div class="set-group-title">Sharing (peer-to-peer)</div>
<label class="set-row"> <label class="set-row">
@@ -9881,7 +9847,6 @@ async function renderSettings() {
const definitions = [ const definitions = [
['appearance', 'Appearance', 'Listening', () => `${data.settings.theme || 'dark'} · ${data.settings.fontScale || 'normal'} text`, '<circle cx="12" cy="12" r="8"/><path d="M12 4v16"/>'], ['appearance', 'Appearance', 'Listening', () => `${data.settings.theme || 'dark'} · ${data.settings.fontScale || 'normal'} text`, '<circle cx="12" cy="12" r="8"/><path d="M12 4v16"/>'],
['playback', 'Playback & sound', 'Listening', () => `${data.settings.quality || 'auto'} quality · ${Level.enabled() ? 'Levelled volume' : 'Original volume'}`, '<path d="m9 5 10 7-10 7zM4 5v14"/>'], ['playback', 'Playback & sound', 'Listening', () => `${data.settings.quality || 'auto'} quality · ${Level.enabled() ? 'Levelled volume' : 'Original volume'}`, '<path d="m9 5 10 7-10 7zM4 5v14"/>'],
['jamesdsp', 'Audio effects (JamesDSP)', 'Listening', () => data.settings.jamesDsp?.enabled ? `On · ${data.settings.jamesDsp.preset || 'Flat'}` : 'Off', '<path d="M4 12h3l2-7 4 14 2-7h5"/>'],
['sharing', 'Sharing', 'Library & storage', () => data.settings.p2pShare !== false ? 'Sharing saved videos' : 'Sharing off', '<circle cx="6" cy="12" r="2"/><circle cx="18" cy="5" r="2"/><circle cx="18" cy="19" r="2"/><path d="m8 11 8-5M8 13l8 5"/>'], ['sharing', 'Sharing', 'Library & storage', () => data.settings.p2pShare !== false ? 'Sharing saved videos' : 'Sharing off', '<circle cx="6" cy="12" r="2"/><circle cx="18" cy="5" r="2"/><circle cx="18" cy="19" r="2"/><path d="m8 11 8-5M8 13l8 5"/>'],
['offline', 'Offline storage', 'Library & storage', () => `${SaveSlots.limit ? SaveSlots.limit() : Number(data.settings.parallelSaves) || 4} at once · ${$('cacheTotal')?.textContent || 'Checking storage'}`, '<path d="M12 3v12m-4-4 4 4 4-4M4 16v5h16v-5"/>'], ['offline', 'Offline storage', 'Library & storage', () => `${SaveSlots.limit ? SaveSlots.limit() : Number(data.settings.parallelSaves) || 4} at once · ${$('cacheTotal')?.textContent || 'Checking storage'}`, '<path d="M12 3v12m-4-4 4 4 4-4M4 16v5h16v-5"/>'],
['searches', 'Saved searches', 'Library & storage', () => `${SearchLibrary.list().length} saved searches`, '<circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/>'], ['searches', 'Saved searches', 'Library & storage', () => `${SearchLibrary.list().length} saved searches`, '<circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/>'],
@@ -9960,12 +9925,6 @@ async function renderSettings() {
refresh(); refresh();
} }
$('setJamesDsp').addEventListener('change', (e) => { JamesDSP.toggle(e.target.checked).catch(() => {}); });
$('setJamesDspPreset').addEventListener('change', async (e) => { data.settings.jamesDsp ||= { enabled: false, params: {} }; data.settings.jamesDsp.preset = e.target.value; persist(); try { await JamesDSP.apply(); } catch {} });
const dspParam = (id, name, parse = Number) => $(id).addEventListener('input', async (e) => { data.settings.jamesDsp ||= { enabled: false, preset: 'Flat', params: {} }; const value = parse(e.target.value); data.settings.jamesDsp.params ||= {}; data.settings.jamesDsp.params[name] = value; persist(); try { if (JamesDSP.controller) await JamesDSP.controller.update(name, value, data.settings.jamesDsp); } catch (error) { JamesDSP.fail(error); } });
dspParam('setDspEqLow', 'eq.band0'); dspParam('setDspEqMid', 'eq.band8'); dspParam('setDspEqHigh', 'eq.band14'); dspParam('setDspBass', 'bass.boost'); dspParam('setDspLimiter', 'limiter.threshold');
$('setDspCrossfeed').addEventListener('change', async (e) => { const s = data.settings.jamesDsp ||= { enabled: false, preset: 'Flat', params: {} }; s.params ||= {}; s.params['crossfeed.enabled'] = Number(e.target.checked); persist(); try { if (JamesDSP.controller) await JamesDSP.controller.update('crossfeed.enabled', Number(e.target.checked), s); } catch (error) { JamesDSP.fail(error); } });
// ---- Wire playback controls ---- // ---- Wire playback controls ----
$('setQuality').addEventListener('change', (e) => { $('setQuality').addEventListener('change', (e) => {
data.settings.quality = e.target.value; data.settings.quality = e.target.value;

242
frontend/assets.json Normal file
View File

@@ -0,0 +1,242 @@
{
"groups": {
"core": {
"contract": 1,
"eager": true,
"files": [
"/index.html",
"/styles.css",
"/service-ui.css",
"/refresh.css",
"/recommendations.css",
"/flag-ui.js",
"/async-guard.js",
"/visible-timer.js",
"/sw-update.js",
"/fingerprint.js",
"/opfs.js",
"/opfs-worker.js",
"/lyrics-core.js",
"/lyrics-display.js",
"/sha256.js",
"/resume-core.js",
"/loudness-wasm.js",
"/loudness.js",
"/sha256-wasm.js",
"/settings-sections.js",
"/related-videos.js",
"/related-videos.css",
"/audio-continuity.js",
"/fullscreen-orientation.js",
"/fullscreen-orientation.css",
"/download-actions.js",
"/download-actions.css",
"/download-errors.js",
"/downloads.js",
"/downloads-page.js",
"/offline-pages.css",
"/saved-page.js",
"/server-backfill.js",
"/saved-page.css",
"/prewarm-next.js",
"/settings-sections.css",
"/app.js",
"/manifest.webmanifest",
"/icons/icon-192.png",
"/icons/icon-512.png",
"/fonts/fonts.css",
"/fonts/BricolageGrotesque-latin-ext.woff2",
"/fonts/BricolageGrotesque-latin.woff2",
"/fonts/HankenGrotesk-latin-ext.woff2",
"/fonts/HankenGrotesk-latin.woff2",
"/fonts/JetBrainsMono-latin-ext.woff2",
"/fonts/JetBrainsMono-latin.woff2"
]
},
"layout:glass-stage": {
"contract": 1,
"eager": false,
"files": [
"/theme-glass.css",
"/theme-glass-controls.css",
"/glass-controls.js",
"/theme-glass-panel.css",
"/glass-panel-layout.js"
]
},
"layout:classic": {
"contract": 1,
"eager": false,
"files": [
"/theme-classic.css"
]
},
"layout:bento-hub": {
"contract": 1,
"eager": false,
"files": [
"/theme-bento-hub.css",
"/bento-hub.js",
"/fonts/PlusJakartaSans-latin.woff2",
"/fonts/PlusJakartaSans-latin-ext.woff2"
]
},
"layout:f7-swipe": {
"contract": 1,
"eager": false,
"files": [
"/theme-f7.css",
"/f7-layout.js",
"/vendor/framework7-swipe.min.js"
]
},
"feature:piano": {
"contract": 1,
"eager": false,
"files": [
"/piano-core.js",
"/piano.js",
"/piano-engine.mjs"
]
},
"feature:midi": {
"contract": 1,
"eager": false,
"files": [
"/midi.js"
]
},
"feature:party-dj": {
"contract": 1,
"eager": false,
"files": [
"/party-dj.js"
]
},
"feature:lyrics-window": {
"contract": 1,
"eager": false,
"files": [
"/lyrics-window.js"
]
},
"feature:lower-third": {
"contract": 1,
"eager": false,
"files": [
"/lower-third.js",
"/lower-third.css"
]
},
"feature:vocal-reducer": {
"contract": 1,
"eager": false,
"files": [
"/vocal-reducer.js"
]
},
"feature:eq-core": {
"contract": 1,
"eager": false,
"files": [
"/eq-core.js"
]
},
"feature:setlist-import": {
"contract": 1,
"eager": false,
"files": [
"/setlist-import.js"
]
},
"feature:car-mode": {
"contract": 1,
"eager": false,
"files": [
"/car-mode.js",
"/car-mode.css"
]
},
"feature:p2p": {
"contract": 1,
"eager": false,
"files": [
"/p2p-client.js",
"/p2p-core.js",
"/p2p-transfer.js",
"/p2p-recv-worker.js",
"/hash-worker.js",
"/device-db.js"
]
},
"feature:direct": {
"contract": 1,
"eager": false,
"files": [
"/direct-protocol.js",
"/direct-stream.js",
"/direct-media.js",
"/direct-media.css",
"/direct-recv-worker.js"
]
},
"feature:video-edit": {
"contract": 1,
"eager": false,
"files": [
"/video-edit.js"
]
},
"feature:stats": {
"contract": 1,
"eager": false,
"files": [
"/stats-core.js"
]
},
"feature:export": {
"contract": 1,
"eager": false,
"files": [
"/export.js"
]
},
"feature:playlist": {
"contract": 1,
"eager": true,
"files": [],
"embeddedIn": [
"/app.js"
]
},
"feature:settings": {
"contract": 1,
"eager": true,
"files": [],
"embeddedIn": [
"/app.js",
"/settings-sections.js",
"/settings-sections.css"
]
},
"feature:service-mode": {
"contract": 1,
"eager": true,
"files": [],
"embeddedIn": [
"/app.js",
"/service-ui.css"
]
},
"feature:lyrics-editor": {
"contract": 1,
"eager": true,
"files": [],
"embeddedIn": [
"/app.js",
"/lyrics-core.js",
"/lyrics-display.js"
]
}
}
}

View File

@@ -129,8 +129,8 @@
<!-- Player pane --> <!-- Player pane -->
<section id="playerPane" class="player-pane empty"> <section id="playerPane" class="player-pane empty">
<div class="player-stage"> <div class="player-stage">
<video crossorigin="anonymous" id="video" playsinline></video> <video id="video" playsinline></video>
<audio id="audio" crossorigin="anonymous"></audio> <audio id="audio"></audio>
<!-- Lyrics shown in place of the video (watch party / sing-along) --> <!-- Lyrics shown in place of the video (watch party / sing-along) -->
<div id="stageLyrics" class="stage-lyrics hidden"> <div id="stageLyrics" class="stage-lyrics hidden">
<div id="stageLyricsList" class="sl-list"></div> <div id="stageLyricsList" class="sl-list"></div>
@@ -694,6 +694,7 @@
<script src="setlist-import.js"></script> <script src="setlist-import.js"></script>
<script src="car-mode.js"></script> <script src="car-mode.js"></script>
<script src="visible-timer.js"></script> <script src="visible-timer.js"></script>
<script src="prewarm-next.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

37
frontend/prewarm-next.js Normal file
View File

@@ -0,0 +1,37 @@
/* Pure queue candidate selection for stream prewarming. */
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.PrewarmNext = factory();
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
function candidates(queue, index, state = {}, n = 2, known = {}) {
if (!Array.isArray(queue) || !queue.length || !Number.isInteger(index) || index < 0 || index >= queue.length) return [];
if (state.loopOne) return [];
const count = Math.max(0, Math.floor(Number(n) || 0));
if (!count) return [];
const repeatAll = state.repeatMode === 'all' || state.repeatAll === true;
const offline = new Set(known.offline || []);
const serverCached = new Set(known.serverCached || []);
const current = queue[index];
const currentId = typeof current === 'string' ? current : current && current.id;
const seen = new Set(currentId ? [currentId] : []);
const ids = [];
for (let step = 1; step < queue.length + (repeatAll ? 1 : 0) && ids.length < count; step++) {
let at = index + step;
if (at >= queue.length) {
if (!repeatAll) break;
at %= queue.length;
}
const item = queue[at];
const id = typeof item === 'string' ? item : item && item.id;
if (!id || seen.has(id)) continue;
seen.add(id);
if (offline.has(id) || serverCached.has(id) || (item && item.serverCached)) continue;
ids.push(id);
}
return ids;
}
return { candidates };
});

View File

@@ -0,0 +1,26 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { candidates } = require('./prewarm-next.js');
const q = ['a', 'b', 'c', 'd'].map(id => ({ id }));
test('returns the next n queue ids in existing order', () => {
assert.deepEqual(candidates(q, 0, {}, 2), ['b', 'c']);
});
test('repeat all wraps and does not duplicate ids', () => {
assert.deepEqual(candidates(q, 3, { repeatMode: 'all' }, 2), ['a', 'b']);
assert.deepEqual(candidates([{ id: 'x' }, { id: 'x' }], 0, { repeatMode: 'all' }, 3), []);
});
test('repeat off stops at the end and loop one has no candidates', () => {
assert.deepEqual(candidates(q, 3, {}, 2), []);
assert.deepEqual(candidates(q, 0, { loopOne: true, repeatMode: 'all' }, 2), []);
});
test('skips offline and known server cached ids and deduplicates', () => {
assert.deepEqual(candidates(q, 0, {}, 3, { offline: ['b'], serverCached: ['c'] }), ['d']);
assert.deepEqual(candidates([{ id: 'a' }, { id: 'b', serverCached: true }, { id: 'c' }], 0, {}, 2), ['c']);
});
test('shuffle follows the queue order selected before advance', () => {
const shuffled = ['a', 'd', 'b', 'c'].map(id => ({ id }));
assert.deepEqual(candidates(shuffled, 0, { shuffle: true }, 2), ['d', 'b']);
});

View File

@@ -27,3 +27,33 @@ test('every SHELL asset exists on disk and there are no duplicate entries', () =
assert.equal(new Set(shell).size, shell.length, 'SHELL has no duplicate entries'); assert.equal(new Set(shell).size, shell.length, 'SHELL has no duplicate entries');
for (const asset of shell) assert.ok(existsSync(join(__dirname, asset.slice(1))), `${asset} exists`); for (const asset of shell) assert.ok(existsSync(join(__dirname, asset.slice(1))), `${asset} exists`);
}); });
test('assets.json describes the complete existing shell exactly once with eager core features', () => {
const { groups } = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8'));
const described = new Set();
for (const [name, group] of Object.entries(groups)) {
assert.equal(group.contract, 1, `${name} initial contract`);
assert.equal(typeof group.eager, 'boolean');
for (const asset of group.files) {
assert.ok(!described.has(asset), `${asset} has one group`);
described.add(asset);
assert.ok(existsSync(join(__dirname, asset.slice(1))), `${asset} exists`);
}
}
for (const asset of shell) if (asset !== '/') assert.ok(described.has(asset), `${asset} is described`);
// piano-engine is dynamically imported and historically absent from SHELL.
for (const asset of described) assert.ok(listed.has(asset) || asset === '/piano-engine.mjs', `${asset} shell or documented dynamic import`);
assert.equal(groups.core.eager, true);
for (const name of ['playlist', 'settings', 'service-mode', 'lyrics-editor']) {
assert.equal(groups[`feature:${name}`].eager, true);
for (const asset of groups[`feature:${name}`].embeddedIn) assert.ok(groups.core.files.includes(asset), `${name} remains in core`);
}
});
test('the selected glass-stage layout includes its controls and panel dependencies', () => {
const { groups } = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8'));
assert.deepEqual(groups['layout:glass-stage'].files, [
'/theme-glass.css', '/theme-glass-controls.css', '/glass-controls.js',
'/theme-glass-panel.css', '/glass-panel-layout.js',
]);
});

View File

@@ -96,12 +96,6 @@ const SHELL = [
'/p2p-transfer.js', '/p2p-transfer.js',
'/p2p-recv-worker.js', '/p2p-recv-worker.js',
'/settings-sections.js', '/settings-sections.js',
'/vendor/jamesdsp/controller.js',
'/vendor/jamesdsp/jamesdsp-node.js',
'/vendor/jamesdsp/worklet.js',
'/vendor/jamesdsp/params.js',
'/vendor/jamesdsp/dsp/jamesdsp.js',
'/vendor/jamesdsp/dsp/jamesdsp.wasm',
'/related-videos.js', '/related-videos.js',
'/related-videos.css', '/related-videos.css',
'/audio-continuity.js', '/audio-continuity.js',
@@ -131,6 +125,7 @@ const SHELL = [
'/piano.js', '/piano.js',
'/setlist-import.js', '/setlist-import.js',
'/car-mode.js', '/car-mode.js',
'/prewarm-next.js',
'/car-mode.css', '/car-mode.css',
'/lower-third.css', '/lower-third.css',
'/settings-sections.css', '/settings-sections.css',

View File

@@ -1 +0,0 @@
JamesDSP Web port files in this directory are licensed under GPL-3.0-or-later; see the upstream project LICENSE and upstream notices for provenance. The upstream README notes that its bundled original JamesDSP license text says GPL-2.0, so recipients should retain the upstream notices when redistributing.

View File

@@ -1,56 +0,0 @@
import { JamesDSPNode } from './jamesdsp-node.js';
const presets = {
Flat: {},
'Bass boost': { 'bass.enabled': 1, 'bass.boost': 6, 'eq.enabled': 1, 'eq.band0': 3, 'eq.band1': 2, 'gain': -3 },
Vocal: { 'eq.enabled': 1, 'eq.band3': -2, 'eq.band5': 2, 'eq.band7': 3, 'eq.band9': 2, 'eq.band11': -1 },
'Headphone crossfeed': { 'crossfeed.enabled': 1, 'crossfeed.mode': 1 },
Loudness: { 'limiter.threshold': -1, 'gain': 2 },
};
export class JamesDSPController {
constructor() { this.context = null; this.node = null; this.source = null; this.element = null; this.sources = new WeakMap(); }
async enable(element, settings) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) throw new Error('Web Audio is not supported in this browser');
if (!this.context) this.context = new AC();
if (![44100, 48000].includes(this.context.sampleRate)) throw new Error(`JamesDSP supports 44.1 or 48 kHz; this device uses ${this.context.sampleRate} Hz`);
if (!this.node) {
this.node = await JamesDSPNode.create(this.context);
this.node.addEventListener('error', (event) => this.fault(event.detail));
}
if (this.element !== element) {
if (this.source) { this.source.disconnect(this.node); this.source.connect(this.context.destination); }
this.source = this.sources.get(element) || this.context.createMediaElementSource(element);
this.sources.set(element, this.source);
this.node.connect(this.source);
this.element = element;
}
await this.node.setBypass(false);
await this.apply(settings);
if (this.context.state === 'suspended') await this.context.resume();
}
async apply(settings) {
if (!this.node) return;
const preset = presets[settings.preset] || presets.Flat;
const params = { ...preset, ...(settings.params || {}) };
await this.node.setBypass(true);
await this.node.applyPreset(params);
await this.node.setBypass(false);
}
async update(name, value, settings) {
settings.params ||= {};
settings.params[name] = value;
await this.apply(settings);
}
async disable() {
if (this.node) await this.node.setBypass(true).catch(() => {});
}
fault(error) {
console.warn('[JamesDSP] Processing failed; routing media dry:', error);
try { this.source?.disconnect(this.node); this.source?.connect(this.context.destination); } catch {}
this.node?.destroy(); this.node = null; this.source = null; this.element = null;
this.onError?.(error || new Error('Audio processor failed'));
}
}
export const JAMESDSP_PRESETS = Object.keys(presets);

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -1,72 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
import {DEFAULT_PARAMS, validateParam} from './params.js';
const moduleLoads = new WeakMap();
export class JamesDSPNode extends EventTarget {
static async create(context, {wasmURL=new URL('./dsp/jamesdsp.wasm',import.meta.url), workletURL=new URL('./worklet.js',import.meta.url)}={}) {
if (![44100,48000].includes(context.sampleRate)) throw new RangeError('Use a 44.1 or 48 kHz AudioContext');
if (!moduleLoads.has(context)) moduleLoads.set(context,new Map());
const cache=moduleLoads.get(context), key=String(workletURL);
if (!cache.has(key)) cache.set(key,context.audioWorklet.addModule(workletURL).catch(e=>{cache.delete(key);throw e;}));
const response=await fetch(wasmURL);
if (!response.ok) throw new Error(`WASM fetch failed: ${response.status}`);
const wasmBytes=await response.arrayBuffer();
await cache.get(key);
const wrapper=new JamesDSPNode(context,wasmBytes);
try { await wrapper.ready; return wrapper; } catch(e) { wrapper.destroy(); throw e; }
}
constructor(context,wasmBytes) {
super(); this.context=context; this.params={...DEFAULT_PARAMS}; this.texts={}; this.pending=new Map(); this.serial=0; this.destroyed=false; this.source=null;
this.node=new AudioWorkletNode(context,'jamesdsp',{numberOfInputs:1,numberOfOutputs:1,outputChannelCount:[2],channelCount:2,channelCountMode:'explicit',processorOptions:{wasmBytes}});
this.ready=new Promise((resolve,reject)=> {
const timer=setTimeout(()=>reject(new Error('DSP initialization timed out')),15000);
this.node.onprocessorerror=()=>{const error=new Error('DSP processor stopped');clearTimeout(timer);reject(error);this.fail(error);};
this.node.port.onmessage=({data})=> {
if(data.type==='ready') {clearTimeout(timer);resolve();}
else if(data.type==='reply') { const p=this.pending.get(data.id); if(p) {this.pending.delete(data.id);clearTimeout(p.timer);data.error?p.reject(new Error(data.error)):p.resolve(data.result);} }
else if(data.type==='error') {const error=new Error(data.error); clearTimeout(timer); reject(error); this.fail(error);}
else this.dispatchEvent(new CustomEvent(data.type,{detail:data}));
};
});
}
fail(error) { for(const p of this.pending.values()) {clearTimeout(p.timer);p.reject(error);} this.pending.clear();this.dispatchEvent(new CustomEvent('error',{detail:error})); }
request(type,data={}) {
if(this.destroyed) return Promise.reject(new Error('DSP has been destroyed'));
const id=++this.serial;
return new Promise((resolve,reject)=> {
const timer=setTimeout(()=>{this.pending.delete(id);reject(new Error(`DSP ${type} timed out`));},15000);
this.pending.set(id,{resolve,reject,timer});this.node.port.postMessage({type,id,...data});
});
}
// One source → this DSP → destination. For arbitrary graphs use .node directly.
connect(source,destination=this.context.destination) {
if(this.destroyed) throw new Error('DSP has been destroyed');
if(this.source) this.source.disconnect(this.node);
this.node.disconnect();this.source=source;source.connect(this.node);this.node.connect(destination);return destination;
}
async setParam(name,value) {validateParam(name,value);await this.request('param',{name,value});this.params[name]=value;}
setBypass(enabled) {return this.setParam('bypass',Number(Boolean(enabled)));}
async setText(effect,text) {
if(!['liveprog','ddc','arbitrary'].includes(effect) || typeof text!=='string' || text.length>65536) throw new RangeError('Invalid text effect');
await this.request('text',{effect,text});this.texts[effect]=text;this.params[`${effect}.enabled`]=0;
}
async loadIR(buffer) {
if(buffer.sampleRate!==this.context.sampleRate || ![1,2].includes(buffer.numberOfChannels) || buffer.length>65536 || buffer.length<1) throw new RangeError('IR must be mono/stereo, at context rate, at most 65536 frames');
const pcm=new Float32Array(buffer.length*buffer.numberOfChannels);
for(let ch=0;ch<buffer.numberOfChannels;ch++) {const data=buffer.getChannelData(ch);for(let i=0;i<buffer.length;i++) pcm[i*buffer.numberOfChannels+ch]=data[i];}
await this.request('ir',{pcm,frames:buffer.length,channels:buffer.numberOfChannels});this.params['convolver.enabled']=0;
}
async applyPreset(preset) {
// All keys validated before mutation; applying a preset resets unspecified params.
const params={...DEFAULT_PARAMS,...(preset.params||preset)};
for(const [name,value] of Object.entries(params)) validateParam(name,value);
for(const [effect,text] of Object.entries(preset.texts||{})) await this.setText(effect,text);
// Configure before enabling; text and IR resources must have been loaded.
for(const [name,value] of Object.entries(params).sort(([a],[b])=>Number(a.endsWith('.enabled'))-Number(b.endsWith('.enabled')))) await this.setParam(name,value);
}
toJSON() {return {version:1,params:{...this.params},texts:{...this.texts}};}
destroy() {
if(this.destroyed) return;
if(this.source) {try {this.source.disconnect(this.node);} catch {}} this.node.disconnect();
this.node.port.postMessage({type:'destroy'});this.destroyed=true;this.fail(new Error('DSP destroyed'));this.node.port.close();
}
}

View File

@@ -1,27 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
export const EQ_FREQUENCIES = [25,40,63,100,160,250,400,630,1000,1600,2500,4000,6300,10000,16000];
export const COMP_FREQUENCIES = [95,200,400,800,1600,3400,7500];
export const EFFECTS = ['eq','compressor','bass','stereo','reverb','crossfeed','tube','convolver','liveprog','ddc','arbitrary'];
export const PARAMS = {
bypass:[0,1,0], gain:[-24,12,0],
'limiter.threshold':[-24,0,-0.1], 'limiter.release':[10,1000,100],
'eq.mode':[0,5,1], 'eq.interpolation':[0,1,0],
'compressor.time':[0.01,2,0.1], 'compressor.mode':[0,3,3], 'compressor.granularity':[0,4,0],
'bass.boost':[0,12,6], 'stereo.mix':[0,1,0.5], 'reverb.preset':[0,18,0],
'crossfeed.mode':[0,5,0], 'tube.drive':[-12,12,0],
};
for (const effect of EFFECTS) PARAMS[`${effect}.enabled`] = [0,1,0];
EQ_FREQUENCIES.forEach((_, i) => PARAMS[`eq.band${i}`] = [-12,12,0]);
COMP_FREQUENCIES.forEach((_, i) => PARAMS[`compressor.band${i}`] = [-12,12,0]);
export const DEFAULT_PARAMS = Object.fromEntries(Object.entries(PARAMS).map(([k,v])=>[k,v[2]]));
export function validateParam(name, value) {
const spec=PARAMS[name];
if (!spec || typeof value!=='number' || !Number.isFinite(value) || value<spec[0] || value>spec[1]) throw new RangeError(`Invalid parameter ${name}: ${value}`);
if ((name.endsWith('.enabled') || /mode|preset|granularity|interpolation|bypass/.test(name)) && !Number.isInteger(value)) throw new RangeError(`${name} requires an integer`);
}
export const PRESETS = {
Flat: {},
'Warm lows': {'eq.band0':4,'eq.band1':4,'eq.band2':3,'eq.band3':2,'eq.enabled':1,'gain':-3},
'Headphones': {'crossfeed.enabled':1,'crossfeed.mode':1,'eq.band8':-2,'eq.enabled':1},
'Small room': {'reverb.preset':7,'reverb.enabled':1,'gain':-3},
};

View File

@@ -1,55 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
import createJamesDSP from './dsp/jamesdsp.js';
class JamesDSPProcessor extends AudioWorkletProcessor {
constructor(options) {
super(); this.closed=false;this.failed=false;
this.initialize(options);
}
async initialize(options) {
try {
this.wasm=await createJamesDSP({wasmBinary:options.processorOptions.wasmBytes,locateFile:()=>'jamesdsp.wasm',print:()=>{},printErr:()=>{}});
this.handle=this.wasm._jdsp_create(sampleRate);
this.pointer=this.wasm._malloc(128*2*4);
if(!this.handle || !this.pointer) throw new Error('DSP allocation failed or sample rate unsupported');
this.audio=this.wasm.HEAPF32.subarray(this.pointer/4,this.pointer/4+256);
this.port.onmessage=({data})=>this.message(data);
this.port.postMessage({type:'ready'});
} catch(error) {this.failed=true;this.port.postMessage({type:'error',error:String(error)});}
}
string(value,fn) {
const utf8=encodeURIComponent(value+'\0').replace(/%([0-9A-F]{2})/g,(_,hex)=>String.fromCharCode(parseInt(hex,16)));
const bytes=Uint8Array.from(utf8,c=>c.charCodeAt(0)), p=this.wasm._malloc(bytes.length);
if(!p) throw new Error('DSP text allocation failed');
try {this.wasm.HEAPU8.set(bytes,p);return fn(p);} finally {this.wasm._free(p);}
}
message(data) {
if(data.type==='destroy') {if(this.handle) this.wasm._jdsp_destroy(this.handle);if(this.pointer) this.wasm._free(this.pointer);this.closed=true;return;}
try {
let result;
if(data.type==='param') result=this.string(data.name,p=>this.wasm._jdsp_set_param(this.handle,p,data.value));
else if(data.type==='text') result=this.string(data.effect,e=>this.string(data.text,t=>this.wasm._jdsp_set_text(this.handle,e,t)));
else if(data.type==='ir') {
const p=this.wasm._malloc(data.pcm.byteLength);if(!p) throw new Error('IR allocation failed');
try {this.wasm.HEAPF32.set(data.pcm,p/4);result=this.wasm._jdsp_load_ir(this.handle,p,data.frames,data.channels);} finally {this.wasm._free(p);}
} else throw new Error('Unknown DSP message');
if(result!==0) throw new Error(`DSP rejected ${data.name||data.effect||data.type}`);
this.port.postMessage({type:'reply',id:data.id,result});
} catch(error) {this.port.postMessage({type:'reply',id:data.id,error:String(error)});}
}
process(inputs,outputs) {
if(this.closed) return false;
const out=outputs[0], input=inputs[0];
if(!out?.length || this.failed || !this.handle) return true;
// Mono duplicates to stereo; missing input becomes silence, preserving tails.
for(let offset=0;offset<out[0].length;offset+=128) {
const n=Math.min(128,out[0].length-offset);
for(let i=0;i<n;i++) {this.audio[i*2]=input[0]?.[offset+i]||0;this.audio[i*2+1]=(input[1]||input[0])?.[offset+i]||0;}
try {
if(this.wasm._jdsp_process(this.handle,this.pointer,n)!==0) throw new Error('DSP produced nonfinite output');
for(let i=0;i<n;i++) {out[0][offset+i]=this.audio[i*2];if(out[1]) out[1][offset+i]=this.audio[i*2+1];}
} catch(error) {out.forEach(channel=>channel.fill(0));this.failed=true;this.port.postMessage({type:'error',error:String(error)});break;}
}
return true;
}
}
registerProcessor('jamesdsp',JamesDSPProcessor);

View File

@@ -141,3 +141,43 @@ node perf/hidden-timers.mjs
``` ```
Exits with code 0 on success, or code 1 if any interval fails to pause, fails to resume, or if page errors occur. Exits with code 0 on success, or code 1 if any interval fails to pause, fails to resume, or if page errors occur.
## Phase 1 production-client migration
Build the production fixture locally without contacting the unavailable production
site (COMMON.md takes precedence over the older live-tag instructions above):
```bash
perf/make-shell-fixture.sh b77938a 1175f1a1d2c1
node perf/migration.mjs # Chromium and WebKit
node perf/migration.mjs --browser webkit # one browser
```
The harness archives the actual `b77938a` server and uses the reconstructed old
frontend, isolated database/media/upload paths, and an ephemeral origin behind the
`lossy` throttle/stall proxy. It restarts onto the current server and frontend at
that same origin, then invokes the OLD application's update check and clicks its
actual Refresh UI button. Assertions cover one banner, exactly one reload, cleared
update-attempt state, all new SHELL entries with correct hashes, no script errors,
and offline boot. Plain, stale `?v=<oldTag>` and `?__ytpfresh=` asset responses are
also compared byte-for-byte with the current files. Results are stored in
`perf/results/phase1-migration-2026-10-07.json`; scratch trees are removed on exit.
No service-worker source is patched or simulated. Offline checks stop Bun and
block the proxy; WebKit avoids its broken native setOffline API. Known WebKit
transport errors for disconnected version/recommendation requests are verified
separately from script errors.
The current server snapshots static bytes at boot; restart it after frontend
changes. `ASSET_HASHING=0` restores the previous single-tag stamp/cache behavior.
The manifest's tag hashes sorted canonical metadata: CSS/JS URLs are stamped
before hashing the index, with its derived build-meta placeholder intact. The
published index/SW hashes describe their final served bytes after tag injection;
these derived hashes are not fed recursively into their own build identity.
JS-created workers/importScripts/dynamic imports retain their existing plain URLs
in Phase 1. Adding a separate loader would require changing the intentionally
unchanged SHELL; worker import URL propagation belongs with the client migration.
WASM binaries are embedded in the existing `sha256-wasm.js`/`loudness-wasm.js`,
which receive hashed URLs as HTML scripts. Font preloads retain plain URLs to match
`fonts.css`; the webmanifest and its icon URLs also stay plain and precached.
`piano-engine.mjs` is manifested but is historically absent from SHELL; this
existing offline piano limitation is deferred to the loader phase.

176
perf/migration.mjs Normal file
View File

@@ -0,0 +1,176 @@
#!/usr/bin/env node
// Real production-client migration through the unchanged Refresh UI flow.
import assert from 'node:assert/strict';
import { spawn, execFileSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, symlinkSync, unlinkSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { createServer } from 'node:net';
import { fileURLToPath } from 'node:url';
import { createThrottleProxy } from './proxy.mjs';
import { chromium, webkit } from 'playwright';
const repo = resolve(fileURLToPath(new URL('..', import.meta.url)));
const commit = 'b77938a', oldTag = '1175f1a1d2c1';
const args = process.argv.slice(2);
const browserName = args.includes('--browser') ? args[args.indexOf('--browser') + 1] : 'all';
assert.ok(['all', 'chromium', 'webkit'].includes(browserName));
execFileSync('bash', [join(repo, 'perf/make-shell-fixture.sh'), commit, oldTag], { cwd: repo, stdio: 'inherit' });
const scratch = mkdtempSync(join(repo, 'perf/.tmp/migration-'));
const archive = join(scratch, 'old-server.tar');
writeFileSync(archive, execFileSync('git', ['archive', commit, 'server'], { cwd: repo, maxBuffer: 20 * 1024 * 1024 }));
execFileSync('tar', ['-xf', archive, '-C', scratch]);
symlinkSync(join(repo, 'server/node_modules'), join(scratch, 'server/node_modules'));
// Archived server modules resolve their Docker-style public imports relative
// to the module, while static routes resolve ./public relative to cwd.
symlinkSync(join(repo, `perf/fixtures/shell-${oldTag}`), join(scratch, 'server/public'));
const shell = [...readFileSync(join(repo, 'frontend/sw.js'), 'utf8').match(/const SHELL = \[([\s\S]*?)\n\];/)[1].matchAll(/['"](\/[^'"]+)['"]/g)].map(m => m[1]);
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
async function freePort() {
const server = createServer();
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const port = server.address().port;
await new Promise(resolve => server.close(resolve));
return port;
}
async function run(name) {
const cwd = join(scratch, name);
mkdirSync(cwd);
const port = await freePort();
let child, output = '', browser, proxy;
async function stop() {
if (!child) return;
const process = child; child = null;
await new Promise(resolve => {
if (process.exitCode !== null) return resolve();
process.once('exit', resolve); process.kill('SIGTERM');
});
}
async function start(old) {
symlinkSync(old ? join(repo, `perf/fixtures/shell-${oldTag}`) : join(repo, 'frontend'), join(cwd, 'public'));
output = '';
child = spawn('bun', [old ? join(scratch, 'server/server.js') : join(repo, 'server/server.js')], {
cwd, env: { ...process.env, BUILD_TAG: '', ASSET_HASHING: old ? '0' : '1', PORT: String(port),
DATA_DIR: join(cwd, 'data'), DB_PATH: join(cwd, 'db.sqlite'), MEDIA_DIR: join(cwd, 'media'), UPLOAD_DIR: join(cwd, 'uploads') },
stdio: ['ignore', 'pipe', 'pipe'],
});
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => { output = (output + chunk).slice(-12000); });
for (let i = 0; i < 200; i++) {
if (child.exitCode !== null) throw new Error(`Bun server exited: ${output}`);
try { if ((await fetch(`http://127.0.0.1:${port}/api/version`)).ok) return; } catch {}
await sleep(100);
}
throw new Error(`Bun startup timeout: ${output}`);
}
try {
await start(true);
assert.equal((await (await fetch(`http://127.0.0.1:${port}/api/version`)).json()).buildTag, oldTag);
proxy = createThrottleProxy({ targetPort: port, profile: 'lossy' });
const proxyPort = await proxy.listen();
const origin = `http://127.0.0.1:${proxyPort}`;
browser = await ({ chromium, webkit }[name]).launch({ headless: true });
const context = await browser.newContext();
// Keep this harness entirely local, including any UI third-party requests.
await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.abort());
let banners = 0, navigations = 0;
const errors = [];
const failedRequests = [];
let disconnected = false;
const page = await context.newPage();
await page.exposeFunction('migrationBanner', () => { banners++; });
await page.addInitScript(() => {
let visible = false;
new MutationObserver(() => {
const modal = document.querySelector('#modal');
const now = !!modal && !modal.classList.contains('hidden') && /Update available/.test(document.querySelector('#modalTitle')?.textContent || '');
if (now && !visible) window.migrationBanner();
visible = now;
}).observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
});
page.on('pageerror', error => errors.push({ message: error.message, disconnected }));
page.on('requestfailed', request => failedRequests.push(request.url()));
page.on('framenavigated', frame => { if (frame === page.mainFrame()) navigations++; });
await page.goto(origin, { waitUntil: 'load', timeout: 90000 });
await page.waitForFunction(() => !!navigator.serviceWorker.controller, { timeout: 90000 });
assert.equal(await page.locator('meta[name="ytp-build"]').getAttribute('content'), oldTag);
assert.equal(banners, 0, 'no first-install banner');
await stop(); unlinkSync(join(cwd, 'public')); await start(false);
const backend = `http://127.0.0.1:${port}`;
const manifest = await (await fetch(`${backend}/api/manifest`)).json();
assert.notEqual(manifest.buildTag, oldTag);
// Old URLs must continue delivering actual NEW bytes, not error/fallback HTML.
for (const path of ['/app.js', '/theme-glass.css', '/opfs-worker.js', '/sha256-wasm.js', '/manifest.webmanifest', '/icons/icon-192.png', '/fonts/HankenGrotesk-latin.woff2']) {
for (const query of ['', '?__ytpfresh=migration', `?v=${oldTag}`]) {
const response = await fetch(`${backend}${path}${query}`);
assert.equal(response.status, 200);
assert.equal(response.headers.get('x-asset-hash'), manifest.files[path].h);
assert.deepEqual(Buffer.from(await response.arrayBuffer()), readFileSync(join(repo, 'frontend', path.slice(1))));
if (query.includes('?v=')) assert.equal(response.headers.get('cache-control'), 'no-store');
}
}
proxy.resetStats(); proxy.startTracking();
const started = Date.now();
// Use the old application's public functions; no simulated update helper.
await page.evaluate(async () => {
await checkBuildTag();
await (await navigator.serviceWorker.getRegistration()).update();
});
await page.waitForFunction(async () => !!(await navigator.serviceWorker.getRegistration())?.waiting, { timeout: 90000 });
await page.waitForFunction(() => /Update available/.test(document.querySelector('#modalTitle')?.textContent || '') && !document.querySelector('#modal').classList.contains('hidden'));
assert.equal(banners, 1, 'exactly one offer before refresh');
const before = navigations;
await page.locator('#modal').getByRole('button', { name: 'Refresh UI', exact: true }).click();
await page.waitForFunction(tag => document.querySelector('meta[name="ytp-build"]')?.content === tag && typeof checkBuildTag === 'function', manifest.buildTag, { timeout: 90000 });
await page.waitForFunction(() => !sessionStorage.getItem('ytpUpdateAttempt'));
await page.evaluate(async () => { await checkBuildTag(); await checkBuildTag(); await checkBuildTag(); });
await sleep(2000);
assert.equal(navigations - before, 1, 'one reload, no update loop');
assert.equal(banners, 1, 'no banner after applying the current build');
const cacheResult = await page.evaluate(async ({ tag, shell, files }) => {
const cache = await caches.open(`ytplayer-${tag}`);
const missing = [], incorrect = [];
for (const path of shell) {
const response = await cache.match(path);
if (!response) { missing.push(path); continue; }
if (response.headers.get('x-asset-hash') !== files[path === '/' ? '/index.html' : path]?.h) incorrect.push(path);
}
return { missing, incorrect, count: (await cache.keys()).length };
}, { tag: manifest.buildTag, shell, files: manifest.files });
assert.deepEqual(cacheResult.missing, []);
assert.deepEqual(cacheResult.incorrect, [], 'every cached shell asset belongs to the new build');
proxy.stopTracking();
const stats = proxy.getStats();
const durationMs = Date.now() - started;
assert.deepEqual(errors, [], 'no script failures during installation and update');
disconnected = true;
// Match baseline.mjs: Linux WebKit's setOffline breaks even SW navigation.
// Cut the proxy AND stop Bun so offline boot cannot use network bytes.
if (name !== 'webkit') await context.setOffline(true);
proxy.setOffline(true);
await stop();
await page.reload({ waitUntil: 'load', timeout: 60000 });
assert.equal(await page.locator('meta[name="ytp-build"]').getAttribute('content'), manifest.buildTag);
await page.waitForFunction(() => typeof checkBuildTag === 'function' && !!document.querySelector('#cards')?.dataset.view);
assert.equal(banners, 1);
// WebKit surfaces rejected SW network fetches as pageerrors even when the
// app catches them. Check only the exact, intentional offline API failures;
// syntax/reference/runtime exceptions must still fail this harness.
for (const error of errors) {
assert.equal(name, 'webkit', 'only WebKit has offline transport pageerrors');
assert.equal(error.disconnected, true, 'transport errors only after disconnection');
assert.ok(error.message === 'TypeError: Load failed' || /\/api\/(?:recommendations\?[^\s]*|version)\.$/.test(error.message), `unexpected offline error: ${error.message}`);
}
if (errors.length) assert.ok(failedRequests.some(url => /\/api\/(?:recommendations|version)/.test(url)), 'transport errors correspond to disconnected API requests');
const result = { browser: name, oldTag, newTag: manifest.buildTag, banners, updateReloads: 1, cachedFiles: cacheResult.count, durationMs, wireBytes: stats.totalWireBytes, offline: true, legacyURLs: 21, profile: 'lossy', offlineTransportErrors: errors.length };
console.log(JSON.stringify(result));
return result;
} finally {
await browser?.close();
if (proxy) { proxy.server.closeAllConnections(); await proxy.close(); }
await stop();
}
}
try {
const results = [];
for (const name of browserName === 'all' ? ['chromium', 'webkit'] : [browserName]) results.push(await run(name));
writeFileSync(join(repo, 'perf/results/phase1-migration-2026-10-07.json'), JSON.stringify({ commit, results }, null, 2) + '\n');
} finally { rmSync(scratch, { recursive: true, force: true }); }

View File

@@ -0,0 +1,128 @@
{
"date": "2026-10-07T14:58:19.058Z",
"commit": "6cb00d5073931325b15ec5b773bf15dc2b53816e",
"runsConfigured": 3,
"browsers": {
"chromium": {
"lte": {
"cold": {
"requestCount": {
"median": 168,
"min": 168,
"max": 168,
"values": [
168,
168,
168
]
},
"wireBytes": {
"median": 1042903,
"min": 1042903,
"max": 1042903,
"values": [
1042903,
1042903,
1042903
]
},
"fcp": {
"median": 1576,
"min": 1536,
"max": 1604,
"values": [
1604,
1536,
1576
]
},
"lcp": {
"median": 1924,
"min": 1892,
"max": 1960,
"values": [
1960,
1892,
1924
]
},
"domContentLoaded": {
"median": 10331,
"min": 10304,
"max": 11004,
"values": [
11004,
10304,
10331
]
},
"bootDone": {
"median": 10466,
"min": 10425,
"max": 11184,
"values": [
11184,
10425,
10466
]
},
"longTaskTotal": {
"median": 450,
"min": 379,
"max": 481,
"values": [
481,
379,
450
]
},
"swInstallDuration": {
"median": 40643,
"min": 40439,
"max": 41259,
"values": [
41259,
40439,
40643
]
},
"swInstallBytes": {
"median": 1042903,
"min": 1042903,
"max": 1042903,
"values": [
1042903,
1042903,
1042903
]
},
"storageEstimate": {
"usage": 3490858,
"quota": 5372199978
},
"mediaReady": {
"median": null,
"min": null,
"max": null,
"values": [
null,
null,
null
]
},
"autoplayBlocked": true,
"wireBytesByType": {
"html": 28587,
"js": 479281,
"css": 130718,
"fonts": 397072,
"images": 6278,
"media": 0,
"api": 336,
"other": 631
}
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
{
"commit": "b77938a",
"results": [
{
"browser": "chromium",
"oldTag": "1175f1a1d2c1",
"newTag": "ee1f3de99a02",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 87,
"durationMs": 19381,
"wireBytes": 1191657,
"offline": true,
"legacyURLs": 21,
"profile": "lossy",
"offlineTransportErrors": 0
},
{
"browser": "webkit",
"oldTag": "1175f1a1d2c1",
"newTag": "ee1f3de99a02",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 87,
"durationMs": 15257,
"wireBytes": 668490,
"offline": true,
"legacyURLs": 21,
"profile": "lossy",
"offlineTransportErrors": 4
}
]
}

View File

@@ -0,0 +1,296 @@
{
"date": "2026-10-07T15:08:29.812Z",
"commit": "d18d17cf29778f0f3afaccb9df308de0f3a1e46c",
"runsConfigured": 1,
"browsers": {
"chromium": {
"lte": {
"cold": {
"requestCount": {
"median": 168,
"min": 168,
"max": 168,
"values": [
168
]
},
"wireBytes": {
"median": 1042858,
"min": 1042858,
"max": 1042858,
"values": [
1042858
]
},
"fcp": {
"median": 1628,
"min": 1628,
"max": 1628,
"values": [
1628
]
},
"lcp": {
"median": 1976,
"min": 1976,
"max": 1976,
"values": [
1976
]
},
"domContentLoaded": {
"median": 11020,
"min": 11020,
"max": 11020,
"values": [
11020
]
},
"bootDone": {
"median": 11149,
"min": 11149,
"max": 11149,
"values": [
11149
]
},
"longTaskTotal": {
"median": 460,
"min": 460,
"max": 460,
"values": [
460
]
},
"swInstallDuration": {
"median": 41250,
"min": 41250,
"max": 41250,
"values": [
41250
]
},
"swInstallBytes": {
"median": 1042858,
"min": 1042858,
"max": 1042858,
"values": [
1042858
]
},
"storageEstimate": {
"usage": 3490858,
"quota": 5372199978
},
"mediaReady": {
"median": null,
"min": null,
"max": null,
"values": [
null
]
},
"autoplayBlocked": true,
"wireBytesByType": {
"html": 28542,
"js": 479281,
"css": 130718,
"fonts": 397072,
"images": 6278,
"media": 0,
"api": 336,
"other": 631
}
},
"warm": {
"requestCount": {
"median": 5,
"min": 5,
"max": 5,
"values": [
5
]
},
"wireBytes": {
"median": 10581,
"min": 10581,
"max": 10581,
"values": [
10581
]
},
"fcp": {
"median": 280,
"min": 280,
"max": 280,
"values": [
280
]
},
"lcp": {
"median": 992,
"min": 992,
"max": 992,
"values": [
992
]
},
"domContentLoaded": {
"median": 787,
"min": 787,
"max": 787,
"values": [
787
]
},
"bootDone": {
"median": 974,
"min": 974,
"max": 974,
"values": [
974
]
},
"longTaskTotal": {
"median": 287,
"min": 287,
"max": 287,
"values": [
287
]
}
},
"offline": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"fcp": {
"median": 100,
"min": 100,
"max": 100,
"values": [
100
]
},
"lcp": {
"median": 100,
"min": 100,
"max": 100,
"values": [
100
]
},
"domContentLoaded": {
"median": 296,
"min": 296,
"max": 296,
"values": [
296
]
},
"bootDone": {
"median": 327,
"min": 327,
"max": 327,
"values": [
327
]
},
"reloadDuration": {
"median": 349,
"min": 349,
"max": 349,
"values": [
349
]
}
},
"updateJs": {
"requestCount": {
"median": 90,
"min": 90,
"max": 90,
"values": [
90
]
},
"wireBytes": {
"median": 613029,
"min": 613029,
"max": 613029,
"values": [
613029
]
},
"duration": {
"median": 2088,
"min": 2088,
"max": 2088,
"values": [
2088
]
},
"wireBytesByType": {
"html": 18996,
"js": 267655,
"css": 65359,
"fonts": 254048,
"images": 6278,
"media": 0,
"api": 62,
"other": 631
}
},
"updateCss": {
"requestCount": {
"median": 90,
"min": 90,
"max": 90,
"values": [
90
]
},
"wireBytes": {
"median": 613046,
"min": 613046,
"max": 613046,
"values": [
613046
]
},
"duration": {
"median": 1997,
"min": 1997,
"max": 1997,
"values": [
1997
]
},
"wireBytesByType": {
"html": 18996,
"js": 267644,
"css": 65387,
"fonts": 254048,
"images": 6278,
"media": 0,
"api": 62,
"other": 631
}
}
}
}
}
}

View File

@@ -0,0 +1,296 @@
{
"date": "2026-10-07T15:10:34.781Z",
"commit": "84546eeb7da50fdfcfadb5368cbdf9054b014b9e",
"runsConfigured": 1,
"browsers": {
"chromium": {
"lte": {
"cold": {
"requestCount": {
"median": 168,
"min": 168,
"max": 168,
"values": [
168
]
},
"wireBytes": {
"median": 1042858,
"min": 1042858,
"max": 1042858,
"values": [
1042858
]
},
"fcp": {
"median": 1564,
"min": 1564,
"max": 1564,
"values": [
1564
]
},
"lcp": {
"median": 1920,
"min": 1920,
"max": 1920,
"values": [
1920
]
},
"domContentLoaded": {
"median": 11001,
"min": 11001,
"max": 11001,
"values": [
11001
]
},
"bootDone": {
"median": 11137,
"min": 11137,
"max": 11137,
"values": [
11137
]
},
"longTaskTotal": {
"median": 430,
"min": 430,
"max": 430,
"values": [
430
]
},
"swInstallDuration": {
"median": 41164,
"min": 41164,
"max": 41164,
"values": [
41164
]
},
"swInstallBytes": {
"median": 1042858,
"min": 1042858,
"max": 1042858,
"values": [
1042858
]
},
"storageEstimate": {
"usage": 3490858,
"quota": 5372199978
},
"mediaReady": {
"median": null,
"min": null,
"max": null,
"values": [
null
]
},
"autoplayBlocked": true,
"wireBytesByType": {
"html": 28542,
"js": 479281,
"css": 130718,
"fonts": 397072,
"images": 6278,
"media": 0,
"api": 336,
"other": 631
}
},
"warm": {
"requestCount": {
"median": 5,
"min": 5,
"max": 5,
"values": [
5
]
},
"wireBytes": {
"median": 10581,
"min": 10581,
"max": 10581,
"values": [
10581
]
},
"fcp": {
"median": 172,
"min": 172,
"max": 172,
"values": [
172
]
},
"lcp": {
"median": 648,
"min": 648,
"max": 648,
"values": [
648
]
},
"domContentLoaded": {
"median": 531,
"min": 531,
"max": 531,
"values": [
531
]
},
"bootDone": {
"median": 641,
"min": 641,
"max": 641,
"values": [
641
]
},
"longTaskTotal": {
"median": 148,
"min": 148,
"max": 148,
"values": [
148
]
}
},
"offline": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"fcp": {
"median": 80,
"min": 80,
"max": 80,
"values": [
80
]
},
"lcp": {
"median": 240,
"min": 240,
"max": 240,
"values": [
240
]
},
"domContentLoaded": {
"median": 211,
"min": 211,
"max": 211,
"values": [
211
]
},
"bootDone": {
"median": 232,
"min": 232,
"max": 232,
"values": [
232
]
},
"reloadDuration": {
"median": 254,
"min": 254,
"max": 254,
"values": [
254
]
}
},
"updateJs": {
"requestCount": {
"median": 90,
"min": 90,
"max": 90,
"values": [
90
]
},
"wireBytes": {
"median": 613029,
"min": 613029,
"max": 613029,
"values": [
613029
]
},
"duration": {
"median": 1891,
"min": 1891,
"max": 1891,
"values": [
1891
]
},
"wireBytesByType": {
"html": 18996,
"js": 267655,
"css": 65359,
"fonts": 254048,
"images": 6278,
"media": 0,
"api": 62,
"other": 631
}
},
"updateCss": {
"requestCount": {
"median": 88,
"min": 88,
"max": 88,
"values": [
88
]
},
"wireBytes": {
"median": 607743,
"min": 607743,
"max": 607743,
"values": [
607743
]
},
"duration": {
"median": 1890,
"min": 1890,
"max": 1890,
"values": [
1890
]
},
"wireBytesByType": {
"html": 18996,
"js": 262403,
"css": 65387,
"fonts": 254048,
"images": 6278,
"media": 0,
"api": 0,
"other": 631
}
}
}
}
}
}

40
plans/phase1-plan.md Normal file
View File

@@ -0,0 +1,40 @@
# Phase 1 — per-file asset hashes and server manifest
1. Add tests first in `server/asset-manifest.test.js`, deliberately extend
`server/static-delivery.test.js` and `frontend/shell-consistency.test.js`.
2. Describe the existing shell in `frontend/assets.json`: ordered file groups,
contract 1, eager core/settings/playlist/service-mode/lyrics-editor. Preserve
index script order and both service-worker sources byte-for-byte.
3. Add importable `server/asset-manifest.js`; integrate boot-time recursive
hashing, stamped index, manifest endpoint, per-file headers and rollback into
`server/server.js`. Register module tests in `server/package.json`.
4. Stamp HTML CSS/JS resource URLs. Keep font preloads consistent with
fonts.css and retain the existing webmanifest URL. Retain
plain JS-created worker/import URLs: introducing a new helper would require
modifying the unchanged worker's shell or frontend callers. Inventory these
exceptions explicitly in the report. Hash every recursively served file,
including files outside the shell, so the banner never misses a change.
5. Extract `b77938a` using `perf/make-shell-fixture.sh` with an explicitly
computed expected tag (never query production). Add `perf/migration.mjs`
and instructions to `perf/README.md`; test old-client Refresh UI, a single
banner, complete new cache, no loop, offline reload, and legacy URL bodies
on Chromium and WebKit. Run reduced LTE cold/warm/offline baselines.
6. Review against master plan §3.1/§6b; commit `plans/phase1-report.md` (≤350
words) with commit IDs, measurements, exceptions and iPhone checks; write
DONE01 to the queue signal only after verification succeeds.
Hash self-reference: stamp resource URLs first; hash a canonical index with its
build-meta placeholder intact to derive the manifest build tag. Stamp that tag
and expose the actual final index byte hash. Canonical tag computation normalizes
only this derived index field, avoiding a cryptographic fixed-point requirement.
SW response hashing must likewise describe injected bytes rather than raw source.
Rollback: `ASSET_HASHING=0` restores the existing recursive build tag and legacy
single-tag CSS/JS stamping/header behavior. Default ON; test both modes.
Risks: old-worker ignoreSearch, stalled installation, mutable stale URLs,
recursive paths, derived HTML/SW hashes, shared iOS cache/audio quota.
Acceptance: both unit suites green after each implementation commit; no weakened
characterization tests; migration passes in both browsers; reduced baseline cold,
warm and offline boot succeeds with no first-paint regression. Phase 1 still
fetches the whole shell on update; <30 KB CSS-only updates belong to Phase 2.

44
plans/phase1-report.md Normal file
View File

@@ -0,0 +1,44 @@
# Phase 1 report
Shipped: `6cb00d5` adds asset groups/contracts, boot snapshots, per-file hashes,
HTML stamping, `/api/manifest`, ETag/304, hash headers and `ASSET_HASHING=0`.
`d18d17c` preserves the exact legacy index/binary cache rules and pins SW bytes.
Plan: `ad85cb6`; `84546ee` groups Glass Stage dependencies together.
Migration harness/results: `7c45d09`.
Verification: 156 frontend and 178 server tests pass. Server tests used an
isolated scratch Python environment with yt-dlp installed; none were skipped.
Both service-worker sources, index source, playback and timers remain unchanged.
Production fixture: `b77938a` → `1175f1a1d2c1`, reproduced without production calls.
Chromium/WebKit migration passes through the lossy/stalling proxy: one banner,
one update reload, all 87 shell entries verified, 21 legacy URL/body checks,
and offline boot with Bun stopped. Migration took 19.4/15.3 s respectively.
WebKit's known disconnected API transport errors are checked separately; script
errors during installation/update fail the harness.
Reduced Chromium LTE: three-run cold median before final regrouping 10,466 ms
vs 10,480 ms. Final standalone single-run cold: 11,137 ms (+6.3%); FCP
1,564 ms vs 1,572 ms; wire bytes 1,042,858 (+0.3%). Warm/offline boots:
641/232 ms vs 692/259 ms. Overlapped-run timings are retained separately.
JS-requested assets receiving hashed URLs: **none**. HTML CSS/JS references are
hashed, including sha256-wasm.js/loudness-wasm.js (embedded WASM; no standalone
.wasm fetch). Plain worker URLs: opfs-worker.js, hash-worker.js,
direct-recv-worker.js, p2p-recv-worker.js. Plain worker imports: sha256.js,
resume-core.js, sha256-wasm.js. Plain dynamic loads: piano-engine.mjs,
vendor/framework7-swipe.min.js. Propagating URL maps safely needs caller/worker
changes and a cached bootstrap; retain the unchanged client protocol this phase.
All are manifested; piano-engine.mjs is historically absent from SHELL, an
existing offline limitation deferred to the loader phase.
Also plain: manifest.webmanifest; icons/icon-{192,512}.png;
fonts/{BricolageGrotesque,HankenGrotesk,JetBrainsMono,PlusJakartaSans}-{latin,latin-ext}.woff2.
Font preloads must match fonts.css URLs; manifest icon paths remain unchanged.
Build identity hashes sorted canonical metadata with the index build-meta
placeholder intact; published index/SW hashes describe final served bytes,
avoiding self-reference. Restart after frontend edits; hashing mode ignores
BUILD_TAG overrides. Incremental downloads/lazy parsing await later phases.
Owner iPhone checks: install/update, offline launch, active layout, then 30-minute
locked playback, dropouts, battery use and resume. No push, merge or deployment.

View File

@@ -1,6 +0,0 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests', testMatch: /jamesdsp\.spec\.js/, timeout: 60000, workers: 1,
use: { baseURL: 'http://localhost:8088', browserName: 'chromium', serviceWorkers: 'block', viewport: { width: 1280, height: 900 } },
webServer: { command: 'npx serve frontend -l 8088 --no-clipboard', port: 8088, reuseExistingServer: !process.env.CI },
});

91
server/asset-manifest.js Normal file
View File

@@ -0,0 +1,91 @@
// Boot-time snapshot of ./public, independent of frontend source locations.
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { join } from 'node:path';
export const assetHash = bytes => createHash('sha256').update(bytes).digest('hex').slice(0, 10);
const tagHash = bytes => createHash('sha256').update(bytes).digest('hex').slice(0, 12);
export const injectBuildTag = (source, tag) => source.replace(
/typeof __BUILD_TAG__ !== 'undefined' \? __BUILD_TAG__ : '[^']*'/, JSON.stringify(tag));
export function stampIndex(source, files, { hashing = true, buildTag = '__BUILD_TAG__' } = {}) {
if (!hashing) return source.replace('__BUILD_TAG__', buildTag)
.replace(/((?:href|src)=")((?![a-z]+:|\/)[^"?]+\.(?:css|js))(")/g, `$1$2?v=${buildTag}$3`);
// Rewrite resource tags only, not anchors. Preserve unrelated query/fragment
// components and absolute external URLs; include root-relative local URLs.
return source.replace(/<(?:script|link)\b[^>]*>/gi, tag => tag.replace(
/\b(href|src)=(['"])([^'"]+)\2/gi, (attribute, name, quote, url) => {
if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(url)) return attribute;
const parsed = new URL(url, 'https://assets.invalid/');
const file = files[decodeURIComponent(parsed.pathname)];
// Font preload URLs must stay identical to fonts.css's relative URLs.
if (!file || !/\.(?:css|js)$/.test(parsed.pathname) || parsed.pathname === '/sw.js') return attribute;
parsed.searchParams.set('v', file.h);
const originalPath = url.split(/[?#]/)[0];
return `${name}=${quote}${originalPath}${parsed.search}${parsed.hash}${quote}`;
})).replace('__BUILD_TAG__', buildTag);
}
export function createAssetManifest(publicDir = './public', { hashing = true, buildTag: override } = {}) {
const bytes = new Map();
const legacy = createHash('sha256');
function walk(dir, prefix = '') {
for (const name of readdirSync(dir).sort()) {
const path = join(dir, name), url = `${prefix}/${name}`;
if (statSync(path).isDirectory()) walk(path, url);
else {
const content = readFileSync(path);
bytes.set(url, content);
legacy.update(`./public${url}`); legacy.update(content);
}
}
}
// Missing public is the historical fixed fallback used by local tests.
if (!existsSync(publicDir)) return { manifest: { buildTag: override || 'dev-build', files: {}, groups: {}, contracts: {} }, index: null, sw: null, swSource: null, bytes };
walk(publicDir);
const definition = bytes.has('/assets.json') ? JSON.parse(bytes.get('/assets.json').toString()) : {
groups: { core: { contract: 1, eager: true, files: [...bytes.keys()].sort() } },
};
const groups = {}, contracts = {}, membership = new Map();
for (const name of Object.keys(definition.groups).sort()) {
const group = definition.groups[name];
if (!Number.isInteger(group.contract) || group.contract < 1 || typeof group.eager !== 'boolean' || !Array.isArray(group.files)) throw new Error(`Invalid asset group ${name}`);
groups[name] = group; contracts[name] = group.contract;
for (const url of group.files) {
if (!bytes.has(url)) throw new Error(`Asset group ${name}: missing ${url}`);
if (membership.has(url)) throw new Error(`Duplicate asset membership: ${url}`);
membership.set(url, name);
}
}
const files = {};
for (const url of [...bytes.keys()].sort()) files[url] = { h: assetHash(bytes.get(url)), s: bytes.get(url).length, g: membership.get(url) || 'core' };
const source = bytes.get('/index.html')?.toString() ?? null;
const swSource = bytes.get('/sw.js')?.toString() ?? null;
// Derived build metadata cannot be an input to its own hash. Canonicalize
// the index meta and SW injected tag, then publish hashes of the final bytes.
// All original bytes (including index/SW source) remain inputs via source hashes.
let canonicalIndex = source === null ? null : stampIndex(source, files, { hashing });
const canonical = { files: { ...files }, groups, contracts };
if (canonicalIndex !== null && hashing) canonical.files['/index.html'] = { ...files['/index.html'], h: assetHash(canonicalIndex), s: Buffer.byteLength(canonicalIndex), source: files['/index.html'].h };
const buildTag = hashing ? tagHash(JSON.stringify(canonical)) : (override || legacy.digest('hex').slice(0, 12));
const index = source === null ? null : stampIndex(source, files, { hashing, buildTag });
const sw = swSource === null ? null : injectBuildTag(swSource, buildTag);
if (index !== null) files['/index.html'] = { ...files['/index.html'], h: assetHash(index), s: Buffer.byteLength(index) };
if (sw !== null) files['/sw.js'] = { ...files['/sw.js'], h: assetHash(sw), s: Buffer.byteLength(sw) };
if (index !== null) bytes.set('/index.html', Buffer.from(index));
if (sw !== null) bytes.set('/sw.js', Buffer.from(sw));
return { manifest: { buildTag, files, groups, contracts }, index, sw, swSource, bytes };
}
export function assetCacheControl(path, version, manifest, hashing = true) {
const shortCache = /(^|\/)(fonts|icons)\//.test(path) ? 'public, max-age=2592000' : 'no-cache';
// The legacy index route always revalidates; legacy binary delivery ignored v.
if (!hashing && path === '/index.html') return 'no-cache';
if (!hashing && !/\.(?:js|css|html|json|webmanifest|svg|txt)$/i.test(path)) return shortCache;
const current = hashing ? manifest.files[path]?.h : manifest.buildTag;
if (version !== undefined) {
if (version === current) return 'public, max-age=31536000, immutable';
return hashing ? 'no-store' : 'no-cache';
}
return /(^|\/)(fonts|icons)\//.test(path) && (hashing || !/\.(?:js|css|html|json|webmanifest|svg|txt)$/i.test(path)) ? 'public, max-age=2592000' : 'no-cache';
}

View File

@@ -0,0 +1,62 @@
import { test, expect } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { createAssetManifest } from './asset-manifest.js';
const hash = value => createHash('sha256').update(value).digest('hex').slice(0, 10);
function fixture(fn) {
const dir = mkdtempSync(join(tmpdir(), 'ytp-manifest-'));
try {
writeFileSync(join(dir, 'index.html'), '<meta name="ytp-build" content="__BUILD_TAG__"><link href="/app.css"><script src="app.js?old=1"></script><script src="https://example.com/x.js"></script>');
writeFileSync(join(dir, 'app.js'), 'window.app=1');
writeFileSync(join(dir, 'app.css'), 'body{}');
writeFileSync(join(dir, 'sw.js'), "const BUILD_TAG = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'old';");
writeFileSync(join(dir, 'assets.json'), JSON.stringify({ groups: { core: { contract: 1, eager: true, files: ['/index.html', '/app.js', '/app.css'] } } }));
fn(dir);
} finally { rmSync(dir, { recursive: true, force: true }); }
}
test('manifest determinism, final served byte hashes and one-file URL isolation', () => fixture(dir => {
const first = createAssetManifest(dir);
expect(createAssetManifest(dir)).toEqual(first);
expect(first.manifest.buildTag).toMatch(/^[a-f0-9]{12}$/);
expect(first.manifest.files['/app.js']).toEqual({ h: hash('window.app=1'), s: 12, g: 'core' });
expect(first.manifest.contracts.core).toBe(1);
expect(first.manifest.groups.core.eager).toBe(true);
expect(first.index).toContain(`/app.css?v=${first.manifest.files['/app.css'].h}`);
expect(first.index).toContain(`app.js?old=1&v=${first.manifest.files['/app.js'].h}`);
expect(first.index).toContain('https://example.com/x.js');
expect(first.index).toContain(`content="${first.manifest.buildTag}"`);
expect(first.manifest.files['/index.html'].h).toBe(hash(first.index));
expect(first.manifest.files['/sw.js'].h).toBe(hash(first.sw));
writeFileSync(join(dir, 'app.js'), 'window.app=2');
const second = createAssetManifest(dir);
expect(second.manifest.buildTag).not.toBe(first.manifest.buildTag);
for (const path of ['/app.css', '/assets.json']) expect(second.manifest.files[path]).toEqual(first.manifest.files[path]);
expect(second.manifest.files['/app.js'].h).not.toBe(first.manifest.files['/app.js'].h);
}));
test('unlisted files affect build identity; group contracts and order affect identity', () => fixture(dir => {
const first = createAssetManifest(dir);
writeFileSync(join(dir, 'other.bin'), 'shipped');
const second = createAssetManifest(dir);
expect(second.manifest.buildTag).not.toBe(first.manifest.buildTag);
expect(second.manifest.files['/other.bin'].h).toBe(hash('shipped'));
writeFileSync(join(dir, 'assets.json'), JSON.stringify({ groups: { core: { contract: 2, eager: true, files: ['/app.css', '/app.js', '/index.html'] } } }));
expect(createAssetManifest(dir).manifest.buildTag).not.toBe(second.manifest.buildTag);
}));
test('invalid group definitions fail visibly', () => fixture(dir => {
writeFileSync(join(dir, 'assets.json'), JSON.stringify({ groups: { core: { contract: 1, eager: true, files: ['/missing.js'] } } }));
expect(() => createAssetManifest(dir)).toThrow('missing');
}));
test('rollback retains legacy tag algorithm and relative-only single-tag stamping', () => fixture(dir => {
const result = createAssetManifest(dir, { hashing: false });
const expected = createHash('sha256');
for (const name of ['app.css', 'app.js', 'assets.json', 'index.html', 'sw.js']) {
expected.update(`./public/${name}`);
const { readFileSync } = require('node:fs');
expected.update(readFileSync(join(dir, name)));
}
expect(result.manifest.buildTag).toBe(expected.digest('hex').slice(0, 12));
expect(result.index).toContain('href="/app.css"');
}));

View File

@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"start": "bun server.js", "start": "bun server.js",
"dev": "bun --hot server.js", "dev": "bun --hot server.js",
"test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js && bun test ./static-delivery.test.js" "test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js && bun test ./asset-manifest.test.js && bun test ./static-delivery.test.js"
}, },
"dependencies": { "dependencies": {
"@hono/node-server": "^1.14.0", "@hono/node-server": "^1.14.0",

View File

@@ -30,10 +30,10 @@
* UI code works without modification in WEB mode. * UI code works without modification in WEB mode.
* ========================================================================== */ * ========================================================================== */
import { createAssetManifest, assetCacheControl } from './asset-manifest.js';
import { registerDownloadRetryRoute } from './download-retry.js'; import { registerDownloadRetryRoute } from './download-retry.js';
import { createResilientYtdlp, BOT_CHECK_RE, DEFAULT_FALLBACK_CLIENTS } from './ytdlp-resilience.js'; import { createResilientYtdlp, BOT_CHECK_RE, DEFAULT_FALLBACK_CLIENTS } from './ytdlp-resilience.js';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { serveStatic } from 'hono/bun';
import { logger } from 'hono/logger'; import { logger } from 'hono/logger';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { createServer } from 'node:http'; import { createServer } from 'node:http';
@@ -96,48 +96,11 @@ function withSaveSlot(fn) {
return run; return run;
} }
// ---------------------------------------------------------------------------- // Snapshot every shipped public file. Hashes describe the served HTML/SW bytes;
// BUILD_TAG — must be DETERMINISTIC across restarts of identical code. // rollback retains the historical recursive tag and single-tag URL stamping.
// const ASSET_HASHING = process.env.ASSET_HASHING !== '0';
// Previously this was `Date.now().toString(36)`, which changes every time the const assets = createAssetManifest('./public', { hashing: ASSET_HASHING, buildTag: process.env.BUILD_TAG });
// process starts even if nothing was deployed (crash-loop, healthcheck const BUILD_TAG = assets.manifest.buildTag;
// restart, container reschedule). The frontend's checkBuildTag() polls
// /api/version and re-shows the "Update available" modal the instant the tag
// drifts — so a restarting-but-unchanged server kept re-announcing an update
// that never actually happened, and clicking "Refresh UI" (which itself
// reloads the page and re-polls) never made the prompt go away for good.
//
// Fix: hash the actual served frontend files. Identical code → identical
// hash → identical tag, no matter how many times the process restarts. A
// real deploy (changed files) still produces a new tag as intended.
// process.env.BUILD_TAG still wins if a CI pipeline already injects a git
// SHA — that's an even better source of truth than a content hash.
// ----------------------------------------------------------------------------
function computeBuildTag() {
try {
// Hash EVERY served frontend file (recursively, in sorted order), not a
// hand-picked subset — a change to any shell file (e.g. sw-update.js or
// opfs.js) must produce a new tag, or clients keep their old SW cache
// and never receive the change.
const hash = createHash('sha256');
const walk = (dir) => {
for (const name of readdirSync(dir).sort()) {
const path = `${dir}/${name}`;
if (statSync(path).isDirectory()) walk(path);
else { hash.update(path); hash.update(readFileSync(path)); }
}
};
walk('./public');
return hash.digest('hex').slice(0, 12);
} catch {
// Frontend files not readable (e.g. unit tests run outside ./public) —
// fall back to a fixed tag rather than Date.now(), so it still never
// drifts spuriously between restarts.
return 'dev-build';
}
}
const BUILD_TAG = process.env.BUILD_TAG || computeBuildTag();
// BUILD_TIME — human-readable "when was this image built". Written by the // BUILD_TIME — human-readable "when was this image built". Written by the
// Dockerfile at image build time (never at container start, so restarts // Dockerfile at image build time (never at container start, so restarts
@@ -387,8 +350,16 @@ registerCatalogRoutes(app, { resolveListener: recommendationListener });
// ============================================================================ // ============================================================================
// GET /api/version // GET /api/version
// Returns version string + a build tag that changes on every server restart/deploy. // Returns version and a deterministic tag that changes only with shipped files.
// Clients poll this to detect when a new build is live and prompt a reload. // Clients poll this to detect when a new build is live and prompt a reload.
app.get('/api/manifest', (c) => {
const etag = `"${BUILD_TAG}"`;
const headers = { 'Cache-Control': 'no-store', ETag: etag };
const matches = (c.req.header('if-none-match') || '').split(',').map(value => value.trim().replace(/^W\//, ''));
if (matches.includes(etag) || matches.includes('*')) return new Response(null, { status: 304, headers });
return c.json(assets.manifest, 200, headers);
});
app.get('/api/version', (c) => app.get('/api/version', (c) =>
c.json( c.json(
{ version: APP_VERSION, buildTag: BUILD_TAG, buildTime: BUILD_TIME }, { version: APP_VERSION, buildTag: BUILD_TAG, buildTime: BUILD_TIME },
@@ -2461,15 +2432,9 @@ app.post('/api/admin/p2p/revoke', notes.requireAdminOrToken, async (c) => {
// Served with no-store cache headers so browsers always re-fetch it and // Served with no-store cache headers so browsers always re-fetch it and
// pick up the substituted value rather than a browser-cached stale copy. // pick up the substituted value rather than a browser-cached stale copy.
// ============================================================================ // ============================================================================
let _swSource = null; const _swSource = assets.swSource;
app.get('/sw.js', (c) => { app.get('/sw.js', (c) => {
if (!_swSource) { if (_swSource === null) return c.text('Service worker not found', 404);
try {
_swSource = readFileSync('./public/sw.js', 'utf8');
} catch {
return c.text('Service worker not found', 404);
}
}
// Inject the build tag: replace the whole fallback expression with the // Inject the build tag: replace the whole fallback expression with the
// real value. Matched by REGEX, not an exact string — an exact match broke // real value. Matched by REGEX, not an exact string — an exact match broke
// the moment the fallback literal in sw.js was bumped ('v1.0.3' → 'v1.0.4'), // the moment the fallback literal in sw.js was bumped ('v1.0.3' → 'v1.0.4'),
@@ -2482,7 +2447,7 @@ app.get('/sw.js', (c) => {
if (src === _swSource) { if (src === _swSource) {
console.error('[sw] BUILD_TAG injection failed — placeholder not found in sw.js'); console.error('[sw] BUILD_TAG injection failed — placeholder not found in sw.js');
} }
return sendCompressed(c, compressedEntry('sw.js', Buffer.from(src), MIME.js), 'no-store, no-cache, must-revalidate'); return sendCompressed(c, compressedEntry('sw.js', Buffer.from(src), MIME.js), 'no-store, no-cache, must-revalidate', assets.manifest.files['/sw.js']?.h);
}); });
// ============================================================================ // ============================================================================
@@ -2511,8 +2476,9 @@ function compressedEntry(key, raw, type) {
compressedCache.set(key, e); compressedCache.set(key, e);
return e; return e;
} }
function sendCompressed(c, e, cacheControl) { function sendCompressed(c, e, cacheControl, hash) {
const headers = { 'Content-Type': e.type, 'Cache-Control': cacheControl, ETag: e.etag, Vary: 'Accept-Encoding' }; const headers = { 'Content-Type': e.type, 'Cache-Control': cacheControl, ETag: e.etag, Vary: 'Accept-Encoding' };
if (hash) headers['X-Asset-Hash'] = hash;
const inm = c.req.header('if-none-match') || ''; const inm = c.req.header('if-none-match') || '';
if (inm.split(',').map((s) => s.trim()).includes(e.etag)) return new Response(null, { status: 304, headers }); if (inm.split(',').map((s) => s.trim()).includes(e.etag)) return new Response(null, { status: 304, headers });
const ae = c.req.header('accept-encoding') || ''; const ae = c.req.header('accept-encoding') || '';
@@ -2523,42 +2489,28 @@ function sendCompressed(c, e, cacheControl) {
const MIME = { js: 'text/javascript; charset=utf-8', css: 'text/css; charset=utf-8', html: 'text/html; charset=utf-8', const MIME = { js: 'text/javascript; charset=utf-8', css: 'text/css; charset=utf-8', html: 'text/html; charset=utf-8',
json: 'application/json', webmanifest: 'application/manifest+json', svg: 'image/svg+xml', txt: 'text/plain; charset=utf-8' }; json: 'application/json', webmanifest: 'application/manifest+json', svg: 'image/svg+xml', txt: 'text/plain; charset=utf-8' };
let _indexSource = null;
function indexHtml(c) { function indexHtml(c) {
if (_indexSource === null) { if (assets.index === null) return c.text('index.html not found', 404);
try { _indexSource = readFileSync('./public/index.html', 'utf8'); } return sendCompressed(c, compressedEntry('index.html', Buffer.from(assets.index), MIME.html),
catch { return c.text('index.html not found', 404); } assetCacheControl('/index.html', c.req.query('v'), assets.manifest, ASSET_HASHING), assets.manifest.files['/index.html']?.h);
}
// Local css/js get `?v=<build>`: that URL's bytes never change, so the static
// handler below lets browsers keep it forever; a new build is a new URL.
const html = _indexSource.replace('__BUILD_TAG__', BUILD_TAG)
.replace(/((?:href|src)=")((?![a-z]+:|\/)[^"?]+\.(?:css|js))(")/g, `$1$2?v=${BUILD_TAG}$3`);
return sendCompressed(c, compressedEntry('index.html', Buffer.from(html), MIME.html), 'no-cache');
} }
app.get('/', indexHtml); app.get('/', indexHtml);
app.get('/index.html', indexHtml); app.get('/index.html', indexHtml);
// no-cache (revalidate every time) on the shell: the service worker is the // Serve every file with its per-file hash, including binary icons/fonts and
// only cache that should hold app files. With no headers at all, a browser // unversioned URLs requested by old clients/workers. A stale version gets the
// may heuristically cache app.js and hand a new worker's install the old one. // current body with no-store, never immutable caching under a mismatched URL.
app.get('/*', async (c, next) => { app.get('/*', async (c, next) => {
const p = decodeURIComponent(new URL(c.req.url).pathname); const p = decodeURIComponent(new URL(c.req.url).pathname);
if (!COMPRESSIBLE.test(p) || p.includes('..') || p.startsWith('/api/')) return next(); if (p.includes('..') || p.startsWith('/api/') || !assets.manifest.files[p]) return next();
const file = Bun.file('./public' + p); const file = Bun.file('./public' + p);
if (!(await file.exists())) return next(); const raw = assets.bytes.get(p);
const raw = Buffer.from(await file.arrayBuffer());
const ext = p.slice(p.lastIndexOf('.') + 1).toLowerCase(); const ext = p.slice(p.lastIndexOf('.') + 1).toLowerCase();
// `?v=` equal to the running build → forever (a new build changes the URL). const hash = assets.manifest.files[p].h;
// Any other / missing v keeps revalidating. const cacheControl = assetCacheControl(p, c.req.query('v'), assets.manifest, ASSET_HASHING);
const forever = c.req.query('v') === BUILD_TAG; if (COMPRESSIBLE.test(p)) return sendCompressed(c, compressedEntry(p, raw, MIME[ext] || file.type), cacheControl, hash);
return sendCompressed(c, compressedEntry(p, raw, MIME[ext] || 'application/octet-stream'), return new Response(raw, { headers: { 'Content-Type': file.type, 'Cache-Control': cacheControl, 'X-Asset-Hash': hash } });
forever ? 'public, max-age=31536000, immutable' : 'no-cache');
}); });
// Self-hosted fonts and icons rarely change: let the browser keep them for 30 days
// (the service worker precaches the shell anyway); everything else revalidates.
app.use('/*', serveStatic({ root: './public', onFound: (path, c) => {
c.header('Cache-Control', /(^|\/)(fonts|icons)\//.test(path) ? 'public, max-age=2592000' : 'no-cache');
} }));
// SPA fallback — return index.html for any unmatched path // SPA fallback — return index.html for any unmatched path
app.get('/*', indexHtml); app.get('/*', indexHtml);

View File

@@ -32,11 +32,11 @@ async function freePort() {
}); });
} }
async function startServer() { async function startServer(hashing = true) {
const port = await freePort(); const port = await freePort();
const child = Bun.spawn([process.execPath, serverFile], { const child = Bun.spawn([process.execPath, serverFile], {
cwd: root, cwd: root,
env: { ...process.env, PORT: String(port), DB_PATH: join(dataDir, `db-${port}.sqlite`), MEDIA_DIR: join(dataDir, 'media'), UPLOAD_DIR: join(dataDir, 'uploads') }, env: { ...process.env, PORT: String(port), ASSET_HASHING: hashing ? '1' : '0', BUILD_TAG: '', DB_PATH: join(dataDir, `db-${port}.sqlite`), MEDIA_DIR: join(dataDir, 'media'), UPLOAD_DIR: join(dataDir, 'uploads') },
stdout: 'pipe', stderr: 'pipe', stdout: 'pipe', stderr: 'pipe',
}); });
const stdout = new Response(child.stdout).text(); const stdout = new Response(child.stdout).text();
@@ -94,23 +94,23 @@ describe('static delivery characterization', () => {
expect(fallback.buildTag).toBe('dev-build'); expect(fallback.buildTag).toBe('dev-build');
}); });
test('index stamps only relative local css/js and serves expected cache headers', async () => { test('index stamps relative and root-local css/js and serves expected cache headers', async () => {
const index = await fetch(`${server.base}/`); const index = await fetch(`${server.base}/`);
const html = await index.text(); const html = await index.text();
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json(); const { buildTag, files } = await (await fetch(`${server.base}/api/manifest`)).json();
expect(html).toContain(`<meta name="ytp-build" content="${buildTag}">`); expect(html).toContain(`<meta name="ytp-build" content="${buildTag}">`);
expect(html).toContain(`href="app.css?v=${buildTag}"`); expect(html).toContain(`href="app.css?v=${files['/app.css'].h}"`);
expect(html).toContain(`src="app.js?v=${buildTag}"`); expect(html).toContain(`src="app.js?v=${files['/app.js'].h}"`);
expect(html).toContain('https://cdn.example/x.js'); expect(html).toContain('https://cdn.example/x.js');
expect(html).toContain('href="/root.css"'); expect(html).toContain(`href="/root.css?v=${files['/root.css'].h}"`);
expect(index.headers.get('cache-control')).toBe('no-cache'); expect(index.headers.get('cache-control')).toBe('no-cache');
expect((await fetch(`${server.base}/index.html`)).headers.get('cache-control')).toBe('no-cache'); expect((await fetch(`${server.base}/index.html`)).headers.get('cache-control')).toBe('no-cache');
const current = await fetch(`${server.base}/app.js?v=${buildTag}`, { headers: { 'Accept-Encoding': 'br' } }); const current = await fetch(`${server.base}/app.js?v=${files['/app.js'].h}`, { headers: { 'Accept-Encoding': 'br' } });
expect(current.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); expect(current.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
expect(current.headers.get('content-encoding')).toBe('br'); expect(current.headers.get('content-encoding')).toBe('br');
expect(current.headers.get('etag')).toBeTruthy(); expect(current.headers.get('etag')).toBeTruthy();
expect((await fetch(`${server.base}/app.js?v=stale`)).headers.get('cache-control')).toBe('no-cache'); expect((await fetch(`${server.base}/app.js?v=stale`)).headers.get('cache-control')).toBe('no-store');
expect((await fetch(`${server.base}/app.js`)).headers.get('cache-control')).toBe('no-cache'); expect((await fetch(`${server.base}/app.js`)).headers.get('cache-control')).toBe('no-cache');
expect((await fetch(`${server.base}/fonts/font.woff2`)).headers.get('cache-control')).toBe('public, max-age=2592000'); expect((await fetch(`${server.base}/fonts/font.woff2`)).headers.get('cache-control')).toBe('public, max-age=2592000');
expect((await fetch(`${server.base}/icons/icon.png`)).headers.get('cache-control')).toBe('public, max-age=2592000'); expect((await fetch(`${server.base}/icons/icon.png`)).headers.get('cache-control')).toBe('public, max-age=2592000');
@@ -118,11 +118,56 @@ describe('static delivery characterization', () => {
expect((await fetch(`${server.base}${path}`)).headers.get('cache-control')).toBe('no-store, no-cache, must-revalidate'); expect((await fetch(`${server.base}${path}`)).headers.get('cache-control')).toBe('no-store, no-cache, must-revalidate');
} }
const etag = current.headers.get('etag'); const etag = current.headers.get('etag');
const notModified = await fetch(`${server.base}/app.js?v=${buildTag}`, { headers: { 'If-None-Match': etag } }); const notModified = await fetch(`${server.base}/app.js?v=${files['/app.js'].h}`, { headers: { 'If-None-Match': etag } });
expect(notModified.status).toBe(304); expect(notModified.status).toBe(304);
expect(await notModified.text()).toBe(''); expect(await notModified.text()).toBe('');
}); });
test('manifest API, ETag/304 and hashes cover compressed and binary assets', async () => {
const response = await fetch(`${server.base}/api/manifest`);
expect(response.headers.get('cache-control')).toBe('no-store');
const manifest = await response.json();
expect(manifest.buildTag).toBe((await (await fetch(`${server.base}/api/version`)).json()).buildTag);
expect(manifest.groups.core.eager).toBe(true);
expect(manifest.contracts.core).toBe(1);
expect(response.headers.get('etag')).toBe(`"${manifest.buildTag}"`);
const unchanged = await fetch(`${server.base}/api/manifest`, { headers: { 'If-None-Match': response.headers.get('etag') } });
expect(unchanged.status).toBe(304);
expect(await unchanged.text()).toBe('');
const { createHash } = await import('node:crypto');
for (const path of ['/app.js', '/fonts/font.woff2', '/icons/icon.png', '/index.html', '/sw.js']) {
const h = manifest.files[path].h;
for (const query of ['', `?v=${h}`, '?v=oldTag', '?__ytpfresh=123', '?v=']) {
const asset = await fetch(`${server.base}${path}${query}`);
expect(asset.status).toBe(200);
expect(asset.headers.get('x-asset-hash')).toBe(h);
expect(createHash('sha256').update(Buffer.from(await asset.arrayBuffer())).digest('hex').slice(0, 10)).toBe(h);
if (path !== '/sw.js') {
if (query === `?v=${h}`) expect(asset.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
if (query === '?v=oldTag' || query === '?v=') expect(asset.headers.get('cache-control')).toBe('no-store');
}
}
}
const canonical = await (await fetch(`${server.base}/`)).text();
for (const path of ['/index.html', '/search', '/playlist/x']) expect(await (await fetch(`${server.base}${path}`)).text()).toBe(canonical);
});
test('ASSET_HASHING=0 restores single-tag stamping and old cache matrix', async () => {
await stopServer(server);
server = await startServer(false);
try {
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
const html = await (await fetch(`${server.base}/`)).text();
expect(html).toContain(`src="app.js?v=${buildTag}"`);
expect(html).toContain('href="/root.css"');
expect((await fetch(`${server.base}/app.js?v=${buildTag}`)).headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
expect((await fetch(`${server.base}/app.js?v=stale`)).headers.get('cache-control')).toBe('no-cache');
expect((await fetch(`${server.base}/index.html?v=${buildTag}`)).headers.get('cache-control')).toBe('no-cache');
expect((await fetch(`${server.base}/fonts/font.woff2?v=${buildTag}`)).headers.get('cache-control')).toBe('public, max-age=2592000');
expect((await fetch(`${server.base}/fonts/font.woff2?v=stale`)).headers.get('cache-control')).toBe('public, max-age=2592000');
} finally { await stopServer(server); server = await startServer(); }
});
test('service worker injection tracks fallback literal and reports missing placeholder', async () => { test('service worker injection tracks fallback literal and reports missing placeholder', async () => {
let sw = await (await fetch(`${server.base}/sw.js`)).text(); let sw = await (await fetch(`${server.base}/sw.js`)).text();
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json(); const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
@@ -147,6 +192,20 @@ describe('static delivery characterization', () => {
server = await startServer(); server = await startServer();
}); });
test('served bytes remain pinned to their boot-time hashes', async () => {
const before = await (await fetch(`${server.base}/app.js`)).text();
const swBefore = await (await fetch(`${server.base}/sw.js`)).text();
writeFileSync(join(publicDir, 'app.js'), 'changed after boot');
writeFileSync(join(publicDir, 'sw.js'), 'changed after boot');
try {
expect(await (await fetch(`${server.base}/app.js`)).text()).toBe(before);
expect(await (await fetch(`${server.base}/sw.js`)).text()).toBe(swBefore);
} finally {
writeFileSync(join(publicDir, 'app.js'), before);
writeFileSync(join(publicDir, 'sw.js'), "const BUILD_TAG = 'missing-placeholder';\n");
}
});
test('SPA routes fall back to the stamped index', async () => { test('SPA routes fall back to the stamped index', async () => {
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json(); const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
for (const path of ['/search', '/playlist/x']) { for (const path of ['/search', '/playlist/x']) {

View File

@@ -1,43 +0,0 @@
const { test, expect } = require('@playwright/test');
const { openClassic } = require('./helpers/classic-fixture');
function wavTone() {
const rate = 48000, frames = rate;
const b = Buffer.alloc(44 + frames * 2);
b.write('RIFF', 0); b.writeUInt32LE(b.length - 8, 4); b.write('WAVEfmt ', 8);
b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34);
b.write('data', 36); b.writeUInt32LE(frames * 2, 40);
for (let i = 0; i < frames; i++) b.writeInt16LE(Math.round(Math.sin(2 * Math.PI * 440 * i / rate) * 9000), 44 + i * 2);
return b;
}
test('JamesDSP stays lazy when off, plays a fixture tone when enabled, and persists settings', async ({ page }) => {
await page.route('**/tone.wav', route => route.fulfill({ contentType: 'audio/wav', body: wavTone() }));
await openClassic(page);
await page.evaluate(() => {
window.__audioContexts = 0;
const AC = window.AudioContext;
window.AudioContext = class extends AC { constructor(...args) { super(...args); window.__audioContexts++; } };
});
expect(await page.evaluate(() => window.__audioContexts)).toBe(0);
await page.locator('.nav-item[data-view="settings"]').click();
await page.locator('[data-section="jamesdsp"]').click();
await page.locator('#setJamesDsp').check();
await expect.poll(() => page.evaluate(() => window.__audioContexts)).toBe(1);
await page.evaluate(async () => {
const audio = document.getElementById('audio');
Player.mode = 'audio'; Player.master = audio; Player.secondary = null;
await JamesDSP.activate(audio);
audio.src = '/tone.wav'; audio.load(); await audio.play();
});
await expect.poll(() => page.locator('#audio').evaluate(a => a.currentTime)).toBeGreaterThan(0.1);
await page.waitForTimeout(600);
const reloaded = await page.context().newPage();
await reloaded.goto('/');
await reloaded.locator('.nav-item[data-view="settings"]').click();
await expect(reloaded.locator('[data-section="jamesdsp"]')).toBeVisible();
await reloaded.locator('[data-section="jamesdsp"]').click();
await expect(reloaded.locator('#setJamesDsp')).toBeChecked();
await expect(reloaded.locator('#setJamesDspPreset')).toHaveValue('Flat');
});