From 123eb6139f19d4bf8a2c42a366a7b35a11696059 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 11 Jun 2026 16:51:50 -0700 Subject: [PATCH] =?UTF-8?q?Artist=20detail:=20"DB=20Record"=20inspector=20?= =?UTF-8?q?=E2=80=94=20everything=20the=20DB=20knows=20about=20an=20artist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A small glowing button at the bottom-right of the artist hero (library artists only) opens a programmer-style modal showing the COMPLETE artists DB row — every source id + match status, cached bios / tags / similar / urls, soul_id, timestamps, the lot (62 columns) — plus owned album/track counts. - Backend: GET /api/artist//record returns the full row with JSON-text columns (genres, aliases, lastfm_tags/similar, discogs_urls, …) decoded into real arrays/objects, + album/track counts. 404 for non-library artists. - Frontend: editor-themed modal (Tokyo-night tokens) with a Fields tab (copyable, filterable key/value rows) and a syntax-highlighted JSON tab. Copy-all-as-JSON, per-value copy (HTTP/Docker clipboard fallback), and Save .json. Esc / click-out to close. Helpers namespaced (_arecEsc) so they can't clobber the shared globals. Tests: endpoint returns the full row with decoded JSON + counts; 404 for a missing artist. 64 script-split integrity tests still green; ruff clean. --- tests/test_artist_db_record_endpoint.py | 59 +++++++ web_server.py | 53 ++++++ webui/static/library.js | 222 ++++++++++++++++++++++++ webui/static/style.css | 200 +++++++++++++++++++++ 4 files changed, 534 insertions(+) create mode 100644 tests/test_artist_db_record_endpoint.py diff --git a/tests/test_artist_db_record_endpoint.py b/tests/test_artist_db_record_endpoint.py new file mode 100644 index 00000000..d2671019 --- /dev/null +++ b/tests/test_artist_db_record_endpoint.py @@ -0,0 +1,59 @@ +"""GET /api/artist//record — the artist-detail "DB Record" inspector source. +Returns the full artists row (JSON text columns decoded) + owned counts, 404 if +the artist isn't in the library.""" + +from __future__ import annotations + +import os +import sqlite3 +import tempfile + +import pytest + +_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-arec-') +os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'a.db') +os.environ['SOULSYNC_TEST_DB_READY'] = '1' + +web_server = pytest.importorskip('web_server') + + +@pytest.fixture +def client(): + return web_server.app.test_client() + + +def _insert_artist(): + db = web_server.get_database() + conn = db._get_connection() + try: + conn.execute( + "INSERT OR REPLACE INTO artists (id, name, genres, musicbrainz_id, " + "musicbrainz_match_status, lastfm_listeners) VALUES (?,?,?,?,?,?)", + ('99001', 'Test Artist', '["rock", "metal"]', + 'mbid-123', 'matched', 4242), + ) + conn.commit() + finally: + conn.close() + + +def test_record_returns_full_row_with_decoded_json(client): + _insert_artist() + r = client.get('/api/artist/99001/record') + assert r.status_code == 200 + body = r.get_json() + assert body['success'] is True + rec = body['record'] + assert rec['name'] == 'Test Artist' + assert rec['genres'] == ['rock', 'metal'] # JSON text decoded to a list + assert rec['musicbrainz_id'] == 'mbid-123' + assert rec['musicbrainz_match_status'] == 'matched' + assert rec['lastfm_listeners'] == 4242 + assert 'counts' in body and 'albums' in body['counts'] and 'tracks' in body['counts'] + assert body['artist_id'] == '99001' + + +def test_missing_artist_is_404(client): + r = client.get('/api/artist/does-not-exist-77777/record') + assert r.status_code == 404 + assert r.get_json()['success'] is False diff --git a/web_server.py b/web_server.py index 200cea1a..13206f45 100644 --- a/web_server.py +++ b/web_server.py @@ -9316,6 +9316,59 @@ def get_album_tracks(album_id): logger.exception("Error fetching album tracks for album %s", album_id) return jsonify({"error": str(e)}), 500 +@app.route('/api/artist//record', methods=['GET']) +def get_artist_db_record(artist_id): + """Return the COMPLETE database record for a library artist — every column of + the ``artists`` row (all source IDs + match statuses, cached bios / tags / + similar / urls, timestamps, soul_id, etc.) plus owned album/track counts. + + Powers the artist-detail "DB Record" inspector. JSON-encoded text columns + (genres, aliases, lastfm_tags/similar, discogs_urls, …) are decoded into real + arrays/objects so the dump is clean rather than escaped strings. + """ + try: + database = get_database() + conn = database._get_connection() + try: + cur = conn.cursor() + cur.execute("SELECT * FROM artists WHERE id = ?", (str(artist_id),)) + row = cur.fetchone() + if row is None: + return jsonify({"success": False, "error": "Artist not found in library"}), 404 + + record = {} + for key in row.keys(): + val = row[key] + if isinstance(val, str): + s = val.strip() + if s and s[0] in '[{': + try: + val = json.loads(s) + except Exception: # noqa: S110 — leave non-JSON text as-is + pass + record[key] = val + + counts = {} + for label, table in (('albums', 'albums'), ('tracks', 'tracks')): + try: + cur.execute(f"SELECT COUNT(*) FROM {table} WHERE artist_id = ?", (str(artist_id),)) + counts[label] = cur.fetchone()[0] + except Exception: + counts[label] = None + finally: + conn.close() + + return jsonify({ + "success": True, + "artist_id": str(artist_id), + "counts": counts, + "record": record, + }) + except Exception as e: + logger.error(f"Artist DB record fetch failed for {artist_id}: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + @app.route('/api/artist//download-discography', methods=['POST']) def download_discography(artist_id): """Add selected albums from an artist's discography to the wishlist. diff --git a/webui/static/library.js b/webui/static/library.js index 4eaf6c03..4adcda46 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -1226,6 +1226,9 @@ function populateArtistDetailPage(data) { // Update hero section with image, name, and stats updateArtistHeroSection(artist, discography); + // "DB Record" inspector button (library artists only) + setupArtistRecordButton(artist); + // Update genres (if element exists) updateArtistGenres(artist.genres); @@ -8788,3 +8791,222 @@ async function _mlmDeleteMatch(id) { } // ================================= + + +// ════════════════════════════════════════════════════════════════════════════ +// Artist "DB Record" inspector — everything the database knows about an artist. +// A small glowing button at the bottom-right of the hero opens a programmer-style +// modal: a copyable field table + syntax-highlighted raw JSON, with copy-all and +// save-as-JSON. Library artists only (source artists have no DB row). +// ════════════════════════════════════════════════════════════════════════════ + +let _artistRecordData = null; // last-fetched { artist_id, counts, record } + +function setupArtistRecordButton(artist) { + const hero = document.getElementById('artist-hero-section'); + if (!hero) return; + let btn = document.getElementById('artist-db-record-btn'); + + const isLibrary = !!(artist && artist.id && document.body.dataset.artistSource === 'library'); + if (!isLibrary) { if (btn) btn.style.display = 'none'; return; } + + if (!btn) { + btn = document.createElement('button'); + btn.id = 'artist-db-record-btn'; + btn.className = 'artist-db-record-btn'; + btn.type = 'button'; + btn.title = 'Inspect everything the database knows about this artist'; + btn.innerHTML = + 'DB Record'; + hero.appendChild(btn); + } + btn.style.display = ''; + btn.onclick = () => openArtistRecordModal(artist.id, artist.name || 'Artist'); +} + +async function openArtistRecordModal(artistId, artistName) { + // Clean any prior instance + const existing = document.getElementById('artist-record-overlay'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'artist-record-overlay'; + overlay.className = 'arec-overlay'; + overlay.innerHTML = + ''; + document.body.appendChild(overlay); + requestAnimationFrame(() => overlay.classList.add('visible')); + + const close = () => { + overlay.classList.remove('visible'); + document.removeEventListener('keydown', onKey); + setTimeout(() => overlay.remove(), 220); + }; + const onKey = (e) => { if (e.key === 'Escape') close(); }; + document.addEventListener('keydown', onKey); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + overlay.querySelector('#arec-close').onclick = close; + + // Fetch the record + let payload; + try { + const res = await fetch(`/api/artist/${encodeURIComponent(artistId)}/record`); + payload = await res.json(); + if (!payload || !payload.success) throw new Error((payload && payload.error) || 'Request failed'); + } catch (err) { + document.getElementById('arec-body').innerHTML = + '
Could not load record: ' + _arecEsc(err.message || String(err)) + '
'; + return; + } + + _artistRecordData = payload; + const record = payload.record || {}; + const counts = payload.counts || {}; + + // Footer stat line + const fieldCount = Object.keys(record).length; + const matched = Object.entries(record).filter(([k, v]) => /match_status$/.test(k) && v === 'matched').length; + document.getElementById('arec-footer').innerHTML = + '' + fieldCount + ' fields' + + '' + (counts.albums != null ? counts.albums : '–') + ' albums' + + '' + (counts.tracks != null ? counts.tracks : '–') + ' tracks' + + '' + matched + ' sources matched' + + 'id ' + _arecEsc(String(payload.artist_id)) + ''; + + _arecRenderFields(record); + + // Toolbar wiring + overlay.querySelectorAll('.arec-tab').forEach(tab => { + tab.onclick = () => { + overlay.querySelectorAll('.arec-tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + const filterEl = document.getElementById('arec-filter'); + if (tab.dataset.tab === 'json') { _arecRenderJson(record); filterEl.style.visibility = 'hidden'; } + else { _arecRenderFields(record); filterEl.style.visibility = ''; _arecApplyFilter(filterEl.value); } + }; + }); + document.getElementById('arec-filter').addEventListener('input', (e) => _arecApplyFilter(e.target.value)); + document.getElementById('arec-copy').onclick = () => + _arecCopy(JSON.stringify(record, null, 2), 'Full record copied as JSON'); + document.getElementById('arec-download').onclick = () => _arecDownload(record, artistName); +} + +function _arecRenderFields(record) { + const body = document.getElementById('arec-body'); + if (!body) return; + const rows = Object.entries(record).map(([key, val]) => { + const isEmpty = val === null || val === undefined || val === ''; + let display, copyVal; + if (isEmpty) { display = 'null'; copyVal = ''; } + else if (typeof val === 'object') { + copyVal = JSON.stringify(val); + display = '' + _arecEsc(JSON.stringify(val)) + ''; + } else { + copyVal = String(val); + display = _arecEsc(String(val)); + } + return '
' + + '' + _arecEsc(key) + '' + + '' + display + '' + + '' + + '
'; + }).join(''); + body.innerHTML = '
' + rows + '
'; + body.querySelectorAll('.arec-rowcopy').forEach(b => { + b.onclick = () => _arecCopy(b.getAttribute('data-copy'), 'Value copied'); + }); +} + +function _arecRenderJson(record) { + const body = document.getElementById('arec-body'); + if (!body) return; + body.innerHTML = '
' + _jsonSyntaxHighlight(record) + '
'; +} + +function _arecApplyFilter(q) { + q = (q || '').trim().toLowerCase(); + document.querySelectorAll('#arec-body .arec-row').forEach(row => { + row.style.display = (!q || row.dataset.field.includes(q)) ? '' : 'none'; + }); +} + +function _jsonSyntaxHighlight(obj) { + let json = JSON.stringify(obj, null, 2); + json = json.replace(/&/g, '&').replace(//g, '>'); + return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false)\b|\bnull\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, (m) => { + let cls = 'tok-num'; + if (/^"/.test(m)) cls = /:$/.test(m) ? 'tok-key' : 'tok-str'; + else if (/true|false/.test(m)) cls = 'tok-bool'; + else if (/null/.test(m)) cls = 'tok-null'; + return '' + m + ''; + }); +} + +function _arecCopy(text, label) { + text = text == null ? '' : String(text); + const done = () => (typeof showToast === 'function') && showToast(label || 'Copied', 'success'); + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(done).catch(() => _arecCopyFallback(text, done)); + } else { _arecCopyFallback(text, done); } +} + +function _arecCopyFallback(text, done) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.cssText = 'position:fixed;left:-9999px'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); } catch (e) { /* ignore */ } + document.body.removeChild(ta); + done(); +} + +function _arecDownload(record, artistName) { + const safe = String(artistName || 'artist').replace(/[^a-z0-9._-]+/gi, '_').slice(0, 60) || 'artist'; + const blob = new Blob([JSON.stringify(record, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = safe + '_db_record.json'; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + if (typeof showToast === 'function') showToast('Saved ' + a.download, 'success'); +} + +function _arecEsc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>'); +} +function _arecEscAttr(s) { + return _arecEsc(s).replace(/"/g, '"'); +} diff --git a/webui/static/style.css b/webui/static/style.css index 754d32c7..69f45953 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -67977,3 +67977,203 @@ body.em-scroll-lock { overflow: hidden; } font-weight: 500; margin-bottom: 6px; } + + +/* ════════════════════════════════════════════════════════════════════════ + Artist "DB Record" inspector — button + programmer-style modal + ════════════════════════════════════════════════════════════════════════ */ +#artist-hero-section { position: relative; } + +.artist-db-record-btn { + position: absolute; + bottom: 14px; + right: 16px; + z-index: 6; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 7px 13px; + font: 600 11.5px/1 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + letter-spacing: 0.04em; + color: #cdd6f4; + background: linear-gradient(135deg, rgba(20,24,34,0.72), rgba(14,17,23,0.82)); + border: 1px solid rgba(122,162,247,0.35); + border-radius: 999px; + cursor: pointer; + backdrop-filter: blur(8px); + box-shadow: 0 4px 18px rgba(0,0,0,0.35), inset 0 0 0 1px rgba(255,255,255,0.03), + 0 0 0 0 rgba(122,162,247,0.0); + opacity: 0.82; + transition: transform .2s cubic-bezier(.4,0,.2,1), box-shadow .25s ease, + opacity .2s ease, border-color .25s ease, color .2s ease; +} +.artist-db-record-btn svg { color: #7aa2f7; transition: color .2s ease, transform .3s ease; } +.artist-db-record-btn:hover { + opacity: 1; + transform: translateY(-2px); + color: #fff; + border-color: rgba(122,162,247,0.7); + box-shadow: 0 8px 26px rgba(0,0,0,0.45), inset 0 0 0 1px rgba(255,255,255,0.05), + 0 0 18px rgba(122,162,247,0.35); +} +.artist-db-record-btn:hover svg { color: #9ece6a; transform: rotate(-6deg) scale(1.08); } +.artist-db-record-btn:active { transform: translateY(0); } + +/* ── Overlay + card ── */ +.arec-overlay { + position: fixed; inset: 0; z-index: 10050; + display: flex; align-items: center; justify-content: center; + padding: 24px; + background: rgba(8,10,14,0.7); + backdrop-filter: blur(10px); + opacity: 0; transition: opacity .22s ease; +} +.arec-overlay.visible { opacity: 1; } + +.arec-card { + width: min(760px, 96vw); + max-height: min(82vh, 820px); + display: flex; flex-direction: column; + background: linear-gradient(180deg, #11151c, #0d1017); + border: 1px solid rgba(122,162,247,0.22); + border-radius: 16px; + box-shadow: 0 30px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.02), + 0 0 60px rgba(122,162,247,0.08); + overflow: hidden; + transform: translateY(10px) scale(0.985); + transition: transform .26s cubic-bezier(.34,1.3,.5,1); + font-family: ui-sans-serif, system-ui, sans-serif; +} +.arec-overlay.visible .arec-card { transform: translateY(0) scale(1); } + +.arec-header { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 16px 12px; + border-bottom: 1px solid rgba(255,255,255,0.06); + background: linear-gradient(180deg, rgba(122,162,247,0.06), transparent); +} +.arec-title { + display: flex; align-items: center; gap: 8px; + font: 700 14px/1 'JetBrains Mono', ui-monospace, Menlo, monospace; + color: #e6ebff; letter-spacing: 0.02em; +} +.arec-dot { + width: 9px; height: 9px; border-radius: 50%; + background: #9ece6a; box-shadow: 0 0 10px #9ece6a; + animation: arecPulse 2.4s ease-in-out infinite; +} +@keyframes arecPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } } +.arec-sub { + margin-top: 4px; font-size: 12px; color: #8b93b0; + font-family: ui-monospace, Menlo, monospace; +} +.arec-close { + background: none; border: none; color: #8b93b0; font-size: 24px; + line-height: 1; cursor: pointer; padding: 0 4px; transition: color .15s ease, transform .15s ease; +} +.arec-close:hover { color: #ff6b6b; transform: scale(1.12); } + +/* ── Toolbar ── */ +.arec-toolbar { + display: flex; align-items: center; gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid rgba(255,255,255,0.06); + flex-wrap: wrap; +} +.arec-tabs { display: inline-flex; background: rgba(255,255,255,0.04); border-radius: 8px; padding: 2px; } +.arec-tab { + border: none; background: none; cursor: pointer; + padding: 5px 12px; border-radius: 6px; + font: 600 11.5px/1 'JetBrains Mono', ui-monospace, monospace; + color: #8b93b0; transition: all .15s ease; +} +.arec-tab.active { background: rgba(122,162,247,0.22); color: #cdd6f4; } +.arec-tab:not(.active):hover { color: #cdd6f4; } +.arec-filter { + flex: 1; min-width: 120px; + padding: 7px 11px; + background: rgba(0,0,0,0.35); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 8px; + color: #cdd6f4; + font: 12px/1 ui-monospace, Menlo, monospace; + outline: none; transition: border-color .15s ease, box-shadow .15s ease; +} +.arec-filter:focus { border-color: rgba(122,162,247,0.5); box-shadow: 0 0 0 3px rgba(122,162,247,0.12); } +.arec-actions { display: inline-flex; gap: 8px; } +.arec-btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 7px 12px; + background: rgba(122,162,247,0.1); + border: 1px solid rgba(122,162,247,0.25); + border-radius: 8px; + color: #c0caf5; cursor: pointer; + font: 600 11.5px/1 'JetBrains Mono', ui-monospace, monospace; + transition: all .15s ease; +} +.arec-btn:hover { background: rgba(122,162,247,0.2); border-color: rgba(122,162,247,0.55); color: #fff; } +.arec-btn svg { opacity: 0.85; } + +/* ── Body ── */ +.arec-body { + flex: 1; overflow: auto; padding: 8px 6px 8px 14px; + font-family: ui-monospace, 'JetBrains Mono', Menlo, Consolas, monospace; +} +.arec-loading, .arec-error { padding: 28px; text-align: center; color: #8b93b0; font-size: 13px; } +.arec-error { color: #ff8585; } + +.arec-fields { display: flex; flex-direction: column; } +.arec-row { + display: grid; grid-template-columns: minmax(140px, 210px) 1fr auto; + gap: 10px; align-items: start; + padding: 6px 8px; border-radius: 7px; + border-bottom: 1px solid rgba(255,255,255,0.035); + transition: background .12s ease; +} +.arec-row:hover { background: rgba(122,162,247,0.07); } +.arec-row.is-empty { opacity: 0.5; } +.arec-key { color: #7aa2f7; font-size: 12px; font-weight: 600; word-break: break-all; } +.arec-val { color: #c8d0e8; font-size: 12px; word-break: break-word; white-space: pre-wrap; } +.arec-val .arec-json { color: #9ece6a; } +.arec-val .arec-null, .arec-null { color: #565f89; font-style: italic; } +.arec-rowcopy { + background: none; border: none; cursor: pointer; + color: #565f89; font-size: 13px; padding: 1px 5px; border-radius: 5px; + opacity: 0; transition: opacity .12s ease, color .12s ease, background .12s ease; +} +.arec-row:hover .arec-rowcopy { opacity: 1; } +.arec-rowcopy:hover { color: #9ece6a; background: rgba(255,255,255,0.06); } + +.arec-code { + margin: 0; padding: 6px 8px; font-size: 12px; line-height: 1.55; + color: #c0caf5; white-space: pre; tab-size: 2; +} +.arec-code .tok-key { color: #7aa2f7; } +.arec-code .tok-str { color: #9ece6a; } +.arec-code .tok-num { color: #ff9e64; } +.arec-code .tok-bool { color: #bb9af7; } +.arec-code .tok-null { color: #565f89; font-style: italic; } + +/* ── Footer ── */ +.arec-footer { + display: flex; align-items: center; gap: 16px; flex-wrap: wrap; + padding: 10px 16px; + border-top: 1px solid rgba(255,255,255,0.06); + background: rgba(0,0,0,0.25); + font: 11px/1 'JetBrains Mono', ui-monospace, monospace; + color: #8b93b0; +} +.arec-footer b { color: #cdd6f4; } +.arec-footer .arec-id { margin-left: auto; color: #565f89; } + +/* scrollbar */ +.arec-body::-webkit-scrollbar { width: 10px; } +.arec-body::-webkit-scrollbar-thumb { background: rgba(122,162,247,0.25); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } +.arec-body::-webkit-scrollbar-thumb:hover { background: rgba(122,162,247,0.45); background-clip: padding-box; } + +@media (max-width: 640px) { + .artist-db-record-btn span { display: none; } + .artist-db-record-btn { padding: 8px; } + .arec-row { grid-template-columns: 1fr; gap: 2px; } + .arec-rowcopy { opacity: 1; justify-self: end; margin-top: -22px; } +}