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).
This commit is contained in:
BoulderBadgeDad 2026-06-02 19:06:44 -07:00
parent 7956aaac9e
commit 0b3c3f656d
8 changed files with 1749 additions and 3 deletions

View file

@ -18,9 +18,14 @@ from __future__ import annotations
from typing import Any, Callable, Optional 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.services import EnrichmentService, get_service
from core.enrichment.unmatched import (
SERVICE_ENTITY_SUPPORT,
UnmatchedQueryError,
supported_entity_types,
)
from utils.logging_config import get_logger from utils.logging_config import get_logger
@ -32,6 +37,7 @@ logger = get_logger("enrichment.api")
_config_set: Optional[Callable[[str, Any], None]] = None _config_set: Optional[Callable[[str, Any], None]] = None
_auto_paused_discard: Optional[Callable[[str], None]] = None _auto_paused_discard: Optional[Callable[[str], None]] = None
_yield_override_add: Optional[Callable[[str], None]] = None _yield_override_add: Optional[Callable[[str], None]] = None
_db_getter: Optional[Callable[[], Any]] = None
def configure( def configure(
@ -39,16 +45,19 @@ def configure(
config_set: Optional[Callable[[str, Any], None]] = None, config_set: Optional[Callable[[str, Any], None]] = None,
auto_paused_discard: Optional[Callable[[str], None]] = None, auto_paused_discard: Optional[Callable[[str], None]] = None,
yield_override_add: Optional[Callable[[str], None]] = None, yield_override_add: Optional[Callable[[str], None]] = None,
db_getter: Optional[Callable[[], Any]] = None,
) -> None: ) -> None:
"""Wire host-side mutators that the generic routes call after pause/resume. """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 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 _config_set = config_set
_auto_paused_discard = auto_paused_discard _auto_paused_discard = auto_paused_discard
_yield_override_add = yield_override_add _yield_override_add = yield_override_add
_db_getter = db_getter
def _persist_paused(service: EnrichmentService, paused: bool) -> None: 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) logger.error("Error resuming %s worker: %s", service.id, e)
return jsonify({'error': str(e)}), 500 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 return bp

View file

@ -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',
]

View file

@ -1021,6 +1021,64 @@ class MusicDatabase:
finally: finally:
conn.close() 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): def _add_mirrored_playlist_explored_column(self, cursor):
"""Add explored_at column to mirrored_playlists to persist explore badge.""" """Add explored_at column to mirrored_playlists to persist explore badge."""
try: try:

View file

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

View file

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

View file

@ -625,6 +625,13 @@
</div> </div>
</div> </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> </div>
<!-- Watchlist / Wishlist quick-nav (top-right corner) --> <!-- Watchlist / Wishlist quick-nav (top-right corner) -->
<div class="header-quick-nav"> <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-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='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.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='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='auto-sync.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='pages-extra.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='pages-extra.js', v=static_v) }}"></script>

View file

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

View file

@ -64744,3 +64744,388 @@ body.reduce-effects .dash-card::after {
background: rgba(var(--accent-rgb), 0.3); background: rgba(var(--accent-rgb), 0.3);
background-clip: padding-box; 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); }