album page: the record gets a page of its own, under the same glass bar
The album page was a back link, a 150px cover and a list of titles — a
placeholder beside the artist page it is reached from. It is now built from the
same parts: the frosted glass header, a hero carrying the record's own art and
palette, the play-count bars, and the edition cards.
**The glass bar is the artist page's, not a copy of it.** Same 46px, same
rgba(20,20,24,0.28) over blur(12px) saturate(1.7), same sticky top:-24px with
the -24/-28/-22 bleed that spans the pane and leaves zero flow height. It is
meant to be the header every page navigates by, so it had to be the same
object rather than something that resembles it.
It gains one thing here: the record's cover, name and a play button fade into
the bar once the title itself has gone under it, so the bar always says where
you are. That watches the TITLE, not the identity block — watching the block
meant waiting for the artwork to clear too, about 240px of scroll on a page
that only has 199px of it, so it would never have fired on a ten-track album.
**The hero is one proportional block.** `--cover-scale` sizes the artwork
(282px) and everything beside it is `em` off that one root, so type, gaps, the
artist portrait, the pills and the stat divider all move by the same factor.
Two earlier attempts are worth naming because they both looked wrong:
- damping the type (0.6 + 0.4·s) made the words visibly lag the artwork;
- `space-between` filled the height but opened the gaps faster than the type
grew, so the block came apart as it got bigger.
The gap is 1.8em, which is what the five rows need to fill the cover exactly,
and being an em it keeps that fit at any size. Measured live: the label's top
and the buttons' bottom both sit 0px from the artwork's edges. The hero's own
height derives from the artwork (`--cover + 68px`), so the 24px under the glass
bar holds at every scale instead of being a second number that drifts.
**Editions come from src/lib/editions.js — the artist page's own grouping.**
`releaseFamilies`, `editionBaseTitle`, `isEdition` and `editionLabel` moved out
of CatalogArtist.svelte (143 lines deleted there, not duplicated) so both pages
fold a record's pressings the same way. Two implementations would have drifted
invisibly: each page would show a plausible edition list and disagree about
which pressings are one album.
The album payload has no siblings, so the family is rebuilt from the artist's
discography. It reads the FULL page rather than the overview — the overview is
the artist page's first-paint subset and both dropped the anniversary edition
and carried no track counts, so the rows read "1994" where the discography
reads "1994 · 50 tracks". Cached per artist, and already warm whenever this
page was reached from theirs. Live, the Blue Album now lists Original 10 /
Deluxe 24 / Anniversary Super Deluxe 50, matching the discography exactly.
One deliberate narrowing: the artist page buckets releases by category before
grouping, and that categorisation leans on page state this view does not load
(harvested description hints, MusicBrainz type hints). So a sibling is accepted
only when the backend LINKED it (`edition_group`, a verified ordered core-track
match) or it declares the same record type. That can only ever show fewer
editions than the discography, never promote a separate live album or
compilation into this record's edition list.
**Switching edition is a swap, not a navigation.** `openCatalogAlbum` grew a
`keepCurrent` option that holds the page already on screen instead of clearing
to the loading state; the incoming cover is decoded before the swap so the
artwork crossfades rather than blinking out; and the parts are re-dealt on the
same clock as the expander — 400ms quintOut, 7px lift, banner → cover → kind →
title → meta → stats → actions → tracklist → description → editions → credits.
Polled at 16ms through a swap: 0 blank frames. The blurred banner fades without
lifting, because sliding a 42px blur reads as a wobble, and every part animates
to its OWN resting opacity — the banner rests at 0.62 and animating it to 1
would have run the colour wash half again too bright for the whole swap. A
failed load under `keepCurrent` clears anyway, so a dead edition surfaces the
error instead of looking like the click did nothing.
**The byline shows the artist, not the album cover again.** The album payload
carries the artist's NAME and nothing else, so `resolveArtistSeedByName` reads
and fills the same cache `openArtistByName` uses: the click that navigates
there has usually already paid for it, in-flight lookups are deduped so the
page and its own back button cannot fire the same search twice, and a transport
failure stays retryable rather than being cached as "no artist". A neutral dot
holds the space until it lands, so the line never reflows.
Shuffle and Preview join Play and Save. Shuffle is an action on this record,
not a toggle — it always turns shuffle on rather than flipping a global and
silently starting the album in order — and stays lit while it is on so that
state is visible from here. Preview opens the same modal the artist page does;
`buildAlbumManifest` already existed in the preview layer with no caller, which
is what this surface was for. It reports partial availability the same way, and
the manifest is a snapshot so an edition swap cannot rewrite an open session.
Also: plays bars, the whole-track/listener totals, the editorial description
and a released/label line, all off data `catalog_album` was already returning
and the page was throwing away. The plays column hides below three tracks — a
bar measured against itself is not a comparison, and on a single it read as a
rating. Track counts are pluralised, so a single says "1 track".
No add-to-playlist button. `add_to_playlist` requires a file path per track, so
a catalog album can only be added once it is in the library; that is a playlist
decision, not something to slip in behind an icon.
150 Rust tests and 85 JS tests pass.
This commit is contained in:
1 parent
fa3cbc64ca
commit
ea9a040d09
4 files changed
+1512
-298
No files matched your search
@@ -0,0 +1,164 @@
|
|||||||
|
/** Edition families — the rule that folds a work's reissues, deluxes and
|
||||||
|
* remasters into ONE record with a list of editions.
|
||||||
|
*
|
||||||
|
* Lifted out of the artist page so the album page groups a record exactly the
|
||||||
|
* same way. Two implementations of this would drift, and the drift would be
|
||||||
|
* invisible: both pages would show a plausible edition list and disagree about
|
||||||
|
* which pressings belong to the same album.
|
||||||
|
*
|
||||||
|
* Every rule here was derived from a measured failure — the comments name the
|
||||||
|
* record that forced each one. Do not "simplify" a clause without a release
|
||||||
|
* that proves it is dead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const norm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||||
|
const year = (album) => album.release_date?.slice(0, 4) ?? "";
|
||||||
|
|
||||||
|
const EDITION_MARKER = /\b(?:super\s+deluxe|deluxe|expanded|remaster(?:ed)?|anniversary|legacy\s+edition|special\s+edition|collector'?s\s+edition|bonus\s+tracks?|complete\s+edition|definitive\s+edition|reissue|redux|mono\s+edition|stereo\s+edition|\w+(?:['’]s)?\s+edition|half[-\s]speed|track\s+by\s+track)\b/i;
|
||||||
|
const EDITION_SUFFIX = /\s*(?:[-–—:]\s*)?(?:the\s+)?(?:(?:\d{4}|\d+(?:st|nd|rd|th))\s+)?(?:super\s+deluxe|deluxe|expanded|remaster(?:ed)?|anniversary|legacy\s+edition|special\s+edition|collector'?s\s+edition|bonus\s+tracks?|complete\s+edition|definitive\s+edition|reissue|redux|mono\s+edition|stereo\s+edition|\w+(?:['’]s)?\s+edition|half[-\s]speed|track\s+by\s+track)(?:\s+(?:edition|version))?.*$/i;
|
||||||
|
|
||||||
|
// A bracketed tail naming an alternate cut of the same work — "(Acoustic
|
||||||
|
// Version)", "(2010 Mono Version)", "(Urban Mixes)", "(Sampler)". These
|
||||||
|
// fold into the base release's family. "<Name>'s Version" is exempt: a
|
||||||
|
// re-recorded catalogue (Taylor's Version) is its own work and must keep
|
||||||
|
// its own card.
|
||||||
|
const VARIANT_SEGMENT = /\b(?:versions?|mix(?:es)?|edits?|acoustic|instrumentals?|sampler|extras|mono|stereo|demos?|extended|live)\b/i;
|
||||||
|
const OWN_WORK_SEGMENT = /^\S+['’]s\s+version$/i;
|
||||||
|
|
||||||
|
/** A pressing that presents itself as a variant of another release. */
|
||||||
|
export const isEdition = (a) => EDITION_MARKER.test(a.title)
|
||||||
|
|| /[\[(][^\])]*\bedition\b[^\])]*[\])]/i.test(a.title);
|
||||||
|
|
||||||
|
/** The title with its edition/variant tail removed — the key that folds
|
||||||
|
* unlinked pressings of one work together. */
|
||||||
|
export function editionBaseTitle(title) {
|
||||||
|
// Keep a useful qualifier that shares an edition bracket. For example,
|
||||||
|
// "Weezer (Deluxe Edition / Red Album)" belongs to "Weezer (Red
|
||||||
|
// Album)", not to every self-titled Weezer record.
|
||||||
|
const bracketSafe = title.replace(/\s*[\[(]([^\])]+)[\])]/g, (whole, inner) => {
|
||||||
|
const variantInner = VARIANT_SEGMENT.test(inner) && !OWN_WORK_SEGMENT.test(inner.trim());
|
||||||
|
if (!EDITION_MARKER.test(inner) && !/\bedition\b/i.test(inner) && !variantInner) return whole;
|
||||||
|
const qualifiers = inner
|
||||||
|
.split(/\s*[/|;]\s*/)
|
||||||
|
// A segment can carry a real qualifier AND a marker tail —
|
||||||
|
// "1973-1976 2011 Remaster" must keep "1973-1976" so the
|
||||||
|
// remaster lands in the same family as "Deep Cuts
|
||||||
|
// (1973-1976)". Strip the marker tail first, then judge.
|
||||||
|
.map((part) => part
|
||||||
|
.replace(/\s*(?:\d{4}\s+)?(?:super\s+deluxe|deluxe|expanded|remaster(?:ed)?|anniversary|reissue|redux)(?:\s+(?:edition|version))?\s*$/i, "")
|
||||||
|
.trim())
|
||||||
|
.filter((part) => part
|
||||||
|
&& !EDITION_MARKER.test(part)
|
||||||
|
&& !/\bedition\b/i.test(part)
|
||||||
|
&& !(VARIANT_SEGMENT.test(part) && !OWN_WORK_SEGMENT.test(part)));
|
||||||
|
return qualifiers.length ? ` (${qualifiers.join(" / ")})` : "";
|
||||||
|
});
|
||||||
|
const stripped = bracketSafe
|
||||||
|
.replace(EDITION_SUFFIX, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.replace(/\s*[-–—:]\s*$/, "");
|
||||||
|
return stripped || title;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editionLabel(edition, family) {
|
||||||
|
// The expander rail header already names the album, so each edition
|
||||||
|
// row shows only what differs: "Original" for the canonical release,
|
||||||
|
// otherwise the qualifier ("Deluxe Edition", "2025 Remaster"). The
|
||||||
|
// full title stays available as the row tooltip.
|
||||||
|
if (edition.id === family.id) return "Original";
|
||||||
|
const title = edition.title;
|
||||||
|
const base = family.baseTitle ?? "";
|
||||||
|
// "Weezer (Deluxe Edition / Red Album)" shares its bracket with the
|
||||||
|
// family qualifier — drop segments the family already carries so the
|
||||||
|
// label is just "Deluxe Edition". Test against the canonical title,
|
||||||
|
// not baseTitle: edition_group families derive baseTitle from
|
||||||
|
// whichever release was processed first, so it can be bare "Weezer".
|
||||||
|
const known = `${family.title ?? ""} ${base}`.toLowerCase();
|
||||||
|
const dropBaseParts = (raw) => {
|
||||||
|
const parts = raw.split(/\s*[/|;]\s*/).map((p) => p.trim()).filter(Boolean);
|
||||||
|
const kept = parts.filter((p) => !known.includes(p.toLowerCase()));
|
||||||
|
return (kept.length ? kept : parts).join(" / ");
|
||||||
|
};
|
||||||
|
const trim = (raw) => raw
|
||||||
|
.replace(/^[\s\-–—:([]+/, "")
|
||||||
|
.replace(/[)\]]+$/, "")
|
||||||
|
.trim();
|
||||||
|
if (base && title.toLowerCase().startsWith(base.toLowerCase())) {
|
||||||
|
const rest = trim(title.slice(base.length));
|
||||||
|
if (rest) return dropBaseParts(rest);
|
||||||
|
}
|
||||||
|
const suffix = title.match(EDITION_SUFFIX)?.[0];
|
||||||
|
return (suffix && dropBaseParts(trim(suffix))) || title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group `items` (one category's releases) into families, newest first.
|
||||||
|
* Each family is its canonical release plus `{ familyKey, baseTitle,
|
||||||
|
* editions }`, where `editions[0]` is the canonical one. */
|
||||||
|
export function releaseFamilies(items, category) {
|
||||||
|
// A work whose rows are only PARTLY edition_group-linked must not
|
||||||
|
// split into a work-keyed family and a title-keyed family showing two
|
||||||
|
// cards ("Nevermind" linked to its deluxe, "Nevermind (Remastered)"
|
||||||
|
// unlinked). Rows without a group adopt the group of a linked row
|
||||||
|
// that shares their base title.
|
||||||
|
const groupByBase = new Map();
|
||||||
|
for (const album of items) {
|
||||||
|
if (album.edition_group == null) continue;
|
||||||
|
const base = norm(editionBaseTitle(album.title));
|
||||||
|
if (base && !groupByBase.has(base)) groupByBase.set(base, album.edition_group);
|
||||||
|
}
|
||||||
|
const families = new Map();
|
||||||
|
for (const album of items) {
|
||||||
|
const baseTitle = editionBaseTitle(album.title);
|
||||||
|
// Only an EDITION may adopt a group by base title. The backend
|
||||||
|
// stamps `edition_group` on the base row as well as its editions,
|
||||||
|
// so a group always contains its own base — which means a
|
||||||
|
// plain-titled row that is unlinked belongs to no group, and
|
||||||
|
// adopting one can only ever attach it to somebody else's work.
|
||||||
|
// Measured: "Weezer (Deluxe Edition)" is linked to the 2008 Red
|
||||||
|
// Album and reduces to the base title "Weezer", so the bare-titled
|
||||||
|
// 2026 Gold Album adopted the Red Album's family and was filed as
|
||||||
|
// one of its editions. The case this heuristic exists for —
|
||||||
|
// "Nevermind (Remastered)" joining "Nevermind" — is an edition
|
||||||
|
// adopting, and still works.
|
||||||
|
const adopted = isEdition(album)
|
||||||
|
? groupByBase.get(norm(baseTitle))
|
||||||
|
: undefined;
|
||||||
|
const group = album.edition_group ?? adopted;
|
||||||
|
const key = group != null
|
||||||
|
? `${category}:work:${group}`
|
||||||
|
: `${category}:${norm(baseTitle) || album.id}`;
|
||||||
|
if (!families.has(key)) {
|
||||||
|
families.set(key, { key, baseTitle, editions: [] });
|
||||||
|
}
|
||||||
|
const family = families.get(key);
|
||||||
|
// Deezer can return the same regional/digital release under more
|
||||||
|
// than one catalog id, while MusicBrainz supplements can carry the
|
||||||
|
// same title/year without a track count. Those are duplicate source
|
||||||
|
// records, not editions. Different known track counts remain
|
||||||
|
// separate because they can represent a real expanded edition.
|
||||||
|
const releaseYear = year(album);
|
||||||
|
const duplicate = releaseYear && family.editions.some((edition) =>
|
||||||
|
norm(edition.title) === norm(album.title)
|
||||||
|
&& year(edition) === releaseYear
|
||||||
|
&& (edition.track_count == null
|
||||||
|
|| album.track_count == null
|
||||||
|
|| edition.track_count === album.track_count),
|
||||||
|
);
|
||||||
|
if (duplicate) continue;
|
||||||
|
family.editions.push(album);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...families.values()]
|
||||||
|
.map((family) => {
|
||||||
|
const byDate = family.editions
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (a.release_date ?? "9999").localeCompare(b.release_date ?? "9999"));
|
||||||
|
const canonical = byDate.find((album) => !isEdition(album)) ?? byDate[0];
|
||||||
|
const editions = [canonical, ...byDate.filter((album) => album.id !== canonical.id)];
|
||||||
|
return { ...canonical, familyKey: family.key, baseTitle: family.baseTitle, editions };
|
||||||
|
})
|
||||||
|
// Studio chronology should follow the original album, not the date
|
||||||
|
// of its latest anniversary reissue.
|
||||||
|
.sort((a, b) => (b.release_date ?? "").localeCompare(a.release_date ?? ""));
|
||||||
|
}
|
||||||
+66
-3
@@ -2002,8 +2002,15 @@ export async function openCatalogAlbum(albumId, opts = {}) {
|
|||||||
albumResolveRequest += 1;
|
albumResolveRequest += 1;
|
||||||
const request = ++albumPageRequest;
|
const request = ++albumPageRequest;
|
||||||
ui.view = "catalogAlbum";
|
ui.view = "catalogAlbum";
|
||||||
catalog.albumPage = null;
|
// `keepCurrent` holds the page that is already on screen until the new one
|
||||||
catalog.albumLoading = true;
|
// lands, instead of clearing to the loading state. Switching between two
|
||||||
|
// editions of the same record is a swap, not a navigation — blanking the
|
||||||
|
// whole page for it reads as a flash. The album view crossfades on top of
|
||||||
|
// this; every other caller still gets the plain loading state.
|
||||||
|
if (!opts.keepCurrent) {
|
||||||
|
catalog.albumPage = null;
|
||||||
|
catalog.albumLoading = true;
|
||||||
|
}
|
||||||
catalog.pendingTrackId = opts.trackId ?? null;
|
catalog.pendingTrackId = opts.trackId ?? null;
|
||||||
catalog.error = "";
|
catalog.error = "";
|
||||||
const active = () => request === albumPageRequest && ui.view === "catalogAlbum";
|
const active = () => request === albumPageRequest && ui.view === "catalogAlbum";
|
||||||
@@ -2022,7 +2029,13 @@ export async function openCatalogAlbum(albumId, opts = {}) {
|
|||||||
if (t) catalog.pendingTrackId = t.id;
|
if (t) catalog.pendingTrackId = t.id;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (active()) catalog.error = String(e);
|
if (active()) {
|
||||||
|
catalog.error = String(e);
|
||||||
|
// A held page would swallow the failure — the old edition stays up
|
||||||
|
// and the click looks like it did nothing. Clear so the error is
|
||||||
|
// the thing on screen, exactly as a normal navigation would.
|
||||||
|
if (opts.keepCurrent) catalog.albumPage = null;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (request === albumPageRequest) catalog.albumLoading = false;
|
if (request === albumPageRequest) catalog.albumLoading = false;
|
||||||
}
|
}
|
||||||
@@ -2032,6 +2045,56 @@ const catNorm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|||||||
|
|
||||||
/** Resolve an artist by name (cached) and open their catalog page — used by
|
/** Resolve an artist by name (cached) and open their catalog page — used by
|
||||||
* the now-playing artist link, which only knows the name. */
|
* the now-playing artist link, which only knows the name. */
|
||||||
|
/** Resolve an artist NAME to their catalog seed — `{ id, name, picture, fans,
|
||||||
|
* album_count }` — or null when no artist by that name exists.
|
||||||
|
*
|
||||||
|
* Shares the very caches `openArtistByName` fills, so a page that shows an
|
||||||
|
* artist's portrait beside a name has already paid for the click that
|
||||||
|
* navigates to them, and vice versa. In-flight lookups are deduped by name:
|
||||||
|
* an album page mounting can otherwise fire the same search its own back
|
||||||
|
* button is about to.
|
||||||
|
*/
|
||||||
|
const artistSeedRequests = new Map(); // normalized name → Promise<seed|null>
|
||||||
|
export function resolveArtistSeedByName(name) {
|
||||||
|
const norm = (name || "").trim().toLowerCase();
|
||||||
|
if (!norm) return Promise.resolve(null);
|
||||||
|
const cached = artistSeedCache.has(norm) ? touchCache(artistSeedCache, norm) : null;
|
||||||
|
if (cached) return Promise.resolve(cached);
|
||||||
|
// A remembered miss stays a miss — the negative is cached as a null id.
|
||||||
|
if (artistIdCache.has(norm) && touchCache(artistIdCache, norm) == null) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
const inflight = artistSeedRequests.get(norm);
|
||||||
|
if (inflight) return inflight;
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.catalogSearch(name);
|
||||||
|
const artists = res.artists ?? [];
|
||||||
|
// Same rule as openArtistByName: the most-followed exact match, so
|
||||||
|
// a shared name resolves to the canonical artist rather than an
|
||||||
|
// obscure namesake the search happened to list first.
|
||||||
|
const exact = artists.filter((a) => a.name.trim().toLowerCase() === norm);
|
||||||
|
const pool = exact.length ? exact : artists;
|
||||||
|
const best = pool.reduce(
|
||||||
|
(top, artist) => ((artist.fans ?? 0) > (top?.fans ?? -1) ? artist : top),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
if (!best) {
|
||||||
|
setBoundedMap(artistIdCache, norm, null, ARTIST_IDENTITY_CACHE_MAX);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return rememberArtistSeed(best);
|
||||||
|
} catch {
|
||||||
|
// Transport failures stay retryable — never negative-cached.
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
artistSeedRequests.delete(norm);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
artistSeedRequests.set(norm, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
export async function openArtistByName(name) {
|
export async function openArtistByName(name) {
|
||||||
const norm = (name || "").trim().toLowerCase();
|
const norm = (name || "").trim().toLowerCase();
|
||||||
if (!norm) return;
|
if (!norm) return;
|
||||||
|
|||||||
+1273
-153
File diff suppressed because it is too large.
Load diff
@@ -31,6 +31,12 @@
|
|||||||
} from "../lib/state.svelte.js";
|
} from "../lib/state.svelte.js";
|
||||||
import * as api from "../lib/api.js";
|
import * as api from "../lib/api.js";
|
||||||
import { accentFromImage } from "../lib/color.js";
|
import { accentFromImage } from "../lib/color.js";
|
||||||
|
import {
|
||||||
|
editionBaseTitle,
|
||||||
|
editionLabel,
|
||||||
|
isEdition,
|
||||||
|
releaseFamilies,
|
||||||
|
} from "../lib/editions.js";
|
||||||
import { buildArtistManifest } from "../lib/preview/manifest.js";
|
import { buildArtistManifest } from "../lib/preview/manifest.js";
|
||||||
import { openPreview } from "../lib/preview/previewSession.svelte.js";
|
import { openPreview } from "../lib/preview/previewSession.svelte.js";
|
||||||
import { traceToSvg, svgDataUrl } from "../lib/trace.js";
|
import { traceToSvg, svgDataUrl } from "../lib/trace.js";
|
||||||
@@ -2071,80 +2077,9 @@
|
|||||||
const isUpcomingAlbum = (a) => a.record_type?.toLowerCase() === "album"
|
const isUpcomingAlbum = (a) => a.record_type?.toLowerCase() === "album"
|
||||||
&& isFutureOrUnresolvedDate(a.release_date);
|
&& isFutureOrUnresolvedDate(a.release_date);
|
||||||
|
|
||||||
const EDITION_MARKER = /\b(?:super\s+deluxe|deluxe|expanded|remaster(?:ed)?|anniversary|legacy\s+edition|special\s+edition|collector'?s\s+edition|bonus\s+tracks?|complete\s+edition|definitive\s+edition|reissue|redux|mono\s+edition|stereo\s+edition|\w+(?:['’]s)?\s+edition|half[-\s]speed|track\s+by\s+track)\b/i;
|
// Edition identity (isEdition / editionBaseTitle / editionLabel /
|
||||||
const EDITION_SUFFIX = /\s*(?:[-–—:]\s*)?(?:the\s+)?(?:(?:\d{4}|\d+(?:st|nd|rd|th))\s+)?(?:super\s+deluxe|deluxe|expanded|remaster(?:ed)?|anniversary|legacy\s+edition|special\s+edition|collector'?s\s+edition|bonus\s+tracks?|complete\s+edition|definitive\s+edition|reissue|redux|mono\s+edition|stereo\s+edition|\w+(?:['’]s)?\s+edition|half[-\s]speed|track\s+by\s+track)(?:\s+(?:edition|version))?.*$/i;
|
// releaseFamilies) lives in src/lib/editions.js so the album page folds a
|
||||||
|
// record's pressings exactly the way this page does.
|
||||||
// A bracketed tail naming an alternate cut of the same work — "(Acoustic
|
|
||||||
// Version)", "(2010 Mono Version)", "(Urban Mixes)", "(Sampler)". These
|
|
||||||
// fold into the base release's family. "<Name>'s Version" is exempt: a
|
|
||||||
// re-recorded catalogue (Taylor's Version) is its own work and must keep
|
|
||||||
// its own card.
|
|
||||||
const VARIANT_SEGMENT = /\b(?:versions?|mix(?:es)?|edits?|acoustic|instrumentals?|sampler|extras|mono|stereo|demos?|extended|live)\b/i;
|
|
||||||
const OWN_WORK_SEGMENT = /^\S+['’]s\s+version$/i;
|
|
||||||
|
|
||||||
const isEdition = (a) => EDITION_MARKER.test(a.title)
|
|
||||||
|| /[\[(][^\])]*\bedition\b[^\])]*[\])]/i.test(a.title);
|
|
||||||
|
|
||||||
function editionBaseTitle(title) {
|
|
||||||
// Keep a useful qualifier that shares an edition bracket. For example,
|
|
||||||
// "Weezer (Deluxe Edition / Red Album)" belongs to "Weezer (Red
|
|
||||||
// Album)", not to every self-titled Weezer record.
|
|
||||||
const bracketSafe = title.replace(/\s*[\[(]([^\])]+)[\])]/g, (whole, inner) => {
|
|
||||||
const variantInner = VARIANT_SEGMENT.test(inner) && !OWN_WORK_SEGMENT.test(inner.trim());
|
|
||||||
if (!EDITION_MARKER.test(inner) && !/\bedition\b/i.test(inner) && !variantInner) return whole;
|
|
||||||
const qualifiers = inner
|
|
||||||
.split(/\s*[/|;]\s*/)
|
|
||||||
// A segment can carry a real qualifier AND a marker tail —
|
|
||||||
// "1973-1976 2011 Remaster" must keep "1973-1976" so the
|
|
||||||
// remaster lands in the same family as "Deep Cuts
|
|
||||||
// (1973-1976)". Strip the marker tail first, then judge.
|
|
||||||
.map((part) => part
|
|
||||||
.replace(/\s*(?:\d{4}\s+)?(?:super\s+deluxe|deluxe|expanded|remaster(?:ed)?|anniversary|reissue|redux)(?:\s+(?:edition|version))?\s*$/i, "")
|
|
||||||
.trim())
|
|
||||||
.filter((part) => part
|
|
||||||
&& !EDITION_MARKER.test(part)
|
|
||||||
&& !/\bedition\b/i.test(part)
|
|
||||||
&& !(VARIANT_SEGMENT.test(part) && !OWN_WORK_SEGMENT.test(part)));
|
|
||||||
return qualifiers.length ? ` (${qualifiers.join(" / ")})` : "";
|
|
||||||
});
|
|
||||||
const stripped = bracketSafe
|
|
||||||
.replace(EDITION_SUFFIX, "")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim()
|
|
||||||
.replace(/\s*[-–—:]\s*$/, "");
|
|
||||||
return stripped || title;
|
|
||||||
}
|
|
||||||
|
|
||||||
function editionLabel(edition, family) {
|
|
||||||
// The expander rail header already names the album, so each edition
|
|
||||||
// row shows only what differs: "Original" for the canonical release,
|
|
||||||
// otherwise the qualifier ("Deluxe Edition", "2025 Remaster"). The
|
|
||||||
// full title stays available as the row tooltip.
|
|
||||||
if (edition.id === family.id) return "Original";
|
|
||||||
const title = edition.title;
|
|
||||||
const base = family.baseTitle ?? "";
|
|
||||||
// "Weezer (Deluxe Edition / Red Album)" shares its bracket with the
|
|
||||||
// family qualifier — drop segments the family already carries so the
|
|
||||||
// label is just "Deluxe Edition". Test against the canonical title,
|
|
||||||
// not baseTitle: edition_group families derive baseTitle from
|
|
||||||
// whichever release was processed first, so it can be bare "Weezer".
|
|
||||||
const known = `${family.title ?? ""} ${base}`.toLowerCase();
|
|
||||||
const dropBaseParts = (raw) => {
|
|
||||||
const parts = raw.split(/\s*[/|;]\s*/).map((p) => p.trim()).filter(Boolean);
|
|
||||||
const kept = parts.filter((p) => !known.includes(p.toLowerCase()));
|
|
||||||
return (kept.length ? kept : parts).join(" / ");
|
|
||||||
};
|
|
||||||
const trim = (raw) => raw
|
|
||||||
.replace(/^[\s\-–—:([]+/, "")
|
|
||||||
.replace(/[)\]]+$/, "")
|
|
||||||
.trim();
|
|
||||||
if (base && title.toLowerCase().startsWith(base.toLowerCase())) {
|
|
||||||
const rest = trim(title.slice(base.length));
|
|
||||||
if (rest) return dropBaseParts(rest);
|
|
||||||
}
|
|
||||||
const suffix = title.match(EDITION_SUFFIX)?.[0];
|
|
||||||
return (suffix && dropBaseParts(trim(suffix))) || title;
|
|
||||||
}
|
|
||||||
|
|
||||||
function categoryFor(album) {
|
function categoryFor(album) {
|
||||||
// A backend-linked edition files WITH its base release: "The Life of a
|
// A backend-linked edition files WITH its base release: "The Life of a
|
||||||
@@ -2213,74 +2148,6 @@
|
|||||||
return ["album", "ep", "single"].includes(type) ? type : "other";
|
return ["album", "ep", "single"].includes(type) ? type : "other";
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseFamilies(items, category) {
|
|
||||||
// A work whose rows are only PARTLY edition_group-linked must not
|
|
||||||
// split into a work-keyed family and a title-keyed family showing two
|
|
||||||
// cards ("Nevermind" linked to its deluxe, "Nevermind (Remastered)"
|
|
||||||
// unlinked). Rows without a group adopt the group of a linked row
|
|
||||||
// that shares their base title.
|
|
||||||
const groupByBase = new Map();
|
|
||||||
for (const album of items) {
|
|
||||||
if (album.edition_group == null) continue;
|
|
||||||
const base = norm(editionBaseTitle(album.title));
|
|
||||||
if (base && !groupByBase.has(base)) groupByBase.set(base, album.edition_group);
|
|
||||||
}
|
|
||||||
const families = new Map();
|
|
||||||
for (const album of items) {
|
|
||||||
const baseTitle = editionBaseTitle(album.title);
|
|
||||||
// Only an EDITION may adopt a group by base title. The backend
|
|
||||||
// stamps `edition_group` on the base row as well as its editions,
|
|
||||||
// so a group always contains its own base — which means a
|
|
||||||
// plain-titled row that is unlinked belongs to no group, and
|
|
||||||
// adopting one can only ever attach it to somebody else's work.
|
|
||||||
// Measured: "Weezer (Deluxe Edition)" is linked to the 2008 Red
|
|
||||||
// Album and reduces to the base title "Weezer", so the bare-titled
|
|
||||||
// 2026 Gold Album adopted the Red Album's family and was filed as
|
|
||||||
// one of its editions. The case this heuristic exists for —
|
|
||||||
// "Nevermind (Remastered)" joining "Nevermind" — is an edition
|
|
||||||
// adopting, and still works.
|
|
||||||
const adopted = isEdition(album)
|
|
||||||
? groupByBase.get(norm(baseTitle))
|
|
||||||
: undefined;
|
|
||||||
const group = album.edition_group ?? adopted;
|
|
||||||
const key = group != null
|
|
||||||
? `${category}:work:${group}`
|
|
||||||
: `${category}:${norm(baseTitle) || album.id}`;
|
|
||||||
if (!families.has(key)) {
|
|
||||||
families.set(key, { key, baseTitle, editions: [] });
|
|
||||||
}
|
|
||||||
const family = families.get(key);
|
|
||||||
// Deezer can return the same regional/digital release under more
|
|
||||||
// than one catalog id, while MusicBrainz supplements can carry the
|
|
||||||
// same title/year without a track count. Those are duplicate source
|
|
||||||
// records, not editions. Different known track counts remain
|
|
||||||
// separate because they can represent a real expanded edition.
|
|
||||||
const releaseYear = year(album);
|
|
||||||
const duplicate = releaseYear && family.editions.some((edition) =>
|
|
||||||
norm(edition.title) === norm(album.title)
|
|
||||||
&& year(edition) === releaseYear
|
|
||||||
&& (edition.track_count == null
|
|
||||||
|| album.track_count == null
|
|
||||||
|| edition.track_count === album.track_count),
|
|
||||||
);
|
|
||||||
if (duplicate) continue;
|
|
||||||
family.editions.push(album);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...families.values()]
|
|
||||||
.map((family) => {
|
|
||||||
const byDate = family.editions
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => (a.release_date ?? "9999").localeCompare(b.release_date ?? "9999"));
|
|
||||||
const canonical = byDate.find((album) => !isEdition(album)) ?? byDate[0];
|
|
||||||
const editions = [canonical, ...byDate.filter((album) => album.id !== canonical.id)];
|
|
||||||
return { ...canonical, familyKey: family.key, baseTitle: family.baseTitle, editions };
|
|
||||||
})
|
|
||||||
// Studio chronology should follow the original album, not the date
|
|
||||||
// of its latest anniversary reissue.
|
|
||||||
.sort((a, b) => (b.release_date ?? "").localeCompare(a.release_date ?? ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Discography tabs (Spotify-style). Each tab merges one or more of the
|
// Discography tabs (Spotify-style). Each tab merges one or more of the
|
||||||
// nine fine-grained categories into a single date-sorted grid, so every
|
// nine fine-grained categories into a single date-sorted grid, so every
|
||||||
// release stays reachable and the per-grid expander machinery works
|
// release stays reachable and the per-grid expander machinery works
|
||||||
|
|||||||
Reference in new issue
Block a user