Enrichment manager: 'process this group first' + refined hero header
Per-worker processing-order override + UI polish.
Feature — pin an entity group to enrich first:
- Each worker normally runs artist -> album -> track. A user can pin one
group (artist/album/track) to run first from the modal; the worker keeps
that group first until it's exhausted, then resumes the normal chain.
- core/worker_utils.py: read_enrichment_priority() (reads
<service>_enrichment_priority each loop, live) + priority_pending_item()
(shared, whitelisted query returning the worker's expected item shape;
Spotify/iTunes get album_individual/track_individual via a type map).
- A guarded ~6-line hook at the top of all 11 workers' _get_next_item.
CRITICAL: when nothing is pinned (default) the hook returns immediately,
so default enrichment order is byte-identical to before. Discogs (no track)
and Genius (no album) only honor their supported entities.
- core/enrichment/api.py: GET/POST /api/enrichment/<id>/priority (+ config_get
hook); POST validates the entity against what the source enriches.
- 14 new tests (helper shapes, exhaustion, route get/set/clear/validate).
UI:
- Refined hero header: identity + inline status left, single Pause right,
'now enriching' quiet sub-line; overall coverage % moved into the stats
section ('82% matched · 1,203 of 1,460'). Hero gently pulses while running.
- New processing-order strip: artist→album→track steps showing the live phase
(pulsing 'now'), pinned group ('first' + 📌), and done/remaining; click a
step to pin it, click again for auto.
py_compile clean across all 11 workers; 52 enrichment tests green.
This commit is contained in:
parent
fc9a9f1c90
commit
e53a157793
17 changed files with 527 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ logger = get_logger("enrichment.api")
|
|||
# Hooks the host wires up so the blueprint can persist pause state and
|
||||
# clean up auto-pause / yield-override sets without circular imports.
|
||||
_config_set: Optional[Callable[[str, Any], None]] = None
|
||||
_config_get: Optional[Callable[[str, Any], Any]] = None
|
||||
_auto_paused_discard: Optional[Callable[[str], None]] = None
|
||||
_yield_override_add: Optional[Callable[[str], None]] = None
|
||||
_db_getter: Optional[Callable[[], Any]] = None
|
||||
|
|
@ -43,6 +44,7 @@ _db_getter: Optional[Callable[[], Any]] = None
|
|||
def configure(
|
||||
*,
|
||||
config_set: Optional[Callable[[str, Any], None]] = None,
|
||||
config_get: Optional[Callable[[str, Any], Any]] = None,
|
||||
auto_paused_discard: Optional[Callable[[str], None]] = None,
|
||||
yield_override_add: Optional[Callable[[str], None]] = None,
|
||||
db_getter: Optional[Callable[[], Any]] = None,
|
||||
|
|
@ -51,10 +53,12 @@ def configure(
|
|||
|
||||
Each is optional — pass None for hosts that don't have a corresponding
|
||||
mechanism (e.g. tests). ``db_getter`` returns the live ``MusicDatabase``
|
||||
for the unmatched-browser routes.
|
||||
for the unmatched-browser routes; ``config_get``/``config_set`` read and
|
||||
write the per-worker priority override.
|
||||
"""
|
||||
global _config_set, _auto_paused_discard, _yield_override_add, _db_getter
|
||||
global _config_set, _config_get, _auto_paused_discard, _yield_override_add, _db_getter
|
||||
_config_set = config_set
|
||||
_config_get = config_get
|
||||
_auto_paused_discard = auto_paused_discard
|
||||
_yield_override_add = yield_override_add
|
||||
_db_getter = db_getter
|
||||
|
|
@ -250,4 +254,45 @@ def create_blueprint() -> Blueprint:
|
|||
return jsonify({'success': True, 'reset': count, 'service': service_id,
|
||||
'entity_type': entity_type, 'scope': scope}), 200
|
||||
|
||||
@bp.route('/api/enrichment/<service_id>/priority', methods=['GET'])
|
||||
def enrichment_get_priority(service_id: str):
|
||||
"""Return the pinned 'process this group first' entity for a worker."""
|
||||
if service_id not in SERVICE_ENTITY_SUPPORT:
|
||||
return jsonify({'error': f'Unknown enrichment service: {service_id}'}), 404
|
||||
priority = ''
|
||||
if _config_get is not None:
|
||||
try:
|
||||
priority = (_config_get(f'{service_id}_enrichment_priority', '') or '').strip().lower()
|
||||
except Exception as e:
|
||||
logger.debug("reading %s priority: %s", service_id, e)
|
||||
if priority not in supported_entity_types(service_id):
|
||||
priority = ''
|
||||
return jsonify({'service': service_id, 'priority': priority,
|
||||
'entity_types': list(supported_entity_types(service_id))}), 200
|
||||
|
||||
@bp.route('/api/enrichment/<service_id>/priority', methods=['POST'])
|
||||
def enrichment_set_priority(service_id: str):
|
||||
"""Pin (or clear) the entity type the worker should process first.
|
||||
|
||||
Body: ``entity`` = 'artist'|'album'|'track' to pin, or '' / null / 'none'
|
||||
to clear. Must be an entity type the source actually enriches.
|
||||
"""
|
||||
if service_id not in SERVICE_ENTITY_SUPPORT:
|
||||
return jsonify({'error': f'Unknown enrichment service: {service_id}'}), 404
|
||||
if _config_set is None:
|
||||
return jsonify({'error': 'config unavailable'}), 503
|
||||
data = request.get_json(silent=True) or {}
|
||||
entity = (data.get('entity') or '').strip().lower()
|
||||
if entity in ('none', 'clear'):
|
||||
entity = ''
|
||||
if entity and entity not in supported_entity_types(service_id):
|
||||
return jsonify({'error': f'{service_id} does not enrich {entity!r}'}), 400
|
||||
try:
|
||||
_config_set(f'{service_id}_enrichment_priority', entity)
|
||||
except Exception as e:
|
||||
logger.error("setting %s priority: %s", service_id, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
logger.info("%s enrichment priority set to %r via UI", service_id, entity or '(none)')
|
||||
return jsonify({'success': True, 'service': service_id, 'priority': entity}), 200
|
||||
|
||||
return bp
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -78,3 +78,67 @@ def set_album_api_track_count(cursor, album_id, count):
|
|||
logger.warning(
|
||||
"Failed to cache api_track_count for album %s: %s", album_id, e
|
||||
)
|
||||
|
||||
|
||||
# --- Enrichment "process this group first" override -----------------------
|
||||
# Each enrichment worker normally processes artist -> album -> track. A user
|
||||
# can pin one entity type to run first via the Manage Enrichment Workers modal;
|
||||
# the choice is stored in config as "<service>_enrichment_priority" and read
|
||||
# at the top of each worker's _get_next_item so it takes effect live. When the
|
||||
# pinned group is exhausted (or unset), the worker falls back to its normal
|
||||
# chain — so the default path is unchanged.
|
||||
|
||||
PRIORITY_ENTITIES = ('artist', 'album', 'track')
|
||||
|
||||
|
||||
def read_enrichment_priority(service: str) -> str:
|
||||
"""Return the pinned entity ('artist'|'album'|'track') for a worker, or ''.
|
||||
|
||||
Read every loop so the override applies without restarting the worker.
|
||||
Any error / unset / invalid value yields '' (no override)."""
|
||||
try:
|
||||
from config.settings import config_manager
|
||||
val = (config_manager.get(f'{service}_enrichment_priority', '') or '')
|
||||
val = str(val).strip().lower()
|
||||
return val if val in PRIORITY_ENTITIES else ''
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def priority_pending_item(cursor, service, entity, type_overrides=None):
|
||||
"""Return one pending (NULL match_status) item of `entity`, or None.
|
||||
|
||||
`service` is the column prefix (e.g. 'spotify' -> spotify_match_status) and
|
||||
MUST be a trusted worker-supplied literal (it is interpolated into SQL).
|
||||
`type_overrides` maps the canonical entity to the worker's dispatch 'type'
|
||||
string — Spotify/iTunes process individual items as 'album_individual' /
|
||||
'track_individual', the other workers use 'album' / 'track'. The returned
|
||||
dict matches the shape those workers already return from _get_next_item."""
|
||||
if not str(service).isalpha() or entity not in PRIORITY_ENTITIES:
|
||||
return None
|
||||
type_overrides = type_overrides or {}
|
||||
ms = f"{service}_match_status"
|
||||
|
||||
if entity == 'artist':
|
||||
cursor.execute(
|
||||
f"SELECT id, name FROM artists WHERE {ms} IS NULL AND id IS NOT NULL "
|
||||
f"ORDER BY id ASC LIMIT 1"
|
||||
)
|
||||
r = cursor.fetchone()
|
||||
return {'type': type_overrides.get('artist', 'artist'), 'id': r[0], 'name': r[1]} if r else None
|
||||
|
||||
if entity == 'album':
|
||||
cursor.execute(
|
||||
f"SELECT a.id, a.title, ar.name FROM albums a JOIN artists ar ON a.artist_id = ar.id "
|
||||
f"WHERE a.{ms} IS NULL AND a.id IS NOT NULL ORDER BY a.id ASC LIMIT 1"
|
||||
)
|
||||
r = cursor.fetchone()
|
||||
return {'type': type_overrides.get('album', 'album'), 'id': r[0], 'name': r[1], 'artist': r[2]} if r else None
|
||||
|
||||
# track
|
||||
cursor.execute(
|
||||
f"SELECT t.id, t.title, ar.name FROM tracks t JOIN artists ar ON t.artist_id = ar.id "
|
||||
f"WHERE t.{ms} IS NULL AND t.id IS NOT NULL ORDER BY t.id ASC LIMIT 1"
|
||||
)
|
||||
r = cursor.fetchone()
|
||||
return {'type': type_overrides.get('track', 'track'), 'id': r[0], 'name': r[1], 'artist': r[2]} if r else None
|
||||
|
|
|
|||
138
tests/enrichment/test_worker_priority.py
Normal file
138
tests/enrichment/test_worker_priority.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""Priority 'process this group first' helper for enrichment workers.
|
||||
|
||||
The shared helper returns one pending item of a chosen entity type in the
|
||||
shape the worker's dispatch already expects (with Spotify/iTunes mapped to
|
||||
their album_individual / track_individual types). Default path (no override)
|
||||
is exercised by the workers themselves and unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core.worker_utils import (
|
||||
PRIORITY_ENTITIES,
|
||||
priority_pending_item,
|
||||
read_enrichment_priority,
|
||||
)
|
||||
from database.music_database import MusicDatabase
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
d = MusicDatabase(str(tmp_path / 'prio.db'))
|
||||
conn = d._get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("INSERT INTO artists (id, name) VALUES ('a1', 'Pending Artist')") # NULL status
|
||||
cur.execute("INSERT INTO artists (id, name, spotify_match_status) VALUES ('a2','Done','matched')")
|
||||
cur.execute("INSERT INTO albums (id, artist_id, title) VALUES ('al1','a2','Pending Album')") # NULL status
|
||||
cur.execute("INSERT INTO tracks (id, album_id, artist_id, title) VALUES ('t1','al1','a2','Pending Track')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return d
|
||||
|
||||
|
||||
def _cur(db):
|
||||
return db._get_connection().cursor()
|
||||
|
||||
|
||||
def test_priority_artist_shape(db):
|
||||
item = priority_pending_item(_cur(db), 'spotify', 'artist')
|
||||
assert item == {'type': 'artist', 'id': 'a1', 'name': 'Pending Artist'}
|
||||
|
||||
|
||||
def test_priority_album_default_type(db):
|
||||
item = priority_pending_item(_cur(db), 'spotify', 'album')
|
||||
assert item['id'] == 'al1' and item['name'] == 'Pending Album' and item['artist'] == 'Done'
|
||||
assert item['type'] == 'album' # default type string
|
||||
|
||||
|
||||
def test_priority_album_type_override_for_spotify_itunes(db):
|
||||
item = priority_pending_item(_cur(db), 'spotify', 'album',
|
||||
{'album': 'album_individual', 'track': 'track_individual'})
|
||||
assert item['type'] == 'album_individual' # matches Spotify/iTunes dispatch
|
||||
|
||||
|
||||
def test_priority_track_shape(db):
|
||||
item = priority_pending_item(_cur(db), 'spotify', 'track')
|
||||
assert item['id'] == 't1' and item['type'] == 'track' and item['artist'] == 'Done'
|
||||
|
||||
|
||||
def test_priority_returns_none_when_group_exhausted(db):
|
||||
# No pending artists once a1 is matched -> None, so worker resumes its chain.
|
||||
conn = db._get_connection(); conn.execute("UPDATE artists SET spotify_match_status='matched' WHERE id='a1'"); conn.commit(); conn.close()
|
||||
assert priority_pending_item(_cur(db), 'spotify', 'artist') is None
|
||||
|
||||
|
||||
def test_priority_rejects_bad_entity_and_service(db):
|
||||
assert priority_pending_item(_cur(db), 'spotify', 'bogus') is None
|
||||
assert priority_pending_item(_cur(db), 'spot;drop', 'artist') is None # non-alpha service blocked
|
||||
|
||||
|
||||
def test_read_priority_unset_is_empty():
|
||||
# Unknown/unset key -> '' (no override). Uses the real config_manager.
|
||||
assert read_enrichment_priority('definitely_not_a_service') == ''
|
||||
|
||||
|
||||
def test_read_priority_roundtrip():
|
||||
from config.settings import config_manager
|
||||
key = 'spotify_enrichment_priority'
|
||||
old = config_manager.get(key, '')
|
||||
try:
|
||||
config_manager.set(key, 'album')
|
||||
assert read_enrichment_priority('spotify') == 'album'
|
||||
config_manager.set(key, 'bogus')
|
||||
assert read_enrichment_priority('spotify') == '' # invalid -> ignored
|
||||
finally:
|
||||
config_manager.set(key, old)
|
||||
|
||||
|
||||
def test_priority_entities_constant():
|
||||
assert PRIORITY_ENTITIES == ('artist', 'album', 'track')
|
||||
|
||||
|
||||
# --- priority GET/POST routes ---------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from flask import Flask
|
||||
from core.enrichment import api as enrichment_api
|
||||
store = {}
|
||||
enrichment_api.configure(
|
||||
config_get=lambda k, d=None: store.get(k, d),
|
||||
config_set=lambda k, v: store.__setitem__(k, v),
|
||||
db_getter=lambda: None,
|
||||
)
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(enrichment_api.create_blueprint())
|
||||
with app.test_client() as c:
|
||||
c._store = store
|
||||
yield c
|
||||
enrichment_api.configure(config_get=None, config_set=None, db_getter=None)
|
||||
|
||||
|
||||
def test_route_priority_get_default_empty(client):
|
||||
r = client.get('/api/enrichment/spotify/priority')
|
||||
assert r.status_code == 200
|
||||
assert r.get_json()['priority'] == ''
|
||||
|
||||
|
||||
def test_route_priority_set_and_get(client):
|
||||
assert client.post('/api/enrichment/spotify/priority', json={'entity': 'album'}).status_code == 200
|
||||
assert client._store['spotify_enrichment_priority'] == 'album'
|
||||
assert client.get('/api/enrichment/spotify/priority').get_json()['priority'] == 'album'
|
||||
|
||||
|
||||
def test_route_priority_clear(client):
|
||||
client.post('/api/enrichment/spotify/priority', json={'entity': 'album'})
|
||||
client.post('/api/enrichment/spotify/priority', json={'entity': 'none'})
|
||||
assert client.get('/api/enrichment/spotify/priority').get_json()['priority'] == ''
|
||||
|
||||
|
||||
def test_route_priority_rejects_unsupported_entity(client):
|
||||
# Genius has no albums -> 400
|
||||
assert client.post('/api/enrichment/genius/priority', json={'entity': 'album'}).status_code == 400
|
||||
|
||||
|
||||
def test_route_priority_unknown_service_404(client):
|
||||
assert client.get('/api/enrichment/bogus/priority').status_code == 404
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ const enrichmentManagerState = {
|
|||
selected: null,
|
||||
statuses: {}, // id -> last /status payload
|
||||
breakdown: null, // selected worker's breakdown
|
||||
priority: '', // pinned 'process first' entity for selected worker ('' = auto)
|
||||
entityTab: 'artist',
|
||||
statusFilter: 'unmatched',
|
||||
search: '',
|
||||
|
|
@ -229,6 +230,7 @@ async function _emPollSelected() {
|
|||
enrichmentManagerState.statuses[id] = await res.json();
|
||||
_emUpdateHeaderLive(); // in-place — no logo reflow/flicker
|
||||
_emUpdateRailRow(id);
|
||||
_emRenderChain(); // current-phase highlight tracks live
|
||||
}
|
||||
} catch (_e) { /* transient — keep last */ }
|
||||
}
|
||||
|
|
@ -307,6 +309,7 @@ async function selectEnrichmentWorker(id) {
|
|||
enrichmentManagerState.selected = id;
|
||||
enrichmentManagerState.breakdown = null;
|
||||
enrichmentManagerState.unmatched = null;
|
||||
enrichmentManagerState.priority = '';
|
||||
enrichmentManagerState.search = '';
|
||||
enrichmentManagerState.page = 0;
|
||||
enrichmentManagerState.statusFilter = 'unmatched';
|
||||
|
|
@ -316,10 +319,29 @@ async function selectEnrichmentWorker(id) {
|
|||
// unmatched call returns entity_types; default to artist meanwhile).
|
||||
enrichmentManagerState.entityTab = 'artist';
|
||||
renderEnrichmentPanel();
|
||||
await Promise.all([_emLoadBreakdown(id), _emLoadUnmatched()]);
|
||||
await Promise.all([_emLoadBreakdown(id), _emLoadUnmatched(), _emLoadPriority(id)]);
|
||||
renderEnrichmentPanel();
|
||||
}
|
||||
|
||||
async function _emLoadPriority(id) {
|
||||
try {
|
||||
const res = await fetch(`/api/enrichment/${id}/priority`);
|
||||
enrichmentManagerState.priority = res.ok ? ((await res.json()).priority || '') : '';
|
||||
} catch (_e) {
|
||||
enrichmentManagerState.priority = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Which phase the worker is on right now, from current_item.type.
|
||||
function _emCurrentPhase(status) {
|
||||
const t = status && status.current_item && status.current_item.type;
|
||||
if (!t) return '';
|
||||
if (t.indexOf('artist') === 0) return 'artist';
|
||||
if (t.indexOf('album') === 0) return 'album';
|
||||
if (t.indexOf('track') === 0) return 'track';
|
||||
return '';
|
||||
}
|
||||
|
||||
async function _emLoadBreakdown(id) {
|
||||
try {
|
||||
const res = await fetch(`/api/enrichment/${id}/breakdown`);
|
||||
|
|
@ -367,7 +389,11 @@ function renderEnrichmentPanel() {
|
|||
|
||||
panel.innerHTML = `
|
||||
<div class="em-panel-header" id="em-panel-header"></div>
|
||||
<div class="em-section-label">Enrichment coverage</div>
|
||||
<div class="em-chain" id="em-chain"></div>
|
||||
<div class="em-section-label em-section-label--row">
|
||||
<span>Enrichment coverage</span>
|
||||
<span class="em-coverage-overall" id="em-coverage-overall"></span>
|
||||
</div>
|
||||
<div class="em-stats" id="em-stats"></div>
|
||||
<div class="em-unmatched">
|
||||
<div class="em-unmatched-controls" id="em-unmatched-controls"></div>
|
||||
|
|
@ -375,11 +401,81 @@ function renderEnrichmentPanel() {
|
|||
<div class="em-pager" id="em-pager"></div>
|
||||
</div>`;
|
||||
_emRenderPanelHeader();
|
||||
_emRenderChain();
|
||||
_emRenderStats();
|
||||
_emRenderUnmatchedControls();
|
||||
_emRenderUnmatchedList();
|
||||
}
|
||||
|
||||
// Processing-order strip: shows the artist→album→track chain, where the worker
|
||||
// currently is, and lets the user pin one group to run first.
|
||||
function _emRenderChain() {
|
||||
const host = document.getElementById('em-chain');
|
||||
if (!host) return;
|
||||
const id = enrichmentManagerState.selected;
|
||||
const supported = (enrichmentManagerState.unmatched && enrichmentManagerState.unmatched.entity_types)
|
||||
|| (enrichmentManagerState.breakdown && Object.keys(enrichmentManagerState.breakdown))
|
||||
|| ['artist'];
|
||||
const status = enrichmentManagerState.statuses[id];
|
||||
const phase = _emCurrentPhase(status);
|
||||
const pinned = enrichmentManagerState.priority;
|
||||
const bd = enrichmentManagerState.breakdown || {};
|
||||
const glyphs = { artist: '🎤', album: '💿', track: '🎵' };
|
||||
|
||||
const steps = supported.map((e, i) => {
|
||||
const d = bd[e] || {};
|
||||
const pending = (d.pending || 0) + (d.not_found || 0);
|
||||
const isCurrent = phase === e;
|
||||
const isPinned = pinned === e;
|
||||
const isDone = bd[e] && pending === 0;
|
||||
const cls = ['em-step',
|
||||
isPinned ? 'em-step--pinned' : '',
|
||||
isCurrent ? 'em-step--current' : '',
|
||||
isDone ? 'em-step--done' : ''].filter(Boolean).join(' ');
|
||||
const note = isPinned ? 'first' : (isCurrent ? 'now' : (isDone ? 'done' : `${pending.toLocaleString()} left`));
|
||||
const arrow = i < supported.length - 1 ? '<span class="em-step-arrow">→</span>' : '';
|
||||
return `
|
||||
<button class="${cls}" title="Process ${_emEntityLabel(e, true).toLowerCase()} first"
|
||||
onclick="setEnrichmentPriority('${isPinned ? '' : e}')">
|
||||
<span class="em-step-glyph">${glyphs[e] || '•'}</span>
|
||||
<span class="em-step-name">${_emEntityLabel(e, true)}</span>
|
||||
<span class="em-step-note">${note}</span>
|
||||
</button>${arrow}`;
|
||||
}).join('');
|
||||
|
||||
host.innerHTML = `
|
||||
<div class="em-chain-head">
|
||||
<span class="em-section-label">Processing order</span>
|
||||
<span class="em-chain-hint">${pinned
|
||||
? `Pinned <strong>${_emEntityLabel(pinned, true)}</strong> first · click again for auto`
|
||||
: 'Click a group to process it first'}</span>
|
||||
</div>
|
||||
<div class="em-chain-flow">${steps}</div>`;
|
||||
}
|
||||
|
||||
async function setEnrichmentPriority(entity) {
|
||||
const id = enrichmentManagerState.selected;
|
||||
try {
|
||||
const res = await fetch(`/api/enrichment/${id}/priority`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ entity: entity || 'none' }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok && data.success) {
|
||||
enrichmentManagerState.priority = data.priority || '';
|
||||
showToast(enrichmentManagerState.priority
|
||||
? `${_emWorkerById[id].name} will process ${_emEntityLabel(enrichmentManagerState.priority, true).toLowerCase()} first`
|
||||
: `${_emWorkerById[id].name} back to automatic order`, 'success');
|
||||
_emRenderChain();
|
||||
} else {
|
||||
showToast(data.error || 'Could not set processing order', 'error');
|
||||
}
|
||||
} catch (_e) {
|
||||
showToast('Error setting processing order', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function _emRenderPanelHeader() {
|
||||
const host = document.getElementById('em-panel-header');
|
||||
if (!host) return;
|
||||
|
|
@ -393,14 +489,15 @@ function _emRenderPanelHeader() {
|
|||
<div class="em-hero-glow"></div>
|
||||
${_emIconHtml(id, 'lg')}
|
||||
<div class="em-ph-titles">
|
||||
<div class="em-ph-name">${_emEscape(worker.name)} <span class="em-ph-name-sub">enrichment</span></div>
|
||||
<div class="em-ph-nameline">
|
||||
<span class="em-ph-name">${_emEscape(worker.name)} <span class="em-ph-name-sub">enrichment</span></span>
|
||||
<span class="em-pill" id="em-ph-pill"></span>
|
||||
</div>
|
||||
<div class="em-ph-sub" id="em-ph-current"></div>
|
||||
</div>
|
||||
<div class="em-hero-metric" id="em-ph-metric"></div>
|
||||
<div class="em-ph-actions">
|
||||
<span class="em-pill" id="em-ph-pill"></span>
|
||||
<span id="em-ph-budget"></span>
|
||||
<span id="em-ph-errors"></span>
|
||||
<span id="em-ph-budget"></span>
|
||||
<button class="em-btn" id="em-ph-toggle" onclick="toggleEnrichmentWorker('${id}')"></button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
|
@ -415,14 +512,8 @@ function _emUpdateHeaderLive() {
|
|||
const pill = document.getElementById('em-ph-pill');
|
||||
if (pill) { pill.className = `em-pill em-pill--${info.cls}`; pill.textContent = info.label; }
|
||||
|
||||
const metric = document.getElementById('em-ph-metric');
|
||||
if (metric) {
|
||||
const pct = _emOverallPct(status);
|
||||
metric.innerHTML = pct == null
|
||||
? ''
|
||||
: `<span class="em-hero-pct">${pct}<span class="em-hero-pct-sym">%</span></span>
|
||||
<span class="em-hero-pct-label">enriched</span>`;
|
||||
}
|
||||
const hero = document.querySelector('#em-panel-header .em-hero');
|
||||
if (hero) hero.classList.toggle('em-hero--live', info.cls === 'running');
|
||||
|
||||
const cur = document.getElementById('em-ph-current');
|
||||
if (cur) {
|
||||
|
|
@ -497,6 +588,18 @@ function _emRenderStats() {
|
|||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Overall matched coverage across every entity type (relocated here from
|
||||
// the hero per the refined-banner header).
|
||||
const overall = document.getElementById('em-coverage-overall');
|
||||
if (overall) {
|
||||
let m = 0, t = 0;
|
||||
Object.values(bd).forEach(d => { m += d.matched || 0; t += d.total || 0; });
|
||||
const pct = t ? Math.round((m / t) * 100) : 0;
|
||||
overall.innerHTML = t
|
||||
? `<strong>${pct}%</strong> matched · ${m.toLocaleString()} of ${t.toLocaleString()}`
|
||||
: '';
|
||||
}
|
||||
|
||||
// Animate the segments in from 0 on the next frame (CSS transition does the rest).
|
||||
requestAnimationFrame(() => {
|
||||
host.querySelectorAll('.em-seg-fill').forEach(el => {
|
||||
|
|
@ -746,11 +849,11 @@ function openEnrichmentMatch(service, entityType, entityId, anchorBtn) {
|
|||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'enrichment-match-overlay';
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.className = 'modal-overlay em-overlay';
|
||||
overlay.style.zIndex = '10010'; // above the manager modal
|
||||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
|
||||
overlay.innerHTML = `
|
||||
<div class="enhanced-manual-match-modal">
|
||||
<div class="enhanced-manual-match-modal em-in">
|
||||
<div class="enhanced-bulk-modal-header">
|
||||
<h3>Match ${_emEntityLabel(entityType)} on ${_emEscape(_emWorkerById[service]?.name || service)}</h3>
|
||||
<button class="enhanced-bulk-modal-close">×</button>
|
||||
|
|
@ -855,6 +958,7 @@ window.setEnrichmentStatusFilter = setEnrichmentStatusFilter;
|
|||
window.onEnrichmentSearchInput = onEnrichmentSearchInput;
|
||||
window.changeEnrichmentPage = changeEnrichmentPage;
|
||||
window.toggleEnrichmentWorker = toggleEnrichmentWorker;
|
||||
window.setEnrichmentPriority = setEnrichmentPriority;
|
||||
window.retryEnrichmentItem = retryEnrichmentItem;
|
||||
window.retryAllFailedEnrichment = retryAllFailedEnrichment;
|
||||
window.openEnrichmentMatch = openEnrichmentMatch;
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue