diff --git a/web/src/app/_ui/artist-picker.tsx b/web/src/app/_ui/artist-picker.tsx new file mode 100644 index 0000000..45b8867 --- /dev/null +++ b/web/src/app/_ui/artist-picker.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Modal } from "./modal"; +import type { ArtistHit } from "@/lib/artist-resolve"; + +/** Disambiguation picker: several MusicBrainz artists share a name (e.g. two bands both called + * "Looking Glass"), so let the user choose the right one by its MB disambiguation text instead + * of silently trusting Last.fm's — sometimes wrong — MBID. Candidates arrive in MB relevance + * order, so the first is flagged "Suggested". */ +export function ArtistPicker({ + open, + name, + candidates, + onPick, + onClose, +}: { + open: boolean; + name: string; + candidates: ArtistHit[]; + onPick: (mbid: string, name: string) => void; + onClose: () => void; +}) { + return ( + +

+ More than one artist is named “{name}”. Pick the right one. +

+ +
+ ); +} diff --git a/web/src/app/lastfm/lastfm-client.tsx b/web/src/app/lastfm/lastfm-client.tsx index afa7045..f8a5fc3 100644 Binary files a/web/src/app/lastfm/lastfm-client.tsx and b/web/src/app/lastfm/lastfm-client.tsx differ diff --git a/web/src/lib/artist-resolve.test.ts b/web/src/lib/artist-resolve.test.ts new file mode 100644 index 0000000..aed54a5 --- /dev/null +++ b/web/src/lib/artist-resolve.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { resolveArtistChoice, type ArtistHit } from "./artist-resolve"; + +const US: ArtistHit = { mbid: "us", name: "Looking Glass", disambiguation: "70's US pop group" }; +const AU: ArtistHit = { mbid: "au", name: "Looking Glass", disambiguation: "Australian stoner rock" }; + +describe("resolveArtistChoice", () => { + it("is ambiguous when 2+ candidates share the exact name", () => { + expect(resolveArtistChoice("Looking Glass", [US, AU], "au")).toEqual({ + kind: "ambiguous", + candidates: [US, AU], + }); + }); + + it("uses the single exact-name match even over a disagreeing fallback mbid", () => { + const hit: ArtistHit = { mbid: "r", name: "Radiohead", disambiguation: "" }; + expect(resolveArtistChoice("Radiohead", [hit], "wrong-lastfm-mbid")).toEqual({ + kind: "single", + mbid: "r", + }); + }); + + it("matches exact name case- and whitespace-insensitively", () => { + expect(resolveArtistChoice(" looking GLASS ", [US, AU])).toEqual({ + kind: "ambiguous", + candidates: [US, AU], + }); + }); + + it("falls back to the provided mbid when no candidate name matches exactly", () => { + const fuzzy: ArtistHit = { mbid: "x", name: "The Beatles", disambiguation: "" }; + expect(resolveArtistChoice("Beatles", [fuzzy], "lastfm-mbid")).toEqual({ + kind: "single", + mbid: "lastfm-mbid", + }); + }); + + it("falls back to MB's top hit when there is no exact match and no fallback", () => { + const fuzzy: ArtistHit = { mbid: "top", name: "The Beatles", disambiguation: "" }; + expect(resolveArtistChoice("Beatles", [fuzzy])).toEqual({ kind: "single", mbid: "top" }); + }); + + it("is none when there are no candidates and no fallback", () => { + expect(resolveArtistChoice("Nobody Here", [])).toEqual({ kind: "none" }); + }); +}); diff --git a/web/src/lib/artist-resolve.ts b/web/src/lib/artist-resolve.ts new file mode 100644 index 0000000..c5afc20 --- /dev/null +++ b/web/src/lib/artist-resolve.ts @@ -0,0 +1,34 @@ +export type ArtistHit = { mbid: string; name: string; disambiguation: string }; + +export type Resolution = + | { kind: "single"; mbid: string } + | { kind: "ambiguous"; candidates: ArtistHit[] } + | { kind: "none" }; + +const norm = (s: string) => s.trim().toLowerCase(); + +/** + * Decide which MusicBrainz artist a Last.fm row refers to, given MB's search candidates. + * + * The bug this guards against: Last.fm's own MBID for an ambiguous name can point at the + * wrong same-named band (it mapped "Looking Glass" to an Australian stoner band, not the US + * pop group). So we prefer MB's own resolution: + * - 2+ candidates whose name EXACTLY matches (normalized) → ambiguous; let the user pick. + * - exactly 1 exact-name match → use it, even if it disagrees with `fallbackMbid`. + * - no exact-name match → keep prior behavior: the Last.fm `fallbackMbid`, else MB's top hit. + * + * `candidates` is assumed to arrive in MB relevance order (best first), so `candidates[0]` + * is the suggested default and exact-name matches keep that ordering. + */ +export function resolveArtistChoice( + name: string, + candidates: ArtistHit[], + fallbackMbid?: string | null, +): Resolution { + const exact = candidates.filter((a) => norm(a.name) === norm(name)); + if (exact.length >= 2) return { kind: "ambiguous", candidates: exact }; + if (exact.length === 1) return { kind: "single", mbid: exact[0].mbid }; + if (fallbackMbid) return { kind: "single", mbid: fallbackMbid }; + if (candidates.length > 0) return { kind: "single", mbid: candidates[0].mbid }; + return { kind: "none" }; +}