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:
parent
7bc7936371
commit
f11b91a5c6
8 changed files with 236 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) }}">
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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...');
|
||||
|
||||
|
|
|
|||
37
webui/static/manifest.json
Normal file
37
webui/static/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
webui/static/pwa-icon-192.png
Normal file
BIN
webui/static/pwa-icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
webui/static/pwa-icon-512.png
Normal file
BIN
webui/static/pwa-icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
162
webui/static/sw.js
Normal file
162
webui/static/sw.js
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue