Files
ytplayer/frontend/sw.js

286 lines
11 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 = 800;
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',
'/async-guard.js',
'/sw-update.js',
'/fingerprint.js',
'/opfs.js',
'/opfs-worker.js',
'/video-edit.js',
'/lyrics-core.js',
'/app.js',
'/manifest.webmanifest',
'/icons/icon-192.png',
'/icons/icon-512.png',
];
// ---- 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 ----
self.addEventListener('fetch', (e) => {
const { request } = e;
const url = new URL(request.url);
// 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;
// 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();
}
});
// ============================================================================
// Fetch helpers
// ============================================================================
// Cache-first: serve from cache; if missing, fetch, cache, return.
async function cacheFirst(request) {
const cache = await caches.open(CACHE);
const cached = await cache.match(request);
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(() => {});
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(() => {});
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;
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();
}