Enrichment manager: 6 UX improvements

- #1 Unconfigured-source banner: when a source has enabled=false, show a
  notice that browsing works but matches/retries won't run until it's set up.
- #2 Rate-limit detail: when rate_limited, surface 'resumes in ~Xm' (from the
  status payload) instead of just a pill.
- #3 Richer rows: unmatched items now show parent context — an album's artist,
  a track's album — via a parent expression in the query (+ test).
- #4 Bulk select: per-row checkboxes + a bulk bar to retry several at once
  (capped concurrency), reusing the /retry item endpoint.
- #5 Remember last worker: selection persists in localStorage and is restored
  on open; openEnrichmentManager(workerId) supports future deep-linking
  (bubbles left on their pause-on-click behaviour).
- #6 Keyboard nav: ArrowUp/Down moves focus between rows; actions are native
  buttons (Enter/Space) and Escape closes — list isn't poll-refreshed so focus
  is stable.

53 enrichment tests green; JS syntax clean.
This commit is contained in:
BoulderBadgeDad 2026-06-02 19:50:57 -07:00
parent e53a157793
commit 62ee1f8520
4 changed files with 161 additions and 14 deletions

View file

@ -34,21 +34,26 @@ SERVICE_ENTITY_SUPPORT = {
'amazon': ('artist', 'album', 'track'),
}
# entity_type -> table / display-name column / image expression / optional join.
# entity_type -> table / display-name column / image expression / optional join
# / parent-context expression (the artist an album belongs to; the album a
# track belongs to) so the UI can disambiguate same-named items.
# tracks carry no artwork column of their own, so we borrow the parent album's.
_ENTITY_TABLE = {
'artist': {
'table': 'artists', 'name': 'name',
'image': 'artists.thumb_url', 'join': '',
'image': 'artists.thumb_url', 'join': '', 'parent': None,
},
'album': {
'table': 'albums', 'name': 'title',
'image': 'albums.thumb_url', 'join': '',
'image': 'albums.thumb_url',
'join': 'LEFT JOIN artists par ON albums.artist_id = par.id',
'parent': 'par.name',
},
'track': {
'table': 'tracks', 'name': 'title',
'image': 'al.thumb_url',
'join': 'LEFT JOIN albums al ON tracks.album_id = al.id',
'parent': 'al.title',
},
}
@ -134,9 +139,11 @@ def build_unmatched_query(
where.append(f"{table}.{name_col} LIKE ?")
params.append(f"%{query}%")
parent_expr = meta.get('parent')
parent_select = f"{parent_expr} AS parent" if parent_expr else "NULL AS parent"
sql = (
f"SELECT {table}.id AS id, {table}.{name_col} AS name, "
f"{image_expr} AS image_url, {table}.{ms} AS status, "
f"{image_expr} AS image_url, {parent_select}, {table}.{ms} AS status, "
f"{table}.{la} AS last_attempted "
f"FROM {table} {join} "
f"WHERE {' AND '.join(where)} "

View file

@ -150,6 +150,16 @@ def test_track_unmatched_borrows_album_artwork(db):
assert res['items'][0]['image_url'] == 'http://img/evolve.jpg'
def test_unmatched_includes_parent_context(db):
# album's parent is its artist; track's parent is its album
album = db.get_enrichment_unmatched('spotify', 'album', status='not_found')['items'][0]
assert album['parent'] == 'Failed Dragons'
track = db.get_enrichment_unmatched('spotify', 'track', status='not_found')['items'][0]
assert track['parent'] == 'Evolve'
artist = db.get_enrichment_unmatched('spotify', 'artist', status='not_found')['items'][0]
assert artist['parent'] is None
def test_db_raises_on_bad_input(db):
with pytest.raises(UnmatchedQueryError):
db.get_enrichment_unmatched('spotify', 'artist', status='bogus')

View file

@ -83,6 +83,7 @@ const enrichmentManagerState = {
page: 0,
pageSize: 25,
unmatched: null, // { total, items }
selectedItems: new Set(), // ids checked for bulk retry
pollTimer: null,
loadToken: 0, // guards against out-of-order async renders
};
@ -120,7 +121,7 @@ function _emRelativeTime(value) {
// ── Open / close ──────────────────────────────────────────────────────────
async function openEnrichmentManager() {
async function openEnrichmentManager(workerId) {
let overlay = document.getElementById('enrichment-manager-overlay');
if (!overlay) {
overlay = document.createElement('div');
@ -157,11 +158,16 @@ async function openEnrichmentManager() {
await refreshAllEnrichmentStatuses();
renderEnrichmentRail();
// Default selection: first running worker, else first in the list.
const running = ENRICHMENT_WORKERS.find(
w => enrichmentManagerState.statuses[w.id]?.running
);
selectEnrichmentWorker((running || ENRICHMENT_WORKERS[0]).id);
// Selection priority: explicit deep-link arg → last-viewed (remembered) →
// first running worker → first in the list.
let remembered = null;
try { remembered = localStorage.getItem('em-last-worker'); } catch (_e) { /* ignore */ }
const valid = (wid) => wid && _emWorkerById[wid];
const running = ENRICHMENT_WORKERS.find(w => enrichmentManagerState.statuses[w.id]?.running);
const initial = (valid(workerId) && workerId)
|| (valid(remembered) && remembered)
|| (running || ENRICHMENT_WORKERS[0]).id;
selectEnrichmentWorker(initial);
if (modal) setTimeout(() => modal.focus(), 60);
if (enrichmentManagerState.pollTimer) clearInterval(enrichmentManagerState.pollTimer);
@ -307,6 +313,8 @@ function _emUpdateRailRow(id) {
async function selectEnrichmentWorker(id) {
enrichmentManagerState.selected = id;
try { localStorage.setItem('em-last-worker', id); } catch (_e) { /* ignore */ }
enrichmentManagerState.selectedItems.clear();
enrichmentManagerState.breakdown = null;
enrichmentManagerState.unmatched = null;
enrichmentManagerState.priority = '';
@ -389,6 +397,7 @@ function renderEnrichmentPanel() {
panel.innerHTML = `
<div class="em-panel-header" id="em-panel-header"></div>
<div class="em-banner" id="em-banner" hidden></div>
<div class="em-chain" id="em-chain"></div>
<div class="em-section-label em-section-label--row">
<span>Enrichment coverage</span>
@ -397,7 +406,8 @@ function renderEnrichmentPanel() {
<div class="em-stats" id="em-stats"></div>
<div class="em-unmatched">
<div class="em-unmatched-controls" id="em-unmatched-controls"></div>
<div class="em-unmatched-list" id="em-unmatched-list"></div>
<div class="em-bulk-bar" id="em-bulk-bar" hidden></div>
<div class="em-unmatched-list" id="em-unmatched-list" onkeydown="onEnrichmentListKey(event)"></div>
<div class="em-pager" id="em-pager"></div>
</div>`;
_emRenderPanelHeader();
@ -543,6 +553,33 @@ function _emUpdateHeaderLive() {
toggle.classList.toggle('em-btn--go', !!isPaused);
toggle.textContent = isPaused ? '▶ Resume' : '⏸ Pause';
}
// #1 unconfigured + #2 rate-limit banner.
const banner = document.getElementById('em-banner');
if (banner) {
let html = '', cls = 'em-banner';
if (status && status.enabled === false) {
cls += ' em-banner--warn';
html = '⚙️ This source isnt configured — add its credentials in Settings. '
+ 'Browsing works, but matches and retries wont run until its set up.';
} else if (status && status.rate_limited) {
cls += ' em-banner--warn';
const rl = status.rate_limit || {};
const secs = rl.retry_after || rl.reset_in || rl.cooldown_seconds;
const when = secs ? ` — resumes in ~${_emHumanDuration(secs)}` : ' — it will resume automatically';
html = `⏳ Rate-limited by the source${when}.`;
}
banner.className = cls;
banner.innerHTML = html;
banner.hidden = !html;
}
}
function _emHumanDuration(seconds) {
const s = Math.max(0, Math.round(Number(seconds) || 0));
if (s < 60) return `${s}s`;
const m = Math.round(s / 60);
return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`;
}
function _emRenderStats() {
@ -692,22 +729,96 @@ function _emRenderUnmatchedList() {
? '<span class="em-chip em-chip--nf">not found</span>'
: '<span class="em-chip em-chip--pend">pending</span>';
const safeName = _emEscape(item.name || 'Unknown');
const safeId = _emEscape(item.id);
const parent = item.parent
? `<span class="em-row-parent" title="${_emEscape(item.parent)}">· ${_emEscape(item.parent)}</span>`
: '';
const checked = enrichmentManagerState.selectedItems.has(String(item.id)) ? 'checked' : '';
return `
<div class="em-row">
<input type="checkbox" class="em-row-check" ${checked}
aria-label="Select ${safeName}"
onchange="toggleEnrichmentRowSelect('${safeId}', this.checked)">
${img}
<div class="em-row-info">
<div class="em-row-name" title="${safeName}">${safeName}</div>
<div class="em-row-name" title="${safeName}">${safeName} ${parent}</div>
<div class="em-row-meta">${statusBadge} ${last}</div>
</div>
<div class="em-row-actions">
<button class="em-btn em-btn--sm" onclick="openEnrichmentMatch('${id}','${entity}','${_emEscape(item.id)}', this)">Match</button>
<button class="em-btn em-btn--sm" onclick="openEnrichmentMatch('${id}','${entity}','${safeId}', this)">Match</button>
<button class="em-btn em-btn--sm em-btn--ghost" title="Re-queue for the worker to try again"
onclick="retryEnrichmentItem('${id}','${entity}','${_emEscape(item.id)}', this)">Retry</button>
onclick="retryEnrichmentItem('${id}','${entity}','${safeId}', this)">Retry</button>
</div>
</div>`;
}).join('');
}
_emRenderPager();
_emRenderBulkBar();
}
function _emRenderBulkBar() {
const bar = document.getElementById('em-bulk-bar');
if (!bar) return;
const n = enrichmentManagerState.selectedItems.size;
if (!n) { bar.hidden = true; bar.innerHTML = ''; return; }
bar.hidden = false;
bar.innerHTML = `
<span class="em-bulk-count">${n} selected</span>
<button class="em-btn em-btn--sm" onclick="retrySelectedEnrichment(this)"> Retry selected</button>
<button class="em-btn em-btn--sm em-btn--ghost" onclick="clearEnrichmentSelection()">Clear</button>`;
}
function toggleEnrichmentRowSelect(id, checked) {
if (checked) enrichmentManagerState.selectedItems.add(String(id));
else enrichmentManagerState.selectedItems.delete(String(id));
_emRenderBulkBar();
}
function clearEnrichmentSelection() {
enrichmentManagerState.selectedItems.clear();
_emRenderUnmatchedList();
}
// #6 keyboard nav: ↑/↓ moves focus between rows' Match buttons (Enter/Space
// then activates natively). The list isn't refreshed by polling, so focus
// stays put between user actions.
function onEnrichmentListKey(e) {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
const host = document.getElementById('em-unmatched-list');
if (!host) return;
const btns = Array.from(host.querySelectorAll('.em-row .em-row-actions .em-btn:first-child'));
if (!btns.length) return;
e.preventDefault();
const idx = btns.indexOf(document.activeElement);
const next = e.key === 'ArrowDown'
? (idx < 0 ? 0 : Math.min(idx + 1, btns.length - 1))
: (idx <= 0 ? 0 : idx - 1);
btns[next].focus();
}
async function retrySelectedEnrichment(btn) {
const service = enrichmentManagerState.selected;
const entity = enrichmentManagerState.entityTab;
const ids = Array.from(enrichmentManagerState.selectedItems);
if (!ids.length) return;
if (btn) { btn.disabled = true; btn.textContent = 'Re-queuing…'; }
let ok = 0;
// Cap concurrency to be gentle on the server.
for (let i = 0; i < ids.length; i += 5) {
const slice = ids.slice(i, i + 5);
const results = await Promise.all(slice.map(eid =>
fetch(`/api/enrichment/${service}/retry`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_type: entity, scope: 'item', entity_id: eid }),
}).then(r => r.ok).catch(() => false)
));
ok += results.filter(Boolean).length;
}
showToast(`Re-queued ${ok.toLocaleString()} item(s)`, ok ? 'success' : 'error');
enrichmentManagerState.selectedItems.clear();
await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]);
_emRenderStats();
_emRenderUnmatchedList();
}
function _emRenderPager() {
@ -961,4 +1072,8 @@ window.toggleEnrichmentWorker = toggleEnrichmentWorker;
window.setEnrichmentPriority = setEnrichmentPriority;
window.retryEnrichmentItem = retryEnrichmentItem;
window.retryAllFailedEnrichment = retryAllFailedEnrichment;
window.toggleEnrichmentRowSelect = toggleEnrichmentRowSelect;
window.clearEnrichmentSelection = clearEnrichmentSelection;
window.retrySelectedEnrichment = retrySelectedEnrichment;
window.onEnrichmentListKey = onEnrichmentListKey;
window.openEnrichmentMatch = openEnrichmentMatch;

View file

@ -65170,6 +65170,21 @@ body.em-scroll-lock { overflow: hidden; }
.em-search:focus, .em-select:focus { outline: none; border-color: rgba(var(--em-accent-rgb, 99,102,241), 0.6); }
.em-row:hover { background: rgba(var(--em-accent-rgb, 255,255,255), 0.07); border-color: rgba(var(--em-accent-rgb, 255,255,255), 0.14); }
/* Disabled / rate-limit banner */
.em-banner { flex: 0 0 auto; padding: 10px 14px; border-radius: 10px; font-size: 12.5px; line-height: 1.45;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: rgba(255,255,255,0.75); }
.em-banner--warn { background: rgba(240,176,80,0.12); border-color: rgba(240,176,80,0.35); color: #f3cd86; }
/* Row checkbox + parent context (#3 / #4) */
.em-row-check { width: 16px; height: 16px; flex: 0 0 auto; cursor: pointer; accent-color: rgb(var(--em-accent-rgb, 99,102,241)); }
.em-row-parent { font-size: 12px; font-weight: 500; color: rgba(255,255,255,0.4); }
/* Bulk action bar */
.em-bulk-bar { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; padding: 8px 12px;
background: rgba(var(--em-accent-rgb, 99,102,241), 0.12); border: 1px solid rgba(var(--em-accent-rgb, 99,102,241), 0.3);
border-radius: 10px; }
.em-bulk-count { font-size: 13px; font-weight: 700; color: #fff; margin-right: auto; }
/* Bulk retry-all + helper hint */
.em-retry-all { margin-left: 4px; }
.em-hint { font-size: 11px; color: rgba(255,255,255,0.38); flex: 0 0 auto; margin-top: -2px; }