feat: matchedAlbum on item detail, onLoan collection filter
This commit is contained in:
@@ -62,3 +62,37 @@ export function resolveRipStatusBatch(
|
|||||||
return albumKeys.has(key) ? 'ripped' : 'not_ripped'
|
return albumKeys.has(key) ? 'ripped' : 'not_ripped'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MatchedAlbum {
|
||||||
|
id: number
|
||||||
|
subsonicId: string
|
||||||
|
lastPlayedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Album behind an item's rip match — link wins, else confident fuzzy match. Independent of rip_override. */
|
||||||
|
export function findMatchedAlbum(db: DB, userId: number, itemId: number): MatchedAlbum | null {
|
||||||
|
const item = db
|
||||||
|
.prepare('SELECT id, title, artist FROM collection_items WHERE id = ? AND user_id = ?')
|
||||||
|
.get(itemId, userId) as { id: number; title: string; artist: string } | undefined
|
||||||
|
if (!item) return null
|
||||||
|
|
||||||
|
const link = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT da.id, da.subsonic_id, da.last_played_at FROM match_links ml
|
||||||
|
JOIN digital_albums da ON da.id = ml.album_id WHERE ml.item_id = ?`
|
||||||
|
)
|
||||||
|
.get(itemId) as { id: number; subsonic_id: string; last_played_at: string | null } | undefined
|
||||||
|
if (link) {
|
||||||
|
return { id: link.id, subsonicId: link.subsonic_id, lastPlayedAt: link.last_played_at }
|
||||||
|
}
|
||||||
|
|
||||||
|
const album = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, subsonic_id, last_played_at, title, artist FROM digital_albums WHERE user_id = ?`
|
||||||
|
)
|
||||||
|
.all(userId) as { id: number; subsonic_id: string; last_played_at: string | null; title: string; artist: string }[]
|
||||||
|
const match = album.find((a) => isConfidentMatch(item, a))
|
||||||
|
return match
|
||||||
|
? { id: match.id, subsonicId: match.subsonic_id, lastPlayedAt: match.last_played_at }
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FastifyInstance } from 'fastify'
|
import { FastifyInstance } from 'fastify'
|
||||||
import type { DB } from '../db.js'
|
import type { DB } from '../db.js'
|
||||||
import { cacheArtwork } from '../artwork.js'
|
import { cacheArtwork } from '../artwork.js'
|
||||||
import { resolveRipStatus, resolveRipStatusBatch, type RipStatus } from '../ripstatus.js'
|
import { resolveRipStatus, resolveRipStatusBatch, findMatchedAlbum, type RipStatus } from '../ripstatus.js'
|
||||||
import { requireAuth } from './authRoutes.js'
|
import { requireAuth } from './authRoutes.js'
|
||||||
import { discogsClientFor, discogsErrorStatus } from './lookupRoutes.js'
|
import { discogsClientFor, discogsErrorStatus } from './lookupRoutes.js'
|
||||||
|
|
||||||
@@ -146,7 +146,12 @@ export async function registerCollectionRoutes(app: FastifyInstance): Promise<vo
|
|||||||
notRipped: items.filter((i) => i.ripStatus === 'not_ripped').length,
|
notRipped: items.filter((i) => i.ripStatus === 'not_ripped').length,
|
||||||
}
|
}
|
||||||
|
|
||||||
const { format, ripped, q } = request.query as { format?: string; ripped?: string; q?: string }
|
const { format, ripped, q, onLoan } = request.query as {
|
||||||
|
format?: string
|
||||||
|
ripped?: string
|
||||||
|
q?: string
|
||||||
|
onLoan?: string
|
||||||
|
}
|
||||||
let filtered = items
|
let filtered = items
|
||||||
if (format) filtered = filtered.filter((i) => i.formats.some((f: string) => f.includes(format)))
|
if (format) filtered = filtered.filter((i) => i.formats.some((f: string) => f.includes(format)))
|
||||||
if (ripped === 'ripped' || ripped === 'not_ripped') {
|
if (ripped === 'ripped' || ripped === 'not_ripped') {
|
||||||
@@ -158,6 +163,15 @@ export async function registerCollectionRoutes(app: FastifyInstance): Promise<vo
|
|||||||
(i) => i.title.toLowerCase().includes(needle) || i.artist.toLowerCase().includes(needle)
|
(i) => i.title.toLowerCase().includes(needle) || i.artist.toLowerCase().includes(needle)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (onLoan === 'true' || onLoan === 'false') {
|
||||||
|
const want = onLoan === 'true'
|
||||||
|
filtered = filtered.filter((i) => {
|
||||||
|
const has = !!db
|
||||||
|
.prepare('SELECT id FROM loans WHERE item_id = ? AND returned_at IS NULL')
|
||||||
|
.get(i.id)
|
||||||
|
return has === want
|
||||||
|
})
|
||||||
|
}
|
||||||
return { items: filtered, counts }
|
return { items: filtered, counts }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -165,7 +179,7 @@ export async function registerCollectionRoutes(app: FastifyInstance): Promise<vo
|
|||||||
const db = request.server.db
|
const db = request.server.db
|
||||||
const row = getItem(db, request.user!.id, Number((request.params as { id: string }).id))
|
const row = getItem(db, request.user!.id, Number((request.params as { id: string }).id))
|
||||||
if (!row) return reply.code(404).send({ error: 'not_found' })
|
if (!row) return reply.code(404).send({ error: 'not_found' })
|
||||||
return rowToItem(db, row)
|
return { ...rowToItem(db, row), matchedAlbum: findMatchedAlbum(db, request.user!.id, row.id) }
|
||||||
})
|
})
|
||||||
|
|
||||||
app.delete('/api/collection/:id', { preHandler: [requireAuth] }, async (request, reply) => {
|
app.delete('/api/collection/:id', { preHandler: [requireAuth] }, async (request, reply) => {
|
||||||
|
|||||||
@@ -212,4 +212,39 @@ describe('collection routes', () => {
|
|||||||
expect(list.json().items).toHaveLength(0)
|
expect(list.json().items).toHaveLength(0)
|
||||||
await app.close()
|
await app.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('detail includes matchedAlbum (null when unmatched)', 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.json().matchedAlbum).toBeNull()
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/library/albums/test-seed',
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { albums: [{ subsonicId: 'alb-9', title: 'Motion', artist: 'The Cinematic Orchestra' }] },
|
||||||
|
})
|
||||||
|
const again = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) })
|
||||||
|
expect(again.json().matchedAlbum).toMatchObject({ subsonicId: 'alb-9', lastPlayedAt: null })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
// KNOWN-RED handoff: the loan route arrives in Task 5 — this test turns
|
||||||
|
// green then. Everything else in this file must pass now.
|
||||||
|
it('list supports onLoan=true/false filter', 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/collection/${id}/loan`, ...auth(cookie), payload: { borrower: 'Bob' } })
|
||||||
|
|
||||||
|
const all = await app.inject({ method: 'GET', url: '/api/collection', ...auth(cookie) })
|
||||||
|
expect(all.json().items).toHaveLength(1)
|
||||||
|
const onLoan = await app.inject({ method: 'GET', url: '/api/collection?onLoan=true', ...auth(cookie) })
|
||||||
|
expect(onLoan.json().items).toHaveLength(1)
|
||||||
|
const notOnLoan = await app.inject({ method: 'GET', url: '/api/collection?onLoan=false', ...auth(cookie) })
|
||||||
|
expect(notOnLoan.json().items).toHaveLength(0)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { openDatabase, type DB } from '../src/db.js'
|
import { openDatabase, type DB } from '../src/db.js'
|
||||||
import { resolveRipStatus } from '../src/ripstatus.js'
|
import { resolveRipStatus, findMatchedAlbum } from '../src/ripstatus.js'
|
||||||
|
|
||||||
describe('resolveRipStatus', () => {
|
describe('resolveRipStatus', () => {
|
||||||
let db: DB
|
let db: DB
|
||||||
@@ -56,3 +56,42 @@ describe('resolveRipStatus', () => {
|
|||||||
expect(resolveRipStatus(db, 1, itemId)).toBe('ripped')
|
expect(resolveRipStatus(db, 1, itemId)).toBe('ripped')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('findMatchedAlbum', () => {
|
||||||
|
let db: DB
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = openDatabase(':memory:')
|
||||||
|
db.prepare("INSERT INTO users (id, username, password_hash, is_admin) VALUES (1, 'sam', 'x', 1)").run()
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO collection_items (id, user_id, discogs_release_id, title, artist) VALUES (10, 1, 100, 'Motion', 'The Cinematic Orchestra')"
|
||||||
|
).run()
|
||||||
|
})
|
||||||
|
|
||||||
|
function addAlbum(id: number, title: string, artist: string) {
|
||||||
|
db.prepare('INSERT INTO digital_albums (id, user_id, subsonic_id, title, artist) VALUES (?, 1, ?, ?, ?)').run(
|
||||||
|
id,
|
||||||
|
`sub-${id}`,
|
||||||
|
title,
|
||||||
|
artist
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns the match-linked album', () => {
|
||||||
|
addAlbum(1, 'Motion (Remastered)', 'The Cinematic Orchestra')
|
||||||
|
db.prepare('INSERT INTO match_links (user_id, item_id, album_id) VALUES (1, 10, 1)').run()
|
||||||
|
const m = findMatchedAlbum(db, 1, 10)
|
||||||
|
expect(m).toMatchObject({ subsonicId: 'sub-1', lastPlayedAt: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the confident fuzzy match', () => {
|
||||||
|
addAlbum(2, 'Motion!', 'Cinematic Orchestra')
|
||||||
|
const m = findMatchedAlbum(db, 1, 10)
|
||||||
|
expect(m).toMatchObject({ subsonicId: 'sub-2' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when nothing matches or the item is missing', () => {
|
||||||
|
expect(findMatchedAlbum(db, 1, 10)).toBeNull()
|
||||||
|
expect(findMatchedAlbum(db, 1, 9999)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user