1
0

feat: web api client additions for wave 1

This commit is contained in:
2026-09-03 22:06:57 +02:00
parent 3ce55ba09e
commit ee19bc773c
3 changed files with 112 additions and 2 deletions

View File

@@ -1,10 +1,16 @@
import type {
AlbumTracks,
BackupFile,
Candidate,
CollectionResponse,
DigitalAlbum,
Item,
ItemDetail,
Loan,
LoansResponse,
ReleasePreview,
SettingsView,
Stats,
SyncState,
User,
} from './types'
@@ -73,17 +79,18 @@ export const api = {
),
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()
if (params.format) usp.set('format', params.format)
if (params.ripped) usp.set('ripped', params.ripped)
if (params.q) usp.set('q', params.q)
if (params.onLoan) usp.set('onLoan', params.onLoan)
const qs = usp.toString()
return request<CollectionResponse>(`/api/collection${qs ? `?${qs}` : ''}`)
},
addToCollection: (body: { releaseId: number; barcode?: string; matchAlbumId?: number }) =>
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' }),
setRip: (id: number, ripped: boolean | null) =>
request<Item>(`/api/collection/${id}/rip`, {
@@ -97,4 +104,18 @@ export const api = {
syncStatus: () => request<SyncState>('/api/library/sync'),
startSync: () => request<SyncState>('/api/library/sync', { method: 'POST' }),
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'),
}

View File

@@ -68,3 +68,49 @@ export interface DigitalAlbum {
title: 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
View 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')
})
})