fix(web): artistPlays via user.getTop{Tracks,Albums} (getArtistTracks deprecated)
Last.fm returns error 27 "Deprecated" for user.getArtistTracks, so the per-artist plays modal was 502ing for every artist. Pivot to filtering the user's supported all-time top tracks + top albums (which carry real per-item play counts) to the artist — exact personal counts, cheaper, and the clicked artists are the user's top artists so their items cluster near the front. Adds topTracks(); route + modal return shape unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
+49
-37
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
+55
-32
@@ -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<string, number>, 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<LfmPage<{ name: string; artist: string; playcount: number }>>,
|
||||
): 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<ArtistPlays> {
|
||||
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 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 songs = new Map<string, number>();
|
||||
const albums = new Map<string, number>();
|
||||
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<LfmPage<LfmTrack>> {
|
||||
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<LfmPage<LfmAlbum>> {
|
||||
|
||||
Reference in New Issue
Block a user