From 2dfc9ab2779584f8f74012561dc37177888ec153 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 12 Jul 2026 00:26:29 +0200 Subject: [PATCH] feat(discovery): web ListenBrainz similar-artists client Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/lib/listenbrainz.test.ts | 22 ++++++++++++++++++++++ web/src/lib/listenbrainz.ts | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 web/src/lib/listenbrainz.test.ts create mode 100644 web/src/lib/listenbrainz.ts diff --git a/web/src/lib/listenbrainz.test.ts b/web/src/lib/listenbrainz.test.ts new file mode 100644 index 0000000..f53de77 --- /dev/null +++ b/web/src/lib/listenbrainz.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { similarArtists } from "./listenbrainz"; + +afterEach(() => vi.restoreAllMocks()); + +describe("similarArtists", () => { + it("parses rows and drops the seed itself", async () => { + vi.spyOn(global, "fetch").mockResolvedValue( + new Response(JSON.stringify([ + { artist_mbid: "seed", name: "Seed", score: 100 }, + { artist_mbid: "c1", name: "Cand", score: 42 }, + ]), { status: 200 }), + ); + const out = await similarArtists("seed"); + expect(out).toEqual([{ mbid: "c1", name: "Cand", score: 42 }]); + }); + + it("throws on a non-2xx response", async () => { + vi.spyOn(global, "fetch").mockResolvedValue(new Response("nope", { status: 500 })); + await expect(similarArtists("seed")).rejects.toThrow(); + }); +}); diff --git a/web/src/lib/listenbrainz.ts b/web/src/lib/listenbrainz.ts new file mode 100644 index 0000000..86628af --- /dev/null +++ b/web/src/lib/listenbrainz.ts @@ -0,0 +1,18 @@ +const LB_BASE = process.env.LISTENBRAINZ_URL ?? "https://labs.api.listenbrainz.org"; +// Keep in sync with the worker's _listenbrainz.py default algorithm. +const ALGORITHM = + "session_based_days_7500_session_300_contribution_5_threshold_15_limit_50_filter_True_skip_30"; + +export type SimilarArtist = { mbid: string; name: string; score: number }; + +export async function similarArtists(artistMbid: string): Promise { + const url = + `${LB_BASE}/similar-artists/json?artist_mbids=${encodeURIComponent(artistMbid)}` + + `&algorithm=${encodeURIComponent(ALGORITHM)}`; + const res = await fetch(url, { headers: { Accept: "application/json" } }); + if (!res.ok) throw new Error(`ListenBrainz request failed: ${res.status}`); + const rows: any[] = (await res.json()) ?? []; + return rows + .filter((r) => r.artist_mbid && r.artist_mbid !== artistMbid) + .map((r) => ({ mbid: r.artist_mbid, name: r.name ?? r.comment ?? "", score: Number(r.score ?? 0) })); +}