Files
mcma-webui/public/sw.js
T
Senko-san ceee9b9d12 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>
2026-06-07 19:59:31 +03:00

257 lines
7.8 KiB
JavaScript

/*
* 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) });
}
})(),
);
});