From 165ac9683057879d6f359deaec59281fc25d0f59 Mon Sep 17 00:00:00 2001 From: Samu Date: Fri, 4 Sep 2026 13:37:01 +0200 Subject: [PATCH] feat: matchedAlbum.webUrl from subsonic settings; remove stream proxy routes --- server/src/app.ts | 2 - server/src/routes/collectionRoutes.ts | 9 +- server/src/routes/streamRoutes.ts | 65 --------- server/test/collection.test.ts | 17 ++- server/test/lastplayed.test.ts | 30 ---- server/test/stream.test.ts | 203 -------------------------- 6 files changed, 24 insertions(+), 302 deletions(-) delete mode 100644 server/src/routes/streamRoutes.ts delete mode 100644 server/test/stream.test.ts diff --git a/server/src/app.ts b/server/src/app.ts index 0ce2d18..bfac4b3 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -14,7 +14,6 @@ import { registerSettingsRoutes } from './routes/settingsRoutes.js' import { registerLookupRoutes } from './routes/lookupRoutes.js' import { registerCollectionRoutes } from './routes/collectionRoutes.js' import { registerLibraryRoutes } from './routes/libraryRoutes.js' -import { registerStreamRoutes } from './routes/streamRoutes.js' import { registerLoanRoutes } from './routes/loanRoutes.js' import { registerStatsRoutes } from './routes/statsRoutes.js' import { registerDataRoutes } from './routes/dataRoutes.js' @@ -56,7 +55,6 @@ export async function buildApp(opts: AppOptions): Promise { await registerLookupRoutes(app) await registerCollectionRoutes(app) await registerLibraryRoutes(app) - await registerStreamRoutes(app) await registerLoanRoutes(app) await registerStatsRoutes(app) await registerDataRoutes(app) diff --git a/server/src/routes/collectionRoutes.ts b/server/src/routes/collectionRoutes.ts index b0fa2c3..d8590e4 100644 --- a/server/src/routes/collectionRoutes.ts +++ b/server/src/routes/collectionRoutes.ts @@ -4,6 +4,7 @@ import { cacheArtwork } from '../artwork.js' import { resolveRipStatus, resolveRipStatusBatch, findMatchedAlbum, type RipStatus } from '../ripstatus.js' import { requireAuth } from './authRoutes.js' import { discogsClientFor, discogsErrorStatus } from './lookupRoutes.js' +import { getSettings } from './settingsRoutes.js' interface ItemRow { id: number @@ -182,9 +183,15 @@ export async function registerCollectionRoutes(app: FastifyInstance): Promise { - app.get('/api/stream/:songId', { preHandler: [requireAuth] }, async (request, reply) => { - const client = clientFor(request) - if (!client) return reply.code(409).send({ error: 'no_subsonic_config' }) - const songId = (request.params as { songId: string }).songId - const range = request.headers.range - let upstream: Response - try { - upstream = await request.server.fetchImpl(client.url('stream', { id: songId }), { - headers: range ? { Range: range } : {}, - }) - } catch { - return reply.code(502).send({ error: 'stream_unavailable' }) - } - if (!upstream.ok && upstream.status !== 206) { - return reply.code(502).send({ error: 'stream_unavailable' }) - } - const headers: Record = {} - for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges']) { - const v = upstream.headers.get(h) - if (v) headers[h] = v - } - if (!upstream.headers.get('accept-ranges')) headers['accept-ranges'] = 'bytes' - return reply.code(upstream.status).headers(headers).send(upstream.body) - }) - - app.get('/api/album/:subsonicId/tracks', { preHandler: [requireAuth] }, async (request, reply) => { - const client = clientFor(request) - if (!client) return reply.code(409).send({ error: 'no_subsonic_config' }) - try { - const album = await client.getAlbum((request.params as { subsonicId: string }).subsonicId) - return album - } catch (err) { - if (err instanceof SubsonicError) return reply.code(502).send({ error: 'subsonic_error', detail: err.message }) - return reply.code(502).send({ error: 'subsonic_unreachable' }) - } - }) - - app.post('/api/album/:subsonicId/played', { preHandler: [requireAuth] }, async (request, reply) => { - const subsonicId = (request.params as { subsonicId: string }).subsonicId - const info = request.server.db - .prepare( - 'UPDATE digital_albums SET last_played_at = ? WHERE user_id = ? AND subsonic_id = ?' - ) - .run(new Date().toISOString(), request.user!.id, subsonicId) - if (info.changes === 0) return reply.code(404).send({ error: 'not_found' }) - return { ok: true } - }) -} diff --git a/server/test/collection.test.ts b/server/test/collection.test.ts index 1cb81fa..c485025 100644 --- a/server/test/collection.test.ts +++ b/server/test/collection.test.ts @@ -5,6 +5,12 @@ import { discogsReleaseFixture } from './fixtures.js' function discogsStub(): typeof fetch { return (async (input: any) => { const url = String(input) + if (url.includes('/rest/ping')) { + return new Response(JSON.stringify({ 'subsonic-response': { status: 'ok' } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } if (url.includes('/releases/1001')) { return new Response(JSON.stringify(discogsReleaseFixture), { status: 200, @@ -220,6 +226,12 @@ describe('collection routes', () => { const detail = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) }) expect(detail.json().matchedAlbum).toBeNull() + await app.inject({ + method: 'PUT', + url: '/api/settings', + ...auth(cookie), + payload: { subsonicUrl: 'http://navidrome.local', subsonicUsername: 'sam', subsonicPassword: 'pass' }, + }) await app.inject({ method: 'POST', url: '/api/library/albums/test-seed', @@ -227,7 +239,10 @@ describe('collection routes', () => { payload: { albums: [{ subsonicId: 'alb-9', title: 'Motion', artist: 'The Cinematic Orchestra' }] }, }) const again = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) }) - expect(again.json().matchedAlbum).toMatchObject({ subsonicId: 'alb-9', lastPlayedAt: null }) + expect(again.json().matchedAlbum).toMatchObject({ + subsonicId: 'alb-9', + webUrl: 'http://navidrome.local/app/#/album/alb-9', + }) await app.close() }) diff --git a/server/test/lastplayed.test.ts b/server/test/lastplayed.test.ts index d9f8c5f..0b52da0 100644 --- a/server/test/lastplayed.test.ts +++ b/server/test/lastplayed.test.ts @@ -51,34 +51,4 @@ describe('last_played_at stamping', () => { expect(row.last_played_at).toBe('2026-09-01T10:00:00Z') await app.close() }) - - it('played endpoint stamps now and returns 404 for unknown albums', async () => { - const app = await buildTestApp() - const cookie = await setupAdmin(app) - await app.inject({ - method: 'POST', - url: '/api/library/albums/test-seed', - ...auth(cookie), - payload: { albums: [{ subsonicId: 'a1', title: 'Motion', artist: 'TCO' }] }, - }) - const before = ( - app.db.prepare("SELECT last_played_at FROM digital_albums WHERE subsonic_id = 'a1'").get() as { - last_played_at: string | null - } - ).last_played_at - expect(before).toBeNull() - - const res = await app.inject({ method: 'POST', url: '/api/album/a1/played', ...auth(cookie) }) - expect(res.statusCode).toBe(200) - const after = ( - app.db.prepare("SELECT last_played_at FROM digital_albums WHERE subsonic_id = 'a1'").get() as { - last_played_at: string | null - } - ).last_played_at - expect(after).toBeTruthy() - - const missing = await app.inject({ method: 'POST', url: '/api/album/zzz/played', ...auth(cookie) }) - expect(missing.statusCode).toBe(404) - await app.close() - }) }) diff --git a/server/test/stream.test.ts b/server/test/stream.test.ts deleted file mode 100644 index cb980c4..0000000 --- a/server/test/stream.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { openDatabase } from '../src/db.js' -import { buildTestAppWithDb, setupAdmin, auth } from './helpers.js' - -const PASSWORD = 's3cret-pass-xyz' -const AUDIO = new TextEncoder().encode('fake-audio-payload') - -interface UpstreamRequest { - url: URL - range: string | null -} - -interface StubOptions { - album?: () => Response - stream?: (req: UpstreamRequest) => Response - onStream?: (req: UpstreamRequest) => void -} - -/** Subsonic stub: ping/getAlbumList2 always ok (settings PUT + sync); album/stream configurable. */ -function subsonicStub(opts: StubOptions): typeof fetch { - return (async (input: any, init?: any) => { - const url = new URL(String(input)) - if (url.pathname.endsWith('/rest/ping')) { - return new Response(JSON.stringify({ 'subsonic-response': { status: 'ok' } }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }) - } - if (url.pathname.endsWith('/rest/getAlbumList2')) { - return new Response( - JSON.stringify({ 'subsonic-response': { status: 'ok', albumList2: { album: [] } } }), - { status: 200, headers: { 'content-type': 'application/json' } } - ) - } - if (url.pathname.endsWith('/rest/getAlbum')) { - return opts.album ? opts.album() : new Response('nope', { status: 404 }) - } - if (url.pathname.endsWith('/rest/stream')) { - const req: UpstreamRequest = { url, range: init?.headers?.Range ?? null } - opts.onStream?.(req) - return opts.stream ? opts.stream(req) : new Response('nope', { status: 404 }) - } - return new Response('nope', { status: 404 }) - }) as typeof fetch -} - -async function appWithSubsonic(fetchImpl: typeof fetch) { - const app = await buildTestAppWithDb(openDatabase(':memory:'), fetchImpl) - const cookie = await setupAdmin(app) - await app.inject({ - method: 'PUT', - url: '/api/settings', - ...auth(cookie), - payload: { subsonicUrl: 'http://n.local', subsonicUsername: 'sam', subsonicPassword: PASSWORD }, - }) - return { app, cookie } -} - -// Note: the played endpoint is covered in lastplayed.test.ts — this file -// focuses on the stream/album proxy behaviour. -describe('stream/album proxy routes', () => { - it('forwards the Range header to the stream upstream', async () => { - const seen: UpstreamRequest[] = [] - const { app, cookie } = await appWithSubsonic( - subsonicStub({ - onStream: (req) => { - seen.push(req) - }, - stream: () => - new Response(AUDIO, { status: 200, headers: { 'content-type': 'audio/mpeg' } }), - }) - ) - const res = await app.inject({ - method: 'GET', - url: '/api/stream/song-1', - ...auth(cookie), - headers: { range: 'bytes=100-' }, - }) - expect(res.statusCode).toBe(200) - expect(seen[0]?.range).toBe('bytes=100-') - expect(res.body).toBe('fake-audio-payload') - await app.close() - }) - - it('passes through an upstream 206 partial response with content-range', async () => { - const { app, cookie } = await appWithSubsonic( - subsonicStub({ - stream: () => - new Response(AUDIO, { - status: 206, - headers: { 'content-type': 'audio/mpeg', 'content-range': 'bytes 100-116/500' }, - }), - }) - ) - const res = await app.inject({ - method: 'GET', - url: '/api/stream/song-1', - ...auth(cookie), - headers: { range: 'bytes=100-' }, - }) - expect(res.statusCode).toBe(206) - expect(res.headers['content-type']).toBe('audio/mpeg') - expect(res.headers['content-range']).toBe('bytes 100-116/500') - expect(res.body).toBe('fake-audio-payload') - await app.close() - }) - - it('returns 502 stream_unavailable when the upstream fetch throws', async () => { - const { app, cookie } = await appWithSubsonic( - subsonicStub({ - stream: () => { - throw new Error('upstream down') - }, - }) - ) - const res = await app.inject({ method: 'GET', url: '/api/stream/song-1', ...auth(cookie) }) - expect(res.statusCode).toBe(502) - expect(res.json()).toEqual({ error: 'stream_unavailable' }) - await app.close() - }) - - it('leaks no subsonic credentials in body or headers (token auth upstream)', async () => { - const upstreamUrls: URL[] = [] - const { app, cookie } = await appWithSubsonic( - subsonicStub({ - onStream: (req) => { - upstreamUrls.push(req.url) - }, - stream: () => - new Response(AUDIO, { status: 200, headers: { 'content-type': 'audio/mpeg' } }), - }) - ) - const res = await app.inject({ method: 'GET', url: '/api/stream/song-1', ...auth(cookie) }) - expect(res.statusCode).toBe(200) - expect(res.body).not.toContain(PASSWORD) - expect(JSON.stringify(res.headers)).not.toContain(PASSWORD) - // upstream request used token auth — raw password never sent - const upstreamUrl = upstreamUrls[0] - expect(upstreamUrl?.searchParams.get('u')).toBe('sam') - expect(upstreamUrl?.searchParams.get('t')).toMatch(/^[0-9a-f]{32}$/) - expect(upstreamUrl?.searchParams.has('p')).toBe(false) - expect(upstreamUrl?.toString()).not.toContain(PASSWORD) - await app.close() - }) - - it('album tracks endpoint returns the getAlbum payload with ordered tracks', async () => { - const { app, cookie } = await appWithSubsonic( - subsonicStub({ - album: () => - new Response( - JSON.stringify({ - '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 }, - ], - }, - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } } - ), - }) - ) - const res = await app.inject({ method: 'GET', url: '/api/album/alb-1/tracks', ...auth(cookie) }) - expect(res.statusCode).toBe(200) - expect(res.json()).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 }, - ], - }) - await app.close() - }) - - it('returns 502 subsonic_error when subsonic rejects the credentials', async () => { - const { app, cookie } = await appWithSubsonic( - subsonicStub({ - album: () => - new Response( - JSON.stringify({ - 'subsonic-response': { - status: 'failed', - error: { code: 40, message: 'Wrong username or password' }, - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } } - ), - }) - ) - const res = await app.inject({ method: 'GET', url: '/api/album/alb-1/tracks', ...auth(cookie) }) - expect(res.statusCode).toBe(502) - expect(res.json().error).toBe('subsonic_error') - await app.close() - }) -})