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 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
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user