From 0b3c3f656d50aedcc4e0a31049734f10dd904763 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 2 Jun 2026 19:06:44 -0700 Subject: [PATCH 1/9] 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//{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). --- core/enrichment/api.py | 76 ++- core/enrichment/unmatched.py | 214 ++++++++ database/music_database.py | 58 ++ tests/enrichment/test_unmatched.py | 194 +++++++ web_server.py | 1 + webui/index.html | 8 + webui/static/enrichment-manager.js | 816 +++++++++++++++++++++++++++++ webui/static/style.css | 385 ++++++++++++++ 8 files changed, 1749 insertions(+), 3 deletions(-) create mode 100644 core/enrichment/unmatched.py create mode 100644 tests/enrichment/test_unmatched.py create mode 100644 webui/static/enrichment-manager.js diff --git a/core/enrichment/api.py b/core/enrichment/api.py index e4d097d7..7cf472f7 100644 --- a/core/enrichment/api.py +++ b/core/enrichment/api.py @@ -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//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//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 diff --git a/core/enrichment/unmatched.py b/core/enrichment/unmatched.py new file mode 100644 index 00000000..924a3bd9 --- /dev/null +++ b/core/enrichment/unmatched.py @@ -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 ``_match_status`` is ``'not_found'`` +(or still pending = ``NULL``) so the user can manually match them. Every +enrichment source writes a uniform ``_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 + ``_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', +] diff --git a/database/music_database.py b/database/music_database.py index 74fa5e13..7dee6199 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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: diff --git a/tests/enrichment/test_unmatched.py b/tests/enrichment/test_unmatched.py new file mode 100644 index 00000000..0a0730ab --- /dev/null +++ b/tests/enrichment/test_unmatched.py @@ -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} diff --git a/web_server.py b/web_server.py index 2849ac1c..6d0d007e 100644 --- a/web_server.py +++ b/web_server.py @@ -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()) diff --git a/webui/index.html b/webui/index.html index 659ba7f4..eb3f5ac6 100644 --- a/webui/index.html +++ b/webui/index.html @@ -625,6 +625,13 @@ + +
@@ -8032,6 +8039,7 @@ + diff --git a/webui/static/enrichment-manager.js b/webui/static/enrichment-manager.js new file mode 100644 index 00000000..f0be70ce --- /dev/null +++ b/webui/static/enrichment-manager.js @@ -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//{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 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 + ? `` + : `${initial}`; + return `${inner}`; +} + +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 => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 = ` + `; + 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 ? '' : ` + `; + return ` + `; + }).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 = ` +
+ +
+
+
+
+
+
`; + _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 = ` +
+
+ ${_emIconHtml(id, 'lg')} +
+
${_emEscape(worker.name)} enrichment
+
+
+
+
+ + + + +
+
`; + _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 + ? '' + : `${pct}% + enriched`; + } + + const cur = document.getElementById('em-ph-current'); + if (cur) { + const item = status && status.current_item; + cur.innerHTML = item + ? `Now enriching: ${_emEscape(item.name || '')}${item.type ? ` (${_emEscape(item.type)})` : ''}` + : 'No item processing'; + } + + const budgetEl = document.getElementById('em-ph-budget'); + if (budgetEl) { + const b = status && status.daily_budget; + budgetEl.innerHTML = (b && b.limit) + ? `Budget ${b.used ?? '?'} / ${b.limit}` : ''; + } + + const errEl = document.getElementById('em-ph-errors'); + if (errEl) { + const errors = (status && status.stats && status.stats.errors) || 0; + errEl.innerHTML = errors ? `⚠ ${errors}` : ''; + } + + 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 }, () => ` +
+
+
+
+
`).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 ` +
+
+ ${glyphs[entity] || '•'}${_emEntityLabel(entity, true)} + ${pct}% +
+
+
+
+
+
+
+ ${matched.toLocaleString()} matched + ${notFound.toLocaleString()} missed + ${pending.toLocaleString()} pending +
+
`; + }).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 => ` + `).join(''); + + host.innerHTML = ` +
+ +
+
${tabs}
+ +
+ + +
+
+
`; +} + +function _emRenderUnmatchedList() { + const host = document.getElementById('em-unmatched-list'); + if (!host) return; + const data = enrichmentManagerState.unmatched; + if (!data) { + host.innerHTML = Array.from({ length: 6 }, () => ` +
+
+
+
+
+
+
`).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 = `
+
${allMatched ? '🎉' : '🔍'}
+
${allMatched + ? 'Every item is matched for this source.' + : 'Nothing matches this filter.'}
+
`; + } else { + const id = enrichmentManagerState.selected; + const entity = enrichmentManagerState.entityTab; + host.innerHTML = data.items.map(item => { + const img = item.image_url + ? `` + : '
'; + const rel = _emRelativeTime(item.last_attempted); + const last = rel + ? `tried ${rel}` + : 'never tried'; + const statusBadge = item.status === 'not_found' + ? 'not found' + : 'pending'; + const safeName = _emEscape(item.name || 'Unknown'); + return ` +
+ ${img} +
+
${safeName}
+
${statusBadge} ${last}
+
+
+ + +
+
`; + }).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 = ` + + ${from}–${to} of ${total.toLocaleString()} + `; +} + +// ── Controls ────────────────────────────────────────────────────────────────── + +async function setEnrichmentEntityTab(entity) { + enrichmentManagerState.entityTab = entity; + enrichmentManagerState.page = 0; + _emRenderUnmatchedControls(); + document.getElementById('em-unmatched-list').innerHTML = '
'; + 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 = ` +
+
+

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

+ +
+
+ + +
+
+
Search to find a match.
+
+
`; + 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 = '
Enter a search term
'; + return; + } + container.innerHTML = '
'; + 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 = '
No results. Try a different search.
'; + return; + } + container.innerHTML = ''; + list.forEach(r => { + const row = document.createElement('div'); + row.className = 'enhanced-match-result-row'; + const imgHtml = r.image + ? `` + : '
🎵
'; + const providerLabel = r.provider && r.provider !== service ? ` (${_emEscape(r.provider)})` : ''; + row.innerHTML = ` + ${imgHtml} +
+
${_emEscape(r.name || 'Unknown')}
+ ${r.extra ? `
${_emEscape(r.extra)}
` : ''} +
ID: ${_emEscape(r.id)}${providerLabel}
+
`; + 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 = `
Search error: ${_emEscape(e.message)}
`; + } +} + +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; diff --git a/webui/static/style.css b/webui/static/style.css index 2324a5c3..0fa81c2a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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); } From fc9a9f1c90b1a4b3a39bf71dc253b07aa6f60f85 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 2 Jun 2026 19:19:39 -0700 Subject: [PATCH 2/9] Enrichment manager v2: working retry + bulk retry-all-failed Fixes a correctness bug and adds bulk re-queuing. - Bug: per-row 'Retry' used clear-match, which sets an item to not_found with last_attempted=NULL. The worker only retries not_found items where last_attempted < (now - 30d), and 'NULL < cutoff' is false in SQLite, so those items were never re-queued. Fixed by resetting match_status to NULL (pending), which every worker's queue picks up on the next pass. - New POST /api/enrichment//retry with scope 'item' | 'failed' (failed = re-queue every not_found item of an entity type), backed by a pure whitelisted build_reset_query + MusicDatabase.reset_enrichment(). - UI: per-row Retry now hits /retry; a 'Retry all failed' bulk button appears when the current entity has not-found items (confirm + count toast); a hint line explains retry/match/auto-retry behaviour. - 11 new tests (38 enrichment tests total, all green). --- core/enrichment/api.py | 27 ++++++++++++++ core/enrichment/unmatched.py | 42 +++++++++++++++++++++ database/music_database.py | 18 +++++++++ tests/enrichment/test_unmatched.py | 59 ++++++++++++++++++++++++++++++ webui/static/enrichment-manager.js | 54 ++++++++++++++++++++++++--- webui/static/style.css | 5 +++ 6 files changed, 200 insertions(+), 5 deletions(-) diff --git a/core/enrichment/api.py b/core/enrichment/api.py index 7cf472f7..746e0bd5 100644 --- a/core/enrichment/api.py +++ b/core/enrichment/api.py @@ -223,4 +223,31 @@ def create_blueprint() -> Blueprint: }) return jsonify(result), 200 + @bp.route('/api/enrichment//retry', methods=['POST']) + def enrichment_retry(service_id: str): + """Re-queue item(s) so the worker re-attempts them. + + Body: ``entity_type`` (artist|album|track), ``scope`` (item|failed), + ``entity_id`` (required when scope='item'). 'failed' re-queues every + not_found item of that entity type. + """ + 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 + + data = request.get_json(silent=True) or {} + entity_type = (data.get('entity_type') or 'artist').strip() + scope = (data.get('scope') or 'item').strip() + entity_id = data.get('entity_id') + try: + count = _db_getter().reset_enrichment(service_id, entity_type, scope, entity_id) + except UnmatchedQueryError as e: + return jsonify({'error': str(e)}), 400 + except Exception as e: + logger.error("Error re-queuing %s %s (%s): %s", service_id, entity_type, scope, e) + return jsonify({'error': str(e)}), 500 + return jsonify({'success': True, 'reset': count, 'service': service_id, + 'entity_type': entity_type, 'scope': scope}), 200 + return bp diff --git a/core/enrichment/unmatched.py b/core/enrichment/unmatched.py index 924a3bd9..fdf9fbfb 100644 --- a/core/enrichment/unmatched.py +++ b/core/enrichment/unmatched.py @@ -173,6 +173,46 @@ def build_count_query( return sql, params +# Reset scopes for re-queuing items so the worker re-attempts them. +RESET_SCOPES = ('item', 'failed') + + +def build_reset_query( + service: str, + entity_type: str, + scope: str = 'item', + entity_id=None, +) -> Tuple[str, List]: + """Build the UPDATE that re-queues item(s) for enrichment. + + Re-queuing means clearing ``_match_status`` back to NULL (and + ``_last_attempted`` to NULL): every worker's pending query selects + ``match_status IS NULL`` first, so the item is retried on the next pass. + Nulling last_attempted alone is NOT enough — the not_found retry path uses + ``last_attempted < cutoff`` and ``NULL < cutoff`` is false, so the item + would never be picked up. + + * scope='item' -> a single row (requires entity_id) + * scope='failed' -> every 'not_found' row for this entity type + """ + _validate(service, entity_type) + if scope not in RESET_SCOPES: + raise UnmatchedQueryError(f"Invalid reset scope: {scope!r}") + + meta = _ENTITY_TABLE[entity_type] + table = meta['table'] + ms = match_status_column(service) + la = last_attempted_column(service) + set_clause = f"SET {ms} = NULL, {la} = NULL" + + if scope == 'item': + if not entity_id: + raise UnmatchedQueryError("entity_id is required for an item reset") + return f"UPDATE {table} {set_clause} WHERE id = ?", [entity_id] + # 'failed' — re-queue everything this source explicitly gave up on. + return f"UPDATE {table} {set_clause} WHERE {ms} = 'not_found'", [] + + 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) @@ -211,4 +251,6 @@ __all__ = [ 'build_unmatched_query', 'build_count_query', 'build_breakdown_query', + 'build_reset_query', + 'RESET_SCOPES', ] diff --git a/database/music_database.py b/database/music_database.py index 7dee6199..aceb49c4 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -1079,6 +1079,24 @@ class MusicDatabase: finally: conn.close() + def reset_enrichment(self, service: str, entity_type: str, scope: str = 'item', entity_id=None) -> int: + """Re-queue item(s) for a source by clearing match_status back to NULL. + + scope='item' resets one row (entity_id); scope='failed' resets every + 'not_found' row for that entity type. Returns the number of rows reset. + Raises ``UnmatchedQueryError`` on bad input.""" + from core.enrichment.unmatched import build_reset_query + + sql, params = build_reset_query(service, entity_type, scope, entity_id) + conn = self._get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, params) + conn.commit() + return cursor.rowcount 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: diff --git a/tests/enrichment/test_unmatched.py b/tests/enrichment/test_unmatched.py index 0a0730ab..d6c88f08 100644 --- a/tests/enrichment/test_unmatched.py +++ b/tests/enrichment/test_unmatched.py @@ -17,6 +17,7 @@ from core.enrichment.unmatched import ( UnmatchedQueryError, build_breakdown_query, build_count_query, + build_reset_query, build_unmatched_query, supported_entity_types, ) @@ -154,6 +155,43 @@ def test_db_raises_on_bad_input(db): db.get_enrichment_unmatched('spotify', 'artist', status='bogus') +# -------------------------------------------------------------------------- +# Reset / retry (re-queue) — must clear match_status to NULL so the worker +# re-attempts (nulling last_attempted alone leaves not_found in limbo). +# -------------------------------------------------------------------------- + +def test_reset_builder_requires_id_for_item(): + with pytest.raises(UnmatchedQueryError): + build_reset_query('spotify', 'artist', 'item') # no entity_id + + +def test_reset_builder_bad_scope(): + with pytest.raises(UnmatchedQueryError): + build_reset_query('spotify', 'artist', 'bogus', entity_id='x') + + +def test_reset_builder_nulls_status_not_just_attempted(): + sql, _ = build_reset_query('spotify', 'artist', 'failed') + assert 'spotify_match_status = NULL' in sql + assert 'spotify_last_attempted = NULL' in sql + assert "WHERE spotify_match_status = 'not_found'" in sql + + +def test_reset_item_requeues_to_pending(db): + n = db.reset_enrichment('spotify', 'artist', 'item', entity_id='a2') # was not_found + assert n == 1 + # not_found dropped by 1, pending gained 1 + bd = db.get_enrichment_breakdown('spotify', 'artist') + assert bd == {'matched': 1, 'not_found': 0, 'pending': 2, 'total': 3} + + +def test_reset_failed_requeues_all(db): + n = db.reset_enrichment('spotify', 'album', 'failed') # one not_found album + assert n == 1 + bd = db.get_enrichment_breakdown('spotify', 'album') + assert bd['not_found'] == 0 and bd['pending'] == 1 + + # -------------------------------------------------------------------------- # Flask routes # -------------------------------------------------------------------------- @@ -192,3 +230,24 @@ def test_route_breakdown(client): assert r.status_code == 200 bd = r.get_json()['breakdown'] assert bd['artist'] == {'matched': 1, 'not_found': 1, 'pending': 1, 'total': 3} + + +def test_route_retry_item(client): + r = client.post('/api/enrichment/spotify/retry', + json={'entity_type': 'artist', 'scope': 'item', 'entity_id': 'a2'}) + assert r.status_code == 200 + body = r.get_json() + assert body['success'] is True and body['reset'] == 1 + + +def test_route_retry_failed_bulk(client): + r = client.post('/api/enrichment/spotify/retry', + json={'entity_type': 'artist', 'scope': 'failed'}) + assert r.status_code == 200 + assert r.get_json()['reset'] == 1 # one not_found artist re-queued + + +def test_route_retry_item_missing_id_400(client): + r = client.post('/api/enrichment/spotify/retry', + json={'entity_type': 'artist', 'scope': 'item'}) + assert r.status_code == 400 diff --git a/webui/static/enrichment-manager.js b/webui/static/enrichment-manager.js index f0be70ce..70b4cbac 100644 --- a/webui/static/enrichment-manager.js +++ b/webui/static/enrichment-manager.js @@ -511,15 +511,22 @@ function _emRenderUnmatchedControls() { const data = enrichmentManagerState.unmatched; const supported = (data && data.entity_types) || ['artist']; const total = data ? (data.total || 0) : null; + const entity = enrichmentManagerState.entityTab; + const failed = enrichmentManagerState.breakdown?.[entity]?.not_found || 0; const tabs = supported.map(e => ` `).join(''); + const bulkBtn = failed + ? `` + : ''; host.innerHTML = `
${tabs}
@@ -535,7 +542,8 @@ function _emRenderUnmatchedControls() { oninput="onEnrichmentSearchInput(this.value)">
-
`; + +
Failed lookups auto-retry after 30 days · “Retry” re-queues immediately · “Match” assigns a result by hand.
`; } function _emRenderUnmatchedList() { @@ -671,13 +679,15 @@ async function toggleEnrichmentWorker(id) { 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', + // Reset match_status to NULL (pending) so the worker re-attempts on its + // next pass — see /retry (clearing to not_found would NOT re-queue). + const res = await fetch(`/api/enrichment/${service}/retry`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ entity_type: entityType, entity_id: entityId, service }), + body: JSON.stringify({ entity_type: entityType, scope: 'item', entity_id: entityId }), }); const data = await res.json().catch(() => ({})); - if (data.success) { + if (res.ok && data.success) { showToast('Re-queued for enrichment', 'success'); await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]); _emRenderStats(); @@ -692,6 +702,39 @@ async function retryEnrichmentItem(service, entityType, entityId, btn) { } } +// Bulk: re-queue every not_found item of the current entity type. +async function retryAllFailedEnrichment(btn) { + const service = enrichmentManagerState.selected; + const entity = enrichmentManagerState.entityTab; + const bd = enrichmentManagerState.breakdown?.[entity]; + const failed = bd ? (bd.not_found || 0) : 0; + if (!failed) { showToast('No failed items to retry', 'info'); return; } + if (!confirm(`Re-queue all ${failed.toLocaleString()} not-found ${_emEntityLabel(entity, true).toLowerCase()} for ${_emWorkerById[service].name}? The worker will retry them on its next pass.`)) return; + if (btn) { btn.disabled = true; btn.textContent = 'Re-queuing…'; } + try { + const res = await fetch(`/api/enrichment/${service}/retry`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ entity_type: entity, scope: 'failed' }), + }); + const data = await res.json().catch(() => ({})); + if (res.ok && data.success) { + showToast(`Re-queued ${(data.reset || 0).toLocaleString()} item(s)`, 'success'); + enrichmentManagerState.page = 0; + await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]); + _emRenderStats(); + _emRenderUnmatchedControls(); + _emRenderUnmatchedList(); + } else { + showToast(data.error || 'Failed to re-queue', 'error'); + } + } catch (_e) { + showToast('Error re-queuing failed items', 'error'); + } finally { + if (btn) { btn.disabled = false; btn.textContent = '↻ Retry all failed'; } + } +} + // ── Inline manual match (decoupled from the library artist-detail page) ─────── function openEnrichmentMatch(service, entityType, entityId, anchorBtn) { @@ -813,4 +856,5 @@ window.onEnrichmentSearchInput = onEnrichmentSearchInput; window.changeEnrichmentPage = changeEnrichmentPage; window.toggleEnrichmentWorker = toggleEnrichmentWorker; window.retryEnrichmentItem = retryEnrichmentItem; +window.retryAllFailedEnrichment = retryAllFailedEnrichment; window.openEnrichmentMatch = openEnrichmentMatch; diff --git a/webui/static/style.css b/webui/static/style.css index 0fa81c2a..c5616812 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -65129,3 +65129,8 @@ body.em-scroll-lock { overflow: hidden; } .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); } + +/* Bulk retry-all + helper hint */ +.em-retry-all { margin-left: 4px; } +.em-hint { font-size: 11px; color: rgba(255,255,255,0.38); flex: 0 0 auto; margin-top: -2px; } +.em-hint :is(b, strong) { color: rgba(255,255,255,0.6); } From e53a157793cbb4f7bb2e9499c182675260a82173 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 2 Jun 2026 19:45:04 -0700 Subject: [PATCH 3/9] Enrichment manager: 'process this group first' + refined hero header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-worker processing-order override + UI polish. Feature — pin an entity group to enrich first: - Each worker normally runs artist -> album -> track. A user can pin one group (artist/album/track) to run first from the modal; the worker keeps that group first until it's exhausted, then resumes the normal chain. - core/worker_utils.py: read_enrichment_priority() (reads _enrichment_priority each loop, live) + priority_pending_item() (shared, whitelisted query returning the worker's expected item shape; Spotify/iTunes get album_individual/track_individual via a type map). - A guarded ~6-line hook at the top of all 11 workers' _get_next_item. CRITICAL: when nothing is pinned (default) the hook returns immediately, so default enrichment order is byte-identical to before. Discogs (no track) and Genius (no album) only honor their supported entities. - core/enrichment/api.py: GET/POST /api/enrichment//priority (+ config_get hook); POST validates the entity against what the source enriches. - 14 new tests (helper shapes, exhaustion, route get/set/clear/validate). UI: - Refined hero header: identity + inline status left, single Pause right, 'now enriching' quiet sub-line; overall coverage % moved into the stats section ('82% matched · 1,203 of 1,460'). Hero gently pulses while running. - New processing-order strip: artist→album→track steps showing the live phase (pulsing 'now'), pinned group ('first' + 📌), and done/remaining; click a step to pin it, click again for auto. py_compile clean across all 11 workers; 52 enrichment tests green. --- core/amazon_worker.py | 10 ++ core/audiodb_worker.py | 10 ++ core/deezer_worker.py | 10 ++ core/discogs_worker.py | 10 ++ core/enrichment/api.py | 49 +++++++- core/genius_worker.py | 10 ++ core/itunes_worker.py | 11 ++ core/lastfm_worker.py | 10 ++ core/musicbrainz_worker.py | 10 ++ core/qobuz_worker.py | 10 ++ core/spotify_worker.py | 11 ++ core/tidal_worker.py | 10 ++ core/worker_utils.py | 64 +++++++++++ tests/enrichment/test_worker_priority.py | 138 +++++++++++++++++++++++ web_server.py | 1 + webui/static/enrichment-manager.js | 136 +++++++++++++++++++--- webui/static/style.css | 50 +++++++- 17 files changed, 527 insertions(+), 23 deletions(-) create mode 100644 tests/enrichment/test_worker_priority.py diff --git a/core/amazon_worker.py b/core/amazon_worker.py index c4dbc0c7..ee4b9751 100644 --- a/core/amazon_worker.py +++ b/core/amazon_worker.py @@ -174,6 +174,16 @@ class AmazonWorker: cursor = conn.cursor() self._ensure_amazon_schema(cursor) + # Pinned-group override (Manage Enrichment Workers): process one + # entity type first, then fall through to the normal chain. Unset or + # exhausted ⇒ default artist→album→track order, unchanged. + from core.worker_utils import read_enrichment_priority, priority_pending_item + _prio = read_enrichment_priority('amazon') + if _prio: + _pi = priority_pending_item(cursor, 'amazon', _prio) + if _pi: + return _pi + # Priority 1: Unattempted artists cursor.execute(""" SELECT id, name FROM artists diff --git a/core/audiodb_worker.py b/core/audiodb_worker.py index ec687346..a8767cb8 100644 --- a/core/audiodb_worker.py +++ b/core/audiodb_worker.py @@ -162,6 +162,16 @@ class AudioDBWorker: conn = self.db._get_connection() cursor = conn.cursor() + # Pinned-group override (Manage Enrichment Workers): process one + # entity type first, then fall through to the normal chain. Unset or + # exhausted ⇒ default artist→album→track order, unchanged. + from core.worker_utils import read_enrichment_priority, priority_pending_item + _prio = read_enrichment_priority('audiodb') + if _prio: + _pi = priority_pending_item(cursor, 'audiodb', _prio) + if _pi: + return _pi + # Priority 1: Unattempted artists cursor.execute(""" SELECT id, name diff --git a/core/deezer_worker.py b/core/deezer_worker.py index aba5c6fb..9b592627 100644 --- a/core/deezer_worker.py +++ b/core/deezer_worker.py @@ -163,6 +163,16 @@ class DeezerWorker: conn = self.db._get_connection() cursor = conn.cursor() + # Pinned-group override (Manage Enrichment Workers): process one + # entity type first, then fall through to the normal chain. Unset or + # exhausted ⇒ default artist→album→track order, unchanged. + from core.worker_utils import read_enrichment_priority, priority_pending_item + _prio = read_enrichment_priority('deezer') + if _prio: + _pi = priority_pending_item(cursor, 'deezer', _prio) + if _pi: + return _pi + # Priority 1: Unattempted artists cursor.execute(""" SELECT id, name diff --git a/core/discogs_worker.py b/core/discogs_worker.py index 8663934f..429ba320 100644 --- a/core/discogs_worker.py +++ b/core/discogs_worker.py @@ -174,6 +174,16 @@ class DiscogsWorker: conn = self.db._get_connection() cursor = conn.cursor() + # Pinned-group override (Manage Enrichment Workers): process one + # entity type first, then fall through to the normal chain. Discogs + # has no track endpoint, so only artist/album are honored. + from core.worker_utils import read_enrichment_priority, priority_pending_item + _prio = read_enrichment_priority('discogs') + if _prio in ('artist', 'album'): + _pi = priority_pending_item(cursor, 'discogs', _prio) + if _pi: + return _pi + # Priority 1: Unattempted artists cursor.execute(""" SELECT id, name FROM artists diff --git a/core/enrichment/api.py b/core/enrichment/api.py index 746e0bd5..99c26110 100644 --- a/core/enrichment/api.py +++ b/core/enrichment/api.py @@ -35,6 +35,7 @@ logger = get_logger("enrichment.api") # Hooks the host wires up so the blueprint can persist pause state and # clean up auto-pause / yield-override sets without circular imports. _config_set: Optional[Callable[[str, Any], None]] = None +_config_get: Optional[Callable[[str, Any], Any]] = None _auto_paused_discard: Optional[Callable[[str], None]] = None _yield_override_add: Optional[Callable[[str], None]] = None _db_getter: Optional[Callable[[], Any]] = None @@ -43,6 +44,7 @@ _db_getter: Optional[Callable[[], Any]] = None def configure( *, config_set: Optional[Callable[[str, Any], None]] = None, + config_get: Optional[Callable[[str, Any], Any]] = None, auto_paused_discard: Optional[Callable[[str], None]] = None, yield_override_add: Optional[Callable[[str], None]] = None, db_getter: Optional[Callable[[], Any]] = None, @@ -51,10 +53,12 @@ def configure( Each is optional — pass None for hosts that don't have a corresponding mechanism (e.g. tests). ``db_getter`` returns the live ``MusicDatabase`` - for the unmatched-browser routes. + for the unmatched-browser routes; ``config_get``/``config_set`` read and + write the per-worker priority override. """ - global _config_set, _auto_paused_discard, _yield_override_add, _db_getter + global _config_set, _config_get, _auto_paused_discard, _yield_override_add, _db_getter _config_set = config_set + _config_get = config_get _auto_paused_discard = auto_paused_discard _yield_override_add = yield_override_add _db_getter = db_getter @@ -250,4 +254,45 @@ def create_blueprint() -> Blueprint: return jsonify({'success': True, 'reset': count, 'service': service_id, 'entity_type': entity_type, 'scope': scope}), 200 + @bp.route('/api/enrichment//priority', methods=['GET']) + def enrichment_get_priority(service_id: str): + """Return the pinned 'process this group first' entity for a worker.""" + if service_id not in SERVICE_ENTITY_SUPPORT: + return jsonify({'error': f'Unknown enrichment service: {service_id}'}), 404 + priority = '' + if _config_get is not None: + try: + priority = (_config_get(f'{service_id}_enrichment_priority', '') or '').strip().lower() + except Exception as e: + logger.debug("reading %s priority: %s", service_id, e) + if priority not in supported_entity_types(service_id): + priority = '' + return jsonify({'service': service_id, 'priority': priority, + 'entity_types': list(supported_entity_types(service_id))}), 200 + + @bp.route('/api/enrichment//priority', methods=['POST']) + def enrichment_set_priority(service_id: str): + """Pin (or clear) the entity type the worker should process first. + + Body: ``entity`` = 'artist'|'album'|'track' to pin, or '' / null / 'none' + to clear. Must be an entity type the source actually enriches. + """ + if service_id not in SERVICE_ENTITY_SUPPORT: + return jsonify({'error': f'Unknown enrichment service: {service_id}'}), 404 + if _config_set is None: + return jsonify({'error': 'config unavailable'}), 503 + data = request.get_json(silent=True) or {} + entity = (data.get('entity') or '').strip().lower() + if entity in ('none', 'clear'): + entity = '' + if entity and entity not in supported_entity_types(service_id): + return jsonify({'error': f'{service_id} does not enrich {entity!r}'}), 400 + try: + _config_set(f'{service_id}_enrichment_priority', entity) + except Exception as e: + logger.error("setting %s priority: %s", service_id, e) + return jsonify({'error': str(e)}), 500 + logger.info("%s enrichment priority set to %r via UI", service_id, entity or '(none)') + return jsonify({'success': True, 'service': service_id, 'priority': entity}), 200 + return bp diff --git a/core/genius_worker.py b/core/genius_worker.py index e2e731fd..1184e6db 100644 --- a/core/genius_worker.py +++ b/core/genius_worker.py @@ -178,6 +178,16 @@ class GeniusWorker: conn = self.db._get_connection() cursor = conn.cursor() + # Pinned-group override (Manage Enrichment Workers): process one + # entity type first, then fall through to the normal chain. Genius + # is artist/track only, so albums are not honored. + from core.worker_utils import read_enrichment_priority, priority_pending_item + _prio = read_enrichment_priority('genius') + if _prio in ('artist', 'track'): + _pi = priority_pending_item(cursor, 'genius', _prio) + if _pi: + return _pi + # Priority 1: Unattempted artists cursor.execute(""" SELECT id, name diff --git a/core/itunes_worker.py b/core/itunes_worker.py index 50cf0df0..cca60db0 100644 --- a/core/itunes_worker.py +++ b/core/itunes_worker.py @@ -172,6 +172,17 @@ class iTunesWorker: conn = self.db._get_connection() cursor = conn.cursor() + # Pinned-group override (Manage Enrichment Workers): process one + # entity type first, then fall through to the normal chain. Unset or + # exhausted ⇒ default artist→album→track order, unchanged. + from core.worker_utils import read_enrichment_priority, priority_pending_item + _prio = read_enrichment_priority('itunes') + if _prio: + _pi = priority_pending_item(cursor, 'itunes', _prio, + {'album': 'album_individual', 'track': 'track_individual'}) + if _pi: + return _pi + # Priority 1: Unattempted artists cursor.execute(""" SELECT id, name diff --git a/core/lastfm_worker.py b/core/lastfm_worker.py index 69a4c360..c2011e40 100644 --- a/core/lastfm_worker.py +++ b/core/lastfm_worker.py @@ -177,6 +177,16 @@ class LastFMWorker: conn = self.db._get_connection() cursor = conn.cursor() + # Pinned-group override (Manage Enrichment Workers): process one + # entity type first, then fall through to the normal chain. Unset or + # exhausted ⇒ default artist→album→track order, unchanged. + from core.worker_utils import read_enrichment_priority, priority_pending_item + _prio = read_enrichment_priority('lastfm') + if _prio: + _pi = priority_pending_item(cursor, 'lastfm', _prio) + if _pi: + return _pi + # Priority 1: Unattempted artists cursor.execute(""" SELECT id, name diff --git a/core/musicbrainz_worker.py b/core/musicbrainz_worker.py index bc494433..4f3adf55 100644 --- a/core/musicbrainz_worker.py +++ b/core/musicbrainz_worker.py @@ -166,6 +166,16 @@ class MusicBrainzWorker: conn = self.db._get_connection() cursor = conn.cursor() + # Pinned-group override (Manage Enrichment Workers): process one + # entity type first, then fall through to the normal chain. Unset or + # exhausted ⇒ default artist→album→track order, unchanged. + from core.worker_utils import read_enrichment_priority, priority_pending_item + _prio = read_enrichment_priority('musicbrainz') + if _prio: + _pi = priority_pending_item(cursor, 'musicbrainz', _prio) + if _pi: + return _pi + # Priority 1: Unattempted artists cursor.execute(""" SELECT id, name diff --git a/core/qobuz_worker.py b/core/qobuz_worker.py index c6ba2be7..706d6295 100644 --- a/core/qobuz_worker.py +++ b/core/qobuz_worker.py @@ -186,6 +186,16 @@ class QobuzWorker: conn = self.db._get_connection() cursor = conn.cursor() + # Pinned-group override (Manage Enrichment Workers): process one + # entity type first, then fall through to the normal chain. Unset or + # exhausted ⇒ default artist→album→track order, unchanged. + from core.worker_utils import read_enrichment_priority, priority_pending_item + _prio = read_enrichment_priority('qobuz') + if _prio: + _pi = priority_pending_item(cursor, 'qobuz', _prio) + if _pi: + return _pi + # Priority 1: Unattempted artists cursor.execute(""" SELECT id, name diff --git a/core/spotify_worker.py b/core/spotify_worker.py index 9349bb74..4e0b3e66 100644 --- a/core/spotify_worker.py +++ b/core/spotify_worker.py @@ -260,6 +260,17 @@ class SpotifyWorker: conn = self.db._get_connection() cursor = conn.cursor() + # Pinned-group override (Manage Enrichment Workers): process one + # entity type first, then fall through to the normal chain. Unset or + # exhausted ⇒ default artist→album→track order, unchanged. + from core.worker_utils import read_enrichment_priority, priority_pending_item + _prio = read_enrichment_priority('spotify') + if _prio: + _pi = priority_pending_item(cursor, 'spotify', _prio, + {'album': 'album_individual', 'track': 'track_individual'}) + if _pi: + return _pi + # Priority 1: Unattempted artists cursor.execute(""" SELECT id, name diff --git a/core/tidal_worker.py b/core/tidal_worker.py index 4bcb434a..051f2843 100644 --- a/core/tidal_worker.py +++ b/core/tidal_worker.py @@ -198,6 +198,16 @@ class TidalWorker: conn = self.db._get_connection() cursor = conn.cursor() + # Pinned-group override (Manage Enrichment Workers): process one + # entity type first, then fall through to the normal chain. Unset or + # exhausted ⇒ default artist→album→track order, unchanged. + from core.worker_utils import read_enrichment_priority, priority_pending_item + _prio = read_enrichment_priority('tidal') + if _prio: + _pi = priority_pending_item(cursor, 'tidal', _prio) + if _pi: + return _pi + # Priority 1: Unattempted artists cursor.execute(""" SELECT id, name diff --git a/core/worker_utils.py b/core/worker_utils.py index e572c702..a8d965b9 100644 --- a/core/worker_utils.py +++ b/core/worker_utils.py @@ -78,3 +78,67 @@ def set_album_api_track_count(cursor, album_id, count): logger.warning( "Failed to cache api_track_count for album %s: %s", album_id, e ) + + +# --- Enrichment "process this group first" override ----------------------- +# Each enrichment worker normally processes artist -> album -> track. A user +# can pin one entity type to run first via the Manage Enrichment Workers modal; +# the choice is stored in config as "_enrichment_priority" and read +# at the top of each worker's _get_next_item so it takes effect live. When the +# pinned group is exhausted (or unset), the worker falls back to its normal +# chain — so the default path is unchanged. + +PRIORITY_ENTITIES = ('artist', 'album', 'track') + + +def read_enrichment_priority(service: str) -> str: + """Return the pinned entity ('artist'|'album'|'track') for a worker, or ''. + + Read every loop so the override applies without restarting the worker. + Any error / unset / invalid value yields '' (no override).""" + try: + from config.settings import config_manager + val = (config_manager.get(f'{service}_enrichment_priority', '') or '') + val = str(val).strip().lower() + return val if val in PRIORITY_ENTITIES else '' + except Exception: + return '' + + +def priority_pending_item(cursor, service, entity, type_overrides=None): + """Return one pending (NULL match_status) item of `entity`, or None. + + `service` is the column prefix (e.g. 'spotify' -> spotify_match_status) and + MUST be a trusted worker-supplied literal (it is interpolated into SQL). + `type_overrides` maps the canonical entity to the worker's dispatch 'type' + string — Spotify/iTunes process individual items as 'album_individual' / + 'track_individual', the other workers use 'album' / 'track'. The returned + dict matches the shape those workers already return from _get_next_item.""" + if not str(service).isalpha() or entity not in PRIORITY_ENTITIES: + return None + type_overrides = type_overrides or {} + ms = f"{service}_match_status" + + if entity == 'artist': + cursor.execute( + f"SELECT id, name FROM artists WHERE {ms} IS NULL AND id IS NOT NULL " + f"ORDER BY id ASC LIMIT 1" + ) + r = cursor.fetchone() + return {'type': type_overrides.get('artist', 'artist'), 'id': r[0], 'name': r[1]} if r else None + + if entity == 'album': + cursor.execute( + f"SELECT a.id, a.title, ar.name FROM albums a JOIN artists ar ON a.artist_id = ar.id " + f"WHERE a.{ms} IS NULL AND a.id IS NOT NULL ORDER BY a.id ASC LIMIT 1" + ) + r = cursor.fetchone() + return {'type': type_overrides.get('album', 'album'), 'id': r[0], 'name': r[1], 'artist': r[2]} if r else None + + # track + cursor.execute( + f"SELECT t.id, t.title, ar.name FROM tracks t JOIN artists ar ON t.artist_id = ar.id " + f"WHERE t.{ms} IS NULL AND t.id IS NOT NULL ORDER BY t.id ASC LIMIT 1" + ) + r = cursor.fetchone() + return {'type': type_overrides.get('track', 'track'), 'id': r[0], 'name': r[1], 'artist': r[2]} if r else None diff --git a/tests/enrichment/test_worker_priority.py b/tests/enrichment/test_worker_priority.py new file mode 100644 index 00000000..e17e5582 --- /dev/null +++ b/tests/enrichment/test_worker_priority.py @@ -0,0 +1,138 @@ +"""Priority 'process this group first' helper for enrichment workers. + +The shared helper returns one pending item of a chosen entity type in the +shape the worker's dispatch already expects (with Spotify/iTunes mapped to +their album_individual / track_individual types). Default path (no override) +is exercised by the workers themselves and unchanged. +""" + +from __future__ import annotations + +import pytest + +from core.worker_utils import ( + PRIORITY_ENTITIES, + priority_pending_item, + read_enrichment_priority, +) +from database.music_database import MusicDatabase + + +@pytest.fixture +def db(tmp_path): + d = MusicDatabase(str(tmp_path / 'prio.db')) + conn = d._get_connection() + cur = conn.cursor() + cur.execute("INSERT INTO artists (id, name) VALUES ('a1', 'Pending Artist')") # NULL status + cur.execute("INSERT INTO artists (id, name, spotify_match_status) VALUES ('a2','Done','matched')") + cur.execute("INSERT INTO albums (id, artist_id, title) VALUES ('al1','a2','Pending Album')") # NULL status + cur.execute("INSERT INTO tracks (id, album_id, artist_id, title) VALUES ('t1','al1','a2','Pending Track')") + conn.commit() + conn.close() + return d + + +def _cur(db): + return db._get_connection().cursor() + + +def test_priority_artist_shape(db): + item = priority_pending_item(_cur(db), 'spotify', 'artist') + assert item == {'type': 'artist', 'id': 'a1', 'name': 'Pending Artist'} + + +def test_priority_album_default_type(db): + item = priority_pending_item(_cur(db), 'spotify', 'album') + assert item['id'] == 'al1' and item['name'] == 'Pending Album' and item['artist'] == 'Done' + assert item['type'] == 'album' # default type string + + +def test_priority_album_type_override_for_spotify_itunes(db): + item = priority_pending_item(_cur(db), 'spotify', 'album', + {'album': 'album_individual', 'track': 'track_individual'}) + assert item['type'] == 'album_individual' # matches Spotify/iTunes dispatch + + +def test_priority_track_shape(db): + item = priority_pending_item(_cur(db), 'spotify', 'track') + assert item['id'] == 't1' and item['type'] == 'track' and item['artist'] == 'Done' + + +def test_priority_returns_none_when_group_exhausted(db): + # No pending artists once a1 is matched -> None, so worker resumes its chain. + conn = db._get_connection(); conn.execute("UPDATE artists SET spotify_match_status='matched' WHERE id='a1'"); conn.commit(); conn.close() + assert priority_pending_item(_cur(db), 'spotify', 'artist') is None + + +def test_priority_rejects_bad_entity_and_service(db): + assert priority_pending_item(_cur(db), 'spotify', 'bogus') is None + assert priority_pending_item(_cur(db), 'spot;drop', 'artist') is None # non-alpha service blocked + + +def test_read_priority_unset_is_empty(): + # Unknown/unset key -> '' (no override). Uses the real config_manager. + assert read_enrichment_priority('definitely_not_a_service') == '' + + +def test_read_priority_roundtrip(): + from config.settings import config_manager + key = 'spotify_enrichment_priority' + old = config_manager.get(key, '') + try: + config_manager.set(key, 'album') + assert read_enrichment_priority('spotify') == 'album' + config_manager.set(key, 'bogus') + assert read_enrichment_priority('spotify') == '' # invalid -> ignored + finally: + config_manager.set(key, old) + + +def test_priority_entities_constant(): + assert PRIORITY_ENTITIES == ('artist', 'album', 'track') + + +# --- priority GET/POST routes --------------------------------------------- + +@pytest.fixture +def client(): + from flask import Flask + from core.enrichment import api as enrichment_api + store = {} + enrichment_api.configure( + config_get=lambda k, d=None: store.get(k, d), + config_set=lambda k, v: store.__setitem__(k, v), + db_getter=lambda: None, + ) + app = Flask(__name__) + app.register_blueprint(enrichment_api.create_blueprint()) + with app.test_client() as c: + c._store = store + yield c + enrichment_api.configure(config_get=None, config_set=None, db_getter=None) + + +def test_route_priority_get_default_empty(client): + r = client.get('/api/enrichment/spotify/priority') + assert r.status_code == 200 + assert r.get_json()['priority'] == '' + + +def test_route_priority_set_and_get(client): + assert client.post('/api/enrichment/spotify/priority', json={'entity': 'album'}).status_code == 200 + assert client._store['spotify_enrichment_priority'] == 'album' + assert client.get('/api/enrichment/spotify/priority').get_json()['priority'] == 'album' + + +def test_route_priority_clear(client): + client.post('/api/enrichment/spotify/priority', json={'entity': 'album'}) + client.post('/api/enrichment/spotify/priority', json={'entity': 'none'}) + assert client.get('/api/enrichment/spotify/priority').get_json()['priority'] == '' + + +def test_route_priority_rejects_unsupported_entity(client): + # Genius has no albums -> 400 + assert client.post('/api/enrichment/genius/priority', json={'entity': 'album'}).status_code == 400 + + +def test_route_priority_unknown_service_404(client): + assert client.get('/api/enrichment/bogus/priority').status_code == 404 diff --git a/web_server.py b/web_server.py index 6d0d007e..8d8caa8d 100644 --- a/web_server.py +++ b/web_server.py @@ -34643,6 +34643,7 @@ _register_enrichment_services([ _configure_enrichment_api( config_set=lambda key, value: config_manager.set(key, value), + config_get=lambda key, default=None: config_manager.get(key, default), auto_paused_discard=lambda token: _download_auto_paused.discard(token), yield_override_add=lambda token: _download_yield_override.add(token), db_getter=get_database, diff --git a/webui/static/enrichment-manager.js b/webui/static/enrichment-manager.js index 70b4cbac..563111f1 100644 --- a/webui/static/enrichment-manager.js +++ b/webui/static/enrichment-manager.js @@ -76,6 +76,7 @@ const enrichmentManagerState = { selected: null, statuses: {}, // id -> last /status payload breakdown: null, // selected worker's breakdown + priority: '', // pinned 'process first' entity for selected worker ('' = auto) entityTab: 'artist', statusFilter: 'unmatched', search: '', @@ -229,6 +230,7 @@ async function _emPollSelected() { enrichmentManagerState.statuses[id] = await res.json(); _emUpdateHeaderLive(); // in-place — no logo reflow/flicker _emUpdateRailRow(id); + _emRenderChain(); // current-phase highlight tracks live } } catch (_e) { /* transient — keep last */ } } @@ -307,6 +309,7 @@ async function selectEnrichmentWorker(id) { enrichmentManagerState.selected = id; enrichmentManagerState.breakdown = null; enrichmentManagerState.unmatched = null; + enrichmentManagerState.priority = ''; enrichmentManagerState.search = ''; enrichmentManagerState.page = 0; enrichmentManagerState.statusFilter = 'unmatched'; @@ -316,10 +319,29 @@ async function selectEnrichmentWorker(id) { // unmatched call returns entity_types; default to artist meanwhile). enrichmentManagerState.entityTab = 'artist'; renderEnrichmentPanel(); - await Promise.all([_emLoadBreakdown(id), _emLoadUnmatched()]); + await Promise.all([_emLoadBreakdown(id), _emLoadUnmatched(), _emLoadPriority(id)]); renderEnrichmentPanel(); } +async function _emLoadPriority(id) { + try { + const res = await fetch(`/api/enrichment/${id}/priority`); + enrichmentManagerState.priority = res.ok ? ((await res.json()).priority || '') : ''; + } catch (_e) { + enrichmentManagerState.priority = ''; + } +} + +// Which phase the worker is on right now, from current_item.type. +function _emCurrentPhase(status) { + const t = status && status.current_item && status.current_item.type; + if (!t) return ''; + if (t.indexOf('artist') === 0) return 'artist'; + if (t.indexOf('album') === 0) return 'album'; + if (t.indexOf('track') === 0) return 'track'; + return ''; +} + async function _emLoadBreakdown(id) { try { const res = await fetch(`/api/enrichment/${id}/breakdown`); @@ -367,7 +389,11 @@ function renderEnrichmentPanel() { panel.innerHTML = `
- +
+
@@ -375,11 +401,81 @@ function renderEnrichmentPanel() {
`; _emRenderPanelHeader(); + _emRenderChain(); _emRenderStats(); _emRenderUnmatchedControls(); _emRenderUnmatchedList(); } +// Processing-order strip: shows the artist→album→track chain, where the worker +// currently is, and lets the user pin one group to run first. +function _emRenderChain() { + const host = document.getElementById('em-chain'); + if (!host) return; + const id = enrichmentManagerState.selected; + const supported = (enrichmentManagerState.unmatched && enrichmentManagerState.unmatched.entity_types) + || (enrichmentManagerState.breakdown && Object.keys(enrichmentManagerState.breakdown)) + || ['artist']; + const status = enrichmentManagerState.statuses[id]; + const phase = _emCurrentPhase(status); + const pinned = enrichmentManagerState.priority; + const bd = enrichmentManagerState.breakdown || {}; + const glyphs = { artist: '🎤', album: '💿', track: '🎵' }; + + const steps = supported.map((e, i) => { + const d = bd[e] || {}; + const pending = (d.pending || 0) + (d.not_found || 0); + const isCurrent = phase === e; + const isPinned = pinned === e; + const isDone = bd[e] && pending === 0; + const cls = ['em-step', + isPinned ? 'em-step--pinned' : '', + isCurrent ? 'em-step--current' : '', + isDone ? 'em-step--done' : ''].filter(Boolean).join(' '); + const note = isPinned ? 'first' : (isCurrent ? 'now' : (isDone ? 'done' : `${pending.toLocaleString()} left`)); + const arrow = i < supported.length - 1 ? '' : ''; + return ` + ${arrow}`; + }).join(''); + + host.innerHTML = ` +
+ + ${pinned + ? `Pinned ${_emEntityLabel(pinned, true)} first · click again for auto` + : 'Click a group to process it first'} +
+
${steps}
`; +} + +async function setEnrichmentPriority(entity) { + const id = enrichmentManagerState.selected; + try { + const res = await fetch(`/api/enrichment/${id}/priority`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ entity: entity || 'none' }), + }); + const data = await res.json().catch(() => ({})); + if (res.ok && data.success) { + enrichmentManagerState.priority = data.priority || ''; + showToast(enrichmentManagerState.priority + ? `${_emWorkerById[id].name} will process ${_emEntityLabel(enrichmentManagerState.priority, true).toLowerCase()} first` + : `${_emWorkerById[id].name} back to automatic order`, 'success'); + _emRenderChain(); + } else { + showToast(data.error || 'Could not set processing order', 'error'); + } + } catch (_e) { + showToast('Error setting processing order', 'error'); + } +} + function _emRenderPanelHeader() { const host = document.getElementById('em-panel-header'); if (!host) return; @@ -393,14 +489,15 @@ function _emRenderPanelHeader() {
${_emIconHtml(id, 'lg')}
-
${_emEscape(worker.name)} enrichment
+
+ ${_emEscape(worker.name)} enrichment + +
-
- - +
`; @@ -415,14 +512,8 @@ function _emUpdateHeaderLive() { const pill = document.getElementById('em-ph-pill'); if (pill) { pill.className = `em-pill em-pill--${info.cls}`; pill.textContent = info.label; } - const metric = document.getElementById('em-ph-metric'); - if (metric) { - const pct = _emOverallPct(status); - metric.innerHTML = pct == null - ? '' - : `${pct}% - enriched`; - } + const hero = document.querySelector('#em-panel-header .em-hero'); + if (hero) hero.classList.toggle('em-hero--live', info.cls === 'running'); const cur = document.getElementById('em-ph-current'); if (cur) { @@ -497,6 +588,18 @@ function _emRenderStats() { `; }).join(''); + // Overall matched coverage across every entity type (relocated here from + // the hero per the refined-banner header). + const overall = document.getElementById('em-coverage-overall'); + if (overall) { + let m = 0, t = 0; + Object.values(bd).forEach(d => { m += d.matched || 0; t += d.total || 0; }); + const pct = t ? Math.round((m / t) * 100) : 0; + overall.innerHTML = t + ? `${pct}% matched · ${m.toLocaleString()} of ${t.toLocaleString()}` + : ''; + } + // Animate the segments in from 0 on the next frame (CSS transition does the rest). requestAnimationFrame(() => { host.querySelectorAll('.em-seg-fill').forEach(el => { @@ -746,11 +849,11 @@ function openEnrichmentMatch(service, entityType, entityId, anchorBtn) { const overlay = document.createElement('div'); overlay.id = 'enrichment-match-overlay'; - overlay.className = 'modal-overlay'; + overlay.className = 'modal-overlay em-overlay'; overlay.style.zIndex = '10010'; // above the manager modal overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; overlay.innerHTML = ` -
+

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

@@ -855,6 +958,7 @@ window.setEnrichmentStatusFilter = setEnrichmentStatusFilter; window.onEnrichmentSearchInput = onEnrichmentSearchInput; window.changeEnrichmentPage = changeEnrichmentPage; window.toggleEnrichmentWorker = toggleEnrichmentWorker; +window.setEnrichmentPriority = setEnrichmentPriority; window.retryEnrichmentItem = retryEnrichmentItem; window.retryAllFailedEnrichment = retryAllFailedEnrichment; window.openEnrichmentMatch = openEnrichmentMatch; diff --git a/webui/static/style.css b/webui/static/style.css index c5616812..22b2de9a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -65000,7 +65000,7 @@ body.em-scroll-lock { overflow: hidden; } from { opacity: 0; transform: translateY(14px) scale(0.97); } to { opacity: 1; transform: translateY(0) scale(1); } } -.enrichment-manager-modal.em-in { animation: em-pop-in 0.28s cubic-bezier(0.16, 1, 0.3, 1) both; } +.em-in { animation: em-pop-in 0.28s cubic-bezier(0.16, 1, 0.3, 1) both; } .em-overlay.em-closing .enrichment-manager-modal { transform: scale(0.98); opacity: 0; transition: all 0.16s ease; } .enrichment-manager-modal:focus { outline: none; } @@ -65083,14 +65083,54 @@ body.em-scroll-lock { overflow: hidden; } background: radial-gradient(circle, rgba(var(--em-accent-rgb, 99,102,241), 0.22), transparent 70%); pointer-events: none; } +/* Gentle 'alive' pulse while the worker is actively running. */ +.em-hero--live .em-hero-glow { animation: em-glow-pulse 3.2s ease-in-out infinite; } +@keyframes em-glow-pulse { 0%,100% { opacity: 0.55; } 50% { opacity: 1; } } +@media (prefers-reduced-motion: reduce) { .em-hero--live .em-hero-glow { animation: none; } } .em-hero .em-icon { width: 56px; height: 56px; } .em-hero .em-ph-titles { flex: 1 1 auto; min-width: 0; z-index: 1; } +.em-ph-nameline { display: flex; align-items: center; gap: 11px; flex-wrap: wrap; } .em-ph-name-sub { font-size: 14px; font-weight: 500; color: rgba(255,255,255,0.45); } -.em-hero-metric { display: flex; flex-direction: column; align-items: center; justify-content: center; line-height: 1; z-index: 1; padding: 0 6px; } -.em-hero-pct { font-size: 30px; font-weight: 800; color: #fff; } -.em-hero-pct-sym { font-size: 16px; opacity: 0.6; } -.em-hero-pct-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.6px; color: rgba(255,255,255,0.45); margin-top: 3px; } +.em-hero .em-ph-actions { z-index: 1; } +/* Processing-order chain strip */ +.em-chain { display: flex; flex-direction: column; gap: 9px; flex: 0 0 auto; } +.em-chain-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.em-chain-hint { font-size: 11.5px; color: rgba(255,255,255,0.4); } +.em-chain-hint strong { color: rgb(var(--em-accent-rgb, 129,140,248)); } +.em-chain-flow { display: flex; align-items: stretch; gap: 8px; flex-wrap: wrap; } +.em-step { + flex: 1 1 0; min-width: 120px; + display: flex; flex-direction: column; align-items: flex-start; gap: 2px; + padding: 10px 13px; border-radius: 12px; cursor: pointer; text-align: left; + background: rgba(255,255,255,0.035); border: 1.5px solid rgba(255,255,255,0.08); + transition: all 0.2s ease; position: relative; overflow: hidden; +} +.em-step:hover { background: rgba(255,255,255,0.06); border-color: rgba(var(--em-accent-rgb, 99,102,241), 0.4); transform: translateY(-1px); } +.em-step-glyph { font-size: 16px; } +.em-step-name { font-size: 13px; font-weight: 700; color: #fff; } +.em-step-note { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.5px; color: rgba(255,255,255,0.4); } +.em-step-arrow { align-self: center; color: rgba(255,255,255,0.25); font-size: 16px; } +/* Pinned = will run first */ +.em-step--pinned { + background: rgba(var(--em-accent-rgb, 99,102,241), 0.16); + border-color: rgba(var(--em-accent-rgb, 99,102,241), 0.6); +} +.em-step--pinned .em-step-note { color: rgb(var(--em-accent-rgb, 129,140,248)); font-weight: 800; } +.em-step--pinned::after { content: '📌'; position: absolute; top: 6px; right: 8px; font-size: 11px; } +/* Current = being processed now */ +.em-step--current .em-step-name::after { content: ''; display: inline-block; width: 7px; height: 7px; margin-left: 6px; border-radius: 50%; background: #4ade80; box-shadow: 0 0 8px #4ade80; animation: em-glow-pulse 1.6s ease-in-out infinite; vertical-align: middle; } +.em-step--current { border-color: rgba(74,222,128,0.45); } +.em-step--current .em-step-note { color: #4ade80; } +/* Done = nothing left */ +.em-step--done .em-step-glyph { opacity: 0.5; } +.em-step--done .em-step-note { color: rgba(74,222,128,0.7); } +@media (prefers-reduced-motion: reduce) { .em-step--current .em-step-name::after { animation: none; } } + +/* Section label as a row (coverage % relocated here from the hero) */ +.em-section-label--row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } +.em-coverage-overall { font-size: 12.5px; font-weight: 500; letter-spacing: 0; text-transform: none; color: rgba(255,255,255,0.55); } +.em-coverage-overall strong { color: rgb(var(--em-accent-rgb, 129,140,248)); font-weight: 800; } /* Accent-themed buttons / active states inside the panel */ .em-panel .em-btn { border-color: rgba(var(--em-accent-rgb, 99,102,241), 0.45); background: rgba(var(--em-accent-rgb, 99,102,241), 0.16); } .em-panel .em-btn:hover:not(:disabled) { background: rgba(var(--em-accent-rgb, 99,102,241), 0.3); } From 62ee1f852048e30e6b3bb5ee67cd57b3630d9a38 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 2 Jun 2026 19:50:57 -0700 Subject: [PATCH 4/9] Enrichment manager: 6 UX improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #1 Unconfigured-source banner: when a source has enabled=false, show a notice that browsing works but matches/retries won't run until it's set up. - #2 Rate-limit detail: when rate_limited, surface 'resumes in ~Xm' (from the status payload) instead of just a pill. - #3 Richer rows: unmatched items now show parent context — an album's artist, a track's album — via a parent expression in the query (+ test). - #4 Bulk select: per-row checkboxes + a bulk bar to retry several at once (capped concurrency), reusing the /retry item endpoint. - #5 Remember last worker: selection persists in localStorage and is restored on open; openEnrichmentManager(workerId) supports future deep-linking (bubbles left on their pause-on-click behaviour). - #6 Keyboard nav: ArrowUp/Down moves focus between rows; actions are native buttons (Enter/Space) and Escape closes — list isn't poll-refreshed so focus is stable. 53 enrichment tests green; JS syntax clean. --- core/enrichment/unmatched.py | 15 +++- tests/enrichment/test_unmatched.py | 10 +++ webui/static/enrichment-manager.js | 135 ++++++++++++++++++++++++++--- webui/static/style.css | 15 ++++ 4 files changed, 161 insertions(+), 14 deletions(-) diff --git a/core/enrichment/unmatched.py b/core/enrichment/unmatched.py index fdf9fbfb..1bcd5eae 100644 --- a/core/enrichment/unmatched.py +++ b/core/enrichment/unmatched.py @@ -34,21 +34,26 @@ SERVICE_ENTITY_SUPPORT = { 'amazon': ('artist', 'album', 'track'), } -# entity_type -> table / display-name column / image expression / optional join. +# entity_type -> table / display-name column / image expression / optional join +# / parent-context expression (the artist an album belongs to; the album a +# track belongs to) so the UI can disambiguate same-named items. # tracks carry no artwork column of their own, so we borrow the parent album's. _ENTITY_TABLE = { 'artist': { 'table': 'artists', 'name': 'name', - 'image': 'artists.thumb_url', 'join': '', + 'image': 'artists.thumb_url', 'join': '', 'parent': None, }, 'album': { 'table': 'albums', 'name': 'title', - 'image': 'albums.thumb_url', 'join': '', + 'image': 'albums.thumb_url', + 'join': 'LEFT JOIN artists par ON albums.artist_id = par.id', + 'parent': 'par.name', }, 'track': { 'table': 'tracks', 'name': 'title', 'image': 'al.thumb_url', 'join': 'LEFT JOIN albums al ON tracks.album_id = al.id', + 'parent': 'al.title', }, } @@ -134,9 +139,11 @@ def build_unmatched_query( where.append(f"{table}.{name_col} LIKE ?") params.append(f"%{query}%") + parent_expr = meta.get('parent') + parent_select = f"{parent_expr} AS parent" if parent_expr else "NULL AS parent" sql = ( f"SELECT {table}.id AS id, {table}.{name_col} AS name, " - f"{image_expr} AS image_url, {table}.{ms} AS status, " + f"{image_expr} AS image_url, {parent_select}, {table}.{ms} AS status, " f"{table}.{la} AS last_attempted " f"FROM {table} {join} " f"WHERE {' AND '.join(where)} " diff --git a/tests/enrichment/test_unmatched.py b/tests/enrichment/test_unmatched.py index d6c88f08..1f749568 100644 --- a/tests/enrichment/test_unmatched.py +++ b/tests/enrichment/test_unmatched.py @@ -150,6 +150,16 @@ def test_track_unmatched_borrows_album_artwork(db): assert res['items'][0]['image_url'] == 'http://img/evolve.jpg' +def test_unmatched_includes_parent_context(db): + # album's parent is its artist; track's parent is its album + album = db.get_enrichment_unmatched('spotify', 'album', status='not_found')['items'][0] + assert album['parent'] == 'Failed Dragons' + track = db.get_enrichment_unmatched('spotify', 'track', status='not_found')['items'][0] + assert track['parent'] == 'Evolve' + artist = db.get_enrichment_unmatched('spotify', 'artist', status='not_found')['items'][0] + assert artist['parent'] is None + + def test_db_raises_on_bad_input(db): with pytest.raises(UnmatchedQueryError): db.get_enrichment_unmatched('spotify', 'artist', status='bogus') diff --git a/webui/static/enrichment-manager.js b/webui/static/enrichment-manager.js index 563111f1..9eadc351 100644 --- a/webui/static/enrichment-manager.js +++ b/webui/static/enrichment-manager.js @@ -83,6 +83,7 @@ const enrichmentManagerState = { page: 0, pageSize: 25, unmatched: null, // { total, items } + selectedItems: new Set(), // ids checked for bulk retry pollTimer: null, loadToken: 0, // guards against out-of-order async renders }; @@ -120,7 +121,7 @@ function _emRelativeTime(value) { // ── Open / close ────────────────────────────────────────────────────────── -async function openEnrichmentManager() { +async function openEnrichmentManager(workerId) { let overlay = document.getElementById('enrichment-manager-overlay'); if (!overlay) { overlay = document.createElement('div'); @@ -157,11 +158,16 @@ async function openEnrichmentManager() { await refreshAllEnrichmentStatuses(); renderEnrichmentRail(); - // Default selection: first running worker, else first in the list. - const running = ENRICHMENT_WORKERS.find( - w => enrichmentManagerState.statuses[w.id]?.running - ); - selectEnrichmentWorker((running || ENRICHMENT_WORKERS[0]).id); + // Selection priority: explicit deep-link arg → last-viewed (remembered) → + // first running worker → first in the list. + let remembered = null; + try { remembered = localStorage.getItem('em-last-worker'); } catch (_e) { /* ignore */ } + const valid = (wid) => wid && _emWorkerById[wid]; + const running = ENRICHMENT_WORKERS.find(w => enrichmentManagerState.statuses[w.id]?.running); + const initial = (valid(workerId) && workerId) + || (valid(remembered) && remembered) + || (running || ENRICHMENT_WORKERS[0]).id; + selectEnrichmentWorker(initial); if (modal) setTimeout(() => modal.focus(), 60); if (enrichmentManagerState.pollTimer) clearInterval(enrichmentManagerState.pollTimer); @@ -307,6 +313,8 @@ function _emUpdateRailRow(id) { async function selectEnrichmentWorker(id) { enrichmentManagerState.selected = id; + try { localStorage.setItem('em-last-worker', id); } catch (_e) { /* ignore */ } + enrichmentManagerState.selectedItems.clear(); enrichmentManagerState.breakdown = null; enrichmentManagerState.unmatched = null; enrichmentManagerState.priority = ''; @@ -389,6 +397,7 @@ function renderEnrichmentPanel() { panel.innerHTML = `
+