diff --git a/server/src/subsonic.ts b/server/src/subsonic.ts index 315a0e2..93be9ce 100644 --- a/server/src/subsonic.ts +++ b/server/src/subsonic.ts @@ -55,8 +55,11 @@ export class SubsonicClient { throw new SubsonicError('unreachable', `could not reach ${this.base}`) } if (!res.ok) throw new SubsonicError('unreachable', `HTTP ${res.status} from ${this.base}`) - const body = (await res.json()) as { - 'subsonic-response'?: { status?: string; error?: { code?: number; message?: string } } + let body: any + try { + body = await res.json() + } catch { + throw new SubsonicError('api', 'malformed subsonic response') } const envelope = body['subsonic-response'] if (!envelope) throw new SubsonicError('api', 'malformed subsonic response') diff --git a/server/src/sync.ts b/server/src/sync.ts index 4bb3b85..527e5cf 100644 --- a/server/src/sync.ts +++ b/server/src/sync.ts @@ -40,14 +40,20 @@ export class SyncManager { const client = new SubsonicClient({ ...config, fetchImpl: this.fetchImpl }) try { const albums = await client.getAllAlbums() + const seenIds = new Set(albums.map((a) => a.id)) 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) + const selectAll = this.db.prepare('SELECT id, subsonic_id FROM digital_albums WHERE user_id = ?') + const deleteById = this.db.prepare('DELETE FROM digital_albums WHERE id = ?') + const apply = this.db.transaction(() => { + for (const r of albums) upsert.run(userId, r.id, r.title, r.artist) + for (const row of selectAll.all(userId) as { id: number; subsonic_id: string }[]) { + if (!seenIds.has(row.subsonic_id)) deleteById.run(row.id) + } }) - insertAll(albums) + apply() this.states.set(userId, { status: 'done', error: null, diff --git a/server/test/library.test.ts b/server/test/library.test.ts index fbb9bbd..6139d1b 100644 --- a/server/test/library.test.ts +++ b/server/test/library.test.ts @@ -154,4 +154,53 @@ describe('library sync', () => { expect(state.albums).toBe(503) await app.close() }) + + it('re-sync removes albums that disappeared from subsonic', async () => { + // sync 1 (from settings PUT): 1 album 'Kept Album' + // sync 2 (explicit POST): 1 album 'Only Album' — 'Kept Album' must be gone + let syncCount = 0 + const shrinking = (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')) { + syncCount++ + const album = + syncCount === 1 + ? { id: 1, name: 'Kept Album', artist: 'Artist A' } + : { id: 9, name: 'Only Album', artist: 'Artist B' } + return new Response( + JSON.stringify({ 'subsonic-response': { status: 'ok', albumList2: { album: [album] } } }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + } + return new Response('nope', { status: 404 }) + }) as typeof fetch + const app = await buildTestApp(shrinking) + const cookie = await setupAdmin(app) + await app.inject({ + method: 'PUT', + url: '/api/settings', + ...auth(cookie), + payload: { subsonicUrl: 'http://n.local', subsonicUsername: 'sam', subsonicPassword: 'pass' }, + }) + + const first = await waitForDone(app, cookie) + expect(first.albums).toBe(1) + expect(await app.inject({ method: 'GET', url: '/api/library/albums?q=Kept', ...auth(cookie) }).then((r) => r.json())).toMatchObject({ albums: [expect.objectContaining({ title: 'Kept Album' })] }) + + await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) }) + const second = await waitForDone(app, cookie) + expect(second.albums).toBe(1) + + const stale = await app.inject({ method: 'GET', url: '/api/library/albums?q=Kept', ...auth(cookie) }) + expect(stale.json().albums).toHaveLength(0) // stale album removed + const kept = await app.inject({ method: 'GET', url: '/api/library/albums?q=Only', ...auth(cookie) }) + expect(kept.json().albums).toHaveLength(1) + await app.close() + }) })