Service worker for cover art + PWA manifest

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.
This commit is contained in:
Broque Thomas 2026-04-26 22:17:52 -07:00
parent 7bc7936371
commit f11b91a5c6
8 changed files with 236 additions and 1 deletions

View file

@ -22,7 +22,7 @@ from pathlib import Path
from urllib.parse import quote, urljoin, urlparse
from concurrent.futures import ThreadPoolExecutor, as_completed
from flask import Flask, render_template, request, jsonify, redirect, send_file, Response, session, g, abort
from flask import Flask, render_template, request, jsonify, redirect, send_file, send_from_directory, Response, session, g, abort
from flask_socketio import SocketIO, emit, join_room, leave_room
from utils.logging_config import get_logger, setup_logging
from utils.async_helpers import run_async
@ -5222,6 +5222,25 @@ def run_detection(server_type):
def index():
return render_template('index.html')
@app.route('/sw.js')
def service_worker():
"""Serve sw.js from root scope so the service worker can intercept
fetches site-wide. A service worker only controls URLs at or below
its own served path `/static/sw.js` would scope to `/static/*`
only. Serving from `/sw.js` (with the file living under static/)
grants full-site scope without needing the Service-Worker-Allowed
header dance.
Cache-Control: no-cache so deploys that change the SW propagate on
the next page load instead of being pinned by the 1-year static
cache the rest of /static/ uses.
"""
response = send_from_directory(app.static_folder, 'sw.js', mimetype='application/javascript')
response.headers['Cache-Control'] = 'no-cache'
return response
@app.route('/<path:page>')
def spa_catch_all(page):
# Serve index.html for client-side routes; let Flask handle real routes first.

View file

@ -6,6 +6,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<title>SoulSync - Music Sync & Manager</title>
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png', v=static_v) }}">
<link rel="manifest" href="{{ url_for('static', filename='manifest.json', v=static_v) }}">
<meta name="theme-color" content="#1db954">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='pwa-icon-192.png', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='setup-wizard.css', v=static_v) }}">

View file

@ -3448,6 +3448,7 @@ const WHATS_NEW = {
{ title: 'Faster Docker Startup — yt-dlp Pinned', desc: 'docker startup used to run `pip install -U yt-dlp` on every container start. removed that — yt-dlp is now pinned in requirements.txt so startup is fast and reproducible. tradeoff: youtube fixes ship via soulsync releases now instead of next container restart.' },
{ title: 'Settings Endpoints: Admin-Only', desc: 'the /api/settings endpoints (read, write, log-level, config-status, verify) had no auth gate — any logged-in profile could read or change service tokens, oauth secrets, api keys. now admin-only. single-admin setups (no multi-profile config) work transparently as before.', page: 'settings' },
{ title: 'Browser Caching for Static Assets + Discover Pages', desc: 'static assets (js/css/icons) now get a 1-year browser cache instead of revalidating on every page load. safe because the existing ?v=static_v cache-bust query changes every server restart, so deploys still ship live. discover pages (hero, similar artists, recent releases, deep cuts, etc.) now cache 5 minutes browser-side so toggling between sections doesn\'t re-fetch everything. faster repeat loads, fewer round-trips.', page: 'discover' },
{ title: 'Service Worker for Cover Art + Installable PWA', desc: 'cover art used to re-fetch from the cdn on every library / discover page visit. now a service worker caches images locally — second visit serves art instantly from disk, no network hit. also added a pwa manifest so soulsync can be installed to home screen / desktop as a standalone app (chrome / edge / safari → install soulsync). cache versioned so future strategy changes invalidate cleanly.' },
],
'2.4.0': [
// --- April 26, 2026 — Search & Artists unification + reorganize queue ---

View file

@ -1903,6 +1903,19 @@ async function checkAdminPinRequired() {
}
}
// Service worker registration. Runs as soon as the JS parses (doesn't
// need to wait for DOMContentLoaded). Cache-first image strategy +
// stale-while-revalidate static shell — see /sw.js for details. Skipped
// when the API isn't available (older browsers, file:// origin) or when
// the page is loaded from a non-secure origin (SW requires HTTPS or
// localhost).
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { scope: '/' })
.catch((err) => console.warn('[SW] registration failed:', err));
});
}
document.addEventListener('DOMContentLoaded', async function () {
console.log('SoulSync WebUI initializing...');

View file

@ -0,0 +1,37 @@
{
"name": "SoulSync",
"short_name": "SoulSync",
"description": "Music download & sync app — playlists, watchlist, library management.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"theme_color": "#1db954",
"background_color": "#0a0a0a",
"icons": [
{
"src": "/static/pwa-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/pwa-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/pwa-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static/pwa-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

162
webui/static/sw.js Normal file
View file

@ -0,0 +1,162 @@
/* 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();
}
}