/* ============================================================================ * 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-). // 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', '/lyrics-display.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', '/settings-sections.js', '/downloads.js', '/downloads-page.js', '/offline-pages.css', '/saved-page.js', '/server-backfill.js', '/glass-controls.js', '/theme-glass-controls.css', '/saved-page.css', '/party-dj.js', '/lyrics-window.js', '/midi.js', '/lower-third.js', '/vocal-reducer.js', '/eq-core.js', '/piano-core.js', '/piano.js', '/setlist-import.js', '/car-mode.js', '/car-mode.css', '/lower-third.css', '/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', ]; // ---- 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- 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=` (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 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 = '' + '' + ''; 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(); }