diff --git a/web/src/api.ts b/web/src/api.ts index d8d71e2..35663dc 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -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(`/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(`/api/collection${qs ? `?${qs}` : ''}`) }, addToCollection: (body: { releaseId: number; barcode?: string; matchAlbumId?: number }) => post('/api/collection', body), - getItem: (id: number) => request(`/api/collection/${id}`), + getItem: (id: number) => request(`/api/collection/${id}`), deleteItem: (id: number) => request<{ ok: boolean }>(`/api/collection/${id}`, { method: 'DELETE' }), setRip: (id: number, ripped: boolean | null) => request(`/api/collection/${id}/rip`, { @@ -97,4 +104,18 @@ export const api = { syncStatus: () => request('/api/library/sync'), startSync: () => request('/api/library/sync', { method: 'POST' }), searchAlbums: (q: string) => request<{ albums: DigitalAlbum[] }>(`/api/library/albums?q=${encodeURIComponent(q)}`), + + getAlbumTracks: (subsonicId: string) => request(`/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('/api/stats'), + exportUrl: () => '/api/export', + + lendItem: (id: number, borrower: string) => post(`/api/collection/${id}/loan`, { borrower }), + getLoans: () => request('/api/loans'), + returnLoan: (id: number) => post<{ ok: boolean }>(`/api/loans/${id}/return`), + + triggerBackup: () => post<{ file: string }>('/api/backup'), + getBackups: () => request<{ backups: BackupFile[] }>('/api/backups'), } diff --git a/web/src/types.ts b/web/src/types.ts index 5492bfb..cd90dba 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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 +} diff --git a/web/test/api.test.ts b/web/test/api.test.ts new file mode 100644 index 0000000..50caa56 --- /dev/null +++ b/web/test/api.test.ts @@ -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') + }) +})