Phase 1+2 of a self-hosted, multi-user listening archive — the basis for day/month/year stats and a yearly "Wrapped". Runs IN ADDITION to the existing Navidrome/Last.fm scrobble; never replaces it. Service (isolated, own container): - scrobble/scrobble-server.js — node:sqlite play-log (own volume), routes POST /scrobble/plays (idempotent by uuid), GET /scrobble/history (archive), DELETE /scrobble/plays?user= (test cleanup), GET /scrobble/health. Multi-user auth via Navidrome ping.view (cached) -> user not spoofable. - docker-compose: novasonic-scrobble service (node:22, port 3710) + volume; backend bumped node:20->22 (EOL); SCROBBLE_URL plumbing (entrypoint, .env.example). Capture (both platforms, offline-safe queue, idempotent batch flush): - Fires at the existing scrobbleMode counted-play moment — consistent with Last.fm, respects the user's setting (no separate 50%/4min rule). - Web: src/player/scrobbleLog.js (localStorage queue) + subsonicAuthParams() in api.js + hooks in player.js + flush init in main.js; S.cfg.scrobbleUrl. - Android: data/PlayLogClient.kt (filesDir queue, reused Ktor client) + repo.logPlay/flushPlayLog + PlayerController hook + NovaSonicApp flush + scrobbleUrl in PrefsStore/SettingsViewModel/SettingsScreen (with test button). Plan + design decisions documented in docs/scrobble-plan.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
78 lines
2.6 KiB
JavaScript
78 lines
2.6 KiB
JavaScript
const CACHE = 'ns-v64';
|
|
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
|
|
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
|
|
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
|
|
// The actual fetch still uses the full URL (with token); only the stored key is normalized.
|
|
const STRIP_PARAMS = ['t', 's', 'u', 'v', 'c', 'f'];
|
|
|
|
self.addEventListener('install', e => {
|
|
e.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE)));
|
|
self.skipWaiting();
|
|
});
|
|
|
|
self.addEventListener('activate', e => {
|
|
e.waitUntil(
|
|
caches.keys()
|
|
.then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))))
|
|
.then(() => self.clients.claim())
|
|
.then(() => self.clients.matchAll({ type: 'window', includeUncontrolled: true }))
|
|
.then(clients => clients.forEach(c => c.postMessage({ type: 'SW_UPDATED', version: CACHE })))
|
|
);
|
|
});
|
|
|
|
self.addEventListener('message', e => {
|
|
if (e.data?.type === 'GET_VERSION') {
|
|
e.source.postMessage({ type: 'VERSION', version: CACHE });
|
|
}
|
|
});
|
|
|
|
// Build a stable cache key by stripping Navidrome auth params
|
|
function _cacheKey(url) {
|
|
try {
|
|
const u = new URL(url);
|
|
STRIP_PARAMS.forEach(p => u.searchParams.delete(p));
|
|
return u.toString();
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
self.addEventListener('fetch', e => {
|
|
if (!e.request.url.startsWith(self.location.origin)) return;
|
|
if (SKIP_PATHS.some(p => e.request.url.includes(p))) return;
|
|
|
|
const isCoverArt = e.request.url.includes('/rest/getCoverArt');
|
|
|
|
if (isCoverArt) {
|
|
// Cache-first for artwork — serves instantly from SW cache even when Doze blocks network.
|
|
// Key is token-stripped so it survives session restarts with new auth tokens.
|
|
const keyUrl = _cacheKey(e.request.url);
|
|
const keyReq = new Request(keyUrl);
|
|
e.respondWith(
|
|
caches.open(CACHE).then(cache =>
|
|
cache.match(keyReq).then(cached => {
|
|
if (cached) return cached;
|
|
return fetch(e.request).then(res => {
|
|
if (res.status === 200) cache.put(keyReq, res.clone());
|
|
return res;
|
|
}).catch(() => Response.error());
|
|
})
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
// All other requests: network-first, fall back to cache
|
|
e.respondWith(
|
|
fetch(e.request)
|
|
.then(res => {
|
|
if (res.status === 200 && e.request.method === 'GET') {
|
|
const clone = res.clone();
|
|
caches.open(CACHE).then(c => c.put(e.request, clone));
|
|
}
|
|
return res;
|
|
})
|
|
.catch(() => caches.match(e.request).then(r => r || Response.error()))
|
|
);
|
|
});
|