Add Manage Enrichment Workers modal (v1 + polish)

Dashboard 'enrichment bubbles' could pause/hover but offered no way to
*manage* a worker. This adds a full management modal opened from a new
header button, covering all 11 enrichment sources.

Backend (testable core helper + seam tests; no live-DB dependency):
- core/enrichment/unmatched.py: pure, whitelisted SQL builders for the
  unmatched browser. service/entity validated against a support map (never
  interpolated raw); search + pagination bound as params; tracks join albums
  for artwork; limit capped at 200.
- database/music_database.py: get_enrichment_unmatched() +
  get_enrichment_breakdown() (the breakdown splits matched/not_found/pending,
  which the existing get_stats().progress lumps together).
- core/enrichment/api.py: GET /api/enrichment/<id>/{unmatched,breakdown} on
  the existing blueprint + a db_getter hook.
- web_server.py: wire db_getter=get_database.
- tests/enrichment/test_unmatched.py: 19 tests across builders, DB methods,
  and Flask routes.

Frontend (vanilla, matches app conventions):
- webui/static/enrichment-manager.js: worker rail with live status + coverage
  micro-bars, accent-themed detail panel (hero header, segmented matched/
  not_found/pending stat cards, current item, pause/resume), and a searchable
  paginated unmatched browser with inline manual match (reusing
  search-service + manual-match) and retry (clear-match re-queues).
- Polish: entrance/exit motion, scroll-lock, Escape, refresh control,
  flicker-free polling (in-place updates), skeleton loaders, relative
  timestamps, per-worker accent theming, real dashboard logos reused at
  runtime (with the same invert/circle treatment), responsive rail.
- index.html: header button + script include. style.css: full styling.

Reuses existing pause/resume, status, and manual search+assign endpoints.
Backend tests green (19 new + 11 existing enrichment tests).
pull/778/head
BoulderBadgeDad 3 weeks ago
parent 7956aaac9e
commit 0b3c3f656d

@ -18,9 +18,14 @@ from __future__ import annotations
from typing import Any, Callable, Optional
from flask import Blueprint, jsonify
from flask import Blueprint, jsonify, request
from core.enrichment.services import EnrichmentService, get_service
from core.enrichment.unmatched import (
SERVICE_ENTITY_SUPPORT,
UnmatchedQueryError,
supported_entity_types,
)
from utils.logging_config import get_logger
@ -32,6 +37,7 @@ logger = get_logger("enrichment.api")
_config_set: Optional[Callable[[str, Any], None]] = None
_auto_paused_discard: Optional[Callable[[str], None]] = None
_yield_override_add: Optional[Callable[[str], None]] = None
_db_getter: Optional[Callable[[], Any]] = None
def configure(
@ -39,16 +45,19 @@ def configure(
config_set: Optional[Callable[[str, Any], None]] = None,
auto_paused_discard: Optional[Callable[[str], None]] = None,
yield_override_add: Optional[Callable[[str], None]] = None,
db_getter: Optional[Callable[[], Any]] = None,
) -> None:
"""Wire host-side mutators that the generic routes call after pause/resume.
Each is optional pass None for hosts that don't have a corresponding
mechanism (e.g. tests).
mechanism (e.g. tests). ``db_getter`` returns the live ``MusicDatabase``
for the unmatched-browser routes.
"""
global _config_set, _auto_paused_discard, _yield_override_add
global _config_set, _auto_paused_discard, _yield_override_add, _db_getter
_config_set = config_set
_auto_paused_discard = auto_paused_discard
_yield_override_add = yield_override_add
_db_getter = db_getter
def _persist_paused(service: EnrichmentService, paused: bool) -> None:
@ -153,4 +162,65 @@ def create_blueprint() -> Blueprint:
logger.error("Error resuming %s worker: %s", service.id, e)
return jsonify({'error': str(e)}), 500
@bp.route('/api/enrichment/<service_id>/breakdown', methods=['GET'])
def enrichment_breakdown(service_id: str):
"""matched / not_found / pending tallies per entity type for the modal."""
if service_id not in SERVICE_ENTITY_SUPPORT:
return jsonify({'error': f'Unknown enrichment service: {service_id}'}), 404
if _db_getter is None:
return jsonify({'error': 'database unavailable'}), 503
try:
db = _db_getter()
breakdown = {
entity: db.get_enrichment_breakdown(service_id, entity)
for entity in supported_entity_types(service_id)
}
return jsonify({'service': service_id, 'breakdown': breakdown}), 200
except UnmatchedQueryError as e:
return jsonify({'error': str(e)}), 400
except Exception as e:
logger.error("Error building %s enrichment breakdown: %s", service_id, e)
return jsonify({'error': str(e)}), 500
@bp.route('/api/enrichment/<service_id>/unmatched', methods=['GET'])
def enrichment_unmatched(service_id: str):
"""Paginated list of items this source hasn't matched (for manual match).
Query params: ``entity_type`` (artist|album|track), ``status``
(not_found|pending|unmatched), ``q`` (name search), ``limit``, ``offset``.
"""
if service_id not in SERVICE_ENTITY_SUPPORT:
return jsonify({'error': f'Unknown enrichment service: {service_id}'}), 404
if _db_getter is None:
return jsonify({'error': 'database unavailable'}), 503
entity_type = (request.args.get('entity_type') or 'artist').strip()
status = (request.args.get('status') or 'not_found').strip()
query = (request.args.get('q') or '').strip() or None
try:
limit = int(request.args.get('limit', 50))
offset = int(request.args.get('offset', 0))
except (TypeError, ValueError):
return jsonify({'error': 'limit/offset must be integers'}), 400
try:
result = _db_getter().get_enrichment_unmatched(
service_id, entity_type, status, query, limit, offset
)
except UnmatchedQueryError as e:
return jsonify({'error': str(e)}), 400
except Exception as e:
logger.error("Error listing %s unmatched %ss: %s", service_id, entity_type, e)
return jsonify({'error': str(e)}), 500
result.update({
'service': service_id,
'entity_type': entity_type,
'status': status,
'limit': limit,
'offset': offset,
'entity_types': list(supported_entity_types(service_id)),
})
return jsonify(result), 200
return bp

@ -0,0 +1,214 @@
"""Read-side helpers for browsing the items an enrichment source hasn't matched.
The dashboard "Manage Enrichment Workers" modal lists, per source, the
artists / albums / tracks whose ``<service>_match_status`` is ``'not_found'``
(or still pending = ``NULL``) so the user can manually match them. Every
enrichment source writes a uniform ``<service>_match_status`` column, so one
parametric query serves all 11 workers.
This module owns the column mapping and SQL construction. ``service`` and
``entity_type`` are whitelisted against :data:`SERVICE_ENTITY_SUPPORT` and the
entity table map before any column name is interpolated user-supplied values
(the search term, pagination) are always bound parameters, never interpolated.
"""
from __future__ import annotations
from typing import List, Optional, Tuple
# Which entity types each enrichment source covers. Mirrors the authoritative
# ``_SERVICE_ID_COLUMNS`` map in web_server.py (used by manual-match), kept here
# so the unmatched browser is self-contained and unit-testable. Singular keys
# ('artist'/'album'/'track') match the manual-match entity_type vocabulary.
SERVICE_ENTITY_SUPPORT = {
'spotify': ('artist', 'album', 'track'),
'musicbrainz': ('artist', 'album', 'track'),
'deezer': ('artist', 'album', 'track'),
'audiodb': ('artist', 'album', 'track'),
'discogs': ('artist', 'album'), # no track-level id column
'itunes': ('artist', 'album', 'track'),
'lastfm': ('artist', 'album', 'track'),
'genius': ('artist', 'track'), # no album-level id column
'tidal': ('artist', 'album', 'track'),
'qobuz': ('artist', 'album', 'track'),
'amazon': ('artist', 'album', 'track'),
}
# entity_type -> table / display-name column / image expression / optional join.
# 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': '',
},
'album': {
'table': 'albums', 'name': 'title',
'image': 'albums.thumb_url', 'join': '',
},
'track': {
'table': 'tracks', 'name': 'title',
'image': 'al.thumb_url',
'join': 'LEFT JOIN albums al ON tracks.album_id = al.id',
},
}
# 'unmatched' = not yet matched at all (pending OR explicitly not_found).
VALID_STATUSES = ('not_found', 'pending', 'unmatched')
# Hard cap so a malicious/buggy caller can't ask for the whole library at once.
MAX_LIMIT = 200
class UnmatchedQueryError(ValueError):
"""Raised for an unknown service / unsupported entity type / bad status."""
def supported_entity_types(service: str) -> Tuple[str, ...]:
"""Return the entity types a source enriches, or () for an unknown source."""
return SERVICE_ENTITY_SUPPORT.get(service, ())
def match_status_column(service: str) -> str:
return f"{service}_match_status"
def last_attempted_column(service: str) -> str:
return f"{service}_last_attempted"
def _validate(service: str, entity_type: str) -> None:
support = SERVICE_ENTITY_SUPPORT.get(service)
if support is None:
raise UnmatchedQueryError(f"Unknown enrichment service: {service!r}")
if entity_type not in support:
raise UnmatchedQueryError(
f"{service} does not enrich {entity_type!r} entities"
)
if entity_type not in _ENTITY_TABLE: # defensive — support map drift
raise UnmatchedQueryError(f"No table mapping for entity type {entity_type!r}")
def _status_predicate(service: str, status: str, qualifier: str) -> str:
"""SQL predicate selecting rows in the requested match state.
``qualifier`` (the table name/alias) is always prefixed so the predicate is
unambiguous even when the query joins a second table that also carries a
``<service>_match_status`` column (tracks LEFT JOIN albums).
"""
col = f"{qualifier}.{match_status_column(service)}"
if status == 'not_found':
return f"{col} = 'not_found'"
if status == 'pending':
return f"{col} IS NULL"
# 'unmatched'
return f"({col} IS NULL OR {col} = 'not_found')"
def build_unmatched_query(
service: str,
entity_type: str,
status: str = 'not_found',
query: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> Tuple[str, List]:
"""Build the paginated SELECT for one (service, entity_type, status) view.
Returns ``(sql, params)``. Selected columns: id, name, image_url, status,
last_attempted.
"""
_validate(service, entity_type)
if status not in VALID_STATUSES:
raise UnmatchedQueryError(f"Invalid status: {status!r}")
meta = _ENTITY_TABLE[entity_type]
table, name_col, image_expr, join = (
meta['table'], meta['name'], meta['image'], meta['join'],
)
ms = match_status_column(service)
la = last_attempted_column(service)
where = [_status_predicate(service, status, table)]
params: List = []
if query:
where.append(f"{table}.{name_col} LIKE ?")
params.append(f"%{query}%")
sql = (
f"SELECT {table}.id AS id, {table}.{name_col} AS name, "
f"{image_expr} AS image_url, {table}.{ms} AS status, "
f"{table}.{la} AS last_attempted "
f"FROM {table} {join} "
f"WHERE {' AND '.join(where)} "
f"ORDER BY {table}.{name_col} COLLATE NOCASE "
f"LIMIT ? OFFSET ?"
).replace(' ', ' ')
params.append(_clamp_limit(limit))
params.append(max(int(offset or 0), 0))
return sql, params
def build_count_query(
service: str,
entity_type: str,
status: str = 'not_found',
query: Optional[str] = None,
) -> Tuple[str, List]:
"""Build the COUNT(*) matching :func:`build_unmatched_query`'s filters."""
_validate(service, entity_type)
if status not in VALID_STATUSES:
raise UnmatchedQueryError(f"Invalid status: {status!r}")
meta = _ENTITY_TABLE[entity_type]
table, name_col = meta['table'], meta['name']
where = [_status_predicate(service, status, table)]
params: List = []
if query:
where.append(f"{table}.{name_col} LIKE ?")
params.append(f"%{query}%")
sql = f"SELECT COUNT(*) FROM {table} WHERE {' AND '.join(where)}"
return sql, params
def build_breakdown_query(service: str, entity_type: str) -> Tuple[str, List]:
"""Build the matched / not_found / pending / total tally for one entity type."""
_validate(service, entity_type)
meta = _ENTITY_TABLE[entity_type]
table = meta['table']
ms = f"{table}.{match_status_column(service)}"
sql = (
"SELECT "
f"SUM(CASE WHEN {ms} = 'matched' THEN 1 ELSE 0 END) AS matched, "
f"SUM(CASE WHEN {ms} = 'not_found' THEN 1 ELSE 0 END) AS not_found, "
f"SUM(CASE WHEN {ms} IS NULL THEN 1 ELSE 0 END) AS pending, "
f"COUNT(*) AS total "
f"FROM {table}"
)
return sql, []
def _clamp_limit(limit) -> int:
try:
n = int(limit)
except (TypeError, ValueError):
return 50
if n <= 0:
return 50
return min(n, MAX_LIMIT)
__all__ = [
'SERVICE_ENTITY_SUPPORT',
'VALID_STATUSES',
'MAX_LIMIT',
'UnmatchedQueryError',
'supported_entity_types',
'match_status_column',
'last_attempted_column',
'build_unmatched_query',
'build_count_query',
'build_breakdown_query',
]

@ -1021,6 +1021,64 @@ class MusicDatabase:
finally:
conn.close()
def get_enrichment_unmatched(
self,
service: str,
entity_type: str,
status: str = 'not_found',
query: str = None,
limit: int = 50,
offset: int = 0,
) -> dict:
"""List items a given enrichment source hasn't matched, paginated.
Powers the "Manage Enrichment Workers" modal's unmatched browser.
Returns ``{'total': int, 'items': [{id, name, image_url, status,
last_attempted}]}``. Raises ``UnmatchedQueryError`` for an unknown
service / unsupported entity type / bad status (the caller maps that to
an HTTP 400)."""
from core.enrichment.unmatched import (
build_count_query,
build_unmatched_query,
)
sql, params = build_unmatched_query(
service, entity_type, status, query, limit, offset
)
count_sql, count_params = build_count_query(service, entity_type, status, query)
conn = self._get_connection()
try:
cursor = conn.cursor()
total = cursor.execute(count_sql, count_params).fetchone()[0]
rows = cursor.execute(sql, params).fetchall()
items = [dict(row) for row in rows]
return {'total': total or 0, 'items': items}
finally:
conn.close()
def get_enrichment_breakdown(self, service: str, entity_type: str) -> dict:
"""Return ``{matched, not_found, pending, total}`` for a source/entity.
The per-worker ``get_stats().progress`` lumps matched + not_found into a
single 'processed' count; this splits them so the modal can show the
real match rate. Raises ``UnmatchedQueryError`` on bad input."""
from core.enrichment.unmatched import build_breakdown_query
sql, params = build_breakdown_query(service, entity_type)
conn = self._get_connection()
try:
row = conn.cursor().execute(sql, params).fetchone()
if not row:
return {'matched': 0, 'not_found': 0, 'pending': 0, 'total': 0}
return {
'matched': row[0] or 0,
'not_found': row[1] or 0,
'pending': row[2] or 0,
'total': row[3] or 0,
}
finally:
conn.close()
def _add_mirrored_playlist_explored_column(self, cursor):
"""Add explored_at column to mirrored_playlists to persist explore badge."""
try:

@ -0,0 +1,194 @@
"""Unmatched-browser backend for the Manage Enrichment Workers modal.
Three seams:
* pure SQL builders + validation (core.enrichment.unmatched)
* the MusicDatabase read methods against a temp DB
* the Flask routes via a test client
"""
from __future__ import annotations
import pytest
from flask import Flask
from core.enrichment import api as enrichment_api
from core.enrichment.unmatched import (
MAX_LIMIT,
UnmatchedQueryError,
build_breakdown_query,
build_count_query,
build_unmatched_query,
supported_entity_types,
)
from database.music_database import MusicDatabase
# --------------------------------------------------------------------------
# Pure builders / validation
# --------------------------------------------------------------------------
def test_unknown_service_rejected():
with pytest.raises(UnmatchedQueryError):
build_unmatched_query('not-a-service', 'artist')
def test_unsupported_entity_type_rejected():
# Genius enriches artists + tracks but has no album-level id column.
assert 'album' not in supported_entity_types('genius')
with pytest.raises(UnmatchedQueryError):
build_unmatched_query('genius', 'album')
with pytest.raises(UnmatchedQueryError):
build_breakdown_query('discogs', 'track') # discogs has no track column
def test_bad_status_rejected():
with pytest.raises(UnmatchedQueryError):
build_unmatched_query('spotify', 'artist', status='bogus')
def test_status_predicates():
nf, _ = build_count_query('spotify', 'artist', 'not_found')
pend, _ = build_count_query('spotify', 'artist', 'pending')
un, _ = build_count_query('spotify', 'artist', 'unmatched')
assert "artists.spotify_match_status = 'not_found'" in nf
assert "artists.spotify_match_status IS NULL" in pend
assert "IS NULL OR" in un and "= 'not_found'" in un
def test_track_query_qualifies_status_to_avoid_join_ambiguity():
# tracks LEFT JOIN albums for artwork — both carry spotify_match_status,
# so the predicate must be qualified or SQLite errors "ambiguous column".
sql, _ = build_unmatched_query('spotify', 'track', 'not_found')
assert 'LEFT JOIN albums al' in sql
assert 'tracks.spotify_match_status' in sql
assert 'al.thumb_url AS image_url' in sql
def test_search_adds_like_param():
sql, params = build_unmatched_query('spotify', 'artist', 'not_found', query='dragons')
assert 'LIKE ?' in sql
assert '%dragons%' in params
def test_limit_is_clamped():
_, params = build_unmatched_query('spotify', 'artist', 'not_found', limit=99999)
assert params[-2] == MAX_LIMIT # limit
assert params[-1] == 0 # offset
_, params2 = build_unmatched_query('spotify', 'artist', 'not_found', limit=0)
assert params2[-2] == 50 # invalid -> default
# --------------------------------------------------------------------------
# MusicDatabase integration (temp DB)
# --------------------------------------------------------------------------
def _seed(db: MusicDatabase):
conn = db._get_connection()
cur = conn.cursor()
# 3 artists: matched / not_found / pending(NULL)
cur.execute("INSERT INTO artists (id, name, spotify_match_status) VALUES ('a1','Matched Artist','matched')")
cur.execute("INSERT INTO artists (id, name, spotify_match_status) VALUES ('a2','Failed Dragons','not_found')")
cur.execute("INSERT INTO artists (id, name) VALUES ('a3','Pending Person')") # NULL status
# album + track to exercise the join-for-artwork path
cur.execute("INSERT INTO albums (id, artist_id, title, thumb_url, spotify_match_status) "
"VALUES ('al1','a2','Evolve','http://img/evolve.jpg','not_found')")
cur.execute("INSERT INTO tracks (id, album_id, artist_id, title, spotify_match_status) "
"VALUES ('t1','al1','a2','Believer','not_found')")
conn.commit()
conn.close()
@pytest.fixture
def db(tmp_path):
d = MusicDatabase(str(tmp_path / 'enrich.db'))
_seed(d)
return d
def test_breakdown_splits_matched_notfound_pending(db):
bd = db.get_enrichment_breakdown('spotify', 'artist')
assert bd == {'matched': 1, 'not_found': 1, 'pending': 1, 'total': 3}
def test_unmatched_not_found_only(db):
res = db.get_enrichment_unmatched('spotify', 'artist', status='not_found')
assert res['total'] == 1
assert [i['name'] for i in res['items']] == ['Failed Dragons']
assert res['items'][0]['status'] == 'not_found'
def test_unmatched_pending_only(db):
res = db.get_enrichment_unmatched('spotify', 'artist', status='pending')
assert res['total'] == 1
assert res['items'][0]['name'] == 'Pending Person'
def test_unmatched_combined(db):
res = db.get_enrichment_unmatched('spotify', 'artist', status='unmatched')
assert res['total'] == 2
assert {i['name'] for i in res['items']} == {'Failed Dragons', 'Pending Person'}
def test_unmatched_search_filters_by_name(db):
res = db.get_enrichment_unmatched('spotify', 'artist', status='unmatched', query='dragons')
assert res['total'] == 1
assert res['items'][0]['name'] == 'Failed Dragons'
def test_unmatched_pagination(db):
page = db.get_enrichment_unmatched('spotify', 'artist', status='unmatched', limit=1, offset=0)
assert page['total'] == 2 and len(page['items']) == 1
page2 = db.get_enrichment_unmatched('spotify', 'artist', status='unmatched', limit=1, offset=1)
assert page2['items'][0]['name'] != page['items'][0]['name']
def test_track_unmatched_borrows_album_artwork(db):
res = db.get_enrichment_unmatched('spotify', 'track', status='not_found')
assert res['total'] == 1
assert res['items'][0]['name'] == 'Believer'
assert res['items'][0]['image_url'] == 'http://img/evolve.jpg'
def test_db_raises_on_bad_input(db):
with pytest.raises(UnmatchedQueryError):
db.get_enrichment_unmatched('spotify', 'artist', status='bogus')
# --------------------------------------------------------------------------
# Flask routes
# --------------------------------------------------------------------------
@pytest.fixture
def client(db):
enrichment_api.configure(db_getter=lambda: db)
app = Flask(__name__)
app.register_blueprint(enrichment_api.create_blueprint())
with app.test_client() as c:
yield c
enrichment_api.configure(db_getter=None) # reset module global
def test_route_unknown_service_404(client):
assert client.get('/api/enrichment/bogus/unmatched').status_code == 404
def test_route_bad_entity_type_400(client):
# genius has no album column -> 400, not a 500
r = client.get('/api/enrichment/genius/unmatched?entity_type=album')
assert r.status_code == 400
def test_route_happy_path(client):
r = client.get('/api/enrichment/spotify/unmatched?entity_type=artist&status=unmatched')
assert r.status_code == 200
body = r.get_json()
assert body['total'] == 2
assert body['service'] == 'spotify'
assert body['entity_types'] == ['artist', 'album', 'track']
def test_route_breakdown(client):
r = client.get('/api/enrichment/spotify/breakdown')
assert r.status_code == 200
bd = r.get_json()['breakdown']
assert bd['artist'] == {'matched': 1, 'not_found': 1, 'pending': 1, 'total': 3}

@ -34645,6 +34645,7 @@ _configure_enrichment_api(
config_set=lambda key, value: config_manager.set(key, value),
auto_paused_discard=lambda token: _download_auto_paused.discard(token),
yield_override_add=lambda token: _download_yield_override.add(token),
db_getter=get_database,
)
app.register_blueprint(_create_enrichment_blueprint())

@ -625,6 +625,13 @@
</div>
</div>
</div>
<!-- Manage Enrichment Workers — opens the full management modal -->
<button class="em-manage-btn" id="manage-enrichment-btn"
title="Manage enrichment workers — stats, unmatched items, manual matching"
onclick="openEnrichmentManager()">
<span class="em-manage-btn-icon">🧬</span>
<span class="em-manage-btn-label">Manage Workers</span>
</button>
</div>
<!-- Watchlist / Wishlist quick-nav (top-right corner) -->
<div class="header-quick-nav">
@ -8032,6 +8039,7 @@
<script src="{{ url_for('static', filename='discover-section-controller.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='discover.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='enrichment.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='enrichment-manager.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='stats-automations.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='auto-sync.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='pages-extra.js', v=static_v) }}"></script>

@ -0,0 +1,816 @@
/*
* Manage Enrichment Workers modal.
*
* The dashboard "enrichment bubbles" expose hover/pause but no way to *manage*
* a worker. This modal surfaces, per worker: live status + current item,
* pause/resume, a matched/not-found/pending breakdown per entity type, and a
* searchable/paginated browser of the items that source hasn't matched each
* with inline manual-match (reusing /api/library/search-service +
* manual-match) and retry (clear-match, which re-queues the item).
*
* Backend: GET /api/enrichment/<id>/{status,breakdown,unmatched}, POST
* .../{pause,resume}. The unmatched/breakdown routes are generic across all 11
* workers (see core/enrichment/unmatched.py).
*/
// Per-source accent + the CSS selector of that worker's logo already rendered
// in the dashboard bubble. We reuse those exact <img> sources at runtime
// (via _emLogoSrc) so the modal shows the real logos — including AudioDB's
// inline base64 — and stays in sync if the dashboard logos ever change.
// imgFilter / imgRound mirror the per-logo CSS the dashboard bubbles apply, so
// black-on-dark icons (Discogs/Tidal/Qobuz/Amazon) get inverted to white and
// square logos (Last.fm) clip to a circle here too.
const ENRICHMENT_WORKERS = [
{ id: 'spotify', name: 'Spotify', color: '#1db954', logoSel: '.spotify-enrich-logo' },
{ id: 'itunes', name: 'iTunes', color: '#fb5bc5', logoSel: '.itunes-enrich-logo' },
{ id: 'musicbrainz', name: 'MusicBrainz', color: '#ba55d3', logoSel: '.mb-logo' },
{ id: 'deezer', name: 'Deezer', color: '#a238ff', logoSel: '.deezer-logo' },
{ id: 'audiodb', name: 'AudioDB', color: '#1c8cf0', logoSel: '.audiodb-logo' },
{ id: 'discogs', name: 'Discogs', color: '#cfcfcf', logoSel: '.discogs-logo', imgFilter: 'brightness(0) invert(1)' },
{ id: 'lastfm', name: 'Last.fm', color: '#d51007', logoSel: '.lastfm-enrich-logo', imgRound: true },
{ id: 'genius', name: 'Genius', color: '#ffe600', logoSel: '.genius-enrich-logo' },
{ id: 'tidal', name: 'Tidal', color: '#00cfe6', logoSel: '.tidal-enrich-logo', imgFilter: 'invert(1) brightness(1.8)', imgRound: true },
{ id: 'qobuz', name: 'Qobuz', color: '#0070ef', logoSel: '.qobuz-enrich-logo', imgFilter: 'invert(1)', imgRound: true },
{ id: 'amazon', name: 'Amazon Music', color: '#ff9900', logoSel: '.amazon-enrich-logo', imgFilter: 'brightness(0) invert(1)' },
];
const _emWorkerById = Object.fromEntries(ENRICHMENT_WORKERS.map(w => [w.id, w]));
// '#1db954' -> '29,185,84' for rgba(var(--em-accent-rgb), a) usage.
function _emHexToRgb(hex) {
const h = String(hex || '').replace('#', '');
const full = h.length === 3 ? h.split('').map(c => c + c).join('') : h;
const n = parseInt(full, 16);
if (isNaN(n) || full.length !== 6) return '120,120,120';
return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255}`;
}
// Resolve a worker's logo URL from the live dashboard bubble (null if absent).
function _emLogoSrc(workerId) {
const w = _emWorkerById[workerId];
if (!w || !w.logoSel) return null;
const img = document.querySelector(w.logoSel);
return img && img.src ? img.src : null;
}
// A circular, glowing icon chip mirroring the dashboard bubbles. Falls back to
// a colored initial if the logo is missing or fails to load.
function _emIconHtml(workerId, size) {
const w = _emWorkerById[workerId];
const src = _emLogoSrc(workerId);
const cls = `em-icon${size === 'lg' ? ' em-icon--lg' : ''}`;
const initial = w.name.charAt(0).toUpperCase();
const imgStyle = [
w.imgFilter ? `filter:${w.imgFilter}` : '',
w.imgRound ? 'border-radius:50%' : '',
].filter(Boolean).join(';');
const inner = src
? `<img src="${_emEscape(src)}" alt="" class="em-icon-img"${imgStyle ? ` style="${imgStyle}"` : ''}
onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'em-icon-letter',textContent:'${initial}'}))">`
: `<span class="em-icon-letter">${initial}</span>`;
return `<span class="${cls}" style="--em-accent:${w.color}">${inner}</span>`;
}
const enrichmentManagerState = {
open: false,
selected: null,
statuses: {}, // id -> last /status payload
breakdown: null, // selected worker's breakdown
entityTab: 'artist',
statusFilter: 'unmatched',
search: '',
page: 0,
pageSize: 25,
unmatched: null, // { total, items }
pollTimer: null,
loadToken: 0, // guards against out-of-order async renders
};
function _emEntityLabel(entity, plural) {
const map = { artist: 'Artist', album: 'Album', track: 'Track' };
const base = map[entity] || entity;
return plural ? base + 's' : base;
}
function _emEscape(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
// Human "3 days ago" for a SQLite timestamp; '' when never attempted.
function _emRelativeTime(value) {
if (!value) return '';
// SQLite stores 'YYYY-MM-DD HH:MM:SS' (UTC) — normalize to ISO.
const ts = Date.parse(String(value).replace(' ', 'T') + (String(value).includes('Z') ? '' : 'Z'));
if (isNaN(ts)) return '';
const secs = Math.max(0, (Date.now() - ts) / 1000);
if (secs < 60) return 'just now';
const mins = secs / 60;
if (mins < 60) return `${Math.floor(mins)}m ago`;
const hrs = mins / 60;
if (hrs < 24) return `${Math.floor(hrs)}h ago`;
const days = hrs / 24;
if (days < 30) return `${Math.floor(days)}d ago`;
const months = days / 30;
if (months < 12) return `${Math.floor(months)}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
// ── Open / close ──────────────────────────────────────────────────────────
async function openEnrichmentManager() {
let overlay = document.getElementById('enrichment-manager-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'enrichment-manager-overlay';
overlay.className = 'modal-overlay em-overlay hidden';
overlay.onclick = (e) => { if (e.target === overlay) closeEnrichmentManager(); };
overlay.innerHTML = `
<div class="enrichment-manager-modal" role="dialog" aria-modal="true"
aria-label="Manage Enrichment Workers" tabindex="-1">
<div class="enhanced-bulk-modal-header">
<h3>🧬 Manage Enrichment Workers</h3>
<div class="em-header-actions">
<button class="em-icon-btn" id="em-refresh-btn" title="Refresh"
onclick="refreshEnrichmentManager(this)"></button>
<button class="enhanced-bulk-modal-close" onclick="closeEnrichmentManager()">&times;</button>
</div>
</div>
<div class="em-body">
<div class="em-rail" id="em-rail"></div>
<div class="em-panel" id="em-panel"></div>
</div>
</div>`;
document.body.appendChild(overlay);
}
overlay.classList.remove('hidden', 'em-closing');
// Re-trigger the entrance animation even when reusing the element.
const modal = overlay.querySelector('.enrichment-manager-modal');
if (modal) { modal.classList.remove('em-in'); void modal.offsetWidth; modal.classList.add('em-in'); }
document.body.classList.add('em-scroll-lock');
document.addEventListener('keydown', _emOnKeydown);
enrichmentManagerState.open = true;
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);
if (modal) setTimeout(() => modal.focus(), 60);
if (enrichmentManagerState.pollTimer) clearInterval(enrichmentManagerState.pollTimer);
enrichmentManagerState.pollTimer = setInterval(_emPollSelected, 3000);
}
function closeEnrichmentManager() {
const overlay = document.getElementById('enrichment-manager-overlay');
enrichmentManagerState.open = false;
document.removeEventListener('keydown', _emOnKeydown);
document.body.classList.remove('em-scroll-lock');
if (enrichmentManagerState.pollTimer) {
clearInterval(enrichmentManagerState.pollTimer);
enrichmentManagerState.pollTimer = null;
}
if (!overlay) return;
// Brief fade/scale-out, then hide.
overlay.classList.add('em-closing');
setTimeout(() => {
overlay.classList.add('hidden');
overlay.classList.remove('em-closing');
}, 170);
}
// Escape closes the nested match overlay first (if open), else the manager.
function _emOnKeydown(e) {
if (e.key !== 'Escape') return;
const match = document.getElementById('enrichment-match-overlay');
if (match) { match.remove(); return; }
closeEnrichmentManager();
}
// Manual refresh: re-pull every worker's status + the selected worker's data.
async function refreshEnrichmentManager(btn) {
if (btn) btn.classList.add('em-spinning');
await refreshAllEnrichmentStatuses();
renderEnrichmentRail();
const sel = enrichmentManagerState.selected;
if (sel) await Promise.all([_emLoadBreakdown(sel), _emLoadUnmatched()]);
_emRenderStats();
_emRenderUnmatchedList();
_emRenderPanelHeader();
if (btn) setTimeout(() => btn.classList.remove('em-spinning'), 400);
}
// ── Status loading ──────────────────────────────────────────────────────────
async function refreshAllEnrichmentStatuses() {
const results = await Promise.all(ENRICHMENT_WORKERS.map(async (w) => {
try {
const res = await fetch(`/api/enrichment/${w.id}/status`);
return [w.id, res.ok ? await res.json() : null];
} catch (_e) {
return [w.id, null];
}
}));
for (const [id, status] of results) enrichmentManagerState.statuses[id] = status;
}
async function _emPollSelected() {
const id = enrichmentManagerState.selected;
if (!id || !enrichmentManagerState.open) return;
try {
const res = await fetch(`/api/enrichment/${id}/status`);
if (res.ok) {
enrichmentManagerState.statuses[id] = await res.json();
_emUpdateHeaderLive(); // in-place — no logo reflow/flicker
_emUpdateRailRow(id);
}
} catch (_e) { /* transient — keep last */ }
}
function _emStatusInfo(status) {
if (!status || !status.enabled) return { cls: 'disabled', label: 'Disabled' };
if (status.rate_limited) return { cls: 'ratelimited', label: 'Rate-limited' };
if (status.paused) return { cls: 'paused', label: 'Paused' };
if (status.idle) return { cls: 'idle', label: 'Idle' };
if (status.running) return { cls: 'running', label: 'Running' };
return { cls: 'stopped', label: 'Stopped' };
}
// ── Left rail ───────────────────────────────────────────────────────────────
// Overall library coverage (% of items this source has attempted) from the
// status payload's progress block — a cheap at-a-glance rail signal.
function _emOverallPct(status) {
const p = status && status.progress;
if (!p) return null;
let matched = 0, total = 0;
for (const k of ['artists', 'albums', 'tracks']) {
if (p[k]) { matched += p[k].matched || 0; total += p[k].total || 0; }
}
return total ? Math.round((matched / total) * 100) : 0;
}
function renderEnrichmentRail() {
const rail = document.getElementById('em-rail');
if (!rail) return;
rail.innerHTML = ENRICHMENT_WORKERS.map(w => {
const status = enrichmentManagerState.statuses[w.id];
const info = _emStatusInfo(status);
const pct = _emOverallPct(status);
const cov = pct == null ? '' : `
<span class="em-rail-cov"><span class="em-rail-cov-fill" style="width:${pct}%"></span></span>`;
return `
<button class="em-worker-row" id="em-row-${w.id}"
onclick="selectEnrichmentWorker('${w.id}')">
${_emIconHtml(w.id)}
<span class="em-worker-meta">
<span class="em-worker-name">${_emEscape(w.name)}</span>
<span class="em-worker-sub">${info.label}${pct == null ? '' : ` · ${pct}%`}</span>
${cov}
</span>
<span class="em-dot em-dot--${info.cls}" title="${info.label}"></span>
</button>`;
}).join('');
_emHighlightRail();
}
function _emHighlightRail() {
ENRICHMENT_WORKERS.forEach(w => {
const row = document.getElementById(`em-row-${w.id}`);
if (row) row.classList.toggle('active', w.id === enrichmentManagerState.selected);
});
}
function _emUpdateRailRow(id) {
const row = document.getElementById(`em-row-${id}`);
if (!row) return;
const status = enrichmentManagerState.statuses[id];
const info = _emStatusInfo(status);
const pct = _emOverallPct(status);
const dot = row.querySelector('.em-dot');
if (dot) { dot.className = `em-dot em-dot--${info.cls}`; dot.title = info.label; }
const sub = row.querySelector('.em-worker-sub');
if (sub) sub.textContent = `${info.label}${pct == null ? '' : ` · ${pct}%`}`;
const cov = row.querySelector('.em-rail-cov-fill');
if (cov && pct != null) cov.style.width = `${pct}%`;
}
// ── Worker selection ──────────────────────────────────────────────────────────
async function selectEnrichmentWorker(id) {
enrichmentManagerState.selected = id;
enrichmentManagerState.breakdown = null;
enrichmentManagerState.unmatched = null;
enrichmentManagerState.search = '';
enrichmentManagerState.page = 0;
enrichmentManagerState.statusFilter = 'unmatched';
_emHighlightRail();
// Pick a default entity tab the worker actually supports (filled after the
// unmatched call returns entity_types; default to artist meanwhile).
enrichmentManagerState.entityTab = 'artist';
renderEnrichmentPanel();
await Promise.all([_emLoadBreakdown(id), _emLoadUnmatched()]);
renderEnrichmentPanel();
}
async function _emLoadBreakdown(id) {
try {
const res = await fetch(`/api/enrichment/${id}/breakdown`);
enrichmentManagerState.breakdown = res.ok ? (await res.json()).breakdown : null;
} catch (_e) {
enrichmentManagerState.breakdown = null;
}
}
async function _emLoadUnmatched() {
const id = enrichmentManagerState.selected;
const token = ++enrichmentManagerState.loadToken;
const { entityTab, statusFilter, search, page, pageSize } = enrichmentManagerState;
const params = new URLSearchParams({
entity_type: entityTab,
status: statusFilter,
limit: String(pageSize),
offset: String(page * pageSize),
});
if (search) params.set('q', search);
try {
const res = await fetch(`/api/enrichment/${id}/unmatched?${params}`);
const data = res.ok ? await res.json() : { total: 0, items: [] };
if (token !== enrichmentManagerState.loadToken) return; // stale
enrichmentManagerState.unmatched = data;
} catch (_e) {
if (token === enrichmentManagerState.loadToken) {
enrichmentManagerState.unmatched = { total: 0, items: [] };
}
}
}
// ── Detail panel ──────────────────────────────────────────────────────────────
function renderEnrichmentPanel() {
const panel = document.getElementById('em-panel');
if (!panel) return;
const id = enrichmentManagerState.selected;
const worker = _emWorkerById[id];
if (!worker) { panel.innerHTML = ''; return; }
// Theme the whole panel to the selected worker's accent colour.
panel.style.setProperty('--em-accent', worker.color);
panel.style.setProperty('--em-accent-rgb', _emHexToRgb(worker.color));
panel.innerHTML = `
<div class="em-panel-header" id="em-panel-header"></div>
<div class="em-section-label">Enrichment coverage</div>
<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-pager" id="em-pager"></div>
</div>`;
_emRenderPanelHeader();
_emRenderStats();
_emRenderUnmatchedControls();
_emRenderUnmatchedList();
}
function _emRenderPanelHeader() {
const host = document.getElementById('em-panel-header');
if (!host) return;
const id = enrichmentManagerState.selected;
const worker = _emWorkerById[id];
// Structure is rendered once per worker selection; the live bits below
// (pill / current-item / errors / toggle) are updated in place by
// _emUpdateHeaderLive on each poll so the logo never reflows or flickers.
host.innerHTML = `
<div class="em-hero">
<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-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>
<button class="em-btn" id="em-ph-toggle" onclick="toggleEnrichmentWorker('${id}')"></button>
</div>
</div>`;
_emUpdateHeaderLive();
}
function _emUpdateHeaderLive() {
const id = enrichmentManagerState.selected;
const status = enrichmentManagerState.statuses[id];
const info = _emStatusInfo(status);
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 cur = document.getElementById('em-ph-current');
if (cur) {
const item = status && status.current_item;
cur.innerHTML = item
? `Now enriching: <strong>${_emEscape(item.name || '')}</strong>${item.type ? ` <span class="em-muted">(${_emEscape(item.type)})</span>` : ''}`
: '<span class="em-muted">No item processing</span>';
}
const budgetEl = document.getElementById('em-ph-budget');
if (budgetEl) {
const b = status && status.daily_budget;
budgetEl.innerHTML = (b && b.limit)
? `<span class="em-chip" title="Daily API budget">Budget ${b.used ?? '?'} / ${b.limit}</span>` : '';
}
const errEl = document.getElementById('em-ph-errors');
if (errEl) {
const errors = (status && status.stats && status.stats.errors) || 0;
errEl.innerHTML = errors ? `<span class="em-chip em-chip--err" title="Errors this run">⚠ ${errors}</span>` : '';
}
const toggle = document.getElementById('em-ph-toggle');
if (toggle) {
const isPaused = status && status.paused;
toggle.disabled = !(status && status.enabled);
toggle.classList.toggle('em-btn--go', !!isPaused);
toggle.textContent = isPaused ? '▶ Resume' : '⏸ Pause';
}
}
function _emRenderStats() {
const host = document.getElementById('em-stats');
if (!host) return;
const bd = enrichmentManagerState.breakdown;
if (!bd) {
// Skeleton cards (count unknown yet — 3 covers the common case).
host.innerHTML = Array.from({ length: 3 }, () => `
<div class="em-stat-card em-skel-card">
<div class="em-skel em-skel-line" style="width:40%"></div>
<div class="em-skel em-skel-bar"></div>
<div class="em-skel em-skel-line" style="width:75%"></div>
</div>`).join('');
return;
}
const glyphs = { artist: '🎤', album: '💿', track: '🎵' };
host.innerHTML = Object.keys(bd).map(entity => {
const d = bd[entity] || {};
const total = d.total || 0;
const matched = d.matched || 0;
const notFound = d.not_found || 0;
const pending = d.pending || 0;
const pct = total ? Math.round((matched / total) * 100) : 0;
const seg = (n) => (total ? (n / total) * 100 : 0);
return `
<div class="em-stat-card">
<div class="em-stat-head">
<span class="em-stat-title"><span class="em-stat-ico">${glyphs[entity] || '•'}</span>${_emEntityLabel(entity, true)}</span>
<span class="em-stat-pct">${pct}<span class="em-stat-pct-sym">%</span></span>
</div>
<div class="em-seg" title="${matched.toLocaleString()} matched · ${notFound.toLocaleString()} not found · ${pending.toLocaleString()} pending">
<div class="em-seg-fill em-seg--matched" data-pct="${seg(matched)}" style="width:0%"></div>
<div class="em-seg-fill em-seg--nf" data-pct="${seg(notFound)}" style="width:0%"></div>
<div class="em-seg-fill em-seg--pend" data-pct="${seg(pending)}" style="width:0%"></div>
</div>
<div class="em-stat-legend">
<span class="em-leg em-leg--matched"><i></i>${matched.toLocaleString()} matched</span>
<span class="em-leg em-leg--nf"><i></i>${notFound.toLocaleString()} missed</span>
<span class="em-leg em-leg--pend"><i></i>${pending.toLocaleString()} pending</span>
</div>
</div>`;
}).join('');
// Animate the segments in from 0 on the next frame (CSS transition does the rest).
requestAnimationFrame(() => {
host.querySelectorAll('.em-seg-fill').forEach(el => {
el.style.width = `${el.dataset.pct || 0}%`;
});
});
}
function _emRenderUnmatchedControls() {
const host = document.getElementById('em-unmatched-controls');
if (!host) return;
const data = enrichmentManagerState.unmatched;
const supported = (data && data.entity_types) || ['artist'];
const total = data ? (data.total || 0) : null;
const tabs = supported.map(e => `
<button class="em-seg-tab ${e === enrichmentManagerState.entityTab ? 'active' : ''}"
onclick="setEnrichmentEntityTab('${e}')">${_emEntityLabel(e, true)}</button>`).join('');
host.innerHTML = `
<div class="em-unmatched-bar">
<div class="em-section-label em-section-label--inline">
Needs matching
${total == null ? '' : `<span class="em-count">${total.toLocaleString()}</span>`}
</div>
<div class="em-filter-row">
<div class="em-seg-tabs">${tabs}</div>
<select class="em-select" onchange="setEnrichmentStatusFilter(this.value)">
<option value="unmatched" ${enrichmentManagerState.statusFilter === 'unmatched' ? 'selected' : ''}>All unmatched</option>
<option value="not_found" ${enrichmentManagerState.statusFilter === 'not_found' ? 'selected' : ''}>Not found</option>
<option value="pending" ${enrichmentManagerState.statusFilter === 'pending' ? 'selected' : ''}>Pending</option>
</select>
<div class="em-search-wrap">
<span class="em-search-ico"></span>
<input class="em-search" type="text" placeholder="Search name…"
value="${_emEscape(enrichmentManagerState.search)}"
oninput="onEnrichmentSearchInput(this.value)">
</div>
</div>
</div>`;
}
function _emRenderUnmatchedList() {
const host = document.getElementById('em-unmatched-list');
if (!host) return;
const data = enrichmentManagerState.unmatched;
if (!data) {
host.innerHTML = Array.from({ length: 6 }, () => `
<div class="em-row em-skel-row">
<div class="em-skel em-row-img"></div>
<div class="em-row-info">
<div class="em-skel em-skel-line" style="width:55%"></div>
<div class="em-skel em-skel-line" style="width:30%;margin-top:6px"></div>
</div>
</div>`).join('');
return;
}
// Keep the count badge in sync without re-rendering the controls (would
// steal focus from the search box mid-type).
const countEl = document.querySelector('#em-unmatched-controls .em-count');
if (countEl) countEl.textContent = (data.total || 0).toLocaleString();
if (!data.items.length) {
const allMatched = enrichmentManagerState.statusFilter === 'unmatched';
host.innerHTML = `<div class="em-empty">
<div class="em-empty-emoji">${allMatched ? '🎉' : '🔍'}</div>
<div>${allMatched
? 'Every item is matched for this source.'
: 'Nothing matches this filter.'}</div>
</div>`;
} else {
const id = enrichmentManagerState.selected;
const entity = enrichmentManagerState.entityTab;
host.innerHTML = data.items.map(item => {
const img = item.image_url
? `<img class="em-row-img" src="${_emEscape(item.image_url)}" alt="" loading="lazy" onerror="this.style.display='none'">`
: '<div class="em-row-img em-row-img--ph">♪</div>';
const rel = _emRelativeTime(item.last_attempted);
const last = rel
? `<span class="em-muted">tried ${rel}</span>`
: '<span class="em-muted">never tried</span>';
const statusBadge = item.status === 'not_found'
? '<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');
return `
<div class="em-row">
${img}
<div class="em-row-info">
<div class="em-row-name" title="${safeName}">${safeName}</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 em-btn--ghost" title="Re-queue for the worker to try again"
onclick="retryEnrichmentItem('${id}','${entity}','${_emEscape(item.id)}', this)">Retry</button>
</div>
</div>`;
}).join('');
}
_emRenderPager();
}
function _emRenderPager() {
const host = document.getElementById('em-pager');
if (!host) return;
const data = enrichmentManagerState.unmatched;
if (!data) { host.innerHTML = ''; return; }
const { page, pageSize } = enrichmentManagerState;
const total = data.total || 0;
const from = total ? page * pageSize + 1 : 0;
const to = Math.min((page + 1) * pageSize, total);
const hasPrev = page > 0;
const hasNext = to < total;
host.innerHTML = `
<button class="em-btn em-btn--sm" ${hasPrev ? '' : 'disabled'} onclick="changeEnrichmentPage(-1)"> Prev</button>
<span class="em-muted">${from}${to} of ${total.toLocaleString()}</span>
<button class="em-btn em-btn--sm" ${hasNext ? '' : 'disabled'} onclick="changeEnrichmentPage(1)">Next </button>`;
}
// ── Controls ──────────────────────────────────────────────────────────────────
async function setEnrichmentEntityTab(entity) {
enrichmentManagerState.entityTab = entity;
enrichmentManagerState.page = 0;
_emRenderUnmatchedControls();
document.getElementById('em-unmatched-list').innerHTML = '<div class="enhanced-loading"><div class="spinner"></div></div>';
await _emLoadUnmatched();
_emRenderUnmatchedList();
}
async function setEnrichmentStatusFilter(value) {
enrichmentManagerState.statusFilter = value;
enrichmentManagerState.page = 0;
await _emLoadUnmatched();
_emRenderUnmatchedList();
}
let _emSearchDebounce = null;
function onEnrichmentSearchInput(value) {
enrichmentManagerState.search = value;
enrichmentManagerState.page = 0;
if (_emSearchDebounce) clearTimeout(_emSearchDebounce);
_emSearchDebounce = setTimeout(async () => {
await _emLoadUnmatched();
_emRenderUnmatchedList();
}, 300);
}
async function changeEnrichmentPage(delta) {
enrichmentManagerState.page = Math.max(0, enrichmentManagerState.page + delta);
await _emLoadUnmatched();
_emRenderUnmatchedList();
}
async function toggleEnrichmentWorker(id) {
const status = enrichmentManagerState.statuses[id];
const action = status?.paused ? 'resume' : 'pause';
try {
const res = await fetch(`/api/enrichment/${id}/${action}`, { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showToast(data.error || `Could not ${action} worker`, 'error');
return;
}
showToast(`${_emWorkerById[id].name} ${action === 'pause' ? 'paused' : 'resumed'}`, 'success');
await _emPollSelected();
} catch (_e) {
showToast(`Error trying to ${action} worker`, 'error');
}
}
async function retryEnrichmentItem(service, entityType, entityId, btn) {
if (btn) { btn.disabled = true; btn.textContent = '…'; }
try {
const res = await fetch('/api/library/clear-match', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_type: entityType, entity_id: entityId, service }),
});
const data = await res.json().catch(() => ({}));
if (data.success) {
showToast('Re-queued for enrichment', 'success');
await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]);
_emRenderStats();
_emRenderUnmatchedList();
} else {
showToast(data.error || 'Failed to re-queue', 'error');
if (btn) { btn.disabled = false; btn.textContent = 'Retry'; }
}
} catch (_e) {
showToast('Error re-queuing item', 'error');
if (btn) { btn.disabled = false; btn.textContent = 'Retry'; }
}
}
// ── Inline manual match (decoupled from the library artist-detail page) ───────
function openEnrichmentMatch(service, entityType, entityId, anchorBtn) {
const defaultQuery = anchorBtn
? (anchorBtn.closest('.em-row')?.querySelector('.em-row-name')?.textContent || '')
: '';
const existing = document.getElementById('enrichment-match-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'enrichment-match-overlay';
overlay.className = 'modal-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-bulk-modal-header">
<h3>Match ${_emEntityLabel(entityType)} on ${_emEscape(_emWorkerById[service]?.name || service)}</h3>
<button class="enhanced-bulk-modal-close">&times;</button>
</div>
<div class="enhanced-match-search-row">
<input type="text" class="enhanced-match-search-input" placeholder="Search…" value="${_emEscape(defaultQuery)}">
<button class="enhanced-enrich-btn em-match-go">Search</button>
</div>
<div class="enhanced-match-results" id="enrichment-match-results">
<div class="enhanced-match-results-hint">Search to find a match.</div>
</div>
</div>`;
document.body.appendChild(overlay);
const input = overlay.querySelector('.enhanced-match-search-input');
const results = overlay.querySelector('#enrichment-match-results');
overlay.querySelector('.enhanced-bulk-modal-close').onclick = () => overlay.remove();
const run = () => _emRunMatchSearch(service, entityType, entityId, input.value, results, overlay);
overlay.querySelector('.em-match-go').onclick = run;
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); });
if (defaultQuery.trim()) run();
setTimeout(() => input.focus(), 50);
}
async function _emRunMatchSearch(service, entityType, entityId, query, container, overlay) {
if (!query.trim()) {
container.innerHTML = '<div class="enhanced-match-results-hint">Enter a search term</div>';
return;
}
container.innerHTML = '<div class="enhanced-loading"><div class="spinner"></div></div>';
try {
const res = await fetch('/api/library/search-service', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service, entity_type: entityType, query: query.trim() }),
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Search failed');
const list = data.results || [];
if (!list.length) {
container.innerHTML = '<div class="enhanced-match-results-hint">No results. Try a different search.</div>';
return;
}
container.innerHTML = '';
list.forEach(r => {
const row = document.createElement('div');
row.className = 'enhanced-match-result-row';
const imgHtml = r.image
? `<img class="enhanced-match-result-img" src="${_emEscape(r.image)}" alt="" onerror="this.style.display='none'">`
: '<div class="enhanced-match-result-img-placeholder">&#127925;</div>';
const providerLabel = r.provider && r.provider !== service ? ` (${_emEscape(r.provider)})` : '';
row.innerHTML = `
${imgHtml}
<div class="enhanced-match-result-info">
<div class="enhanced-match-result-name">${_emEscape(r.name || 'Unknown')}</div>
${r.extra ? `<div class="enhanced-match-result-extra">${_emEscape(r.extra)}</div>` : ''}
<div class="enhanced-match-result-id">ID: ${_emEscape(r.id)}${providerLabel}</div>
</div>`;
const btn = document.createElement('button');
btn.className = 'enhanced-meta-save-btn';
btn.textContent = 'Match';
btn.onclick = () => _emApplyMatch(entityType, entityId, r.provider || service, r.id, overlay);
row.appendChild(btn);
container.appendChild(row);
});
} catch (e) {
container.innerHTML = `<div class="enhanced-match-results-hint">Search error: ${_emEscape(e.message)}</div>`;
}
}
async function _emApplyMatch(entityType, entityId, service, serviceId, overlay) {
try {
const res = await fetch('/api/library/manual-match', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_type: entityType, entity_id: entityId, service, service_id: serviceId }),
});
const data = await res.json();
if (data.success) {
showToast('Matched ✓', 'success');
if (overlay) overlay.remove();
// Refresh the manager's stats + list for the *selected* worker.
const sel = enrichmentManagerState.selected;
await Promise.all([_emLoadBreakdown(sel), _emLoadUnmatched()]);
_emRenderStats();
_emRenderUnmatchedList();
} else {
showToast(data.error || 'Failed to match', 'error');
}
} catch (_e) {
showToast('Error applying match', 'error');
}
}
// Expose for inline onclick handlers.
window.openEnrichmentManager = openEnrichmentManager;
window.closeEnrichmentManager = closeEnrichmentManager;
window.refreshEnrichmentManager = refreshEnrichmentManager;
window.selectEnrichmentWorker = selectEnrichmentWorker;
window.setEnrichmentEntityTab = setEnrichmentEntityTab;
window.setEnrichmentStatusFilter = setEnrichmentStatusFilter;
window.onEnrichmentSearchInput = onEnrichmentSearchInput;
window.changeEnrichmentPage = changeEnrichmentPage;
window.toggleEnrichmentWorker = toggleEnrichmentWorker;
window.retryEnrichmentItem = retryEnrichmentItem;
window.openEnrichmentMatch = openEnrichmentMatch;

@ -64744,3 +64744,388 @@ body.reduce-effects .dash-card::after {
background: rgba(var(--accent-rgb), 0.3);
background-clip: padding-box;
}
/* ===========================================================================
Manage Enrichment Workers modal (enrichment-manager.js)
=========================================================================== */
.em-manage-btn {
display: inline-flex;
align-items: center;
gap: 9px;
height: 44px;
padding: 0 18px 0 8px;
margin-left: 10px;
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.16) 0%, rgba(var(--accent-rgb), 0.07) 100%);
backdrop-filter: blur(20px) saturate(1.4);
-webkit-backdrop-filter: blur(20px) saturate(1.4);
border: 1.5px solid rgba(var(--accent-rgb), 0.28);
border-radius: 999px;
color: #fff;
font-size: 13.5px;
font-weight: 700;
letter-spacing: 0.2px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
box-shadow:
0 4px 16px rgba(var(--accent-rgb), 0.18),
0 2px 8px rgba(0,0,0,0.15),
inset 0 1px 0 rgba(255,255,255,0.08);
}
.em-manage-btn:hover {
border-color: rgba(var(--accent-rgb), 0.5);
transform: scale(1.04);
box-shadow:
0 6px 22px rgba(var(--accent-rgb), 0.32),
0 3px 12px rgba(0,0,0,0.2),
inset 0 1px 0 rgba(255,255,255,0.12);
}
.em-manage-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 50%;
font-size: 16px;
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.9), rgba(var(--accent-rgb), 0.55));
box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.5);
}
.em-manage-btn-label { background: linear-gradient(90deg, #fff, rgba(255,255,255,0.85)); -webkit-background-clip: text; background-clip: text; }
.enrichment-manager-modal {
position: relative;
background:
radial-gradient(120% 80% at 0% 0%, rgba(var(--accent-rgb), 0.10), transparent 55%),
radial-gradient(100% 70% at 100% 0%, rgba(255,255,255,0.04), transparent 50%),
linear-gradient(150deg, #1c1c1f 0%, #131316 55%, #0f0f12 100%);
border-radius: 18px;
border: 1px solid rgba(255,255,255,0.09);
width: 1150px;
max-width: 95vw;
height: 82vh;
max-height: 860px;
display: flex;
flex-direction: column;
box-shadow:
0 30px 90px rgba(0,0,0,0.65),
0 0 0 1px rgba(var(--accent-rgb), 0.12),
inset 0 1px 0 rgba(255,255,255,0.06);
overflow: hidden;
}
/* Hairline top accent line across the whole modal. */
.enrichment-manager-modal::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 1px;
background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.6), transparent);
z-index: 2;
}
.enrichment-manager-modal .enhanced-bulk-modal-header {
flex: 0 0 auto;
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.14), transparent 70%);
}
.enrichment-manager-modal .enhanced-bulk-modal-header h3 { letter-spacing: 0.3px; }
.em-body { display: flex; flex: 1 1 auto; min-height: 0; }
/* Left rail */
.em-rail {
flex: 0 0 230px;
border-right: 1px solid rgba(255,255,255,0.07);
padding: 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.em-worker-row {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
background: transparent;
border: 1px solid transparent;
border-radius: 10px;
cursor: pointer;
text-align: left;
transition: background 0.15s ease, border-color 0.15s ease;
}
.em-worker-row { position: relative; }
.em-worker-row:hover { background: rgba(255,255,255,0.05); }
.em-worker-row.active {
background: rgba(var(--accent-rgb), 0.14);
border-color: rgba(var(--accent-rgb), 0.4);
}
.em-worker-row.active::before {
content: '';
position: absolute; left: -1px; top: 8px; bottom: 8px; width: 3px;
border-radius: 0 3px 3px 0;
background: rgb(var(--accent-rgb));
box-shadow: 0 0 10px rgba(var(--accent-rgb), 0.7);
}
/* Circular glowing logo chip — mirrors the dashboard enrichment bubbles. */
.em-icon {
flex: 0 0 auto;
width: 34px; height: 34px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, rgba(255,255,255,0.07), rgba(255,255,255,0.02));
border: 1.5px solid color-mix(in srgb, var(--em-accent, #888) 45%, transparent);
box-shadow: 0 0 12px color-mix(in srgb, var(--em-accent, #888) 30%, transparent),
inset 0 1px 0 rgba(255,255,255,0.08);
overflow: hidden;
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.em-icon--lg { width: 52px; height: 52px; border-width: 2px; }
.em-icon-img { width: 66%; height: 66%; object-fit: contain; display: block; }
.em-icon-letter { font-weight: 800; font-size: 15px; color: var(--em-accent, #fff); text-shadow: 0 1px 2px rgba(0,0,0,0.5); }
.em-icon--lg .em-icon-letter { font-size: 22px; }
.em-worker-row:hover .em-icon { transform: scale(1.08); }
.em-worker-meta { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.em-worker-name { color: #eee; font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.em-worker-sub { font-size: 10.5px; color: rgba(255,255,255,0.4); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.em-rail-cov { display: block; height: 3px; border-radius: 999px; background: rgba(255,255,255,0.1); overflow: hidden; margin-top: 2px; }
.em-rail-cov-fill { display: block; height: 100%; border-radius: 999px; background: color-mix(in srgb, var(--accent, #4ade80) 70%, transparent); background: rgb(var(--accent-rgb)); transition: width 0.5s cubic-bezier(0.4,0,0.2,1); }
.em-dot { flex: 0 0 auto; width: 9px; height: 9px; border-radius: 50%; background: #555; }
.em-dot--running { background: #1db954; box-shadow: 0 0 8px #1db954; }
.em-dot--idle { background: #4a90d9; }
.em-dot--paused { background: #e0a93b; }
.em-dot--ratelimited { background: #e05b5b; box-shadow: 0 0 8px #e05b5b; }
.em-dot--disabled, .em-dot--stopped { background: #555; }
/* Right panel */
.em-panel {
flex: 1 1 auto;
min-width: 0;
padding: 18px 22px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.em-panel-header { flex: 0 0 auto; }
.em-ph-top { display: flex; align-items: center; gap: 14px; }
.em-ph-titles { flex: 1 1 auto; min-width: 0; }
.em-ph-name { font-size: 19px; font-weight: 800; color: #fff; }
.em-ph-sub { font-size: 13px; color: rgba(255,255,255,0.7); margin-top: 2px; }
.em-ph-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.em-muted { color: rgba(255,255,255,0.45); }
.em-pill {
padding: 4px 11px; border-radius: 999px; font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.4px;
}
.em-pill--running { background: rgba(29,185,84,0.18); color: #4ade80; }
.em-pill--idle { background: rgba(74,144,217,0.18); color: #7fb5ec; }
.em-pill--paused { background: rgba(224,169,59,0.18); color: #f0c060; }
.em-pill--ratelimited { background: rgba(224,91,91,0.2); color: #ff8b8b; }
.em-pill--disabled, .em-pill--stopped { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.5); }
.em-chip {
padding: 4px 9px; border-radius: 8px; font-size: 11px; font-weight: 600;
background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.75);
}
.em-chip--err { background: rgba(224,91,91,0.18); color: #ff8b8b; }
.em-chip--nf { background: rgba(224,91,91,0.15); color: #ff9b9b; }
.em-chip--pend { background: rgba(224,169,59,0.15); color: #f0c060; }
.em-btn {
padding: 8px 14px; border-radius: 9px; border: 1px solid rgba(var(--accent-rgb), 0.4);
background: rgba(var(--accent-rgb), 0.15); color: #fff; font-size: 13px; font-weight: 600;
cursor: pointer; transition: all 0.15s ease;
}
.em-btn:hover:not(:disabled) { background: rgba(var(--accent-rgb), 0.28); }
.em-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.em-btn--go { background: rgba(29,185,84,0.2); border-color: rgba(29,185,84,0.5); }
.em-btn--sm { padding: 5px 10px; font-size: 12px; }
.em-btn--ghost { background: transparent; border-color: rgba(255,255,255,0.18); color: rgba(255,255,255,0.7); }
.em-btn--ghost:hover:not(:disabled) { background: rgba(255,255,255,0.08); }
/* Stat cards */
.em-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; flex: 0 0 auto; }
.em-stat-card {
background: linear-gradient(160deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px; padding: 14px;
transition: transform 0.2s ease, border-color 0.2s ease;
}
.em-stat-card:hover { transform: translateY(-2px); border-color: rgba(var(--accent-rgb), 0.3); }
.em-bar-fill { box-shadow: 0 0 10px rgba(var(--accent-rgb), 0.5); }
.em-stat-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; }
.em-stat-title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: rgba(255,255,255,0.6); }
.em-stat-pct { font-size: 20px; font-weight: 800; color: #fff; }
.em-bar { height: 7px; border-radius: 999px; background: rgba(255,255,255,0.1); overflow: hidden; }
.em-bar-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, rgba(var(--accent-rgb),0.7), rgba(var(--accent-rgb),1)); transition: width 0.4s ease; }
.em-stat-legend { display: flex; gap: 10px; margin-top: 9px; font-size: 11px; flex-wrap: wrap; }
.em-leg--matched { color: #4ade80; }
.em-leg--nf { color: #ff9b9b; }
.em-leg--pend { color: #f0c060; }
/* Unmatched browser */
.em-unmatched { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; gap: 10px; }
.em-unmatched-controls { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; flex: 0 0 auto; }
.em-tabs { display: flex; gap: 6px; }
.em-tab {
padding: 7px 14px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1);
background: transparent; color: rgba(255,255,255,0.65); font-size: 13px; font-weight: 600; cursor: pointer;
}
.em-tab.active { background: rgba(var(--accent-rgb), 0.16); border-color: rgba(var(--accent-rgb), 0.4); color: #fff; }
.em-filter-row { display: flex; gap: 8px; align-items: center; }
.em-select, .em-search {
padding: 8px 12px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px; color: #fff; font-size: 13px;
}
.em-search { min-width: 200px; }
.em-search:focus, .em-select:focus { outline: none; border-color: rgba(var(--accent-rgb), 0.6); }
.em-unmatched-list { flex: 1 1 auto; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; padding-right: 4px; }
.em-row {
display: flex; align-items: center; gap: 12px; padding: 9px 12px;
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px;
}
.em-row:hover { background: rgba(255,255,255,0.06); }
.em-row-img { width: 42px; height: 42px; border-radius: 8px; object-fit: cover; flex: 0 0 auto; }
.em-row-img--ph { display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.4); font-size: 18px; }
.em-row-info { flex: 1 1 auto; min-width: 0; }
.em-row-name { font-size: 14px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.em-row-meta { display: flex; gap: 8px; align-items: center; margin-top: 3px; font-size: 11px; }
.em-row-actions { display: flex; gap: 6px; flex: 0 0 auto; }
.em-empty { text-align: center; padding: 48px 20px; color: rgba(255,255,255,0.55); font-size: 15px; }
.em-empty-emoji { font-size: 38px; margin-bottom: 10px; opacity: 0.85; }
.em-pager { display: flex; align-items: center; justify-content: center; gap: 14px; flex: 0 0 auto; padding-top: 4px; font-size: 12px; }
/* --- Motion: entrance / exit + scroll lock --- */
body.em-scroll-lock { overflow: hidden; }
.em-overlay { transition: opacity 0.22s ease, backdrop-filter 0.22s ease; }
.em-overlay.em-closing { opacity: 0; }
@keyframes em-pop-in {
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-overlay.em-closing .enrichment-manager-modal { transform: scale(0.98); opacity: 0; transition: all 0.16s ease; }
.enrichment-manager-modal:focus { outline: none; }
/* --- Header actions / refresh --- */
.em-header-actions { display: flex; align-items: center; gap: 8px; }
.em-icon-btn {
width: 32px; height: 32px; border-radius: 50%;
background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.8);
border: none; cursor: pointer; font-size: 16px; line-height: 1;
display: flex; align-items: center; justify-content: center;
transition: background 0.2s ease, transform 0.2s ease;
}
.em-icon-btn:hover { background: rgba(255,255,255,0.16); transform: rotate(15deg); }
.em-icon-btn.em-spinning { animation: em-spin 0.6s linear; }
@keyframes em-spin { to { transform: rotate(360deg); } }
.em-stat-pct-sym { font-size: 13px; opacity: 0.6; margin-left: 1px; }
/* --- Skeleton loaders --- */
.em-skel {
position: relative; overflow: hidden;
background: rgba(255,255,255,0.06); border-radius: 6px;
}
.em-skel::after {
content: ''; position: absolute; inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.10), transparent);
transform: translateX(-100%); animation: em-shimmer 1.3s infinite;
}
@keyframes em-shimmer { 100% { transform: translateX(100%); } }
.em-skel-line { height: 11px; }
.em-skel-bar { height: 7px; margin: 12px 0; border-radius: 999px; }
.em-skel-card { display: flex; flex-direction: column; }
.em-skel-row .em-row-img { background: rgba(255,255,255,0.06); }
.em-skel-row { pointer-events: none; }
@media (prefers-reduced-motion: reduce) {
.enrichment-manager-modal.em-in,
.em-icon-btn.em-spinning,
.em-skel::after { animation: none; }
.em-bar-fill, .em-rail-cov-fill { transition: none; }
}
/* --- Narrow screens: rail becomes a horizontal strip --- */
@media (max-width: 760px) {
.enrichment-manager-modal { height: 90vh; }
.em-body { flex-direction: column; }
.em-rail { flex: 0 0 auto; flex-direction: row; overflow-x: auto; border-right: none; border-bottom: 1px solid rgba(255,255,255,0.07); }
.em-worker-row { flex: 0 0 auto; }
.em-worker-meta { display: none; }
}
/* ===== Panel polish: hero header, accent theming, segmented stats ===== */
/* The panel sets --em-accent / --em-accent-rgb to the selected worker colour. */
.em-section-label {
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px;
color: rgba(255,255,255,0.4); flex: 0 0 auto;
}
.em-section-label--inline { display: flex; align-items: center; gap: 8px; }
.em-count {
display: inline-flex; align-items: center; justify-content: center;
min-width: 22px; height: 19px; padding: 0 7px; border-radius: 999px;
background: rgba(var(--em-accent-rgb, 99,102,241), 0.2);
color: rgb(var(--em-accent-rgb, 129,140,248));
font-size: 11px; font-weight: 800; letter-spacing: 0;
}
/* Hero header */
.em-hero {
position: relative; overflow: hidden;
display: flex; align-items: center; gap: 16px;
padding: 18px 20px;
border-radius: 16px;
background:
linear-gradient(135deg, rgba(var(--em-accent-rgb, 99,102,241), 0.16), rgba(var(--em-accent-rgb, 99,102,241), 0.03) 60%),
rgba(255,255,255,0.03);
border: 1px solid rgba(var(--em-accent-rgb, 99,102,241), 0.22);
}
.em-hero-glow {
position: absolute; top: -60%; right: -10%; width: 300px; height: 300px;
background: radial-gradient(circle, rgba(var(--em-accent-rgb, 99,102,241), 0.22), transparent 70%);
pointer-events: 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-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; }
/* 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); }
/* Entity glyph in stat-card titles */
.em-stat-title { display: inline-flex; align-items: center; gap: 7px; }
.em-stat-ico { font-size: 14px; filter: saturate(0.9); }
/* Segmented matched/not-found/pending bar */
.em-seg { display: flex; height: 9px; border-radius: 999px; overflow: hidden; background: rgba(255,255,255,0.07); }
.em-seg-fill { height: 100%; transition: width 0.6s cubic-bezier(0.16,1,0.3,1); }
.em-seg--matched { background: linear-gradient(90deg, rgba(var(--em-accent-rgb, 74,222,128),0.85), rgb(var(--em-accent-rgb, 74,222,128))); }
.em-seg--nf { background: #e0586b; }
.em-seg--pend { background: rgba(240,192,96,0.85); }
.em-stat-legend .em-leg { display: inline-flex; align-items: center; gap: 5px; }
.em-stat-legend .em-leg i { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.em-leg--matched i { background: rgb(var(--em-accent-rgb, 74,222,128)); }
.em-leg--nf i { background: #e0586b; }
.em-leg--pend i { background: rgba(240,192,96,0.9); }
/* Unmatched toolbar */
.em-unmatched-bar { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; flex: 0 0 auto; }
.em-seg-tabs { display: inline-flex; padding: 3px; gap: 2px; background: rgba(255,255,255,0.05); border-radius: 10px; border: 1px solid rgba(255,255,255,0.07); }
.em-seg-tab {
padding: 6px 14px; border-radius: 8px; border: none; background: transparent;
color: rgba(255,255,255,0.6); font-size: 12.5px; font-weight: 600; cursor: pointer;
transition: all 0.18s ease;
}
.em-seg-tab:hover { color: #fff; }
.em-seg-tab.active {
background: rgba(var(--em-accent-rgb, 99,102,241), 0.9);
color: #fff; box-shadow: 0 2px 8px rgba(var(--em-accent-rgb, 99,102,241), 0.4);
}
.em-search-wrap { position: relative; display: inline-flex; align-items: center; }
.em-search-ico { position: absolute; left: 11px; color: rgba(255,255,255,0.4); font-size: 15px; pointer-events: none; }
.em-search-wrap .em-search { padding-left: 30px; }
.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); }

Loading…
Cancel
Save