diff --git a/server/src/ripstatus.ts b/server/src/ripstatus.ts index ba3fffd..4bbd8cd 100644 --- a/server/src/ripstatus.ts +++ b/server/src/ripstatus.ts @@ -62,3 +62,37 @@ export function resolveRipStatusBatch( 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 +} diff --git a/server/src/routes/collectionRoutes.ts b/server/src/routes/collectionRoutes.ts index f3cb5cc..71a7b08 100644 --- a/server/src/routes/collectionRoutes.ts +++ b/server/src/routes/collectionRoutes.ts @@ -1,7 +1,7 @@ import { FastifyInstance } from 'fastify' import type { DB } from '../db.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 { discogsClientFor, discogsErrorStatus } from './lookupRoutes.js' @@ -146,7 +146,12 @@ export async function registerCollectionRoutes(app: FastifyInstance): Promise 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 if (format) filtered = filtered.filter((i) => i.formats.some((f: string) => f.includes(format))) if (ripped === 'ripped' || ripped === 'not_ripped') { @@ -158,6 +163,15 @@ export async function registerCollectionRoutes(app: FastifyInstance): Promise 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 } }) @@ -165,7 +179,7 @@ export async function registerCollectionRoutes(app: FastifyInstance): Promise { diff --git a/server/test/collection.test.ts b/server/test/collection.test.ts index b4f4cc3..1cb81fa 100644 --- a/server/test/collection.test.ts +++ b/server/test/collection.test.ts @@ -212,4 +212,39 @@ describe('collection routes', () => { expect(list.json().items).toHaveLength(0) 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() + }) }) diff --git a/server/test/ripstatus.test.ts b/server/test/ripstatus.test.ts index eae2914..f411710 100644 --- a/server/test/ripstatus.test.ts +++ b/server/test/ripstatus.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest' import { openDatabase, type DB } from '../src/db.js' -import { resolveRipStatus } from '../src/ripstatus.js' +import { resolveRipStatus, findMatchedAlbum } from '../src/ripstatus.js' describe('resolveRipStatus', () => { let db: DB @@ -56,3 +56,42 @@ describe('resolveRipStatus', () => { 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() + }) +})