- frontend/wasm/loudness.c → loudness-wasm.js (1.5 KB, base64-embedded; built by scripts/build-loudness-wasm.sh with clang or Zig's clang): K-weighting + 100 ms energy steps. loudness.js does the 400 ms gating (abs -70 LUFS, rel -10 LU) with an identical JS fallback. EBU Tech 3341 sine cases measure -23.0/-33.0 on both engines; a real track reads -21.7 vs ffmpeg ebur128 -21.8. - The device measures from audio it already has (saved copy or the server cache's m4a sidecar, songs up to 12 min, decoded natively at 22.05 kHz) and reports the number to /api/loudness, so each song is measured once for everyone; the server stores numbers only, no audio processing. - The EQ graph gains a level stage (gain -> limiter) that eases each track toward -14 LUFS (-12..+9 dB). Settings -> Playback -> Level volume: on by default on Android/desktop, opt-in on iPhone (Web Audio stops sound when the screen locks there).
373 lines
15 KiB
JavaScript
373 lines
15 KiB
JavaScript
/* ============================================================================
|
|
* sw.js — YT Player Service Worker
|
|
*
|
|
* Strategy:
|
|
* App shell (HTML/CSS/JS) → cache-first, versioned cache
|
|
* /api/* requests → network-only (never cache yt-dlp results)
|
|
* /api/download/* → network-only (streamed binary, never cache)
|
|
* YouTube thumbnails (i.ytimg.com) → stale-while-revalidate
|
|
* Everything else → network, fallback to cache
|
|
*
|
|
* Auto-update flow:
|
|
* 1. New SW installs alongside the old one and waits (skipWaiting() is NOT
|
|
* called automatically — see the install handler below).
|
|
* 2. The client (app.js) shows the "Update available" banner only when the
|
|
* build it is running differs from the server's /api/version buildTag —
|
|
* a waiting worker or a broadcast is merely a prompt to re-check.
|
|
* 3. "Refresh UI" (sw-update.js) re-downloads every shell file into every
|
|
* versioned shell cache (all-or-nothing), activates a waiting worker if
|
|
* there is one (SKIP_WAITING), and reloads once.
|
|
* 4. activate: if an older *versioned shell cache* is found (i.e. this
|
|
* activation is genuinely replacing a previous deploy, not just the
|
|
* first-ever install of a freshly (re)registered worker), broadcast
|
|
* SW_UPDATE_AVAILABLE to all clients as a secondary/fallback signal.
|
|
* ========================================================================== */
|
|
|
|
// BUILD_TAG is injected by the server at request time (GET /sw.js).
|
|
// It changes on every deploy/restart so the cache is busted automatically
|
|
// without any manual version bump.
|
|
const VERSION = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v1.0.4';
|
|
const CACHE = 'ytplayer-' + VERSION;
|
|
|
|
// Prefix shared by every versioned app-shell cache (ytplayer-<VERSION>).
|
|
// Utility caches (ytplayer-thumbs, ytplayer-fonts) intentionally do NOT
|
|
// match this — they aren't versioned and must survive every activate.
|
|
const SHELL_CACHE_PREFIX = 'ytplayer-';
|
|
const UTILITY_CACHES = new Set(['ytplayer-thumbs', 'ytplayer-fonts']);
|
|
const THUMB_CACHE = 'ytplayer-thumbs';
|
|
|
|
// Hosts serving video thumbnails and channel avatars. yt-dlp hands back
|
|
// i.ytimg.com today, but the numbered mirrors and the ggpht/googleusercontent
|
|
// avatar hosts show up too — an unlisted host silently bypasses the cache and
|
|
// leaves a broken image offline, so cover them all.
|
|
const THUMB_HOSTS = new Set([
|
|
'i.ytimg.com', 'i1.ytimg.com', 'i2.ytimg.com', 'i3.ytimg.com', 'i4.ytimg.com', 'i9.ytimg.com',
|
|
'img.youtube.com', 'yt3.ggpht.com', 'yt3.googleusercontent.com', 'lh3.googleusercontent.com',
|
|
]);
|
|
|
|
// Thumbnails accumulate with every card ever scrolled past — keep it bounded.
|
|
const THUMB_CACHE_MAX = 25000;
|
|
function isVersionedShellCache(key) {
|
|
return key.startsWith(SHELL_CACHE_PREFIX) && !UTILITY_CACHES.has(key);
|
|
}
|
|
|
|
// Files that form the installable app shell.
|
|
const SHELL = [
|
|
'/',
|
|
'/index.html',
|
|
'/styles.css',
|
|
'/service-ui.css',
|
|
'/refresh.css',
|
|
'/theme-glass.css',
|
|
'/theme-classic.css',
|
|
'/recommendations.css',
|
|
'/flag-ui.js',
|
|
'/export.js',
|
|
'/async-guard.js',
|
|
'/sw-update.js',
|
|
'/fingerprint.js',
|
|
'/opfs.js',
|
|
'/opfs-worker.js',
|
|
'/video-edit.js',
|
|
'/lyrics-core.js',
|
|
'/stats-core.js',
|
|
'/sha256.js',
|
|
'/resume-core.js',
|
|
'/loudness-wasm.js',
|
|
'/loudness.js',
|
|
'/sha256-wasm.js',
|
|
'/device-db.js',
|
|
'/hash-worker.js',
|
|
'/p2p-client.js',
|
|
'/p2p-core.js',
|
|
'/p2p-transfer.js',
|
|
'/p2p-recv-worker.js',
|
|
'/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',
|
|
];
|
|
|
|
// ---- Install: pre-cache the app shell ----
|
|
// skipWaiting() is NOT called here — the page activates a waiting worker
|
|
// from "Refresh UI" (see sw-update.js).
|
|
self.addEventListener('install', (e) => {
|
|
e.waitUntil(precacheShell());
|
|
});
|
|
|
|
// Fresh requests (`cache: 'reload'` skips the browser HTTP cache, which could
|
|
// otherwise hand the new worker yesterday's app.js), a few attempts per file
|
|
// because the homelab link drops requests, and a failed install deletes its
|
|
// partial cache — a leftover ytplayer-<tag> cache is exactly what activate
|
|
// uses to decide "this is a genuine update", and it used to re-open the
|
|
// update banner on pages that were already current.
|
|
const PRECACHE_ATTEMPTS = 3;
|
|
async function precacheShell() {
|
|
const cache = await caches.open(CACHE);
|
|
try {
|
|
await Promise.all(SHELL.map(async (url) => {
|
|
let lastErr = null;
|
|
for (let attempt = 0; attempt < PRECACHE_ATTEMPTS; attempt++) {
|
|
try {
|
|
const res = await fetch(new Request(url, { cache: 'reload' }));
|
|
if (!res.ok) throw new Error(url + ' → ' + res.status);
|
|
await cache.put(url, res);
|
|
return;
|
|
} catch (err) { lastErr = err; }
|
|
}
|
|
throw lastErr;
|
|
}));
|
|
} catch (err) {
|
|
await caches.delete(CACHE);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// ---- Activate: evict old caches, claim clients, notify about update ----
|
|
self.addEventListener('activate', (e) => {
|
|
e.waitUntil((async () => {
|
|
const keys = await caches.keys();
|
|
|
|
// Was there a *previous deploy's* app-shell cache lying around? If so,
|
|
// this activation is a genuine version bump — worth telling the client
|
|
// about. If the only versioned shell cache present is our own CACHE (or
|
|
// none at all), this is the first-ever activation of a freshly
|
|
// (re)registered worker — e.g. right after hardReloadUI() unregisters
|
|
// the old SW and hard-navigates — and there is nothing new to report.
|
|
// Without this check, that harmless re-install would re-broadcast
|
|
// SW_UPDATE_AVAILABLE and immediately reopen the "Update ready" banner
|
|
// the user just dismissed by clicking "Reload now".
|
|
const staleShellCaches = keys.filter((k) => isVersionedShellCache(k) && k !== CACHE);
|
|
const isGenuineUpdate = staleShellCaches.length > 0;
|
|
|
|
// Delete every stale *versioned shell* cache — never the utility caches
|
|
// (thumbs/fonts), which aren't tied to a deploy version and should
|
|
// survive every activate.
|
|
await Promise.all(staleShellCaches.map((k) => caches.delete(k)));
|
|
|
|
// Claim all open clients immediately (new installs)
|
|
await self.clients.claim();
|
|
|
|
if (isGenuineUpdate) {
|
|
// Broadcast to every open window so the app can show an update banner
|
|
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
all.forEach((c) => c.postMessage({ type: 'SW_UPDATE_AVAILABLE', version: VERSION }));
|
|
}
|
|
})());
|
|
});
|
|
|
|
// ---- Fetch: routing logic ----
|
|
// ---- Share target (manifest share_target) ----
|
|
// The OS share sheet POSTs the shared link/text/files here. Files are parked in
|
|
// a cache the app reads on open (name does not start with "ytplayer-", so no
|
|
// deploy ever evicts it), then the page is opened to process them.
|
|
const SHARE_INBOX = 'ytp-share-inbox';
|
|
async function receiveShare(request) {
|
|
let form;
|
|
try { form = await request.formData(); } catch { return Response.redirect('/?shared=', 303); }
|
|
const files = form.getAll('media').filter((f) => f && typeof f !== 'string' && f.size);
|
|
if (files.length) {
|
|
const inbox = await caches.open(SHARE_INBOX);
|
|
let n = 0;
|
|
for (const f of files) {
|
|
const key = `/__share/${Date.now()}-${n++}`;
|
|
await inbox.put(key, new Response(f, { headers: {
|
|
'Content-Type': f.type || 'application/octet-stream',
|
|
'X-Name': encodeURIComponent(f.name || 'shared'),
|
|
'Content-Length': String(f.size),
|
|
} }));
|
|
}
|
|
return Response.redirect('/?share=files', 303);
|
|
}
|
|
const pick = (k) => String(form.get(k) || '').trim();
|
|
const text = [pick('url'), pick('text'), pick('title')].filter(Boolean).join(' ');
|
|
return Response.redirect('/?shared=' + encodeURIComponent(text.slice(0, 2000)), 303);
|
|
}
|
|
|
|
self.addEventListener('fetch', (e) => {
|
|
const { request } = e;
|
|
const url = new URL(request.url);
|
|
|
|
if (request.method === 'POST' && url.pathname === '/share-target') {
|
|
e.respondWith(receiveShare(request));
|
|
return;
|
|
}
|
|
|
|
// Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified
|
|
if (request.method !== 'GET' && request.method !== 'HEAD') return;
|
|
|
|
// The admin page is online-only and must never be served from a cache.
|
|
if (url.pathname === '/admin' || url.pathname.startsWith('/admin.')) return;
|
|
|
|
// Catalog artwork uses the same offline image cache as YouTube artwork.
|
|
if (url.origin === self.location.origin && /^\/api\/catalog\/[\w-]{11}\/thumbnail$/.test(url.pathname)) {
|
|
e.respondWith(thumbnail(request));
|
|
return;
|
|
}
|
|
|
|
// API calls and binary downloads → network only, no caching
|
|
if (url.pathname.startsWith('/api/')) {
|
|
e.respondWith(fetch(request));
|
|
return;
|
|
}
|
|
|
|
// YouTube thumbnails → cache-first, kept across deploys
|
|
if (THUMB_HOSTS.has(url.hostname)) {
|
|
e.respondWith(thumbnail(request));
|
|
return;
|
|
}
|
|
|
|
// Google Fonts CSS — stale-while-revalidate so offline doesn't break type
|
|
if (url.hostname === 'fonts.googleapis.com' || url.hostname === 'fonts.gstatic.com') {
|
|
e.respondWith(staleWhileRevalidate(request, 'ytplayer-fonts'));
|
|
return;
|
|
}
|
|
|
|
// App shell → cache-first, then network, then generic offline fallback
|
|
e.respondWith(cacheFirst(request));
|
|
});
|
|
|
|
// ---- Message: handle SKIP_WAITING from the client ----
|
|
self.addEventListener('message', (e) => {
|
|
if (e.data && e.data.type === 'SKIP_WAITING') {
|
|
self.skipWaiting();
|
|
}
|
|
// "Is your shell already downloaded?" — a waiting worker has precached the
|
|
// whole new build during install, so "Refresh UI" can swap to it instantly
|
|
// instead of downloading everything a second time. Answer honestly: install
|
|
// deletes its cache on failure, but a cache can also be evicted under
|
|
// storage pressure, so the files are actually checked.
|
|
if (e.data && e.data.type === 'CACHE_STATUS') {
|
|
e.waitUntil((async () => {
|
|
let missing = SHELL.length;
|
|
try {
|
|
const cache = await caches.open(CACHE);
|
|
const found = await Promise.all(SHELL.map((url) => cache.match(url)));
|
|
missing = found.filter((r) => !r).length;
|
|
} catch { /* storage blocked — report not ready */ }
|
|
const reply = { type: 'CACHE_STATUS', version: VERSION, ready: missing === 0, missing };
|
|
if (e.ports && e.ports[0]) e.ports[0].postMessage(reply);
|
|
else if (e.source) e.source.postMessage(reply);
|
|
})());
|
|
}
|
|
});
|
|
|
|
// ============================================================================
|
|
// Fetch helpers
|
|
// ============================================================================
|
|
|
|
// Cache-first: serve from cache; if missing, fetch, cache, return.
|
|
async function cacheFirst(request) {
|
|
const cache = await caches.open(CACHE);
|
|
// Shell files are precached under their plain URL; index.html asks for them as
|
|
// `file?v=<build>` (so the HTTP cache can keep them forever) — match ignoring it.
|
|
const versioned = /[?&]v=/.test(request.url);
|
|
const cached = await cache.match(request, versioned ? { ignoreSearch: true } : undefined);
|
|
if (cached) return cached;
|
|
try {
|
|
const response = await fetch(request);
|
|
// Only cache successful, non-opaque responses
|
|
if (response && response.status === 200 && response.type !== 'opaque') {
|
|
cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
} catch {
|
|
// Network failed and nothing in cache — return a minimal offline page
|
|
// for navigation requests; let sub-resources fail naturally.
|
|
if (request.mode === 'navigate') {
|
|
const nav = await cache.match('/index.html');
|
|
if (nav) return nav;
|
|
}
|
|
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
|
|
}
|
|
}
|
|
|
|
// Thumbnails: cache-first. A given thumbnail URL is immutable, so there is
|
|
// nothing to revalidate — cache-first also means an offline launch renders
|
|
// every card it has ever shown.
|
|
//
|
|
// The catch that made offline thumbnails disappear entirely: an <img> to
|
|
// another origin is a **no-cors** request, so `fetch(request)` resolves to an
|
|
// OPAQUE response with `status === 0`. The old `if (r.status === 200)` guard
|
|
// rejected every single one, so this cache was always empty. ytimg/ggpht all
|
|
// send `Access-Control-Allow-Origin: *`, so re-issue the request in cors mode
|
|
// and store a real, readable response; an opaque one is the last resort (it
|
|
// still renders, it just costs padded quota).
|
|
async function thumbnail(request) {
|
|
const cache = await caches.open(THUMB_CACHE);
|
|
const cached = await cache.match(request, { ignoreVary: true });
|
|
if (cached) return cached;
|
|
|
|
try {
|
|
const cors = await fetch(request.url, { mode: 'cors', credentials: 'omit' });
|
|
if (cors && cors.ok) {
|
|
await cache.put(request, cors.clone()).catch(() => {});
|
|
if (++_thumbPuts % 100 === 0) trimThumbCache();
|
|
return cors;
|
|
}
|
|
} catch { /* CORS refused or offline — fall through to the raw request */ }
|
|
|
|
try {
|
|
const raw = await fetch(request);
|
|
if (raw && (raw.status === 200 || raw.status === 0)) {
|
|
await cache.put(request, raw.clone()).catch(() => {});
|
|
// keys() on a 25k-entry cache is not free — trim every 100th put, not each one.
|
|
if (++_thumbPuts % 100 === 0) trimThumbCache();
|
|
}
|
|
return raw;
|
|
} catch {
|
|
// Offline and never cached — a neutral tile beats a broken-image glyph.
|
|
return placeholderThumb();
|
|
}
|
|
}
|
|
|
|
// Cache.keys() is insertion-ordered, so the front of the list is the oldest.
|
|
let _trimmingThumbs = false;
|
|
let _thumbPuts = 0;
|
|
async function trimThumbCache() {
|
|
if (_trimmingThumbs) return;
|
|
_trimmingThumbs = true;
|
|
try {
|
|
const cache = await caches.open(THUMB_CACHE);
|
|
const keys = await cache.keys();
|
|
if (keys.length > THUMB_CACHE_MAX) {
|
|
const excess = keys.slice(0, keys.length - THUMB_CACHE_MAX);
|
|
await Promise.all(excess.map((k) => cache.delete(k)));
|
|
}
|
|
} catch { /* trimming is best-effort */ }
|
|
finally { _trimmingThumbs = false; }
|
|
}
|
|
|
|
const PLACEHOLDER_THUMB =
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180">' +
|
|
'<rect width="320" height="180" fill="#1d1d24"/>' +
|
|
'<path d="M134 66l56 24-56 24z" fill="#3c3c48"/></svg>';
|
|
function placeholderThumb() {
|
|
return new Response(PLACEHOLDER_THUMB, {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-store' },
|
|
});
|
|
}
|
|
|
|
// Stale-while-revalidate: return cached immediately, update in background.
|
|
async function staleWhileRevalidate(request, cacheName) {
|
|
const cache = await caches.open(cacheName);
|
|
const cached = await cache.match(request);
|
|
// Start a background revalidation — don't await it before responding
|
|
const networkFetch = fetch(request).then((r) => {
|
|
if (r && r.status === 200) cache.put(request, r.clone());
|
|
return r;
|
|
}).catch(() => null);
|
|
|
|
// networkFetch resolves to null when offline; respondWith(null) throws, so
|
|
// surface a real network error instead.
|
|
return cached || (await networkFetch) || Response.error();
|
|
}
|