From ecdad8897361a689bb27755239b553282f8041b3 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 13 Jul 2026 19:01:04 +0200 Subject: [PATCH] feat(web): Last.fm client lib + top-artists/albums + mb release-group routes --- .../app/api/lastfm/top-albums/route.test.ts | 67 ++++++++++ web/src/app/api/lastfm/top-albums/route.ts | 56 +++++++++ .../app/api/lastfm/top-artists/route.test.ts | 75 ++++++++++++ web/src/app/api/lastfm/top-artists/route.ts | 50 ++++++++ .../app/api/mb/release-group/route.test.ts | 55 +++++++++ web/src/app/api/mb/release-group/route.ts | 21 ++++ web/src/lib/lastfm.test.ts | 115 ++++++++++++++++++ web/src/lib/lastfm.ts | 63 ++++++++++ 8 files changed, 502 insertions(+) create mode 100644 web/src/app/api/lastfm/top-albums/route.test.ts create mode 100644 web/src/app/api/lastfm/top-albums/route.ts create mode 100644 web/src/app/api/lastfm/top-artists/route.test.ts create mode 100644 web/src/app/api/lastfm/top-artists/route.ts create mode 100644 web/src/app/api/mb/release-group/route.test.ts create mode 100644 web/src/app/api/mb/release-group/route.ts create mode 100644 web/src/lib/lastfm.test.ts create mode 100644 web/src/lib/lastfm.ts diff --git a/web/src/app/api/lastfm/top-albums/route.test.ts b/web/src/app/api/lastfm/top-albums/route.test.ts new file mode 100644 index 0000000..9c34e33 --- /dev/null +++ b/web/src/app/api/lastfm/top-albums/route.test.ts @@ -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); + }); +}); diff --git a/web/src/app/api/lastfm/top-albums/route.ts b/web/src/app/api/lastfm/top-albums/route.ts new file mode 100644 index 0000000..f6baa89 --- /dev/null +++ b/web/src/app/api/lastfm/top-albums/route.ts @@ -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 }); +} diff --git a/web/src/app/api/lastfm/top-artists/route.test.ts b/web/src/app/api/lastfm/top-artists/route.test.ts new file mode 100644 index 0000000..2f6948b --- /dev/null +++ b/web/src/app/api/lastfm/top-artists/route.test.ts @@ -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); + }); +}); diff --git a/web/src/app/api/lastfm/top-artists/route.ts b/web/src/app/api/lastfm/top-artists/route.ts new file mode 100644 index 0000000..ae478e4 --- /dev/null +++ b/web/src/app/api/lastfm/top-artists/route.ts @@ -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 }); +} diff --git a/web/src/app/api/mb/release-group/route.test.ts b/web/src/app/api/mb/release-group/route.test.ts new file mode 100644 index 0000000..3ee631a --- /dev/null +++ b/web/src/app/api/mb/release-group/route.test.ts @@ -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); + }); +}); diff --git a/web/src/app/api/mb/release-group/route.ts b/web/src/app/api/mb/release-group/route.ts new file mode 100644 index 0000000..1202c5e --- /dev/null +++ b/web/src/app/api/mb/release-group/route.ts @@ -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); +} diff --git a/web/src/lib/lastfm.test.ts b/web/src/lib/lastfm.test.ts new file mode 100644 index 0000000..860ddae --- /dev/null +++ b/web/src/lib/lastfm.test.ts @@ -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/, + ); + }); +}); diff --git a/web/src/lib/lastfm.ts b/web/src/lib/lastfm.ts new file mode 100644 index 0000000..5bb7ed9 --- /dev/null +++ b/web/src/lib/lastfm.ts @@ -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 = { 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): Promise { + 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(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> { + 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> { + 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 }; +}