1
0

feat: background subsonic library sync with per-user state

This commit is contained in:
2026-08-29 17:34:05 +02:00
parent f1d6320dfe
commit 875bc1087c
5 changed files with 317 additions and 1 deletions

View File

@@ -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<FastifyInstance> {
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

View File

@@ -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<void> {
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 }
})
}

View File

@@ -94,6 +94,14 @@ export async function registerSettingsRoutes(app: FastifyInstance): Promise<void
userId
)
return settingsView(getSettings(db, userId))
const updated = getSettings(db, userId)
if (subsonicConfigComplete(updated)) {
request.server.sync.start(userId, {
url: updated.subsonic_url as string,
username: updated.subsonic_username as string,
password: updated.subsonic_password as string,
})
}
return settingsView(updated)
})
}

66
server/src/sync.ts Normal file
View File

@@ -0,0 +1,66 @@
import type { DB } from './db.js'
import { SubsonicClient, SubsonicError } from './subsonic.js'
export interface SyncState {
status: 'idle' | 'running' | 'done' | 'error'
error: string | null
lastSyncedAt: string | null
albums: number
}
export function initialSyncState(): SyncState {
return { status: 'idle', error: null, lastSyncedAt: null, albums: 0 }
}
export class SyncManager {
private states = new Map<number, SyncState>()
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<void> {
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',
})
}
}
}

157
server/test/library.test.ts Normal file
View File

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