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);
|
||||
}
|
||||
Reference in New Issue
Block a user