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 { 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'),
} }

View File

@@ -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
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')
})
})