diff --git a/server/src/app.ts b/server/src/app.ts index 154a05e..7987c62 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -16,6 +16,7 @@ import { registerCollectionRoutes } from './routes/collectionRoutes.js' import { registerLibraryRoutes } from './routes/libraryRoutes.js' import { registerStreamRoutes } from './routes/streamRoutes.js' import { registerLoanRoutes } from './routes/loanRoutes.js' +import { registerStatsRoutes } from './routes/statsRoutes.js' declare module 'fastify' { interface FastifyInstance { @@ -56,6 +57,7 @@ export async function buildApp(opts: AppOptions): Promise { await registerLibraryRoutes(app) await registerStreamRoutes(app) await registerLoanRoutes(app) + await registerStatsRoutes(app) // artwork cache (always available) await app.register(fastifyStatic, { diff --git a/server/src/routes/statsRoutes.ts b/server/src/routes/statsRoutes.ts new file mode 100644 index 0000000..9e57cab --- /dev/null +++ b/server/src/routes/statsRoutes.ts @@ -0,0 +1,66 @@ +import { FastifyInstance } from 'fastify' +import { requireAuth } from './authRoutes.js' +import { resolveRipStatusBatch } from '../ripstatus.js' + +function countBy(values: string[]): { name: string; count: number }[] { + const map = new Map() + for (const v of values) { + if (!v) continue + map.set(v, (map.get(v) ?? 0) + 1) + } + return [...map.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count) +} + +export async function registerStatsRoutes(app: FastifyInstance): Promise { + app.get('/api/stats', { preHandler: [requireAuth] }, async (request) => { + const db = request.server.db + const userId = request.user!.id + const items = db + .prepare('SELECT id, title, artist, year, formats, genres, date_added, rip_override FROM collection_items WHERE user_id = ?') + .all(userId) as { + id: number + title: string + artist: string + year: number | null + formats: string + genres: string + date_added: string + rip_override: number | null + }[] + + const statuses = resolveRipStatusBatch(db, userId, items) + let ripped = 0 + const formats: string[] = [] + const genres: string[] = [] + const months = new Map() + items.forEach((item, i) => { + if (statuses[i] === 'ripped') ripped++ + for (const f of JSON.parse(item.formats) as string[]) formats.push(f) + for (const g of JSON.parse(item.genres) as string[]) genres.push(g) + const month = (item.date_added ?? '').slice(0, 7) + if (/^\d{4}-\d{2}$/.test(month)) months.set(month, (months.get(month) ?? 0) + 1) + }) + + const onLoan = ( + db.prepare('SELECT COUNT(*) AS n FROM loans WHERE user_id = ? AND returned_at IS NULL').get(userId) as { n: number } + ).n + + const addedByMonth: { month: string; count: number }[] = [] + const now = new Date() + for (let i = 11; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1) + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` + addedByMonth.push({ month: key, count: months.get(key) ?? 0 }) + } + + return { + totals: { items: items.length, ripped, notRipped: items.length - ripped, onLoan }, + ripRatio: items.length === 0 ? 0 : ripped / items.length, + formats: countBy(formats), + decades: countBy(items.map((i) => (i.year ? `${Math.floor(i.year / 10) * 10}s` : ''))), + topGenres: countBy(genres).slice(0, 10), + topArtists: countBy(items.map((i) => i.artist)).slice(0, 10), + addedByMonth, + } + }) +} diff --git a/server/test/stats.test.ts b/server/test/stats.test.ts new file mode 100644 index 0000000..81e7ed8 --- /dev/null +++ b/server/test/stats.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { openDatabase } from '../src/db.js' +import { buildTestAppWithDb, setupAdmin, auth } from './helpers.js' + +function seedItems(db: ReturnType) { + const ins = db.prepare( + `INSERT INTO collection_items (user_id, discogs_release_id, title, artist, year, formats, genres, date_added, rip_override) + VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + ins.run(1, 'Motion', 'The Cinematic Orchestra', 1999, JSON.stringify(['CD']), JSON.stringify(['Electronic']), '2026-08-01 10:00:00', null) + ins.run(2, 'Blue Lines', 'Massive Attack', 1991, JSON.stringify(['Vinyl']), JSON.stringify(['Electronic']), '2026-08-15 10:00:00', 1) + ins.run(3, 'Mezzanine', 'Massive Attack', 1998, JSON.stringify(['CD']), JSON.stringify(['Downtempo', 'Electronic']), '2026-08-20 10:00:00', null) + ins.run(4, 'Dummy', 'Portishead', 1994, JSON.stringify(['CD', 'Album']), JSON.stringify(['Downtempo']), '2025-09-01 10:00:00', null) + db.prepare("INSERT INTO loans (user_id, item_id, borrower) VALUES (1, 2, 'Bob')").run() +} + +describe('GET /api/stats', () => { + it('aggregates totals, formats, genres, artists, months, ratio, loans', async () => { + const db = openDatabase(':memory:') + const app = await buildTestAppWithDb(db) + const cookie = await setupAdmin(app) + // seed after setup so user_id 1 (admin) exists and FKs hold + seedItems(db) + const res = await app.inject({ method: 'GET', url: '/api/stats', ...auth(cookie) }) + expect(res.statusCode).toBe(200) + const s = res.json() + // only Blue Lines has rip_override=1; no digital albums/match_links → ripped=1, notRipped=3 + expect(s.totals).toEqual({ items: 4, ripped: 1, notRipped: 3, onLoan: 1 }) + expect(s.ripRatio).toBeCloseTo(0.25) + expect(s.formats).toEqual([ + { name: 'CD', count: 3 }, + { name: 'Vinyl', count: 1 }, + { name: 'Album', count: 1 }, + ]) + expect(s.topGenres.slice(0, 2)).toEqual([ + { name: 'Electronic', count: 3 }, + { name: 'Downtempo', count: 2 }, + ]) + // Massive Attack (2) leads; Cinematic Orchestra and Portishead both 1 → assert lead + total only + expect(s.topArtists).toHaveLength(3) + expect(s.topArtists[0]).toEqual({ name: 'Massive Attack', count: 2 }) + // 2026-08 items: Motion, Blue Lines, Mezzanine (Dummy 2025-09 is outside the 12-month window) + const aug = s.addedByMonth.find((m: { month: string }) => m.month === '2026-08') + expect(aug).toEqual({ month: '2026-08', count: 3 }) + await app.close() + }) +})