From 875bc1087c4b3ff9c55c8adaf89f231fb000bf7e Mon Sep 17 00:00:00 2001 From: Samu Date: Sat, 29 Aug 2026 17:34:05 +0200 Subject: [PATCH] feat: background subsonic library sync with per-user state --- server/src/app.ts | 5 + server/src/routes/libraryRoutes.ts | 80 ++++++++++++++ server/src/routes/settingsRoutes.ts | 10 +- server/src/sync.ts | 66 ++++++++++++ server/test/library.test.ts | 157 ++++++++++++++++++++++++++++ 5 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 server/src/routes/libraryRoutes.ts create mode 100644 server/src/sync.ts create mode 100644 server/test/library.test.ts diff --git a/server/src/app.ts b/server/src/app.ts index 3d29c68..5e89d52 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -3,10 +3,12 @@ import Database from 'better-sqlite3' import cookie from '@fastify/cookie' import type { Config } from './config.js' import { SerialQueue } from './queue.js' +import { SyncManager } from './sync.js' import { registerAuthRoutes } from './routes/authRoutes.js' import { registerSettingsRoutes } from './routes/settingsRoutes.js' import { registerLookupRoutes } from './routes/lookupRoutes.js' import { registerCollectionRoutes } from './routes/collectionRoutes.js' +import { registerLibraryRoutes } from './routes/libraryRoutes.js' declare module 'fastify' { interface FastifyInstance { @@ -14,6 +16,7 @@ declare module 'fastify' { config: Config fetchImpl: typeof fetch discogsQueue: SerialQueue + sync: SyncManager } interface FastifyRequest { user?: import('./auth.js').UserRow @@ -32,12 +35,14 @@ export async function buildApp(opts: AppOptions): Promise { app.decorate('config', opts.config) app.decorate('fetchImpl', opts.fetchImpl ?? fetch) app.decorate('discogsQueue', new SerialQueue({ minIntervalMs: 1100 })) + app.decorate('sync', new SyncManager(opts.db, opts.fetchImpl ?? fetch)) await app.register(cookie) await registerAuthRoutes(app) await registerSettingsRoutes(app) await registerLookupRoutes(app) await registerCollectionRoutes(app) + await registerLibraryRoutes(app) app.get('/api/health', async () => ({ ok: true })) return app diff --git a/server/src/routes/libraryRoutes.ts b/server/src/routes/libraryRoutes.ts new file mode 100644 index 0000000..19e3a3f --- /dev/null +++ b/server/src/routes/libraryRoutes.ts @@ -0,0 +1,80 @@ +import { FastifyInstance } from 'fastify' +import { requireAuth } from './authRoutes.js' +import { getSettings, subsonicConfigComplete } from './settingsRoutes.js' + +export async function registerLibraryRoutes(app: FastifyInstance): Promise { + app.get('/api/library/sync', { preHandler: [requireAuth] }, async (request) => { + return request.server.sync.getState(request.user!.id) + }) + + app.post('/api/library/sync', { preHandler: [requireAuth] }, async (request, reply) => { + const s = getSettings(request.server.db, request.user!.id) + if (!subsonicConfigComplete(s)) { + return reply.code(409).send({ error: 'no_subsonic_config' }) + } + request.server.sync.start(request.user!.id, { + url: s.subsonic_url as string, + username: s.subsonic_username as string, + password: s.subsonic_password as string, + }) + return reply.code(202).send(request.server.sync.getState(request.user!.id)) + }) + + app.get('/api/library/albums', { preHandler: [requireAuth] }, async (request) => { + const { q } = request.query as { q?: string } + const userId = request.user!.id + let rows: { id: number; subsonic_id: string; title: string; artist: string }[] + if (q) { + const needle = `%${q}%` + rows = request.server.db + .prepare( + `SELECT id, subsonic_id, title, artist FROM digital_albums + WHERE user_id = ? AND (title LIKE ? OR artist LIKE ?) + ORDER BY artist, title LIMIT 50` + ) + .all(userId, needle, needle) as { + id: number + subsonic_id: string + title: string + artist: string + }[] + } else { + rows = request.server.db + .prepare( + `SELECT id, subsonic_id, title, artist FROM digital_albums + WHERE user_id = ? ORDER BY artist, title LIMIT 50` + ) + .all(userId) as { + id: number + subsonic_id: string + title: string + artist: string + }[] + } + return { + albums: rows.map((r) => ({ id: r.id, subsonicId: r.subsonic_id, title: r.title, artist: r.artist })), + } + }) + + // Test-only seeding route so lookup/collection tests can populate digital + // albums without a live Subsonic server. Disabled outside tests. + app.post('/api/library/albums/test-seed', { preHandler: [requireAuth] }, async (request, reply) => { + if (process.env.NODE_ENV !== 'test' && process.env.VITEST !== 'true') { + return reply.code(404).send({ error: 'not_found' }) + } + const { albums } = (request.body ?? {}) as { + albums?: { subsonicId: string; title: string; artist: string }[] + } + const userId = request.user!.id + const insert = request.server.db.prepare( + `INSERT INTO digital_albums (user_id, subsonic_id, title, artist) VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, subsonic_id) DO UPDATE SET title = excluded.title, artist = excluded.artist` + ) + let inserted = 0 + for (const a of albums ?? []) { + insert.run(userId, a.subsonicId, a.title, a.artist) + inserted++ + } + return { inserted } + }) +} diff --git a/server/src/routes/settingsRoutes.ts b/server/src/routes/settingsRoutes.ts index 606c5e4..73afef0 100644 --- a/server/src/routes/settingsRoutes.ts +++ b/server/src/routes/settingsRoutes.ts @@ -94,6 +94,14 @@ export async function registerSettingsRoutes(app: FastifyInstance): Promise() + + constructor( + private db: DB, + private fetchImpl: typeof fetch + ) {} + + getState(userId: number): SyncState { + return this.states.get(userId) ?? initialSyncState() + } + + /** Returns false when a sync is already running. */ + start(userId: number, config: { url: string; username: string; password: string }): boolean { + const state = this.getState(userId) + if (state.status === 'running') return false + this.states.set(userId, { ...state, status: 'running', error: null }) + void this.run(userId, config) + return true + } + + private async run( + userId: number, + config: { url: string; username: string; password: string } + ): Promise { + const client = new SubsonicClient({ ...config, fetchImpl: this.fetchImpl }) + try { + const albums = await client.getAllAlbums() + const upsert = this.db.prepare( + `INSERT INTO digital_albums (user_id, subsonic_id, title, artist) VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, subsonic_id) DO UPDATE SET title = excluded.title, artist = excluded.artist` + ) + const insertAll = this.db.transaction((rows: { id: string; title: string; artist: string }[]) => { + for (const r of rows) upsert.run(userId, r.id, r.title, r.artist) + }) + insertAll(albums) + this.states.set(userId, { + status: 'done', + error: null, + lastSyncedAt: new Date().toISOString(), + albums: albums.length, + }) + } catch (err) { + const state = this.getState(userId) + this.states.set(userId, { + ...state, + status: 'error', + error: err instanceof SubsonicError ? err.message : 'sync failed', + }) + } + } +} diff --git a/server/test/library.test.ts b/server/test/library.test.ts new file mode 100644 index 0000000..fbb9bbd --- /dev/null +++ b/server/test/library.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from 'vitest' +import { buildTestApp, setupAdmin, loginAs, auth } from './helpers.js' + +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)}`, + })), + }, + }, + } +} + +function subsonicStub(): 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 offset = Number(url.searchParams.get('offset') ?? 0) + return new Response( + JSON.stringify(offset === 0 ? albumPage(500, 0) : albumPage(3, 500)), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + } + return new Response('nope', { status: 404 }) + }) as typeof fetch +} + +async function appWithSubsonic() { + const app = await buildTestApp(subsonicStub()) + const cookie = await setupAdmin(app) + await app.inject({ + method: 'PUT', + url: '/api/settings', + ...auth(cookie), + payload: { + subsonicUrl: 'http://navidrome.local', + subsonicUsername: 'sam', + subsonicPassword: 'pass', + }, + }) + return { app, cookie } +} + +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 in time') +} + +describe('library sync', () => { + it('sync paginates subsonic and caches albums', async () => { + const { app, cookie } = await appWithSubsonic() + const start = await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) }) + expect(start.statusCode).toBe(202) + + const state = await waitForDone(app, cookie) + expect(state.status).toBe('done') + expect(state.albums).toBe(503) + expect(state.lastSyncedAt).toBeTruthy() + + const albums = await app.inject({ method: 'GET', url: '/api/library/albums?q=Album 7', ...auth(cookie) }) + expect(albums.json().albums.length).toBeGreaterThan(0) + + // idempotent re-sync: count stays 503 (upsert, no duplicates) + await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) }) + const second = await waitForDone(app, cookie) + expect(second.albums).toBe(503) + await app.close() + }) + + it('returns 409 without subsonic config', async () => { + const app = await buildTestApp() + const cookie = await setupAdmin(app) + const res = await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) }) + expect(res.statusCode).toBe(409) + expect(res.json()).toEqual({ error: 'no_subsonic_config' }) + await app.close() + }) + + it('reports error state when album fetch fails after valid ping', async () => { + // ping succeeds (settings validation passes) but getAlbumList2 fails (sync errors) + const fetcher = (async (input: any) => { + if (String(input).includes('/rest/ping')) { + return new Response(JSON.stringify({ 'subsonic-response': { status: 'ok' } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + throw new TypeError('fetch failed') + }) as unknown as typeof fetch + const app = await buildTestApp(fetcher) + const cookie = await setupAdmin(app) + await app.inject({ + method: 'PUT', + url: '/api/settings', + ...auth(cookie), + payload: { + subsonicUrl: 'http://flaky.local', + subsonicUsername: 'sam', + subsonicPassword: 'pass', + }, + }) + await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) }) + const state = await waitForDone(app, cookie) + expect(state.status).toBe('error') + expect(state.error).toBeTruthy() + await app.close() + }) + + it('albums search is scoped per user', async () => { + const { app, cookie } = await appWithSubsonic() + await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) }) + await waitForDone(app, cookie) + + const bobCookie = await loginAs(app, cookie, 'bob', 'bobpass123') + const empty = await app.inject({ method: 'GET', url: '/api/library/albums?q=Album', ...auth(bobCookie) }) + expect(empty.json().albums).toHaveLength(0) + await app.close() + }) + + it('test-seed route inserts albums directly (used by lookup/collection tests)', async () => { + const app = await buildTestApp() + const cookie = await setupAdmin(app) + const res = await app.inject({ + method: 'POST', + url: '/api/library/albums/test-seed', + ...auth(cookie), + payload: { albums: [{ subsonicId: 'a1', title: 'Motion', artist: 'The Cinematic Orchestra' }] }, + }) + expect(res.statusCode).toBe(200) + expect(res.json().inserted).toBe(1) + await app.close() + }) + + it('saving subsonic settings triggers a first sync', async () => { + const { app, cookie } = await appWithSubsonic() + const state = await waitForDone(app, cookie) + expect(state.status).toBe('done') + expect(state.albums).toBe(503) + await app.close() + }) +})