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:
Broque Thomas 2026-04-03 11:38:50 -07:00
parent fd09cad83e
commit 93fb082172
5 changed files with 712 additions and 75 deletions

View file

@ -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()

View file

@ -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")

View file

@ -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 ==
# ===============================

View file

@ -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()">&times;</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()">&times;</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;

View file

@ -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;