/* ============================================================================ * sw.js — YT Player Service Worker * * Strategy: * App shell (HTML/CSS/JS) → cache-first, exact per-file hash keys * /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. Install syncs missing assets into ytplayer-assets. "Refresh UI" activates * the verified waiting worker (SKIP_WAITING) and reloads once. The legacy * full-shell path remains available through ASSET_SYNC=0. * 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 ASSET_SYNC = typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true; if (ASSET_SYNC) importScripts('/asset-sync-core.js'); 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', 'ytplayer-assets']); 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', '/layout-base.css', '/theme-glass.css', '/theme-bento-hub.css', '/theme-f7.css', '/fonts/PlusJakartaSans-latin.woff2', '/fonts/PlusJakartaSans-latin-ext.woff2', '/theme-glass-panel.css', '/glass-panel-layout.js', '/theme-classic.css', '/recommendations.css', '/flag-ui.js', '/export.js', '/async-guard.js', '/visible-timer.js', '/asset-sync-core.js', '/lazy.js', '/feature-bootstrap.js', '/eq-parameters.js', '/assets.json', '/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', '/direct-media.css', '/direct-protocol.js', '/direct-media.js', '/direct-stream.js', '/direct-recv-worker.js', '/p2p-transfer.js', '/p2p-recv-worker.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', '/glass-controls.js', '/bento-hub.js', '/f7-layout.js', '/vendor/framework7-swipe.min.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', '/prewarm-next.js', '/car-mode.css', '/lower-third.css', '/settings-sections.css', '/shell-core.js', '/views-core.js', '/section-rail.js', '/presenter.js', '/remote-client.js', '/watch-party-client.js', '/share-external.js', '/stats-view.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(ASSET_SYNC ? installAssets() : 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 () => { if (ASSET_SYNC) { await activateAssets(); return; } 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, e.clientId)); }); // ---- Message: handle SKIP_WAITING from the client ---- const playingClients = new Map(), deferredClients = new Set(); let activeLayout = 'classic', layoutReply, warmTask; self.addEventListener('message', (e) => { if (ASSET_SYNC && e.data?.type === 'LAYOUT' && e.source && typeof e.data.value === 'string') { activeLayout = e.data.value; layoutReply?.(); e.waitUntil((async()=>{ const cache=await caches.open(AssetSyncCore.CACHE); await cache.put('/__ytp_layout',Response.json({layout:activeLayout})); const m=await candidate(cache); if(m && m.activeLayout!==activeLayout) { await AssetSyncCore.syncAssets(m,{cache,fetchFn:fetch,activeLayout}); m.activeLayout=activeLayout; await cache.put(candidateKey,Response.json(m)); } })()); } if (ASSET_SYNC && e.data?.type === 'WARM_ASSETS' && !e.data.saveData) { // Boot, activation and multiple tabs can report idle together. Share the // verified pool so they never download the same missing group twice. warmTask ||= (async()=>{ const cache=await caches.open(AssetSyncCore.CACHE), m=(await AssetSyncCore.state(cache))?.current; if(m)await AssetSyncCore.syncAssets(m,{cache,fetchFn:fetch,groups:Object.keys(m.groups).filter(name=>m.groups[name].background)}); })().catch(error=>console.warn('[asset-sync] idle warm interrupted',error.message)).finally(()=>{warmTask=null;}); e.waitUntil(warmTask); } if (e.data && e.data.type === 'PLAYING' && e.source) { playingClients.set(e.source.id, !!e.data.value); if (!e.data.value && deferredClients.delete(e.source.id)) self.skipWaiting(); } if (e.data && e.data.type === 'SKIP_WAITING' && e.source && e.source.id) { const id = e.source && e.source.id; if (playingClients.get(id)) deferredClients.add(id); else 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 () => { if (ASSET_SYNC) { let reply = { ready: false, missing: -1, version: VERSION }; try { const cache = await caches.open(AssetSyncCore.CACHE), state = await AssetSyncCore.state(cache); const pending = await candidate(cache), m = pending || state?.current; if(m) reply = await AssetSyncCore.status(m,cache,{previous:pending ? state?.current : state?.previous,activeLayout:e.data.activeLayout || (pending ? m.activeLayout : activeLayout) || 'classic'}); } catch {} reply.type = 'CACHE_STATUS'; reply.assetSync = true; if(e.ports && e.ports[0]) e.ports[0].postMessage(reply); else if(e.source) e.source.postMessage(reply); return; } 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, clientId) { if (ASSET_SYNC) return assetFetch(request, clientId); 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(); } // Installation stores a candidate only. Activation publishes one atomic state record. const candidateKey = '/__ytp_candidate/' + VERSION; async function candidate(cache) { const r = await cache.match(candidateKey); return r ? r.json() : null; } async function installAssets() { const r = await fetch('/api/manifest', { cache: 'no-store' }); if (!r.ok) throw Error('Manifest unavailable'); const m = await r.json(); if (m.buildTag !== VERSION) throw Error('Deployment changed during install'); const cache = await caches.open(AssetSyncCore.CACHE); const remembered = await cache.match('/__ytp_layout'); if(remembered)activeLayout=(await remembered.json()).layout || 'classic'; let layoutKnown = !!remembered; const clients = await self.clients.matchAll({type:'window',includeUncontrolled:true}); await new Promise(resolve=>{ const timer=setTimeout(resolve,500); layoutReply=()=>{layoutKnown=true;clearTimeout(timer);resolve();}; for(const client of clients)client.postMessage({type:'ASSET_LAYOUT_REQUEST'}); }); layoutReply=null; m.activeLayout=activeLayout; const freshInstall = !(await AssetSyncCore.state(cache)); const fetchAsset = async (url,init) => { // Fonts/icons still use plain URLs in CSS/manifest. On first install reuse // their HTTP-cache responses, but only if the hash proves current bytes. if(freshInstall && /^(?:\/fonts\/|\/icons\/|\/manifest.webmanifest)/.test(url)) { const r = await fetch(url.split('?')[0],{...init,cache:'force-cache'}); if(r.ok && r.headers.get('X-Asset-Hash') === url.split('v=')[1]) return r; } return fetch(url,init); }; await AssetSyncCore.syncAssets(m, { cache, fetchFn: fetchAsset, activeLayout }); // Older pages cannot report their saved choice. Keep every layout ready for // that one migration so an immediate offline reload cannot lose its theme. if(!layoutKnown && ((await AssetSyncCore.state(cache)) || (await caches.keys()).some(isVersionedShellCache))) { const layouts=new Set(Object.keys(m.groups).filter(name=>name.startsWith('layout:'))); for(const name of layouts)for(const dependency of m.groups[name].dependencies || [])layouts.add(dependency); await AssetSyncCore.syncAssets(m,{cache,fetchFn:fetchAsset,groups:[...layouts]}); } await cache.put(candidateKey, new Response(JSON.stringify(m))); } async function migrateLegacy(cache, m) { // Legacy responses lack hash headers. Verify their actual bytes locally before // retaining them; this also preserves old whole-build URLs in already-open tabs. const names = (await caches.keys()).filter(isVersionedShellCache); let previous = null; for (const name of names) { const old = await caches.open(name); const files = {}, legacyURLs = {}; for (const req of await old.keys()) { const u = new URL(req.url), p = u.pathname === '/' ? '/index.html' : u.pathname; if (!m.files[p]) continue; const r = await old.match(req); if (!r || !r.ok) continue; const bytes = await r.clone().arrayBuffer(); const digest = await crypto.subtle.digest('SHA-256', bytes); const h = [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2,'0')).join('').slice(0,10); const headers = new Headers(r.headers); headers.set('X-Asset-Hash', h); files[p] = { h }; legacyURLs[p] = p + '?v=' + h; await cache.put(legacyURLs[p], new Response(bytes, { headers })); } if (Object.keys(files).length) previous = { buildTag:name.slice(SHELL_CACHE_PREFIX.length),files,groups:{core:{files:Object.keys(files)}},legacyURLs }; } if (previous && !(await AssetSyncCore.state(cache))) await cache.put(AssetSyncCore.STATE, new Response(JSON.stringify({current:previous}))); return names; } async function activateAssets() { const cache = await caches.open(AssetSyncCore.CACHE), m = await candidate(cache); if (!m) throw Error('Missing candidate manifest'); const names = await migrateLegacy(cache,m), old = await AssetSyncCore.state(cache); const previousClients = (await self.clients.matchAll({type:'window'})).map(c=>c.id); await AssetSyncCore.commit(m,cache,{previousClients,activeLayout:m.activeLayout || activeLayout}); // Legacy deletion occurs strictly after verified commit. await Promise.all(names.map(n => caches.delete(n))); for (const req of await cache.keys()) if(new URL(req.url).pathname.startsWith('/__ytp_candidate/')) await cache.delete(req); await self.clients.claim(); if(old && old.current.buildTag !== m.buildTag) for(const c of await self.clients.matchAll({type:'window',includeUncontrolled:true})) c.postMessage({type:'SW_UPDATE_AVAILABLE',version:VERSION}); try { console.info('[asset-sync] storage', await self.navigator.storage.estimate()); } catch {} } async function assetFetch(request, clientId) { const cache = await caches.open(AssetSyncCore.CACHE), state = await AssetSyncCore.state(cache); if(!state) return fetch(request); const u = new URL(request.url); let m = state.current; if(u.origin !== self.location.origin) return fetch(request); if(u.searchParams.has('__ytpfresh')) return fetch(request); const nav = request.mode === 'navigate' || u.pathname === '/' || u.pathname === '/index.html'; if(!nav && state.previousClients?.includes(clientId) && state.previous?.files[u.pathname]) m = state.previous; let path = nav ? '/index.html' : u.pathname; let key; if(u.searchParams.has('v') && state.current.files[u.pathname]) { key = u.pathname + u.search; if(state.previous?.legacyURLs && u.searchParams.get('v') === state.previous.buildTag) key = state.previous.legacyURLs[path] || key; } else if(m.files[path]) key = AssetSyncCore.url(path,m.files[path]); if(key) { const cached = await cache.match(key); const h = new URL(key,self.location.origin).searchParams.get('v'); if(cached && cached.headers.get('X-Asset-Hash') === h) return cached; if(state.current.files[path]?.h === h) { const previousKey=AssetSyncCore.fallback(state.current,state.previous,path); const previousResponse=previousKey && await cache.match(previousKey); if(previousResponse && previousResponse.headers.get('X-Asset-Hash')===state.previous.files[path].h) return previousResponse; } try { const r = await fetch(key); if(r.ok && r.headers.get('X-Asset-Hash') !== h) return new Response('Asset version unavailable',{status:409}); if(r.ok && request.method !== 'HEAD') await cache.put(key,r.clone()); return r; } catch { return new Response('Offline',{status:503}); } } try { return await fetch(request); } catch { return new Response('Offline',{status:503}); } }