diff --git a/web/src/app/api/lastfm/artist-plays/route.test.ts b/web/src/app/api/lastfm/artist-plays/route.test.ts new file mode 100644 index 0000000..9c9a391 --- /dev/null +++ b/web/src/app/api/lastfm/artist-plays/route.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; +import { prisma } from "@/lib/db"; +import { encryptSecret } from "@/lib/crypto"; +import { GET } from "./route"; + +afterEach(() => vi.restoreAllMocks()); + +beforeAll(() => { + process.env.LYRA_SECRET_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; +}); + +function req(qs = "") { + return new Request(`http://localhost/api/lastfm/artist-plays${qs}`); +} + +async function seedConfig() { + await prisma.config.create({ data: { key: "lastfm.username", value: "jonathan", secret: false } }); + await prisma.config.create({ data: { key: "lastfm.api_key", value: encryptSecret("real-key"), secret: true } }); +} + +function mockLfmArtistTracks(tracks: unknown[], totalPages = 1) { + vi.spyOn(global, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + artisttracks: { track: tracks, "@attr": { totalPages: String(totalPages), page: "1" } }, + }), + { status: 200 }, + ), + ); +} + +describe("GET /api/lastfm/artist-plays", () => { + it("400s when Last.fm creds are unset", async () => { + const res = await GET(req("?artist=Michael+Jackson")); + expect(res.status).toBe(400); + }); + + it("400s when artist param is missing or blank", async () => { + await seedConfig(); + const missing = await GET(req()); + expect(missing.status).toBe(400); + const blank = await GET(req("?artist=%20")); + expect(blank.status).toBe(400); + }); + + it("returns the tally on the happy path", async () => { + await seedConfig(); + mockLfmArtistTracks([ + { name: "Billie Jean", album: { "#text": "Thriller" } }, + { name: "Billie Jean", album: { "#text": "Thriller" } }, + { name: "Beat It", album: { "#text": "Thriller" } }, + ]); + const res = await GET(req("?artist=Michael+Jackson")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ + topTracks: [ + { name: "Billie Jean", plays: 2 }, + { name: "Beat It", plays: 1 }, + ], + topAlbums: [{ name: "Thriller", plays: 3 }], + sampled: false, + }); + }); + + it("502s when Last.fm throws", async () => { + await seedConfig(); + vi.spyOn(global, "fetch").mockResolvedValue( + new Response(JSON.stringify({ error: 10, message: "Invalid API key" }), { status: 200 }), + ); + const res = await GET(req("?artist=Michael+Jackson")); + expect(res.status).toBe(502); + }); +}); diff --git a/web/src/app/api/lastfm/artist-plays/route.ts b/web/src/app/api/lastfm/artist-plays/route.ts new file mode 100644 index 0000000..20ddf3f --- /dev/null +++ b/web/src/app/api/lastfm/artist-plays/route.ts @@ -0,0 +1,30 @@ +import { prisma } from "@/lib/db"; +import { decryptSecret } from "@/lib/crypto"; +import { artistPlays } from "@/lib/lastfm"; + +async function getCreds(): Promise<{ username: string; apiKey: string } | null> { + const rows = await prisma.config.findMany({ where: { key: { in: ["lastfm.username", "lastfm.api_key"] } } }); + const byKey = new Map(rows.map((r) => [r.key, r])); + const username = byKey.get("lastfm.username")?.value ?? ""; + const keyRow = byKey.get("lastfm.api_key"); + const apiKey = keyRow ? (keyRow.secret ? decryptSecret(keyRow.value) : keyRow.value) : ""; + if (!username || !apiKey) return null; + return { username, apiKey }; +} + +export async function GET(request: Request) { + const creds = await getCreds(); + if (!creds) return Response.json({ error: "Last.fm not configured" }, { status: 400 }); + + const artist = new URL(request.url).searchParams.get("artist")?.trim(); + if (!artist) return Response.json({ error: "artist is required" }, { status: 400 }); + + let plays; + try { + plays = await artistPlays(creds.username, artist, creds.apiKey); + } catch (e) { + return Response.json({ error: e instanceof Error ? e.message : "Last.fm request failed" }, { status: 502 }); + } + + return Response.json(plays); +} diff --git a/web/src/lib/lastfm.test.ts b/web/src/lib/lastfm.test.ts index 860ddae..e170c90 100644 --- a/web/src/lib/lastfm.test.ts +++ b/web/src/lib/lastfm.test.ts @@ -1,8 +1,12 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { topArtists, topAlbums } from "./lastfm"; +import { topArtists, topAlbums, artistPlays } from "./lastfm"; afterEach(() => vi.restoreAllMocks()); +function jsonResponse(data: unknown) { + return Promise.resolve(new Response(JSON.stringify(data), { status: 200 })); +} + describe("topArtists", () => { it("parses the nested shape and coerces a single artist object to an array", async () => { vi.spyOn(global, "fetch").mockResolvedValue( @@ -113,3 +117,51 @@ describe("topAlbums", () => { ); }); }); + +describe("artistPlays", () => { + it("tallies songs and albums across pages, skips empty album text, and coerces a single-object track", async () => { + const page1 = { + artisttracks: { + track: [ + { name: "Billie Jean", album: { "#text": "Thriller" } }, + { name: "Beat It", album: { "#text": "Thriller" } }, + { name: "Billie Jean", album: { "#text": "Thriller" } }, + ], + "@attr": { user: "jonathan", page: "1", perPage: "50", totalPages: "2", total: "4", artist: "Michael Jackson" }, + }, + }; + const page2 = { + artisttracks: { + // single object, not an array - must be coerced + track: { name: "Billie Jean", album: { "#text": "" } }, + "@attr": { user: "jonathan", page: "2", perPage: "50", totalPages: "2", total: "4", artist: "Michael Jackson" }, + }, + }; + const fetchMock = vi.fn().mockReturnValueOnce(jsonResponse(page1)).mockReturnValueOnce(jsonResponse(page2)); + vi.stubGlobal("fetch", fetchMock); + + const out = await artistPlays("jonathan", "Michael Jackson", "key"); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(out.topTracks[0]).toEqual({ name: "Billie Jean", plays: 3 }); + expect(out.topTracks[1]).toEqual({ name: "Beat It", plays: 1 }); + expect(out.topAlbums).toEqual([{ name: "Thriller", plays: 3 }]); + expect(out.sampled).toBe(false); + }); + + it("sets sampled=true and only fetches page 1 when totalPages exceeds maxPages", async () => { + const page1 = { + artisttracks: { + track: [{ name: "Billie Jean", album: { "#text": "Thriller" } }], + "@attr": { user: "jonathan", page: "1", perPage: "50", totalPages: "5", total: "250", artist: "Michael Jackson" }, + }, + }; + const fetchMock = vi.fn().mockReturnValueOnce(jsonResponse(page1)); + vi.stubGlobal("fetch", fetchMock); + + const out = await artistPlays("jonathan", "Michael Jackson", "key", { maxPages: 1 }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(out.sampled).toBe(true); + }); +}); diff --git a/web/src/lib/lastfm.ts b/web/src/lib/lastfm.ts index 5bb7ed9..8e29f07 100644 --- a/web/src/lib/lastfm.ts +++ b/web/src/lib/lastfm.ts @@ -43,6 +43,52 @@ export async function topArtists(user: string, apiKey: string, { period, page, l return { items, totalPages, page: respPage }; } +export type PlayCount = { name: string; plays: number }; +export type ArtistPlays = { topTracks: PlayCount[]; topAlbums: PlayCount[]; sampled: boolean }; + +function topN(tally: Map, n: number): PlayCount[] { + return Array.from(tally, ([name, plays]) => ({ name, plays })) + .sort((a, b) => b.plays - a.plays || a.name.localeCompare(b.name)) + .slice(0, n); +} + +export async function artistPlays( + user: string, + artist: string, + apiKey: string, + opts: { maxPages?: number } = {}, +): Promise { + const maxPages = opts.maxPages ?? 20; + + const page1 = await lfmGet("user.getArtistTracks", apiKey, { user, artist, page: "1" }); + const totalPages = Number(page1.artisttracks?.["@attr"]?.totalPages) || 1; + const sampled = totalPages > maxPages; + const lastPage = Math.min(totalPages, maxPages); + + const pages = [page1]; + if (lastPage > 1) { + const rest = await Promise.all( + Array.from({ length: lastPage - 1 }, (_, i) => i + 2).map((page) => + lfmGet("user.getArtistTracks", apiKey, { user, artist, page: String(page) }), + ), + ); + pages.push(...rest); + } + + const songs = new Map(); + const albums = new Map(); + for (const data of pages) { + for (const track of toArray(data.artisttracks?.track)) { + const name = track?.name ?? ""; + songs.set(name, (songs.get(name) ?? 0) + 1); + const albumName = track?.album?.["#text"]; + if (albumName) albums.set(albumName, (albums.get(albumName) ?? 0) + 1); + } + } + + return { topTracks: topN(songs, 10), topAlbums: topN(albums, 10), sampled }; +} + export async function topAlbums(user: string, apiKey: string, { period, page, limit }: LfmQuery): Promise> { const data = await lfmGet("user.gettopalbums", apiKey, { user,