diff --git a/server/src/ripstatus.ts b/server/src/ripstatus.ts new file mode 100644 index 0000000..d30b7e7 --- /dev/null +++ b/server/src/ripstatus.ts @@ -0,0 +1,31 @@ +import type { DB } from './db.js' +import { isConfidentMatch } from './matcher.js' + +export type RipStatus = 'ripped' | 'not_ripped' + +/** + * Resolution order per spec: + * 1. manual rip_override (non-null) wins + * 2. else a stored match_link means ripped + * 3. else confident fuzzy match against the user's digital_albums + */ +export function resolveRipStatus(db: DB, userId: number, itemId: number): RipStatus { + const item = db + .prepare('SELECT id, title, artist, rip_override FROM collection_items WHERE id = ? AND user_id = ?') + .get(itemId, userId) as + | { id: number; title: string; artist: string; rip_override: number | null } + | undefined + if (!item) return 'not_ripped' + + if (item.rip_override !== null && item.rip_override !== undefined) { + return item.rip_override === 1 ? 'ripped' : 'not_ripped' + } + + const link = db.prepare('SELECT album_id FROM match_links WHERE item_id = ?').get(itemId) + if (link) return 'ripped' + + const albums = db + .prepare('SELECT title, artist FROM digital_albums WHERE user_id = ?') + .all(userId) as { title: string; artist: string }[] + return albums.some((a) => isConfidentMatch(item, a)) ? 'ripped' : 'not_ripped' +} diff --git a/server/test/ripstatus.test.ts b/server/test/ripstatus.test.ts new file mode 100644 index 0000000..eae2914 --- /dev/null +++ b/server/test/ripstatus.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { openDatabase, type DB } from '../src/db.js' +import { resolveRipStatus } from '../src/ripstatus.js' + +describe('resolveRipStatus', () => { + let db: DB + let itemId: number + + 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() + itemId = 10 + }) + + 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('no album and no override → not_ripped', () => { + expect(resolveRipStatus(db, 1, itemId)).toBe('not_ripped') + }) + + it('confident auto-match → ripped', () => { + addAlbum(1, 'Motion!', 'Cinematic Orchestra') + expect(resolveRipStatus(db, 1, itemId)).toBe('ripped') + }) + + it('same title different artist → not_ripped', () => { + addAlbum(1, 'Motion', 'Massive Attack') + expect(resolveRipStatus(db, 1, itemId)).toBe('not_ripped') + }) + + it('match link → ripped even without fuzzy match', () => { + addAlbum(1, 'Motion (Remastered)', 'The Cinematic Orchestra') + db.prepare('INSERT INTO match_links (user_id, item_id, album_id) VALUES (1, 10, 1)').run() + expect(resolveRipStatus(db, 1, itemId)).toBe('ripped') + }) + + it('manual override wins over everything (both directions)', () => { + addAlbum(1, 'Motion', 'The Cinematic Orchestra') + db.prepare('UPDATE collection_items SET rip_override = 0 WHERE id = 10').run() + expect(resolveRipStatus(db, 1, itemId)).toBe('not_ripped') + + db.prepare('UPDATE collection_items SET rip_override = 1 WHERE id = 10').run() + expect(resolveRipStatus(db, 1, itemId)).toBe('ripped') + + db.prepare('UPDATE collection_items SET rip_override = NULL WHERE id = 10').run() + expect(resolveRipStatus(db, 1, itemId)).toBe('ripped') + }) +})