From 9d1291b58fdd3f7dcae9d82e5787e4846ef8a0f6 Mon Sep 17 00:00:00 2001 From: Samu Date: Thu, 3 Sep 2026 20:53:49 +0200 Subject: [PATCH] feat: subsonic getAlbum/getRecentAlbums and stream url builder --- server/src/subsonic.ts | 34 +++++++++++++++++ server/test/subsonic.test.ts | 72 ++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/server/src/subsonic.ts b/server/src/subsonic.ts index 93be9ce..586555f 100644 --- a/server/src/subsonic.ts +++ b/server/src/subsonic.ts @@ -75,6 +75,40 @@ export class SubsonicClient { await this.request('ping') } + /** Raw endpoint URL with auth params — for streaming passthrough. */ + url(endpoint: string, params: Record = {}): string { + const u = new URL(`${this.base}/rest/${endpoint}`) + const search = { ...this.authParams(), ...params } + for (const [k, v] of Object.entries(search)) u.searchParams.set(k, v) + return u.toString() + } + + async getAlbum(albumId: string): Promise<{ id: string; title: string; artist: string; tracks: { id: string; title: string; duration: number | null; track: number | null }[] }> { + const envelope = await this.request('getAlbum', { id: albumId }) + const album = envelope.album ?? {} + const songs: any[] = album.song ?? [] + const tracks = songs + .map((s) => ({ + id: String(s.id), + title: String(s.title ?? ''), + duration: Number.isFinite(Number(s.duration)) ? Number(s.duration) : null, + track: Number.isInteger(Number(s.track)) ? Number(s.track) : null, + })) + .sort((a, b) => (a.track ?? 9999) - (b.track ?? 9999)) + return { id: String(album.id ?? albumId), title: album.name ?? album.title ?? '', artist: album.artist ?? '', tracks } + } + + async getRecentAlbums(size = 500): Promise<{ id: string; title: string; artist: string; playedAt: string | null }[]> { + const envelope = await this.request('getAlbumList2', { type: 'recent', size: String(size) }) + const list: any[] = envelope.albumList2?.album ?? [] + return list.map((a) => ({ + id: String(a.id), + title: a.name ?? a.title ?? '', + artist: a.artist ?? '', + playedAt: typeof a.played === 'string' ? a.played : typeof a.playedAt === 'string' ? a.playedAt : null, + })) + } + async getAllAlbums( onProgress?: (albums: SubsonicAlbum[], done: number) => void ): Promise { diff --git a/server/test/subsonic.test.ts b/server/test/subsonic.test.ts index a99514e..ff71039 100644 --- a/server/test/subsonic.test.ts +++ b/server/test/subsonic.test.ts @@ -118,4 +118,76 @@ describe('SubsonicClient', () => { { id: '2', title: 'Album 2', artist: 'Artist 0' }, ]) }) + + 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 }, + ]) + }) })