diff --git a/server/src/subsonic.ts b/server/src/subsonic.ts index 7b89500..06261b9 100644 --- a/server/src/subsonic.ts +++ b/server/src/subsonic.ts @@ -71,4 +71,25 @@ export class SubsonicClient { async ping(): Promise { await this.request('ping') } + + async getAllAlbums( + onProgress?: (albums: SubsonicAlbum[], done: number) => void + ): Promise { + const all: SubsonicAlbum[] = [] + const pageSize = 500 + for (let offset = 0; ; offset += pageSize) { + const envelope = await this.request('getAlbumList2', { + type: 'alphabeticalByName', + size: String(pageSize), + offset: String(offset), + }) + const list: any[] = envelope.albumList2?.album ?? [] + for (const a of list) { + all.push({ id: String(a.id), title: a.name ?? a.title ?? '', artist: a.artist ?? '' }) + } + onProgress?.(all, all.length) + if (list.length < pageSize || all.length > 20000) break + } + return all + } } diff --git a/server/test/subsonic.test.ts b/server/test/subsonic.test.ts new file mode 100644 index 0000000..6d5c324 --- /dev/null +++ b/server/test/subsonic.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest' +import { SubsonicClient, SubsonicError } from '../src/subsonic.js' + +function subsonicResponse(body: object, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +function albumPage(count: number, offset: number) { + return { + 'subsonic-response': { + status: 'ok', + albumList2: { + album: Array.from({ length: count }, (_, i) => ({ + id: offset + i + 1, + name: `Album ${offset + i + 1}`, + artist: `Artist ${Math.floor((offset + i) / 10)}`, + })), + }, + }, + } +} + +describe('SubsonicClient', () => { + it('ping sends auth params and succeeds', async () => { + const seen: string[] = [] + const c = new SubsonicClient({ + url: 'http://navidrome.local', + username: 'sam', + password: 'pass', + fetchImpl: (async (input: any) => { + seen.push(String(input)) + return subsonicResponse({ 'subsonic-response': { status: 'ok' } }) + }) as typeof fetch, + }) + await c.ping() + expect(seen[0]!).toContain('/rest/ping') + expect(seen[0]!).toContain('u=sam') + expect(seen[0]!).toContain('v=1.16.1') + expect(seen[0]!).toContain('c=record-shop') + expect(seen[0]!).toContain('f=json') + expect(seen[0]!).toMatch(/t=[0-9a-f]{32}/) + }) + + it('ping raises auth error on failed status with code 40', async () => { + const c = new SubsonicClient({ + url: 'http://x', + username: 'sam', + password: 'bad', + fetchImpl: (async () => + subsonicResponse({ + 'subsonic-response': { + status: 'failed', + error: { code: 40, message: 'Wrong username or password.' }, + }, + })) as typeof fetch, + }) + const err = await c.ping().catch((e) => e) + expect(err).toBeInstanceOf(SubsonicError) + expect((err as SubsonicError).code).toBe('auth') + }) + + it('getAllAlbums paginates until a short page', async () => { + const seen: string[] = [] + const c = new SubsonicClient({ + url: 'http://navidrome.local', + username: 'sam', + password: 'pass', + fetchImpl: (async (input: any) => { + const url = new URL(String(input)) + seen.push(url.searchParams.get('offset') ?? '') + const offset = Number(url.searchParams.get('offset') ?? 0) + // first page: 500 albums, second: 3, third never requested + return subsonicResponse(offset === 0 ? albumPage(500, 0) : albumPage(3, 500)) + }) as typeof fetch, + }) + const albums = await c.getAllAlbums() + expect(seen).toEqual(['0', '500']) + expect(albums).toHaveLength(503) + expect(albums[0]!).toEqual({ id: '1', title: 'Album 1', artist: 'Artist 0' }) + }) + + it('getAllAlbums reports progress', async () => { + const c = new SubsonicClient({ + url: 'http://x', + username: 'sam', + password: 'pass', + fetchImpl: (async (input: any) => { + const offset = Number(new URL(String(input)).searchParams.get('offset') ?? 0) + return subsonicResponse(offset === 0 ? albumPage(500, 0) : albumPage(3, 500)) + }) as typeof fetch, + }) + const progress: number[] = [] + await c.getAllAlbums((_albums, done) => progress.push(done)) + expect(progress[progress.length - 1]!).toBe(503) + }) +})