feat(web): artistPlays lib + /api/lastfm/artist-plays (personal per-artist tally)

This commit is contained in:
Jonathan
2026-07-13 21:20:47 +02:00
parent b6623944ff
commit 5816a17952
4 changed files with 203 additions and 1 deletions
+53 -1
View File
@@ -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);
});
});
+46
View File
@@ -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<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);
}
export async function artistPlays(
user: string,
artist: string,
apiKey: string,
opts: { maxPages?: 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 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 topAlbums(user: string, apiKey: string, { period, page, limit }: LfmQuery): Promise<LfmPage<LfmAlbum>> {
const data = await lfmGet("user.gettopalbums", apiKey, {
user,