v3 library: artist pages — in-your-library view, similar-in-library, links-only web links (#731)

* v3 library: artist pages — in-your-library view, similar-in-library, links-only web

The greenlit artist-pages feature. Every page renders from LOCAL data;
an optional, opt-in external-links strip is the only network surface.

Server:
- artist_enrichment table (mb_artist_id PK, url_rels JSON, genres JSON,
  fetched_at) — never purged; one row per matched MusicBrainz artist.
- GET /api/artist/{name}/page — all-local: canonical name (+ raw alias
  variants), song/album/mastered counts, album list, similar-in-library
  (top artists by shared genre, self excluded, empty is fine), and the
  artist's MB id when any matched/manual song carries one. THE DENOMINATOR
  LAW: "N mastered" counts songs you OWN (best_accuracy >= 0.9 across the
  artist's library songs), never a global discography — a stored score for
  a song no longer in the library does not count.
- GET /api/artist/{name}/links — lazy, cached-forever: returns the cached
  row, else (external links enabled + network + a known MB artist id) ONE
  throttled artist lookup (inc=url-rels+genres+tags), whitelisted into
  {official, tour, video, social[], wikipedia}. Every URL passes the same
  http(s) scheme gate as art redirects, so a hostile javascript:/data:/file:
  can never reach an href. POST .../links/refresh re-fetches. Offline /
  no-mbid / links-disabled → empty, no error. Both routes demo-blocked.
- Settings keys artist_pages_enabled (default ON — local-only) and
  artist_external_links (default OFF — opt-in per the dev-chat thread).

Frontend (static/v3/songs.js): an in-place sub-render mirroring openAlbum()
with a "← Song Library" back + scroll restore. 2x2 album-art mosaic header
(borrows the playlist-cover renderer), canonical name + "also shown as"
variants + a Matched·MusicBrainz pill when known; stats strip that omits
the mastered segment at zero (invitational, never "0 mastered"); Play all /
Shuffle (playQueue) + Save as smart playlist (collections rule {artist});
album rail → openAlbum; song list via the artist filter + wireCards;
"Similar in your library" chips → open that artist; and the external-links
row under an "On the web · opens your browser" divider, each link
target=_blank rel=noopener noreferrer with its domain shown — rendered only
when external links are on AND links exist. Empty modules hide.

Entry points: card ⋮ "Go to artist", the grid card artist line, and a
"View artist page" link in the Details drawer — all via
window.__fbOpenArtistPage.

Tests: tests/test_artist_page.py — page counts/albums/alias folding, the
denominator law (owned-only, best-across-arrangements), similar ranking +
empty, mb-id only from matched rows, links whitelist + scheme gate (a
javascript: and an ftp:// URL both rejected), disabled-by-default no
network, cache-no-second-fetch, refresh, demo block. 21 pass (35 with
artist_alias). node --check clean; tailwind rebuilt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(v3 artist pages): unfiltered album view + select-mode row guard on artist page

Artist-page album click no longer applies the global library filters: openAlbum
gains an ignoreFilters option that builds the /api/library request scoped only to
artist+album (no drawer/genre/tuning/search params), so the album view and
Play-album match the artist page's full-shelf counts. The normal albums-view
click path is unchanged (ignoreFilters defaults off).

Select-mode row clicks on the artist page now toggle selection instead of playing.
Extracted the grid/tree capture-phase select guard into a shared bindSelectGuard()
and attach it to the persistent artist-page host too. Each host is bound once at
shell build; innerHTML re-renders reuse the same element, so there is no
double-binding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
ChrisBeWithYou
2026-07-03 08:52:20 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent 64a499975e
commit be9e965001
6 changed files with 1179 additions and 66 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+13
View File
@@ -785,6 +785,19 @@
<span id="enrich-status" class="text-xs text-gray-500"></span>
</div>
</div>
<!-- Artist pages (PR-B — wired by static/v3/match-review.js). Sits
beside the Metadata matching card; the Settings→Library tab
regroup is a separate PR. -->
<div class="fb-srow fb-srow-stack">
<div class="fb-srow-main">
<div class="fb-srow-title">Artist pages</div>
<div class="fb-srow-desc">A page for every artist in your library — their songs, albums and your practice progress, built entirely from your local collection. External links (official site, tour dates, videos, social) come from one MusicBrainz lookup per matched artist and always open in your browser — nothing plays in-app, and they stay off until you opt in.</div>
</div>
<div class="grid grid-cols-2 gap-2 mb-1 text-xs text-gray-400 fb-srow-wide">
<label class="flex items-center gap-2"><input type="checkbox" id="artist-pages-enabled" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Artist pages</label>
<label class="flex items-center gap-2"><input type="checkbox" id="artist-external-links" class="rounded border-gray-600 bg-dark-700 text-accent"> Show external links (opens your browser)</label>
</div>
</div>
<!-- Backup -->
<div class="fb-srow fb-srow-stack">
<div class="fb-srow-main">
+11 -2
View File
@@ -475,14 +475,23 @@
['enrich-apply-year', 'enrich_apply_year'],
['enrich-apply-genres', 'enrich_apply_genres'],
['enrich-apply-art', 'enrich_apply_art'],
// Artist pages (PR-B): the page itself — local-only, default ON.
['artist-pages-enabled', 'artist_pages_enabled'],
].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el);
if (!toggles.length && !sel && !btn) return;
// Default-OFF toggles load with the opposite absent-key semantic
// (checked only when explicitly true): the external-links row is
// opt-IN per the dev-chat thread.
const optInToggles = [
['artist-external-links', 'artist_external_links'],
].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el);
if (!toggles.length && !optInToggles.length && !sel && !btn) return;
(async () => {
try {
const r = await fetch('/api/settings');
if (r.ok) {
const cfg = await r.json();
for (const [el, key] of toggles) el.checked = cfg[key] !== false;
for (const [el, key] of optInToggles) el.checked = cfg[key] === true;
if (sel) {
const t = Number(cfg.enrich_auto_threshold);
const want = Number.isFinite(t) ? t : 0.9;
@@ -502,7 +511,7 @@
refreshChip(); // also fills #enrich-status
})();
const save = (key, value) => post('/api/settings', { [key]: value });
for (const [el, key] of toggles) {
for (const [el, key] of toggles.concat(optInToggles)) {
el.addEventListener('change', () => save(key, !!el.checked));
}
sel?.addEventListener('change', () => save('enrich_auto_threshold', Number(sel.value)));
+425 -62
View File
@@ -65,6 +65,14 @@
scrollBound: false,
songsById: {}, selectMode: false, selected: new Set(),
railLetters: null, railLettersAreSongCounts: false, railJumping: false,
// ── Artist page (PR-B) ──
// Non-null while the artist sub-page is showing (the artist's canonical
// or raw name). The gates mirror the two Settings toggles: pages are
// local-only and default ON; the external-links row is opt-in.
artistPage: null,
artistReturnScroll: null, // scrollTop to restore on ← Song Library
artistPagesEnabled: true,
artistLinksEnabled: false,
// ── Windowed (virtualized) grid, stage 2 of #636 item 3 ──
// state.songs is a SPARSE array indexed by absolute library position
// (0..total-1); only the fetched pages are populated and only the visible
@@ -845,7 +853,15 @@
'<button data-menu title="More" aria-label="More actions" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white text-sm leading-none">⋮</button>' +
'</div></div>' +
'<div class="mt-1 text-sm text-fb-text truncate" title="' + esc(shown.title) + '">' + esc(shown.title) + '</div>' +
'<div class="text-xs text-fb-textDim truncate">' + esc(song.artist) + '</div>' +
// Artist line → the artist page (PR-B, entry point 2). The text
// block sits OUTSIDE the data-v3-play hitbox, so making it a
// button steals no play clicks. Same classes/line-height as the
// plain div (uniform card height is what makes the windowed
// grid's absolute-position math exact); non-local providers and
// the pages-off setting keep the original inert div.
((state.provider === 'local' && song.artist && state.artistPagesEnabled !== false)
? '<button data-v3-artist class="block w-full text-left text-xs text-fb-textDim truncate hover:text-fb-primary transition" title="Go to ' + esc(song.artist) + '">' + esc(song.artist) + '</button>'
: '<div class="text-xs text-fb-textDim truncate">' + esc(song.artist) + '</div>') +
// Always emit the chip row (even when empty) at a FIXED single-line
// height — uniform card height is what makes the windowed grid's
// absolute-position math exact (.v3-card-chips in v3.css).
@@ -888,6 +904,10 @@
? [{ id: '__unsplit', label: 'Rejoin other versions' }] : []),
{ id: '__playlist', label: 'Add to playlist' },
{ id: '__save', label: 'Save for later' },
// Artist page (PR-B, entry point 1) — local library only (the
// page reads the local DB) and gated on the Settings toggle.
...(state.provider === 'local' && song.artist && state.artistPagesEnabled !== false
? [{ id: '__artist', label: 'Go to artist' }] : []),
...items.map((a) => ({ id: a.id, label: a.label, destructive: a.destructive, enabled: a.enabled, plugin: a.pluginId })),
// Metadata + file actions (R2) — local library only (they all
// address the local DB / filesystem). Both openers (⋮ and
@@ -937,6 +957,7 @@
}
if (id === '__playlist') { await addFilenamesToPlaylist([song.filename]); return; }
if (id === '__save') { if (window.v3Saved) await window.v3Saved.toggle(song.filename); return; }
if (id === '__artist') { openArtistPage(song.artist); return; }
// Per-chart metadata actions follow the DISPLAYED chart (playTarget),
// like Play — under an intrinsic filter that's the matching member,
// not the group representative. (__remove stays on `song`: it needs
@@ -1410,6 +1431,12 @@
e.stopPropagation();
openChartsDrawer(e.currentTarget.getAttribute('data-charts'), song);
});
// Artist line → the artist page (PR-B). In select mode the grid's
// capture-phase toggle intercepts first, so selection still wins.
el.querySelector('[data-v3-artist]')?.addEventListener('click', (e) => {
e.stopPropagation();
openArtistPage(song.artist);
});
el.querySelector('[data-fav]')?.addEventListener('click', async (e) => {
e.stopPropagation();
const btn = e.currentTarget;
@@ -1452,6 +1479,26 @@
renderBatchBar();
}
// Bulletproof multi-select: in select mode a capture-phase click anywhere
// inside a [data-fn] row toggles the card and STOPS the event, so nothing (a
// per-card handler, a stray/legacy listener, an arrangement chip) can start
// playback. Attached ONCE to each persistent host (grid / tree / artist page)
// — their innerHTML is replaced on re-render but the host element survives,
// so a single bind never double-fires. Group headers / non-song chrome sit
// outside any [data-fn], so closest() is null and their native clicks pass
// through untouched.
function bindSelectGuard(hostEl) {
if (!hostEl) return;
hostEl.addEventListener('click', (e) => {
if (!state.selectMode) return;
const card = e.target.closest('[data-fn]');
if (!card || !hostEl.contains(card)) return;
e.preventDefault();
e.stopImmediatePropagation();
toggleSelect(card.getAttribute('data-fn'), card);
}, true);
}
function setSelectMode(on) {
state.selectMode = on;
if (!on) state.selected.clear();
@@ -2260,18 +2307,29 @@
if (a) openAlbum(a);
}));
}
async function openAlbum(a) {
const host = document.getElementById('v3-songs-albums');
// `opts` (PR-B): the artist page reuses this album detail inside its own
// host with its own back label/target — { host, backLabel, onBack,
// ignoreFilters }. Call sites without opts are byte-for-byte the original
// albums-view flow.
async function openAlbum(a, opts) {
const host = (opts && opts.host) || document.getElementById('v3-songs-albums');
if (!host) return;
const backLabel = (opts && opts.backLabel) || '← Albums';
const onBack = (opts && opts.onBack) || (() => loadAlbums());
host.innerHTML = '<p class="text-fb-textDim text-sm">Loading…</p>';
// Honour the active drawer filters (like the album grid) but pin THIS
// album's artist/album and force track order — so the track list and
// Play-album never include songs the user filtered out.
const p = queryParams({ artist: a.artist, album: a.album, size: '300', sort: 'track' }, { catalog: true });
// Normally honour the active drawer filters (like the album grid) but pin
// THIS album's artist/album and force track order — so the track list and
// Play-album never include songs the user filtered out. When opened FROM
// an artist page (ignoreFilters), drop the global filters entirely: the
// artist page is the artist's whole shelf, so its album view must show
// every track to match the page's counts — scoped only to artist+album.
const p = (opts && opts.ignoreFilters)
? new URLSearchParams({ provider: state.provider, artist: a.artist, album: a.album, size: '300', sort: 'track' })
: queryParams({ artist: a.artist, album: a.album, size: '300', sort: 'track' }, { catalog: true });
const data = await jget('/api/library?' + p.toString());
const songs = (data && data.songs) || [];
host.innerHTML =
'<button data-albums-back class="text-sm text-fb-textDim hover:text-fb-text mb-4">← Albums</button>' +
'<button data-albums-back class="text-sm text-fb-textDim hover:text-fb-text mb-4">' + esc(backLabel) + '</button>' +
'<div class="flex items-center justify-between gap-3 mb-4">' +
'<div class="min-w-0"><h2 class="text-2xl font-bold text-fb-text truncate">' + esc(a.album) + '</h2>' +
'<p class="text-sm text-fb-textDim truncate">' + esc(a.artist) + ' · ' + songs.length + ' track' + (songs.length === 1 ? '' : 's') + '</p></div>' +
@@ -2281,7 +2339,7 @@
'<li><button data-album-track="' + i + '" class="w-full flex items-center gap-3 px-3 py-2 rounded-md hover:bg-white/5 text-left">' +
'<span class="text-xs text-fb-textDim w-6 text-right">' + (i + 1) + '</span>' +
'<span class="flex-1 truncate text-sm text-fb-text">' + esc(s.title || s.filename) + '</span></button></li>').join('') + '</ul>';
host.querySelector('[data-albums-back]')?.addEventListener('click', () => loadAlbums());
host.querySelector('[data-albums-back]')?.addEventListener('click', () => onBack());
host.querySelector('[data-album-playall]')?.addEventListener('click', () => {
const files = songs.map((s) => s.filename).filter(Boolean);
if (!files.length) return;
@@ -2294,6 +2352,315 @@
}));
}
// One list-row of a song — shared by the tree view and the artist page's
// song list, so wireCards() gives both the same play/chips/fav/save/⋮
// behaviour from one markup source.
function treeSongRowHtml(s) {
const k = cardKey(s); const fl = fmtLabel(s); const chips = arrChipsHtml(s); const sel = state.selected.has(k);
// Display-only checkbox (pointer-events-none); the row's
// capture-phase select handler (render()) owns the toggle.
const checkbox = state.selectMode
? '<input type="checkbox" data-select class="shrink-0 w-5 h-5 accent-fb-primary pointer-events-none"' + (sel ? ' checked' : '') + '>'
: '';
return (
'<div class="relative flex items-center gap-2 py-1 group" data-fn="' + esc(k) + '" data-library-song="' + esc(songId(s)) + '" data-library-provider="' + esc(state.provider) + '">' +
checkbox +
'<img src="' + esc(artUrl(s)) + '" alt="" loading="lazy" decoding="async" class="w-8 h-8 rounded object-cover bg-fb-card cursor-pointer' + (sel ? ' ring-2 ring-fb-primary' : '') + '" data-v3-play onerror="this.style.visibility=\'hidden\'">' +
'<span class="flex-1 min-w-0 cursor-pointer" data-v3-play><span class="block text-sm text-fb-text truncate">' + esc(s.title) + '</span></span>' +
(chips ? '<span class="hidden sm:flex items-center gap-1 shrink-0">' + chips + '</span>' : '') +
(fl ? '<span class="text-[0.5625rem] font-bold px-1 py-0.5 rounded shrink-0 ' + (fl === 'FEEDPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card text-fb-textDim') + '">' + fl + '</span>' : '') +
accuracyBadge(k, 'tree') +
// Same fav / save-for-later / overflow-menu cluster as the grid
// card. Always shown (like the arrangement chips), not hover-
// revealed. wireCards() binds all three for any [data-fn].
'<div class="flex items-center gap-0.5 shrink-0">' +
'<button data-fav data-fav-idle="text-fb-textDim" title="Favorite" aria-label="Favorite" aria-pressed="' + (s.favorite ? 'true' : 'false') + '" class="px-1 ' + (s.favorite ? 'text-fb-accent' : 'text-fb-textDim') + '">' + (s.favorite ? '♥' : '♡') + '</button>' +
'<button data-save title="Save for later" aria-label="Save for later" class="px-1 text-fb-textDim hover:text-fb-text">🔖</button>' +
'<button data-menu title="More" aria-label="More actions" class="px-1 text-fb-textDim hover:text-fb-text leading-none">⋮</button>' +
'</div>' +
'</div>');
}
// ── Artist page (PR-B, artist-pages launch charrette) ──────────────────────
// An in-place sub-render like openAlbum(): the artist "in your library" — a
// shelf plus your relationship to it, never a discography browser (locked
// position 1). Renders 100% from the local /page payload; the external
// links row is the one decorated extra, gated on the opt-in Settings toggle
// AND a MusicBrainz match, fetched lazily and cached server-side. Every
// count obeys the DENOMINATOR LAW (locked position 2): songs YOU OWN.
function _artistHostEl() { return document.getElementById('v3-songs-artistpage'); }
// Sync the two Settings gates into module state (fire-and-forget — the
// cached flags gate entry-point rendering; openArtistPage re-checks).
function refreshArtistPageGates() {
return jget('/api/settings').then((cfg) => {
if (!cfg) return;
state.artistPagesEnabled = cfg.artist_pages_enabled !== false;
state.artistLinksEnabled = cfg.artist_external_links === true;
});
}
// 2×2 mosaic of the artist's OWN album art — the playlist-cover grammar
// (#626 playlistCoverHtml) adapted to the page payload's art_urls. Never a
// broken-image tile: no art → a quiet glyph.
function artistMosaicHtml(arts) {
const box = 'w-32 h-32 sm:w-40 sm:h-40 shrink-0 rounded-xl overflow-hidden bg-fb-card';
const img = (u) => '<img src="' + esc(u) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover" onerror="this.style.visibility=\'hidden\'">';
if (!arts || !arts.length) return '<div class="' + box + ' flex items-center justify-center text-5xl text-fb-textDim">🎤</div>';
if (arts.length < 4) return '<div class="' + box + '">' + img(arts[0]) + '</div>';
return '<div class="' + box + ' grid grid-cols-2 grid-rows-2 gap-px">' + arts.slice(0, 4).map(img).join('') + '</div>';
}
function _linkDomain(u) {
try { return new URL(u).hostname.replace(/^www\./, ''); } catch (_) { return ''; }
}
// Toggle the browse hosts (grid/tree/albums/folder + home + rail) so the
// artist page can own the scroller, and back again on close.
function _setBrowseHostsHidden(hidden) {
if (hidden) {
['v3-songs-gridsizer', 'v3-songs-tree', 'v3-songs-albums', 'lib-folder-tree',
'v3-lib-home', 'v3-songs-azrail', 'v3-songs-azbubble']
.forEach((id) => document.getElementById(id)?.classList.add('hidden'));
const fc = document.getElementById('lib-folder-controls');
if (fc) fc.style.display = 'none';
} else {
document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', state.view !== 'grid');
document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
document.getElementById('v3-songs-albums')?.classList.toggle('hidden', state.view !== 'albums');
document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder');
const fc = document.getElementById('lib-folder-controls');
if (fc) fc.style.display = state.view === 'folder' ? 'flex' : 'none';
refreshRail();
updateLibraryHome();
}
}
// The one exported opener — every entry point (card ⋮ / right-click "Go to
// artist", the grid card's artist line, the Details drawer link, a
// similar-artist chip) funnels through here.
async function openArtistPage(artistName) {
const host = _artistHostEl();
if (!host || !artistName) return;
if (state.provider !== 'local' || state.artistPagesEnabled === false) return;
const main = _getV3MainScroller();
// Remember where browsing left off ONCE — chip-hopping between artist
// pages keeps the original return point.
if (!state.artistPage) state.artistReturnScroll = main ? main.scrollTop : 0;
state.artistPage = artistName;
_setBrowseHostsHidden(true);
host.classList.remove('hidden');
host.innerHTML = '<p class="text-fb-textDim text-sm">Loading…</p>';
_applyMainScrollTop(0);
const page = await jget('/api/artist/' + enc(artistName) + '/page');
if (state.artistPage !== artistName) return; // superseded
if (!page) { closeArtistPage(); return; }
await renderArtistPage(page);
}
function closeArtistPage() {
const host = _artistHostEl();
if (host) { host.classList.add('hidden'); host.innerHTML = ''; }
if (!state.artistPage) return;
state.artistPage = null;
_setBrowseHostsHidden(false);
const top = state.artistReturnScroll;
state.artistReturnScroll = null;
_applyMainScrollTop(top || 0);
if (state.view === 'grid') requestWindowRender();
}
// reload() (any toolbar-driven change) leaves the sub-page without the
// scroll restore — the new state describes a fresh browse from the top.
function _dropArtistPageSilently() {
if (!state.artistPage) return;
state.artistPage = null;
state.artistReturnScroll = null;
const host = _artistHostEl();
if (host) { host.classList.add('hidden'); host.innerHTML = ''; }
}
async function renderArtistPage(page) {
const host = _artistHostEl();
if (!host) return;
const me = state.artistPage;
const name = page.artist || me || '';
// Songs list: page through /api/library with the artist filter (locked
// position 6 — query_page, keyset-safe; never the DISTINCT+OFFSET
// query_artists path). Unfiltered on purpose: the page is the artist's
// whole shelf, not the grid's current filter view.
const songs = [];
let p = 0, total = Infinity;
while (songs.length < total) {
const q = new URLSearchParams({
provider: 'local', artist: name, sort: 'artist',
size: '100', page: String(p),
});
const data = await jget('/api/library?' + q.toString());
if (!data || !Array.isArray(data.songs)) break;
songs.push(...data.songs);
total = (data.total != null) ? data.total : songs.length;
if (!data.songs.length || p > 50) break; // safety: no progress / runaway
p++;
}
if (state.artistPage !== me || !host.isConnected) return; // superseded mid-fetch
songs.forEach((s) => { state.songsById[cardKey(s)] = s; });
const aliasLine = (page.variants || []).length
? '<div class="text-xs text-fb-textDim mt-1">also shown as: ' +
page.variants.map((v) => esc(v.name) + ' ×' + v.count).join(' · ') + '</div>'
: '';
// Provenance pill — only when the artist is actually matched (drawer/
// Get-info grammar: say where the tidy names come from, ≤2 taps away).
const pill = page.mb_artist_id
? '<div class="mt-2"><span class="inline-flex items-center text-[0.625rem] px-2 py-0.5 rounded-full bg-fb-primary/15 text-fb-primary border border-fb-primary/40" title="This artist is matched to MusicBrainz — the match lives in your local cache; your files are never modified">Matched · MusicBrainz</span></div>'
: '';
// Stats strip. DENOMINATOR LAW: every number is songs in YOUR library;
// the mastered segment is omitted entirely until one exists —
// invitational, never "0 mastered" (launch blind-spot #3).
const bits = [
page.song_count + ' song' + (page.song_count === 1 ? '' : 's'),
page.album_count + ' album' + (page.album_count === 1 ? '' : 's'),
];
if (page.mastered_count > 0) bits.push(page.mastered_count + ' mastered');
const albumsHtml = (page.albums || []).length
? '<section class="mt-6"><h3 class="text-sm font-semibold text-fb-text mb-2">Albums</h3>' +
'<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">' +
page.albums.map((al, i) =>
'<button data-ap-album="' + i + '" class="group text-left">' +
'<div class="aspect-square rounded-lg overflow-hidden bg-fb-card mb-2">' +
(al.cover ? '<img src="' + esc(artUrl({ filename: al.cover })) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' : '') +
'</div>' +
'<div class="text-sm text-fb-text truncate">' + esc(al.name) + '</div>' +
'<div class="text-xs text-fb-textDim truncate">' + (al.year ? esc(al.year) + ' · ' : '') + (al.count || 0) + ' track' + (al.count === 1 ? '' : 's') + '</div>' +
'</button>').join('') +
'</div></section>'
: '';
const songsHtml = songs.length
? '<section class="mt-6"><h3 class="text-sm font-semibold text-fb-text mb-2">Songs</h3>' +
'<div class="space-y-0.5">' + songs.map(treeSongRowHtml).join('') + '</div></section>'
: '<p class="text-sm text-fb-textDim mt-6">No songs by this artist are in your library.</p>';
// Similar in your library (locked position 3): genre co-occurrence over
// artists you already OWN — never an acquisition funnel. Empty → the
// whole module hides (never "Similar: none").
const similarHtml = (page.similar || []).length
? '<section class="mt-6"><h3 class="text-sm font-semibold text-fb-text mb-2">Similar in your library</h3>' +
'<div class="flex flex-wrap gap-2">' +
page.similar.map((s) =>
'<button data-ap-similar="' + esc(s.artist) + '" class="text-xs px-3 py-1.5 rounded-full bg-fb-card/60 border border-fb-border/50 text-fb-text hover:border-fb-primary/60 hover:text-fb-primary transition">' + esc(s.artist) + '</button>').join('') +
'</div></section>'
: '';
host.innerHTML =
'<button data-ap-back class="text-sm text-fb-textDim hover:text-fb-text mb-4">← Song Library</button>' +
'<div class="flex items-start gap-4">' +
artistMosaicHtml(page.art_urls) +
'<div class="min-w-0 flex-1">' +
'<h2 class="text-2xl font-bold text-fb-text truncate" title="' + esc(name) + '">' + esc(name) + '</h2>' +
aliasLine + pill +
'<p class="text-sm text-fb-textDim mt-2">' + bits.join(' · ') + '</p>' +
'<div class="flex flex-wrap gap-2 mt-3">' +
(songs.length
? '<button data-ap-playall class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play all</button>' +
'<button data-ap-shuffle class="bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 text-fb-text text-sm px-4 py-2 rounded-md">⇄ Shuffle</button>'
: '') +
'<button data-ap-smart class="bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 text-fb-text text-sm px-4 py-2 rounded-md" title="A live playlist of everything by this artist — new songs join it automatically">Save as smart playlist</button>' +
'</div>' +
'</div></div>' +
albumsHtml +
songsHtml +
similarHtml +
// External links land here (lazy fetch) — hidden until they exist.
'<div data-ap-links></div>';
host.querySelector('[data-ap-back]')?.addEventListener('click', closeArtistPage);
// Play all / Shuffle → the shared playQueue (same path as Play-album).
const startQueue = (shuffle) => {
let files = songs.map((s) => s.filename).filter(Boolean);
if (!files.length) return;
if (shuffle) {
files = files.slice();
for (let i = files.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const t = files[i]; files[i] = files[j]; files[j] = t;
}
}
_saveLibraryScrollSnapshot();
if (window.feedBack && window.feedBack.playQueue) window.feedBack.playQueue.start(files, { source: name });
else if (typeof window.playSong === 'function') window.playSong(enc(files[0]));
};
host.querySelector('[data-ap-playall]')?.addEventListener('click', () => startQueue(false));
host.querySelector('[data-ap-shuffle]')?.addEventListener('click', () => startQueue(true));
// Save as smart playlist (locked position 12): a rules-based
// collection over the existing machinery — a LIVING query that
// regenerates, never a completable checklist.
host.querySelector('[data-ap-smart]')?.addEventListener('click', async (e) => {
const btn = e.currentTarget;
const res = await jsend('POST', '/api/collections', { name: name, rules: { artist: name } });
if (res && res.ok) {
btn.textContent = '✓ Saved';
btn.disabled = true;
if (window.fbNotify) {
try { window.fbNotify.show({ title: 'Smart playlist saved', message: '“' + name + '” is now a source in the library picker', icon: '🎵' }); } catch (_) { /* */ }
}
}
});
// Album cells reuse the album detail in place; back returns HERE.
host.querySelectorAll('[data-ap-album]').forEach((b) => b.addEventListener('click', () => {
const al = (page.albums || [])[Number(b.getAttribute('data-ap-album'))];
if (!al) return;
openAlbum({ artist: name, album: al.name },
{ host: host, backLabel: '← ' + name, onBack: () => openArtistPage(name), ignoreFilters: true });
}));
// Similar chips → that artist's page (the return point stays the
// original browse position — see openArtistPage).
host.querySelectorAll('[data-ap-similar]').forEach((b) => b.addEventListener('click', () => {
openArtistPage(b.getAttribute('data-ap-similar'));
}));
wireCards(host);
decorateTuningChips(host); // feature-detected; no-op without the capability
_fillArtistLinks(host, name, page);
}
// External links row (locked position 4): whitelisted MB url-rels, opt-in
// via Settings, always the external browser, domain visible. Renders ONLY
// when the toggle is on AND the fetch yields links — otherwise the section
// simply never appears (empty modules hide).
async function _fillArtistLinks(host, name, page) {
if (!state.artistLinksEnabled || !page.mb_artist_id) return;
const slot = host.querySelector('[data-ap-links]');
if (!slot) return;
const data = await jget('/api/artist/' + enc(name) + '/links');
// slot.isConnected covers every superseded case — navigating away, a
// reload, or hopping to another artist all replace this DOM.
if (!data || !slot.isConnected) return;
const links = data.links || {};
const items = [];
const push = (label, url) => { if (url) items.push({ label: label, url: url }); };
push('Official site', links.official);
push('Tour dates', links.tour);
push('Videos', links.video);
(Array.isArray(links.social) ? links.social : []).forEach((u) => push('Social', u));
push('Wikipedia', links.wikipedia);
if (!items.length) return;
slot.innerHTML =
'<div class="mt-6 pt-4 border-t border-fb-border/40">' +
'<div class="text-xs text-fb-textDim mb-2">On the web · opens your browser</div>' +
'<div class="flex flex-wrap gap-2">' +
items.map((it) =>
'<a href="' + esc(it.url) + '" target="_blank" rel="noopener noreferrer" class="text-xs px-3 py-1.5 rounded-full bg-fb-card/60 border border-fb-border/50 text-fb-text hover:border-fb-primary/60 transition">' +
esc(it.label) + ' ↗ <span class="text-fb-textDim">' + esc(_linkDomain(it.url)) + '</span></a>').join('') +
'</div></div>';
}
// Global opener — the drawer link, plugins, and other views reach the page
// without touching this module's internals.
window.__fbOpenArtistPage = openArtistPage;
async function loadTree() {
const host = document.getElementById('v3-songs-tree');
if (!host) return;
@@ -2326,30 +2693,7 @@
'<span>' + esc(a.name) + '</span><span class="text-xs text-fb-textDim">' + esc(a.song_count) + '</span></summary>' +
'<div class="pl-3 pb-2 space-y-2">' + (a.albums || []).map((al) =>
'<div><div class="text-xs uppercase tracking-wider text-fb-textDim/70 mt-2 mb-1">' + esc(al.name || 'Unknown') + '</div>' +
(al.songs || []).map((s) => {
const k = cardKey(s); const fl = fmtLabel(s); const chips = arrChipsHtml(s); const sel = state.selected.has(k);
// Display-only checkbox (pointer-events-none); the row's
// capture-phase select handler (render()) owns the toggle.
const checkbox = state.selectMode
? '<input type="checkbox" data-select class="shrink-0 w-5 h-5 accent-fb-primary pointer-events-none"' + (sel ? ' checked' : '') + '>'
: '';
return (
'<div class="relative flex items-center gap-2 py-1 group" data-fn="' + esc(k) + '" data-library-song="' + esc(songId(s)) + '" data-library-provider="' + esc(state.provider) + '">' +
checkbox +
'<img src="' + esc(artUrl(s)) + '" alt="" loading="lazy" decoding="async" class="w-8 h-8 rounded object-cover bg-fb-card cursor-pointer' + (sel ? ' ring-2 ring-fb-primary' : '') + '" data-v3-play onerror="this.style.visibility=\'hidden\'">' +
'<span class="flex-1 min-w-0 cursor-pointer" data-v3-play><span class="block text-sm text-fb-text truncate">' + esc(s.title) + '</span></span>' +
(chips ? '<span class="hidden sm:flex items-center gap-1 shrink-0">' + chips + '</span>' : '') +
(fl ? '<span class="text-[0.5625rem] font-bold px-1 py-0.5 rounded shrink-0 ' + (fl === 'FEEDPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card text-fb-textDim') + '">' + fl + '</span>' : '') +
accuracyBadge(k, 'tree') +
// Same fav / save-for-later / overflow-menu cluster as the grid
// card. Always shown (like the arrangement chips), not hover-
// revealed. wireCards() binds all three for any [data-fn].
'<div class="flex items-center gap-0.5 shrink-0">' +
'<button data-fav data-fav-idle="text-fb-textDim" title="Favorite" aria-label="Favorite" aria-pressed="' + (s.favorite ? 'true' : 'false') + '" class="px-1 ' + (s.favorite ? 'text-fb-accent' : 'text-fb-textDim') + '">' + (s.favorite ? '♥' : '♡') + '</button>' +
'<button data-save title="Save for later" aria-label="Save for later" class="px-1 text-fb-textDim hover:text-fb-text">🔖</button>' +
'<button data-menu title="More" aria-label="More actions" class="px-1 text-fb-textDim hover:text-fb-text leading-none">⋮</button>' +
'</div>' +
'</div>'); }).join('') + '</div>').join('') + '</div></details>').join('');
(al.songs || []).map(treeSongRowHtml).join('') + '</div>').join('') + '</div></details>').join('');
wireCards(host);
}
@@ -2626,7 +2970,13 @@
// Identity — writes back into the feedpak FILE
'<div class="space-y-3"><div class="flex items-center gap-2"><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Identity</div>' +
'<span class="text-[0.625rem] px-1.5 py-0.5 rounded-full bg-gray-800/70 text-fb-textDim border border-gray-700" title="These came from the song&#39;s feedpak. Editing them writes back to the file.">From pack</span></div>' +
field('det-title', 'Title', st.t) + field('det-artist', 'Artist', st.a) + field('det-album', 'Album', st.al) +
field('det-title', 'Title', st.t) + field('det-artist', 'Artist', st.a) +
// Artist page (PR-B, entry point 3) — a small jump-off next to the
// Artist field; local library + pages-toggle gated like the others.
((state.provider === 'local' && (st.a || song.artist) && state.artistPagesEnabled !== false)
? '<button data-det-artist-page class="text-xs text-fb-primary hover:text-fb-primaryHi text-left">View artist page →</button>'
: '') +
field('det-album', 'Album', st.al) +
'<div><label for="det-year" class="text-xs text-fb-textDim mb-1 block">Year</label><input type="text" inputmode="numeric" id="det-year" value="' + esc(st.y) + '" placeholder="e.g. 2024" class="w-full bg-fb-card border border-fb-border/60 rounded-lg px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary/60"></div>' +
provenanceHtml(st) +
'<div data-det-gapfill>' + gapFillHtml(st) + '</div></div>' +
@@ -2714,6 +3064,15 @@
closeDetails();
if (window.__fbFixMatch) window.__fbFixMatch(song);
});
// "View artist page →" — uses the field's CURRENT text (an in-progress
// rename still lands on the right page once saved; unsaved text simply
// canonicalizes server-side), falling back to the row's artist.
$('[data-det-artist-page]')?.addEventListener('click', () => {
const a = (st.a || '').trim() || song.artist || '';
if (!a) return;
closeDetails();
openArtistPage(a);
});
// Gap-fill (R4a): user-initiated write of CONFIRMED missing info into
// the pack file. The server recomputes proposals under its io lock, so
@@ -2950,6 +3309,10 @@
function reload() {
_clearLibraryScrollSnapshot();
// Any toolbar-driven change backs out of the artist sub-page — the new
// state describes a fresh browse, and the host toggles below re-show
// the picked view (mirrors how openAlbum's detail yields to a reload).
_dropArtistPageSilently();
// Record the state this fetch reflects so a later sidebar return can
// tell whether the grid is stale (e.g. an off-screen search changed
// state.q) and needs a refresh rather than a scroll-preserving no-op.
@@ -3019,6 +3382,9 @@
(async () => { state.accuracy = (await jget('/api/stats/best')) || {}; })(),
jget('/api/library/tuning-names?provider=' + enc(state.provider)),
loadArtistCatalog(),
// Artist-page gates (PR-B) ride the initial fetch batch so the
// first card paint already knows whether artist lines are links.
refreshArtistPageGates(),
]);
state.tuningNames = (tn && tn.tunings) || [];
try { const _g = await jget('/api/library/genres?provider=' + enc(state.provider)); state.genres = (_g && _g.genres) || []; } catch (e) { state.genres = []; }
@@ -3062,6 +3428,9 @@
'</div>' +
'<div id="v3-songs-tree" class="hidden"></div>' +
'<div id="v3-songs-albums" class="hidden"></div>' +
// Artist page host (PR-B) — an openAlbum-style in-place sub-render;
// populated + shown by openArtistPage, cleared on close/reload.
'<div id="v3-songs-artistpage" class="hidden"></div>' +
'<div id="lib-folder-controls" style="display:none"></div>' +
'<div id="lib-folder-tree" class="space-y-1 hidden"></div>' +
'<div id="v3-songs-sentinel" class="h-8"></div>' +
@@ -3120,34 +3489,16 @@
} catch (e) { /* */ }
})();
// Bulletproof multi-select: in select mode, a capture-phase click on the
// grid toggles the card and STOPS the event, so nothing (a per-card
// handler, a stray/legacy listener, an arrangement chip) can start
// playback. Fixes "checkbox click opens the song / access-denied".
const gridEl = byId('v3-songs-grid');
if (gridEl) gridEl.addEventListener('click', (e) => {
if (!state.selectMode) return;
const card = e.target.closest('[data-fn]');
if (!card || !gridEl.contains(card)) return;
e.preventDefault();
e.stopImmediatePropagation();
toggleSelect(card.getAttribute('data-fn'), card);
}, true);
// Same bulletproof guard for the list/tree view. Without it, clicking a
// song row (or its arrangement chip) in select mode falls through to the
// per-card play handler and starts playback instead of selecting. The
// <summary> group headers sit OUTSIDE any [data-fn], so closest() is null
// for them and their native expand/collapse is left untouched.
const treeEl = byId('v3-songs-tree');
if (treeEl) treeEl.addEventListener('click', (e) => {
if (!state.selectMode) return;
const card = e.target.closest('[data-fn]');
if (!card || !treeEl.contains(card)) return;
e.preventDefault();
e.stopImmediatePropagation();
toggleSelect(card.getAttribute('data-fn'), card);
}, true);
// Capture-phase select-mode guard on each persistent list host. Without
// it, clicking a card/row (or its arrangement chip) in select mode falls
// through to the per-card play handler and starts playback instead of
// selecting ("checkbox click opens the song / access-denied"). The artist
// page renders the same [data-fn] song rows into its own host, so it
// needs the guard too — otherwise a row click there plays instead of
// toggling when select mode is already on.
bindSelectGuard(byId('v3-songs-grid'));
bindSelectGuard(byId('v3-songs-tree'));
bindSelectGuard(byId('v3-songs-artistpage'));
const setView = async (v) => {
state.view = v;
byId('v3-songs-grid-btn').className = 'px-3 py-2 text-sm ' + (v === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
@@ -3178,6 +3529,18 @@
// from scratch instead of restoring a cached (possibly empty, pre-DLC)
// snapshot. Must win over every fast-path below.
if (_libraryDirty) { _libraryDirty = false; await reload(); return; }
// Keep the entry-point gates current (a Settings visit may have
// toggled artist pages / external links). Fire-and-forget.
refreshArtistPageGates();
// An open artist sub-page survives a screen bounce as-is — its DOM is
// self-contained. A torn-down/hidden host means the state is stale;
// clear it and fall through to the normal restore paths.
if (state.artistPage) {
const ah = document.getElementById('v3-songs-artistpage');
if (ah && !ah.classList.contains('hidden') && ah.childElementCount) return;
state.artistPage = null;
state.artistReturnScroll = null;
}
// Pull in any scores recorded while the library was off-screen (the usual
// play→return flow) before the fast-paths below restore the cached DOM,
// so the just-played song's badge is current. The full render() path