1
0

feat: subsonic library pagination

This commit is contained in:
2026-08-29 16:31:08 +02:00
parent 72f3fc8436
commit dc63db54ba
2 changed files with 120 additions and 0 deletions

View File

@@ -71,4 +71,25 @@ export class SubsonicClient {
async ping(): Promise<void> {
await this.request('ping')
}
async getAllAlbums(
onProgress?: (albums: SubsonicAlbum[], done: number) => void
): Promise<SubsonicAlbum[]> {
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
}
}

View File

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