diff --git a/core/amazon_worker.py b/core/amazon_worker.py index c4dbc0c7..ee4b9751 100644 --- a/core/amazon_worker.py +++ b/core/amazon_worker.py @@ -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 diff --git a/core/audiodb_worker.py b/core/audiodb_worker.py index ec687346..a8767cb8 100644 --- a/core/audiodb_worker.py +++ b/core/audiodb_worker.py @@ -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 diff --git a/core/deezer_worker.py b/core/deezer_worker.py index aba5c6fb..9b592627 100644 --- a/core/deezer_worker.py +++ b/core/deezer_worker.py @@ -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 diff --git a/core/discogs_worker.py b/core/discogs_worker.py index 8663934f..429ba320 100644 --- a/core/discogs_worker.py +++ b/core/discogs_worker.py @@ -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 diff --git a/core/enrichment/api.py b/core/enrichment/api.py index 746e0bd5..99c26110 100644 --- a/core/enrichment/api.py +++ b/core/enrichment/api.py @@ -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//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//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 diff --git a/core/genius_worker.py b/core/genius_worker.py index e2e731fd..1184e6db 100644 --- a/core/genius_worker.py +++ b/core/genius_worker.py @@ -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 diff --git a/core/itunes_worker.py b/core/itunes_worker.py index 50cf0df0..cca60db0 100644 --- a/core/itunes_worker.py +++ b/core/itunes_worker.py @@ -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 diff --git a/core/lastfm_worker.py b/core/lastfm_worker.py index 69a4c360..c2011e40 100644 --- a/core/lastfm_worker.py +++ b/core/lastfm_worker.py @@ -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 diff --git a/core/musicbrainz_worker.py b/core/musicbrainz_worker.py index bc494433..4f3adf55 100644 --- a/core/musicbrainz_worker.py +++ b/core/musicbrainz_worker.py @@ -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 diff --git a/core/qobuz_worker.py b/core/qobuz_worker.py index c6ba2be7..706d6295 100644 --- a/core/qobuz_worker.py +++ b/core/qobuz_worker.py @@ -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 diff --git a/core/spotify_worker.py b/core/spotify_worker.py index 9349bb74..4e0b3e66 100644 --- a/core/spotify_worker.py +++ b/core/spotify_worker.py @@ -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 diff --git a/core/tidal_worker.py b/core/tidal_worker.py index 4bcb434a..051f2843 100644 --- a/core/tidal_worker.py +++ b/core/tidal_worker.py @@ -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 diff --git a/core/worker_utils.py b/core/worker_utils.py index e572c702..a8d965b9 100644 --- a/core/worker_utils.py +++ b/core/worker_utils.py @@ -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 "_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 diff --git a/tests/enrichment/test_worker_priority.py b/tests/enrichment/test_worker_priority.py new file mode 100644 index 00000000..e17e5582 --- /dev/null +++ b/tests/enrichment/test_worker_priority.py @@ -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 diff --git a/web_server.py b/web_server.py index 6d0d007e..8d8caa8d 100644 --- a/web_server.py +++ b/web_server.py @@ -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, diff --git a/webui/static/enrichment-manager.js b/webui/static/enrichment-manager.js index 70b4cbac..563111f1 100644 --- a/webui/static/enrichment-manager.js +++ b/webui/static/enrichment-manager.js @@ -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 = `
- +
+
@@ -375,11 +401,81 @@ function renderEnrichmentPanel() {
`; _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 ? '' : ''; + return ` + ${arrow}`; + }).join(''); + + host.innerHTML = ` +
+ + ${pinned + ? `Pinned ${_emEntityLabel(pinned, true)} first · click again for auto` + : 'Click a group to process it first'} +
+
${steps}
`; +} + +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() {
${_emIconHtml(id, 'lg')}
-
${_emEscape(worker.name)} enrichment
+
+ ${_emEscape(worker.name)} enrichment + +
-
- - +
`; @@ -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 - ? '' - : `${pct}% - enriched`; - } + 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() { `; }).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 + ? `${pct}% 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 = ` -
+

Match ${_emEntityLabel(entityType)} on ${_emEscape(_emWorkerById[service]?.name || service)}

@@ -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; diff --git a/webui/static/style.css b/webui/static/style.css index c5616812..22b2de9a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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); }