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>
257 lines
7.7 KiB
JavaScript
257 lines
7.7 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
|
|
* (`/stream/<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) });
|
|
}
|
|
})(),
|
|
);
|
|
});
|