feat: collection CRUD with artwork caching, rip override and match links
This commit is contained in:
@@ -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
|
||||
|
||||
211
server/src/routes/collectionRoutes.ts
Normal file
211
server/src/routes/collectionRoutes.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user