diff --git a/src/lib/editions.js b/src/lib/editions.js index ff7119f..4d62cf5 100644 --- a/src/lib/editions.js +++ b/src/lib/editions.js @@ -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( + /^.*?(? EDITION_MARKER.test(a.title) || /[\[(][^\])]*\bedition\b[^\])]*[\])]/i.test(a.title); diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index 028649f..64144c2 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -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