feat(web): artistPlays lib + /api/lastfm/artist-plays (personal per-artist tally)
This commit is contained in:
@@ -0,0 +1,74 @@
|
|||||||
|
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/artist-plays${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 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 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GET /api/lastfm/artist-plays", () => {
|
||||||
|
it("400s when Last.fm creds are unset", async () => {
|
||||||
|
const res = await GET(req("?artist=Michael+Jackson"));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("400s when artist param is missing or blank", async () => {
|
||||||
|
await seedConfig();
|
||||||
|
const missing = await GET(req());
|
||||||
|
expect(missing.status).toBe(400);
|
||||||
|
const blank = await GET(req("?artist=%20"));
|
||||||
|
expect(blank.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the tally on the happy path", async () => {
|
||||||
|
await seedConfig();
|
||||||
|
mockLfmArtistTracks([
|
||||||
|
{ name: "Billie Jean", album: { "#text": "Thriller" } },
|
||||||
|
{ name: "Billie Jean", album: { "#text": "Thriller" } },
|
||||||
|
{ name: "Beat It", album: { "#text": "Thriller" } },
|
||||||
|
]);
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
topAlbums: [{ name: "Thriller", plays: 3 }],
|
||||||
|
sampled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("502s 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("?artist=Michael+Jackson"));
|
||||||
|
expect(res.status).toBe(502);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { decryptSecret } from "@/lib/crypto";
|
||||||
|
import { artistPlays } from "@/lib/lastfm";
|
||||||
|
|
||||||
|
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 artist = new URL(request.url).searchParams.get("artist")?.trim();
|
||||||
|
if (!artist) return Response.json({ error: "artist is required" }, { status: 400 });
|
||||||
|
|
||||||
|
let plays;
|
||||||
|
try {
|
||||||
|
plays = await artistPlays(creds.username, artist, creds.apiKey);
|
||||||
|
} catch (e) {
|
||||||
|
return Response.json({ error: e instanceof Error ? e.message : "Last.fm request failed" }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(plays);
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
import { topArtists, topAlbums } from "./lastfm";
|
import { topArtists, topAlbums, artistPlays } from "./lastfm";
|
||||||
|
|
||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
function jsonResponse(data: unknown) {
|
||||||
|
return Promise.resolve(new Response(JSON.stringify(data), { status: 200 }));
|
||||||
|
}
|
||||||
|
|
||||||
describe("topArtists", () => {
|
describe("topArtists", () => {
|
||||||
it("parses the nested shape and coerces a single artist object to an array", async () => {
|
it("parses the nested shape and coerces a single artist object to an array", async () => {
|
||||||
vi.spyOn(global, "fetch").mockResolvedValue(
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -43,6 +43,52 @@ export async function topArtists(user: string, apiKey: string, { period, page, l
|
|||||||
return { items, totalPages, page: respPage };
|
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>> {
|
export async function topAlbums(user: string, apiKey: string, { period, page, limit }: LfmQuery): Promise<LfmPage<LfmAlbum>> {
|
||||||
const data = await lfmGet("user.gettopalbums", apiKey, {
|
const data = await lfmGet("user.gettopalbums", apiKey, {
|
||||||
user,
|
user,
|
||||||
|
|||||||
Reference in New Issue
Block a user