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.