Enrichment manager: 'process this group first' + refined hero header

Per-worker processing-order override + UI polish.

Feature — pin an entity group to enrich first:
- Each worker normally runs artist -> album -> track. A user can pin one
  group (artist/album/track) to run first from the modal; the worker keeps
  that group first until it's exhausted, then resumes the normal chain.
- core/worker_utils.py: read_enrichment_priority() (reads
  <service>_enrichment_priority each loop, live) + priority_pending_item()
  (shared, whitelisted query returning the worker's expected item shape;
  Spotify/iTunes get album_individual/track_individual via a type map).
- A guarded ~6-line hook at the top of all 11 workers' _get_next_item.
  CRITICAL: when nothing is pinned (default) the hook returns immediately,
  so default enrichment order is byte-identical to before. Discogs (no track)
  and Genius (no album) only honor their supported entities.
- core/enrichment/api.py: GET/POST /api/enrichment/<id>/priority (+ config_get
  hook); POST validates the entity against what the source enriches.
- 14 new tests (helper shapes, exhaustion, route get/set/clear/validate).

UI:
- Refined hero header: identity + inline status left, single Pause right,
  'now enriching' quiet sub-line; overall coverage % moved into the stats
  section ('82% matched · 1,203 of 1,460'). Hero gently pulses while running.
- New processing-order strip: artist→album→track steps showing the live phase
  (pulsing 'now'), pinned group ('first' + 📌), and done/remaining; click a
  step to pin it, click again for auto.

py_compile clean across all 11 workers; 52 enrichment tests green.
This commit is contained in:
BoulderBadgeDad 2026-06-02 19:45:04 -07:00
parent fc9a9f1c90
commit e53a157793
17 changed files with 527 additions and 23 deletions

View file

@ -174,6 +174,16 @@ class AmazonWorker:
cursor = conn.cursor()
self._ensure_amazon_schema(cursor)
# Pinned-group override (Manage Enrichment Workers): process one
# entity type first, then fall through to the normal chain. Unset or
# exhausted ⇒ default artist→album→track order, unchanged.
from core.worker_utils import read_enrichment_priority, priority_pending_item
_prio = read_enrichment_priority('amazon')
if _prio:
_pi = priority_pending_item(cursor, 'amazon', _prio)
if _pi:
return _pi
# Priority 1: Unattempted artists
cursor.execute("""
SELECT id, name FROM artists

View file

@ -162,6 +162,16 @@ class AudioDBWorker:
conn = self.db._get_connection()
cursor = conn.cursor()
# Pinned-group override (Manage Enrichment Workers): process one
# entity type first, then fall through to the normal chain. Unset or
# exhausted ⇒ default artist→album→track order, unchanged.
from core.worker_utils import read_enrichment_priority, priority_pending_item
_prio = read_enrichment_priority('audiodb')
if _prio:
_pi = priority_pending_item(cursor, 'audiodb', _prio)
if _pi:
return _pi
# Priority 1: Unattempted artists
cursor.execute("""
SELECT id, name

View file

@ -163,6 +163,16 @@ class DeezerWorker:
conn = self.db._get_connection()
cursor = conn.cursor()
# Pinned-group override (Manage Enrichment Workers): process one
# entity type first, then fall through to the normal chain. Unset or
# exhausted ⇒ default artist→album→track order, unchanged.
from core.worker_utils import read_enrichment_priority, priority_pending_item
_prio = read_enrichment_priority('deezer')
if _prio:
_pi = priority_pending_item(cursor, 'deezer', _prio)
if _pi:
return _pi
# Priority 1: Unattempted artists
cursor.execute("""
SELECT id, name

View file

@ -174,6 +174,16 @@ class DiscogsWorker:
conn = self.db._get_connection()
cursor = conn.cursor()
# Pinned-group override (Manage Enrichment Workers): process one
# entity type first, then fall through to the normal chain. Discogs
# has no track endpoint, so only artist/album are honored.
from core.worker_utils import read_enrichment_priority, priority_pending_item
_prio = read_enrichment_priority('discogs')
if _prio in ('artist', 'album'):
_pi = priority_pending_item(cursor, 'discogs', _prio)
if _pi:
return _pi
# Priority 1: Unattempted artists
cursor.execute("""
SELECT id, name FROM artists

View file

@ -35,6 +35,7 @@ logger = get_logger("enrichment.api")
# Hooks the host wires up so the blueprint can persist pause state and
# clean up auto-pause / yield-override sets without circular imports.
_config_set: Optional[Callable[[str, Any], None]] = None
_config_get: Optional[Callable[[str, Any], Any]] = None
_auto_paused_discard: Optional[Callable[[str], None]] = None
_yield_override_add: Optional[Callable[[str], None]] = None
_db_getter: Optional[Callable[[], Any]] = None
@ -43,6 +44,7 @@ _db_getter: Optional[Callable[[], Any]] = None
def configure(
*,
config_set: Optional[Callable[[str, Any], None]] = None,
config_get: Optional[Callable[[str, Any], Any]] = None,
auto_paused_discard: Optional[Callable[[str], None]] = None,
yield_override_add: Optional[Callable[[str], None]] = None,
db_getter: Optional[Callable[[], Any]] = None,
@ -51,10 +53,12 @@ def configure(
Each is optional pass None for hosts that don't have a corresponding
mechanism (e.g. tests). ``db_getter`` returns the live ``MusicDatabase``
for the unmatched-browser routes.
for the unmatched-browser routes; ``config_get``/``config_set`` read and
write the per-worker priority override.
"""
global _config_set, _auto_paused_discard, _yield_override_add, _db_getter
global _config_set, _config_get, _auto_paused_discard, _yield_override_add, _db_getter
_config_set = config_set
_config_get = config_get
_auto_paused_discard = auto_paused_discard
_yield_override_add = yield_override_add
_db_getter = db_getter
@ -250,4 +254,45 @@ def create_blueprint() -> Blueprint:
return jsonify({'success': True, 'reset': count, 'service': service_id,
'entity_type': entity_type, 'scope': scope}), 200
@bp.route('/api/enrichment/<service_id>/priority', methods=['GET'])
def enrichment_get_priority(service_id: str):
"""Return the pinned 'process this group first' entity for a worker."""
if service_id not in SERVICE_ENTITY_SUPPORT:
return jsonify({'error': f'Unknown enrichment service: {service_id}'}), 404
priority = ''
if _config_get is not None:
try:
priority = (_config_get(f'{service_id}_enrichment_priority', '') or '').strip().lower()
except Exception as e:
logger.debug("reading %s priority: %s", service_id, e)
if priority not in supported_entity_types(service_id):
priority = ''
return jsonify({'service': service_id, 'priority': priority,
'entity_types': list(supported_entity_types(service_id))}), 200
@bp.route('/api/enrichment/<service_id>/priority', methods=['POST'])
def enrichment_set_priority(service_id: str):
"""Pin (or clear) the entity type the worker should process first.
Body: ``entity`` = 'artist'|'album'|'track' to pin, or '' / null / 'none'
to clear. Must be an entity type the source actually enriches.
"""
if service_id not in SERVICE_ENTITY_SUPPORT:
return jsonify({'error': f'Unknown enrichment service: {service_id}'}), 404
if _config_set is None:
return jsonify({'error': 'config unavailable'}), 503
data = request.get_json(silent=True) or {}
entity = (data.get('entity') or '').strip().lower()
if entity in ('none', 'clear'):
entity = ''
if entity and entity not in supported_entity_types(service_id):
return jsonify({'error': f'{service_id} does not enrich {entity!r}'}), 400
try:
_config_set(f'{service_id}_enrichment_priority', entity)
except Exception as e:
logger.error("setting %s priority: %s", service_id, e)
return jsonify({'error': str(e)}), 500
logger.info("%s enrichment priority set to %r via UI", service_id, entity or '(none)')
return jsonify({'success': True, 'service': service_id, 'priority': entity}), 200
return bp

View file

@ -178,6 +178,16 @@ class GeniusWorker:
conn = self.db._get_connection()
cursor = conn.cursor()
# Pinned-group override (Manage Enrichment Workers): process one
# entity type first, then fall through to the normal chain. Genius
# is artist/track only, so albums are not honored.
from core.worker_utils import read_enrichment_priority, priority_pending_item
_prio = read_enrichment_priority('genius')
if _prio in ('artist', 'track'):
_pi = priority_pending_item(cursor, 'genius', _prio)
if _pi:
return _pi
# Priority 1: Unattempted artists
cursor.execute("""
SELECT id, name

View file

@ -172,6 +172,17 @@ class iTunesWorker:
conn = self.db._get_connection()
cursor = conn.cursor()
# Pinned-group override (Manage Enrichment Workers): process one
# entity type first, then fall through to the normal chain. Unset or
# exhausted ⇒ default artist→album→track order, unchanged.
from core.worker_utils import read_enrichment_priority, priority_pending_item
_prio = read_enrichment_priority('itunes')
if _prio:
_pi = priority_pending_item(cursor, 'itunes', _prio,
{'album': 'album_individual', 'track': 'track_individual'})
if _pi:
return _pi
# Priority 1: Unattempted artists
cursor.execute("""
SELECT id, name

View file

@ -177,6 +177,16 @@ class LastFMWorker:
conn = self.db._get_connection()
cursor = conn.cursor()
# Pinned-group override (Manage Enrichment Workers): process one
# entity type first, then fall through to the normal chain. Unset or
# exhausted ⇒ default artist→album→track order, unchanged.
from core.worker_utils import read_enrichment_priority, priority_pending_item
_prio = read_enrichment_priority('lastfm')
if _prio:
_pi = priority_pending_item(cursor, 'lastfm', _prio)
if _pi:
return _pi
# Priority 1: Unattempted artists
cursor.execute("""
SELECT id, name

View file

@ -166,6 +166,16 @@ class MusicBrainzWorker:
conn = self.db._get_connection()
cursor = conn.cursor()
# Pinned-group override (Manage Enrichment Workers): process one
# entity type first, then fall through to the normal chain. Unset or
# exhausted ⇒ default artist→album→track order, unchanged.
from core.worker_utils import read_enrichment_priority, priority_pending_item
_prio = read_enrichment_priority('musicbrainz')
if _prio:
_pi = priority_pending_item(cursor, 'musicbrainz', _prio)
if _pi:
return _pi
# Priority 1: Unattempted artists
cursor.execute("""
SELECT id, name

View file

@ -186,6 +186,16 @@ class QobuzWorker:
conn = self.db._get_connection()
cursor = conn.cursor()
# Pinned-group override (Manage Enrichment Workers): process one
# entity type first, then fall through to the normal chain. Unset or
# exhausted ⇒ default artist→album→track order, unchanged.
from core.worker_utils import read_enrichment_priority, priority_pending_item
_prio = read_enrichment_priority('qobuz')
if _prio:
_pi = priority_pending_item(cursor, 'qobuz', _prio)
if _pi:
return _pi
# Priority 1: Unattempted artists
cursor.execute("""
SELECT id, name

View file

@ -260,6 +260,17 @@ class SpotifyWorker:
conn = self.db._get_connection()
cursor = conn.cursor()
# Pinned-group override (Manage Enrichment Workers): process one
# entity type first, then fall through to the normal chain. Unset or
# exhausted ⇒ default artist→album→track order, unchanged.
from core.worker_utils import read_enrichment_priority, priority_pending_item
_prio = read_enrichment_priority('spotify')
if _prio:
_pi = priority_pending_item(cursor, 'spotify', _prio,
{'album': 'album_individual', 'track': 'track_individual'})
if _pi:
return _pi
# Priority 1: Unattempted artists
cursor.execute("""
SELECT id, name

View file

@ -198,6 +198,16 @@ class TidalWorker:
conn = self.db._get_connection()
cursor = conn.cursor()
# Pinned-group override (Manage Enrichment Workers): process one
# entity type first, then fall through to the normal chain. Unset or
# exhausted ⇒ default artist→album→track order, unchanged.
from core.worker_utils import read_enrichment_priority, priority_pending_item
_prio = read_enrichment_priority('tidal')
if _prio:
_pi = priority_pending_item(cursor, 'tidal', _prio)
if _pi:
return _pi
# Priority 1: Unattempted artists
cursor.execute("""
SELECT id, name

View file

@ -78,3 +78,67 @@ def set_album_api_track_count(cursor, album_id, count):
logger.warning(
"Failed to cache api_track_count for album %s: %s", album_id, e
)
# --- Enrichment "process this group first" override -----------------------
# Each enrichment worker normally processes artist -> album -> track. A user
# can pin one entity type to run first via the Manage Enrichment Workers modal;
# the choice is stored in config as "<service>_enrichment_priority" and read
# at the top of each worker's _get_next_item so it takes effect live. When the
# pinned group is exhausted (or unset), the worker falls back to its normal
# chain — so the default path is unchanged.
PRIORITY_ENTITIES = ('artist', 'album', 'track')
def read_enrichment_priority(service: str) -> str:
"""Return the pinned entity ('artist'|'album'|'track') for a worker, or ''.
Read every loop so the override applies without restarting the worker.
Any error / unset / invalid value yields '' (no override)."""
try:
from config.settings import config_manager
val = (config_manager.get(f'{service}_enrichment_priority', '') or '')
val = str(val).strip().lower()
return val if val in PRIORITY_ENTITIES else ''
except Exception:
return ''
def priority_pending_item(cursor, service, entity, type_overrides=None):
"""Return one pending (NULL match_status) item of `entity`, or None.
`service` is the column prefix (e.g. 'spotify' -> spotify_match_status) and
MUST be a trusted worker-supplied literal (it is interpolated into SQL).
`type_overrides` maps the canonical entity to the worker's dispatch 'type'
string Spotify/iTunes process individual items as 'album_individual' /
'track_individual', the other workers use 'album' / 'track'. The returned
dict matches the shape those workers already return from _get_next_item."""
if not str(service).isalpha() or entity not in PRIORITY_ENTITIES:
return None
type_overrides = type_overrides or {}
ms = f"{service}_match_status"
if entity == 'artist':
cursor.execute(
f"SELECT id, name FROM artists WHERE {ms} IS NULL AND id IS NOT NULL "
f"ORDER BY id ASC LIMIT 1"
)
r = cursor.fetchone()
return {'type': type_overrides.get('artist', 'artist'), 'id': r[0], 'name': r[1]} if r else None
if entity == 'album':
cursor.execute(
f"SELECT a.id, a.title, ar.name FROM albums a JOIN artists ar ON a.artist_id = ar.id "
f"WHERE a.{ms} IS NULL AND a.id IS NOT NULL ORDER BY a.id ASC LIMIT 1"
)
r = cursor.fetchone()
return {'type': type_overrides.get('album', 'album'), 'id': r[0], 'name': r[1], 'artist': r[2]} if r else None
# track
cursor.execute(
f"SELECT t.id, t.title, ar.name FROM tracks t JOIN artists ar ON t.artist_id = ar.id "
f"WHERE t.{ms} IS NULL AND t.id IS NOT NULL ORDER BY t.id ASC LIMIT 1"
)
r = cursor.fetchone()
return {'type': type_overrides.get('track', 'track'), 'id': r[0], 'name': r[1], 'artist': r[2]} if r else None

View file

@ -0,0 +1,138 @@
"""Priority 'process this group first' helper for enrichment workers.
The shared helper returns one pending item of a chosen entity type in the
shape the worker's dispatch already expects (with Spotify/iTunes mapped to
their album_individual / track_individual types). Default path (no override)
is exercised by the workers themselves and unchanged.
"""
from __future__ import annotations
import pytest
from core.worker_utils import (
PRIORITY_ENTITIES,
priority_pending_item,
read_enrichment_priority,
)
from database.music_database import MusicDatabase
@pytest.fixture
def db(tmp_path):
d = MusicDatabase(str(tmp_path / 'prio.db'))
conn = d._get_connection()
cur = conn.cursor()
cur.execute("INSERT INTO artists (id, name) VALUES ('a1', 'Pending Artist')") # NULL status
cur.execute("INSERT INTO artists (id, name, spotify_match_status) VALUES ('a2','Done','matched')")
cur.execute("INSERT INTO albums (id, artist_id, title) VALUES ('al1','a2','Pending Album')") # NULL status
cur.execute("INSERT INTO tracks (id, album_id, artist_id, title) VALUES ('t1','al1','a2','Pending Track')")
conn.commit()
conn.close()
return d
def _cur(db):
return db._get_connection().cursor()
def test_priority_artist_shape(db):
item = priority_pending_item(_cur(db), 'spotify', 'artist')
assert item == {'type': 'artist', 'id': 'a1', 'name': 'Pending Artist'}
def test_priority_album_default_type(db):
item = priority_pending_item(_cur(db), 'spotify', 'album')
assert item['id'] == 'al1' and item['name'] == 'Pending Album' and item['artist'] == 'Done'
assert item['type'] == 'album' # default type string
def test_priority_album_type_override_for_spotify_itunes(db):
item = priority_pending_item(_cur(db), 'spotify', 'album',
{'album': 'album_individual', 'track': 'track_individual'})
assert item['type'] == 'album_individual' # matches Spotify/iTunes dispatch
def test_priority_track_shape(db):
item = priority_pending_item(_cur(db), 'spotify', 'track')
assert item['id'] == 't1' and item['type'] == 'track' and item['artist'] == 'Done'
def test_priority_returns_none_when_group_exhausted(db):
# No pending artists once a1 is matched -> None, so worker resumes its chain.
conn = db._get_connection(); conn.execute("UPDATE artists SET spotify_match_status='matched' WHERE id='a1'"); conn.commit(); conn.close()
assert priority_pending_item(_cur(db), 'spotify', 'artist') is None
def test_priority_rejects_bad_entity_and_service(db):
assert priority_pending_item(_cur(db), 'spotify', 'bogus') is None
assert priority_pending_item(_cur(db), 'spot;drop', 'artist') is None # non-alpha service blocked
def test_read_priority_unset_is_empty():
# Unknown/unset key -> '' (no override). Uses the real config_manager.
assert read_enrichment_priority('definitely_not_a_service') == ''
def test_read_priority_roundtrip():
from config.settings import config_manager
key = 'spotify_enrichment_priority'
old = config_manager.get(key, '')
try:
config_manager.set(key, 'album')
assert read_enrichment_priority('spotify') == 'album'
config_manager.set(key, 'bogus')
assert read_enrichment_priority('spotify') == '' # invalid -> ignored
finally:
config_manager.set(key, old)
def test_priority_entities_constant():
assert PRIORITY_ENTITIES == ('artist', 'album', 'track')
# --- priority GET/POST routes ---------------------------------------------
@pytest.fixture
def client():
from flask import Flask
from core.enrichment import api as enrichment_api
store = {}
enrichment_api.configure(
config_get=lambda k, d=None: store.get(k, d),
config_set=lambda k, v: store.__setitem__(k, v),
db_getter=lambda: None,
)
app = Flask(__name__)
app.register_blueprint(enrichment_api.create_blueprint())
with app.test_client() as c:
c._store = store
yield c
enrichment_api.configure(config_get=None, config_set=None, db_getter=None)
def test_route_priority_get_default_empty(client):
r = client.get('/api/enrichment/spotify/priority')
assert r.status_code == 200
assert r.get_json()['priority'] == ''
def test_route_priority_set_and_get(client):
assert client.post('/api/enrichment/spotify/priority', json={'entity': 'album'}).status_code == 200
assert client._store['spotify_enrichment_priority'] == 'album'
assert client.get('/api/enrichment/spotify/priority').get_json()['priority'] == 'album'
def test_route_priority_clear(client):
client.post('/api/enrichment/spotify/priority', json={'entity': 'album'})
client.post('/api/enrichment/spotify/priority', json={'entity': 'none'})
assert client.get('/api/enrichment/spotify/priority').get_json()['priority'] == ''
def test_route_priority_rejects_unsupported_entity(client):
# Genius has no albums -> 400
assert client.post('/api/enrichment/genius/priority', json={'entity': 'album'}).status_code == 400
def test_route_priority_unknown_service_404(client):
assert client.get('/api/enrichment/bogus/priority').status_code == 404

View file

@ -34643,6 +34643,7 @@ _register_enrichment_services([
_configure_enrichment_api(
config_set=lambda key, value: config_manager.set(key, value),
config_get=lambda key, default=None: config_manager.get(key, default),
auto_paused_discard=lambda token: _download_auto_paused.discard(token),
yield_override_add=lambda token: _download_yield_override.add(token),
db_getter=get_database,

View file

@ -76,6 +76,7 @@ const enrichmentManagerState = {
selected: null,
statuses: {}, // id -> last /status payload
breakdown: null, // selected worker's breakdown
priority: '', // pinned 'process first' entity for selected worker ('' = auto)
entityTab: 'artist',
statusFilter: 'unmatched',
search: '',
@ -229,6 +230,7 @@ async function _emPollSelected() {
enrichmentManagerState.statuses[id] = await res.json();
_emUpdateHeaderLive(); // in-place — no logo reflow/flicker
_emUpdateRailRow(id);
_emRenderChain(); // current-phase highlight tracks live
}
} catch (_e) { /* transient — keep last */ }
}
@ -307,6 +309,7 @@ async function selectEnrichmentWorker(id) {
enrichmentManagerState.selected = id;
enrichmentManagerState.breakdown = null;
enrichmentManagerState.unmatched = null;
enrichmentManagerState.priority = '';
enrichmentManagerState.search = '';
enrichmentManagerState.page = 0;
enrichmentManagerState.statusFilter = 'unmatched';
@ -316,10 +319,29 @@ async function selectEnrichmentWorker(id) {
// unmatched call returns entity_types; default to artist meanwhile).
enrichmentManagerState.entityTab = 'artist';
renderEnrichmentPanel();
await Promise.all([_emLoadBreakdown(id), _emLoadUnmatched()]);
await Promise.all([_emLoadBreakdown(id), _emLoadUnmatched(), _emLoadPriority(id)]);
renderEnrichmentPanel();
}
async function _emLoadPriority(id) {
try {
const res = await fetch(`/api/enrichment/${id}/priority`);
enrichmentManagerState.priority = res.ok ? ((await res.json()).priority || '') : '';
} catch (_e) {
enrichmentManagerState.priority = '';
}
}
// Which phase the worker is on right now, from current_item.type.
function _emCurrentPhase(status) {
const t = status && status.current_item && status.current_item.type;
if (!t) return '';
if (t.indexOf('artist') === 0) return 'artist';
if (t.indexOf('album') === 0) return 'album';
if (t.indexOf('track') === 0) return 'track';
return '';
}
async function _emLoadBreakdown(id) {
try {
const res = await fetch(`/api/enrichment/${id}/breakdown`);
@ -367,7 +389,11 @@ function renderEnrichmentPanel() {
panel.innerHTML = `
<div class="em-panel-header" id="em-panel-header"></div>
<div class="em-section-label">Enrichment coverage</div>
<div class="em-chain" id="em-chain"></div>
<div class="em-section-label em-section-label--row">
<span>Enrichment coverage</span>
<span class="em-coverage-overall" id="em-coverage-overall"></span>
</div>
<div class="em-stats" id="em-stats"></div>
<div class="em-unmatched">
<div class="em-unmatched-controls" id="em-unmatched-controls"></div>
@ -375,11 +401,81 @@ function renderEnrichmentPanel() {
<div class="em-pager" id="em-pager"></div>
</div>`;
_emRenderPanelHeader();
_emRenderChain();
_emRenderStats();
_emRenderUnmatchedControls();
_emRenderUnmatchedList();
}
// Processing-order strip: shows the artist→album→track chain, where the worker
// currently is, and lets the user pin one group to run first.
function _emRenderChain() {
const host = document.getElementById('em-chain');
if (!host) return;
const id = enrichmentManagerState.selected;
const supported = (enrichmentManagerState.unmatched && enrichmentManagerState.unmatched.entity_types)
|| (enrichmentManagerState.breakdown && Object.keys(enrichmentManagerState.breakdown))
|| ['artist'];
const status = enrichmentManagerState.statuses[id];
const phase = _emCurrentPhase(status);
const pinned = enrichmentManagerState.priority;
const bd = enrichmentManagerState.breakdown || {};
const glyphs = { artist: '🎤', album: '💿', track: '🎵' };
const steps = supported.map((e, i) => {
const d = bd[e] || {};
const pending = (d.pending || 0) + (d.not_found || 0);
const isCurrent = phase === e;
const isPinned = pinned === e;
const isDone = bd[e] && pending === 0;
const cls = ['em-step',
isPinned ? 'em-step--pinned' : '',
isCurrent ? 'em-step--current' : '',
isDone ? 'em-step--done' : ''].filter(Boolean).join(' ');
const note = isPinned ? 'first' : (isCurrent ? 'now' : (isDone ? 'done' : `${pending.toLocaleString()} left`));
const arrow = i < supported.length - 1 ? '<span class="em-step-arrow">→</span>' : '';
return `
<button class="${cls}" title="Process ${_emEntityLabel(e, true).toLowerCase()} first"
onclick="setEnrichmentPriority('${isPinned ? '' : e}')">
<span class="em-step-glyph">${glyphs[e] || '•'}</span>
<span class="em-step-name">${_emEntityLabel(e, true)}</span>
<span class="em-step-note">${note}</span>
</button>${arrow}`;
}).join('');
host.innerHTML = `
<div class="em-chain-head">
<span class="em-section-label">Processing order</span>
<span class="em-chain-hint">${pinned
? `Pinned <strong>${_emEntityLabel(pinned, true)}</strong> first · click again for auto`
: 'Click a group to process it first'}</span>
</div>
<div class="em-chain-flow">${steps}</div>`;
}
async function setEnrichmentPriority(entity) {
const id = enrichmentManagerState.selected;
try {
const res = await fetch(`/api/enrichment/${id}/priority`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity: entity || 'none' }),
});
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
enrichmentManagerState.priority = data.priority || '';
showToast(enrichmentManagerState.priority
? `${_emWorkerById[id].name} will process ${_emEntityLabel(enrichmentManagerState.priority, true).toLowerCase()} first`
: `${_emWorkerById[id].name} back to automatic order`, 'success');
_emRenderChain();
} else {
showToast(data.error || 'Could not set processing order', 'error');
}
} catch (_e) {
showToast('Error setting processing order', 'error');
}
}
function _emRenderPanelHeader() {
const host = document.getElementById('em-panel-header');
if (!host) return;
@ -393,14 +489,15 @@ function _emRenderPanelHeader() {
<div class="em-hero-glow"></div>
${_emIconHtml(id, 'lg')}
<div class="em-ph-titles">
<div class="em-ph-name">${_emEscape(worker.name)} <span class="em-ph-name-sub">enrichment</span></div>
<div class="em-ph-nameline">
<span class="em-ph-name">${_emEscape(worker.name)} <span class="em-ph-name-sub">enrichment</span></span>
<span class="em-pill" id="em-ph-pill"></span>
</div>
<div class="em-ph-sub" id="em-ph-current"></div>
</div>
<div class="em-hero-metric" id="em-ph-metric"></div>
<div class="em-ph-actions">
<span class="em-pill" id="em-ph-pill"></span>
<span id="em-ph-budget"></span>
<span id="em-ph-errors"></span>
<span id="em-ph-budget"></span>
<button class="em-btn" id="em-ph-toggle" onclick="toggleEnrichmentWorker('${id}')"></button>
</div>
</div>`;
@ -415,14 +512,8 @@ function _emUpdateHeaderLive() {
const pill = document.getElementById('em-ph-pill');
if (pill) { pill.className = `em-pill em-pill--${info.cls}`; pill.textContent = info.label; }
const metric = document.getElementById('em-ph-metric');
if (metric) {
const pct = _emOverallPct(status);
metric.innerHTML = pct == null
? ''
: `<span class="em-hero-pct">${pct}<span class="em-hero-pct-sym">%</span></span>
<span class="em-hero-pct-label">enriched</span>`;
}
const hero = document.querySelector('#em-panel-header .em-hero');
if (hero) hero.classList.toggle('em-hero--live', info.cls === 'running');
const cur = document.getElementById('em-ph-current');
if (cur) {
@ -497,6 +588,18 @@ function _emRenderStats() {
</div>`;
}).join('');
// Overall matched coverage across every entity type (relocated here from
// the hero per the refined-banner header).
const overall = document.getElementById('em-coverage-overall');
if (overall) {
let m = 0, t = 0;
Object.values(bd).forEach(d => { m += d.matched || 0; t += d.total || 0; });
const pct = t ? Math.round((m / t) * 100) : 0;
overall.innerHTML = t
? `<strong>${pct}%</strong> matched · ${m.toLocaleString()} of ${t.toLocaleString()}`
: '';
}
// Animate the segments in from 0 on the next frame (CSS transition does the rest).
requestAnimationFrame(() => {
host.querySelectorAll('.em-seg-fill').forEach(el => {
@ -746,11 +849,11 @@ function openEnrichmentMatch(service, entityType, entityId, anchorBtn) {
const overlay = document.createElement('div');
overlay.id = 'enrichment-match-overlay';
overlay.className = 'modal-overlay';
overlay.className = 'modal-overlay em-overlay';
overlay.style.zIndex = '10010'; // above the manager modal
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = `
<div class="enhanced-manual-match-modal">
<div class="enhanced-manual-match-modal em-in">
<div class="enhanced-bulk-modal-header">
<h3>Match ${_emEntityLabel(entityType)} on ${_emEscape(_emWorkerById[service]?.name || service)}</h3>
<button class="enhanced-bulk-modal-close">&times;</button>
@ -855,6 +958,7 @@ window.setEnrichmentStatusFilter = setEnrichmentStatusFilter;
window.onEnrichmentSearchInput = onEnrichmentSearchInput;
window.changeEnrichmentPage = changeEnrichmentPage;
window.toggleEnrichmentWorker = toggleEnrichmentWorker;
window.setEnrichmentPriority = setEnrichmentPriority;
window.retryEnrichmentItem = retryEnrichmentItem;
window.retryAllFailedEnrichment = retryAllFailedEnrichment;
window.openEnrichmentMatch = openEnrichmentMatch;

View file

@ -65000,7 +65000,7 @@ body.em-scroll-lock { overflow: hidden; }
from { opacity: 0; transform: translateY(14px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.enrichment-manager-modal.em-in { animation: em-pop-in 0.28s cubic-bezier(0.16, 1, 0.3, 1) both; }
.em-in { animation: em-pop-in 0.28s cubic-bezier(0.16, 1, 0.3, 1) both; }
.em-overlay.em-closing .enrichment-manager-modal { transform: scale(0.98); opacity: 0; transition: all 0.16s ease; }
.enrichment-manager-modal:focus { outline: none; }
@ -65083,14 +65083,54 @@ body.em-scroll-lock { overflow: hidden; }
background: radial-gradient(circle, rgba(var(--em-accent-rgb, 99,102,241), 0.22), transparent 70%);
pointer-events: none;
}
/* Gentle 'alive' pulse while the worker is actively running. */
.em-hero--live .em-hero-glow { animation: em-glow-pulse 3.2s ease-in-out infinite; }
@keyframes em-glow-pulse { 0%,100% { opacity: 0.55; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .em-hero--live .em-hero-glow { animation: none; } }
.em-hero .em-icon { width: 56px; height: 56px; }
.em-hero .em-ph-titles { flex: 1 1 auto; min-width: 0; z-index: 1; }
.em-ph-nameline { display: flex; align-items: center; gap: 11px; flex-wrap: wrap; }
.em-ph-name-sub { font-size: 14px; font-weight: 500; color: rgba(255,255,255,0.45); }
.em-hero-metric { display: flex; flex-direction: column; align-items: center; justify-content: center; line-height: 1; z-index: 1; padding: 0 6px; }
.em-hero-pct { font-size: 30px; font-weight: 800; color: #fff; }
.em-hero-pct-sym { font-size: 16px; opacity: 0.6; }
.em-hero-pct-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.6px; color: rgba(255,255,255,0.45); margin-top: 3px; }
.em-hero .em-ph-actions { z-index: 1; }
/* Processing-order chain strip */
.em-chain { display: flex; flex-direction: column; gap: 9px; flex: 0 0 auto; }
.em-chain-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.em-chain-hint { font-size: 11.5px; color: rgba(255,255,255,0.4); }
.em-chain-hint strong { color: rgb(var(--em-accent-rgb, 129,140,248)); }
.em-chain-flow { display: flex; align-items: stretch; gap: 8px; flex-wrap: wrap; }
.em-step {
flex: 1 1 0; min-width: 120px;
display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
padding: 10px 13px; border-radius: 12px; cursor: pointer; text-align: left;
background: rgba(255,255,255,0.035); border: 1.5px solid rgba(255,255,255,0.08);
transition: all 0.2s ease; position: relative; overflow: hidden;
}
.em-step:hover { background: rgba(255,255,255,0.06); border-color: rgba(var(--em-accent-rgb, 99,102,241), 0.4); transform: translateY(-1px); }
.em-step-glyph { font-size: 16px; }
.em-step-name { font-size: 13px; font-weight: 700; color: #fff; }
.em-step-note { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.5px; color: rgba(255,255,255,0.4); }
.em-step-arrow { align-self: center; color: rgba(255,255,255,0.25); font-size: 16px; }
/* Pinned = will run first */
.em-step--pinned {
background: rgba(var(--em-accent-rgb, 99,102,241), 0.16);
border-color: rgba(var(--em-accent-rgb, 99,102,241), 0.6);
}
.em-step--pinned .em-step-note { color: rgb(var(--em-accent-rgb, 129,140,248)); font-weight: 800; }
.em-step--pinned::after { content: '📌'; position: absolute; top: 6px; right: 8px; font-size: 11px; }
/* Current = being processed now */
.em-step--current .em-step-name::after { content: ''; display: inline-block; width: 7px; height: 7px; margin-left: 6px; border-radius: 50%; background: #4ade80; box-shadow: 0 0 8px #4ade80; animation: em-glow-pulse 1.6s ease-in-out infinite; vertical-align: middle; }
.em-step--current { border-color: rgba(74,222,128,0.45); }
.em-step--current .em-step-note { color: #4ade80; }
/* Done = nothing left */
.em-step--done .em-step-glyph { opacity: 0.5; }
.em-step--done .em-step-note { color: rgba(74,222,128,0.7); }
@media (prefers-reduced-motion: reduce) { .em-step--current .em-step-name::after { animation: none; } }
/* Section label as a row (coverage % relocated here from the hero) */
.em-section-label--row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.em-coverage-overall { font-size: 12.5px; font-weight: 500; letter-spacing: 0; text-transform: none; color: rgba(255,255,255,0.55); }
.em-coverage-overall strong { color: rgb(var(--em-accent-rgb, 129,140,248)); font-weight: 800; }
/* Accent-themed buttons / active states inside the panel */
.em-panel .em-btn { border-color: rgba(var(--em-accent-rgb, 99,102,241), 0.45); background: rgba(var(--em-accent-rgb, 99,102,241), 0.16); }
.em-panel .em-btn:hover:not(:disabled) { background: rgba(var(--em-accent-rgb, 99,102,241), 0.3); }