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; }