diff --git a/core/enrichment/unmatched.py b/core/enrichment/unmatched.py
index fdf9fbfb..1bcd5eae 100644
--- a/core/enrichment/unmatched.py
+++ b/core/enrichment/unmatched.py
@@ -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)} "
diff --git a/tests/enrichment/test_unmatched.py b/tests/enrichment/test_unmatched.py
index d6c88f08..1f749568 100644
--- a/tests/enrichment/test_unmatched.py
+++ b/tests/enrichment/test_unmatched.py
@@ -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')
diff --git a/webui/static/enrichment-manager.js b/webui/static/enrichment-manager.js
index 563111f1..9eadc351 100644
--- a/webui/static/enrichment-manager.js
+++ b/webui/static/enrichment-manager.js
@@ -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 = `
+
Enrichment coverage
@@ -397,7 +406,8 @@ function renderEnrichmentPanel() {
`;
_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 isn’t configured — add its credentials in Settings. '
+ + 'Browsing works, but matches and retries won’t run until it’s 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() {
? '
not found'
: '
pending';
const safeName = _emEscape(item.name || 'Unknown');
+ const safeId = _emEscape(item.id);
+ const parent = item.parent
+ ? `
· ${_emEscape(item.parent)}`
+ : '';
+ const checked = enrichmentManagerState.selectedItems.has(String(item.id)) ? 'checked' : '';
return `
+
${img}
-
${safeName}
+
${safeName} ${parent}
${statusBadge} ${last}
-
+
+ onclick="retryEnrichmentItem('${id}','${entity}','${safeId}', this)">Retry
`;
}).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 = `
+
${n} selected
+
+
`;
+}
+
+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;
diff --git a/webui/static/style.css b/webui/static/style.css
index 22b2de9a..2caa10e2 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -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; }