Artist detail: "DB Record" inspector — everything the DB knows about an artist

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/<id>/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.
This commit is contained in:
BoulderBadgeDad 2026-06-11 16:51:50 -07:00
parent ee4d514d60
commit 123eb6139f
4 changed files with 534 additions and 0 deletions

View file

@ -0,0 +1,59 @@
"""GET /api/artist/<id>/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

View file

@ -9316,6 +9316,59 @@ def get_album_tracks(album_id):
logger.exception("Error fetching album tracks for album %s", album_id) logger.exception("Error fetching album tracks for album %s", album_id)
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@app.route('/api/artist/<artist_id>/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/<artist_id>/download-discography', methods=['POST']) @app.route('/api/artist/<artist_id>/download-discography', methods=['POST'])
def download_discography(artist_id): def download_discography(artist_id):
"""Add selected albums from an artist's discography to the wishlist. """Add selected albums from an artist's discography to the wishlist.

View file

@ -1226,6 +1226,9 @@ function populateArtistDetailPage(data) {
// Update hero section with image, name, and stats // Update hero section with image, name, and stats
updateArtistHeroSection(artist, discography); updateArtistHeroSection(artist, discography);
// "DB Record" inspector button (library artists only)
setupArtistRecordButton(artist);
// Update genres (if element exists) // Update genres (if element exists)
updateArtistGenres(artist.genres); 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 =
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<ellipse cx="12" cy="5" rx="8" ry="3"></ellipse>' +
'<path d="M4 5v6c0 1.66 3.58 3 8 3s8-1.34 8-3V5"></path>' +
'<path d="M4 11v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6"></path>' +
'</svg><span>DB Record</span>';
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 =
'<div class="arec-card" role="dialog" aria-label="Artist database record">' +
'<div class="arec-header">' +
'<div class="arec-title-wrap">' +
'<div class="arec-title"><span class="arec-dot"></span>Artist DB Record</div>' +
'<div class="arec-sub" id="arec-sub">' + _arecEsc(artistName) + '</div>' +
'</div>' +
'<button class="arec-close" id="arec-close" title="Close (Esc)">&times;</button>' +
'</div>' +
'<div class="arec-toolbar">' +
'<div class="arec-tabs">' +
'<button class="arec-tab active" data-tab="fields">Fields</button>' +
'<button class="arec-tab" data-tab="json">JSON</button>' +
'</div>' +
'<input id="arec-filter" class="arec-filter" type="text" placeholder="filter fields…" autocomplete="off" spellcheck="false">' +
'<div class="arec-actions">' +
'<button class="arec-btn" id="arec-copy"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>Copy JSON</button>' +
'<button class="arec-btn" id="arec-download"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>Save .json</button>' +
'</div>' +
'</div>' +
'<div class="arec-body" id="arec-body">' +
'<div class="arec-loading">Loading record…</div>' +
'</div>' +
'<div class="arec-footer" id="arec-footer"></div>' +
'</div>';
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 =
'<div class="arec-error">Could not load record: ' + _arecEsc(err.message || String(err)) + '</div>';
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 =
'<span><b>' + fieldCount + '</b> fields</span>' +
'<span><b>' + (counts.albums != null ? counts.albums : '') + '</b> albums</span>' +
'<span><b>' + (counts.tracks != null ? counts.tracks : '') + '</b> tracks</span>' +
'<span><b>' + matched + '</b> sources matched</span>' +
'<span class="arec-id">id ' + _arecEsc(String(payload.artist_id)) + '</span>';
_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 = '<span class="arec-null">null</span>'; copyVal = ''; }
else if (typeof val === 'object') {
copyVal = JSON.stringify(val);
display = '<span class="arec-json">' + _arecEsc(JSON.stringify(val)) + '</span>';
} else {
copyVal = String(val);
display = _arecEsc(String(val));
}
return '<div class="arec-row' + (isEmpty ? ' is-empty' : '') + '" data-field="' + _arecEscAttr(key.toLowerCase()) +
' ' + _arecEscAttr(copyVal.toLowerCase()) + '">' +
'<span class="arec-key">' + _arecEsc(key) + '</span>' +
'<span class="arec-val">' + display + '</span>' +
'<button class="arec-rowcopy" title="Copy value" data-copy="' + _arecEscAttr(copyVal) + '">⧉</button>' +
'</div>';
}).join('');
body.innerHTML = '<div class="arec-fields">' + rows + '</div>';
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 = '<pre class="arec-code">' + _jsonSyntaxHighlight(record) + '</pre>';
}
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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 '<span class="' + cls + '">' + m + '</span>';
});
}
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function _arecEscAttr(s) {
return _arecEsc(s).replace(/"/g, '&quot;');
}

View file

@ -67977,3 +67977,203 @@ body.em-scroll-lock { overflow: hidden; }
font-weight: 500; font-weight: 500;
margin-bottom: 6px; 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; }
}