feat(web): Last.fm client lib + top-artists/albums + mb release-group routes
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { encryptSecret } from "@/lib/crypto";
|
||||
import { GET } from "./route";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.LYRA_SECRET_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=";
|
||||
});
|
||||
|
||||
function req(qs = "") {
|
||||
return new Request(`http://localhost/api/lastfm/top-albums${qs}`);
|
||||
}
|
||||
|
||||
async function seedConfig() {
|
||||
await prisma.config.create({ data: { key: "lastfm.username", value: "jonathan", secret: false } });
|
||||
await prisma.config.create({ data: { key: "lastfm.api_key", value: encryptSecret("real-key"), secret: true } });
|
||||
}
|
||||
|
||||
function mockLfmAlbums(albums: unknown[], totalPages = 1, page = 1) {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
topalbums: { album: albums, "@attr": { totalPages: String(totalPages), page: String(page) } },
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("GET /api/lastfm/top-albums", () => {
|
||||
it("400s when Last.fm creds are unset", async () => {
|
||||
const res = await GET(req());
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("annotates owned/wanted/inLibrary against LibraryItem and MonitoredRelease", async () => {
|
||||
await seedConfig();
|
||||
await prisma.libraryItem.create({
|
||||
data: { artist: "Owned Artist", album: "Owned Album", path: "/x", source: "scan", format: "FLAC", qualityClass: 3 },
|
||||
});
|
||||
await prisma.monitoredRelease.create({
|
||||
data: { artistMbid: "a1", artistName: "Wanted Artist", rgMbid: "rg1", album: "Wanted Album", secondaryTypes: [], monitored: true },
|
||||
});
|
||||
mockLfmAlbums([
|
||||
{ name: "Owned Album", playcount: "10", mbid: null, artist: { name: "Owned Artist" } },
|
||||
{ name: "Wanted Album", playcount: "5", mbid: null, artist: { name: "Wanted Artist" } },
|
||||
{ name: "New Album", playcount: "1", mbid: null, artist: { name: "New Artist" } },
|
||||
]);
|
||||
|
||||
const body = await (await GET(req())).json();
|
||||
const byName = Object.fromEntries(body.albums.map((a: any) => [a.name, a]));
|
||||
expect(byName["Owned Album"]).toMatchObject({ owned: true, wanted: false, inLibrary: true });
|
||||
expect(byName["Wanted Album"]).toMatchObject({ owned: false, wanted: true, inLibrary: true });
|
||||
expect(byName["New Album"]).toMatchObject({ owned: false, wanted: false, inLibrary: false });
|
||||
});
|
||||
|
||||
it("returns 502 when Last.fm throws", async () => {
|
||||
await seedConfig();
|
||||
vi.spyOn(global, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: 6, message: "User not found" }), { status: 200 }),
|
||||
);
|
||||
const res = await GET(req());
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptSecret } from "@/lib/crypto";
|
||||
import { topAlbums, type LfmPeriod } from "@/lib/lastfm";
|
||||
|
||||
const PERIODS: LfmPeriod[] = ["overall", "7day", "1month", "3month", "6month", "12month"];
|
||||
const LIMIT = 50;
|
||||
|
||||
async function getCreds(): Promise<{ username: string; apiKey: string } | null> {
|
||||
const rows = await prisma.config.findMany({ where: { key: { in: ["lastfm.username", "lastfm.api_key"] } } });
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
const username = byKey.get("lastfm.username")?.value ?? "";
|
||||
const keyRow = byKey.get("lastfm.api_key");
|
||||
const apiKey = keyRow ? (keyRow.secret ? decryptSecret(keyRow.value) : keyRow.value) : "";
|
||||
if (!username || !apiKey) return null;
|
||||
return { username, apiKey };
|
||||
}
|
||||
|
||||
function albumKey(artist: string, album: string): string {
|
||||
return `${artist} ${album}`.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const creds = await getCreds();
|
||||
if (!creds) return Response.json({ error: "Last.fm not configured" }, { status: 400 });
|
||||
|
||||
const url = new URL(request.url);
|
||||
const periodParam = url.searchParams.get("period") ?? "overall";
|
||||
const period: LfmPeriod = (PERIODS as string[]).includes(periodParam) ? (periodParam as LfmPeriod) : "overall";
|
||||
const page = Number(url.searchParams.get("page")) || 1;
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await topAlbums(creds.username, creds.apiKey, { period, page, limit: LIMIT });
|
||||
} catch (e) {
|
||||
return Response.json({ error: e instanceof Error ? e.message : "Last.fm request failed" }, { status: 502 });
|
||||
}
|
||||
|
||||
const [libraryItems, monitoredReleases] = await Promise.all([
|
||||
prisma.libraryItem.findMany({ select: { artist: true, album: true } }),
|
||||
prisma.monitoredRelease.findMany({
|
||||
where: { OR: [{ monitored: true }, { currentQualityClass: { not: null } }] },
|
||||
select: { artistName: true, album: true },
|
||||
}),
|
||||
]);
|
||||
const ownedKeys = new Set(libraryItems.map((r) => albumKey(r.artist, r.album)));
|
||||
const wantedKeys = new Set(monitoredReleases.map((r) => albumKey(r.artistName, r.album)));
|
||||
|
||||
const albums = result.items.map((a) => {
|
||||
const key = albumKey(a.artist, a.name);
|
||||
const owned = ownedKeys.has(key);
|
||||
const wanted = wantedKeys.has(key);
|
||||
return { ...a, inLibrary: owned || wanted, owned, wanted };
|
||||
});
|
||||
|
||||
return Response.json({ albums, totalPages: result.totalPages, page: result.page });
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { encryptSecret } from "@/lib/crypto";
|
||||
import { GET } from "./route";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.LYRA_SECRET_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=";
|
||||
});
|
||||
|
||||
function req(qs = "") {
|
||||
return new Request(`http://localhost/api/lastfm/top-artists${qs}`);
|
||||
}
|
||||
|
||||
async function seedConfig() {
|
||||
await prisma.config.create({ data: { key: "lastfm.username", value: "jonathan", secret: false } });
|
||||
await prisma.config.create({ data: { key: "lastfm.api_key", value: encryptSecret("real-key"), secret: true } });
|
||||
}
|
||||
|
||||
function mockLfmArtists(artists: unknown[], totalPages = 1, page = 1) {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
topartists: { artist: artists, "@attr": { totalPages: String(totalPages), page: String(page) } },
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("GET /api/lastfm/top-artists", () => {
|
||||
it("400s when Last.fm creds are unset", async () => {
|
||||
const res = await GET(req());
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("annotates followed/owned/inLibrary against WatchedArtist and LibraryItem", async () => {
|
||||
await seedConfig();
|
||||
await prisma.watchedArtist.create({ data: { mbid: "mb-known", name: "Known Band" } });
|
||||
await prisma.libraryItem.create({
|
||||
data: { artist: "Owned Band", album: "Some Album", path: "/x", source: "scan", format: "FLAC", qualityClass: 3 },
|
||||
});
|
||||
mockLfmArtists([
|
||||
{ name: "Known Band", playcount: "10", mbid: "mb-known" },
|
||||
{ name: "Owned Band", playcount: "5", mbid: null },
|
||||
{ name: "New Band", playcount: "1", mbid: null },
|
||||
]);
|
||||
|
||||
const body = await (await GET(req())).json();
|
||||
expect(body.page).toBe(1);
|
||||
expect(body.totalPages).toBe(1);
|
||||
const byName = Object.fromEntries(body.artists.map((a: any) => [a.name, a]));
|
||||
expect(byName["Known Band"]).toMatchObject({ followed: true, owned: false, inLibrary: true });
|
||||
expect(byName["Owned Band"]).toMatchObject({ followed: false, owned: true, inLibrary: true });
|
||||
expect(byName["New Band"]).toMatchObject({ followed: false, owned: false, inLibrary: false });
|
||||
});
|
||||
|
||||
it("matches followed case-insensitively and by mbid", async () => {
|
||||
await seedConfig();
|
||||
await prisma.watchedArtist.create({ data: { mbid: "mb-case", name: "Case Band" } });
|
||||
mockLfmArtists([{ name: "case band", playcount: "1", mbid: null }]);
|
||||
const body = await (await GET(req())).json();
|
||||
expect(body.artists[0]).toMatchObject({ followed: true });
|
||||
});
|
||||
|
||||
it("passes period/page query params through and returns 502 when Last.fm throws", async () => {
|
||||
await seedConfig();
|
||||
vi.spyOn(global, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: 10, message: "Invalid API key" }), { status: 200 }),
|
||||
);
|
||||
const res = await GET(req("?period=7day&page=2"));
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptSecret } from "@/lib/crypto";
|
||||
import { topArtists, type LfmPeriod } from "@/lib/lastfm";
|
||||
|
||||
const PERIODS: LfmPeriod[] = ["overall", "7day", "1month", "3month", "6month", "12month"];
|
||||
const LIMIT = 50;
|
||||
|
||||
async function getCreds(): Promise<{ username: string; apiKey: string } | null> {
|
||||
const rows = await prisma.config.findMany({ where: { key: { in: ["lastfm.username", "lastfm.api_key"] } } });
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
const username = byKey.get("lastfm.username")?.value ?? "";
|
||||
const keyRow = byKey.get("lastfm.api_key");
|
||||
const apiKey = keyRow ? (keyRow.secret ? decryptSecret(keyRow.value) : keyRow.value) : "";
|
||||
if (!username || !apiKey) return null;
|
||||
return { username, apiKey };
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const creds = await getCreds();
|
||||
if (!creds) return Response.json({ error: "Last.fm not configured" }, { status: 400 });
|
||||
|
||||
const url = new URL(request.url);
|
||||
const periodParam = url.searchParams.get("period") ?? "overall";
|
||||
const period: LfmPeriod = (PERIODS as string[]).includes(periodParam) ? (periodParam as LfmPeriod) : "overall";
|
||||
const page = Number(url.searchParams.get("page")) || 1;
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await topArtists(creds.username, creds.apiKey, { period, page, limit: LIMIT });
|
||||
} catch (e) {
|
||||
return Response.json({ error: e instanceof Error ? e.message : "Last.fm request failed" }, { status: 502 });
|
||||
}
|
||||
|
||||
const [watchedArtists, libraryArtistRows] = await Promise.all([
|
||||
prisma.watchedArtist.findMany({ select: { name: true, mbid: true } }),
|
||||
prisma.libraryItem.findMany({ select: { artist: true }, distinct: ["artist"] }),
|
||||
]);
|
||||
const followedNames = new Set(watchedArtists.map((a) => a.name.trim().toLowerCase()));
|
||||
const followedMbids = new Set(watchedArtists.map((a) => a.mbid));
|
||||
const ownedNames = new Set(libraryArtistRows.map((r) => r.artist.trim().toLowerCase()));
|
||||
|
||||
const artists = result.items.map((a) => {
|
||||
const nameKey = a.name.trim().toLowerCase();
|
||||
const followed = followedNames.has(nameKey) || (a.mbid != null && followedMbids.has(a.mbid));
|
||||
const owned = ownedNames.has(nameKey);
|
||||
return { ...a, inLibrary: owned || followed, followed, owned };
|
||||
});
|
||||
|
||||
return Response.json({ artists, totalPages: result.totalPages, page: result.page });
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/musicbrainz", () => ({ searchReleaseGroup: vi.fn() }));
|
||||
import { searchReleaseGroup } from "@/lib/musicbrainz";
|
||||
import { GET } from "./route";
|
||||
|
||||
const mockSearch = vi.mocked(searchReleaseGroup);
|
||||
afterEach(() => mockSearch.mockReset());
|
||||
|
||||
function req(qs = "") {
|
||||
return new Request(`http://localhost/api/mb/release-group${qs}`);
|
||||
}
|
||||
|
||||
describe("GET /api/mb/release-group", () => {
|
||||
it("400s when artist or album is missing", async () => {
|
||||
expect((await GET(req())).status).toBe(400);
|
||||
expect((await GET(req("?artist=Boygenius"))).status).toBe(400);
|
||||
expect((await GET(req("?album=The+Record"))).status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns the match on a happy path", async () => {
|
||||
mockSearch.mockResolvedValue({
|
||||
rgMbid: "rg1",
|
||||
title: "The Record",
|
||||
primaryType: "Album",
|
||||
secondaryTypes: [],
|
||||
firstReleaseDate: "2023",
|
||||
artistMbid: "a1",
|
||||
artistName: "Boygenius",
|
||||
});
|
||||
const body = await (await GET(req("?artist=Boygenius&album=The+Record"))).json();
|
||||
expect(body).toEqual({
|
||||
rgMbid: "rg1",
|
||||
title: "The Record",
|
||||
primaryType: "Album",
|
||||
secondaryTypes: [],
|
||||
firstReleaseDate: "2023",
|
||||
artistMbid: "a1",
|
||||
artistName: "Boygenius",
|
||||
});
|
||||
expect(mockSearch).toHaveBeenCalledWith("Boygenius", "The Record");
|
||||
});
|
||||
|
||||
it("404s when no match found", async () => {
|
||||
mockSearch.mockResolvedValue(null);
|
||||
const res = await GET(req("?artist=Nobody&album=Nothing"));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("502s when MusicBrainz throws", async () => {
|
||||
mockSearch.mockRejectedValue(new Error("mb down"));
|
||||
const res = await GET(req("?artist=Boygenius&album=The+Record"));
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { searchReleaseGroup } from "@/lib/musicbrainz";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const artist = url.searchParams.get("artist");
|
||||
const album = url.searchParams.get("album");
|
||||
if (!artist || !artist.trim() || !album || !album.trim()) {
|
||||
return Response.json({ error: "artist and album are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
let match;
|
||||
try {
|
||||
match = await searchReleaseGroup(artist.trim(), album.trim());
|
||||
} catch {
|
||||
return Response.json({ error: "MusicBrainz unavailable" }, { status: 502 });
|
||||
}
|
||||
if (!match) {
|
||||
return Response.json({ error: "no match" }, { status: 404 });
|
||||
}
|
||||
return Response.json(match);
|
||||
}
|
||||
@@ -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