Compare commits
2 Commits
446a4bc2d2
...
d275a0ddd6
| Author | SHA1 | Date | |
|---|---|---|---|
| d275a0ddd6 | |||
| 5e10dc24a5 |
@@ -22,6 +22,12 @@ export function Modal({
|
||||
}) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const lastFocus = useRef<HTMLElement | null>(null);
|
||||
// Keep the latest onClose in a ref so the setup effect can depend on [open] alone. If it
|
||||
// depended on onClose, a caller passing a fresh callback each render (the common case)
|
||||
// would re-run the effect on every parent re-render — and panelRef.focus() below would
|
||||
// yank focus off a just-typed <input> back onto the panel (the add-artist modal bug).
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -32,7 +38,7 @@ export function Modal({
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
@@ -54,7 +60,7 @@ export function Modal({
|
||||
document.body.style.overflow = prevOverflow;
|
||||
lastFocus.current?.focus();
|
||||
};
|
||||
}, [open, onClose]);
|
||||
}, [open]);
|
||||
|
||||
if (!open || typeof document === "undefined") return null;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { searchArtists, browseReleaseGroups, searchReleaseGroup } from "./musicbrainz";
|
||||
import { searchArtists, browseReleaseGroups, searchReleaseGroup, stripEditionQualifiers } from "./musicbrainz";
|
||||
|
||||
function jsonResponse(data: unknown) {
|
||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(data) } as Response);
|
||||
@@ -76,4 +76,39 @@ describe("musicbrainz module", () => {
|
||||
// the decoded query must contain the escaped quote, not a bare one that closes the phrase
|
||||
expect(decodeURIComponent(url)).toContain('Back in \\"Black\\"');
|
||||
});
|
||||
|
||||
it("retries with an edition-stripped title when the exact phrase finds nothing", async () => {
|
||||
// Last.fm hands us "The Stranger (Legacy Edition)"; the strict phrase for that returns 0,
|
||||
// then the stripped "The Stranger" resolves to the release group.
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(jsonResponse({ "release-groups": [] }))
|
||||
.mockReturnValueOnce(jsonResponse({ "release-groups": [
|
||||
{ id: "rg-str", title: "The Stranger", "primary-type": "Album", "secondary-types": [], "first-release-date": "1977", "artist-credit": [{ artist: { id: "bj", name: "Billy Joel" } }] },
|
||||
] }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const m = await searchReleaseGroup("Billy Joel", "The Stranger (Legacy Edition)");
|
||||
expect(m?.rgMbid).toBe("rg-str");
|
||||
// first attempt used the full title, second used the stripped title
|
||||
expect(decodeURIComponent(fetchMock.mock.calls[0][0])).toContain("The Stranger (Legacy Edition)");
|
||||
expect(decodeURIComponent(fetchMock.mock.calls[1][0])).toContain('releasegroup:"The Stranger"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripEditionQualifiers", () => {
|
||||
it("removes trailing edition parentheticals and suffixes", () => {
|
||||
expect(stripEditionQualifiers("The Stranger (Legacy Edition)")).toBe("The Stranger");
|
||||
expect(stripEditionQualifiers("21 (Deluxe)")).toBe("21");
|
||||
expect(stripEditionQualifiers("Nevermind - Remastered")).toBe("Nevermind");
|
||||
expect(stripEditionQualifiers("Rumours - Remastered 2011")).toBe("Rumours");
|
||||
expect(stripEditionQualifiers("Blue [Deluxe Edition]")).toBe("Blue");
|
||||
expect(stripEditionQualifiers("Vitalogy (2016 Remaster)")).toBe("Vitalogy");
|
||||
});
|
||||
|
||||
it("leaves non-edition titles untouched", () => {
|
||||
expect(stripEditionQualifiers("(What's the Story) Morning Glory?")).toBe("(What's the Story) Morning Glory?");
|
||||
expect(stripEditionQualifiers("Continuum")).toBe("Continuum");
|
||||
expect(stripEditionQualifiers("Songs in the Key of Life")).toBe("Songs in the Key of Life");
|
||||
expect(stripEditionQualifiers("Give Up")).toBe("Give Up"); // "Up" is not a stray edition word
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,27 @@ function escapeLucenePhrase(s: string): string {
|
||||
return s.replace(/[\\"]/g, (c) => "\\" + c);
|
||||
}
|
||||
|
||||
// Last.fm album names carry per-RELEASE edition/version qualifiers ("(Legacy Edition)",
|
||||
// " - Remastered 2011", "[Deluxe]") that are NOT part of the release-GROUP title, so a
|
||||
// strict phrase search finds nothing. Strip a trailing parenthetical/bracket or " - "
|
||||
// suffix ONLY when it names an edition/version (never a plain parenthetical like
|
||||
// "(What's the Story) Morning Glory?", which isn't trailing anyway).
|
||||
const EDITION_RE =
|
||||
/\b(deluxe|expanded|legacy|remaster(ed)?|anniversary|reissue|bonus|special|collector'?s?|edition|version|mono|stereo|explicit|clean)\b/i;
|
||||
|
||||
export function stripEditionQualifiers(album: string): string {
|
||||
let s = album.trim();
|
||||
for (;;) {
|
||||
const before = s;
|
||||
// trailing (...) or [...]
|
||||
s = s.replace(/\s*[([][^()[\]]*[)\]]\s*$/, (m) => (EDITION_RE.test(m) ? "" : m)).trimEnd();
|
||||
// trailing " - ..." / " – ..." / " — ..." suffix
|
||||
s = s.replace(/\s+[-–—]\s+[^-–—]*$/, (m) => (EDITION_RE.test(m) ? "" : m)).trimEnd();
|
||||
if (s === before) break;
|
||||
}
|
||||
return s.trim() || album.trim(); // never collapse to empty
|
||||
}
|
||||
|
||||
// When several release groups share the searched title (e.g. MusicBrainz ties the
|
||||
// "Thriller" single with the album at the same score, listing the single first), prefer
|
||||
// the canonical studio album. Lower rank = better; MB's own ordering breaks ties within
|
||||
@@ -73,9 +94,21 @@ function albumPreferenceRank(g: any): number {
|
||||
}
|
||||
|
||||
export async function searchReleaseGroup(artist: string, album: string): Promise<ReleaseGroupMatch | null> {
|
||||
const query = `releasegroup:"${escapeLucenePhrase(album)}" AND artist:"${escapeLucenePhrase(artist)}"`;
|
||||
const data = await mbGet(`/release-group?query=${encodeURIComponent(query)}&fmt=json&limit=5`);
|
||||
const groups: any[] = data["release-groups"] ?? [];
|
||||
const art = escapeLucenePhrase(artist);
|
||||
const stripped = stripEditionQualifiers(album);
|
||||
// Decreasing precision: exact phrase on the given title, then on the edition-stripped
|
||||
// title, then a loose (unquoted, OR'd terms) fallback. Stop at the first that yields hits
|
||||
// so the clean case still costs one request.
|
||||
const attempts = [`releasegroup:"${escapeLucenePhrase(album)}" AND artist:"${art}"`];
|
||||
if (stripped !== album) attempts.push(`releasegroup:"${escapeLucenePhrase(stripped)}" AND artist:"${art}"`);
|
||||
attempts.push(`releasegroup:(${escapeLucenePhrase(stripped)}) AND artist:"${art}"`);
|
||||
|
||||
let groups: any[] = [];
|
||||
for (const query of attempts) {
|
||||
const data = await mbGet(`/release-group?query=${encodeURIComponent(query)}&fmt=json&limit=10`);
|
||||
groups = data["release-groups"] ?? [];
|
||||
if (groups.length > 0) break;
|
||||
}
|
||||
if (groups.length === 0) return null;
|
||||
// groups arrive in MB relevance order; keep that as the tiebreak (reduce only switches on
|
||||
// a strictly better rank, so equal-ranked earlier hits win).
|
||||
|
||||
Reference in New Issue
Block a user