Add Failed MB Lookups manager + optimize cache performance
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.
This commit is contained in:
parent
fd09cad83e
commit
93fb082172
5 changed files with 712 additions and 75 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
245
web_server.py
245
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/<int:entry_id>', 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 ==
|
||||
# ===============================
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div class="cache-health-card-value ${s.junk_entities > 0 ? 'warn' : ''}">${s.junk_entities}</div>
|
||||
<div class="cache-health-card-label">Junk Entries</div>
|
||||
</div>
|
||||
<div class="cache-health-card">
|
||||
<div class="cache-health-card ${s.stale_mb_nulls > 0 ? 'clickable' : ''}" ${s.stale_mb_nulls > 0 ? 'onclick="openFailedMBLookupsModal()"' : ''}>
|
||||
<div class="cache-health-card-value ${s.stale_mb_nulls > 10 ? 'warn' : ''}">${s.stale_mb_nulls}</div>
|
||||
<div class="cache-health-card-label">Failed MB Lookups</div>
|
||||
${s.stale_mb_nulls > 0 ? '<div class="cache-health-card-action">Manage ›</div>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -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 = `
|
||||
<div class="failed-mb-modal">
|
||||
<div class="failed-mb-header">
|
||||
<div>
|
||||
<h2 class="failed-mb-title">Failed MusicBrainz Lookups</h2>
|
||||
<p class="failed-mb-subtitle">Tracks, albums, and artists that couldn't be matched automatically</p>
|
||||
</div>
|
||||
<button class="watch-all-close" onclick="document.getElementById('failed-mb-modal-overlay').remove()">×</button>
|
||||
</div>
|
||||
<div class="failed-mb-toolbar">
|
||||
<div class="failed-mb-tabs" id="failed-mb-tabs"></div>
|
||||
<div class="failed-mb-search-row">
|
||||
<input type="text" id="failed-mb-search" class="failed-mb-search-input" placeholder="Filter by name...">
|
||||
<button class="failed-mb-btn failed-mb-btn-danger" onclick="_failedMBClearAll()">Clear All Failed</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="failed-mb-body" id="failed-mb-body">
|
||||
<div class="cache-health-loading"><div class="watch-all-loading-spinner"></div><div>Loading...</div></div>
|
||||
</div>
|
||||
<div class="failed-mb-footer" id="failed-mb-footer"></div>
|
||||
</div>
|
||||
`;
|
||||
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 = `<button class="failed-mb-tab ${!_failedMBState.typeFilter ? 'active' : ''}" onclick="_failedMBSetType('')">All (${allCount})</button>`;
|
||||
const typeLabels = { artist: 'Artists', release: 'Albums', recording: 'Tracks' };
|
||||
for (const [type, count] of Object.entries(_failedMBState.typeCounts)) {
|
||||
tabsHTML += `<button class="failed-mb-tab ${_failedMBState.typeFilter === type ? 'active' : ''}" onclick="_failedMBSetType('${type}')">${typeLabels[type] || type} (${count})</button>`;
|
||||
}
|
||||
tabsEl.innerHTML = tabsHTML;
|
||||
}
|
||||
|
||||
// Render items
|
||||
if (data.items.length === 0) {
|
||||
body.innerHTML = `<div class="failed-mb-empty">${_failedMBState.filter ? 'No matches for your search' : 'No failed lookups — cache is clean!'}</div>`;
|
||||
} else {
|
||||
const typeIcons = { artist: '🎤', release: '💿', recording: '🎵' };
|
||||
body.innerHTML = data.items.map(item => `
|
||||
<div class="failed-mb-item" data-id="${item.id}">
|
||||
<div class="failed-mb-item-icon">${typeIcons[item.entity_type] || '?'}</div>
|
||||
<div class="failed-mb-item-info">
|
||||
<div class="failed-mb-item-name">${escapeHtml(item.entity_name)}</div>
|
||||
${item.artist_name ? `<div class="failed-mb-item-artist">${escapeHtml(item.artist_name)}</div>` : ''}
|
||||
</div>
|
||||
<div class="failed-mb-item-meta">
|
||||
<span class="failed-mb-item-type">${item.entity_type}</span>
|
||||
<span class="failed-mb-item-date">${item.last_updated ? new Date(item.last_updated).toLocaleDateString() : ''}</span>
|
||||
</div>
|
||||
<div class="failed-mb-item-actions">
|
||||
<button class="failed-mb-btn-sm failed-mb-btn-primary" onclick="_failedMBSearch(${item.id}, '${item.entity_type}', '${escapeForInlineJs(item.entity_name)}', '${escapeForInlineJs(item.artist_name || '')}')">Search MB</button>
|
||||
<button class="failed-mb-btn-sm failed-mb-btn-ghost" onclick="_failedMBDelete(${item.id})">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Pagination footer
|
||||
const footer = document.getElementById('failed-mb-footer');
|
||||
if (footer) {
|
||||
const totalPages = Math.ceil(data.total / 50);
|
||||
footer.innerHTML = totalPages > 1 ? `
|
||||
<div class="failed-mb-pagination">
|
||||
<button class="failed-mb-btn-sm" ${_failedMBState.page <= 1 ? 'disabled' : ''} onclick="_failedMBPage(${_failedMBState.page - 1})">Prev</button>
|
||||
<span>Page ${_failedMBState.page} of ${totalPages} (${data.total} total)</span>
|
||||
<button class="failed-mb-btn-sm" ${_failedMBState.page >= totalPages ? 'disabled' : ''} onclick="_failedMBPage(${_failedMBState.page + 1})">Next</button>
|
||||
</div>
|
||||
` : `<div class="failed-mb-pagination"><span>${data.total} entries</span></div>`;
|
||||
}
|
||||
} catch (err) {
|
||||
body.innerHTML = '<div class="failed-mb-empty">Failed to load data</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="mb-search-modal">
|
||||
<div class="mb-search-header">
|
||||
<div>
|
||||
<h2 class="mb-search-title">Search MusicBrainz</h2>
|
||||
<p class="mb-search-subtitle">Find a match for: <strong>${escapeHtml(entityName)}</strong>${artistName ? ` by ${escapeHtml(artistName)}` : ''}</p>
|
||||
</div>
|
||||
<button class="watch-all-close" onclick="document.getElementById('mb-search-modal-overlay').remove()">×</button>
|
||||
</div>
|
||||
<div class="mb-search-inputs">
|
||||
<div class="mb-search-input-row">
|
||||
<label>Type</label>
|
||||
<select id="mb-search-type" class="mb-search-select">
|
||||
<option value="artist" ${entityType === 'artist' ? 'selected' : ''}>Artist</option>
|
||||
<option value="release" ${entityType === 'release' ? 'selected' : ''}>Album / Release</option>
|
||||
<option value="recording" ${entityType === 'recording' ? 'selected' : ''}>Track / Recording</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-search-input-row">
|
||||
<label>Name</label>
|
||||
<input type="text" id="mb-search-query" class="mb-search-input" value="${escapeHtml(entityName)}">
|
||||
</div>
|
||||
<div class="mb-search-input-row" id="mb-search-artist-row" ${entityType === 'artist' ? 'style="display:none"' : ''}>
|
||||
<label>Artist</label>
|
||||
<input type="text" id="mb-search-artist" class="mb-search-input" value="${escapeHtml(artistName)}">
|
||||
</div>
|
||||
<button class="failed-mb-btn failed-mb-btn-primary" id="mb-search-go-btn" onclick="_runMBSearch(${entryId})">Search</button>
|
||||
</div>
|
||||
<div class="mb-search-results" id="mb-search-results">
|
||||
<div class="failed-mb-empty">Enter a search query and click Search</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 = '<div class="cache-health-loading"><div class="watch-all-loading-spinner"></div><div>Searching MusicBrainz...</div></div>';
|
||||
|
||||
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 = '<div class="failed-mb-empty">No results found. Try adjusting your search.</div>';
|
||||
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 `
|
||||
<div class="mb-search-result" onclick="_selectMBMatch(${entryId}, '${r.mbid}', '${escapeForInlineJs(r.name)}')">
|
||||
<div class="mb-search-result-score" style="color:${scoreColor}">${r.score}%</div>
|
||||
<div class="mb-search-result-info">
|
||||
<div class="mb-search-result-name">${escapeHtml(r.name)}</div>
|
||||
${r.disambiguation ? `<div class="mb-search-result-disambig">${escapeHtml(r.disambiguation)}</div>` : ''}
|
||||
${detail ? `<div class="mb-search-result-detail">${escapeHtml(detail)}</div>` : ''}
|
||||
</div>
|
||||
<div class="mb-search-result-mbid" title="${r.mbid}">${r.mbid.substring(0, 8)}...</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
resultsEl.innerHTML = `<div class="failed-mb-empty">Search error: ${err.message}</div>`;
|
||||
} 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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue