1
0

feat: matchedAlbum.webUrl from subsonic settings; remove stream proxy routes

This commit is contained in:
2026-09-04 13:37:01 +02:00
parent ff5f4e9133
commit 165ac96830
6 changed files with 24 additions and 302 deletions

View File

@@ -14,7 +14,6 @@ import { registerSettingsRoutes } from './routes/settingsRoutes.js'
import { registerLookupRoutes } from './routes/lookupRoutes.js' import { registerLookupRoutes } from './routes/lookupRoutes.js'
import { registerCollectionRoutes } from './routes/collectionRoutes.js' import { registerCollectionRoutes } from './routes/collectionRoutes.js'
import { registerLibraryRoutes } from './routes/libraryRoutes.js' import { registerLibraryRoutes } from './routes/libraryRoutes.js'
import { registerStreamRoutes } from './routes/streamRoutes.js'
import { registerLoanRoutes } from './routes/loanRoutes.js' import { registerLoanRoutes } from './routes/loanRoutes.js'
import { registerStatsRoutes } from './routes/statsRoutes.js' import { registerStatsRoutes } from './routes/statsRoutes.js'
import { registerDataRoutes } from './routes/dataRoutes.js' import { registerDataRoutes } from './routes/dataRoutes.js'
@@ -56,7 +55,6 @@ export async function buildApp(opts: AppOptions): Promise<FastifyInstance> {
await registerLookupRoutes(app) await registerLookupRoutes(app)
await registerCollectionRoutes(app) await registerCollectionRoutes(app)
await registerLibraryRoutes(app) await registerLibraryRoutes(app)
await registerStreamRoutes(app)
await registerLoanRoutes(app) await registerLoanRoutes(app)
await registerStatsRoutes(app) await registerStatsRoutes(app)
await registerDataRoutes(app) await registerDataRoutes(app)

View File

@@ -4,6 +4,7 @@ import { cacheArtwork } from '../artwork.js'
import { resolveRipStatus, resolveRipStatusBatch, findMatchedAlbum, type RipStatus } from '../ripstatus.js' import { resolveRipStatus, resolveRipStatusBatch, findMatchedAlbum, type RipStatus } from '../ripstatus.js'
import { requireAuth } from './authRoutes.js' import { requireAuth } from './authRoutes.js'
import { discogsClientFor, discogsErrorStatus } from './lookupRoutes.js' import { discogsClientFor, discogsErrorStatus } from './lookupRoutes.js'
import { getSettings } from './settingsRoutes.js'
interface ItemRow { interface ItemRow {
id: number id: number
@@ -182,9 +183,15 @@ export async function registerCollectionRoutes(app: FastifyInstance): Promise<vo
const loan = db const loan = db
.prepare('SELECT id, borrower, lent_at FROM loans WHERE item_id = ? AND returned_at IS NULL') .prepare('SELECT id, borrower, lent_at FROM loans WHERE item_id = ? AND returned_at IS NULL')
.get(row.id) as { id: number; borrower: string; lent_at: string } | undefined .get(row.id) as { id: number; borrower: string; lent_at: string } | undefined
const matched = findMatchedAlbum(db, request.user!.id, row.id)
const settings = getSettings(db, request.user!.id)
const webUrl =
matched && settings.subsonic_url
? `${settings.subsonic_url.replace(/\/+$/, '')}/app/#/album/${matched.subsonicId}`
: null
return { return {
...rowToItem(db, row), ...rowToItem(db, row),
matchedAlbum: findMatchedAlbum(db, request.user!.id, row.id), matchedAlbum: matched ? { ...matched, webUrl } : null,
loan: loan ? { id: loan.id, borrower: loan.borrower, lentAt: loan.lent_at } : null, loan: loan ? { id: loan.id, borrower: loan.borrower, lentAt: loan.lent_at } : null,
} }
}) })

View File

@@ -1,65 +0,0 @@
import { FastifyInstance } from 'fastify'
import { SubsonicClient, SubsonicError } from '../subsonic.js'
import { requireAuth } from './authRoutes.js'
import { getSettings, subsonicConfigComplete } from './settingsRoutes.js'
function clientFor(request: any): SubsonicClient | null {
const s = getSettings(request.server.db, request.user.id)
if (!subsonicConfigComplete(s)) return null
return new SubsonicClient({
url: s.subsonic_url as string,
username: s.subsonic_username as string,
password: s.subsonic_password as string,
fetchImpl: request.server.fetchImpl,
})
}
export async function registerStreamRoutes(app: FastifyInstance): Promise<void> {
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<string, string> = {}
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 }
})
}

View File

@@ -5,6 +5,12 @@ import { discogsReleaseFixture } from './fixtures.js'
function discogsStub(): typeof fetch { function discogsStub(): typeof fetch {
return (async (input: any) => { return (async (input: any) => {
const url = String(input) 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')) { if (url.includes('/releases/1001')) {
return new Response(JSON.stringify(discogsReleaseFixture), { return new Response(JSON.stringify(discogsReleaseFixture), {
status: 200, status: 200,
@@ -220,6 +226,12 @@ describe('collection routes', () => {
const detail = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) }) const detail = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) })
expect(detail.json().matchedAlbum).toBeNull() 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({ await app.inject({
method: 'POST', method: 'POST',
url: '/api/library/albums/test-seed', url: '/api/library/albums/test-seed',
@@ -227,7 +239,10 @@ describe('collection routes', () => {
payload: { albums: [{ subsonicId: 'alb-9', title: 'Motion', artist: 'The Cinematic Orchestra' }] }, payload: { albums: [{ subsonicId: 'alb-9', title: 'Motion', artist: 'The Cinematic Orchestra' }] },
}) })
const again = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) }) 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() await app.close()
}) })

View File

@@ -51,34 +51,4 @@ describe('last_played_at stamping', () => {
expect(row.last_played_at).toBe('2026-09-01T10:00:00Z') expect(row.last_played_at).toBe('2026-09-01T10:00:00Z')
await app.close() 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()
})
}) })

View File

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