mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-16 05:37:42 +00:00
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:
co-authored by
Claude Opus 4.8
byrongamatos
parent
64a499975e
commit
be9e965001
@@ -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 = A–Z, recent = newest
|
# surface first (they gain the most), artist = A–Z, 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:
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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'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'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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user