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
+322 -1
View File
@@ -256,6 +256,12 @@ _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
# throttled Cover Art Archive calls — anonymous demo visitors don't get # throttled Cover Art Archive calls — anonymous demo visitors don't get
# to spend the shared rate budget (same rule as enrichment search/kick). # to spend the shared rate budget (same rule as enrichment search/kick).
("GET", re.compile(r"^/api/song/.+/art/candidates$")), ("GET", re.compile(r"^/api/song/.+/art/candidates$")),
# Artist pages (PR-B): the links GET lazily fetches from MusicBrainz on a
# visitor's behalf AND writes the artist_enrichment cache; refresh
# re-spends the shared rate limit. The /page route stays open (all-local
# read). Same rationale as /api/enrichment/search above.
("GET", re.compile(r"^/api/artist/.+/links$")),
("POST", re.compile(r"^/api/artist/.+/links/refresh$")),
] ]
@@ -914,6 +920,22 @@ class MetadataDB:
self.conn.execute(ddl) self.conn.execute(ddl)
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
# Artist-level enrichment cache (artist pages, launch charrette §5):
# ONE row per matched MusicBrainz artist holding the whitelisted
# url-relations (external links) + MB genres from a single throttled
# artist lookup, fetched lazily on the first artist-page links request
# and refreshed only on demand. Keyed by mb_artist_id (NOT the display
# name), so alias merges / renames never orphan it. Never purged on
# rescan — like song_enrichment, it is re-derivable but expensive
# (rate-limited) to re-fetch. Additive + idempotent.
self.conn.execute("""
CREATE TABLE IF NOT EXISTS artist_enrichment (
mb_artist_id TEXT PRIMARY KEY,
url_rels TEXT,
genres TEXT,
fetched_at TEXT
)
""")
# Progression (spec 010): instrument paths, challenges, quests, the # Progression (spec 010): instrument paths, challenges, quests, the
# Decibels wallet, and the cosmetics shop. Targets/titles live in the # Decibels wallet, and the cosmetics shop. Targets/titles live in the
# bundled content (data/progression/); these tables hold only player # bundled content (data/progression/); these tables hold only player
@@ -1913,6 +1935,181 @@ class MetadataDB:
return [{"name": r[0], "count": r[1], return [{"name": r[0], "count": r[1],
"canonical": amap.get((r[0] or "").lower(), r[0])} for r in rows] "canonical": amap.get((r[0] or "").lower(), r[0])} for r in rows]
# ── Artist pages (launch charrette PR-B) ─────────────────────────────────
# The artist page is "X *in your library*" — a shelf plus your relationship
# to it, never a discography browser (locked position 1). Everything here
# reads LOCAL rows only; the external-links layer (artist_enrichment) is a
# separate lazy cache keyed by mb_artist_id.
def artist_known_mb_id(self, variants: list) -> str | None:
"""The artist's MusicBrainz id, if any of their songs' enrichment rows
carry one. Only `matched`/`manual` rows count (partial coverage is the
contract degrade gracefully); the most common id wins so one stray
wrong match can't out-vote the rest of the shelf."""
if not variants:
return None
ph = ",".join(["?"] * len(variants))
row = self.conn.execute(
f"SELECT e.mb_artist_id, COUNT(*) c FROM song_enrichment e "
f"JOIN songs s ON s.filename = e.filename "
f"WHERE s.artist COLLATE NOCASE IN ({ph}) "
f"AND e.match_state IN ('matched', 'manual') "
f"AND e.mb_artist_id IS NOT NULL AND e.mb_artist_id != '' "
f"GROUP BY e.mb_artist_id ORDER BY c DESC, e.mb_artist_id LIMIT 1",
variants).fetchone()
return row[0] if row else None
def artist_page(self, name: str) -> dict:
"""The all-LOCAL artist-page payload: canonical name (alias-aware),
the raw variants it merges, song/album counts, the albums list, the
mastered count (DENOMINATOR LAW, locked position 2: every number
counts songs YOU OWN the WHERE is `artist IN (your variants)` over
`songs`, never anything external), mb_artist_id when known, header-
mosaic art, similar-in-library via genre co-occurrence (locked
position 3: only artists already in the library, empty hidden), and
the play-all file list. An unknown name returns a zero-count page (an
unmatched artist is still a fully functional page)."""
from urllib.parse import quote
canonical = self._terminal_canonical((name or "").strip())
variants = self._raw_variants_for(canonical)
ph = ",".join(["?"] * len(variants)) if variants else "?"
rows = self.conn.execute(
f"SELECT filename, title, album, year, genre FROM songs "
f"WHERE title != '' AND artist COLLATE NOCASE IN ({ph}) "
f"ORDER BY album COLLATE NOCASE, (track_number IS NULL) ASC, "
f"COALESCE(disc, 1), track_number, title COLLATE NOCASE",
variants or [canonical]).fetchall()
# Albums: distinct non-empty album names in shelf order, each with the
# earliest authored year, a track count, and a representative cover
# song (the first row → also the mosaic's source).
albums: dict = {}
album_order: list = []
for fn, _t, album, year, _g in rows:
key = (album or "").strip()
if not key:
continue
k = key.lower()
if k not in albums:
albums[k] = {"name": key, "year": (year or ""), "count": 0, "cover": fn}
album_order.append(k)
albums[k]["count"] += 1
if not albums[k]["year"] and year:
albums[k]["year"] = year
album_list = [albums[k] for k in album_order]
# "also shown as": the raw variants actually present in the library
# (the canonical itself is the headline, so it's excluded).
vrows = self.conn.execute(
f"SELECT artist, COUNT(*) FROM songs "
f"WHERE title != '' AND artist COLLATE NOCASE IN ({ph}) "
f"GROUP BY artist COLLATE NOCASE ORDER BY COUNT(*) DESC",
variants or [canonical]).fetchall()
shown_as = [{"name": r[0], "count": r[1]} for r in vrows
if (r[0] or "").lower() != (canonical or "").lower()]
# Mastered / practice presence — over THIS artist's library songs only.
mastered = 0
has_stats = False
fns = [r[0] for r in rows]
if fns:
fph = ",".join(["?"] * len(fns))
srows = self.conn.execute(
f"SELECT filename, MAX(best_accuracy) FROM song_stats "
f"WHERE filename IN ({fph}) GROUP BY filename", fns).fetchall()
has_stats = len(srows) > 0
mastered = sum(1 for _fn, acc in srows
if acc is not None and acc >= MASTERY_ACCURACY)
# Similar in your library: other artists sharing songs.genre values,
# ranked by distinct shared genres then by how many of their songs sit
# in those genres. Raw artist rows are folded through the alias map so
# "ACDC" and "AC/DC" rank as one artist; self is excluded either way.
genres = sorted({(r[4] or "").strip().lower() for r in rows} - {""})
similar: list = []
if genres:
gph = ",".join(["?"] * len(genres))
grows = self.conn.execute(
f"SELECT artist, COUNT(DISTINCT lower(genre)), COUNT(*) FROM songs "
f"WHERE title != '' AND genre != '' AND lower(genre) IN ({gph}) "
f"AND artist IS NOT NULL AND artist != '' "
f"GROUP BY artist COLLATE NOCASE", genres).fetchall()
amap = self.alias_map()
agg: dict = {}
for raw, shared, n in grows:
canon = amap.get((raw or "").lower(), raw)
if (canon or "").lower() == (canonical or "").lower():
continue
cur = agg.setdefault((canon or "").lower(),
{"artist": canon, "shared_genres": 0, "count": 0})
cur["shared_genres"] = max(cur["shared_genres"], shared)
cur["count"] += n
similar = sorted(
agg.values(),
key=lambda a: (-a["shared_genres"], -a["count"], (a["artist"] or "").lower())
)[:5]
# Header mosaic (locked position 10: MB hosts no artist images — the
# default is a mosaic of OWNED album art via the playlist-cover
# grammar): one representative song per album first, then fill from
# the remaining songs, up to 4.
seen: set = set()
art_files: list = []
for al in album_list:
if al["cover"] not in seen:
seen.add(al["cover"])
art_files.append(al["cover"])
if len(art_files) >= 4:
break
if len(art_files) < 4:
for fn in fns:
if fn not in seen:
seen.add(fn)
art_files.append(fn)
if len(art_files) >= 4:
break
return {
"artist": canonical,
"variants": shown_as,
"song_count": len(rows),
"album_count": len(album_list),
"mastered_count": mastered,
"has_stats": has_stats,
"albums": album_list,
"mb_artist_id": self.artist_known_mb_id(variants),
"similar": similar,
"art_urls": [f"/api/song/{quote(fn)}/art" for fn in art_files],
# Play-all seed (album/track order, same as the rows above).
# Bounded so a pathological library can't balloon the payload.
"files": fns[:1000],
}
def get_artist_enrichment(self, mb_artist_id: str) -> dict | None:
"""Cached artist-level enrichment row, JSON fields parsed (bad/legacy
JSON degrades to empty rather than 500ing the links route)."""
row = self.conn.execute(
"SELECT mb_artist_id, url_rels, genres, fetched_at "
"FROM artist_enrichment WHERE mb_artist_id = ?",
(mb_artist_id,)).fetchone()
if not row:
return None
def _parsed(raw, fallback):
try:
v = json.loads(raw) if raw else fallback
except (TypeError, ValueError):
return fallback
return v if isinstance(v, type(fallback)) else fallback
return {"mb_artist_id": row[0], "url_rels": _parsed(row[1], {}),
"genres": _parsed(row[2], []), "fetched_at": row[3]}
def put_artist_enrichment(self, mb_artist_id: str, url_rels: dict,
genres: list) -> None:
"""Store (or refresh) the one artist-level cache row."""
with self._lock:
self.conn.execute(
"INSERT OR REPLACE INTO artist_enrichment "
"(mb_artist_id, url_rels, genres, fetched_at) "
"VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))",
(mb_artist_id, json.dumps(url_rels or {}), json.dumps(genres or [])))
self.conn.commit()
def record_session(self, filename: str, arrangement: int, *, score: int, def record_session(self, filename: str, arrangement: int, *, score: int,
accuracy: float, last_position=None) -> dict: accuracy: float, last_position=None) -> dict:
"""Record a scored play: plays += 1, best_* = max, last_* = new.""" """Record a scored play: plays += 1, best_* = max, last_* = new."""
@@ -8084,6 +8281,120 @@ def delete_artist_alias(raw_name: str):
return {"ok": True} return {"ok": True}
# ── Artist pages (launch charrette PR-B) ──────────────────────────────────────
# GET page = 100% local (renders offline, renders unmatched); GET links = the
# ONE lazy MusicBrainz artist lookup, cached forever in artist_enrichment and
# re-fetched only by the explicit refresh. Both links routes are demo-blocked
# (they store server state + spend the shared MB rate limit).
# MB artist url-relation types → the page's link slots (locked position 4:
# whitelist only, links-only forever). Everything not listed is dropped.
_ARTIST_URL_REL_SLOTS = {
"official homepage": "official",
"setlistfm": "tour",
"concerts": "tour",
"youtube": "video",
"video channel": "video",
"social network": "social",
"bandcamp": "social",
"soundcloud": "social",
"wikipedia": "wikipedia",
"wikidata": "wikipedia",
}
def _artist_links_from_mb(body: dict) -> tuple[dict, list]:
"""Whitelist an MB artist doc's url-relations into the page's link slots:
{official, tour, video, social: [...], wikipedia}. Every URL passes the
same http(s)-scheme gate as art redirects (_safe_art_redirect_url) so a
hostile javascript:/data:/file: resource can never reach an href. First
URL wins per single slot; social collects up to 5; wikipedia is preferred
over wikidata when both exist. Also returns MB's genre names (capped)."""
links: dict = {}
social: list = []
wikidata_url = None
for rel in (body or {}).get("relations") or []:
if not isinstance(rel, dict):
continue
rtype = str(rel.get("type") or "").strip().lower()
slot = _ARTIST_URL_REL_SLOTS.get(rtype)
if not slot:
continue
url = rel.get("url")
url = url.get("resource") if isinstance(url, dict) else url
if _safe_art_redirect_url(url) is None:
continue
if slot == "social":
if url not in social and len(social) < 5:
social.append(url)
elif rtype == "wikidata":
wikidata_url = wikidata_url or url
elif slot not in links:
links[slot] = url
if social:
links["social"] = social
if "wikipedia" not in links and wikidata_url:
links["wikipedia"] = wikidata_url
genres = [str(g.get("name")) for g in (body or {}).get("genres") or []
if isinstance(g, dict) and g.get("name")]
return links, genres[:8]
def _artist_links_payload(name: str, force: bool = False) -> dict:
"""Shared by GET links + POST refresh. Order of gates: the user's opt-in
setting (external links are OFF by default the dev-chat thread's call),
then a known mb_artist_id (no id nothing to look up), then the cache
(unless force), then the offline guard, then ONE throttled fetch."""
cfg = _load_config(CONFIG_DIR / "config.json") or _default_settings()
if cfg.get("artist_external_links") is not True:
return {"links": {}, "matched": False, "disabled": True}
canonical = meta_db._terminal_canonical((name or "").strip())
mbid = meta_db.artist_known_mb_id(meta_db._raw_variants_for(canonical))
mbid = (mbid or "").strip().lower()
# The id is interpolated into the MB request path — same strict-shape rule
# as the manifest identity keys (_MBID_RE), so a junk/hostile value stored
# via a hand-rolled /pick body can never reach the request line.
if not mbid or not _MBID_RE.match(mbid):
return {"links": {}, "matched": False}
if not force:
cached = meta_db.get_artist_enrichment(mbid)
if cached:
return {"links": cached["url_rels"], "genres": cached["genres"],
"matched": True, "cached": True, "mb_artist_id": mbid}
if not _enrich_network_enabled():
return {"links": {}, "matched": True, "offline": True, "mb_artist_id": mbid}
try:
body = _mb_http_get(f"artist/{mbid}", {"inc": "url-rels+genres+tags"})
except EnrichTransportError:
return {"links": {}, "matched": True, "offline": True, "mb_artist_id": mbid}
links, genres = _artist_links_from_mb(body or {})
meta_db.put_artist_enrichment(mbid, links, genres)
return {"links": links, "genres": genres, "matched": True, "cached": False,
"mb_artist_id": mbid}
@app.get("/api/artist/{name:path}/page")
def api_artist_page(name: str):
"""The artist page's all-LOCAL payload — counts, albums, aliases, similar-
in-library, mosaic art, play-all seed. Never touches the network; an
unmatched or even unknown artist still returns a functional page."""
return meta_db.artist_page(name)
@app.get("/api/artist/{name:path}/links")
def api_artist_links(name: str):
"""External links for a matched artist — cached after the first call.
Sync route on purpose (like /api/enrichment/search): FastAPI runs it in
the threadpool so the MB throttle's sleep never blocks the event loop."""
return _artist_links_payload(name)
@app.post("/api/artist/{name:path}/links/refresh")
def api_artist_links_refresh(name: str):
"""Explicit re-fetch of the cached links (the page's manual Refresh)."""
return _artist_links_payload(name, force=True)
# ── Player profile / unified XP / streak (fee[dB]ack v0.3.0) ────────────────── # ── Player profile / unified XP / streak (fee[dB]ack v0.3.0) ──────────────────
def _list_bundled_avatars() -> list[str]: def _list_bundled_avatars() -> list[str]:
@@ -9212,6 +9523,14 @@ def _default_settings():
# surface first (they gain the most), artist = AZ, recent = newest # surface first (they gain the most), artist = AZ, recent = newest
# files first. # files first.
"enrich_review_order": "missing_first", "enrich_review_order": "missing_first",
# Artist pages (PR-B). The page itself is 100% local (renders from
# your own library rows), so it defaults ON; the external-links row
# (official site / tour dates / videos / social, one throttled
# MusicBrainz artist lookup per matched artist) is opt-IN — default
# OFF per the dev-chat thread. Links are links-only forever: always
# the external browser, never media delivered in-app.
"artist_pages_enabled": True,
"artist_external_links": False,
} }
@@ -9384,7 +9703,9 @@ def save_settings(data: dict):
updates["enrich_auto_threshold"] = t updates["enrich_auto_threshold"] = t
for _bool_key in ("enrich_src_musicbrainz", "enrich_src_caa", for _bool_key in ("enrich_src_musicbrainz", "enrich_src_caa",
"enrich_apply_names", "enrich_apply_year", "enrich_apply_names", "enrich_apply_year",
"enrich_apply_genres", "enrich_apply_art"): "enrich_apply_genres", "enrich_apply_art",
# Artist pages (PR-B): page on/off + external-links opt-in.
"artist_pages_enabled", "artist_external_links"):
if _bool_key in data: if _bool_key in data:
raw = data[_bool_key] raw = data[_bool_key]
if raw is not None: if raw is not None:
+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> <span id="enrich-status" class="text-xs text-gray-500"></span>
</div> </div>
</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 --> <!-- Backup -->
<div class="fb-srow fb-srow-stack"> <div class="fb-srow fb-srow-stack">
<div class="fb-srow-main"> <div class="fb-srow-main">
+11 -2
View File
@@ -475,14 +475,23 @@
['enrich-apply-year', 'enrich_apply_year'], ['enrich-apply-year', 'enrich_apply_year'],
['enrich-apply-genres', 'enrich_apply_genres'], ['enrich-apply-genres', 'enrich_apply_genres'],
['enrich-apply-art', 'enrich_apply_art'], ['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); ].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 () => { (async () => {
try { try {
const r = await fetch('/api/settings'); const r = await fetch('/api/settings');
if (r.ok) { if (r.ok) {
const cfg = await r.json(); const cfg = await r.json();
for (const [el, key] of toggles) el.checked = cfg[key] !== false; for (const [el, key] of toggles) el.checked = cfg[key] !== false;
for (const [el, key] of optInToggles) el.checked = cfg[key] === true;
if (sel) { if (sel) {
const t = Number(cfg.enrich_auto_threshold); const t = Number(cfg.enrich_auto_threshold);
const want = Number.isFinite(t) ? t : 0.9; const want = Number.isFinite(t) ? t : 0.9;
@@ -502,7 +511,7 @@
refreshChip(); // also fills #enrich-status refreshChip(); // also fills #enrich-status
})(); })();
const save = (key, value) => post('/api/settings', { [key]: value }); 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)); el.addEventListener('change', () => save(key, !!el.checked));
} }
sel?.addEventListener('change', () => save('enrich_auto_threshold', Number(sel.value))); sel?.addEventListener('change', () => save('enrich_auto_threshold', Number(sel.value)));
+425 -62
View File
@@ -65,6 +65,14 @@
scrollBound: false, scrollBound: false,
songsById: {}, selectMode: false, selected: new Set(), songsById: {}, selectMode: false, selected: new Set(),
railLetters: null, railLettersAreSongCounts: false, railJumping: false, 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 ── // ── Windowed (virtualized) grid, stage 2 of #636 item 3 ──
// state.songs is a SPARSE array indexed by absolute library position // state.songs is a SPARSE array indexed by absolute library position
// (0..total-1); only the fetched pages are populated and only the visible // (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>' + '<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></div>' +
'<div class="mt-1 text-sm text-fb-text truncate" title="' + esc(shown.title) + '">' + esc(shown.title) + '</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 // Always emit the chip row (even when empty) at a FIXED single-line
// height — uniform card height is what makes the windowed grid's // height — uniform card height is what makes the windowed grid's
// absolute-position math exact (.v3-card-chips in v3.css). // absolute-position math exact (.v3-card-chips in v3.css).
@@ -888,6 +904,10 @@
? [{ id: '__unsplit', label: 'Rejoin other versions' }] : []), ? [{ id: '__unsplit', label: 'Rejoin other versions' }] : []),
{ id: '__playlist', label: 'Add to playlist' }, { id: '__playlist', label: 'Add to playlist' },
{ id: '__save', label: 'Save for later' }, { 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 })), ...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 // Metadata + file actions (R2) — local library only (they all
// address the local DB / filesystem). Both openers (⋮ and // address the local DB / filesystem). Both openers (⋮ and
@@ -937,6 +957,7 @@
} }
if (id === '__playlist') { await addFilenamesToPlaylist([song.filename]); return; } if (id === '__playlist') { await addFilenamesToPlaylist([song.filename]); return; }
if (id === '__save') { if (window.v3Saved) await window.v3Saved.toggle(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), // Per-chart metadata actions follow the DISPLAYED chart (playTarget),
// like Play — under an intrinsic filter that's the matching member, // like Play — under an intrinsic filter that's the matching member,
// not the group representative. (__remove stays on `song`: it needs // not the group representative. (__remove stays on `song`: it needs
@@ -1410,6 +1431,12 @@
e.stopPropagation(); e.stopPropagation();
openChartsDrawer(e.currentTarget.getAttribute('data-charts'), song); 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) => { el.querySelector('[data-fav]')?.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
const btn = e.currentTarget; const btn = e.currentTarget;
@@ -1452,6 +1479,26 @@
renderBatchBar(); 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) { function setSelectMode(on) {
state.selectMode = on; state.selectMode = on;
if (!on) state.selected.clear(); if (!on) state.selected.clear();
@@ -2260,18 +2307,29 @@
if (a) openAlbum(a); if (a) openAlbum(a);
})); }));
} }
async function openAlbum(a) { // `opts` (PR-B): the artist page reuses this album detail inside its own
const host = document.getElementById('v3-songs-albums'); // 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; 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>'; host.innerHTML = '<p class="text-fb-textDim text-sm">Loading…</p>';
// Honour the active drawer filters (like the album grid) but pin THIS // Normally honour the active drawer filters (like the album grid) but pin
// album's artist/album and force track order — so the track list and // THIS album's artist/album and force track order — so the track list and
// Play-album never include songs the user filtered out. // Play-album never include songs the user filtered out. When opened FROM
const p = queryParams({ artist: a.artist, album: a.album, size: '300', sort: 'track' }, { catalog: true }); // 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 data = await jget('/api/library?' + p.toString());
const songs = (data && data.songs) || []; const songs = (data && data.songs) || [];
host.innerHTML = 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="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>' + '<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>' + '<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">' + '<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="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>'; '<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', () => { host.querySelector('[data-album-playall]')?.addEventListener('click', () => {
const files = songs.map((s) => s.filename).filter(Boolean); const files = songs.map((s) => s.filename).filter(Boolean);
if (!files.length) return; 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() { async function loadTree() {
const host = document.getElementById('v3-songs-tree'); const host = document.getElementById('v3-songs-tree');
if (!host) return; 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>' + '<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 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>' + '<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) => { (al.songs || []).map(treeSongRowHtml).join('') + '</div>').join('') + '</div></details>').join('');
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('');
wireCards(host); wireCards(host);
} }
@@ -2626,7 +2970,13 @@
// Identity — writes back into the feedpak FILE // 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>' + '<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>' + '<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>' + '<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) + provenanceHtml(st) +
'<div data-det-gapfill>' + gapFillHtml(st) + '</div></div>' + '<div data-det-gapfill>' + gapFillHtml(st) + '</div></div>' +
@@ -2714,6 +3064,15 @@
closeDetails(); closeDetails();
if (window.__fbFixMatch) window.__fbFixMatch(song); 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 // Gap-fill (R4a): user-initiated write of CONFIRMED missing info into
// the pack file. The server recomputes proposals under its io lock, so // the pack file. The server recomputes proposals under its io lock, so
@@ -2950,6 +3309,10 @@
function reload() { function reload() {
_clearLibraryScrollSnapshot(); _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 // 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 // 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. // 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')) || {}; })(), (async () => { state.accuracy = (await jget('/api/stats/best')) || {}; })(),
jget('/api/library/tuning-names?provider=' + enc(state.provider)), jget('/api/library/tuning-names?provider=' + enc(state.provider)),
loadArtistCatalog(), 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) || []; 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 = []; } 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>' +
'<div id="v3-songs-tree" class="hidden"></div>' + '<div id="v3-songs-tree" class="hidden"></div>' +
'<div id="v3-songs-albums" 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-controls" style="display:none"></div>' +
'<div id="lib-folder-tree" class="space-y-1 hidden"></div>' + '<div id="lib-folder-tree" class="space-y-1 hidden"></div>' +
'<div id="v3-songs-sentinel" class="h-8"></div>' + '<div id="v3-songs-sentinel" class="h-8"></div>' +
@@ -3120,34 +3489,16 @@
} catch (e) { /* */ } } catch (e) { /* */ }
})(); })();
// Bulletproof multi-select: in select mode, a capture-phase click on the // Capture-phase select-mode guard on each persistent list host. Without
// grid toggles the card and STOPS the event, so nothing (a per-card // it, clicking a card/row (or its arrangement chip) in select mode falls
// handler, a stray/legacy listener, an arrangement chip) can start // through to the per-card play handler and starts playback instead of
// playback. Fixes "checkbox click opens the song / access-denied". // selecting ("checkbox click opens the song / access-denied"). The artist
const gridEl = byId('v3-songs-grid'); // page renders the same [data-fn] song rows into its own host, so it
if (gridEl) gridEl.addEventListener('click', (e) => { // needs the guard too — otherwise a row click there plays instead of
if (!state.selectMode) return; // toggling when select mode is already on.
const card = e.target.closest('[data-fn]'); bindSelectGuard(byId('v3-songs-grid'));
if (!card || !gridEl.contains(card)) return; bindSelectGuard(byId('v3-songs-tree'));
e.preventDefault(); bindSelectGuard(byId('v3-songs-artistpage'));
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);
const setView = async (v) => { const setView = async (v) => {
state.view = 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'); 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) // from scratch instead of restoring a cached (possibly empty, pre-DLC)
// snapshot. Must win over every fast-path below. // snapshot. Must win over every fast-path below.
if (_libraryDirty) { _libraryDirty = false; await reload(); return; } 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 // 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, // 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 // so the just-played song's badge is current. The full render() path
+407
View File
@@ -0,0 +1,407 @@
"""Server tests for the artist-pages layer (PR-B, artist-pages launch charrette).
Two halves, mirroring the design's split:
* GET /api/artist/{name}/page — the all-LOCAL payload. Covers the counts /
albums / alias variants, the DENOMINATOR LAW (mastered counts songs YOU OWN,
never anything external — locked position 2), similar-in-library genre
co-occurrence (in-library artists only, self excluded, empty → empty), and
mb_artist_id resolution from matched/manual rows only.
* GET /api/artist/{name}/links + POST .../links/refresh — the lazy, cached,
opt-in external-links layer. The HTTP transport is a fake over
`server._mb_http_get` (the ONE network seam — same pattern as
tests/test_mb_enrichment.py), so nothing here opens a socket. Covers the
url-rel whitelist mapping, the http(s) scheme gate (a hostile javascript:
resource never reaches a link slot), cache-hit second calls making no
network call, the offline guard, the default-OFF setting gate, and the
demo-mode blocks.
"""
import importlib
import json
import sys
from urllib.parse import quote
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
MBID = "66c662b6-6e2f-4930-8610-912e24c63ed1"
def _put(server, fn, title=None, artist="AC/DC", album="", year="",
genre="", duration=200):
server.meta_db.put(fn, 0, 0, {
"title": title or fn.split(".")[0], "artist": artist, "album": album,
"year": year, "genre": genre, "duration": duration,
"arrangements": [{"name": "Lead", "index": 0}],
})
def _pin_match(server, fn, artist_id=MBID):
"""Give a song a user-pinned (manual) match carrying an artist MBID."""
assert server.meta_db.set_enrichment_manual(fn, {
"recording_id": "rec-1", "title": "T", "artist": "AC/DC",
"artist_id": artist_id,
})
def _page(client, name="AC/DC"):
r = client.get("/api/artist/" + quote(name, safe="") + "/page")
assert r.status_code == 200
return r.json()
class FakeMBArtist:
"""Canned MusicBrainz artist lookup over the _mb_http_get seam."""
def __init__(self, srv):
self._srv = srv
self.calls = []
self.doc = artist_doc()
self.raise_transport = False
def __call__(self, path, params):
if self.raise_transport:
raise self._srv.EnrichTransportError("fake network down")
self.calls.append((path, dict(params)))
if path == f"artist/{MBID}":
return self.doc
raise AssertionError(f"unexpected MB path {path!r}")
@pytest.fixture()
def mb_artist(server, monkeypatch):
"""Install the fake transport AND enable the network flag (the test env
disables it by default — see test_links_offline_returns_empty)."""
fake = FakeMBArtist(server)
monkeypatch.setattr(server, "_mb_http_get", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
return fake
def artist_doc():
"""An MB artist doc exercising the whole whitelist: a hostile javascript:
URL and an ftp:// URL (both must be scheme-gated out), non-whitelisted rel
types (must be dropped), one of each slot, and both wiki rels (wikipedia
must win over wikidata)."""
rel = lambda rtype, url: {"type": rtype, "url": {"resource": url}}
return {
"id": MBID,
"name": "AC/DC",
"relations": [
rel("official homepage", "javascript:alert(1)"), # scheme-gated
rel("official homepage", "https://www.acdc.com"), # first valid wins
rel("official homepage", "https://second.example"),
rel("setlistfm", "https://www.setlist.fm/setlists/acdc"),
rel("youtube", "https://www.youtube.com/acdc"),
rel("social network", "https://www.instagram.com/acdc"),
rel("bandcamp", "ftp://bad.example/acdc"), # scheme-gated
rel("soundcloud", "https://soundcloud.com/acdc"),
rel("wikidata", "https://www.wikidata.org/wiki/Q27593"),
rel("wikipedia", "https://en.wikipedia.org/wiki/AC/DC"),
rel("streaming", "https://stream.example/acdc"), # not whitelisted
rel("purchase for download", "https://store.example"), # not whitelisted
],
"genres": [{"name": "hard rock", "count": 10}, {"name": "rock", "count": 5}],
}
def _enable_links(client):
r = client.post("/api/settings", json={"artist_external_links": True})
assert r.status_code == 200 and "error" not in r.json()
# ── /page: counts, albums, variants ──────────────────────────────────────────
def test_page_counts_albums_and_files(client, server):
_put(server, "a.sloppak", album="The Razors Edge", year="1990")
_put(server, "b.sloppak", album="The Razors Edge", year="1990")
_put(server, "c.sloppak", album="Back in Black", year="1980")
_put(server, "d.sloppak", album="") # loose, no album
_put(server, "x.sloppak", artist="Other Band", album="Elsewhere")
page = _page(client)
assert page["artist"] == "AC/DC"
assert page["song_count"] == 4 # never the other artist
assert page["album_count"] == 2 # empty album ≠ an album
albums = {a["name"]: a for a in page["albums"]}
assert albums["The Razors Edge"]["count"] == 2
assert albums["The Razors Edge"]["year"] == "1990"
assert albums["Back in Black"]["count"] == 1
assert set(page["files"]) == {"a.sloppak", "b.sloppak", "c.sloppak", "d.sloppak"}
# Mosaic art comes from the artist's own songs.
assert page["art_urls"] and all("/art" in u for u in page["art_urls"])
def test_page_unknown_artist_is_zero_count_not_error(client, server):
page = _page(client, "Nobody Here")
assert page["artist"] == "Nobody Here"
assert page["song_count"] == 0
assert page["albums"] == [] and page["similar"] == []
assert page["mb_artist_id"] is None
def test_page_canonicalizes_aliases_and_lists_variants(client, server):
_put(server, "a.sloppak", artist="ACDC", album="Alb")
_put(server, "b.sloppak", artist="AC/DC", album="Alb")
r = client.post("/api/artist-aliases",
json={"raw_name": "ACDC", "canonical_name": "AC/DC"})
assert r.status_code == 200
# Asking by the RAW name lands on the same canonical page.
for name in ("AC/DC", "ACDC"):
page = _page(client, name)
assert page["artist"] == "AC/DC"
assert page["song_count"] == 2 # both variants counted
assert page["variants"] == [{"name": "ACDC", "count": 1}]
# ── /page: the denominator law ────────────────────────────────────────────────
def test_mastered_counts_only_owned_songs(client, server):
"""Locked position 2: 'N mastered' is over songs in YOUR library — a
song_stats row whose file left the library can never inflate it."""
_put(server, "a.sloppak")
_put(server, "b.sloppak")
_put(server, "c.sloppak")
server.meta_db.record_session("a.sloppak", 0, score=100, accuracy=0.95) # mastered
server.meta_db.record_session("b.sloppak", 0, score=50, accuracy=0.5) # in progress
# A mastered score for a song NOT in the library (deleted / renamed) —
# must not count: the denominator is ownership.
server.meta_db.record_session("gone.sloppak", 0, score=100, accuracy=0.99)
page = _page(client)
assert page["song_count"] == 3
assert page["mastered_count"] == 1
assert page["has_stats"] is True
def test_mastered_uses_best_accuracy_across_arrangements(client, server):
_put(server, "a.sloppak")
server.meta_db.record_session("a.sloppak", 0, score=10, accuracy=0.4)
server.meta_db.record_session("a.sloppak", 1, score=90, accuracy=0.93)
assert _page(client)["mastered_count"] == 1
def test_no_practice_data_reports_zero_and_flag(client, server):
"""The frontend omits the mastered segment when it is 0 (invitational —
never '0 mastered'); the payload carries the honest numbers + flag."""
_put(server, "a.sloppak")
page = _page(client)
assert page["mastered_count"] == 0
assert page["has_stats"] is False
# ── /page: similar-in-library ─────────────────────────────────────────────────
def test_similar_ranks_genre_overlap_in_library_only(client, server):
_put(server, "a1.sloppak", artist="AC/DC", genre="Rock")
_put(server, "a2.sloppak", artist="AC/DC", genre="Blues")
_put(server, "b1.sloppak", artist="Band B", genre="rock") # case folds
_put(server, "b2.sloppak", artist="Band B", genre="Blues") # 2 shared genres
_put(server, "c1.sloppak", artist="Band C", genre="Rock") # 1 shared genre
_put(server, "d1.sloppak", artist="Band D", genre="Jazz") # no overlap
similar = _page(client)["similar"]
names = [s["artist"] for s in similar]
assert names[0] == "Band B" # most shared genres
assert "Band C" in names
assert "Band D" not in names # never non-overlapping
assert "AC/DC" not in names # never self
def test_similar_empty_without_genre_data(client, server):
_put(server, "a.sloppak", genre="")
_put(server, "b.sloppak", artist="Band B", genre="Rock")
assert _page(client)["similar"] == []
def test_similar_folds_alias_variants(client, server):
_put(server, "a.sloppak", artist="AC/DC", genre="Rock")
_put(server, "b.sloppak", artist="Band B", genre="Rock")
_put(server, "b2.sloppak", artist="band b", genre="Rock")
client.post("/api/artist-aliases",
json={"raw_name": "band b", "canonical_name": "Band B"})
similar = _page(client)["similar"]
assert [s["artist"] for s in similar] == ["Band B"] # one entry, folded
assert similar[0]["count"] == 2
# ── /page: mb_artist_id resolution ────────────────────────────────────────────
def test_page_mb_artist_id_from_matched_rows(client, server):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
assert _page(client)["mb_artist_id"] == MBID
def test_page_ignores_unmatched_rows_artist_id(client, server):
"""Only matched/manual rows are identity authority — a failed row's
leftover artist_id must not resurface."""
_put(server, "a.sloppak")
server.meta_db.conn.execute(
"INSERT INTO song_enrichment (filename, match_state, mb_artist_id) "
"VALUES ('a.sloppak', 'failed', ?)", (MBID,))
server.meta_db.conn.commit()
assert _page(client)["mb_artist_id"] is None
# ── /links: setting gate, whitelist, scheme gate ─────────────────────────────
def test_links_disabled_by_default_no_network(client, server, mb_artist):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
r = client.get("/api/artist/AC%2FDC/links")
assert r.status_code == 200
body = r.json()
assert body["links"] == {} and body.get("disabled") is True
assert mb_artist.calls == [] # opt-in means opt-in
def test_links_whitelist_mapping_and_scheme_gate(client, server, mb_artist):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
_enable_links(client)
r = client.get("/api/artist/AC%2FDC/links")
assert r.status_code == 200
body = r.json()
assert body["matched"] is True and body["cached"] is False
links = body["links"]
# The javascript: homepage is scheme-gated out; the first VALID one wins.
assert links["official"] == "https://www.acdc.com"
assert links["tour"] == "https://www.setlist.fm/setlists/acdc"
assert links["video"] == "https://www.youtube.com/acdc"
# Social collects; the ftp:// bandcamp is scheme-gated out.
assert links["social"] == ["https://www.instagram.com/acdc",
"https://soundcloud.com/acdc"]
# Wikipedia preferred over wikidata when both exist.
assert links["wikipedia"] == "https://en.wikipedia.org/wiki/AC/DC"
# Nothing hostile or non-whitelisted anywhere in the payload.
dumped = json.dumps(body)
for bad in ("javascript:", "ftp://", "stream.example", "store.example"):
assert bad not in dumped
# One throttled lookup, with the url-rels include.
assert len(mb_artist.calls) == 1
path, params = mb_artist.calls[0]
assert path == f"artist/{MBID}"
assert "url-rels" in params.get("inc", "")
def test_links_wikidata_fallback_when_no_wikipedia(client, server, mb_artist):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
_enable_links(client)
mb_artist.doc = {"id": MBID, "relations": [
{"type": "wikidata", "url": {"resource": "https://www.wikidata.org/wiki/Q27593"}},
], "genres": []}
links = client.get("/api/artist/AC%2FDC/links").json()["links"]
assert links["wikipedia"] == "https://www.wikidata.org/wiki/Q27593"
def test_links_cached_second_call_makes_no_network_call(client, server, mb_artist):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
_enable_links(client)
first = client.get("/api/artist/AC%2FDC/links").json()
assert first["cached"] is False and len(mb_artist.calls) == 1
second = client.get("/api/artist/AC%2FDC/links").json()
assert second["cached"] is True
assert second["links"] == first["links"]
assert len(mb_artist.calls) == 1 # cache hit — no re-fetch
def test_links_refresh_refetches_and_updates_cache(client, server, mb_artist):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
_enable_links(client)
client.get("/api/artist/AC%2FDC/links")
mb_artist.doc = {"id": MBID, "relations": [
{"type": "official homepage", "url": {"resource": "https://new.example"}},
], "genres": []}
r = client.post("/api/artist/AC%2FDC/links/refresh")
assert r.status_code == 200
assert r.json()["links"]["official"] == "https://new.example"
assert len(mb_artist.calls) == 2
# And the refreshed value is what the next GET serves from cache.
again = client.get("/api/artist/AC%2FDC/links").json()
assert again["cached"] is True
assert again["links"]["official"] == "https://new.example"
# ── /links: offline / unmatched / hostile-id guards ──────────────────────────
def test_links_offline_returns_empty(client, server):
"""The test env's offline default (FEEDBACK_SKIP_STARTUP_TASKS) doubles as
the kill-switch test: matched artist + links on, but no network → empty
links, no error, nothing cached."""
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
_enable_links(client)
body = client.get("/api/artist/AC%2FDC/links").json()
assert body["links"] == {} and body.get("offline") is True
assert server.meta_db.get_artist_enrichment(MBID) is None
def test_links_unmatched_artist_reports_matched_false(client, server, mb_artist):
_put(server, "a.sloppak") # no enrichment match
_enable_links(client)
body = client.get("/api/artist/AC%2FDC/links").json()
assert body == {"links": {}, "matched": False}
assert mb_artist.calls == []
def test_links_rejects_malformed_stored_mbid(client, server, mb_artist):
"""A hand-rolled /pick body can stuff junk into mb_artist_id — the strict
MBID shape gate must keep it off the MB request line."""
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak", artist_id="evil/../../path")
_enable_links(client)
body = client.get("/api/artist/AC%2FDC/links").json()
assert body == {"links": {}, "matched": False}
assert mb_artist.calls == []
# ── demo mode ─────────────────────────────────────────────────────────────────
def test_links_routes_demo_blocked_page_stays_open(client, server, monkeypatch):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "1")
assert client.get("/api/artist/AC%2FDC/links").status_code == 403
assert client.post("/api/artist/AC%2FDC/links/refresh").status_code == 403
# The all-local page read stays available to demo visitors.
assert client.get("/api/artist/AC%2FDC/page").status_code == 200
# ── settings keys ─────────────────────────────────────────────────────────────
def test_artist_page_settings_defaults_and_validation(client, server):
cfg = client.get("/api/settings").json()
assert cfg["artist_pages_enabled"] is True # page is local-only → ON
assert cfg["artist_external_links"] is False # links are opt-in → OFF
# Bool pattern: non-bool shapes return a structured error, not a 500.
for key in ("artist_pages_enabled", "artist_external_links"):
assert "error" in client.post("/api/settings", json={key: "yes"}).json()
assert "error" not in client.post("/api/settings", json={key: True}).json()
assert client.get("/api/settings").json()[key] is True