diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..591f199 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "MCMA — Music", + "short_name": "MCMA", + "description": "Self-hosted music — control center and offline-capable player.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#0b0b0b", + "theme_color": "#0b0b0b", + "icons": [ + { + "src": "/favicon.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "any" + } + ] +} diff --git a/public/sw-core.js b/public/sw-core.js new file mode 100644 index 0000000..46d0613 --- /dev/null +++ b/public/sw-core.js @@ -0,0 +1,98 @@ +/* + * 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; +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..f514c38 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,256 @@ +/* + * MCMA service worker — Tier 3 offline support: audio blob cache. + * + * It sits between the app and the network for audio-stream requests only + * (`/streaming/tracks/`). 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 `` 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) }); + } + })(), + ); +}); diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 35fdd33..794272d 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -32,5 +32,16 @@ export default defineConfig({ // no manual source.define needed. See src/config/env.ts. html: { title: 'MCMA', + // PWA: link the manifest + declare theme/icon so the browser offers + // "Install app". The service worker (audio offline cache) is registered + // from src/index.tsx, not here. + tags: [ + { + tag: 'link', + attrs: { rel: 'manifest', href: '/manifest.webmanifest' }, + }, + { tag: 'meta', attrs: { name: 'theme-color', content: '#0b0b0b' } }, + { tag: 'link', attrs: { rel: 'apple-touch-icon', href: '/favicon.png' } }, + ], }, }); diff --git a/src/api/index.ts b/src/api/index.ts index a872493..f9a4bdd 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,5 +1,6 @@ import { createApi } from '@reduxjs/toolkit/query/react'; import { baseQueryWithReauth } from './baseQuery'; +import { REHYDRATE_API, type RehydrateApiPayload } from './rehydrate'; export const api = createApi({ reducerPath: 'api', @@ -14,5 +15,16 @@ export const api = createApi({ 'User', 'Storage', ], + // Tier 2 offline: seed the cache from the persisted snapshot dispatched at + // startup (see `store/rtkqPersist.ts`). Returning the saved queries/mutations + // lets the last-seen library render before — or instead of — any network call. + extractRehydrationInfo(action) { + if (action.type === REHYDRATE_API) { + // The api reducer reads `queries`/`mutations` off this and restores any + // fulfilled entries; pending/rejected ones are ignored automatically. + return action.payload as RehydrateApiPayload as never; + } + return undefined; + }, endpoints: () => ({}), }); diff --git a/src/api/rehydrate.ts b/src/api/rehydrate.ts new file mode 100644 index 0000000..fd8e706 --- /dev/null +++ b/src/api/rehydrate.ts @@ -0,0 +1,20 @@ +import { createAction } from '@reduxjs/toolkit'; + +/* + * Tier 2 offline support contract. RTK Query can seed its cache from a + * persisted snapshot via `extractRehydrationInfo` (see `api/index.ts`). We use + * a single action whose payload is the previously-saved api slice state; the + * api reducer pulls `queries`/`mutations` out of it on startup so last-seen + * library data renders read-only while the backend is unreachable. + * + * The type string lives here (not in the store) so `api/index.ts` can match it + * without importing from the store layer (which would create a cycle). + */ +export const REHYDRATE_API = 'api/rehydrate'; + +export interface RehydrateApiPayload { + queries: Record; + mutations: Record; +} + +export const rehydrateApi = createAction(REHYDRATE_API); diff --git a/src/components/common/ConnectionStatus.tsx b/src/components/common/ConnectionStatus.tsx index bcbd2d0..974afc9 100644 --- a/src/components/common/ConnectionStatus.tsx +++ b/src/components/common/ConnectionStatus.tsx @@ -22,9 +22,15 @@ export function ConnectionStatus() { const status = useConnectionStatus(); const baseUrl = getApiBaseUrl(); const label = t(STATUS_KEY[status]); + // When the backend is unreachable the UI falls back to the persisted RTKQ + // cache (Tier 2), so flag that the data on screen is last-seen, not live. + const offline = status === 'disconnected' || status === 'error'; + const tip = offline + ? `${label} · ${baseUrl} · ${t('conn.cached')}` + : `${label} · ${baseUrl}`; return ( - + {label} diff --git a/src/components/player/PersistentPlayer.tsx b/src/components/player/PersistentPlayer.tsx index ce5a188..cc1c34d 100644 --- a/src/components/player/PersistentPlayer.tsx +++ b/src/components/player/PersistentPlayer.tsx @@ -14,6 +14,7 @@ import { toggleQueue, } from '../../store/slices/player'; import { useAudioPlayer } from '../../hooks/useAudioPlayer'; +import { useStreamCached } from '../../hooks/useStreamCached'; import { formatDuration } from '../../lib/format'; import { getCoverUrl } from '../../api/endpoints/streaming'; @@ -24,6 +25,8 @@ export function PersistentPlayer() { const player = useAppSelector((s) => s.player); const queue = useAppSelector((s) => s.queue); const currentEntry = queue.entries[queue.currentIndex]; + // Source indicator: cached → playing locally, otherwise streaming. + const cached = useStreamCached(currentEntry?.trackId); if (!currentEntry && !player.currentTrackId) { return
{t('player.nothingPlaying')}
; @@ -31,7 +34,7 @@ export function PersistentPlayer() { const artUrl = getCoverUrl(currentEntry?.albumArtUrl); const seedLabel = currentEntry?.albumTitle ?? currentEntry?.title ?? ''; - const onStream = true; + const onStream = !cached; return (
diff --git a/src/hooks/useStreamCached.ts b/src/hooks/useStreamCached.ts new file mode 100644 index 0000000..ac213af --- /dev/null +++ b/src/hooks/useStreamCached.ts @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react'; +import { useAppSelector } from './useAppDispatch'; +import { isStreamCached } from '../lib/sw'; +import { getStreamUrl } from '../api/endpoints/streaming'; + +/** + * Whether the given track is available from the offline audio cache (Tier 3). + * Drives the player-bar source indicator (local vs streaming). Returns false + * until the service worker is controlling and confirms a hit. + */ +export function useStreamCached(trackId: string | undefined): boolean { + const token = useAppSelector((s) => s.auth.accessToken); + const [cached, setCached] = useState(false); + + useEffect(() => { + if (!trackId || !token) { + setCached(false); + return; + } + let active = true; + void isStreamCached(getStreamUrl(trackId, token)).then((hit) => { + if (active) setCached(hit); + }); + return () => { + active = false; + }; + }, [trackId, token]); + + return cached; +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index ca753de..d63a315 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -17,6 +17,7 @@ const en = { disconnected: 'Offline', error: 'Unreachable', manage: 'Connection — manage instances', + cached: 'Showing last-seen data', }, user: { online: 'online', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 9b7506a..9f22a97 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -19,6 +19,7 @@ const ru: Translations = { disconnected: 'Нет связи', error: 'Недоступно', manage: 'Соединение — управление экземплярами', + cached: 'Показаны последние данные', }, user: { online: 'онлайн', diff --git a/src/index.tsx b/src/index.tsx index 5de2225..73853df 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -10,6 +10,7 @@ import { BrowserRouter } from 'react-router'; import { ThemeProvider, TooltipProvider } from '@olly/modern-sk'; import { store } from './store'; import { AppRoutes } from './routes'; +import { registerServiceWorker } from './lib/sw'; // Import all endpoint injections to ensure they are registered import './api/endpoints/auth'; @@ -21,6 +22,10 @@ import './api/endpoints/storage'; import './api/endpoints/admin'; import './api/endpoints/upload'; +// Tier 3 offline: register the audio-caching service worker (no-op if the +// browser/origin doesn't support it). +registerServiceWorker(); + const rootEl = document.getElementById('root'); if (rootEl) { // grained black-ish background + base text color from modern-sk diff --git a/src/lib/sw.ts b/src/lib/sw.ts new file mode 100644 index 0000000..4f5c404 --- /dev/null +++ b/src/lib/sw.ts @@ -0,0 +1,80 @@ +/* + * Service-worker client: registration + a typed bridge to the audio offline + * cache (Tier 3). The SW itself lives in `public/sw.js`; this is the app side. + * + * Messaging uses a MessageChannel — we hand the SW a port and await its reply — + * so each call resolves with that request's result rather than a global event. + */ + +export interface AudioCacheStats { + count: number; + bytes: number; + maxBytes: number; +} + +/** Register the service worker. No-op when unsupported (e.g. plain http host). */ +export function registerServiceWorker(): void { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { + return; + } + window.addEventListener('load', () => { + // Module worker: sw.js uses ES imports (see public/sw.js + sw-core.js). + navigator.serviceWorker.register('/sw.js', { type: 'module' }).catch(() => { + /* SW unavailable (insecure origin, blocked, …) — app still works online */ + }); + }); +} + +function controller(): ServiceWorker | null { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { + return null; + } + return navigator.serviceWorker.controller; +} + +/** Round-trip a message to the SW; rejects if no controlling SW is present. */ +function send(message: Record): Promise { + const sw = controller(); + if (!sw) return Promise.reject(new Error('no-service-worker')); + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + channel.port1.onmessage = (event) => resolve(event.data as T); + try { + sw.postMessage(message, [channel.port2]); + } catch (err) { + reject(err as Error); + } + }); +} + +/** Total size + count of cached audio, or null when the SW isn't controlling. */ +export async function getAudioCacheStats(): Promise { + try { + return await send({ type: 'STATS' }); + } catch { + return null; + } +} + +/** Whether a given stream URL is already cached for offline playback. */ +export async function isStreamCached(streamUrl: string): Promise { + try { + const { cached } = await send<{ cached: boolean }>({ + type: 'HAS', + url: streamUrl, + }); + return cached; + } catch { + return false; + } +} + +/** Drop the entire audio cache. Resolves false if the SW isn't controlling. */ +export async function clearAudioCache(): Promise { + try { + const { ok } = await send<{ ok: boolean }>({ type: 'CLEAR' }); + return ok; + } catch { + return false; + } +} diff --git a/src/store/index.ts b/src/store/index.ts index 31aa86b..90f0286 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -4,6 +4,8 @@ import authReducer from './slices/auth'; import playerReducer from './slices/player'; import queueReducer from './slices/queue'; import uiReducer from './slices/ui'; +import { loadPlayerState, loadQueueState, startPersistence } from './persist'; +import { rehydrateApiCache, startApiPersistence } from './rtkqPersist'; export const store = configureStore({ reducer: { @@ -13,9 +15,23 @@ export const store = configureStore({ queue: queueReducer, ui: uiReducer, }, + // Tier 1 offline: rehydrate queue/player from the active backend's namespace + // so a reload (even with no backend) restores exactly where the user left off. + preloadedState: { + queue: loadQueueState(), + player: loadPlayerState(), + }, middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(api.middleware), }); +// Flush queue/player changes back to localStorage (throttled). +startPersistence(store); + +// Tier 2 offline: replay the last-seen RTKQ cache, then keep snapshotting it. +// Rehydrate first so cached server data is present before any component mounts. +rehydrateApiCache(store.dispatch); +startApiPersistence(store); + export type RootState = ReturnType; export type AppDispatch = typeof store.dispatch; diff --git a/src/store/persist.ts b/src/store/persist.ts new file mode 100644 index 0000000..1603b40 --- /dev/null +++ b/src/store/persist.ts @@ -0,0 +1,123 @@ +/* + * Tier 1 offline support: persist client state (queue + player) to the active + * backend's localStorage namespace, mirroring the auth slice. This is what lets + * the UI come back exactly as the user left it after a reload with no backend + * reachable — no server data is duplicated (the queue stores track IDs + minimal + * display fields only; full track records still live in the RTKQ cache). + * + * Server data (library/albums/…) is Tier 2 (see `rtkqPersist.ts`). + */ +import { instanceStorage } from '../config/instances'; +import { + queueInitialState, + type QueueState, +} from './slices/queue'; +import { + playerInitialState, + type PlayerState, +} from './slices/player'; +import type { RootState } from './index'; + +const QUEUE_KEY = 'queue'; +const PLAYER_KEY = 'player'; + +// Only persist fields that make sense to restore. `duration`/`isPlaying` are +// derived from the