diff --git a/web/src/app/api/lastfm/artist-plays/route.test.ts b/web/src/app/api/lastfm/artist-plays/route.test.ts index 9c9a391..e30d43f 100644 --- a/web/src/app/api/lastfm/artist-plays/route.test.ts +++ b/web/src/app/api/lastfm/artist-plays/route.test.ts @@ -18,15 +18,13 @@ async function seedConfig() { 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 }, - ), - ); +function mockTopEndpoints(tracks: unknown[], albums: unknown[]) { + vi.spyOn(global, "fetch").mockImplementation((url: any) => { + const body = String(url).includes("gettoptracks") + ? { toptracks: { track: tracks, "@attr": { totalPages: "1", page: "1" } } } + : { topalbums: { album: albums, "@attr": { totalPages: "1", page: "1" } } }; + return Promise.resolve(new Response(JSON.stringify(body), { status: 200 })); + }); } describe("GET /api/lastfm/artist-plays", () => { @@ -43,22 +41,28 @@ describe("GET /api/lastfm/artist-plays", () => { expect(blank.status).toBe(400); }); - it("returns the tally on the happy path", async () => { + it("returns the artist's top tracks + albums (filtered to the artist, real counts)", async () => { await seedConfig(); - mockLfmArtistTracks([ - { name: "Billie Jean", album: { "#text": "Thriller" } }, - { name: "Billie Jean", album: { "#text": "Thriller" } }, - { name: "Beat It", album: { "#text": "Thriller" } }, - ]); + mockTopEndpoints( + [ + { name: "Billie Jean", playcount: "50", artist: { name: "Michael Jackson" } }, + { name: "Wonderwall", playcount: "99", artist: { name: "Oasis" } }, // different artist — excluded + { name: "Beat It", playcount: "30", artist: { name: "Michael Jackson" } }, + ], + [ + { name: "Thriller", playcount: "120", artist: { name: "Michael Jackson" } }, + { name: "Definitely Maybe", playcount: "80", artist: { name: "Oasis" } }, // excluded + ], + ); 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 }, + { name: "Billie Jean", plays: 50 }, + { name: "Beat It", plays: 30 }, ], - topAlbums: [{ name: "Thriller", plays: 3 }], + topAlbums: [{ name: "Thriller", plays: 120 }], sampled: false, }); }); diff --git a/web/src/lib/lastfm.test.ts b/web/src/lib/lastfm.test.ts index e170c90..b3a7bd4 100644 --- a/web/src/lib/lastfm.test.ts +++ b/web/src/lib/lastfm.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { topArtists, topAlbums, artistPlays } from "./lastfm"; +import { topArtists, topAlbums, topTracks, artistPlays } from "./lastfm"; afterEach(() => vi.restoreAllMocks()); @@ -118,50 +118,62 @@ describe("topAlbums", () => { }); }); +describe("topTracks", () => { + it("parses user.gettoptracks with artist name + playcount", async () => { + vi.stubGlobal("fetch", vi.fn(() => jsonResponse({ toptracks: { + track: [{ name: "Gravity", playcount: "40", artist: { name: "John Mayer" }, mbid: "t1" }], + "@attr": { totalPages: "3", page: "1" } } }))); + const out = await topTracks("jonathan", "key", { period: "overall", page: 1, limit: 50 }); + expect(out.items).toEqual([{ name: "Gravity", artist: "John Mayer", playcount: 40, mbid: "t1" }]); + expect(out.totalPages).toBe(3); + }); +}); + 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); + // Last.fm deprecated user.getArtistTracks; artistPlays now filters the user's supported + // top tracks + top albums to the artist, keeping their real per-item play counts. + function mockTopFetch(tracks: unknown, albums: unknown) { + return vi.fn((url: string) => jsonResponse(String(url).includes("gettoptracks") ? tracks : albums)); + } - const out = await artistPlays("jonathan", "Michael Jackson", "key"); + it("filters the user's top tracks + albums to the artist, sorted by real play counts", async () => { + const tracks = { toptracks: { track: [ + { name: "Gravity", playcount: "40", artist: { name: "John Mayer" } }, + { name: "Circles", playcount: "99", artist: { name: "Post Malone" } }, // other artist — excluded + { name: "Slow Dancing", playcount: "25", artist: { name: "John Mayer" } }, + ], "@attr": { totalPages: "1", page: "1" } } }; + const albums = { topalbums: { album: [ + { name: "Continuum", playcount: "185", artist: { name: "John Mayer" } }, + { name: "Bleeding", playcount: "80", artist: { name: "Post Malone" } }, // excluded + { name: "Sob Rock", playcount: "115", artist: { name: "John Mayer" } }, + ], "@attr": { totalPages: "1", page: "1" } } }; + vi.stubGlobal("fetch", mockTopFetch(tracks, albums)); - 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 }]); + const out = await artistPlays("jonathan", "John Mayer", "key"); + + expect(out.topTracks).toEqual([ + { name: "Gravity", plays: 40 }, + { name: "Slow Dancing", plays: 25 }, + ]); + expect(out.topAlbums).toEqual([ + { name: "Continuum", plays: 185 }, + { name: "Sob Rock", plays: 115 }, + ]); 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)); + it("matches the artist case/space-insensitively and flags sampled past the page cap", async () => { + const tracks = { toptracks: { track: [{ name: "Gravity", playcount: "40", artist: { name: "John Mayer" } }], + "@attr": { totalPages: "9", page: "1" } } }; + const albums = { topalbums: { album: [{ name: "Continuum", playcount: "185", artist: { name: "John Mayer" } }], + "@attr": { totalPages: "1", page: "1" } } }; + const fetchMock = mockTopFetch(tracks, albums); vi.stubGlobal("fetch", fetchMock); - const out = await artistPlays("jonathan", "Michael Jackson", "key", { maxPages: 1 }); + const out = await artistPlays("jonathan", " JOHN mayer ", "key", { maxPages: 1 }); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(out.sampled).toBe(true); + expect(out.topTracks).toEqual([{ name: "Gravity", plays: 40 }]); // trimmed + lowercased match + expect(out.sampled).toBe(true); // tracks totalPages 9 > maxPages 1 + expect(fetchMock).toHaveBeenCalledTimes(2); // one page each of tracks + albums }); }); diff --git a/web/src/lib/lastfm.ts b/web/src/lib/lastfm.ts index 8e29f07..316daad 100644 --- a/web/src/lib/lastfm.ts +++ b/web/src/lib/lastfm.ts @@ -43,50 +43,73 @@ export async function topArtists(user: string, apiKey: string, { period, page, l return { items, totalPages, page: respPage }; } +export type LfmTrack = { name: string; artist: string; playcount: number; mbid: string | null }; + 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); +// Last.fm deprecated user.getArtistTracks (a per-artist scrobble feed) — error 27. Instead we +// read the user's supported all-time top tracks + top albums (which carry real per-item play +// counts) and filter them to the target artist. The clicked artists are the user's TOP artists, +// so their items cluster near the front; a page cap bounds the work. `sampled` means we didn't +// scan every top item, so a low-played item of this artist could be missing. +async function collectForArtist( + target: string, + maxPages: number, + fetchPage: (page: number) => Promise>, +): Promise<{ items: PlayCount[]; sampled: boolean }> { + const first = await fetchPage(1); + const lastPage = Math.min(first.totalPages, maxPages); + const pages = [first]; + if (lastPage > 1) { + const rest = await Promise.all( + Array.from({ length: lastPage - 1 }, (_, i) => i + 2).map((page) => fetchPage(page)), + ); + pages.push(...rest); + } + const matched: PlayCount[] = []; + for (const pg of pages) { + for (const it of pg.items) { + if (it.artist.trim().toLowerCase() === target) matched.push({ name: it.name, plays: it.playcount }); + } + } + matched.sort((a, b) => b.plays - a.plays || a.name.localeCompare(b.name)); + return { items: matched.slice(0, 10), sampled: first.totalPages > maxPages }; } export async function artistPlays( user: string, artist: string, apiKey: string, - opts: { maxPages?: number } = {}, + opts: { maxPages?: number; limit?: number } = {}, ): Promise { - const maxPages = opts.maxPages ?? 20; + const maxPages = opts.maxPages ?? 5; + const limit = opts.limit ?? 200; + const target = artist.trim().toLowerCase(); + const [tracks, albums] = await Promise.all([ + collectForArtist(target, maxPages, (page) => topTracks(user, apiKey, { period: "overall", page, limit })), + collectForArtist(target, maxPages, (page) => topAlbums(user, apiKey, { period: "overall", page, limit })), + ]); + return { topTracks: tracks.items, topAlbums: albums.items, sampled: tracks.sampled || albums.sampled }; +} - 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 topTracks(user: string, apiKey: string, { period, page, limit }: LfmQuery): Promise> { + const data = await lfmGet("user.gettoptracks", apiKey, { + user, + period, + page: String(page), + limit: String(limit), + }); + const top = data.toptracks ?? {}; + const items: LfmTrack[] = toArray(top.track).map((t: any) => ({ + name: t?.name ?? "", + artist: t?.artist?.name ?? "", + playcount: Number(t?.playcount) || 0, + mbid: t?.mbid || null, + })); + const totalPages = Number(top["@attr"]?.totalPages) || 1; + const respPage = Number(top["@attr"]?.page) || page; + return { items, totalPages, page: respPage }; } export async function topAlbums(user: string, apiKey: string, { period, page, limit }: LfmQuery): Promise> {