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>
This commit is contained in:
Senko-san
2026-06-08 17:12:44 +03:00
parent bcfb36d53e
commit dacb8b9278
18 changed files with 519 additions and 99 deletions
+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 {