2026-08-29 16:31:08 +02:00
|
|
|
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)
|
|
|
|
|
})
|
2026-08-29 16:35:01 +02:00
|
|
|
|
|
|
|
|
it('getAllAlbums skips null album entries', async () => {
|
|
|
|
|
const c = new SubsonicClient({
|
|
|
|
|
url: 'http://x',
|
|
|
|
|
username: 'sam',
|
|
|
|
|
password: 'pass',
|
|
|
|
|
fetchImpl: (async () =>
|
|
|
|
|
subsonicResponse({
|
|
|
|
|
'subsonic-response': {
|
|
|
|
|
status: 'ok',
|
|
|
|
|
albumList2: {
|
|
|
|
|
album: [{ id: 1, name: 'Album 1', artist: 'Artist 0' }, null, { id: 2, name: 'Album 2', artist: 'Artist 0' }],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})) as typeof fetch,
|
|
|
|
|
})
|
|
|
|
|
const albums = await c.getAllAlbums()
|
|
|
|
|
expect(albums).toEqual([
|
|
|
|
|
{ id: '1', title: 'Album 1', artist: 'Artist 0' },
|
|
|
|
|
{ id: '2', title: 'Album 2', artist: 'Artist 0' },
|
|
|
|
|
])
|
|
|
|
|
})
|
2026-09-03 20:53:49 +02:00
|
|
|
|
|
|
|
|
it('url() builds a raw endpoint URL with auth params', () => {
|
|
|
|
|
const c = new SubsonicClient({
|
|
|
|
|
url: 'http://navidrome.local',
|
|
|
|
|
username: 'sam',
|
|
|
|
|
password: 'pass',
|
|
|
|
|
fetchImpl: (async () => subsonicResponse({})) as typeof fetch,
|
|
|
|
|
})
|
|
|
|
|
const u = new URL(c.url('stream', { id: 'song-9' }))
|
|
|
|
|
expect(u.pathname).toBe('/rest/stream')
|
|
|
|
|
expect(u.searchParams.get('id')).toBe('song-9')
|
|
|
|
|
expect(u.searchParams.get('u')).toBe('sam')
|
|
|
|
|
expect(u.searchParams.get('f')).toBe('json')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('getAlbum returns ordered tracks', async () => {
|
|
|
|
|
const c = new SubsonicClient({
|
|
|
|
|
url: 'http://x',
|
|
|
|
|
username: 'sam',
|
|
|
|
|
password: 'pass',
|
|
|
|
|
fetchImpl: (async () =>
|
|
|
|
|
subsonicResponse({
|
|
|
|
|
'subsonic-response': {
|
|
|
|
|
status: 'ok',
|
|
|
|
|
album: {
|
|
|
|
|
id: 'alb-1',
|
|
|
|
|
name: 'Motion',
|
|
|
|
|
artist: 'The Cinematic Orchestra',
|
|
|
|
|
song: [
|
|
|
|
|
{ id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 },
|
|
|
|
|
{ id: 's1', title: 'Overture', duration: 200, track: 1 },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})) as typeof fetch,
|
|
|
|
|
})
|
|
|
|
|
const album = await c.getAlbum('alb-1')
|
|
|
|
|
expect(album).toEqual({
|
|
|
|
|
id: 'alb-1',
|
|
|
|
|
title: 'Motion',
|
|
|
|
|
artist: 'The Cinematic Orchestra',
|
|
|
|
|
tracks: [
|
|
|
|
|
{ id: 's1', title: 'Overture', duration: 200, track: 1 },
|
|
|
|
|
{ id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 },
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('getRecentAlbums returns the recent list raw', async () => {
|
|
|
|
|
const c = new SubsonicClient({
|
|
|
|
|
url: 'http://x',
|
|
|
|
|
username: 'sam',
|
|
|
|
|
password: 'pass',
|
|
|
|
|
fetchImpl: (async () =>
|
|
|
|
|
subsonicResponse({
|
|
|
|
|
'subsonic-response': {
|
|
|
|
|
status: 'ok',
|
|
|
|
|
albumList2: {
|
|
|
|
|
album: [
|
|
|
|
|
{ id: 'a1', name: 'Motion', artist: 'TCO', played: '2026-09-01T10:00:00Z' },
|
|
|
|
|
{ id: 'a2', name: 'Blue Lines', artist: 'Massive Attack' },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})) as typeof fetch,
|
|
|
|
|
})
|
|
|
|
|
const recent = await c.getRecentAlbums(500)
|
|
|
|
|
expect(recent).toEqual([
|
|
|
|
|
{ id: 'a1', title: 'Motion', artist: 'TCO', playedAt: '2026-09-01T10:00:00Z' },
|
|
|
|
|
{ id: 'a2', title: 'Blue Lines', artist: 'Massive Attack', playedAt: null },
|
|
|
|
|
])
|
|
|
|
|
})
|
2026-08-29 16:31:08 +02:00
|
|
|
})
|