Compare commits

...

2 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
25 changed files with 597 additions and 102 deletions
+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"
+8 -2
View File
@@ -13,8 +13,9 @@ RUN npm ci
COPY . . COPY . .
# Bake the API base URL at build time (rsbuild inlines PUBLIC_* vars). # Build-time default for the API base URL (rsbuild inlines PUBLIC_* vars). This
# Same-origin default ('/api/v1') works behind any reverse proxy. # 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 ARG PUBLIC_API_BASE_URL=/api/v1
ENV PUBLIC_API_BASE_URL=$PUBLIC_API_BASE_URL ENV PUBLIC_API_BASE_URL=$PUBLIC_API_BASE_URL
RUN npm run build 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 dockerfiles/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html 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 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+7
View File
@@ -14,6 +14,13 @@ server {
try_files $uri =404; 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). # SPA: every unknown path falls back to index.html (client-side router).
location / { location / {
try_files $uri $uri/ /index.html; 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__ = {};
+2 -2
View File
@@ -19,8 +19,8 @@ export const MAX_BYTES = 500 * 1024 * 1024; // 500 MB
export const COVER_CACHE = 'mcma-covers-v1'; export const COVER_CACHE = 'mcma-covers-v1';
export const MAX_COVERS = 600; export const MAX_COVERS = 600;
// Backend stream route: /api/v1/streaming/tracks/<trackId>?token=... // Backend stream route: /api/v1/stream/<trackId>?token=...
const STREAM_RE = /\/streaming\/tracks\/([^/?#]+)/; const STREAM_RE = /\/stream\/([^/?#]+)/;
/** The track (content) id from a stream URL, or null if it isn't one. */ /** The track (content) id from a stream URL, or null if it isn't one. */
export function trackIdFromUrl(url) { export function trackIdFromUrl(url) {
+1 -1
View File
@@ -2,7 +2,7 @@
* MCMA service worker — Tier 3 offline support: audio blob cache. * MCMA service worker — Tier 3 offline support: audio blob cache.
* *
* It sits between the app and the network for audio-stream requests only * It sits between the app and the network for audio-stream requests only
* (`/streaming/tracks/<id>`). The first time a track is streamed it's copied * (`/stream/<id>`). The first time a track is streamed it's copied
* into the Cache API (keyed by content id, token stripped); afterwards — or * into the Cache API (keyed by content id, token stripped); afterwards — or
* whenever the backend is unreachable — playback is served straight from the * whenever the backend is unreachable — playback is served straight from the
* cache, so already-heard tracks play with no network at all. * cache, so already-heard tracks play with no network at all.
+10
View File
@@ -36,6 +36,16 @@ export default defineConfig({
// "Install app". The service worker (audio offline cache) is registered // "Install app". The service worker (audio offline cache) is registered
// from src/index.tsx, not here. // from src/index.tsx, not here.
tags: [ 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', tag: 'link',
attrs: { rel: 'manifest', href: '/manifest.webmanifest' }, attrs: { rel: 'manifest', href: '/manifest.webmanifest' },
+9 -10
View File
@@ -34,24 +34,23 @@ export const baseQueryWithReauth: BaseQueryFn<
{ {
url: '/auth/refresh', url: '/auth/refresh',
method: 'POST', method: 'POST',
body: { refreshToken }, body: { refresh_token: refreshToken },
}, },
api, api,
extraOptions, extraOptions,
); );
if (refreshResult.data) { if (refreshResult.data) {
const { // Backend wire format is snake_case with no TTL (see auth.ts adapter).
accessToken, const { access_token, refresh_token } = refreshResult.data as {
refreshToken: newRefresh, access_token: string;
expiresIn, refresh_token: string;
} = refreshResult.data as {
accessToken: string;
refreshToken: string;
expiresIn: number;
}; };
api.dispatch( api.dispatch(
setTokens({ accessToken, refreshToken: newRefresh, expiresIn }), setTokens({
accessToken: access_token,
refreshToken: refresh_token,
}),
); );
result = await rawBaseQuery()(args, api, extraOptions); result = await rawBaseQuery()(args, api, extraOptions);
} else { } else {
+21 -10
View File
@@ -1,33 +1,44 @@
import { api } from '../index'; import { api } from '../index';
import { toUser, type RawUser } from '../mappers';
import type { User } from '../types'; 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({ export const adminApi = api.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
getUsers: build.query<User[], void>({ getUsers: build.query<User[], void>({
query: () => '/admin/users', query: () => '/admin/users',
transformResponse: (raw: RawUser[]) => raw.map(toUser),
providesTags: ['User'], providesTags: ['User'],
}), }),
createUser: build.mutation< createUser: build.mutation<
User, User,
{ { username: string; password: string; role: 'admin' | 'user' }
username: string;
password: string;
email?: 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'], invalidatesTags: ['User'],
}), }),
updateUser: build.mutation< updateUser: build.mutation<
User, 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}`, url: `/admin/users/${id}`,
method: 'PATCH', method: 'PATCH',
body, body: {
is_superuser: role === undefined ? undefined : role === 'admin',
is_active: isActive,
},
}), }),
transformResponse: (raw: RawUser) => toUser(raw),
invalidatesTags: ['User'], invalidatesTags: ['User'],
}), }),
deleteUser: build.mutation<void, string>({ deleteUser: build.mutation<void, string>({
+65 -11
View File
@@ -1,26 +1,80 @@
import { api } from '../index'; 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({ export const authApi = api.injectEndpoints({
endpoints: (build) => ({ 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>({ 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>({ logout: build.mutation<void, { refreshToken: string }>({
query: () => ({ url: '/auth/logout', method: 'POST' }), query: ({ refreshToken }) => ({
url: '/auth/logout',
method: 'POST',
body: { refresh_token: refreshToken },
}),
}), }),
refreshToken: build.mutation< refreshToken: build.mutation<AuthTokens, { refreshToken: string }>({
{ accessToken: string; refreshToken: string; expiresIn: number }, query: ({ refreshToken }) => ({
{ refreshToken: string } url: '/auth/refresh',
>({ method: 'POST',
query: (body) => ({ url: '/auth/refresh', method: 'POST', body }), body: { refresh_token: refreshToken },
}),
transformResponse: (raw: RawTokenResponse) => toTokens(raw),
}), }),
me: build.query<import('../types').User, void>({ me: build.query<User, void>({
query: () => '/auth/me', query: () => '/auth/me',
transformResponse: (raw: RawUser) => toUser(raw),
providesTags: ['User'], providesTags: ['User'],
}), }),
}), }),
overrideExisting: false, 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 { api } from '../index';
import type { DownloadJob } from '../types'; 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({ export const downloadsApi = api.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
getDownloads: build.query< getDownloads: build.query<
+85 -9
View File
@@ -1,4 +1,14 @@
import { api } from '../index'; import { api } from '../index';
import {
toAlbum,
toArtist,
toPage,
toTrack,
type RawAlbum,
type RawArtist,
type RawPaged,
type RawTrack,
} from '../mappers';
import type { import type {
Track, Track,
Album, Album,
@@ -7,10 +17,41 @@ import type {
LibraryFilters, LibraryFilters,
} from '../types'; } 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({ export const libraryApi = api.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
getTracks: build.query<PaginatedResponse<Track>, LibraryFilters | void>({ 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) => providesTags: (result) =>
result result
? [ ? [
@@ -20,7 +61,8 @@ export const libraryApi = api.injectEndpoints({
: ['Track'], : ['Track'],
}), }),
getTrack: build.query<Track, string>({ 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 }], providesTags: (_r, _e, id) => [{ type: 'Track', id }],
}), }),
getAlbums: build.query< getAlbums: build.query<
@@ -32,7 +74,15 @@ export const libraryApi = api.injectEndpoints({
pageSize?: number; pageSize?: number;
} | void } | 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) => providesTags: (result) =>
result result
? [ ? [
@@ -42,11 +92,13 @@ export const libraryApi = api.injectEndpoints({
: ['Album'], : ['Album'],
}), }),
getAlbum: build.query<Album, string>({ 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 }], providesTags: (_r, _e, id) => [{ type: 'Album', id }],
}), }),
getAlbumTracks: build.query<Track[], string>({ 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) => [ providesTags: (_r, _e, albumId) => [
{ type: 'Album', id: albumId }, { type: 'Album', id: albumId },
'Track', 'Track',
@@ -56,7 +108,11 @@ export const libraryApi = api.injectEndpoints({
PaginatedResponse<Artist>, PaginatedResponse<Artist>,
{ search?: string; page?: number; pageSize?: number } | void { 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) => providesTags: (result) =>
result result
? [ ? [
@@ -69,21 +125,40 @@ export const libraryApi = api.injectEndpoints({
: ['Artist'], : ['Artist'],
}), }),
getArtist: build.query<Artist, string>({ 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 }], providesTags: (_r, _e, id) => [{ type: 'Artist', id }],
}), }),
getArtistAlbums: build.query<Album[], string>({ 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) => [ providesTags: (_r, _e, artistId) => [
{ type: 'Artist', id: artistId }, { type: 'Artist', id: artistId },
'Album', '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< searchLibrary: build.query<
{ tracks: Track[]; albums: Album[]; artists: Artist[] }, { tracks: Track[]; albums: Album[]; artists: Artist[] },
string 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'], providesTags: ['Track', 'Album', 'Artist'],
}), }),
}), }),
@@ -99,5 +174,6 @@ export const {
useGetArtistsQuery, useGetArtistsQuery,
useGetArtistQuery, useGetArtistQuery,
useGetArtistAlbumsQuery, useGetArtistAlbumsQuery,
useGetArtistTracksQuery,
useSearchLibraryQuery, useSearchLibraryQuery,
} = libraryApi; } = libraryApi;
+58 -10
View File
@@ -1,20 +1,68 @@
import { api } from '../index'; 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({ export const likesApi = api.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
likeTrack: build.mutation<void, string>({ setLike: build.mutation<LikeState, { trackId: string; value: LikeValue }>({
query: (trackId) => ({ url: `/likes/tracks/${trackId}`, method: 'PUT' }), query: ({ trackId, value }) => ({
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }], url: '/likes',
}), method: 'POST',
unlikeTrack: build.mutation<void, string>({ body: { track_id: trackId, value },
query: (trackId) => ({
url: `/likes/tracks/${trackId}`,
method: 'DELETE',
}), }),
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, 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 { api } from '../index';
import {
toPage,
toPlaylist,
toTrack,
type RawPaged,
type RawPlaylist,
type RawTrack,
} from '../mappers';
import type { Playlist, PlaylistTrack, PaginatedResponse } from '../types'; import type { Playlist, PlaylistTrack, PaginatedResponse } from '../types';
export const playlistsApi = api.injectEndpoints({ export const playlistsApi = api.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
getPlaylists: build.query<PaginatedResponse<Playlist>, void>({ getPlaylists: build.query<PaginatedResponse<Playlist>, void>({
query: () => '/playlists', query: () => '/playlists',
transformResponse: (raw: RawPaged<RawPlaylist>) =>
toPage(raw, toPlaylist),
providesTags: ['Playlist'], providesTags: ['Playlist'],
}), }),
getPlaylist: build.query<Playlist, string>({ getPlaylist: build.query<Playlist, string>({
query: (id) => `/playlists/${id}`, query: (id) => `/playlists/${id}`,
transformResponse: (raw: RawPlaylist) => toPlaylist(raw),
providesTags: (_r, _e, id) => [{ type: 'Playlist', id }], providesTags: (_r, _e, id) => [{ type: 'Playlist', id }],
}), }),
getPlaylistTracks: build.query<PlaylistTrack[], string>({ getPlaylistTracks: build.query<PlaylistTrack[], string>({
query: (id) => `/playlists/${id}/tracks`, 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'], providesTags: (_r, _e, id) => [{ type: 'Playlist', id }, 'Track'],
}), }),
createPlaylist: build.mutation< createPlaylist: build.mutation<
Playlist, 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'], invalidatesTags: ['Playlist'],
}), }),
updatePlaylist: build.mutation< updatePlaylist: build.mutation<
Playlist, 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}`, url: `/playlists/${id}`,
method: 'PATCH', method: 'PATCH',
body, body: { name, description },
}), }),
transformResponse: (raw: RawPlaylist) => toPlaylist(raw),
invalidatesTags: (_r, _e, { id }) => [{ type: 'Playlist', id }], invalidatesTags: (_r, _e, { id }) => [{ type: 'Playlist', id }],
}), }),
deletePlaylist: build.mutation<void, string>({ deletePlaylist: build.mutation<void, string>({
@@ -39,12 +64,12 @@ export const playlistsApi = api.injectEndpoints({
}), }),
addTrackToPlaylist: build.mutation< addTrackToPlaylist: build.mutation<
void, void,
{ playlistId: string; trackId: string } { playlistId: string; trackId: string; position?: number }
>({ >({
query: ({ playlistId, trackId }) => ({ query: ({ playlistId, trackId, position }) => ({
url: `/playlists/${playlistId}/tracks`, url: `/playlists/${playlistId}/tracks`,
method: 'POST', method: 'POST',
body: { trackId }, body: { track_id: trackId, position },
}), }),
invalidatesTags: (_r, _e, { playlistId }) => [ invalidatesTags: (_r, _e, { playlistId }) => [
{ type: 'Playlist', id: playlistId }, { type: 'Playlist', id: playlistId },
@@ -52,10 +77,10 @@ export const playlistsApi = api.injectEndpoints({
}), }),
removeTrackFromPlaylist: build.mutation< removeTrackFromPlaylist: build.mutation<
void, void,
{ playlistId: string; trackId: string; position: number } { playlistId: string; trackId: string }
>({ >({
query: ({ playlistId, position }) => ({ query: ({ playlistId, trackId }) => ({
url: `/playlists/${playlistId}/tracks/${position}`, url: `/playlists/${playlistId}/tracks/${trackId}`,
method: 'DELETE', method: 'DELETE',
}), }),
invalidatesTags: (_r, _e, { playlistId }) => [ invalidatesTags: (_r, _e, { playlistId }) => [
+7
View File
@@ -1,6 +1,13 @@
import { api } from '../index'; import { api } from '../index';
import type { StorageStats } from '../types'; 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({ export const storageApi = api.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
getStorageStats: build.query<StorageStats, void>({ getStorageStats: build.query<StorageStats, void>({
+6 -1
View File
@@ -1,8 +1,13 @@
import { getApiBaseUrl } from '../../config/runtime-config'; 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 { export function getStreamUrl(trackId: string, token: string): string {
const base = getApiBaseUrl(); 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 { export function getCoverUrl(artUrl: string | undefined): string | undefined {
+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,
};
};
+3 -1
View File
@@ -98,7 +98,9 @@ export interface User {
export interface AuthTokens { export interface AuthTokens {
accessToken: string; accessToken: string;
refreshToken: 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 { export interface LoginRequest {
+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 = 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 { interface ImportMeta {
readonly env: ImportMetaEnv; 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 { useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
import { Card, TextField, Button, Callout, Badge } from '@olly/modern-sk'; import { Card, TextField, Button, Callout, Badge } from '@olly/modern-sk';
import { Icon } from '../../components/common/Icon'; import { Icon } from '../../components/common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch'; import { useAppDispatch } from '../../hooks/useAppDispatch';
import { setTokens, setUser } from '../../store/slices/auth'; import { setTokens, setUser } from '../../store/slices/auth';
import { setApiBaseUrl } from '../../config/runtime-config'; import { setApiBaseUrl } from '../../config/runtime-config';
import { useLoginMutation } from '../../api/endpoints/auth';
import { import {
listInstances, listInstances,
getActiveInstanceId, getActiveInstanceId,
setActiveInstanceId, setActiveInstanceId,
removeInstance, removeInstance,
} from '../../config/instances'; } 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() { export function ConnectPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -26,6 +37,9 @@ export function ConnectPage() {
const [apiUrl, setApiUrl] = useState('https://'); const [apiUrl, setApiUrl] = useState('https://');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [login, { isLoading }] = useLoginMutation();
const switchTo = (id: string) => { const switchTo = (id: string) => {
setActiveInstanceId(id); setActiveInstanceId(id);
@@ -37,25 +51,22 @@ export function ConnectPage() {
setRev((r) => r + 1); setRev((r) => r + 1);
}; };
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); 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); setApiBaseUrl(apiUrl);
const fakeUser: User = { try {
id: 'dev-user', const { user, tokens } = await login({ username, password }).unwrap();
username: username || 'dev', dispatch(setTokens(tokens));
role: 'admin', dispatch(setUser(user));
createdAt: new Date().toISOString(), void navigate('/');
}; } catch (err) {
dispatch( setError(resolveLoginError(err));
setTokens({ }
accessToken: 'dev-token',
refreshToken: 'dev-refresh',
expiresIn: 3600,
}),
);
dispatch(setUser(fakeUser));
void navigate('/');
}; };
const labelStyle: React.CSSProperties = { const labelStyle: React.CSSProperties = {
@@ -227,15 +238,14 @@ export function ConnectPage() {
required required
/> />
</div> </div>
<Callout variant="warning"> {error && <Callout variant="danger">{t(error)}</Callout>}
{t('connect.form.stubNote')}
</Callout>
<Button <Button
type="submit" type="submit"
variant="primary" variant="primary"
disabled={isLoading}
style={{ marginTop: '0.5rem' }} style={{ marginTop: '0.5rem' }}
> >
{t('connect.form.submit')} {isLoading ? t('connect.form.submitting') : t('connect.form.submit')}
</Button> </Button>
</form> </form>
</Card> </Card>
+6 -2
View File
@@ -35,8 +35,12 @@ const en = {
username: 'Username', username: 'Username',
password: 'Password', password: 'Password',
submit: 'Connect', submit: 'Connect',
stubNote: submitting: 'Connecting…',
'Stub mode — backend not wired. Connect signs in with a fake admin session, scoped to this instance.', },
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: { library: {
+7 -2
View File
@@ -37,8 +37,13 @@ const ru: Translations = {
username: 'Имя пользователя', username: 'Имя пользователя',
password: 'Пароль', password: 'Пароль',
submit: 'Подключиться', submit: 'Подключиться',
stubNote: submitting: 'Подключение…',
'Режим заглушки — сервер не подключён. Создаётся фиктивная сессия администратора для этого экземпляра.', },
errors: {
unreachable:
'Не удаётся подключиться к серверу. Проверьте URL и доступность.',
badCredentials: 'Неверное имя пользователя или пароль.',
generic: 'Не удалось войти. Попробуйте ещё раз.',
}, },
}, },
library: { library: {
+6 -2
View File
@@ -38,12 +38,16 @@ export const authSlice = createSlice({
action: PayloadAction<{ action: PayloadAction<{
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
expiresIn: number; expiresIn?: number;
}>, }>,
) { ) {
state.accessToken = action.payload.accessToken; state.accessToken = action.payload.accessToken;
state.refreshToken = action.payload.refreshToken; 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); persistAuth(state);
}, },
setUser(state, action: PayloadAction<User>) { setUser(state, action: PayloadAction<User>) {
+6 -6
View File
@@ -8,21 +8,21 @@ import {
test('trackIdFromUrl extracts the content id from a stream URL', () => { test('trackIdFromUrl extracts the content id from a stream URL', () => {
expect( expect(
trackIdFromUrl('https://host/api/v1/streaming/tracks/abc123?token=xyz'), trackIdFromUrl('https://host/api/v1/stream/abc123?token=xyz'),
).toBe('abc123'); ).toBe('abc123');
expect(trackIdFromUrl('https://host/api/v1/library/albums')).toBeNull(); expect(trackIdFromUrl('https://host/api/v1/library/albums')).toBeNull();
}); });
test('cacheKeyFor strips the token so the key is token-stable', () => { test('cacheKeyFor strips the token so the key is token-stable', () => {
const a = cacheKeyFor('https://host/api/v1/streaming/tracks/t1?token=AAA'); const a = cacheKeyFor('https://host/api/v1/stream/t1?token=AAA');
const b = cacheKeyFor('https://host/api/v1/streaming/tracks/t1?token=BBB'); const b = cacheKeyFor('https://host/api/v1/stream/t1?token=BBB');
expect(a).toBe(b); expect(a).toBe(b);
expect(a).toBe('https://host/api/v1/streaming/tracks/t1'); expect(a).toBe('https://host/api/v1/stream/t1');
}); });
test('cacheKeyFor keeps different origins distinct', () => { test('cacheKeyFor keeps different origins distinct', () => {
expect(cacheKeyFor('https://a/streaming/tracks/t1?token=x')).not.toBe( expect(cacheKeyFor('https://a/stream/t1?token=x')).not.toBe(
cacheKeyFor('https://b/streaming/tracks/t1?token=x'), cacheKeyFor('https://b/stream/t1?token=x'),
); );
}); });