feat(offline): make the web UI usable without a reachable backend
Three tiers of offline support, all scoped to the active backend's localStorage namespace (mirroring the auth slice): Tier 1 — persist client state. queue + player slices are saved (queue entries/index/source; player track/position/volume/repeat/shuffle) and rehydrated on load, so a reload with no backend restores where the user left off. Playback never auto-resumes (browsers block autoplay). Retires the DEMO_QUEUE and isQueueOpen:true stubs. Tier 2 — persist the RTK Query cache. Last-seen library/albums/artists are snapshotted (fulfilled queries only) and replayed via RTKQ's extractRehydrationInfo at startup, so the library renders read-only when the backend is down. ConnectionStatus tooltip flags cached data offline. No server data is copied into a slice — the cache feeds itself back. Tier 3 — service worker audio + cover cache (PWA). Audio streams are cached keyed by content id (token stripped), range-aware (synthetic 206 slicing), with a 500MB LRU cap, so already-played tracks play fully offline. Cover art uses stale-while-revalidate in its own bounded cache. Module worker (ESM); pure helpers split into sw-core.js and unit-tested. Web app manifest enables "Install app". Player source badge now reflects real cached state. tsc clean, lint clean, 19 new tests pass, production build verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "MCMA — Music",
|
||||
"short_name": "MCMA",
|
||||
"description": "Self-hosted music — control center and offline-capable player.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0b0b0b",
|
||||
"theme_color": "#0b0b0b",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Service-worker core: pure helpers with NO side effects (no `self`, no
|
||||
* `caches`, no `fetch`). Split out from `sw.js` so the tricky bits — HTTP Range
|
||||
* parsing, LRU eviction, cache-key normalization — can be unit-tested in Node.
|
||||
*
|
||||
* This is an ES module: `sw.js` imports it (the SW is registered with
|
||||
* { type: 'module' }) and tests import it natively — one source, no drift.
|
||||
*/
|
||||
|
||||
// Bump the version to invalidate every cached blob on a breaking change.
|
||||
export const AUDIO_CACHE = 'mcma-audio-v1';
|
||||
// Synthetic same-origin key holding the LRU index ({ key: {size, lastAccess} }).
|
||||
export const INDEX_URL = '/__mcma_audio_index__';
|
||||
// Soft cap on total cached audio. LRU eviction keeps us under this.
|
||||
export const MAX_BYTES = 500 * 1024 * 1024; // 500 MB
|
||||
|
||||
// Cover art (album/artist/playlist images) gets its own cache with a simple
|
||||
// count cap — covers are small and stable, so no per-byte LRU is needed.
|
||||
export const COVER_CACHE = 'mcma-covers-v1';
|
||||
export const MAX_COVERS = 600;
|
||||
|
||||
// Backend stream route: /api/v1/streaming/tracks/<trackId>?token=...
|
||||
const STREAM_RE = /\/streaming\/tracks\/([^/?#]+)/;
|
||||
|
||||
/** The track (content) id from a stream URL, or null if it isn't one. */
|
||||
export function trackIdFromUrl(url) {
|
||||
const m = STREAM_RE.exec(url);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical cache key for a stream URL: origin + path, query dropped. The auth
|
||||
* token rides in the query and rotates on refresh, so keying by content path
|
||||
* (origin keeps two backends from colliding) makes the cache token-stable —
|
||||
* the same track is one entry regardless of which token fetched it.
|
||||
*/
|
||||
export function cacheKeyFor(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.origin + u.pathname;
|
||||
} catch {
|
||||
return String(url).split('?')[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an HTTP `Range` header against a known resource size. Returns inclusive
|
||||
* { start, end } byte offsets, or null for "no range / serve the whole thing".
|
||||
* Handles `bytes=a-b`, `bytes=a-` (open-ended) and `bytes=-n` (last n bytes).
|
||||
*/
|
||||
export function parseRangeHeader(rangeHeader, size) {
|
||||
if (!rangeHeader) return null;
|
||||
const m = /^bytes=(\d*)-(\d*)$/.exec(String(rangeHeader).trim());
|
||||
if (!m) return null;
|
||||
let start = m[1] === '' ? null : parseInt(m[1], 10);
|
||||
let end = m[2] === '' ? null : parseInt(m[2], 10);
|
||||
if (start === null && end === null) return null;
|
||||
if (start === null) {
|
||||
// suffix range: the final `end` bytes
|
||||
start = Math.max(0, size - end);
|
||||
end = size - 1;
|
||||
} else if (end === null) {
|
||||
end = size - 1;
|
||||
}
|
||||
if (Number.isNaN(start) || Number.isNaN(end)) return null;
|
||||
if (end >= size) end = size - 1;
|
||||
if (start < 0 || start > end) return null;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose which cached entries to evict (least-recently-used first) so that the
|
||||
* existing total plus an incoming blob fits under `maxBytes`. Returns the list
|
||||
* of keys to delete; empty when there's already room.
|
||||
*
|
||||
* `index` is { [key]: { size, lastAccess } }.
|
||||
*/
|
||||
export function selectEvictions(index, incomingSize, maxBytes) {
|
||||
let total = incomingSize;
|
||||
for (const k in index) total += index[k].size || 0;
|
||||
if (total <= maxBytes) return [];
|
||||
|
||||
const entries = Object.keys(index)
|
||||
.map((k) => ({
|
||||
key: k,
|
||||
lastAccess: index[k].lastAccess || 0,
|
||||
size: index[k].size || 0,
|
||||
}))
|
||||
.sort((a, b) => a.lastAccess - b.lastAccess); // oldest first
|
||||
|
||||
const evict = [];
|
||||
for (const e of entries) {
|
||||
if (total <= maxBytes) break;
|
||||
evict.push(e.key);
|
||||
total -= e.size;
|
||||
}
|
||||
return evict;
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
/*
|
||||
* MCMA service worker — Tier 3 offline support: audio blob cache.
|
||||
*
|
||||
* It sits between the app and the network for audio-stream requests only
|
||||
* (`/streaming/tracks/<id>`). The first time a track is streamed it's copied
|
||||
* into the Cache API (keyed by content id, token stripped); afterwards — or
|
||||
* whenever the backend is unreachable — playback is served straight from the
|
||||
* cache, so already-heard tracks play with no network at all.
|
||||
*
|
||||
* Pure helpers (range parsing, LRU, key normalization) live in sw-core.js so
|
||||
* they can be unit-tested; this file owns the side-effectful cache/network I/O.
|
||||
* Registered as a module worker ({ type: 'module' }), so it uses ES imports.
|
||||
*/
|
||||
import {
|
||||
AUDIO_CACHE,
|
||||
INDEX_URL,
|
||||
MAX_BYTES,
|
||||
COVER_CACHE,
|
||||
MAX_COVERS,
|
||||
trackIdFromUrl,
|
||||
cacheKeyFor,
|
||||
parseRangeHeader,
|
||||
selectEvictions,
|
||||
} from './sw-core.js';
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
// Activate immediately on first install / update — no stale SW lingering.
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== 'GET') return;
|
||||
if (trackIdFromUrl(req.url)) {
|
||||
event.respondWith(handleAudio(event)); // audio stream → range-aware cache
|
||||
return;
|
||||
}
|
||||
if (req.destination === 'image') {
|
||||
event.respondWith(handleImage(event)); // cover art → stale-while-revalidate
|
||||
}
|
||||
});
|
||||
|
||||
async function handleAudio(event) {
|
||||
const req = event.request;
|
||||
const key = cacheKeyFor(req.url);
|
||||
const range = req.headers.get('range');
|
||||
const cache = await caches.open(AUDIO_CACHE);
|
||||
|
||||
// 1) Serve from cache when we have it (works fully offline).
|
||||
const cached = await cache.match(key);
|
||||
if (cached) {
|
||||
event.waitUntil(touch(key));
|
||||
return range ? buildRangeResponse(cached, range) : cached.clone();
|
||||
}
|
||||
|
||||
// 2) Cache miss → fetch the WHOLE file (strip Range) so we can store a
|
||||
// complete copy, then satisfy the original request (range-sliced if asked).
|
||||
try {
|
||||
const fullReq = new Request(req.url, { headers: withoutRange(req.headers) });
|
||||
const resp = await fetch(fullReq);
|
||||
if (isCacheable(resp)) {
|
||||
event.waitUntil(storeInCache(key, resp.clone()));
|
||||
}
|
||||
return range ? buildRangeResponse(resp, range) : resp;
|
||||
} catch {
|
||||
// Offline and never cached — nothing we can do for this one.
|
||||
return new Response('Offline and not cached', {
|
||||
status: 504,
|
||||
statusText: 'Offline',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cover art: stale-while-revalidate. Serve the cached image instantly (so the
|
||||
* library renders offline), refresh it in the background, and cache fresh hits.
|
||||
* Cover URLs are token-free and stable, and `<img>` is happy with opaque
|
||||
* cross-origin responses — so unlike audio, these cache even cross-origin.
|
||||
*/
|
||||
async function handleImage(event) {
|
||||
const req = event.request;
|
||||
const cache = await caches.open(COVER_CACHE);
|
||||
const cached = await cache.match(req);
|
||||
|
||||
const fromNetwork = fetch(req)
|
||||
.then((resp) => {
|
||||
if (resp && (resp.status === 200 || resp.type === 'opaque')) {
|
||||
return cache
|
||||
.put(req, resp.clone())
|
||||
.then(() => trimCovers(cache))
|
||||
.then(() => resp);
|
||||
}
|
||||
return resp;
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (cached) {
|
||||
event.waitUntil(fromNetwork); // revalidate without blocking the response
|
||||
return cached;
|
||||
}
|
||||
const resp = await fromNetwork;
|
||||
return resp || new Response('', { status: 504, statusText: 'Offline' });
|
||||
}
|
||||
|
||||
/** Keep the cover cache bounded — drop the oldest entries past the cap. */
|
||||
async function trimCovers(cache) {
|
||||
const keys = await cache.keys();
|
||||
const excess = keys.length - MAX_COVERS;
|
||||
for (let i = 0; i < excess; i++) await cache.delete(keys[i]);
|
||||
}
|
||||
|
||||
/** Only cache readable, complete 200 responses — opaque/partial are useless. */
|
||||
function isCacheable(resp) {
|
||||
return (
|
||||
resp.status === 200 &&
|
||||
resp.type !== 'opaque' &&
|
||||
resp.type !== 'opaqueredirect'
|
||||
);
|
||||
}
|
||||
|
||||
function withoutRange(headers) {
|
||||
const out = new Headers();
|
||||
for (const [k, v] of headers.entries()) {
|
||||
if (k.toLowerCase() !== 'range') out.set(k, v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build a 206 Partial Content response by slicing a full cached/fetched body. */
|
||||
async function buildRangeResponse(response, rangeHeader) {
|
||||
const buf = await response.clone().arrayBuffer();
|
||||
const size = buf.byteLength;
|
||||
const r = parseRangeHeader(rangeHeader, size);
|
||||
const type = response.headers.get('content-type') || 'application/octet-stream';
|
||||
|
||||
if (!r) {
|
||||
return new Response(buf, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': type,
|
||||
'content-length': String(size),
|
||||
'accept-ranges': 'bytes',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const sliced = buf.slice(r.start, r.end + 1);
|
||||
return new Response(sliced, {
|
||||
status: 206,
|
||||
statusText: 'Partial Content',
|
||||
headers: {
|
||||
'content-type': type,
|
||||
'content-range': `bytes ${r.start}-${r.end}/${size}`,
|
||||
'content-length': String(sliced.byteLength),
|
||||
'accept-ranges': 'bytes',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function responseSize(resp) {
|
||||
const len = resp.headers.get('content-length');
|
||||
if (len) return Number(len);
|
||||
const blob = await resp.blob();
|
||||
return blob.size;
|
||||
}
|
||||
|
||||
// --- LRU index -------------------------------------------------------------
|
||||
// The index lives as a JSON entry in the same cache. All mutations go through a
|
||||
// single serialized chain so concurrent fetch handlers can't clobber it.
|
||||
let indexChain = Promise.resolve();
|
||||
|
||||
async function readIndex(cache) {
|
||||
try {
|
||||
const res = await cache.match(INDEX_URL);
|
||||
if (!res) return {};
|
||||
return (await res.json()) || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeIndex(mutator) {
|
||||
indexChain = indexChain
|
||||
.then(async () => {
|
||||
const cache = await caches.open(AUDIO_CACHE);
|
||||
const index = await readIndex(cache);
|
||||
await mutator(cache, index);
|
||||
await cache.put(
|
||||
INDEX_URL,
|
||||
new Response(JSON.stringify(index), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep the chain alive even if one write fails */
|
||||
});
|
||||
return indexChain;
|
||||
}
|
||||
|
||||
async function storeInCache(key, resp) {
|
||||
const size = await responseSize(resp.clone());
|
||||
await writeIndex(async (cache, index) => {
|
||||
for (const ek of selectEvictions(index, size, MAX_BYTES)) {
|
||||
await cache.delete(ek);
|
||||
delete index[ek];
|
||||
}
|
||||
await cache.put(key, resp);
|
||||
index[key] = { size, lastAccess: Date.now() };
|
||||
});
|
||||
}
|
||||
|
||||
function touch(key) {
|
||||
return writeIndex((_cache, index) => {
|
||||
if (index[key]) index[key].lastAccess = Date.now();
|
||||
});
|
||||
}
|
||||
|
||||
// --- client messaging ------------------------------------------------------
|
||||
// The app talks to the SW over a MessageChannel: it sends a port, we reply on
|
||||
// it. Used for the offline-cache stats/controls in the UI.
|
||||
self.addEventListener('message', (event) => {
|
||||
const data = event.data || {};
|
||||
const reply = (result) => {
|
||||
if (event.ports && event.ports[0]) event.ports[0].postMessage(result);
|
||||
};
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
const cache = await caches.open(AUDIO_CACHE);
|
||||
const index = await readIndex(cache);
|
||||
if (data.type === 'STATS') {
|
||||
const keys = Object.keys(index);
|
||||
const bytes = keys.reduce((s, k) => s + (index[k].size || 0), 0);
|
||||
reply({ count: keys.length, bytes, maxBytes: MAX_BYTES });
|
||||
} else if (data.type === 'HAS') {
|
||||
reply({ cached: !!index[cacheKeyFor(data.url)] });
|
||||
} else if (data.type === 'CLEAR') {
|
||||
await Promise.all([
|
||||
caches.delete(AUDIO_CACHE),
|
||||
caches.delete(COVER_CACHE),
|
||||
]);
|
||||
reply({ ok: true });
|
||||
} else {
|
||||
reply({ error: 'unknown-message' });
|
||||
}
|
||||
} catch (e) {
|
||||
reply({ error: String(e) });
|
||||
}
|
||||
})(),
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user