feat: background subsonic library sync with per-user state
This commit is contained in:
@@ -3,10 +3,12 @@ import Database from 'better-sqlite3'
|
|||||||
import cookie from '@fastify/cookie'
|
import cookie from '@fastify/cookie'
|
||||||
import type { Config } from './config.js'
|
import type { Config } from './config.js'
|
||||||
import { SerialQueue } from './queue.js'
|
import { SerialQueue } from './queue.js'
|
||||||
|
import { SyncManager } from './sync.js'
|
||||||
import { registerAuthRoutes } from './routes/authRoutes.js'
|
import { registerAuthRoutes } from './routes/authRoutes.js'
|
||||||
import { registerSettingsRoutes } from './routes/settingsRoutes.js'
|
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'
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
@@ -14,6 +16,7 @@ declare module 'fastify' {
|
|||||||
config: Config
|
config: Config
|
||||||
fetchImpl: typeof fetch
|
fetchImpl: typeof fetch
|
||||||
discogsQueue: SerialQueue
|
discogsQueue: SerialQueue
|
||||||
|
sync: SyncManager
|
||||||
}
|
}
|
||||||
interface FastifyRequest {
|
interface FastifyRequest {
|
||||||
user?: import('./auth.js').UserRow
|
user?: import('./auth.js').UserRow
|
||||||
@@ -32,12 +35,14 @@ export async function buildApp(opts: AppOptions): Promise<FastifyInstance> {
|
|||||||
app.decorate('config', opts.config)
|
app.decorate('config', opts.config)
|
||||||
app.decorate('fetchImpl', opts.fetchImpl ?? fetch)
|
app.decorate('fetchImpl', opts.fetchImpl ?? fetch)
|
||||||
app.decorate('discogsQueue', new SerialQueue({ minIntervalMs: 1100 }))
|
app.decorate('discogsQueue', new SerialQueue({ minIntervalMs: 1100 }))
|
||||||
|
app.decorate('sync', new SyncManager(opts.db, opts.fetchImpl ?? fetch))
|
||||||
|
|
||||||
await app.register(cookie)
|
await app.register(cookie)
|
||||||
await registerAuthRoutes(app)
|
await registerAuthRoutes(app)
|
||||||
await registerSettingsRoutes(app)
|
await registerSettingsRoutes(app)
|
||||||
await registerLookupRoutes(app)
|
await registerLookupRoutes(app)
|
||||||
await registerCollectionRoutes(app)
|
await registerCollectionRoutes(app)
|
||||||
|
await registerLibraryRoutes(app)
|
||||||
|
|
||||||
app.get('/api/health', async () => ({ ok: true }))
|
app.get('/api/health', async () => ({ ok: true }))
|
||||||
return app
|
return app
|
||||||
|
|||||||
80
server/src/routes/libraryRoutes.ts
Normal file
80
server/src/routes/libraryRoutes.ts
Normal 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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -94,6 +94,14 @@ export async function registerSettingsRoutes(app: FastifyInstance): Promise<void
|
|||||||
userId
|
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
66
server/src/sync.ts
Normal 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
157
server/test/library.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user