/* * 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/?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; }