dacb8b9278
Replace the faked ConnectPage login with a real /auth/login -> /auth/me
flow, including loading/error states. Add a backend-contract adapter layer
(api/mappers.ts) translating the backend's snake_case, lean *Out schemas and
{items,total,limit,offset} paging into the UI's camelCase domain types, so
swapping backends only touches the mappers.
- auth: chained login (tokens) + /auth/me (user); refresh on snake_case;
expiresIn optional (reauth is 401-driven, backend sends no TTL)
- streaming: GET /stream/{id}?token= (token query param for <audio>); SW
audio cache route + tests follow the path change (token stays cache-stable)
- library/playlists/likes/admin: correct paths (/tracks not /library/tracks),
page/pageSize<->limit/offset, duration_seconds->durationMs, likes as
append-only POST /likes event-log, admin is_superuser<->role
- downloads/storage: marked provisional (backend routes still stubs)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
99 lines
3.4 KiB
JavaScript
99 lines
3.4 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/stream/<trackId>?token=...
|
|
const STREAM_RE = /\/stream\/([^/?#]+)/;
|
|
|
|
/** 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;
|
|
}
|