From 93fb0821725d7be29b7b7ab9881403e635ad1f06 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:38:50 -0700 Subject: [PATCH] Add Failed MB Lookups manager + optimize cache performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New feature: Failed MusicBrainz Lookups management modal accessible from Cache Health. Browse all failed lookups with type filter tabs, search bar, pagination. Click any entry to search MusicBrainz and manually match β€” saves MBID at 100% confidence. Clear individual entries or bulk clear all. Backend: 4 new endpoints β€” failed-mb-lookups list, mb-entry delete, musicbrainz/search (artist/release/recording), mb-match save. Performance: Cache health stats consolidated from 11 queries to 4 using CASE expressions. Added partial index on musicbrainz_cache for failed lookups. Dashboard cache stats now poll every 15s instead of single fire-and-forget fetch. Failed MB type counts cached on frontend, only re-fetched after mutations. Also includes: library reorganize now moves cover.jpg via post-pass sidecar sweep, and changelog updates. --- core/metadata_cache.py | 103 ++++++------ database/music_database.py | 2 + web_server.py | 245 ++++++++++++++++++++++++++-- webui/static/script.js | 322 ++++++++++++++++++++++++++++++++++++- webui/static/style.css | 115 +++++++++++++ 5 files changed, 712 insertions(+), 75 deletions(-) diff --git a/core/metadata_cache.py b/core/metadata_cache.py index dc6a86a6..5f468124 100644 --- a/core/metadata_cache.py +++ b/core/metadata_cache.py @@ -685,7 +685,8 @@ class MetadataCache: return 0 def get_health_stats(self) -> dict: - """Return cache health statistics for the repair dashboard.""" + """Return cache health statistics for the repair dashboard. + Consolidated into fewer queries for faster modal open.""" try: db = self._get_db() conn = db._get_connection() @@ -693,70 +694,60 @@ class MetadataCache: cursor = conn.cursor() stats = {} - # Total counts - cursor.execute("SELECT COUNT(*) FROM metadata_cache_entities") - stats['total_entities'] = cursor.fetchone()[0] + # Query 1: Main entity stats in one pass (was 7 separate queries) + junk_names = "', '".join(self._JUNK_NAMES - {''}) + cursor.execute(f""" + SELECT + COUNT(*) as total, + SUM(CASE WHEN (name IS NULL OR TRIM(name) = '' OR LOWER(TRIM(name)) IN ('{junk_names}')) + AND entity_id NOT LIKE '%\\_features' ESCAPE '\\' + AND entity_id NOT LIKE '%\\_tracks' ESCAPE '\\' + THEN 1 ELSE 0 END) as junk, + SUM(CASE WHEN julianday('now') - julianday(updated_at) > ttl_days - 1 THEN 1 ELSE 0 END) as exp_24h, + SUM(CASE WHEN julianday('now') - julianday(updated_at) > ttl_days - 7 THEN 1 ELSE 0 END) as exp_7d, + ROUND(AVG(julianday('now') - julianday(updated_at)), 1) as avg_age, + COALESCE(SUM(access_count), 0) as total_hits + FROM metadata_cache_entities + """) + row = cursor.fetchone() + stats['total_entities'] = row[0] or 0 + stats['junk_entities'] = row[1] or 0 + stats['expiring_24h'] = row[2] or 0 + stats['expiring_7d'] = row[3] or 0 + stats['avg_age_days'] = row[4] or 0 + stats['total_access_hits'] = row[5] or 0 + # Query 2: Group counts (source + type in one pass) + cursor.execute(""" + SELECT source, entity_type, COUNT(*) as cnt + FROM metadata_cache_entities GROUP BY source, entity_type + """) + by_source = {} + by_type = {} + for r in cursor.fetchall(): + by_source[r[0]] = by_source.get(r[0], 0) + r[2] + by_type[r[1]] = by_type.get(r[1], 0) + r[2] + stats['by_source'] = by_source + stats['by_type'] = by_type + + # Query 3: Search count cursor.execute("SELECT COUNT(*) FROM metadata_cache_searches") stats['total_searches'] = cursor.fetchone()[0] - # Junk entity count - junk_names = "', '".join(self._JUNK_NAMES - {''}) - cursor.execute(f""" - SELECT COUNT(*) FROM metadata_cache_entities - WHERE (name IS NULL OR TRIM(name) = '' OR LOWER(TRIM(name)) IN ('{junk_names}')) - AND entity_id NOT LIKE '%\\_features' ESCAPE '\\' - AND entity_id NOT LIKE '%\\_tracks' ESCAPE '\\' - """) - stats['junk_entities'] = cursor.fetchone()[0] - - # By source - cursor.execute(""" - SELECT source, COUNT(*) FROM metadata_cache_entities GROUP BY source - """) - stats['by_source'] = {row[0]: row[1] for row in cursor.fetchall()} - - # By entity type - cursor.execute(""" - SELECT entity_type, COUNT(*) FROM metadata_cache_entities GROUP BY entity_type - """) - stats['by_type'] = {row[0]: row[1] for row in cursor.fetchall()} - - # Expiring soon - cursor.execute(""" - SELECT COUNT(*) FROM metadata_cache_entities - WHERE julianday('now') - julianday(updated_at) > ttl_days - 1 - """) - stats['expiring_24h'] = cursor.fetchone()[0] - - cursor.execute(""" - SELECT COUNT(*) FROM metadata_cache_entities - WHERE julianday('now') - julianday(updated_at) > ttl_days - 7 - """) - stats['expiring_7d'] = cursor.fetchone()[0] - - # Average age - cursor.execute(""" - SELECT AVG(julianday('now') - julianday(updated_at)) FROM metadata_cache_entities - """) - avg = cursor.fetchone()[0] - stats['avg_age_days'] = round(avg, 1) if avg else 0 - - # MusicBrainz stats + # Query 4: MusicBrainz stats (separate table) try: - cursor.execute("SELECT COUNT(*) FROM musicbrainz_cache") - stats['total_musicbrainz'] = cursor.fetchone()[0] - cursor.execute("SELECT COUNT(*) FROM musicbrainz_cache WHERE musicbrainz_id IS NULL") - stats['stale_mb_nulls'] = cursor.fetchone()[0] + cursor.execute(""" + SELECT COUNT(*) as total, + SUM(CASE WHEN musicbrainz_id IS NULL THEN 1 ELSE 0 END) as failed + FROM musicbrainz_cache + """) + mb = cursor.fetchone() + stats['total_musicbrainz'] = mb[0] or 0 + stats['stale_mb_nulls'] = mb[1] or 0 except Exception: stats['total_musicbrainz'] = 0 stats['stale_mb_nulls'] = 0 - # Total access hits - cursor.execute("SELECT SUM(access_count) FROM metadata_cache_entities") - hits = cursor.fetchone()[0] - stats['total_access_hits'] = hits or 0 - return stats finally: conn.close() diff --git a/database/music_database.py b/database/music_database.py index 4b4faf67..c2df3f44 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -1492,6 +1492,8 @@ class MusicDatabase: cursor.execute("CREATE INDEX IF NOT EXISTS idx_mb_cache_entity ON musicbrainz_cache (entity_type, entity_name)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_mb_cache_mbid ON musicbrainz_cache (musicbrainz_id)") + # Partial index for failed lookups β€” speeds up the management modal queries + cursor.execute("CREATE INDEX IF NOT EXISTS idx_mb_cache_failed ON musicbrainz_cache (entity_type, last_updated) WHERE musicbrainz_id IS NULL") if columns_added: logger.info("πŸŽ‰ MusicBrainz migration completed successfully") diff --git a/web_server.py b/web_server.py index 0e86d28e..6a06a86b 100644 --- a/web_server.py +++ b/web_server.py @@ -11929,6 +11929,7 @@ def reorganize_album_files(album_id): track['_disc_number'] = disc_number # Now do the actual moves + moved_dirs = {} # src_dir β†’ dest_dir for post-pass sidecar sweep for track in tracks: resolved = track.get('_resolved') new_full = track.get('_new_full') @@ -11965,13 +11966,15 @@ def reorganize_album_files(album_id): # Move file _safe_move_file(resolved, new_full) - # Move sidecar files (LRC, cover.jpg, etc.) + # Track sourceβ†’dest directory mapping for post-pass sidecar sweep src_dir = os.path.dirname(resolved) dest_dir = os.path.dirname(new_full) + if src_dir not in moved_dirs: + moved_dirs[src_dir] = dest_dir + + # Move track-level sidecars (same filename stem as audio) src_stem = os.path.splitext(os.path.basename(resolved))[0] new_stem = os.path.splitext(os.path.basename(new_full))[0] - - # Track-level sidecars (same filename stem as audio) for sidecar_ext in ('.lrc', '.nfo', '.txt', '.cue'): sidecar_src = os.path.join(src_dir, src_stem + sidecar_ext) if os.path.isfile(sidecar_src): @@ -11981,17 +11984,6 @@ def reorganize_album_files(album_id): except Exception: pass - # Album-level sidecars (cover art, folder images) - for album_sidecar in ('cover.jpg', 'cover.jpeg', 'cover.png', 'folder.jpg', 'folder.png', 'front.jpg', 'front.png', 'album.jpg', 'album.png'): - sidecar_src = os.path.join(src_dir, album_sidecar) - if os.path.isfile(sidecar_src): - sidecar_dst = os.path.join(dest_dir, album_sidecar) - if not os.path.exists(sidecar_dst): - try: - shutil.move(sidecar_src, sidecar_dst) - except Exception: - pass - # Update DB file_path bg_cursor = bg_conn.cursor() bg_cursor.execute( @@ -12000,9 +11992,6 @@ def reorganize_album_files(album_id): ) bg_conn.commit() - # Clean up empty directories left behind - _cleanup_empty_directories(transfer_dir, resolved) - with _reorganize_lock: _reorganize_state['moved'] += 1 _reorganize_state['processed'] += 1 @@ -12018,6 +12007,39 @@ def reorganize_album_files(album_id): 'error': str(move_err) }) + # Post-pass: sweep source directories for leftover album-level sidecars. + # The per-track loop can't reliably move cover.jpg because multiple tracks + # share the same source dir β€” the first track's move may fail silently, + # or the file may be in a parent directory. This single pass catches them all. + _album_sidecars = ('cover.jpg', 'cover.jpeg', 'cover.png', 'folder.jpg', + 'folder.png', 'front.jpg', 'front.png', 'album.jpg', 'album.png') + for src_dir, dest_dir in moved_dirs.items(): + if not os.path.isdir(src_dir): + continue + # Check if any audio files remain (don't steal sidecars from a dir that still has tracks) + audio_exts = {'.flac', '.mp3', '.m4a', '.ogg', '.opus', '.wav', '.aac', '.wma'} + has_audio = any(os.path.splitext(f)[1].lower() in audio_exts + for f in os.listdir(src_dir) if os.path.isfile(os.path.join(src_dir, f))) + if has_audio: + continue + for sidecar in _album_sidecars: + sidecar_src = os.path.join(src_dir, sidecar) + if os.path.isfile(sidecar_src): + sidecar_dst = os.path.join(dest_dir, sidecar) + if not os.path.exists(sidecar_dst): + try: + shutil.move(sidecar_src, sidecar_dst) + print(f"πŸ“· [Reorganize] Moved {sidecar} to {dest_dir}") + except Exception as sc_err: + print(f"⚠️ [Reorganize] Failed to move {sidecar}: {sc_err}") + + # Clean up empty directories left behind (after sidecars moved) + for src_dir in moved_dirs: + try: + _cleanup_empty_directories(transfer_dir, os.path.join(src_dir, '_')) + except Exception: + pass + except Exception as e: logger.error(f"Reorganize background error: {e}") finally: @@ -23707,6 +23729,195 @@ def metadata_cache_clear_musicbrainz(): logger.error(f"Error clearing MusicBrainz cache: {e}") return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/metadata-cache/failed-mb-lookups', methods=['GET']) +def metadata_cache_failed_mb_lookups(): + """Get all failed MusicBrainz lookups with pagination and filtering.""" + try: + entity_type = request.args.get('entity_type', '') + search = request.args.get('search', '').strip() + page = int(request.args.get('page', 1)) + limit = int(request.args.get('limit', 50)) + offset = (page - 1) * limit + # Only fetch type_counts on first load (page 1, no filters) β€” frontend caches them + include_counts = request.args.get('counts', '').lower() == 'true' + + database = get_database() + conn = database._get_connection() + try: + cursor = conn.cursor() + where_parts = ["musicbrainz_id IS NULL"] + params = [] + if entity_type: + where_parts.append("entity_type = ?") + params.append(entity_type) + if search: + where_parts.append("(entity_name LIKE ? COLLATE NOCASE OR artist_name LIKE ? COLLATE NOCASE)") + params.extend([f"%{search}%", f"%{search}%"]) + + where_clause = f"WHERE {' AND '.join(where_parts)}" + + # Single query: fetch items + use SQL window for total count + cursor.execute(f""" + SELECT id, entity_type, entity_name, artist_name, match_confidence, last_updated, + COUNT(*) OVER() as _total + FROM musicbrainz_cache {where_clause} + ORDER BY last_updated DESC + LIMIT ? OFFSET ? + """, params + [limit, offset]) + + rows = cursor.fetchall() + total = rows[0]['_total'] if rows else 0 + items = [{ + 'id': r['id'], + 'entity_type': r['entity_type'], + 'entity_name': r['entity_name'], + 'artist_name': r['artist_name'] or '', + 'confidence': r['match_confidence'] or 0, + 'last_updated': r['last_updated'] or '', + } for r in rows] + + result = {'items': items, 'total': total, 'page': page} + + # Type counts only when requested (avoids full table scan on every tab switch) + if include_counts: + cursor.execute(""" + SELECT entity_type, COUNT(*) as cnt + FROM musicbrainz_cache WHERE musicbrainz_id IS NULL + GROUP BY entity_type + """) + result['type_counts'] = {row['entity_type']: row['cnt'] for row in cursor.fetchall()} + + return jsonify(result) + finally: + conn.close() + except Exception as e: + logger.error(f"Error getting failed MB lookups: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/metadata-cache/mb-entry/', methods=['DELETE']) +def metadata_cache_delete_mb_entry(entry_id): + """Delete a single MusicBrainz cache entry by ID.""" + try: + database = get_database() + conn = database._get_connection() + try: + cursor = conn.cursor() + cursor.execute("DELETE FROM musicbrainz_cache WHERE id = ?", (entry_id,)) + conn.commit() + return jsonify({"success": True, "deleted": cursor.rowcount}) + finally: + conn.close() + except Exception as e: + logger.error(f"Error deleting MB cache entry: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/musicbrainz/search', methods=['GET']) +def musicbrainz_search_api(): + """Search MusicBrainz for manual matching. Returns raw results.""" + try: + entity_type = request.args.get('type', 'artist') # artist, release, recording + query = request.args.get('q', '').strip() + artist = request.args.get('artist', '').strip() + limit = min(int(request.args.get('limit', 10)), 20) + + if not query: + return jsonify({"error": "Missing query parameter 'q'"}), 400 + + mb_svc = mb_worker.mb_service if mb_worker else None + if not mb_svc: + return jsonify({"error": "MusicBrainz service not available"}), 503 + + mb_client = mb_svc.mb_client + results = [] + + if entity_type == 'artist': + raw = mb_client.search_artist(query, limit=limit) + for r in raw: + results.append({ + 'mbid': r.get('id', ''), + 'name': r.get('name', ''), + 'disambiguation': r.get('disambiguation', ''), + 'score': r.get('score', 0), + 'type': r.get('type', ''), + 'country': r.get('country', ''), + }) + elif entity_type == 'release': + raw = mb_client.search_release(query, artist_name=artist or None, limit=limit) + for r in raw: + artist_credit = ', '.join(a.get('name', '') for a in r.get('artist-credit', []) if isinstance(a, dict)) + results.append({ + 'mbid': r.get('id', ''), + 'name': r.get('title', ''), + 'artist': artist_credit, + 'disambiguation': r.get('disambiguation', ''), + 'score': r.get('score', 0), + 'date': r.get('date', ''), + 'country': r.get('country', ''), + 'track_count': r.get('track-count', 0), + }) + elif entity_type == 'recording': + raw = mb_client.search_recording(query, artist_name=artist or None, limit=limit) + for r in raw: + artist_credit = ', '.join(a.get('name', '') for a in r.get('artist-credit', []) if isinstance(a, dict)) + releases = r.get('releases', []) + first_release = releases[0].get('title', '') if releases else '' + results.append({ + 'mbid': r.get('id', ''), + 'name': r.get('title', ''), + 'artist': artist_credit, + 'disambiguation': r.get('disambiguation', ''), + 'score': r.get('score', 0), + 'album': first_release, + 'length': r.get('length', 0), + }) + else: + return jsonify({"error": f"Unknown entity type: {entity_type}"}), 400 + + return jsonify({"results": results, "total": len(results)}) + except Exception as e: + logger.error(f"Error searching MusicBrainz: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/metadata-cache/mb-match', methods=['POST']) +def metadata_cache_save_mb_match(): + """Save a manual MusicBrainz match for a failed lookup.""" + try: + data = request.get_json() + entry_id = data.get('entry_id') + mbid = data.get('mbid', '').strip() + mb_name = data.get('mb_name', '').strip() + + if not entry_id or not mbid: + return jsonify({"success": False, "error": "Missing entry_id or mbid"}), 400 + + database = get_database() + conn = database._get_connection() + try: + cursor = conn.cursor() + # Update the failed entry with the user-selected MBID + cursor.execute(""" + UPDATE musicbrainz_cache + SET musicbrainz_id = ?, match_confidence = 100, metadata_json = ?, last_updated = CURRENT_TIMESTAMP + WHERE id = ? + """, (mbid, json.dumps({'name': mb_name, 'manual_match': True}), entry_id)) + conn.commit() + + if cursor.rowcount == 0: + return jsonify({"success": False, "error": "Entry not found"}), 404 + + logger.info(f"Manual MB match: entry {entry_id} β†’ {mbid} ({mb_name})") + return jsonify({"success": True}) + finally: + conn.close() + except Exception as e: + logger.error(f"Error saving MB match: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + # =============================== # == QUALITY SCANNER == # =============================== diff --git a/webui/static/script.js b/webui/static/script.js index 5344677b..46e76d72 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -24087,8 +24087,9 @@ async function loadDashboardData() { // Initial load of discovery pool stats for the tool card loadDiscoveryPoolStats(); - // Initial load of metadata cache stats + // Initial load of metadata cache stats + periodic refresh loadMetadataCacheStats(); + setInterval(loadMetadataCacheStats, 15000); // Initial load of wishlist count await updateWishlistCount(); @@ -58920,9 +58921,10 @@ async function openCacheHealthModal() {
${s.junk_entities}
Junk Entries
-
+
0 ? 'onclick="openFailedMBLookupsModal()"' : ''}>
${s.stale_mb_nulls}
Failed MB Lookups
+ ${s.stale_mb_nulls > 0 ? '
Manage β€Ί
' : ''}
@@ -58969,6 +58971,322 @@ async function openCacheHealthModal() { } } +// ── Failed MB Lookups Management Modal ── +let _failedMBState = { items: [], total: 0, page: 1, filter: '', typeFilter: '', typeCounts: {} }; + +async function openFailedMBLookupsModal() { + if (document.getElementById('failed-mb-modal-overlay')) return; + + const overlay = document.createElement('div'); + overlay.id = 'failed-mb-modal-overlay'; + overlay.className = 'modal-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + + overlay.innerHTML = ` +
+
+
+

Failed MusicBrainz Lookups

+

Tracks, albums, and artists that couldn't be matched automatically

+
+ +
+
+
+
+ + +
+
+
+
Loading...
+
+ +
+ `; + document.body.appendChild(overlay); + + // Search debounce + const searchInput = overlay.querySelector('#failed-mb-search'); + let searchTimer = null; + searchInput.addEventListener('input', () => { + clearTimeout(searchTimer); + searchTimer = setTimeout(() => { + _failedMBState.filter = searchInput.value; + _failedMBState.page = 1; + _loadFailedMBLookups(); + }, 300); + }); + + _failedMBState = { items: [], total: 0, page: 1, filter: '', typeFilter: '', typeCounts: {} }; + await _loadFailedMBLookups(); +} + +async function _loadFailedMBLookups() { + const body = document.getElementById('failed-mb-body'); + if (!body) return; + + // Only fetch type_counts on first load β€” cache them for tab switches + const needCounts = Object.keys(_failedMBState.typeCounts).length === 0; + const params = new URLSearchParams({ + page: _failedMBState.page, + limit: 50, + }); + if (needCounts) params.set('counts', 'true'); + if (_failedMBState.typeFilter) params.set('entity_type', _failedMBState.typeFilter); + if (_failedMBState.filter) params.set('search', _failedMBState.filter); + + try { + const resp = await fetch(`/api/metadata-cache/failed-mb-lookups?${params}`); + if (!resp.ok) throw new Error('Failed to load'); + const data = await resp.json(); + _failedMBState.items = data.items; + _failedMBState.total = data.total; + if (data.type_counts) _failedMBState.typeCounts = data.type_counts; + + // Render type filter tabs + const tabsEl = document.getElementById('failed-mb-tabs'); + if (tabsEl) { + const allCount = Object.values(_failedMBState.typeCounts).reduce((a, b) => a + b, 0); + let tabsHTML = ``; + const typeLabels = { artist: 'Artists', release: 'Albums', recording: 'Tracks' }; + for (const [type, count] of Object.entries(_failedMBState.typeCounts)) { + tabsHTML += ``; + } + tabsEl.innerHTML = tabsHTML; + } + + // Render items + if (data.items.length === 0) { + body.innerHTML = `
${_failedMBState.filter ? 'No matches for your search' : 'No failed lookups β€” cache is clean!'}
`; + } else { + const typeIcons = { artist: '🎀', release: 'πŸ’Ώ', recording: '🎡' }; + body.innerHTML = data.items.map(item => ` +
+
${typeIcons[item.entity_type] || '?'}
+
+
${escapeHtml(item.entity_name)}
+ ${item.artist_name ? `
${escapeHtml(item.artist_name)}
` : ''} +
+
+ ${item.entity_type} + ${item.last_updated ? new Date(item.last_updated).toLocaleDateString() : ''} +
+
+ + +
+
+ `).join(''); + } + + // Pagination footer + const footer = document.getElementById('failed-mb-footer'); + if (footer) { + const totalPages = Math.ceil(data.total / 50); + footer.innerHTML = totalPages > 1 ? ` +
+ + Page ${_failedMBState.page} of ${totalPages} (${data.total} total) + +
+ ` : `
${data.total} entries
`; + } + } catch (err) { + body.innerHTML = '
Failed to load data
'; + } +} + +function _failedMBSetType(type) { + _failedMBState.typeFilter = type; + _failedMBState.page = 1; + _loadFailedMBLookups(); +} + +function _failedMBPage(page) { + _failedMBState.page = page; + _loadFailedMBLookups(); +} + +async function _failedMBDelete(entryId) { + try { + const resp = await fetch(`/api/metadata-cache/mb-entry/${entryId}`, { method: 'DELETE' }); + if (resp.ok) { + const row = document.querySelector(`.failed-mb-item[data-id="${entryId}"]`); + if (row) { + row.style.opacity = '0'; + setTimeout(() => { + row.remove(); + _failedMBState.typeCounts = {}; // Force refresh counts + _loadFailedMBLookups(); + }, 200); + } + } + } catch (err) { + showToast('Failed to delete entry', 'error'); + } +} + +async function _failedMBClearAll() { + if (!confirm(`Clear all ${_failedMBState.total} failed lookups? They will be retried on next enrichment run.`)) return; + try { + const resp = await fetch('/api/metadata-cache/clear-musicbrainz?failed_only=true', { method: 'DELETE' }); + const data = await resp.json(); + if (data.success) { + showToast(`Cleared ${data.cleared} failed lookups`, 'success'); + _failedMBState.page = 1; + _failedMBState.typeCounts = {}; // Force refresh counts + _loadFailedMBLookups(); + } + } catch (err) { + showToast('Failed to clear lookups', 'error'); + } +} + +// ── MusicBrainz Search Sub-Modal ── +async function _failedMBSearch(entryId, entityType, entityName, artistName) { + // Remove existing search modal if any + const existing = document.getElementById('mb-search-modal-overlay'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'mb-search-modal-overlay'; + overlay.className = 'modal-overlay'; + overlay.style.zIndex = '10001'; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + + const typeLabels = { artist: 'Artist', release: 'Album', recording: 'Track' }; + overlay.innerHTML = ` +
+
+
+

Search MusicBrainz

+

Find a match for: ${escapeHtml(entityName)}${artistName ? ` by ${escapeHtml(artistName)}` : ''}

+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
Enter a search query and click Search
+
+
+ `; + document.body.appendChild(overlay); + + // Toggle artist row visibility based on type + const typeSelect = overlay.querySelector('#mb-search-type'); + typeSelect.addEventListener('change', () => { + const artistRow = overlay.querySelector('#mb-search-artist-row'); + artistRow.style.display = typeSelect.value === 'artist' ? 'none' : ''; + }); + + // Enter to search + overlay.querySelectorAll('.mb-search-input').forEach(input => { + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') _runMBSearch(entryId); }); + }); + + // Auto-search on open + _runMBSearch(entryId); +} + +async function _runMBSearch(entryId) { + const resultsEl = document.getElementById('mb-search-results'); + const typeEl = document.getElementById('mb-search-type'); + const queryEl = document.getElementById('mb-search-query'); + const artistEl = document.getElementById('mb-search-artist'); + const goBtn = document.getElementById('mb-search-go-btn'); + if (!resultsEl || !queryEl) return; + + const type = typeEl.value; + const query = queryEl.value.trim(); + const artist = artistEl ? artistEl.value.trim() : ''; + if (!query) return; + + goBtn.disabled = true; + goBtn.textContent = 'Searching...'; + resultsEl.innerHTML = '
Searching MusicBrainz...
'; + + try { + const params = new URLSearchParams({ type, q: query, limit: 10 }); + if (artist && type !== 'artist') params.set('artist', artist); + + const resp = await fetch(`/api/musicbrainz/search?${params}`); + if (!resp.ok) throw new Error('Search failed'); + const data = await resp.json(); + + if (!data.results || data.results.length === 0) { + resultsEl.innerHTML = '
No results found. Try adjusting your search.
'; + return; + } + + resultsEl.innerHTML = data.results.map((r, i) => { + const scoreColor = r.score >= 90 ? '#4ade80' : r.score >= 70 ? '#fbbf24' : '#f87171'; + let detail = ''; + if (type === 'release') detail = [r.artist, r.date, r.track_count ? `${r.track_count} tracks` : ''].filter(Boolean).join(' Β· '); + else if (type === 'recording') detail = [r.artist, r.album].filter(Boolean).join(' Β· '); + else detail = [r.type, r.country].filter(Boolean).join(' Β· '); + + return ` +
+
${r.score}%
+
+
${escapeHtml(r.name)}
+ ${r.disambiguation ? `
${escapeHtml(r.disambiguation)}
` : ''} + ${detail ? `
${escapeHtml(detail)}
` : ''} +
+
${r.mbid.substring(0, 8)}...
+
+ `; + }).join(''); + } catch (err) { + resultsEl.innerHTML = `
Search error: ${err.message}
`; + } finally { + goBtn.disabled = false; + goBtn.textContent = 'Search'; + } +} + +async function _selectMBMatch(entryId, mbid, mbName) { + try { + const resp = await fetch('/api/metadata-cache/mb-match', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ entry_id: entryId, mbid, mb_name: mbName }) + }); + const data = await resp.json(); + if (data.success) { + showToast(`Matched to: ${mbName}`, 'success'); + // Close search modal, refresh list with fresh counts + const searchOverlay = document.getElementById('mb-search-modal-overlay'); + if (searchOverlay) searchOverlay.remove(); + _failedMBState.typeCounts = {}; + _loadFailedMBLookups(); + } else { + showToast(data.error || 'Failed to save match', 'error'); + } + } catch (err) { + showToast('Failed to save match', 'error'); + } +} + function filterFindingsByJob(jobId) { const jobFilter = document.getElementById('repair-findings-job-filter'); if (!jobFilter) return; diff --git a/webui/static/style.css b/webui/static/style.css index 4a56bbf7..d1621c5d 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -48360,6 +48360,121 @@ tr.tag-diff-same { .cache-health-metric-label { font-size: 13px; color: rgba(255,255,255,0.4); } .cache-health-metric-value { font-size: 13px; font-weight: 600; color: rgba(255,255,255,0.8); } +/* Clickable card + action link */ +.cache-health-card.clickable { cursor: pointer; transition: border-color 0.2s, transform 0.15s; } +.cache-health-card.clickable:hover { border-color: rgba(255,255,255,0.15); transform: translateY(-1px); } +.cache-health-card-action { font-size: 10px; color: rgba(255,255,255,0.35); margin-top: 4px; letter-spacing: 0.3px; } +.cache-health-card.clickable:hover .cache-health-card-action { color: rgba(255,255,255,0.6); } + +/* ── Failed MB Lookups Modal ── */ +.failed-mb-modal { + background: linear-gradient(165deg, #1e1e32 0%, #181828 100%); border-radius: 16px; + width: 720px; max-width: 95vw; max-height: 85vh; display: flex; flex-direction: column; + box-shadow: 0 24px 80px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.06); +} +.failed-mb-header { + display: flex; justify-content: space-between; align-items: flex-start; + padding: 22px 28px 16px; + background: linear-gradient(180deg, rgba(186,71,143,0.08) 0%, transparent 100%); + border-bottom: 1px solid rgba(255,255,255,0.06); border-radius: 16px 16px 0 0; +} +.failed-mb-title { font-size: 17px; font-weight: 700; color: #fff; margin: 0; letter-spacing: -0.2px; } +.failed-mb-subtitle { font-size: 12px; color: rgba(255,255,255,0.35); margin: 4px 0 0; } +.failed-mb-toolbar { padding: 14px 28px; border-bottom: 1px solid rgba(255,255,255,0.04); } +.failed-mb-tabs { display: flex; gap: 6px; margin-bottom: 12px; flex-wrap: wrap; } +.failed-mb-tab { + padding: 6px 14px; border-radius: 8px; font-size: 12px; font-weight: 600; + background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); + color: rgba(255,255,255,0.45); cursor: pointer; transition: all 0.2s; +} +.failed-mb-tab:hover { background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.7); border-color: rgba(255,255,255,0.1); } +.failed-mb-tab.active { background: rgba(186,71,143,0.15); border-color: rgba(186,71,143,0.35); color: #d584b8; } +.failed-mb-search-row { display: flex; gap: 10px; align-items: center; } +.failed-mb-search-input { + flex: 1; padding: 8px 14px; border-radius: 10px; font-size: 13px; + background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.07); + color: #fff; outline: none; transition: border-color 0.2s; +} +.failed-mb-search-input::placeholder { color: rgba(255,255,255,0.2); } +.failed-mb-search-input:focus { border-color: rgba(186,71,143,0.4); background: rgba(255,255,255,0.06); } +.failed-mb-btn { + padding: 8px 16px; border-radius: 10px; font-size: 12px; font-weight: 600; + border: none; cursor: pointer; white-space: nowrap; transition: all 0.2s; +} +.failed-mb-btn-primary { background: rgba(186,71,143,0.15); color: #d584b8; border: 1px solid rgba(186,71,143,0.25); } +.failed-mb-btn-primary:hover { background: rgba(186,71,143,0.25); color: #e8a3d0; } +.failed-mb-btn-danger { background: rgba(239,68,68,0.08); color: rgba(248,113,113,0.8); border: 1px solid rgba(239,68,68,0.15); } +.failed-mb-btn-danger:hover { background: rgba(239,68,68,0.15); color: #f87171; } +.failed-mb-btn-sm { + padding: 5px 12px; border-radius: 7px; font-size: 11px; font-weight: 600; + border: none; cursor: pointer; transition: all 0.2s; +} +.failed-mb-btn-sm.failed-mb-btn-primary { background: rgba(186,71,143,0.12); color: #d584b8; border: 1px solid rgba(186,71,143,0.2); } +.failed-mb-btn-sm.failed-mb-btn-primary:hover { background: rgba(186,71,143,0.25); color: #e8a3d0; transform: translateY(-1px); } +.failed-mb-btn-sm.failed-mb-btn-ghost { background: transparent; color: rgba(255,255,255,0.25); } +.failed-mb-btn-sm.failed-mb-btn-ghost:hover { color: rgba(248,113,113,0.8); } +.failed-mb-body { + flex: 1; overflow-y: auto; padding: 4px 28px; + scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.08) transparent; +} +.failed-mb-empty { text-align: center; padding: 48px 0; color: rgba(255,255,255,0.25); font-size: 14px; } +.failed-mb-item { + display: flex; align-items: center; gap: 14px; padding: 11px 8px; + border-bottom: 1px solid rgba(255,255,255,0.03); transition: opacity 0.2s, background 0.15s; + border-radius: 8px; margin: 0 -8px; +} +.failed-mb-item:hover { background: rgba(255,255,255,0.02); } +.failed-mb-item:last-child { border-bottom: none; } +.failed-mb-item-icon { font-size: 16px; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 8px; background: rgba(255,255,255,0.03); flex-shrink: 0; } +.failed-mb-item-info { flex: 1; min-width: 0; } +.failed-mb-item-name { font-size: 13px; font-weight: 600; color: rgba(255,255,255,0.85); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.failed-mb-item-artist { font-size: 11px; color: rgba(255,255,255,0.3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.failed-mb-item-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 3px; flex-shrink: 0; } +.failed-mb-item-type { font-size: 9px; color: rgba(186,71,143,0.5); text-transform: uppercase; letter-spacing: 0.8px; font-weight: 700; } +.failed-mb-item-date { font-size: 10px; color: rgba(255,255,255,0.15); } +.failed-mb-item-actions { display: flex; gap: 6px; flex-shrink: 0; } +.failed-mb-footer { padding: 14px 28px; border-top: 1px solid rgba(255,255,255,0.05); } +.failed-mb-pagination { display: flex; justify-content: center; align-items: center; gap: 14px; font-size: 12px; color: rgba(255,255,255,0.35); } + +/* ── MB Search Sub-Modal ── */ +.mb-search-modal { + background: linear-gradient(165deg, #1e1e32 0%, #181828 100%); border-radius: 16px; + width: 620px; max-width: 95vw; max-height: 80vh; display: flex; flex-direction: column; + box-shadow: 0 24px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.06); +} +.mb-search-header { + display: flex; justify-content: space-between; align-items: flex-start; + padding: 22px 28px 14px; + background: linear-gradient(180deg, rgba(186,71,143,0.06) 0%, transparent 100%); + border-bottom: 1px solid rgba(255,255,255,0.06); border-radius: 16px 16px 0 0; +} +.mb-search-title { font-size: 16px; font-weight: 700; color: #fff; margin: 0; letter-spacing: -0.2px; } +.mb-search-subtitle { font-size: 12px; color: rgba(255,255,255,0.35); margin: 4px 0 0; line-height: 1.4; } +.mb-search-subtitle strong { color: rgba(255,255,255,0.75); } +.mb-search-inputs { padding: 18px 28px; border-bottom: 1px solid rgba(255,255,255,0.04); display: flex; flex-direction: column; gap: 10px; } +.mb-search-input-row { display: flex; align-items: center; gap: 12px; } +.mb-search-input-row label { font-size: 11px; color: rgba(255,255,255,0.35); width: 48px; flex-shrink: 0; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; } +.mb-search-input, .mb-search-select { + flex: 1; padding: 8px 14px; border-radius: 10px; font-size: 13px; + background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.07); + color: #fff; outline: none; transition: border-color 0.2s; +} +.mb-search-input:focus, .mb-search-select:focus { border-color: rgba(186,71,143,0.4); background: rgba(255,255,255,0.06); } +.mb-search-select option { background: #1e1e32; } +.mb-search-results { flex: 1; overflow-y: auto; padding: 8px 28px 20px; scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.08) transparent; } +.mb-search-result { + display: flex; align-items: center; gap: 14px; padding: 12px 14px; + border-radius: 10px; cursor: pointer; transition: all 0.15s; + border: 1px solid transparent; margin-bottom: 4px; +} +.mb-search-result:hover { background: rgba(186,71,143,0.06); border-color: rgba(186,71,143,0.15); transform: translateY(-1px); } +.mb-search-result-score { font-size: 13px; font-weight: 800; width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; border-radius: 10px; background: rgba(255,255,255,0.03); flex-shrink: 0; } +.mb-search-result-info { flex: 1; min-width: 0; } +.mb-search-result-name { font-size: 13px; font-weight: 600; color: rgba(255,255,255,0.9); } +.mb-search-result-disambig { font-size: 11px; color: rgba(255,255,255,0.25); font-style: italic; margin-top: 3px; } +.mb-search-result-detail { font-size: 11px; color: rgba(255,255,255,0.3); margin-top: 3px; } +.mb-search-result-mbid { font-size: 9px; color: rgba(255,255,255,0.12); font-family: monospace; flex-shrink: 0; letter-spacing: 0.5px; } + /* ── Findings Tab ── */ .repair-findings-toolbar { display: flex;