forked from Keelrleek/SoulStream
albums: one page for every record, and streamed history rows that actually open
Three things were wrong with opening an album, and only one of them was the one that got reported. The reported half: clicking a local record in the sidebar's history went to the old in-library album view. Local albums carry no catalog identity of any kind -- `albums.mbid` is null and is not even in the DTO -- so there was nothing to route with. openAlbumByName already existed for exactly this (the now-playing links use it, and it requires BOTH artist and title to match normalized, which is the namesake guard); it now reports whether it resolved and caches settled answers, so a second click is instant. The unreported half, and the reason "half of them are not working": every STREAMED row was dead. catalog_album_id is TEXT in SQLite, so it arrives as a string, and `catalog_album` takes a u64 -- the invoke was rejected outright with "invalid type: string ... expected u64". Terrestrials, OK Human and 9 all failed silently on click. The id is coerced in checkedAlbumPayload now, where both other string sources (page seeds) pass through too. And the part that made the first half unfixable: a record no catalog carries had nowhere to go. Panda Bear's "A ? of WHEN" is not on Deezer, so no amount of resolving would ever open it. Local albums now render on the SAME page, composed from the library's own data -- its tracks, durations, bitrates and the cover already on disk -- under a namespaced `lib:` id that can never collide with a Deezer one. Playback stays local: Play and any row queue the files you already have rather than sending a record you own back through the resolver. Save is not offered (it is already saved), previews are absent (there is nothing to preview), and the attribution footer credits your library rather than claiming Deezer + MusicBrainz. With that, the old view has no callers and is deleted: the album-page block, its CSS, and the members that only it used. Nothing anywhere routes to it. Verified live: all six history rows open the new page, a Home card opens it, Play queues 14 local FLACs without streaming, and the back arrow returns.
This commit is contained in:
1 parent
3c5bccf1b6
commit
dde8c7510b
9 files changed
+252
-365
No files matched your search
+146
-8
@@ -1279,6 +1279,21 @@ export async function openEntry(entry) {
|
||||
case "catalogArtist":
|
||||
return openCatalogArtist(entry.artistId);
|
||||
case "catalogAlbum":
|
||||
// A local record lives on this same view but is composed from the
|
||||
// library rather than fetched — the back arrow has to route on the
|
||||
// id's realm or it would ask the catalog for a `lib:` id.
|
||||
if (isLibraryAlbumId(entry.albumId)) {
|
||||
return openLibraryAlbum(libraryAlbumIdOf(entry.albumId), {
|
||||
seed: entry.art
|
||||
? {
|
||||
cover: entry.art,
|
||||
title: entry.label ?? null,
|
||||
artist: entry.artist ?? null,
|
||||
kind: null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
// `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.
|
||||
@@ -2413,26 +2428,149 @@ export async function openArtistByName(name) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Local library albums are rendered by the SAME page as catalog records —
|
||||
* there is no second album view. Their ids are namespaced so they can never
|
||||
* collide with a Deezer album id in the page cache or an identity check.
|
||||
* `openCatalogAlbum` refuses these; they go through `openLibraryAlbum`. */
|
||||
const LIB_ALBUM_PREFIX = "lib:";
|
||||
export const isLibraryAlbumId = (id) =>
|
||||
typeof id === "string" && id.startsWith(LIB_ALBUM_PREFIX);
|
||||
export const libraryAlbumIdOf = (id) =>
|
||||
isLibraryAlbumId(id) ? Number(id.slice(LIB_ALBUM_PREFIX.length)) : null;
|
||||
|
||||
/**
|
||||
* Open a LOCAL album on the album page. Same view, same shape — a library
|
||||
* record simply has no catalog counterpart to fetch, so the page is composed
|
||||
* from what the library itself knows: its tracks, their durations and
|
||||
* bitrates, and the cover already on disk.
|
||||
*
|
||||
* The remote-only sections (listeners, editions, prose) are absent rather than
|
||||
* empty; the page hides what it has no data for. Playback stays LOCAL — the
|
||||
* point of a library record is the file you already have.
|
||||
*/
|
||||
export async function openLibraryAlbum(albumId, opts = {}) {
|
||||
const id = Number(albumId);
|
||||
if (!Number.isFinite(id)) return;
|
||||
captureScroll();
|
||||
// A verified navigation supersedes any still-running name search, exactly
|
||||
// as openCatalogAlbum does — otherwise a slow resolve can land on top.
|
||||
albumResolveRequest += 1;
|
||||
const request = ++albumPageRequest;
|
||||
ui.view = "catalogAlbum";
|
||||
ui.albumId = null;
|
||||
catalog.albumPage = null;
|
||||
catalog.albumLoading = true;
|
||||
catalog.albumSeed = opts.seed ?? null;
|
||||
catalog.error = "";
|
||||
const active = () => request === albumPageRequest && ui.view === "catalogAlbum";
|
||||
try {
|
||||
if (!libraryStore.loaded) await refreshLibrary();
|
||||
const meta = libraryStore.albums.find((a) => a.id === id) ?? null;
|
||||
if (!meta) throw new Error("album is not in your library");
|
||||
const [tracks, cover] = await Promise.all([
|
||||
api.albumTracks(id),
|
||||
getArt(meta.art_path).catch(() => null),
|
||||
]);
|
||||
if (!active()) return;
|
||||
catalog.albumPage = {
|
||||
local: true,
|
||||
libraryAlbumId: id,
|
||||
libraryTracks: tracks,
|
||||
artPath: meta.art_path ?? null,
|
||||
album: {
|
||||
id: `${LIB_ALBUM_PREFIX}${id}`,
|
||||
title: meta.title,
|
||||
artist: meta.artist,
|
||||
cover: cover ?? null,
|
||||
release_date: meta.year ? String(meta.year) : null,
|
||||
record_type: "album",
|
||||
track_count: tracks.length,
|
||||
is_soundtrack: false,
|
||||
},
|
||||
// Shaped like catalog tracks so the tracklist renders unchanged;
|
||||
// `path`/`bitrate_kbps` ride along for the local row and playback.
|
||||
tracks: tracks.map((t) => ({
|
||||
id: `${LIB_ALBUM_PREFIX}t${t.id}`,
|
||||
title: t.title,
|
||||
artist: t.artist || meta.artist,
|
||||
album: meta.title,
|
||||
album_id: null,
|
||||
cover: cover ?? null,
|
||||
duration_secs: t.duration_secs ?? null,
|
||||
preview: null,
|
||||
track_no: t.track_no ?? null,
|
||||
unreleased: false,
|
||||
path: t.path,
|
||||
bitrate_kbps: t.bitrate_kbps ?? null,
|
||||
})),
|
||||
album_plays: null,
|
||||
album_listeners: null,
|
||||
totals_summed: false,
|
||||
};
|
||||
recordVisit({
|
||||
view: "catalogAlbum",
|
||||
label: meta.title,
|
||||
albumId: `${LIB_ALBUM_PREFIX}${id}`,
|
||||
artist: meta.artist,
|
||||
art: cover ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (active()) {
|
||||
catalog.error = String(error);
|
||||
catalog.albumPage = null;
|
||||
}
|
||||
} finally {
|
||||
if (active()) catalog.albumLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Confirmed answers from `openAlbumByName`, keyed on the normalized pair:
|
||||
* an album id, or `null` for a record no catalog has. Both are worth keeping —
|
||||
* a local-only album misses every time, and remembering the miss is what makes
|
||||
* a second click instant instead of re-running the search. Only SETTLED
|
||||
* answers land here; a transport failure stays retryable. */
|
||||
const albumNameResolves = new Map();
|
||||
|
||||
/** Resolve an album by artist + title (via catalog search) and open its page —
|
||||
* used by the now-playing album/song links when there's no catalog albumId
|
||||
* (e.g. a local library track). */
|
||||
* used by the now-playing album/song links and the sidebar's recently-played
|
||||
* rows when there's no catalog albumId (e.g. a local library track, which
|
||||
* carries no catalog identity of any kind).
|
||||
*
|
||||
* Returns the album id it opened, or `null` when nothing matched — callers
|
||||
* that have a fallback destination need to know which happened. */
|
||||
export async function openAlbumByName(artist, title, opts = {}) {
|
||||
const request = ++albumResolveRequest;
|
||||
if (opts.albumId != null) return openCatalogAlbum(opts.albumId, opts);
|
||||
if (!artist || !title) return;
|
||||
if (opts.albumId != null) {
|
||||
openCatalogAlbum(opts.albumId, opts);
|
||||
return opts.albumId;
|
||||
}
|
||||
if (!artist || !title) return null;
|
||||
const na = catNorm(artist);
|
||||
const nt = catNorm(title);
|
||||
const key = `${na}|${nt}`;
|
||||
if (albumNameResolves.has(key)) {
|
||||
const id = albumNameResolves.get(key);
|
||||
if (id != null) openCatalogAlbum(id, opts);
|
||||
return id;
|
||||
}
|
||||
try {
|
||||
const res = await api.catalogSearch(`${artist} ${title}`);
|
||||
if (request !== albumResolveRequest) return;
|
||||
const na = catNorm(artist);
|
||||
const nt = catNorm(title);
|
||||
if (request !== albumResolveRequest) return null;
|
||||
// A title-only or first-result fallback can cross into a namesake's
|
||||
// catalog. Without a verified id, both artist and title must match.
|
||||
const match = res.albums?.find(
|
||||
(album) => catNorm(album.artist) === na && catNorm(album.title) === nt,
|
||||
);
|
||||
if (match) openCatalogAlbum(match.id, opts);
|
||||
// A successful search that matched nothing is an answer ABOUT THE
|
||||
// RECORD: this artist has no such album in the catalog. Negative-cache
|
||||
// it. A throw below is an answer about the network, and is not cached.
|
||||
albumNameResolves.set(key, match ? match.id : null);
|
||||
if (!match) return null;
|
||||
openCatalogAlbum(match.id, opts);
|
||||
return match.id;
|
||||
} catch {
|
||||
// no album match — leave the view as-is
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user