feat(web): Last.fm client lib + top-artists/albums + mb release-group routes

This commit is contained in:
Jonathan
2026-07-13 19:01:04 +02:00
parent 5eb4fbfd55
commit ecdad88973
8 changed files with 502 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { topArtists, topAlbums } from "./lastfm";
afterEach(() => vi.restoreAllMocks());
describe("topArtists", () => {
it("parses the nested shape and coerces a single artist object to an array", async () => {
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
topartists: {
artist: { name: "Boygenius", playcount: "42", mbid: "abc" },
"@attr": { totalPages: "3", page: "1" },
},
}),
{ status: 200 },
),
);
const out = await topArtists("jonathan", "key", { period: "overall", page: 1, limit: 50 });
expect(out).toEqual({
items: [{ name: "Boygenius", playcount: 42, mbid: "abc" }],
totalPages: 3,
page: 1,
});
});
it("parses multiple artists and defaults missing mbid/playcount", async () => {
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
topartists: {
artist: [
{ name: "A", playcount: "10", mbid: "m1" },
{ name: "B" },
],
"@attr": { totalPages: "1", page: "1" },
},
}),
{ status: 200 },
),
);
const out = await topArtists("jonathan", "key", { period: "overall", page: 1, limit: 50 });
expect(out.items).toEqual([
{ name: "A", playcount: 10, mbid: "m1" },
{ name: "B", playcount: 0, mbid: null },
]);
expect(out.totalPages).toBe(1);
});
it("throws on Last.fm's HTTP-200 error envelope", async () => {
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(JSON.stringify({ error: 10, message: "Invalid API key" }), { status: 200 }),
);
await expect(topArtists("jonathan", "bad-key", { period: "overall", page: 1, limit: 50 })).rejects.toThrow(
/Invalid API key/,
);
});
it("throws on a non-2xx response", async () => {
vi.spyOn(global, "fetch").mockResolvedValue(new Response("nope", { status: 500 }));
await expect(topArtists("jonathan", "key", { period: "overall", page: 1, limit: 50 })).rejects.toThrow();
});
});
describe("topAlbums", () => {
it("parses the nested shape and coerces a single album object to an array", async () => {
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
topalbums: {
album: {
name: "the record",
playcount: "7",
mbid: "rg1",
artist: { name: "Boygenius", mbid: "art1" },
},
"@attr": { totalPages: "5", page: "2" },
},
}),
{ status: 200 },
),
);
const out = await topAlbums("jonathan", "key", { period: "7day", page: 2, limit: 50 });
expect(out).toEqual({
items: [{ name: "the record", artist: "Boygenius", playcount: 7, mbid: "rg1" }],
totalPages: 5,
page: 2,
});
});
it("defaults a missing artist name to an empty string", async () => {
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
topalbums: {
album: [{ name: "no artist album", playcount: "1" }],
"@attr": { totalPages: "1", page: "1" },
},
}),
{ status: 200 },
),
);
const out = await topAlbums("jonathan", "key", { period: "overall", page: 1, limit: 50 });
expect(out.items).toEqual([{ name: "no artist album", artist: "", playcount: 1, mbid: null }]);
});
it("throws on Last.fm's HTTP-200 error envelope", async () => {
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(JSON.stringify({ error: 6, message: "User not found" }), { status: 200 }),
);
await expect(topAlbums("nobody", "key", { period: "overall", page: 1, limit: 50 })).rejects.toThrow(
/User not found/,
);
});
});
+63
View File
@@ -0,0 +1,63 @@
const LFM_BASE = "https://ws.audioscrobbler.com/2.0/";
const USER_AGENT = "Lyra/0.1";
export type LfmArtist = { name: string; playcount: number; mbid: string | null };
export type LfmAlbum = { name: string; artist: string; playcount: number; mbid: string | null };
export type LfmPage<T> = { items: T[]; totalPages: number; page: number };
export type LfmPeriod = "overall" | "7day" | "1month" | "3month" | "6month" | "12month";
export type LfmQuery = { period: LfmPeriod; page: number; limit: number };
async function lfmGet(method: string, apiKey: string, params: Record<string, string>): Promise<any> {
const qs = new URLSearchParams({ method, api_key: apiKey, format: "json", ...params });
const res = await fetch(`${LFM_BASE}?${qs}`, { headers: { "User-Agent": USER_AGENT } });
const data = await res.json().catch(() => ({}));
if (data && typeof data.error === "number") {
throw new Error(`Last.fm error ${data.error}: ${data.message ?? ""}`);
}
if (!res.ok) throw new Error(`Last.fm request failed: ${res.status}`);
return data;
}
// Last.fm returns a bare object instead of a one-item array when there's exactly one result.
function toArray<T>(value: T | T[] | undefined | null): T[] {
if (value == null) return [];
return Array.isArray(value) ? value : [value];
}
export async function topArtists(user: string, apiKey: string, { period, page, limit }: LfmQuery): Promise<LfmPage<LfmArtist>> {
const data = await lfmGet("user.gettopartists", apiKey, {
user,
period,
page: String(page),
limit: String(limit),
});
const top = data.topartists ?? {};
const items: LfmArtist[] = toArray(top.artist).map((a: any) => ({
name: a?.name ?? "",
playcount: Number(a?.playcount) || 0,
mbid: a?.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>> {
const data = await lfmGet("user.gettopalbums", apiKey, {
user,
period,
page: String(page),
limit: String(limit),
});
const top = data.topalbums ?? {};
const items: LfmAlbum[] = toArray(top.album).map((a: any) => ({
name: a?.name ?? "",
artist: a?.artist?.name ?? "",
playcount: Number(a?.playcount) || 0,
mbid: a?.mbid || null,
}));
const totalPages = Number(top["@attr"]?.totalPages) || 1;
const respPage = Number(top["@attr"]?.page) || page;
return { items, totalPages, page: respPage };
}