feat: web api client additions for wave 1
This commit is contained in:
@@ -1,10 +1,16 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AlbumTracks,
|
||||||
|
BackupFile,
|
||||||
Candidate,
|
Candidate,
|
||||||
CollectionResponse,
|
CollectionResponse,
|
||||||
DigitalAlbum,
|
DigitalAlbum,
|
||||||
Item,
|
Item,
|
||||||
|
ItemDetail,
|
||||||
|
Loan,
|
||||||
|
LoansResponse,
|
||||||
ReleasePreview,
|
ReleasePreview,
|
||||||
SettingsView,
|
SettingsView,
|
||||||
|
Stats,
|
||||||
SyncState,
|
SyncState,
|
||||||
User,
|
User,
|
||||||
} from './types'
|
} from './types'
|
||||||
@@ -73,17 +79,18 @@ export const api = {
|
|||||||
),
|
),
|
||||||
getReleasePreview: (id: number) => request<ReleasePreview>(`/api/lookup/release/${id}`),
|
getReleasePreview: (id: number) => request<ReleasePreview>(`/api/lookup/release/${id}`),
|
||||||
|
|
||||||
listCollection: (params: { format?: string; ripped?: string; q?: string } = {}) => {
|
listCollection: (params: { format?: string; ripped?: string; q?: string; onLoan?: string } = {}) => {
|
||||||
const usp = new URLSearchParams()
|
const usp = new URLSearchParams()
|
||||||
if (params.format) usp.set('format', params.format)
|
if (params.format) usp.set('format', params.format)
|
||||||
if (params.ripped) usp.set('ripped', params.ripped)
|
if (params.ripped) usp.set('ripped', params.ripped)
|
||||||
if (params.q) usp.set('q', params.q)
|
if (params.q) usp.set('q', params.q)
|
||||||
|
if (params.onLoan) usp.set('onLoan', params.onLoan)
|
||||||
const qs = usp.toString()
|
const qs = usp.toString()
|
||||||
return request<CollectionResponse>(`/api/collection${qs ? `?${qs}` : ''}`)
|
return request<CollectionResponse>(`/api/collection${qs ? `?${qs}` : ''}`)
|
||||||
},
|
},
|
||||||
addToCollection: (body: { releaseId: number; barcode?: string; matchAlbumId?: number }) =>
|
addToCollection: (body: { releaseId: number; barcode?: string; matchAlbumId?: number }) =>
|
||||||
post<Item>('/api/collection', body),
|
post<Item>('/api/collection', body),
|
||||||
getItem: (id: number) => request<Item>(`/api/collection/${id}`),
|
getItem: (id: number) => request<ItemDetail>(`/api/collection/${id}`),
|
||||||
deleteItem: (id: number) => request<{ ok: boolean }>(`/api/collection/${id}`, { method: 'DELETE' }),
|
deleteItem: (id: number) => request<{ ok: boolean }>(`/api/collection/${id}`, { method: 'DELETE' }),
|
||||||
setRip: (id: number, ripped: boolean | null) =>
|
setRip: (id: number, ripped: boolean | null) =>
|
||||||
request<Item>(`/api/collection/${id}/rip`, {
|
request<Item>(`/api/collection/${id}/rip`, {
|
||||||
@@ -97,4 +104,18 @@ export const api = {
|
|||||||
syncStatus: () => request<SyncState>('/api/library/sync'),
|
syncStatus: () => request<SyncState>('/api/library/sync'),
|
||||||
startSync: () => request<SyncState>('/api/library/sync', { method: 'POST' }),
|
startSync: () => request<SyncState>('/api/library/sync', { method: 'POST' }),
|
||||||
searchAlbums: (q: string) => request<{ albums: DigitalAlbum[] }>(`/api/library/albums?q=${encodeURIComponent(q)}`),
|
searchAlbums: (q: string) => request<{ albums: DigitalAlbum[] }>(`/api/library/albums?q=${encodeURIComponent(q)}`),
|
||||||
|
|
||||||
|
getAlbumTracks: (subsonicId: string) => request<AlbumTracks>(`/api/album/${encodeURIComponent(subsonicId)}/tracks`),
|
||||||
|
markPlayed: (subsonicId: string) => post<{ ok: boolean }>(`/api/album/${encodeURIComponent(subsonicId)}/played`),
|
||||||
|
streamUrl: (songId: string) => `/api/stream/${encodeURIComponent(songId)}`,
|
||||||
|
|
||||||
|
getStats: () => request<Stats>('/api/stats'),
|
||||||
|
exportUrl: () => '/api/export',
|
||||||
|
|
||||||
|
lendItem: (id: number, borrower: string) => post<Loan>(`/api/collection/${id}/loan`, { borrower }),
|
||||||
|
getLoans: () => request<LoansResponse>('/api/loans'),
|
||||||
|
returnLoan: (id: number) => post<{ ok: boolean }>(`/api/loans/${id}/return`),
|
||||||
|
|
||||||
|
triggerBackup: () => post<{ file: string }>('/api/backup'),
|
||||||
|
getBackups: () => request<{ backups: BackupFile[] }>('/api/backups'),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,3 +68,49 @@ export interface DigitalAlbum {
|
|||||||
title: string
|
title: string
|
||||||
artist: string
|
artist: string
|
||||||
}
|
}
|
||||||
|
export interface Track {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
duration: number | null
|
||||||
|
track: number | null
|
||||||
|
}
|
||||||
|
export interface AlbumTracks {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
tracks: Track[]
|
||||||
|
}
|
||||||
|
export interface MatchedAlbum {
|
||||||
|
id: number
|
||||||
|
subsonicId: string
|
||||||
|
lastPlayedAt: string | null
|
||||||
|
}
|
||||||
|
export interface Stats {
|
||||||
|
totals: { items: number; ripped: number; notRipped: number; onLoan: number }
|
||||||
|
ripRatio: number
|
||||||
|
formats: { name: string; count: number }[]
|
||||||
|
decades: { name: string; count: number }[]
|
||||||
|
topGenres: { name: string; count: number }[]
|
||||||
|
topArtists: { name: string; count: number }[]
|
||||||
|
addedByMonth: { month: string; count: number }[]
|
||||||
|
}
|
||||||
|
export interface Loan {
|
||||||
|
id: number
|
||||||
|
itemId: number
|
||||||
|
borrower: string
|
||||||
|
lentAt: string
|
||||||
|
returnedAt: string | null
|
||||||
|
}
|
||||||
|
export interface LoansResponse {
|
||||||
|
active: Loan[]
|
||||||
|
history: Loan[]
|
||||||
|
}
|
||||||
|
export interface BackupFile {
|
||||||
|
file: string
|
||||||
|
sizeBytes: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
export interface ItemDetail extends Item {
|
||||||
|
matchedAlbum: MatchedAlbum | null
|
||||||
|
loan: { id: number; borrower: string; lentAt: string } | null
|
||||||
|
}
|
||||||
|
|||||||
43
web/test/api.test.ts
Normal file
43
web/test/api.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { api, ApiError } from '../src/api'
|
||||||
|
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
beforeEach(() => vi.stubGlobal('fetch', fetchMock))
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
function jsonOk(body: unknown) {
|
||||||
|
return Promise.resolve(new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('api additions', () => {
|
||||||
|
it('album tracks + played + stream url', async () => {
|
||||||
|
fetchMock.mockImplementation(() => jsonOk({ id: 'a1', title: 'Motion', artist: 'TCO', tracks: [] }))
|
||||||
|
await api.getAlbumTracks('a1')
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/album/a1/tracks', expect.anything())
|
||||||
|
await api.markPlayed('a1')
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/album/a1/played', expect.objectContaining({ method: 'POST' }))
|
||||||
|
expect(api.streamUrl('s 1')).toBe('/api/stream/s%201')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stats, loans, backups urls', async () => {
|
||||||
|
fetchMock.mockImplementation(() => jsonOk({}))
|
||||||
|
await api.getStats()
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/stats', expect.anything())
|
||||||
|
await api.lendItem(5, 'Bob')
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/collection/5/loan', expect.objectContaining({ method: 'POST' }))
|
||||||
|
await api.returnLoan(7)
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/loans/7/return', expect.objectContaining({ method: 'POST' }))
|
||||||
|
await api.getBackups()
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/backups', expect.anything())
|
||||||
|
expect(api.exportUrl()).toBe('/api/export')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces subsonic errors as ApiError', async () => {
|
||||||
|
fetchMock.mockReturnValue(
|
||||||
|
Promise.resolve(new Response(JSON.stringify({ error: 'no_subsonic_config' }), { status: 409 }))
|
||||||
|
)
|
||||||
|
const err = await api.getAlbumTracks('a1').catch((e) => e)
|
||||||
|
expect(err).toBeInstanceOf(ApiError)
|
||||||
|
expect((err as ApiError).code).toBe('no_subsonic_config')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user