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); }