diff --git a/public/sw-core.js b/public/sw-core.js index 46d0613..47177a2 100644 --- a/public/sw-core.js +++ b/public/sw-core.js @@ -19,8 +19,8 @@ export const MAX_BYTES = 500 * 1024 * 1024; // 500 MB export const COVER_CACHE = 'mcma-covers-v1'; export const MAX_COVERS = 600; -// Backend stream route: /api/v1/streaming/tracks/?token=... -const STREAM_RE = /\/streaming\/tracks\/([^/?#]+)/; +// Backend stream route: /api/v1/stream/?token=... +const STREAM_RE = /\/stream\/([^/?#]+)/; /** The track (content) id from a stream URL, or null if it isn't one. */ export function trackIdFromUrl(url) { diff --git a/public/sw.js b/public/sw.js index f514c38..4083c66 100644 --- a/public/sw.js +++ b/public/sw.js @@ -2,7 +2,7 @@ * MCMA service worker — Tier 3 offline support: audio blob cache. * * It sits between the app and the network for audio-stream requests only - * (`/streaming/tracks/`). The first time a track is streamed it's copied + * (`/stream/`). 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. diff --git a/src/api/baseQuery.ts b/src/api/baseQuery.ts index 1500df7..24084f3 100644 --- a/src/api/baseQuery.ts +++ b/src/api/baseQuery.ts @@ -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 { diff --git a/src/api/endpoints/admin.ts b/src/api/endpoints/admin.ts index 2091077..6f148d7 100644 --- a/src/api/endpoints/admin.ts +++ b/src/api/endpoints/admin.ts @@ -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({ 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({ diff --git a/src/api/endpoints/auth.ts b/src/api/endpoints/auth.ts index 5670299..792ac90 100644 --- a/src/api/endpoints/auth.ts +++ b/src/api/endpoints/auth.ts @@ -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({ - 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({ - query: () => ({ url: '/auth/logout', method: 'POST' }), + logout: build.mutation({ + 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({ + query: ({ refreshToken }) => ({ + url: '/auth/refresh', + method: 'POST', + body: { refresh_token: refreshToken }, + }), + transformResponse: (raw: RawTokenResponse) => toTokens(raw), }), - me: build.query({ + me: build.query({ 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; diff --git a/src/api/endpoints/downloads.ts b/src/api/endpoints/downloads.ts index a2e29fd..52ac13e 100644 --- a/src/api/endpoints/downloads.ts +++ b/src/api/endpoints/downloads.ts @@ -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< diff --git a/src/api/endpoints/library.ts b/src/api/endpoints/library.ts index e403a74..66e807a 100644 --- a/src/api/endpoints/library.ts +++ b/src/api/endpoints/library.ts @@ -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, 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, LibraryFilters | void>({ - query: (filters) => ({ url: '/library/tracks', params: filters ?? {} }), + query: (filters) => ({ + url: '/tracks', + params: trackParams(filters ?? {}), + }), + transformResponse: (raw: RawPaged) => toPage(raw, toTrack), providesTags: (result) => result ? [ @@ -20,7 +61,8 @@ export const libraryApi = api.injectEndpoints({ : ['Track'], }), getTrack: build.query({ - 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) => toPage(raw, toAlbum), providesTags: (result) => result ? [ @@ -42,11 +92,13 @@ export const libraryApi = api.injectEndpoints({ : ['Album'], }), getAlbum: build.query({ - query: (id) => `/library/albums/${id}`, + query: (id) => `/albums/${id}`, + transformResponse: (raw: RawAlbum) => toAlbum(raw), providesTags: (_r, _e, id) => [{ type: 'Album', id }], }), getAlbumTracks: build.query({ - query: (albumId) => `/library/albums/${albumId}/tracks`, + query: (albumId) => `/albums/${albumId}/tracks`, + transformResponse: (raw: RawPaged) => raw.items.map(toTrack), providesTags: (_r, _e, albumId) => [ { type: 'Album', id: albumId }, 'Track', @@ -56,7 +108,11 @@ export const libraryApi = api.injectEndpoints({ PaginatedResponse, { 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) => toPage(raw, toArtist), providesTags: (result) => result ? [ @@ -69,21 +125,40 @@ export const libraryApi = api.injectEndpoints({ : ['Artist'], }), getArtist: build.query({ - query: (id) => `/library/artists/${id}`, + query: (id) => `/artists/${id}`, + transformResponse: (raw: RawArtist) => toArtist(raw), providesTags: (_r, _e, id) => [{ type: 'Artist', id }], }), getArtistAlbums: build.query({ - query: (artistId) => `/library/artists/${artistId}/albums`, + query: (artistId) => `/artists/${artistId}/albums`, + transformResponse: (raw: RawPaged) => raw.items.map(toAlbum), providesTags: (_r, _e, artistId) => [ { type: 'Artist', id: artistId }, 'Album', ], }), + getArtistTracks: build.query({ + query: (artistId) => `/artists/${artistId}/tracks`, + transformResponse: (raw: RawPaged) => 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; diff --git a/src/api/endpoints/likes.ts b/src/api/endpoints/likes.ts index ba03eda..1c218a3 100644 --- a/src/api/endpoints/likes.ts +++ b/src/api/endpoints/likes.ts @@ -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({ - query: (trackId) => ({ url: `/likes/tracks/${trackId}`, method: 'PUT' }), - invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }], - }), - unlikeTrack: build.mutation({ - query: (trackId) => ({ - url: `/likes/tracks/${trackId}`, - method: 'DELETE', + setLike: build.mutation({ + 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({ + 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 }; diff --git a/src/api/endpoints/playlists.ts b/src/api/endpoints/playlists.ts index 878a3b2..c1007af 100644 --- a/src/api/endpoints/playlists.ts +++ b/src/api/endpoints/playlists.ts @@ -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, void>({ query: () => '/playlists', + transformResponse: (raw: RawPaged) => + toPage(raw, toPlaylist), providesTags: ['Playlist'], }), getPlaylist: build.query({ query: (id) => `/playlists/${id}`, + transformResponse: (raw: RawPlaylist) => toPlaylist(raw), providesTags: (_r, _e, id) => [{ type: 'Playlist', id }], }), getPlaylistTracks: build.query({ 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) => + 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({ @@ -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 }) => [ diff --git a/src/api/endpoints/storage.ts b/src/api/endpoints/storage.ts index a4eb39e..5ae1bee 100644 --- a/src/api/endpoints/storage.ts +++ b/src/api/endpoints/storage.ts @@ -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({ diff --git a/src/api/endpoints/streaming.ts b/src/api/endpoints/streaming.ts index 2c5c1dd..38e0a64 100644 --- a/src/api/endpoints/streaming.ts +++ b/src/api/endpoints/streaming.ts @@ -1,8 +1,13 @@ import { getApiBaseUrl } from '../../config/runtime-config'; +/** + * Audio stream URL for the `