Addresses #365 (reported by JohnBaumb), parts 3 & 5. Client-side IDB / sessionStorage data cache (part 4) deferred to its own PR. Cover art on Library and Discover used to re-fetch from the source CDN on every page visit. Now a service worker caches images locally in CacheStorage with cache-first strategy — second visit serves art instantly with zero network round-trips. PWA manifest added so the app is installable to home screen / desktop. Service worker (`webui/static/sw.js`): - Cache-first for images: 10 known CDN hosts (Spotify, Last.fm, Apple, Deezer, Discogs, MusicBrainz CAA, YouTube thumbnails) plus the local `/api/image-proxy` endpoint plus same-origin .png/.jpg/ .webp/.gif/.svg paths. Cross-origin file-extension matches are refused so we don't accidentally cache trackers. - Stale-while-revalidate for `/static/*`: serve cached instantly, refresh in background. Combined with the existing `?v=static_v` cache-bust, deploys still ship live (different query → different cache entry, old ages out). - HTML / API / everything else: no caching, pass through. - Cache-versioned (CACHE_VERSION = 'v1'); activate handler wipes any cache whose name doesn't match the current version. - skipWaiting + clients.claim so deploys propagate to open tabs without requiring a full close-and-reopen. PWA manifest (`webui/static/manifest.json`): - Standalone display mode, theme color #1db954 (matches --accent-rgb). - Two icons (192, 512) with both `any` and `maskable` purpose, generated from favicon.png with aspect-preserving transparent padding so the existing logo lands inside the safe zone for OS-applied masks. Wiring: - `web_server.py` adds a `/sw.js` route that serves the file from root scope (a service worker only controls URLs at or below its served path; `/static/sw.js` would scope to `/static/*` only). `Cache-Control: no-cache` on the SW response so deploys propagate on next page load instead of being pinned by the 1yr static cache the rest of /static/ uses. - `webui/index.html` adds the manifest link, theme-color meta, and an apple-touch-icon for iOS. - `webui/static/init.js` registers the SW on `window.load`. Feature-detected — no-op on browsers without serviceWorker support or on non-secure origins (SW requires https or localhost). One bug caught + fixed during line-by-line self-review: `_staleWhileRevalidate` could return null to `respondWith()` when both the cache miss AND the network fetch failed (the `.catch(() => null)` collapsed the rejection to null, which then short-circuited through the falsy chain). Now explicitly awaits the network promise and falls back to `Response.error()` when it resolves to null — matches the `_cacheFirst` pattern. Browser-verified: sw.js registers, status "activated and is running" in DevTools. 603 tests pass.
162 lines
6.6 KiB
JavaScript
162 lines
6.6 KiB
JavaScript
/* SoulSync Service Worker — image cache + lightweight shell cache.
|
|
*
|
|
* Strategy:
|
|
*
|
|
* - **Images** (cover art / artist photos from CDNs + the local
|
|
* /api/image-proxy endpoint): cache-first. Once an album cover is
|
|
* fetched, every future page load serves it instantly from
|
|
* CacheStorage with no network round-trip. Cover art is the
|
|
* heaviest asset on Library and Discover; this is the single
|
|
* biggest perceived-performance win.
|
|
*
|
|
* - **Static assets** (/static/*.js, /static/*.css, /static/*.png):
|
|
* stale-while-revalidate. Serve from cache instantly, refresh in
|
|
* the background. Combined with the existing ?v=static_v cache
|
|
* bust, deploys still ship live — a new query string means a
|
|
* different cache entry, the old one ages out naturally.
|
|
*
|
|
* - **Everything else** (HTML, /api/*, etc.): no caching. Pass
|
|
* through to the network. We deliberately do NOT cache HTML or
|
|
* API responses — both are user-specific or change frequently
|
|
* enough that staleness would hurt more than it helps.
|
|
*
|
|
* Cache versioning: bump CACHE_VERSION when changing strategies or
|
|
* cache shapes. The activate handler clears any cache whose name
|
|
* doesn't match the current version, so old entries don't accumulate.
|
|
*/
|
|
|
|
const CACHE_VERSION = 'v1';
|
|
const IMAGE_CACHE = `soulsync-images-${CACHE_VERSION}`;
|
|
const STATIC_CACHE = `soulsync-static-${CACHE_VERSION}`;
|
|
const VALID_CACHES = new Set([IMAGE_CACHE, STATIC_CACHE]);
|
|
|
|
// Image hosts we cache. Local /api/image-proxy is treated as an image
|
|
// (see _isImageRequest below) so the proxy endpoint piggybacks on the
|
|
// same strategy without needing to be listed here.
|
|
const IMAGE_HOSTS = [
|
|
'i.scdn.co', // Spotify
|
|
'lastfm.freetls.fastly.net', 'lastfm-img2.akamaized.net',
|
|
'mosaic.scdn.co',
|
|
'is1-ssl.mzstatic.com', 'is2-ssl.mzstatic.com',
|
|
'is3-ssl.mzstatic.com', 'is4-ssl.mzstatic.com',
|
|
'is5-ssl.mzstatic.com', // Apple
|
|
'cdns-images.dzcdn.net', 'e-cdns-images.dzcdn.net', // Deezer
|
|
'i.discogs.com', 'st.discogs.com', // Discogs
|
|
'coverartarchive.org', // MusicBrainz Cover Art Archive
|
|
'i.ytimg.com', // YouTube thumbnails
|
|
];
|
|
|
|
function _isImageRequest(request) {
|
|
if (request.method !== 'GET') return false;
|
|
const url = new URL(request.url);
|
|
// Local image proxy
|
|
if (url.pathname.startsWith('/api/image-proxy')) return true;
|
|
// Known CDN hosts
|
|
if (IMAGE_HOSTS.includes(url.hostname)) return true;
|
|
// Last-resort: file extension hint (covers misc CDNs we missed)
|
|
if (/\.(png|jpe?g|webp|gif|svg)(\?|$)/i.test(url.pathname)) {
|
|
// Only if same-origin or known image host; refuse arbitrary
|
|
// third-party domains so we don't accidentally cache trackers.
|
|
if (url.origin === self.location.origin) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function _isStaticAsset(request) {
|
|
if (request.method !== 'GET') return false;
|
|
const url = new URL(request.url);
|
|
if (url.origin !== self.location.origin) return false;
|
|
return url.pathname.startsWith('/static/');
|
|
}
|
|
|
|
self.addEventListener('install', (event) => {
|
|
// Skip waiting so a freshly-installed SW takes control on the next
|
|
// navigation instead of needing all tabs to close first. Combined
|
|
// with clients.claim() in activate, deploys propagate quickly.
|
|
self.skipWaiting();
|
|
});
|
|
|
|
self.addEventListener('activate', (event) => {
|
|
// Wipe any caches whose name doesn't match the current version, then
|
|
// claim all open clients so this SW starts handling their fetches
|
|
// immediately (otherwise they'd keep using the previous SW until
|
|
// navigation).
|
|
event.waitUntil(
|
|
caches.keys().then((names) => Promise.all(
|
|
names.map((name) => VALID_CACHES.has(name) ? null : caches.delete(name))
|
|
)).then(() => self.clients.claim())
|
|
);
|
|
});
|
|
|
|
self.addEventListener('fetch', (event) => {
|
|
const request = event.request;
|
|
|
|
if (_isImageRequest(request)) {
|
|
event.respondWith(_cacheFirst(request, IMAGE_CACHE));
|
|
return;
|
|
}
|
|
|
|
if (_isStaticAsset(request)) {
|
|
event.respondWith(_staleWhileRevalidate(request, STATIC_CACHE));
|
|
return;
|
|
}
|
|
|
|
// HTML / API / everything else: pass through, no caching.
|
|
// Do NOT call event.respondWith() — let the browser handle it
|
|
// normally. This is intentional: HTML and API responses are
|
|
// user-specific or change too often for SW caching to help.
|
|
});
|
|
|
|
|
|
// ── strategies ───────────────────────────────────────────────────────
|
|
|
|
async function _cacheFirst(request, cacheName) {
|
|
try {
|
|
const cache = await caches.open(cacheName);
|
|
const hit = await cache.match(request);
|
|
if (hit) return hit;
|
|
|
|
const response = await fetch(request);
|
|
// Only cache successful, opaque-OK responses. Don't cache 404s
|
|
// / 500s — would pin a bad placeholder for the lifetime of the
|
|
// cache version.
|
|
if (response && (response.ok || response.type === 'opaque')) {
|
|
// Clone before .put — body is consumed otherwise.
|
|
cache.put(request, response.clone()).catch(() => { /* quota / disk full */ });
|
|
}
|
|
return response;
|
|
} catch (err) {
|
|
// Network failure with no cache hit — let the browser surface
|
|
// its standard offline / error UI (returning Response.error()
|
|
// is equivalent to letting the fetch reject naturally).
|
|
return Response.error();
|
|
}
|
|
}
|
|
|
|
async function _staleWhileRevalidate(request, cacheName) {
|
|
try {
|
|
const cache = await caches.open(cacheName);
|
|
const hit = await cache.match(request);
|
|
|
|
// Kick off a background refresh regardless of cache hit so the
|
|
// next load picks up any deploy. Failure here is silent — we
|
|
// already have a cached copy to serve (or are about to fetch).
|
|
const networkPromise = fetch(request).then((response) => {
|
|
if (response && response.ok) {
|
|
cache.put(request, response.clone()).catch(() => {});
|
|
}
|
|
return response;
|
|
}).catch(() => null);
|
|
|
|
// Serve cached immediately if we have it; otherwise wait on the
|
|
// network and fall back to Response.error() if THAT also failed.
|
|
// Important: must await networkPromise here — returning the
|
|
// Promise directly would let respondWith resolve to null when
|
|
// the fetch rejects, which throws TypeError in the browser.
|
|
if (hit) return hit;
|
|
const networkResponse = await networkPromise;
|
|
return networkResponse || Response.error();
|
|
} catch (err) {
|
|
return Response.error();
|
|
}
|
|
}
|