diff --git a/server/src/app.ts b/server/src/app.ts index b4e444f..3d29c68 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -6,6 +6,7 @@ import { SerialQueue } from './queue.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' declare module 'fastify' { interface FastifyInstance { @@ -36,6 +37,7 @@ export async function buildApp(opts: AppOptions): Promise { await registerAuthRoutes(app) await registerSettingsRoutes(app) await registerLookupRoutes(app) + await registerCollectionRoutes(app) app.get('/api/health', async () => ({ ok: true })) return app diff --git a/server/src/routes/collectionRoutes.ts b/server/src/routes/collectionRoutes.ts new file mode 100644 index 0000000..5ac66ee --- /dev/null +++ b/server/src/routes/collectionRoutes.ts @@ -0,0 +1,211 @@ +import { FastifyInstance } from 'fastify' +import type { DB } from '../db.js' +import { cacheArtwork } from '../artwork.js' +import { resolveRipStatus } from '../ripstatus.js' +import { requireAuth } from './authRoutes.js' +import { discogsClientFor, discogsErrorStatus } from './lookupRoutes.js' + +interface ItemRow { + id: number + user_id: number + discogs_release_id: number + title: string + artist: string + year: number | null + formats: string + genres: string + labels: string + tracklist: string + catno: string | null + country: string | null + cover_url: string | null + local_artwork_path: string | null + barcodes: string + rip_override: number | null + date_added: string +} + +export function rowToItem(db: DB, row: ItemRow) { + return { + id: row.id, + discogsReleaseId: row.discogs_release_id, + title: row.title, + artist: row.artist, + year: row.year, + formats: JSON.parse(row.formats), + genres: JSON.parse(row.genres), + labels: JSON.parse(row.labels), + tracklist: JSON.parse(row.tracklist), + catno: row.catno, + country: row.country, + artworkUrl: row.local_artwork_path ? `/artwork/${row.local_artwork_path}` : row.cover_url, + barcodes: JSON.parse(row.barcodes), + dateAdded: row.date_added, + ripOverride: row.rip_override === null ? null : row.rip_override === 1, + ripStatus: resolveRipStatus(db, row.user_id, row.id), + } +} + +function getItem(db: DB, userId: number, id: number): ItemRow | undefined { + return db + .prepare('SELECT * FROM collection_items WHERE id = ? AND user_id = ?') + .get(id, userId) as ItemRow | undefined +} + +export async function registerCollectionRoutes(app: FastifyInstance): Promise { + app.post('/api/collection', { preHandler: [requireAuth] }, async (request, reply) => { + const client = discogsClientFor(request) + if (!client) return reply.code(409).send({ error: 'no_discogs_token' }) + const { releaseId, barcode, matchAlbumId } = (request.body ?? {}) as { + releaseId?: number + barcode?: string + matchAlbumId?: number + } + if (typeof releaseId !== 'number') return reply.code(400).send({ error: 'invalid_input' }) + + let release + try { + release = await client.getRelease(releaseId) + } catch (err) { + const { code, body } = discogsErrorStatus(err) + return reply.code(code).send(body) + } + + const db = request.server.db + const userId = request.user!.id + const artworkFile = release.coverUrl + ? await cacheArtwork(request.server.config.artworkDir, release.coverUrl, request.server.fetchImpl) + : null + const barcodes = barcode && !release.barcodes.includes(barcode) ? [...release.barcodes, barcode] : release.barcodes + + let itemId: number + try { + const info = db + .prepare( + `INSERT INTO collection_items + (user_id, discogs_release_id, title, artist, year, formats, genres, labels, tracklist, catno, country, cover_url, local_artwork_path, barcodes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + userId, + release.id, + release.title, + release.artist, + release.year, + JSON.stringify(release.formats), + JSON.stringify(release.genres), + JSON.stringify(release.labels), + JSON.stringify(release.tracklist), + release.catno, + release.country, + release.coverUrl, + artworkFile, + JSON.stringify(barcodes) + ) + itemId = Number(info.lastInsertRowid) + } catch (err: any) { + if (String(err.message).includes('UNIQUE constraint failed')) { + return reply.code(409).send({ error: 'duplicate' }) + } + throw err + } + + if (matchAlbumId != null) { + const album = db + .prepare('SELECT id FROM digital_albums WHERE id = ? AND user_id = ?') + .get(matchAlbumId, userId) + if (!album) return reply.code(404).send({ error: 'album_not_found' }) + db.prepare('INSERT INTO match_links (user_id, item_id, album_id) VALUES (?, ?, ?)').run( + userId, + itemId, + matchAlbumId + ) + } + + const row = getItem(db, userId, itemId) as ItemRow + return reply.code(200).send(rowToItem(db, row)) + }) + + app.get('/api/collection', { preHandler: [requireAuth] }, async (request) => { + const db = request.server.db + const userId = request.user!.id + const rows = db + .prepare('SELECT * FROM collection_items WHERE user_id = ? ORDER BY date_added DESC, id DESC') + .all(userId) as ItemRow[] + const items = rows.map((row) => rowToItem(db, row)) + + const counts = { + total: items.length, + ripped: items.filter((i) => i.ripStatus === 'ripped').length, + notRipped: items.filter((i) => i.ripStatus === 'not_ripped').length, + } + + const { format, ripped, q } = request.query as { format?: string; ripped?: string; q?: string } + let filtered = items + if (format) filtered = filtered.filter((i) => i.formats.some((f: string) => f.includes(format))) + if (ripped === 'ripped' || ripped === 'not_ripped') { + filtered = filtered.filter((i) => i.ripStatus === ripped) + } + if (q) { + const needle = q.toLowerCase() + filtered = filtered.filter( + (i) => i.title.toLowerCase().includes(needle) || i.artist.toLowerCase().includes(needle) + ) + } + return { items: filtered, counts } + }) + + app.get('/api/collection/:id', { preHandler: [requireAuth] }, async (request, reply) => { + const db = request.server.db + const row = getItem(db, request.user!.id, Number((request.params as { id: string }).id)) + if (!row) return reply.code(404).send({ error: 'not_found' }) + return rowToItem(db, row) + }) + + app.delete('/api/collection/:id', { preHandler: [requireAuth] }, async (request, reply) => { + const db = request.server.db + const info = db + .prepare('DELETE FROM collection_items WHERE id = ? AND user_id = ?') + .run(Number((request.params as { id: string }).id), request.user!.id) + if (info.changes === 0) return reply.code(404).send({ error: 'not_found' }) + return { ok: true } + }) + + app.patch('/api/collection/:id/rip', { preHandler: [requireAuth] }, async (request, reply) => { + const db = request.server.db + const userId = request.user!.id + const id = Number((request.params as { id: string }).id) + const { ripped } = (request.body ?? {}) as { ripped?: boolean | null } + if (ripped !== true && ripped !== false && ripped !== null) { + return reply.code(400).send({ error: 'invalid_input' }) + } + const value = ripped === null ? null : ripped ? 1 : 0 + const info = db + .prepare('UPDATE collection_items SET rip_override = ? WHERE id = ? AND user_id = ?') + .run(value, id, userId) + if (info.changes === 0) return reply.code(404).send({ error: 'not_found' }) + return rowToItem(db, getItem(db, userId, id) as ItemRow) + }) + + app.post('/api/collection/:id/match', { preHandler: [requireAuth] }, async (request, reply) => { + const db = request.server.db + const userId = request.user!.id + const id = Number((request.params as { id: string }).id) + if (!getItem(db, userId, id)) return reply.code(404).send({ error: 'not_found' }) + const { albumId } = (request.body ?? {}) as { albumId?: number | null } + + if (albumId === null || albumId === undefined) { + db.prepare('DELETE FROM match_links WHERE user_id = ? AND item_id = ?').run(userId, id) + } else { + const album = db + .prepare('SELECT id FROM digital_albums WHERE id = ? AND user_id = ?') + .get(albumId, userId) + if (!album) return reply.code(404).send({ error: 'album_not_found' }) + db.prepare( + `INSERT INTO match_links (user_id, item_id, album_id) VALUES (?, ?, ?) + ON CONFLICT(user_id, item_id) DO UPDATE SET album_id = excluded.album_id` + ).run(userId, id, albumId) + } + return rowToItem(db, getItem(db, userId, id) as ItemRow) + }) +} diff --git a/server/test/collection.test.ts b/server/test/collection.test.ts new file mode 100644 index 0000000..b4f4cc3 --- /dev/null +++ b/server/test/collection.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect } from 'vitest' +import { buildTestApp, setupAdmin, auth, getCookie } from './helpers.js' +import { discogsReleaseFixture } from './fixtures.js' + +function discogsStub(): typeof fetch { + return (async (input: any) => { + const url = String(input) + if (url.includes('/releases/1001')) { + return new Response(JSON.stringify(discogsReleaseFixture), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + if (url.includes('img.discogs.com')) { + return new Response(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]), { + status: 200, + headers: { 'content-type': 'image/jpeg' }, + }) + } + return new Response('nope', { status: 404 }) + }) as typeof fetch +} + +async function appWithToken() { + const app = await buildTestApp(discogsStub()) + const cookie = await setupAdmin(app) + await app.inject({ + method: 'PUT', + url: '/api/settings', + ...auth(cookie), + payload: { discogsToken: 'testtoken' }, + }) + return { app, cookie } +} + +describe('collection routes', () => { + it('adds a release from discogs, returns item with artwork and rip status', async () => { + const { app, cookie } = await appWithToken() + const res = await app.inject({ + method: 'POST', + url: '/api/collection', + ...auth(cookie), + payload: { releaseId: 1001, barcode: '5021592210629' }, + }) + expect(res.statusCode).toBe(200) + const item = res.json() + expect(item).toMatchObject({ + discogsReleaseId: 1001, + title: 'Motion', + artist: 'The Cinematic Orchestra', + year: 1999, + formats: ['CD'], + labels: ['Ninja Tune'], + catno: 'ZENCD012', + barcodes: ['5021592210629'], + ripOverride: null, + ripStatus: 'not_ripped', + }) + expect(item.artworkUrl).toMatch(/^\/artwork\/[0-9a-f]{64}\.jpg$/) + await app.close() + }) + + it('rejects duplicate add with 409', async () => { + const { app, cookie } = await appWithToken() + await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } }) + const again = await app.inject({ + method: 'POST', + url: '/api/collection', + ...auth(cookie), + payload: { releaseId: 1001 }, + }) + expect(again.statusCode).toBe(409) + expect(again.json()).toEqual({ error: 'duplicate' }) + await app.close() + }) + + it('lists items with counts and filters (format, ripped, q)', async () => { + const { app, cookie } = await appWithToken() + await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } }) + + const all = await app.inject({ method: 'GET', url: '/api/collection', ...auth(cookie) }) + expect(all.json().counts).toEqual({ total: 1, ripped: 0, notRipped: 1 }) + expect(all.json().items).toHaveLength(1) + + const cd = await app.inject({ method: 'GET', url: '/api/collection?format=CD', ...auth(cookie) }) + expect(cd.json().items).toHaveLength(1) + const vinyl = await app.inject({ method: 'GET', url: '/api/collection?format=Vinyl', ...auth(cookie) }) + expect(vinyl.json().items).toHaveLength(0) + + const ripped = await app.inject({ method: 'GET', url: '/api/collection?ripped=ripped', ...auth(cookie) }) + expect(ripped.json().items).toHaveLength(0) + const notRipped = await app.inject({ method: 'GET', url: '/api/collection?ripped=not_ripped', ...auth(cookie) }) + expect(notRipped.json().items).toHaveLength(1) + + const q = await app.inject({ method: 'GET', url: '/api/collection?q=motio', ...auth(cookie) }) + expect(q.json().items).toHaveLength(1) + const qMiss = await app.inject({ method: 'GET', url: '/api/collection?q=zzz', ...auth(cookie) }) + expect(qMiss.json().items).toHaveLength(0) + await app.close() + }) + + it('detail, rip override, match link, re-match search, delete', async () => { + const { app, cookie } = await appWithToken() + const added = await app.inject({ + method: 'POST', + url: '/api/collection', + ...auth(cookie), + payload: { releaseId: 1001 }, + }) + const id = added.json().id as number + + const detail = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) }) + expect(detail.statusCode).toBe(200) + expect(detail.json().tracklist).toEqual([ + { position: '1', title: 'Overture' }, + { position: '2', title: 'Theme de Yoyo' }, + ]) + + // manual rip override + const rip = await app.inject({ + method: 'PATCH', + url: `/api/collection/${id}/rip`, + ...auth(cookie), + payload: { ripped: true }, + }) + expect(rip.json().ripOverride).toBe(true) + expect(rip.json().ripStatus).toBe('ripped') + const clear = await app.inject({ + method: 'PATCH', + url: `/api/collection/${id}/rip`, + ...auth(cookie), + payload: { ripped: null }, + }) + expect(clear.json().ripOverride).toBeNull() + expect(clear.json().ripStatus).toBe('not_ripped') + + // match link (simulates confirmed ambiguous match) + await app.inject({ + method: 'POST', + url: '/api/library/albums/test-seed', + ...auth(cookie), + payload: { albums: [{ subsonicId: 'a1', title: 'Motion (Remaster)', artist: 'The Cinematic Orchestra' }] }, + }) + const albums = await app.inject({ method: 'GET', url: '/api/library/albums?q=Motion', ...auth(cookie) }) + const albumId = albums.json().albums[0].id as number + const linked = await app.inject({ + method: 'POST', + url: `/api/collection/${id}/match`, + ...auth(cookie), + payload: { albumId }, + }) + expect(linked.json().ripStatus).toBe('ripped') + + // clear link + const unlinked = await app.inject({ + method: 'POST', + url: `/api/collection/${id}/match`, + ...auth(cookie), + payload: { albumId: null }, + }) + expect(unlinked.json().ripStatus).toBe('not_ripped') + + const del = await app.inject({ method: 'DELETE', url: `/api/collection/${id}`, ...auth(cookie) }) + expect(del.statusCode).toBe(200) + const after = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) }) + expect(after.statusCode).toBe(404) + await app.close() + }) + + it('rejects match to another users album', async () => { + const { app, cookie } = await appWithToken() + const added = await app.inject({ + method: 'POST', + url: '/api/collection', + ...auth(cookie), + payload: { releaseId: 1001 }, + }) + const id = added.json().id as number + await app.inject({ + method: 'POST', + url: '/api/library/albums/test-seed', + ...auth(cookie), + payload: { albums: [{ subsonicId: 'a1', title: 'X', artist: 'Y' }] }, + }) + // album id 99 does not exist for this user + const res = await app.inject({ + method: 'POST', + url: `/api/collection/${id}/match`, + ...auth(cookie), + payload: { albumId: 99 }, + }) + expect(res.statusCode).toBe(404) + await app.close() + }) + + it('second user cannot see first users items', async () => { + const { app, cookie } = await appWithToken() + await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } }) + await app.inject({ + method: 'POST', + url: '/api/users', + ...auth(cookie), + payload: { username: 'bob', password: 'bobpass123' }, + }) + const bobLogin = await app.inject({ + method: 'POST', + url: '/api/login', + payload: { username: 'bob', password: 'bobpass123' }, + }) + const bobCookie = getCookie(bobLogin) + const list = await app.inject({ method: 'GET', url: '/api/collection', ...auth(bobCookie) }) + expect(list.json().items).toHaveLength(0) + await app.close() + }) +})