diff --git a/server/src/app.ts b/server/src/app.ts index a8a41fa..6e840c3 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -14,6 +14,7 @@ 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' declare module 'fastify' { interface FastifyInstance { @@ -52,6 +53,7 @@ export async function buildApp(opts: AppOptions): Promise { await registerLookupRoutes(app) await registerCollectionRoutes(app) await registerLibraryRoutes(app) + await registerStreamRoutes(app) // artwork cache (always available) await app.register(fastifyStatic, { diff --git a/server/src/routes/streamRoutes.ts b/server/src/routes/streamRoutes.ts new file mode 100644 index 0000000..1aa2dc1 --- /dev/null +++ b/server/src/routes/streamRoutes.ts @@ -0,0 +1,65 @@ +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 { + 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/src/sync.ts b/server/src/sync.ts index 527e5cf..f63d917 100644 --- a/server/src/sync.ts +++ b/server/src/sync.ts @@ -54,6 +54,18 @@ export class SyncManager { } }) apply() + // best-effort: stamp played timestamps reported by the server + try { + const recent = await client.getRecentAlbums(500) + const stamp = this.db.prepare( + 'UPDATE digital_albums SET last_played_at = ? WHERE user_id = ? AND subsonic_id = ?' + ) + for (const r of recent) { + if (r.playedAt) stamp.run(r.playedAt, userId, r.id) + } + } catch { + // recency stamping is optional + } this.states.set(userId, { status: 'done', error: null, diff --git a/server/test/lastplayed.test.ts b/server/test/lastplayed.test.ts new file mode 100644 index 0000000..d9f8c5f --- /dev/null +++ b/server/test/lastplayed.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest' +import { buildTestApp, setupAdmin, auth } from './helpers.js' + +function stubWithRecent(recent: { id: number; name: string; artist: string; played?: string }[]): typeof fetch { + return (async (input: 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')) { + const type = url.searchParams.get('type') + const body = + type === 'recent' + ? { 'subsonic-response': { status: 'ok', albumList2: { album: recent } } } + : { 'subsonic-response': { status: 'ok', albumList2: { album: [{ id: 1, name: 'Album 1', artist: 'Artist 0' }] } } } + return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }) + } + return new Response('nope', { status: 404 }) + }) as typeof fetch +} + +async function waitForDone(app: any, cookie: string, timeoutMs = 2000) { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + const state = (await app.inject({ method: 'GET', url: '/api/library/sync', ...auth(cookie) })).json() + if (state.status !== 'running') return state + await new Promise((r) => setTimeout(r, 10)) + } + throw new Error('sync did not finish') +} + +describe('last_played_at stamping', () => { + it('sync stamps played timestamps from the recent list', async () => { + const app = await buildTestApp( + stubWithRecent([{ id: 1, name: 'Album 1', artist: 'Artist 0', played: '2026-09-01T10:00:00Z' }]) + ) + const cookie = await setupAdmin(app) + await app.inject({ + method: 'PUT', + url: '/api/settings', + ...auth(cookie), + payload: { subsonicUrl: 'http://n.local', subsonicUsername: 'sam', subsonicPassword: 'pass' }, + }) + await waitForDone(app, cookie) + const row = app.db + .prepare("SELECT last_played_at FROM digital_albums WHERE subsonic_id = '1'") + .get() as { last_played_at: string | null } + 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() + }) +})