feat(lastfm): disambiguation picker for same-named MB artists
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||||
|
<Modal open={open} onClose={onClose} title="Which artist?">
|
||||||
|
<p className="muted-note">
|
||||||
|
More than one artist is named “{name}”. Pick the right one.
|
||||||
|
</p>
|
||||||
|
<ul className="list">
|
||||||
|
{candidates.map((c, i) => (
|
||||||
|
<li key={c.mbid} className="list-row">
|
||||||
|
<div className="main">
|
||||||
|
<div className="rtitle">
|
||||||
|
<button className="linkish" onClick={() => onPick(c.mbid, c.name)}>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="rmeta">
|
||||||
|
<span>{c.disambiguation || "no description on MusicBrainz"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
{i === 0 ? <span className="chip working">Suggested</span> : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user