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
@@ -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);
});
});
+21
View File
@@ -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);
}
+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 };
}