diff --git a/web_server.py b/web_server.py index 8ce039e8..8d2a06eb 100644 --- a/web_server.py +++ b/web_server.py @@ -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('/') def spa_catch_all(page): # Serve index.html for client-side routes; let Flask handle real routes first. diff --git a/webui/index.html b/webui/index.html index 1bd71056..7bcab176 100644 --- a/webui/index.html +++ b/webui/index.html @@ -6,6 +6,9 @@ SoulSync - Music Sync & Manager + + + diff --git a/webui/static/helper.js b/webui/static/helper.js index e9560b3c..4b8a260a 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -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 --- diff --git a/webui/static/init.js b/webui/static/init.js index a92a010f..2680518a 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -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...'); diff --git a/webui/static/manifest.json b/webui/static/manifest.json new file mode 100644 index 00000000..ccf8acba --- /dev/null +++ b/webui/static/manifest.json @@ -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" + } + ] +} diff --git a/webui/static/pwa-icon-192.png b/webui/static/pwa-icon-192.png new file mode 100644 index 00000000..bad488da Binary files /dev/null and b/webui/static/pwa-icon-192.png differ diff --git a/webui/static/pwa-icon-512.png b/webui/static/pwa-icon-512.png new file mode 100644 index 00000000..069f466c Binary files /dev/null and b/webui/static/pwa-icon-512.png differ diff --git a/webui/static/sw.js b/webui/static/sw.js new file mode 100644 index 00000000..bce4f22b --- /dev/null +++ b/webui/static/sw.js @@ -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(); + } +}