ceee9b9d12
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>
99 lines
3.5 KiB
JavaScript
99 lines
3.5 KiB
JavaScript
/*
|
|
* 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;
|
|
}
|