Compare commits

...

7 Commits

Author SHA1 Message Date
Senko-san dacb8b9278 feat(api): real login + listening wired to the backend contract
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
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>
2026-06-08 17:12:44 +03:00
Senko-san bcfb36d53e feat: make API base URL runtime-configurable
The PROD image baked PUBLIC_API_BASE_URL at build time (rsbuild inlines
PUBLIC_* vars), so a prebuilt image could only ever target a same-origin
'/api/v1' and needed a reverse proxy in front. Move the operator default to
runtime so one image can point at any backend origin without rebuilding.

- public/config.js: committed stub setting window.__APP_CONFIG__ = {}, used
  as the dev/build-time default and overwritten in prod at container start.
- rsbuild.config.ts: inject a classic (non-deferred) <script src="/config.js">
  into <head> so it runs before the deferred app bundle.
- src/config/env.ts: DEFAULT_API_BASE_URL now resolves
  window.__APP_CONFIG__.apiBaseUrl > import.meta.env.PUBLIC_API_BASE_URL >
  '/api/v1'. The user-chosen instance still wins over all of these.
- dockerfiles/30-runtime-config.sh: nginx /docker-entrypoint.d hook that
  regenerates /config.js from $PUBLIC_API_BASE_URL on every start.
- Dockerfile.prod: install the hook (build-time ARG is now just a fallback).
- nginx.conf: serve /config.js with Cache-Control: no-store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:40:59 +03:00
Senko-san 451dbb94a8 fix: cicd
Docker Build & Publish / build (push) Successful in 1m49s
Docker Build & Publish / push (push) Successful in 13s
Docker Build & Publish / Prune old image versions (push) Successful in 1s
2026-06-07 21:31:50 +03:00
Senko-san af0d8e7646 feat: build
Docker Build & Publish / build (push) Failing after 1m0s
Docker Build & Publish / push (push) Has been skipped
Docker Build & Publish / Prune old image versions (push) Has been skipped
2026-06-07 21:29:27 +03:00
Senko-san f712f871f1 feat: build
Docker Build & Publish / build (push) Failing after 11s
Docker Build & Publish / push (push) Has been skipped
Docker Build & Publish / Prune old image versions (push) Has been skipped
2026-06-07 21:26:57 +03:00
Senko-san a2fa425853 feat: build
Docker Build & Publish / build-and-push (push) Failing after 1m24s
Docker Build & Publish / Prune old image versions (push) Has been skipped
2026-06-07 21:11:50 +03:00
Senko-san ceee9b9d12 feat(offline): make the web UI usable without a reachable backend
Three tiers of offline support, all scoped to the active backend's
localStorage namespace (mirroring the auth slice):

Tier 1 — persist client state. queue + player slices are saved (queue
entries/index/source; player track/position/volume/repeat/shuffle) and
rehydrated on load, so a reload with no backend restores where the user
left off. Playback never auto-resumes (browsers block autoplay). Retires
the DEMO_QUEUE and isQueueOpen:true stubs.

Tier 2 — persist the RTK Query cache. Last-seen library/albums/artists
are snapshotted (fulfilled queries only) and replayed via RTKQ's
extractRehydrationInfo at startup, so the library renders read-only when
the backend is down. ConnectionStatus tooltip flags cached data offline.
No server data is copied into a slice — the cache feeds itself back.

Tier 3 — service worker audio + cover cache (PWA). Audio streams are
cached keyed by content id (token stripped), range-aware (synthetic 206
slicing), with a 500MB LRU cap, so already-played tracks play fully
offline. Cover art uses stale-while-revalidate in its own bounded cache.
Module worker (ESM); pure helpers split into sw-core.js and unit-tested.
Web app manifest enables "Install app". Player source badge now reflects
real cached state.

tsc clean, lint clean, 19 new tests pass, production build verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:59:31 +03:00
42 changed files with 1765 additions and 153 deletions
+121
View File
@@ -0,0 +1,121 @@
name: Docker Build & Publish
on:
push:
branches: [master]
workflow_dispatch:
env:
# Number of tagged (non-latest) versions to keep per image name.
KEEP_VERSIONS: "5"
jobs:
build:
runs-on: ubuntu-latest
outputs:
host: ${{ steps.meta.outputs.host }}
image: ${{ steps.meta.outputs.image }}
sha: ${{ steps.meta.outputs.sha }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve registry metadata
id: meta
run: |
host=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||; s|/$||')
repo_lc=$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')
echo "host=$host" >> "$GITHUB_OUTPUT"
echo "image=$host/$repo_lc" >> "$GITHUB_OUTPUT"
echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image
uses: docker/build-push-action@v5
with:
context: .
file: dockerfiles/Dockerfile.prod
push: false
build-args: |
PUBLIC_API_BASE_URL=/api/v1
tags: |
${{ steps.meta.outputs.image }}:latest
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.sha }}
outputs: type=docker,dest=/tmp/image.tar
- name: Upload image artifact
uses: actions/upload-artifact@v3
with:
name: docker-image
path: /tmp/image.tar
retention-days: 1
push:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download image artifact
uses: actions/download-artifact@v3
with:
name: docker-image
path: /tmp
- name: Load image
run: docker load < /tmp/image.tar
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ needs.build.outputs.host }}
username: ${{ gitea.actor }}
password: ${{ secrets.PACKAGE_REGISTRY_TOKEN }}
- name: Push image
run: |
docker push ${{ needs.build.outputs.image }}:latest
docker push ${{ needs.build.outputs.image }}:${{ needs.build.outputs.sha }}
cleanup:
name: Prune old image versions
needs: push
runs-on: ubuntu-latest
steps:
- name: Delete versions beyond KEEP_VERSIONS
env:
GITEA_URL: ${{ gitea.server_url }}
OWNER: ${{ gitea.repository_owner }}
IMAGE: ${{ gitea.event.repository.name }}
TOKEN: ${{ secrets.PACKAGE_REGISTRY_TOKEN }}
run: |
image=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')
# List all container package versions for this image (page size 50 is
# enough for typical repos; increase if you push very frequently).
response=$(curl -sf \
-H "Authorization: token $TOKEN" \
-H "Accept: application/json" \
"${GITEA_URL}/api/v1/packages/${OWNER}?type=container&limit=50&q=${image}")
# Keep the KEEP_VERSIONS newest SHA-tagged versions; always preserve 'latest'.
to_delete=$(printf '%s' "$response" \
| jq -r \
--arg name "$image" \
--argjson keep "$KEEP_VERSIONS" \
'[.[] | select(.name == $name and .version != "latest")]
| sort_by(.created) | reverse
| .[$keep:][].version')
if [ -z "$to_delete" ]; then
echo "Nothing to prune."
exit 0
fi
while IFS= read -r version; do
echo "Deleting ${image}:${version}"
curl -sf -X DELETE \
-H "Authorization: token $TOKEN" \
"${GITEA_URL}/api/v1/packages/${OWNER}/container/${image}/${version}" \
&& echo " ok" || echo " failed (may already be gone, continuing)"
done <<< "$to_delete"
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Write the SPA's runtime operator config at container start.
#
# The nginx base image runs every /docker-entrypoint.d/*.sh before launching
# nginx, so this overwrites the build-time public/config.js stub with the value
# of $PUBLIC_API_BASE_URL. That lets one prebuilt image target any backend
# origin without rebuilding. Resolution + precedence live in src/config/env.ts.
set -eu
: "${PUBLIC_API_BASE_URL:=/api/v1}"
ROOT="${NGINX_HTML_ROOT:-/usr/share/nginx/html}"
printf 'window.__APP_CONFIG__={"apiBaseUrl":"%s"};\n' "$PUBLIC_API_BASE_URL" \
>"$ROOT/config.js"
echo "runtime-config: wrote apiBaseUrl=$PUBLIC_API_BASE_URL to $ROOT/config.js"
+1 -6
View File
@@ -4,14 +4,9 @@
# shadows the container install. Build context = mcma-webui/.
FROM node:22-slim
# `modern-sk` is a git dependency (git+https://...) — npm needs git to fetch it.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json modern-sk-*.tgz ./
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
+9 -3
View File
@@ -8,13 +8,14 @@ FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json modern-sk-*.tgz ./
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# Bake the API base URL at build time (rsbuild inlines PUBLIC_* vars).
# Same-origin default ('/api/v1') works behind any reverse proxy.
# Build-time default for the API base URL (rsbuild inlines PUBLIC_* vars). This
# is only the *fallback* now — the real value is injected at container start by
# 30-runtime-config.sh, so the image can target any backend without a rebuild.
ARG PUBLIC_API_BASE_URL=/api/v1
ENV PUBLIC_API_BASE_URL=$PUBLIC_API_BASE_URL
RUN npm run build
@@ -25,5 +26,10 @@ FROM nginx:1.27-alpine AS runtime
COPY dockerfiles/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
# Runtime config injection: the nginx image runs /docker-entrypoint.d/*.sh
# before starting, regenerating /config.js from $PUBLIC_API_BASE_URL.
COPY dockerfiles/30-runtime-config.sh /docker-entrypoint.d/30-runtime-config.sh
RUN chmod +x /docker-entrypoint.d/30-runtime-config.sh
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+7
View File
@@ -14,6 +14,13 @@ server {
try_files $uri =404;
}
# Runtime operator config — regenerated per container start, so it must
# never be cached or a redeployed backend URL would be ignored.
location = /config.js {
add_header Cache-Control "no-store";
try_files $uri =404;
}
# SPA: every unknown path falls back to index.html (client-side router).
location / {
try_files $uri $uri/ /index.html;
+8
View File
@@ -0,0 +1,8 @@
// Runtime operator configuration, read by the app before the bundle loads
// (see src/config/env.ts). In the PROD image this file is OVERWRITTEN at
// container start from $PUBLIC_API_BASE_URL (dockerfiles/30-runtime-config.sh),
// so one prebuilt image can target any backend origin without a rebuild.
//
// This committed stub is the local-dev / build-time default: it leaves the
// config empty so base-URL resolution falls back to the build-time env var.
window.__APP_CONFIG__ = {};
+19
View File
@@ -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"
}
]
}
+98
View File
@@ -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/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;
}
+256
View File
@@ -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
* (`/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) });
}
})(),
);
});
+21
View File
@@ -32,5 +32,26 @@ 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: [
// Runtime operator config. A classic (non-deferred) head script, so it
// runs before the deferred app bundle and window.__APP_CONFIG__ is set by
// the time src/config/env.ts reads it. Served from public/ in dev and
// overwritten from $PUBLIC_API_BASE_URL at container start in prod.
{
tag: 'script',
attrs: { src: '/config.js' },
head: true,
append: false,
},
{
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' } },
],
},
});
+9 -10
View File
@@ -34,24 +34,23 @@ export const baseQueryWithReauth: BaseQueryFn<
{
url: '/auth/refresh',
method: 'POST',
body: { refreshToken },
body: { refresh_token: refreshToken },
},
api,
extraOptions,
);
if (refreshResult.data) {
const {
accessToken,
refreshToken: newRefresh,
expiresIn,
} = refreshResult.data as {
accessToken: string;
refreshToken: string;
expiresIn: number;
// Backend wire format is snake_case with no TTL (see auth.ts adapter).
const { access_token, refresh_token } = refreshResult.data as {
access_token: string;
refresh_token: string;
};
api.dispatch(
setTokens({ accessToken, refreshToken: newRefresh, expiresIn }),
setTokens({
accessToken: access_token,
refreshToken: refresh_token,
}),
);
result = await rawBaseQuery()(args, api, extraOptions);
} else {
+21 -10
View File
@@ -1,33 +1,44 @@
import { api } from '../index';
import { toUser, type RawUser } from '../mappers';
import type { User } from '../types';
/**
* Admin user management. The backend models authorization as `is_superuser` /
* `is_active` (no `role`/`email`); `toUser` maps superuser→role for the UI and
* the mutations translate role back to `is_superuser` on the way out.
*/
export const adminApi = api.injectEndpoints({
endpoints: (build) => ({
getUsers: build.query<User[], void>({
query: () => '/admin/users',
transformResponse: (raw: RawUser[]) => raw.map(toUser),
providesTags: ['User'],
}),
createUser: build.mutation<
User,
{
username: string;
password: string;
email?: string;
role: 'admin' | 'user';
}
{ username: string; password: string; role: 'admin' | 'user' }
>({
query: (body) => ({ url: '/admin/users', method: 'POST', body }),
query: ({ username, password, role }) => ({
url: '/admin/users',
method: 'POST',
body: { username, password, is_superuser: role === 'admin' },
}),
transformResponse: (raw: RawUser) => toUser(raw),
invalidatesTags: ['User'],
}),
updateUser: build.mutation<
User,
{ id: string; role?: 'admin' | 'user'; email?: string }
{ id: string; role?: 'admin' | 'user'; isActive?: boolean }
>({
query: ({ id, ...body }) => ({
query: ({ id, role, isActive }) => ({
url: `/admin/users/${id}`,
method: 'PATCH',
body,
body: {
is_superuser: role === undefined ? undefined : role === 'admin',
is_active: isActive,
},
}),
transformResponse: (raw: RawUser) => toUser(raw),
invalidatesTags: ['User'],
}),
deleteUser: build.mutation<void, string>({
+65 -11
View File
@@ -1,26 +1,80 @@
import { api } from '../index';
import type { LoginRequest, LoginResponse } from '../types';
import { toUser, type RawUser } from '../mappers';
import type { AuthTokens, LoginRequest, LoginResponse, User } from '../types';
/**
* Auth seam over the backend's wire format: tokens-only login + a separate
* `/auth/me` for the user. Token mapping lives here; user mapping is shared with
* the admin endpoints via `toUser` in `mappers.ts`.
*/
/** `/auth/login` & `/auth/refresh` response shape. */
interface RawTokenResponse {
access_token: string;
refresh_token: string;
token_type?: string;
}
const toTokens = (raw: RawTokenResponse): AuthTokens => ({
accessToken: raw.access_token,
refreshToken: raw.refresh_token,
// No TTL on the wire — expiry is 401-driven (see baseQuery reauth).
});
export const authApi = api.injectEndpoints({
endpoints: (build) => ({
// Login is a two-call flow: POST /auth/login yields tokens, then GET
// /auth/me resolves the user. A queryFn chains both so callers get the
// unified { user, tokens } the UI expects in one await.
login: build.mutation<LoginResponse, LoginRequest>({
query: (body) => ({ url: '/auth/login', method: 'POST', body }),
async queryFn(body, _api, _extra, baseQuery) {
const tokenRes = await baseQuery({
url: '/auth/login',
method: 'POST',
body,
});
if (tokenRes.error) return { error: tokenRes.error };
const tokens = toTokens(tokenRes.data as RawTokenResponse);
// The access token isn't in the store yet, so attach it explicitly —
// baseQuery's prepareHeaders only injects what's already in auth state.
const meRes = await baseQuery({
url: '/auth/me',
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
if (meRes.error) return { error: meRes.error };
const user = toUser(meRes.data as RawUser);
return { data: { user, tokens } };
},
}),
logout: build.mutation<void, void>({
query: () => ({ url: '/auth/logout', method: 'POST' }),
logout: build.mutation<void, { refreshToken: string }>({
query: ({ refreshToken }) => ({
url: '/auth/logout',
method: 'POST',
body: { refresh_token: refreshToken },
}),
}),
refreshToken: build.mutation<
{ accessToken: string; refreshToken: string; expiresIn: number },
{ refreshToken: string }
>({
query: (body) => ({ url: '/auth/refresh', method: 'POST', body }),
refreshToken: build.mutation<AuthTokens, { refreshToken: string }>({
query: ({ refreshToken }) => ({
url: '/auth/refresh',
method: 'POST',
body: { refresh_token: refreshToken },
}),
transformResponse: (raw: RawTokenResponse) => toTokens(raw),
}),
me: build.query<import('../types').User, void>({
me: build.query<User, void>({
query: () => '/auth/me',
transformResponse: (raw: RawUser) => toUser(raw),
providesTags: ['User'],
}),
}),
overrideExisting: false,
});
export const { useLoginMutation, useLogoutMutation, useMeQuery } = authApi;
export const {
useLoginMutation,
useLogoutMutation,
useRefreshTokenMutation,
useMeQuery,
} = authApi;
+6
View File
@@ -1,6 +1,12 @@
import { api } from '../index';
import type { DownloadJob } from '../types';
// NOTE: the backend `/downloads` routes are still unimplemented stubs (they
// return no body / no schema). The request shapes below are provisional and the
// responses will need the same snake→camel mapper treatment as library/playlists
// (see `mappers.ts`) once the backend defines DownloadJob's wire format. Do not
// wire these into the UI until then.
export const downloadsApi = api.injectEndpoints({
endpoints: (build) => ({
getDownloads: build.query<
+85 -9
View File
@@ -1,4 +1,14 @@
import { api } from '../index';
import {
toAlbum,
toArtist,
toPage,
toTrack,
type RawAlbum,
type RawArtist,
type RawPaged,
type RawTrack,
} from '../mappers';
import type {
Track,
Album,
@@ -7,10 +17,41 @@ import type {
LibraryFilters,
} from '../types';
// The backend sorts on a small allow-list; map the UI's sort keys onto it
// (album/year aren't sortable server-side yet → fall back to recency).
const SORT_BY: Record<NonNullable<LibraryFilters['sortBy']>, string> = {
title: 'title',
artist: 'artist',
album: 'created_at',
year: 'created_at',
dateAdded: 'created_at',
};
/** UI page/pageSize → backend limit/offset. */
function paging(page?: number, pageSize?: number) {
const size = pageSize ?? 50;
return { limit: size, offset: ((page ?? 1) - 1) * size };
}
function trackParams(f: LibraryFilters) {
return {
q: f.search,
artist_id: f.artistId,
album_id: f.albumId,
sort_by: f.sortBy ? SORT_BY[f.sortBy] : undefined,
order: f.sortOrder,
...paging(f.page, f.pageSize),
};
}
export const libraryApi = api.injectEndpoints({
endpoints: (build) => ({
getTracks: build.query<PaginatedResponse<Track>, LibraryFilters | void>({
query: (filters) => ({ url: '/library/tracks', params: filters ?? {} }),
query: (filters) => ({
url: '/tracks',
params: trackParams(filters ?? {}),
}),
transformResponse: (raw: RawPaged<RawTrack>) => toPage(raw, toTrack),
providesTags: (result) =>
result
? [
@@ -20,7 +61,8 @@ export const libraryApi = api.injectEndpoints({
: ['Track'],
}),
getTrack: build.query<Track, string>({
query: (id) => `/library/tracks/${id}`,
query: (id) => `/tracks/${id}`,
transformResponse: (raw: RawTrack) => toTrack(raw),
providesTags: (_r, _e, id) => [{ type: 'Track', id }],
}),
getAlbums: build.query<
@@ -32,7 +74,15 @@ export const libraryApi = api.injectEndpoints({
pageSize?: number;
} | void
>({
query: (params) => ({ url: '/library/albums', params: params ?? {} }),
query: (p) => ({
url: '/albums',
params: {
q: p?.search,
artist_id: p?.artistId,
...paging(p?.page, p?.pageSize),
},
}),
transformResponse: (raw: RawPaged<RawAlbum>) => toPage(raw, toAlbum),
providesTags: (result) =>
result
? [
@@ -42,11 +92,13 @@ export const libraryApi = api.injectEndpoints({
: ['Album'],
}),
getAlbum: build.query<Album, string>({
query: (id) => `/library/albums/${id}`,
query: (id) => `/albums/${id}`,
transformResponse: (raw: RawAlbum) => toAlbum(raw),
providesTags: (_r, _e, id) => [{ type: 'Album', id }],
}),
getAlbumTracks: build.query<Track[], string>({
query: (albumId) => `/library/albums/${albumId}/tracks`,
query: (albumId) => `/albums/${albumId}/tracks`,
transformResponse: (raw: RawPaged<RawTrack>) => raw.items.map(toTrack),
providesTags: (_r, _e, albumId) => [
{ type: 'Album', id: albumId },
'Track',
@@ -56,7 +108,11 @@ export const libraryApi = api.injectEndpoints({
PaginatedResponse<Artist>,
{ search?: string; page?: number; pageSize?: number } | void
>({
query: (params) => ({ url: '/library/artists', params: params ?? {} }),
query: (p) => ({
url: '/artists',
params: { q: p?.search, ...paging(p?.page, p?.pageSize) },
}),
transformResponse: (raw: RawPaged<RawArtist>) => toPage(raw, toArtist),
providesTags: (result) =>
result
? [
@@ -69,21 +125,40 @@ export const libraryApi = api.injectEndpoints({
: ['Artist'],
}),
getArtist: build.query<Artist, string>({
query: (id) => `/library/artists/${id}`,
query: (id) => `/artists/${id}`,
transformResponse: (raw: RawArtist) => toArtist(raw),
providesTags: (_r, _e, id) => [{ type: 'Artist', id }],
}),
getArtistAlbums: build.query<Album[], string>({
query: (artistId) => `/library/artists/${artistId}/albums`,
query: (artistId) => `/artists/${artistId}/albums`,
transformResponse: (raw: RawPaged<RawAlbum>) => raw.items.map(toAlbum),
providesTags: (_r, _e, artistId) => [
{ type: 'Artist', id: artistId },
'Album',
],
}),
getArtistTracks: build.query<Track[], string>({
query: (artistId) => `/artists/${artistId}/tracks`,
transformResponse: (raw: RawPaged<RawTrack>) => raw.items.map(toTrack),
providesTags: (_r, _e, artistId) => [
{ type: 'Artist', id: artistId },
'Track',
],
}),
searchLibrary: build.query<
{ tracks: Track[]; albums: Album[]; artists: Artist[] },
string
>({
query: (q) => ({ url: '/library/search', params: { q } }),
query: (q) => ({ url: '/search/library', params: { q } }),
transformResponse: (raw: {
tracks: RawTrack[];
albums: RawAlbum[];
artists: RawArtist[];
}) => ({
tracks: raw.tracks.map(toTrack),
albums: raw.albums.map(toAlbum),
artists: raw.artists.map(toArtist),
}),
providesTags: ['Track', 'Album', 'Artist'],
}),
}),
@@ -99,5 +174,6 @@ export const {
useGetArtistsQuery,
useGetArtistQuery,
useGetArtistAlbumsQuery,
useGetArtistTracksQuery,
useSearchLibraryQuery,
} = libraryApi;
+58 -10
View File
@@ -1,20 +1,68 @@
import { api } from '../index';
/**
* Likes are an append-only event-log on the backend: state changes by POSTing a
* new `{track_id, value}` event, never by PUT/DELETE on a boolean. "Unlike" is
* just a `neutral` event superseding the prior `like`.
*/
type LikeValue = 'like' | 'dislike' | 'neutral';
interface RawLikeState {
track_id: string;
value: LikeValue;
updated_at: string;
}
export interface LikeState {
trackId: string;
value: LikeValue;
updatedAt: string;
}
const toLikeState = (r: RawLikeState): LikeState => ({
trackId: r.track_id,
value: r.value,
updatedAt: r.updated_at,
});
export const likesApi = api.injectEndpoints({
endpoints: (build) => ({
likeTrack: build.mutation<void, string>({
query: (trackId) => ({ url: `/likes/tracks/${trackId}`, method: 'PUT' }),
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }],
}),
unlikeTrack: build.mutation<void, string>({
query: (trackId) => ({
url: `/likes/tracks/${trackId}`,
method: 'DELETE',
setLike: build.mutation<LikeState, { trackId: string; value: LikeValue }>({
query: ({ trackId, value }) => ({
url: '/likes',
method: 'POST',
body: { track_id: trackId, value },
}),
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }],
transformResponse: (raw: RawLikeState) => toLikeState(raw),
invalidatesTags: (_r, _e, { trackId }) => [
'Like',
{ type: 'Track', id: trackId },
],
}),
// Latest like state for a set of tracks (drives like buttons in lists).
getLikesState: build.query<LikeState[], string[]>({
query: (trackIds) => ({
url: '/likes/state',
params: { track_ids: trackIds.join(',') },
}),
transformResponse: (raw: RawLikeState[]) => raw.map(toLikeState),
providesTags: ['Like'],
}),
}),
overrideExisting: false,
});
export const { useLikeTrackMutation, useUnlikeTrackMutation } = likesApi;
const { useSetLikeMutation, useGetLikesStateQuery } = likesApi;
/** Convenience hook preserving the like/unlike call sites. */
export function useLikeActions() {
const [setLike, state] = useSetLikeMutation();
return {
like: (trackId: string) => setLike({ trackId, value: 'like' }),
unlike: (trackId: string) => setLike({ trackId, value: 'neutral' }),
dislike: (trackId: string) => setLike({ trackId, value: 'dislike' }),
state,
};
}
export { useSetLikeMutation, useGetLikesStateQuery };
+36 -11
View File
@@ -1,36 +1,61 @@
import { api } from '../index';
import {
toPage,
toPlaylist,
toTrack,
type RawPaged,
type RawPlaylist,
type RawTrack,
} from '../mappers';
import type { Playlist, PlaylistTrack, PaginatedResponse } from '../types';
export const playlistsApi = api.injectEndpoints({
endpoints: (build) => ({
getPlaylists: build.query<PaginatedResponse<Playlist>, void>({
query: () => '/playlists',
transformResponse: (raw: RawPaged<RawPlaylist>) =>
toPage(raw, toPlaylist),
providesTags: ['Playlist'],
}),
getPlaylist: build.query<Playlist, string>({
query: (id) => `/playlists/${id}`,
transformResponse: (raw: RawPlaylist) => toPlaylist(raw),
providesTags: (_r, _e, id) => [{ type: 'Playlist', id }],
}),
getPlaylistTracks: build.query<PlaylistTrack[], string>({
query: (id) => `/playlists/${id}/tracks`,
// The backend returns plain tracks in playlist order; position/addedAt
// aren't on TrackOut, so derive position from order and default addedAt.
transformResponse: (raw: RawPaged<RawTrack>) =>
raw.items.map((r, i) => ({
...toTrack(r),
position: i,
addedAt: r.created_at,
})),
providesTags: (_r, _e, id) => [{ type: 'Playlist', id }, 'Track'],
}),
createPlaylist: build.mutation<
Playlist,
{ name: string; description?: string; isPublic?: boolean }
{ name: string; description?: string }
>({
query: (body) => ({ url: '/playlists', method: 'POST', body }),
query: ({ name, description }) => ({
url: '/playlists',
method: 'POST',
body: { name, description },
}),
transformResponse: (raw: RawPlaylist) => toPlaylist(raw),
invalidatesTags: ['Playlist'],
}),
updatePlaylist: build.mutation<
Playlist,
{ id: string; name?: string; description?: string; isPublic?: boolean }
{ id: string; name?: string; description?: string }
>({
query: ({ id, ...body }) => ({
query: ({ id, name, description }) => ({
url: `/playlists/${id}`,
method: 'PATCH',
body,
body: { name, description },
}),
transformResponse: (raw: RawPlaylist) => toPlaylist(raw),
invalidatesTags: (_r, _e, { id }) => [{ type: 'Playlist', id }],
}),
deletePlaylist: build.mutation<void, string>({
@@ -39,12 +64,12 @@ export const playlistsApi = api.injectEndpoints({
}),
addTrackToPlaylist: build.mutation<
void,
{ playlistId: string; trackId: string }
{ playlistId: string; trackId: string; position?: number }
>({
query: ({ playlistId, trackId }) => ({
query: ({ playlistId, trackId, position }) => ({
url: `/playlists/${playlistId}/tracks`,
method: 'POST',
body: { trackId },
body: { track_id: trackId, position },
}),
invalidatesTags: (_r, _e, { playlistId }) => [
{ type: 'Playlist', id: playlistId },
@@ -52,10 +77,10 @@ export const playlistsApi = api.injectEndpoints({
}),
removeTrackFromPlaylist: build.mutation<
void,
{ playlistId: string; trackId: string; position: number }
{ playlistId: string; trackId: string }
>({
query: ({ playlistId, position }) => ({
url: `/playlists/${playlistId}/tracks/${position}`,
query: ({ playlistId, trackId }) => ({
url: `/playlists/${playlistId}/tracks/${trackId}`,
method: 'DELETE',
}),
invalidatesTags: (_r, _e, { playlistId }) => [
+7
View File
@@ -1,6 +1,13 @@
import { api } from '../index';
import type { StorageStats } from '../types';
// NOTE: the backend `/storage` routes are still unimplemented stubs (no body /
// no schema), and the real paths differ from these placeholders (`GET /storage`,
// `/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`,
// `POST /storage/cleanup`). Re-point paths and add snake→camel mappers (see
// `mappers.ts`) once the backend defines the storage response shapes; until then
// these are provisional and unused by the UI.
export const storageApi = api.injectEndpoints({
endpoints: (build) => ({
getStorageStats: build.query<StorageStats, void>({
+6 -1
View File
@@ -1,8 +1,13 @@
import { getApiBaseUrl } from '../../config/runtime-config';
/**
* Audio stream URL for the `<audio>` element. The access token rides as a query
* param because `<audio>` can't send an `Authorization` header; the backend
* accepts `?token=` on `GET /stream/{id}` for exactly this reason.
*/
export function getStreamUrl(trackId: string, token: string): string {
const base = getApiBaseUrl();
return `${base}/streaming/tracks/${trackId}?token=${encodeURIComponent(token)}`;
return `${base}/stream/${trackId}?token=${encodeURIComponent(token)}`;
}
export function getCoverUrl(artUrl: string | undefined): string | undefined {
+12
View File
@@ -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: () => ({}),
});
+164
View File
@@ -0,0 +1,164 @@
/**
* Backend-contract adapters: the single place where the backend's wire format
* (snake_case, lean `*Out` schemas, `{items,total,limit,offset}` paging) is
* translated into the UI's internal camelCase domain types from `types.ts`.
*
* The endpoint files (`endpoints/*.ts`) own *paths and params*; this module owns
* *shape*. Swapping or mocking a backend means rewriting the mappers here —
* nothing in components, slices, or `types.ts` changes.
*
* Where the backend's lean schema omits a field the UI type carries (cover art,
* liked state, durations on albums…), the mapper fills a safe client default and
* says why inline. Those defaults are the contract's current edges, not bugs.
*/
import type {
Album,
Artist,
PaginatedResponse,
Playlist,
Track,
User,
} from './types';
// ---- raw wire shapes (snake_case, exactly as the backend emits) ----
export interface RawPaged<T> {
items: T[];
total: number;
limit: number;
offset: number;
}
export interface RawUser {
id: string;
username: string;
is_superuser: boolean;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface RawTrack {
id: string;
title: string;
artist_id: string;
artist_name: string;
album_id: string | null;
album_title: string | null;
duration_seconds: number | null;
file_format: string;
file_size: number;
metadata_status: string;
source: string;
created_at: string;
}
export interface RawAlbum {
id: string;
title: string;
artist_id: string;
artist_name: string;
year: number | null;
track_count: number;
created_at: string;
}
export interface RawArtist {
id: string;
name: string;
album_count: number;
track_count: number;
created_at: string;
}
export interface RawPlaylist {
id: string;
name: string;
description: string | null;
owner_id: string;
version: number;
track_count: number;
created_at: string;
}
// ---- mappers ----
export const toUser = (r: RawUser): User => ({
id: r.id,
username: r.username,
// MVP role model: superuser → admin, everyone else → user.
role: r.is_superuser ? 'admin' : 'user',
createdAt: r.created_at,
});
export const toTrack = (r: RawTrack): Track => ({
id: r.id,
title: r.title,
artistId: r.artist_id,
artistName: r.artist_name,
albumId: r.album_id ?? '',
albumTitle: r.album_title ?? '',
// Cover endpoints aren't wired on the backend yet — leave art undefined so the
// UI renders generated tile art instead of a broken image.
albumArtUrl: undefined,
durationMs: (r.duration_seconds ?? 0) * 1000,
// The lean TrackOut carries no availability/like state: a track returned by
// the library is on the server, and per-track like state comes from /likes.
availability: 'server',
liked: false,
format: r.file_format,
fileSize: r.file_size,
});
export const toAlbum = (r: RawAlbum): Album => ({
id: r.id,
title: r.title,
artistId: r.artist_id,
artistName: r.artist_name,
artUrl: undefined,
year: r.year ?? undefined,
trackCount: r.track_count,
// AlbumOut has no aggregate duration; computed client-side from tracks when
// an album is opened.
totalDurationMs: 0,
});
export const toArtist = (r: RawArtist): Artist => ({
id: r.id,
name: r.name,
artUrl: undefined,
albumCount: r.album_count,
trackCount: r.track_count,
});
export const toPlaylist = (r: RawPlaylist): Playlist => ({
id: r.id,
name: r.name,
description: r.description ?? undefined,
ownerId: r.owner_id,
trackCount: r.track_count,
totalDurationMs: 0,
// No visibility concept on the backend yet — default private.
isPublic: false,
createdAt: r.created_at,
// PlaylistOut omits updated_at; mirror created_at until it's added.
updatedAt: r.created_at,
});
/**
* Translate the backend's `{items,total,limit,offset}` envelope into the UI's
* `{items,total,page,pageSize,hasMore}`, mapping each element.
*/
export const toPage = <R, T>(
raw: RawPaged<R>,
map: (r: R) => T,
): PaginatedResponse<T> => {
const pageSize = raw.limit || raw.items.length || 1;
return {
items: raw.items.map(map),
total: raw.total,
page: Math.floor(raw.offset / pageSize) + 1,
pageSize,
hasMore: raw.offset + raw.items.length < raw.total,
};
};
+20
View File
@@ -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<string, unknown>;
mutations: Record<string, unknown>;
}
export const rehydrateApi = createAction<RehydrateApiPayload>(REHYDRATE_API);
+3 -1
View File
@@ -98,7 +98,9 @@ export interface User {
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresIn: number;
// Optional: the backend's TokenResponse carries no TTL — expiry is driven by
// 401→refresh, not a client-side clock. Present only if a backend supplies it.
expiresIn?: number;
}
export interface LoginRequest {
+7 -1
View File
@@ -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 (
<Tooltip content={`${label} · ${baseUrl}`}>
<Tooltip content={tip}>
<Badge variant={STATUS_VARIANTS[status]} dot>
{label}
</Badge>
+4 -1
View File
@@ -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 <div className="player empty">{t('player.nothingPlaying')}</div>;
@@ -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 (
<div className="player">
+21 -1
View File
@@ -1,2 +1,22 @@
/**
* Default backend base URL — the operator-set fallback used when no specific
* instance is active. Resolution order:
*
* 1. window.__APP_CONFIG__.apiBaseUrl — runtime, injected by the container
* at start from $PUBLIC_API_BASE_URL (see public/config.js). Lets one
* prebuilt image point at any backend origin without rebuilding.
* 2. import.meta.env.PUBLIC_API_BASE_URL — build-time default (rsbuild inlines
* PUBLIC_* vars). Used in local dev and as a baked fallback.
* 3. '/api/v1' — same-origin relative path (works behind a reverse proxy).
*
* The user's chosen instance still wins over all of these — see
* runtime-config.ts / instances.ts.
*/
function runtimeApiBaseUrl(): string | undefined {
if (typeof window === 'undefined') return undefined;
const value = window.__APP_CONFIG__?.apiBaseUrl;
return value ? value : undefined;
}
export const DEFAULT_API_BASE_URL =
import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1';
runtimeApiBaseUrl() ?? import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1';
+8
View File
@@ -5,3 +5,11 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv;
}
// Runtime operator config injected by /config.js before the app bundle loads
// (written from $PUBLIC_API_BASE_URL at container start). See src/config/env.ts.
interface Window {
__APP_CONFIG__?: {
apiBaseUrl?: string;
};
}
+31 -21
View File
@@ -1,18 +1,29 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
import { Card, TextField, Button, Callout, Badge } from '@olly/modern-sk';
import { Icon } from '../../components/common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { setTokens, setUser } from '../../store/slices/auth';
import { setApiBaseUrl } from '../../config/runtime-config';
import { useLoginMutation } from '../../api/endpoints/auth';
import {
listInstances,
getActiveInstanceId,
setActiveInstanceId,
removeInstance,
} from '../../config/instances';
import type { User } from '../../api/types';
/** Map an RTKQ login failure to a user-facing i18n key. */
function resolveLoginError(err: unknown): string {
const e = err as FetchBaseQueryError | undefined;
if (e && 'status' in e) {
if (e.status === 'FETCH_ERROR') return 'connect.errors.unreachable';
if (e.status === 401) return 'connect.errors.badCredentials';
}
return 'connect.errors.generic';
}
export function ConnectPage() {
const { t } = useTranslation();
@@ -26,6 +37,9 @@ export function ConnectPage() {
const [apiUrl, setApiUrl] = useState('https://');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [login, { isLoading }] = useLoginMutation();
const switchTo = (id: string) => {
setActiveInstanceId(id);
@@ -37,25 +51,22 @@ export function ConnectPage() {
setRev((r) => r + 1);
};
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Point the API layer at this backend *before* logging in — baseQuery reads
// the active instance's URL at request time. Auth tokens then persist under
// that instance's namespace, never bleeding across servers.
setApiBaseUrl(apiUrl);
const fakeUser: User = {
id: 'dev-user',
username: username || 'dev',
role: 'admin',
createdAt: new Date().toISOString(),
};
dispatch(
setTokens({
accessToken: 'dev-token',
refreshToken: 'dev-refresh',
expiresIn: 3600,
}),
);
dispatch(setUser(fakeUser));
void navigate('/');
try {
const { user, tokens } = await login({ username, password }).unwrap();
dispatch(setTokens(tokens));
dispatch(setUser(user));
void navigate('/');
} catch (err) {
setError(resolveLoginError(err));
}
};
const labelStyle: React.CSSProperties = {
@@ -227,15 +238,14 @@ export function ConnectPage() {
required
/>
</div>
<Callout variant="warning">
{t('connect.form.stubNote')}
</Callout>
{error && <Callout variant="danger">{t(error)}</Callout>}
<Button
type="submit"
variant="primary"
disabled={isLoading}
style={{ marginTop: '0.5rem' }}
>
{t('connect.form.submit')}
{isLoading ? t('connect.form.submitting') : t('connect.form.submit')}
</Button>
</form>
</Card>
+30
View File
@@ -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;
}
+7 -2
View File
@@ -17,6 +17,7 @@ const en = {
disconnected: 'Offline',
error: 'Unreachable',
manage: 'Connection — manage instances',
cached: 'Showing last-seen data',
},
user: {
online: 'online',
@@ -34,8 +35,12 @@ const en = {
username: 'Username',
password: 'Password',
submit: 'Connect',
stubNote:
'Stub mode — backend not wired. Connect signs in with a fake admin session, scoped to this instance.',
submitting: 'Connecting…',
},
errors: {
unreachable: "Can't reach this server. Check the URL and that it's online.",
badCredentials: 'Incorrect username or password.',
generic: 'Sign-in failed. Please try again.',
},
},
library: {
+8 -2
View File
@@ -19,6 +19,7 @@ const ru: Translations = {
disconnected: 'Нет связи',
error: 'Недоступно',
manage: 'Соединение — управление экземплярами',
cached: 'Показаны последние данные',
},
user: {
online: 'онлайн',
@@ -36,8 +37,13 @@ const ru: Translations = {
username: 'Имя пользователя',
password: 'Пароль',
submit: 'Подключиться',
stubNote:
'Режим заглушки — сервер не подключён. Создаётся фиктивная сессия администратора для этого экземпляра.',
submitting: 'Подключение…',
},
errors: {
unreachable:
'Не удаётся подключиться к серверу. Проверьте URL и доступность.',
badCredentials: 'Неверное имя пользователя или пароль.',
generic: 'Не удалось войти. Попробуйте ещё раз.',
},
},
library: {
+5
View File
@@ -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
+80
View File
@@ -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<T>(message: Record<string, unknown>): Promise<T> {
const sw = controller();
if (!sw) return Promise.reject(new Error('no-service-worker'));
return new Promise<T>((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<AudioCacheStats | null> {
try {
return await send<AudioCacheStats>({ type: 'STATS' });
} catch {
return null;
}
}
/** Whether a given stream URL is already cached for offline playback. */
export async function isStreamCached(streamUrl: string): Promise<boolean> {
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<boolean> {
try {
const { ok } = await send<{ ok: boolean }>({ type: 'CLEAR' });
return ok;
} catch {
return false;
}
}
+16
View File
@@ -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<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
+123
View File
@@ -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 <audio> element on next load, and the panel toggles are
// transient UI, so they are intentionally left out.
type PersistedQueue = Pick<
QueueState,
'entries' | 'currentIndex' | 'source' | 'sourceId' | 'sourceName'
>;
type PersistedPlayer = Pick<
PlayerState,
'currentTrackId' | 'position' | 'volume' | 'muted' | 'repeat' | 'shuffle'
>;
function pickQueue(state: QueueState): PersistedQueue {
return {
entries: state.entries,
currentIndex: state.currentIndex,
source: state.source,
sourceId: state.sourceId,
sourceName: state.sourceName,
};
}
function pickPlayer(state: PlayerState): PersistedPlayer {
return {
currentTrackId: state.currentTrackId,
position: state.position,
volume: state.volume,
muted: state.muted,
repeat: state.repeat,
shuffle: state.shuffle,
};
}
function read<T>(key: string): Partial<T> | null {
try {
const raw = instanceStorage.get(key);
return raw ? (JSON.parse(raw) as Partial<T>) : null;
} catch {
return null;
}
}
/** Build the queue slice's initial state, restoring any persisted queue. */
export function loadQueueState(): QueueState {
const persisted = read<PersistedQueue>(QUEUE_KEY);
if (!persisted) return queueInitialState;
const merged: QueueState = { ...queueInitialState, ...persisted };
// Guard the index against a corrupted/short entries array.
if (
merged.currentIndex >= merged.entries.length ||
merged.currentIndex < -1
) {
merged.currentIndex = merged.entries.length ? 0 : -1;
}
return merged;
}
/** Build the player slice's initial state, restoring any persisted player. */
export function loadPlayerState(): PlayerState {
const persisted = read<PersistedPlayer>(PLAYER_KEY);
if (!persisted) return playerInitialState;
// Never auto-resume playback on load: browsers block autoplay and the
// <audio> element starts paused regardless. isPlaying stays false.
return { ...playerInitialState, ...persisted, isPlaying: false };
}
/**
* Subscribe a store so queue/player changes are flushed to localStorage. The
* write is throttled because `setPosition` fires several times a second during
* playback — without throttling we'd hammer localStorage on every tick.
*/
export function startPersistence(store: {
getState: () => RootState;
subscribe: (listener: () => void) => () => void;
}): () => void {
const initial = store.getState();
let lastQueue = JSON.stringify(pickQueue(initial.queue));
let lastPlayer = JSON.stringify(pickPlayer(initial.player));
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = () => {
timer = null;
const state = store.getState();
const queueSnapshot = JSON.stringify(pickQueue(state.queue));
if (queueSnapshot !== lastQueue) {
instanceStorage.set(QUEUE_KEY, queueSnapshot);
lastQueue = queueSnapshot;
}
const playerSnapshot = JSON.stringify(pickPlayer(state.player));
if (playerSnapshot !== lastPlayer) {
instanceStorage.set(PLAYER_KEY, playerSnapshot);
lastPlayer = playerSnapshot;
}
};
return store.subscribe(() => {
if (timer) return;
timer = setTimeout(flush, 1000);
});
}
+76
View File
@@ -0,0 +1,76 @@
/*
* Tier 2 offline support: persist the RTK Query cache (last-seen server data —
* library/albums/artists/…) to the active backend's localStorage namespace and
* replay it into the cache on startup. With the backend down, components keep
* rendering the last-known data read-only instead of an error state.
*
* Per the architecture invariant, server data is NOT copied into a slice: this
* snapshots the RTKQ cache itself and feeds it back through RTKQ's own
* `extractRehydrationInfo` mechanism (see `api/index.ts`).
*/
import { instanceStorage } from '../config/instances';
import { rehydrateApi, type RehydrateApiPayload } from '../api/rehydrate';
import type { RootState } from './index';
const CACHE_KEY = 'rtkq';
type ApiState = RootState['api'];
type QueryEntry = ApiState['queries'][string];
/**
* Keep only successfully-fulfilled query results — pending/rejected entries
* carry no usable data and subscriptions are rebuilt by components on mount.
* Mutation results are never restored.
*/
function snapshot(apiState: ApiState): RehydrateApiPayload {
const queries: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(apiState.queries)) {
const q = entry as QueryEntry | undefined;
if (q && q.status === 'fulfilled') queries[key] = q;
}
return { queries, mutations: {} };
}
function load(): RehydrateApiPayload | null {
try {
const raw = instanceStorage.get(CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<RehydrateApiPayload>;
if (!parsed.queries) return null;
return { queries: parsed.queries, mutations: {} };
} catch {
return null;
}
}
/** Replay the persisted cache into RTKQ. Call once after the store is created. */
export function rehydrateApiCache(dispatch: (action: unknown) => void): void {
const cached = load();
if (cached) dispatch(rehydrateApi(cached));
}
/**
* Subscribe a store so the RTKQ cache is flushed to localStorage. Throttled,
* since cache state churns on every in-flight query transition.
*/
export function startApiPersistence(store: {
getState: () => RootState;
subscribe: (listener: () => void) => () => void;
}): () => void {
let last = '';
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = () => {
timer = null;
const snap = JSON.stringify(snapshot(store.getState().api));
if (snap !== last) {
instanceStorage.set(CACHE_KEY, snap);
last = snap;
}
};
return store.subscribe(() => {
if (timer) return;
timer = setTimeout(flush, 2000);
});
}
+6 -2
View File
@@ -38,12 +38,16 @@ export const authSlice = createSlice({
action: PayloadAction<{
accessToken: string;
refreshToken: string;
expiresIn: number;
expiresIn?: number;
}>,
) {
state.accessToken = action.payload.accessToken;
state.refreshToken = action.payload.refreshToken;
state.expiresAt = Date.now() + action.payload.expiresIn * 1000;
// Backends that omit a TTL leave expiresAt null — reauth is 401-driven.
state.expiresAt =
action.payload.expiresIn != null
? Date.now() + action.payload.expiresIn * 1000
: null;
persistAuth(state);
},
setUser(state, action: PayloadAction<User>) {
+5 -7
View File
@@ -1,8 +1,8 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
type RepeatMode = 'none' | 'one' | 'all';
export type RepeatMode = 'none' | 'one' | 'all';
interface PlayerState {
export interface PlayerState {
currentTrackId: string | null;
isPlaying: boolean;
position: number;
@@ -15,7 +15,7 @@ interface PlayerState {
isQueueOpen: boolean;
}
const initialState: PlayerState = {
export const playerInitialState: PlayerState = {
currentTrackId: null,
isPlaying: false,
position: 0,
@@ -25,14 +25,12 @@ const initialState: PlayerState = {
repeat: 'none',
shuffle: false,
isNowPlayingOpen: false,
// STUB: open by default so the queue drawer look is visible before a backend
// exists (pairs with DEMO_QUEUE). Default to false once real playback lands.
isQueueOpen: true,
isQueueOpen: false,
};
export const playerSlice = createSlice({
name: 'player',
initialState,
initialState: playerInitialState,
reducers: {
play(state, action: PayloadAction<string>) {
state.currentTrackId = action.payload;
+9 -44
View File
@@ -1,6 +1,6 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
type QueueSource =
export type QueueSource =
| 'manual'
| 'album'
| 'playlist'
@@ -8,7 +8,7 @@ type QueueSource =
| 'search'
| 'radio';
interface QueueEntry {
export interface QueueEntry {
trackId: string;
title: string;
artistName: string;
@@ -17,7 +17,7 @@ interface QueueEntry {
albumArtUrl?: string;
}
interface QueueState {
export interface QueueState {
entries: QueueEntry[];
currentIndex: number;
source: QueueSource;
@@ -25,52 +25,17 @@ interface QueueState {
sourceName: string | null;
}
// STUB demo queue — purely client-side display data so the player bar and
// queue drawer render with content before the backend exists. Delete this
// block (reset entries/currentIndex/source to the empty values) once real
// playback wires tracks into the queue.
const DEMO_QUEUE: QueueEntry[] = [
{
trackId: 'd1',
title: 'Quiet Storage',
artistName: 'Cyan Atlas',
albumTitle: 'Night Index',
durationMs: 312_000,
},
{
trackId: 'd2',
title: 'Magnetic North',
artistName: 'Tidal Bloom',
albumTitle: 'Ferric Coast',
durationMs: 243_000,
},
{
trackId: 'd3',
title: 'Ambergris',
artistName: 'Møller',
albumTitle: 'Warm Static',
durationMs: 201_000,
},
{
trackId: 'd4',
title: 'Slow Carrier',
artistName: 'Tidal Bloom',
albumTitle: 'Ferric Coast',
durationMs: 301_000,
},
];
const initialState: QueueState = {
entries: DEMO_QUEUE,
currentIndex: 0,
source: 'radio',
export const queueInitialState: QueueState = {
entries: [],
currentIndex: -1,
source: 'manual',
sourceId: null,
sourceName: 'My radio',
sourceName: null,
};
export const queueSlice = createSlice({
name: 'queue',
initialState,
initialState: queueInitialState,
reducers: {
setQueue(
state,
+120
View File
@@ -0,0 +1,120 @@
// @rstest-environment jsdom
import { expect, test, beforeEach, rstest } from '@rstest/core';
import {
loadQueueState,
loadPlayerState,
startPersistence,
} from '../src/store/persist';
import { queueInitialState, type QueueState } from '../src/store/slices/queue';
import {
playerInitialState,
type PlayerState,
} from '../src/store/slices/player';
import {
upsertInstance,
setActiveInstanceId,
instanceStorage,
} from '../src/config/instances';
beforeEach(() => {
localStorage.clear();
const inst = upsertInstance('http://test.local');
setActiveInstanceId(inst.id);
});
const sampleQueue: QueueState = {
entries: [
{
trackId: 't1',
title: 'A',
artistName: 'X',
albumTitle: 'Alb',
durationMs: 1000,
},
{
trackId: 't2',
title: 'B',
artistName: 'Y',
albumTitle: 'Alb',
durationMs: 2000,
},
],
currentIndex: 1,
source: 'album',
sourceId: 'alb-1',
sourceName: 'My Album',
};
test('loaders fall back to initial state with nothing persisted', () => {
expect(loadQueueState()).toEqual(queueInitialState);
expect(loadPlayerState()).toEqual(playerInitialState);
});
test('loadQueueState restores a persisted queue', () => {
instanceStorage.set('queue', JSON.stringify(sampleQueue));
expect(loadQueueState()).toEqual(sampleQueue);
});
test('loadQueueState guards a currentIndex past the entries array', () => {
instanceStorage.set(
'queue',
JSON.stringify({ ...sampleQueue, currentIndex: 99 }),
);
expect(loadQueueState().currentIndex).toBe(0);
});
test('loadPlayerState restores fields but never auto-resumes playback', () => {
instanceStorage.set(
'player',
JSON.stringify({
currentTrackId: 't2',
position: 42,
volume: 0.5,
muted: true,
repeat: 'all',
shuffle: true,
// a stale isPlaying:true must not survive a reload
isPlaying: true,
}),
);
const loaded = loadPlayerState();
expect(loaded.currentTrackId).toBe('t2');
expect(loaded.position).toBe(42);
expect(loaded.volume).toBe(0.5);
expect(loaded.repeat).toBe('all');
expect(loaded.isPlaying).toBe(false);
});
test('corrupt JSON falls back to initial state', () => {
instanceStorage.set('queue', '{not json');
expect(loadQueueState()).toEqual(queueInitialState);
});
test('startPersistence flushes changed state to storage after throttle', () => {
rstest.useFakeTimers();
let state = {
queue: queueInitialState,
player: playerInitialState,
} as { queue: QueueState; player: PlayerState };
let listener: (() => void) | null = null;
const store = {
getState: () => state as never,
subscribe: (l: () => void) => {
listener = l;
return () => {};
},
};
startPersistence(store);
// mutate + notify
state = { ...state, queue: sampleQueue };
listener!();
// nothing written before the throttle window elapses
expect(instanceStorage.get('queue')).toBeNull();
rstest.advanceTimersByTime(1000);
expect(JSON.parse(instanceStorage.get('queue')!).currentIndex).toBe(1);
rstest.useRealTimers();
});
+82
View File
@@ -0,0 +1,82 @@
// @rstest-environment jsdom
import { expect, test, beforeEach, rstest } from '@rstest/core';
import {
rehydrateApiCache,
startApiPersistence,
} from '../src/store/rtkqPersist';
import { REHYDRATE_API } from '../src/api/rehydrate';
import {
upsertInstance,
setActiveInstanceId,
instanceStorage,
} from '../src/config/instances';
import type { RootState } from '../src/store/index';
beforeEach(() => {
localStorage.clear();
const inst = upsertInstance('http://test.local');
setActiveInstanceId(inst.id);
});
function apiStateWith(queries: Record<string, unknown>) {
return {
api: { queries, mutations: {}, provided: {}, subscriptions: {}, config: {} },
} as unknown as RootState;
}
test('rehydrateApiCache dispatches nothing when no cache is stored', () => {
const dispatched: unknown[] = [];
rehydrateApiCache((a) => dispatched.push(a));
expect(dispatched).toHaveLength(0);
});
test('rehydrateApiCache replays a stored cache as a rehydrate action', () => {
instanceStorage.set(
'rtkq',
JSON.stringify({
queries: { 'getLibrary(undefined)': { status: 'fulfilled', data: [1] } },
mutations: {},
}),
);
const dispatched: Array<{ type: string; payload: unknown }> = [];
rehydrateApiCache((a) =>
dispatched.push(a as { type: string; payload: unknown }),
);
expect(dispatched).toHaveLength(1);
expect(dispatched[0].type).toBe(REHYDRATE_API);
expect(dispatched[0].payload).toMatchObject({
queries: { 'getLibrary(undefined)': { status: 'fulfilled' } },
});
});
test('startApiPersistence saves only fulfilled queries after throttle', () => {
rstest.useFakeTimers();
let state = apiStateWith({});
let listener: (() => void) | null = null;
const store = {
getState: () => state,
subscribe: (l: () => void) => {
listener = l;
return () => {};
},
};
startApiPersistence(store);
state = apiStateWith({
'getAlbums(undefined)': { status: 'fulfilled', data: ['a'] },
'getArtists(undefined)': { status: 'pending' },
'getTracks(undefined)': { status: 'rejected', error: 'boom' },
});
listener!();
// throttled — nothing yet
expect(instanceStorage.get('rtkq')).toBeNull();
rstest.advanceTimersByTime(2000);
const saved = JSON.parse(instanceStorage.get('rtkq')!);
expect(Object.keys(saved.queries)).toEqual(['getAlbums(undefined)']);
expect(saved.mutations).toEqual({});
rstest.useRealTimers();
});
+79
View File
@@ -0,0 +1,79 @@
import { expect, test } from '@rstest/core';
import {
trackIdFromUrl,
cacheKeyFor,
parseRangeHeader,
selectEvictions,
} from '../public/sw-core.js';
test('trackIdFromUrl extracts the content id from a stream URL', () => {
expect(
trackIdFromUrl('https://host/api/v1/stream/abc123?token=xyz'),
).toBe('abc123');
expect(trackIdFromUrl('https://host/api/v1/library/albums')).toBeNull();
});
test('cacheKeyFor strips the token so the key is token-stable', () => {
const a = cacheKeyFor('https://host/api/v1/stream/t1?token=AAA');
const b = cacheKeyFor('https://host/api/v1/stream/t1?token=BBB');
expect(a).toBe(b);
expect(a).toBe('https://host/api/v1/stream/t1');
});
test('cacheKeyFor keeps different origins distinct', () => {
expect(cacheKeyFor('https://a/stream/t1?token=x')).not.toBe(
cacheKeyFor('https://b/stream/t1?token=x'),
);
});
test('parseRangeHeader: closed range', () => {
expect(parseRangeHeader('bytes=0-99', 1000)).toEqual({ start: 0, end: 99 });
});
test('parseRangeHeader: open-ended range clamps to size', () => {
expect(parseRangeHeader('bytes=500-', 1000)).toEqual({
start: 500,
end: 999,
});
});
test('parseRangeHeader: suffix range (last N bytes)', () => {
expect(parseRangeHeader('bytes=-200', 1000)).toEqual({
start: 800,
end: 999,
});
});
test('parseRangeHeader: end past size is clamped', () => {
expect(parseRangeHeader('bytes=900-5000', 1000)).toEqual({
start: 900,
end: 999,
});
});
test('parseRangeHeader: invalid / no range returns null', () => {
expect(parseRangeHeader('', 1000)).toBeNull();
expect(parseRangeHeader('items=0-1', 1000)).toBeNull();
expect(parseRangeHeader('bytes=500-100', 1000)).toBeNull();
});
test('selectEvictions: nothing evicted when under cap', () => {
const index = {
a: { size: 100, lastAccess: 1 },
b: { size: 100, lastAccess: 2 },
};
expect(selectEvictions(index, 100, 1000)).toEqual([]);
});
test('selectEvictions: evicts least-recently-used first until it fits', () => {
const index = {
a: { size: 400, lastAccess: 10 }, // oldest
b: { size: 400, lastAccess: 30 },
c: { size: 400, lastAccess: 20 },
};
// total 1200 + incoming 400 = 1600, cap 1000 → must free >=600.
// LRU order: a (10), c (20). Evict a (1200→800... wait incl incoming)
const evicted = selectEvictions(index, 400, 1000);
// total with incoming = 1600; evict a → 1200; evict c → 800 <= 1000.
expect(evicted).toEqual(['a', 'c']);
});