Compare commits
9 Commits
codex/jame
...
7434f2f486
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7434f2f486 | ||
|
|
7c45d098b1 | ||
|
|
84546eeb7d | ||
|
|
d18d17cf29 | ||
|
|
6cb00d5073 | ||
|
|
ad85cb6eb4 | ||
|
|
be1d939afd | ||
|
|
d65b9e032a | ||
|
|
fcd98b6a4d |
@@ -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
|
|
||||||
@@ -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
242
frontend/assets.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
37
frontend/prewarm-next.js
Normal 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 };
|
||||||
|
});
|
||||||
26
frontend/prewarm-next.test.js
Normal file
26
frontend/prewarm-next.test.js
Normal 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']);
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
1
frontend/vendor/jamesdsp/LICENSE.txt
vendored
1
frontend/vendor/jamesdsp/LICENSE.txt
vendored
@@ -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.
|
|
||||||
56
frontend/vendor/jamesdsp/controller.js
vendored
56
frontend/vendor/jamesdsp/controller.js
vendored
@@ -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);
|
|
||||||
2
frontend/vendor/jamesdsp/dsp/jamesdsp.js
vendored
2
frontend/vendor/jamesdsp/dsp/jamesdsp.js
vendored
File diff suppressed because one or more lines are too long
BIN
frontend/vendor/jamesdsp/dsp/jamesdsp.wasm
vendored
BIN
frontend/vendor/jamesdsp/dsp/jamesdsp.wasm
vendored
Binary file not shown.
72
frontend/vendor/jamesdsp/jamesdsp-node.js
vendored
72
frontend/vendor/jamesdsp/jamesdsp-node.js
vendored
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
27
frontend/vendor/jamesdsp/params.js
vendored
27
frontend/vendor/jamesdsp/params.js
vendored
@@ -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},
|
|
||||||
};
|
|
||||||
55
frontend/vendor/jamesdsp/worklet.js
vendored
55
frontend/vendor/jamesdsp/worklet.js
vendored
@@ -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);
|
|
||||||
@@ -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
176
perf/migration.mjs
Normal 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 }); }
|
||||||
128
perf/results/phase1-cold-2026-10-07.json
Normal file
128
perf/results/phase1-cold-2026-10-07.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
perf/results/phase1-migration-2026-10-07.json
Normal file
33
perf/results/phase1-migration-2026-10-07.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
296
perf/results/phase1-overlapped-2026-10-07.json
Normal file
296
perf/results/phase1-overlapped-2026-10-07.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
296
perf/results/phase1-reduced-2026-10-07.json
Normal file
296
perf/results/phase1-reduced-2026-10-07.json
Normal 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
40
plans/phase1-plan.md
Normal 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
44
plans/phase1-report.md
Normal 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.
|
||||||
@@ -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
91
server/asset-manifest.js
Normal 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';
|
||||||
|
}
|
||||||
62
server/asset-manifest.test.js
Normal file
62
server/asset-manifest.test.js
Normal 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"');
|
||||||
|
}));
|
||||||
@@ -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",
|
||||||
|
|||||||
112
server/server.js
112
server/server.js
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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']) {
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user