feat(web): Last.fm client lib + top-artists/albums + mb release-group routes
This commit is contained in:
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user