1
0

feat: collection CRUD with artwork caching, rip override and match links

This commit is contained in:
2026-08-29 17:19:17 +02:00
parent ded4e17849
commit 566a9bfc0d
3 changed files with 428 additions and 0 deletions

View File

@@ -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<FastifyInstance> {
await registerAuthRoutes(app)
await registerSettingsRoutes(app)
await registerLookupRoutes(app)
await registerCollectionRoutes(app)
app.get('/api/health', async () => ({ ok: true }))
return app

View File

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

View File

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