From 0443f10a3bfd1370f74a3349c7cdbf176357e61a Mon Sep 17 00:00:00 2001 From: Jonathan Date: Wed, 15 Jul 2026 03:43:54 +0200 Subject: [PATCH] feat(lastfm): disambiguation picker for same-named MB artists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last.fm's artist MBID can point at the wrong same-named band (it mapped "Looking Glass" to an Australian stoner band, not the US pop group), and the /lastfm rows trusted it verbatim — so opening/following grabbed the wrong artist and the grabbed album never showed on the artist page. Now open/follow resolve via MB search: resolveArtistChoice keeps candidates whose name matches exactly — 1 match is used directly (even over a disagreeing Last.fm MBID), 2+ surface an ArtistPicker showing each MB disambiguation ("70's US pop group, track 'Brandy'" vs "Australian stoner rock") with MB's top hit flagged Suggested. Core decision logic unit-tested; no worker/schema change. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/app/_ui/artist-picker.tsx | 49 +++++++++++++++++++++++++++ web/src/app/lastfm/lastfm-client.tsx | Bin 16306 -> 18083 bytes web/src/lib/artist-resolve.test.ts | 46 +++++++++++++++++++++++++ web/src/lib/artist-resolve.ts | 34 +++++++++++++++++++ 4 files changed, 129 insertions(+) create mode 100644 web/src/app/_ui/artist-picker.tsx create mode 100644 web/src/lib/artist-resolve.test.ts create mode 100644 web/src/lib/artist-resolve.ts 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. +

+
    + {candidates.map((c, i) => ( +
  • +
    +
    + +
    +
    + {c.disambiguation || "no description on MusicBrainz"} +
    +
    +
    + {i === 0 ? Suggested : null} +
    +
  • + ))} +
+
+ ); +} diff --git a/web/src/app/lastfm/lastfm-client.tsx b/web/src/app/lastfm/lastfm-client.tsx index afa704559e5bc8aa051c9879fed8ea75ba7a5b4d..f8a5fc32ab41620fa3cf2b11b35a56ebad1927f4 100644 GIT binary patch delta 2307 zcma)7OK%)S5SEn}IN>F;!7oU86*)6#Gb^D8q+M?eafE`1B`X|wSVr!h>G3w6?H;;k zyjqMk`~eVXxWE-8PF(O0;KYG{fP@5y3kMGT0g$Nb-kn`XhU8-JbXQkZe~;?#imyJN z`Sg!VujoveY)gduTz~e-Gv`zH>u3KR(NC4m;1D!7DmmaTa-*k2$QzIiQx0zFj>tye z_b4*YgpzOsu~vPkzuZhjx5;dk>!VD^Gc)rSie?ku>~RSUBMZgsZJ0iEe(*~En1$C8jSx4wTe9+-j&bp54ZP04) zIv&L&&Jm5#xoZYldhIrJ6CtBy*upZHKIU22!wuY>AOMI3AlSv2cBdpKE{J$2 zA`YHG+!78=VEwMx%T;dhw?sO91Rfi61qNEly>Sn&gBa;#)9UF`J(KI0g&Y-zv^xR| z5J)Fy5vUj(f>_fAxU^V|SZ8J^Lx^)}m2k3Y4{@@LA}ROOz;D48Dqk4BHnGD^IK(VK zSDr$l1y8zrTOFW)ws*>ZmY*-*diqW=g;fVUHee#7vcY>sNzeC#kr`lp9ZwOXfJ}#s zuy&;^_~@EkYRmrimf|w;_6Rq;S9t_Y8i0D*LZ72U8Ms-=Z4~TpOo`{jLp%224 z$RmOT>)F4WXStRqA#pqsFadd>F_phTvI;1x)!|Y3+uV)9nMxx9)kP;0htk+s0Bpn4 z!9L0hI-L&GD_zw6>eiJby|DpLYoi4^s34xhnR34e%i{8tr>1L>?&{^^wfHQZEz?@_ zrAWPM$Am}M85^!~!uIyMQ+A|2%fA8jIx`jF^aNpR^vL&J=sWBWb%6n8I7FO5{2BP%oGJ%g`)g&JrL8H zO92Qn(-brmL0nCRYM{X6xl%HdSBQHsRdY?AFQ7EJUXaTHsxL(^v8W`oxTM%#FWMm1 z9&Cn!y}d$SX-*ErECq#Hu$d4NZt~=_l4+BzrL;FY%06avO;qqLEzV4KDoV`Et5N{E z2&k?kBNgN|%xdyi}ku`uYkQ#fiD83W-3Gg4DdsymYX--29ZpoXK_a5|fja z%qQ=XZ`Z-;Hw@pw{bw`zu%_7L#R7654|*uTf`%9`!aRwf5gwIMjGVks%3$&Xl}u5v znK}x2KrdKC%=^A-uyt(L~ip}hjpv~Z^@Zn 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" }; +}