state: one identity merge, one description splitter, and a record named at click time

Three separate things the panel and the pages were doing differently.

The MusicBrainz merge lived inside `applyArtistIdentity`, which only the
artist page calls, so the Now Playing panel showed the raw provider list: My
Bloody Valentine appeared with four releases because Loveless and Isn't
Anything exist only as supplements, and Weezer filed differently because the
panel never received `type_hints`. The merge is now `mergeArtistIdentity`, a
pure function both surfaces run. `applyDiscoveryIdentity` deliberately does
not write `catalog.artistPage` — that would overwrite whatever page the user
is looking at — and is guarded on the normalized artist name so a track
change mid-flight cannot land one artist's supplement on another's list.

`streamSong` knew the record all along and never said so. Every caller passes
`album` and `albumId`; the queue item was built with `sub: artist`, and the
panel splits `sub` on the dash to find its album line, so a single track
could never name the record it came from. `catalogRef` was also assigned
AFTER the Soulseek resolve, so the panel could not start loading the record
until a peer had been picked — that wait was the "why does it take time to
show the rest of the album" delay, with the id in hand the whole time. It is
now set before the resolve, and handed back if no source is found.

`splitDescription` promotes a description's own first sentence to a lede. It
lives here beside the other release-identity helpers because three surfaces
need it and a copy in each is how they start disagreeing. The abbreviation
guard is worth keeping: written as `Nov\.` the lookbehind consumed the very
period it was meant to protect, and "released 4 Nov. 1991" split after "Nov.".
This commit is contained in:
KeelrLeek committed 2026-07-29 18:45:58 -05:00
1 parent 1e35c5e039
commit 35a18d9ca0
2 files changed
+180 -38

No files matched your search

+30
View File
@@ -26,6 +26,36 @@ const VARIANT_SEGMENT = /\b(?:versions?|mix(?:es)?|edits?|acoustic|instrumentals
const OWN_WORK_SEGMENT = /^\S+['’]s\s+version$/i;
/** A pressing that presents itself as a variant of another release. */
/**
* Split an editorial description into a lede and the rest.
*
* The lede is the description's OWN first sentence, promoted — never written
* or generated. An editorial lead already opens this way ("Hurley is the eighth
* studio album by American rock band Weezer…"), so setting that sentence larger
* costs no source and asserts nothing new. A single-sentence description stays
* whole as the lede, leaving `rest` empty, so nothing renders an empty body.
*
* Lives here, beside the other release-identity helpers, because three surfaces
* need it — the Now Playing panel, the album page and the artist page's
* expander — and a copy in each is how they would start disagreeing.
*/
export function splitDescription(text) {
const trimmed = (text ?? "").trim();
if (!trimmed) return { lede: "", rest: "" };
// A sentence end is .!? followed by whitespace or the string's end. Common
// abbreviations are excluded so "Nov. 4" or "Epitaph Records Inc." does not
// cut the lede short.
// The lookbehinds sit BEFORE the `[.!?]`, so they must name the abbreviation
// WITHOUT its period — that period is the character being matched. Writing
// them as `Nov\.` made the lookbehind consume the very dot it was meant to
// protect, and "released on 4 Nov. 1991" split after "Nov.".
const match = trimmed.match(
/^.*?(?<!\b(?:Mr|Mrs|Ms|Dr|Jr|Sr|St|vs|No|Vol|Pt|Inc|Ltd|Co|feat|ft|approx|est|Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sept?|Oct|Nov|Dec))(?<!\b[A-Z])[.!?](?=\s|$)/,
);
const lede = match ? match[0] : trimmed;
return { lede, rest: trimmed.slice(lede.length).trim() };
}
export const isEdition = (a) => EDITION_MARKER.test(a.title)
|| /[\[(][^\])]*\bedition\b[^\])]*[\])]/i.test(a.title);
+150 -38
View File
@@ -829,6 +829,14 @@ export const catalog = $state({
artistOpenSeq: 0,
albumLoading: false,
albumPage: null, // { album, tracks }
// What the thing you clicked already knew about the record — cover, title,
// artist, kind. You reach an album page from a card that was ALREADY
// showing the artwork, so there is no reason for the page to be colourless
// while the request is out: the skeleton paints this immediately and the
// fetch only has to fill in the words. Null when nothing was known (a
// history restore of a record whose art was never stored, say), and the
// skeleton falls back to a placeholder block.
albumSeed: null, // { cover, title, artist, kind } | null
pendingTrackId: null, // a track to scroll to/flash on the album page
error: "",
});
@@ -1250,7 +1258,19 @@ export async function openEntry(entry) {
case "catalogArtist":
return openCatalogArtist(entry.artistId);
case "catalogAlbum":
return openCatalogAlbum(entry.albumId);
// `recordVisit` stored this record's cover, so going back paints
// the skeleton with the artwork rather than a grey block. Older
// entries predate that field and simply seed nothing.
return openCatalogAlbum(entry.albumId, {
seed: entry.art
? {
cover: entry.art,
title: entry.label ?? null,
artist: entry.artist ?? null,
kind: null,
}
: null,
});
case "catalog":
return catalogGo(entry.query ?? entry.label);
case "playlist":
@@ -1588,6 +1608,55 @@ async function applyArtistIdentity(artistId, active) {
if (!active()) return;
const current = catalog.artistPage;
if (!current || !sameArtistId(current.artist?.id, artistId)) return;
const merged = mergeArtistIdentity(current, identity);
catalog.artistPage = merged;
if (merged.fused_record) void disownFusedPortrait(artistId, merged);
// The wordmark is lifted from THESE albums' covers, and this pass is
// fired beside the one above rather than after it — so on a fused record
// it normally wins the race and reads the unfiltered list. Wand's page
// wore a "wand." lifted from an Indonesian co-tenant's single that way.
// Re-run whenever ownership actually changed the covers on offer; the
// request is keyed by the exact list, so an unchanged one is a cache hit
// and costs nothing.
const coversChanged =
(current.albums ?? []).length !== merged.albums.length
|| (current.albums ?? []).some((a, i) => a?.cover !== merged.albums[i]?.cover);
if (coversChanged && !merged.logos?.length) {
void resolveCoverLogoAvailability(
artistId,
merged,
Promise.resolve(merged.logos ?? []),
active,
);
}
// Write the supplemented page back into the session cache: a warm
// revisit must not render less than the cold visit did. Overwriting
// a pending entry is safe — cachedArtistRequest only installs its
// own result while its promise is still the stored value.
const key = artistCacheKey(artistId);
if (key) {
catalogArtistPageCache.delete(key);
catalogArtistPageCache.set(key, merged);
trimArtistRequestCache(catalogArtistPageCache);
}
savePageReplay(artistId, merged);
} catch {
// Best-effort supplement: the page is complete without it.
}
}
/** The identity merge itself, as a pure function of the page and the identity
* payload.
*
* Extracted so the Now Playing panel can run the SAME merge the artist page
* runs. It used to live inside `applyArtistIdentity`, which only the artist
* page calls — so the panel showed the raw Deezer list: My Bloody Valentine
* appeared with four releases because Loveless and Isn't Anything exist only
* as MusicBrainz supplements, and Weezer filed differently because the panel
* never received `type_hints` either. Both surfaces read one implementation
* now; a second copy would only guarantee they disagree in new ways.
*/
function mergeArtistIdentity(current, identity) {
// MB identity extras are always albums, so only rows that could BE
// that album may block one: a same-title SINGLE is a different
// release (the "Phantom Island" shape — a 2024 single and a 2025
@@ -1777,40 +1846,7 @@ async function applyArtistIdentity(artistId, active) {
socials: socials.length ? socials : current.socials,
typeHints: typeHints.length ? typeHints : current.typeHints,
};
catalog.artistPage = merged;
if (merged.fused_record) void disownFusedPortrait(artistId, merged);
// The wordmark is lifted from THESE albums' covers, and this pass is
// fired beside the one above rather than after it — so on a fused record
// it normally wins the race and reads the unfiltered list. Wand's page
// wore a "wand." lifted from an Indonesian co-tenant's single that way.
// Re-run whenever ownership actually changed the covers on offer; the
// request is keyed by the exact list, so an unchanged one is a cache hit
// and costs nothing.
const coversChanged =
(current.albums ?? []).length !== merged.albums.length
|| (current.albums ?? []).some((a, i) => a?.cover !== merged.albums[i]?.cover);
if (coversChanged && !merged.logos?.length) {
void resolveCoverLogoAvailability(
artistId,
merged,
Promise.resolve(merged.logos ?? []),
active,
);
}
// Write the supplemented page back into the session cache: a warm
// revisit must not render less than the cold visit did. Overwriting
// a pending entry is safe — cachedArtistRequest only installs its
// own result while its promise is still the stored value.
const key = artistCacheKey(artistId);
if (key) {
catalogArtistPageCache.delete(key);
catalogArtistPageCache.set(key, merged);
trimArtistRequestCache(catalogArtistPageCache);
}
savePageReplay(artistId, merged);
} catch {
// Best-effort supplement: the page is complete without it.
}
return merged;
}
/** Deferred footer links: merged whenever they arrive, never blocking the
@@ -2136,6 +2172,10 @@ export async function openCatalogAlbum(albumId, opts = {}) {
if (!opts.keepCurrent) {
catalog.albumPage = null;
catalog.albumLoading = true;
// Only on a real navigation. An edition swap holds the page it is
// already showing, so seeding a skeleton it will never render would
// just leave stale artwork behind for the next navigation to flash.
catalog.albumSeed = opts.seed ?? null;
}
catalog.pendingTrackId = opts.trackId ?? null;
catalog.error = "";
@@ -2170,7 +2210,13 @@ export async function openCatalogAlbum(albumId, opts = {}) {
if (opts.keepCurrent) catalog.albumPage = null;
}
} finally {
if (request === albumPageRequest) catalog.albumLoading = false;
if (request === albumPageRequest) {
catalog.albumLoading = false;
// The real page owns the artwork from here. Held any longer, the
// seed would still be on screen behind the next navigation's first
// frame, showing the previous record's cover under a new skeleton.
catalog.albumSeed = null;
}
}
}
@@ -2435,6 +2481,11 @@ export const discovery = $state({
// the artist page below — kept rather than discarded so the Now Playing
// panel can show the same chips the artist page does.
socials: [],
// MusicBrainz's authoritative primary/secondary types, merged in by
// `applyDiscoveryIdentity`. The panel files its releases by these exactly
// as the artist page does; without them the same discography sorted two
// different ways depending on which surface you were looking at.
typeHints: [],
});
const bioCache = new Map(); // normalized name → bio text (or null)
@@ -2530,6 +2581,41 @@ async function fetchArtistBio(artistId, name) {
}
}
/** Run the artist page's identity merge against the panel's own list.
*
* Deliberately NOT `applyArtistIdentity`: that one writes `catalog.artistPage`,
* which would overwrite whatever artist page the user is actually looking at.
* Only the merge is shared — see [`mergeArtistIdentity`] — so the panel gains
* the supplemented releases and the type hints without touching the page.
*
* Guarded on the normalized artist name, like everything else here, so a track
* change mid-flight cannot land one artist's supplement on another's list.
*/
async function applyDiscoveryIdentity(artistId, norm, name) {
try {
const identity = await api.catalogArtistIdentity(artistId, name ?? "");
if (discovery.artist !== norm) return;
if (!(identity?.albums?.length || identity?.type_hints?.length)) return;
const merged = mergeArtistIdentity(
{
// `artist.name` is load-bearing: `artistScopedRecords` uses it to
// reject rows belonging to a co-tenant of a fused Deezer record.
artist: { name },
albums: discovery.albums,
socials: discovery.socials,
typeHints: discovery.typeHints,
},
identity,
);
if (discovery.artist !== norm) return;
discovery.albums = merged.albums ?? discovery.albums;
discovery.typeHints = merged.typeHints ?? discovery.typeHints;
if (merged.socials?.length) discovery.socials = merged.socials;
} catch {
// Best-effort: the panel is complete without the supplement.
}
}
/** Resolve the playing artist's catalog id, then load their discography +
* related artists for the Now Playing discovery sections. Guarded by the
* normalized name so rapid track changes don't clobber each other. */
@@ -2560,6 +2646,7 @@ export async function loadDiscovery(artistName) {
discovery.albums = [];
discovery.related = [];
discovery.socials = [];
discovery.typeHints = [];
discovery.bio = bioCache.has(norm) ? touchCache(bioCache, norm) : null;
}
@@ -2648,8 +2735,19 @@ export async function loadDiscovery(artistName) {
discovery.albumCount = page.artist.album_count ?? page.albums.length;
discovery.albums = page.albums ?? [];
discovery.socials = page.socials ?? [];
discovery.typeHints = page.typeHints ?? [];
discovery.pageLoaded = true;
discovery.pageWanted = false;
// The same MusicBrainz merge the artist page runs. Without it this
// panel showed the raw provider list — My Bloody Valentine with
// four releases, because Loveless and Isn't Anything are supplements
// Deezer's regional catalog does not carry — and filed everything
// without `type_hints`, so the same discography sorted two ways
// depending on which surface you were looking at. Skipped when the
// page arrived already merged (a warm revisit through the cache).
if (!page.identity_merged) {
void applyDiscoveryIdentity(id, norm, page.artist?.name ?? artistName);
}
}
} catch {
// leave whatever loaded; sections just stay empty
@@ -3456,6 +3554,13 @@ export async function streamSong(artist, title, opts = {}) {
autoAlbum = null;
player.track = { title, sub: "finding best source…", source: "stream" };
player.status = "buffering";
// Before the Soulseek resolve, not after it. Every caller already hands us
// the record this track belongs to, and the panel loads its tracklist off
// `catalogRef.albumId` — so setting it here lets the record load in
// PARALLEL with finding a source, instead of starting only once a peer has
// been picked. That wait was the whole "why does it take time to show the
// rest of the album" delay: the id was in hand the moment you clicked.
player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null };
const { responses, artistIncluded } = await resolve(
`${artist} — ${title}`,
[`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)],
@@ -3468,6 +3573,8 @@ export async function streamSong(artist, title, opts = {}) {
if (candidates.length === 0) {
player.status = "idle";
player.track = null;
// Hand back the record claimed above: nothing is playing from it.
player.catalogRef = { albumId: null, trackId: null };
resolver.error = `No streamable sources found for “${title}”`;
return;
}
@@ -3476,7 +3583,13 @@ export async function streamSong(artist, title, opts = {}) {
{
path: c.file.path,
title,
sub: artist,
// "Artist — Album" whenever the record is known, which is the
// same shape `playAlbum` writes. The panel splits `sub` on the
// dash to get its clickable artist/album lines, so a bare
// artist here meant a single track NEVER named the record it
// came from — even though `opts.album` was passed by every
// caller and sat unused one line below.
sub: opts.album ? `${artist} — ${opts.album}` : artist,
artist,
album: opts.album ?? null,
duration: c.file.duration ?? opts.expectSecs ?? null,
@@ -3488,7 +3601,6 @@ export async function streamSong(artist, title, opts = {}) {
},
];
const queuedItem = player.queue[0];
player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null };
playQueueIndex(0);
if (await waitForStreamStart(15000)) {
// Keep the album going: resolve + pre-buffer the next album