feat: collection stats route
This commit is contained in:
@@ -16,6 +16,7 @@ import { registerCollectionRoutes } from './routes/collectionRoutes.js'
|
|||||||
import { registerLibraryRoutes } from './routes/libraryRoutes.js'
|
import { registerLibraryRoutes } from './routes/libraryRoutes.js'
|
||||||
import { registerStreamRoutes } from './routes/streamRoutes.js'
|
import { registerStreamRoutes } from './routes/streamRoutes.js'
|
||||||
import { registerLoanRoutes } from './routes/loanRoutes.js'
|
import { registerLoanRoutes } from './routes/loanRoutes.js'
|
||||||
|
import { registerStatsRoutes } from './routes/statsRoutes.js'
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
@@ -56,6 +57,7 @@ export async function buildApp(opts: AppOptions): Promise<FastifyInstance> {
|
|||||||
await registerLibraryRoutes(app)
|
await registerLibraryRoutes(app)
|
||||||
await registerStreamRoutes(app)
|
await registerStreamRoutes(app)
|
||||||
await registerLoanRoutes(app)
|
await registerLoanRoutes(app)
|
||||||
|
await registerStatsRoutes(app)
|
||||||
|
|
||||||
// artwork cache (always available)
|
// artwork cache (always available)
|
||||||
await app.register(fastifyStatic, {
|
await app.register(fastifyStatic, {
|
||||||
|
|||||||
66
server/src/routes/statsRoutes.ts
Normal file
66
server/src/routes/statsRoutes.ts
Normal file
@@ -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<string, number>()
|
||||||
|
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<void> {
|
||||||
|
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<string, number>()
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
47
server/test/stats.test.ts
Normal file
47
server/test/stats.test.ts
Normal file
@@ -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<typeof openDatabase>) {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user