Expose MusicBrainz cache in UI — browse, clear, and unified health display

- Add MusicBrainz to Cache Browser: stats pill, source filter, dedicated
  browse endpoint, cards with matched/failed status indicators
- Add Clear MusicBrainz and Clear Failed MB Only to cache clear dropdown
- Move MusicBrainz into Cache Health "By Source" bar chart alongside
  Spotify/iTunes/Deezer instead of isolated metric row
- Rename ambiguous "Failed Lookups" to "Failed MB Lookups" in summary cards
- Add browse-musicbrainz and clear-musicbrainz API endpoints
- Add musicbrainz_total/musicbrainz_failed to cache stats response
- Add Global Search Bar and MusicBrainz cache to changelogs
pull/253/head
Broque Thomas 2 months ago
parent 800727299f
commit 7acf7a7d80

@ -525,6 +525,16 @@ class MetadataCache:
stats['oldest'] = row['oldest']
stats['newest'] = row['newest']
# MusicBrainz cache stats
try:
cursor.execute("SELECT COUNT(*) as cnt FROM musicbrainz_cache")
stats['musicbrainz_total'] = cursor.fetchone()['cnt']
cursor.execute("SELECT COUNT(*) as cnt FROM musicbrainz_cache WHERE musicbrainz_id IS NULL")
stats['musicbrainz_failed'] = cursor.fetchone()['cnt']
except Exception:
stats['musicbrainz_total'] = 0
stats['musicbrainz_failed'] = 0
return stats
finally:
conn.close()
@ -805,6 +815,27 @@ class MetadataCache:
logger.error(f"Cache clear error: {e}")
return 0
def clear_musicbrainz(self, failed_only: bool = False) -> int:
"""Clear MusicBrainz cache entries. If failed_only=True, only clears entries with NULL musicbrainz_id."""
try:
db = self._get_db()
conn = db._get_connection()
try:
cursor = conn.cursor()
if failed_only:
cursor.execute("DELETE FROM musicbrainz_cache WHERE musicbrainz_id IS NULL")
else:
cursor.execute("DELETE FROM musicbrainz_cache")
count = cursor.rowcount
conn.commit()
logger.info(f"Cleared {count} MusicBrainz cache entries (failed_only={failed_only})")
return count
finally:
conn.close()
except Exception as e:
logger.error(f"MusicBrainz cache clear error: {e}")
return 0
# ─── Field Extraction ─────────────────────────────────────────────
def _extract_fields(self, source: str, entity_type: str, raw_data: dict) -> dict:

@ -20054,6 +20054,20 @@ def get_version_info():
],
"usage_note": "Click the ⚡ Wing It button next to Start Discovery or Download Missing in any playlist modal."
},
{
"title": "🔍 Global Search Bar — Search From Anywhere",
"description": "Spotlight-style search bar accessible from every page",
"features": [
"• Persistent search bar at the bottom of the screen — faded when idle, expands on focus",
"• Full enhanced search parity — artists, albums, singles/EPs, tracks with source tabs",
"• Keyboard shortcuts: / or Ctrl+K to focus, Escape to close",
"• Click artists to navigate to their detail page, albums to open download modal",
"• In Library badges and green play buttons for tracks you already own",
"• Source tabs (Spotify, iTunes, Deezer) with result counts",
"• Results collapse on navigation, search bar stays visible"
],
"usage_note": "Press / or Ctrl+K from any page, or click the search bar at the bottom of the screen."
},
{
"title": "🔔 Redesigned Notification System",
"description": "Modern compact toasts with notification history and bell button",
@ -20105,7 +20119,9 @@ def get_version_info():
"• Cover Art Archive album art now opt-in via Settings toggle (#232)",
"• cover.jpg now correctly uses Cover Art Archive when enabled (was silently failing)",
"• Genius artist search returns multiple results for manual matching (#233)",
"• Genius API interval increased from 1.5s to 2s to reduce 429 rate limits"
"• Genius API interval increased from 1.5s to 2s to reduce 429 rate limits",
"• MusicBrainz cache now visible in Cache Browser with browse, clear, and clear-failed-only options",
"• Cache Health popup shows MusicBrainz alongside other sources, 'Failed Lookups' clarified as MB-specific"
]
},
{
@ -23236,6 +23252,67 @@ def metadata_cache_entity_detail(source, entity_type, entity_id):
logger.error(f"Error getting metadata cache entity: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/metadata-cache/browse-musicbrainz', methods=['GET'])
def metadata_cache_browse_musicbrainz():
"""Browse MusicBrainz cache entries in the same format as metadata cache browse."""
try:
entity_type = request.args.get('entity_type', 'artist')
search = request.args.get('search', '').strip()
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 48))
offset = (page - 1) * limit
database = get_database()
conn = database._get_connection()
try:
cursor = conn.cursor()
where_parts = []
params = []
if entity_type:
where_parts.append("entity_type = ?")
params.append(entity_type)
if search:
where_parts.append("LOWER(entity_name) LIKE LOWER(?)")
params.append(f"%{search}%")
where_clause = f"WHERE {' AND '.join(where_parts)}" if where_parts else ""
cursor.execute(f"SELECT COUNT(*) FROM musicbrainz_cache {where_clause}", params)
total = cursor.fetchone()[0]
cursor.execute(f"""
SELECT * FROM musicbrainz_cache
{where_clause}
ORDER BY last_updated DESC
LIMIT ? OFFSET ?
""", params + [limit, offset])
items = []
for row in cursor.fetchall():
r = dict(row)
matched = r.get('musicbrainz_id') is not None
items.append({
'entity_id': r.get('musicbrainz_id') or f"mb-{r.get('entity_type','')}-{r.get('entity_name','')}",
'source': 'musicbrainz',
'name': r.get('entity_name', ''),
'artist_name': r.get('artist_name', ''),
'image_url': None,
'popularity': int((r.get('match_confidence') or 0) * 100),
'access_count': 1,
'last_accessed_at': r.get('last_updated', ''),
'created_at': r.get('last_updated', ''),
'_mb_matched': matched,
'_mb_id': r.get('musicbrainz_id', ''),
})
return jsonify({'items': items, 'total': total, 'offset': offset})
finally:
conn.close()
except Exception as e:
logger.error(f"Error browsing MusicBrainz cache: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/metadata-cache/clear', methods=['DELETE'])
def metadata_cache_clear():
"""Clear cached metadata. Optional query params: source, type."""
@ -23263,6 +23340,18 @@ def metadata_cache_evict():
logger.error(f"Error evicting metadata cache: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/metadata-cache/clear-musicbrainz', methods=['DELETE'])
def metadata_cache_clear_musicbrainz():
"""Clear MusicBrainz cache entries. Optional query param: failed_only=true."""
try:
cache = get_metadata_cache()
failed_only = request.args.get('failed_only', '').lower() == 'true'
cleared = cache.clear_musicbrainz(failed_only=failed_only)
return jsonify({"success": True, "cleared": cleared})
except Exception as e:
logger.error(f"Error clearing MusicBrainz cache: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# ===============================
# == QUALITY SCANNER ==
# ===============================

@ -6557,6 +6557,9 @@
<button onclick="clearMetadataCacheBySource('deezer')"><span class="mcache-source-badge deezer" style="margin-right:6px">deezer</span>Clear Deezer</button>
<button onclick="clearMetadataCacheBySource('beatport')"><span class="mcache-source-badge beatport" style="margin-right:6px">beatport</span>Clear Beatport</button>
<div style="border-top:1px solid rgba(255,255,255,0.08);margin:4px 0"></div>
<button onclick="clearMusicBrainzCache()"><span class="mcache-source-badge musicbrainz" style="margin-right:6px">musicbrainz</span>Clear MusicBrainz</button>
<button onclick="clearMusicBrainzCache(true)"><span class="mcache-source-badge musicbrainz" style="margin-right:6px;opacity:0.6">musicbrainz</span>Clear Failed MB Only</button>
<div style="border-top:1px solid rgba(255,255,255,0.08);margin:4px 0"></div>
<button onclick="clearMetadataCache()">Clear All</button>
</div>
</div>
@ -6580,6 +6583,10 @@
<span class="mcache-stat-pill-label">Beatport</span>
<span class="mcache-stat-pill-value" id="mcache-browse-beatport-count">0</span>
</div>
<div class="mcache-stat-pill">
<span class="mcache-stat-pill-label">MusicBrainz</span>
<span class="mcache-stat-pill-value" id="mcache-browse-musicbrainz-count">0</span>
</div>
<div class="mcache-stat-pill">
<span class="mcache-stat-pill-label">Total Hits</span>
<span class="mcache-stat-pill-value" id="mcache-browse-hits">0</span>
@ -6602,6 +6609,7 @@
<option value="itunes">iTunes</option>
<option value="deezer">Deezer</option>
<option value="beatport">Beatport</option>
<option value="musicbrainz">MusicBrainz</option>
</select>
<select class="mcache-sort-filter" id="mcache-sort-filter" onchange="loadMetadataCacheBrowse()">
<option value="last_accessed_at">Recently Accessed</option>

@ -3403,6 +3403,8 @@ function closeHelperSearch() {
const WHATS_NEW = {
'2.2': [
// Newest features first
{ title: 'Global Search Bar', desc: 'Spotlight-style search from any page — press / or Ctrl+K. Full enhanced search with source tabs, library badges, and playback' },
{ title: 'MusicBrainz Cache in Browser', desc: 'MusicBrainz cache now visible in Cache Browser — browse, clear all, or clear failed lookups only. Cache Health shows MB alongside other sources' },
{ title: 'Wing It Mode', desc: 'Download or sync playlists without metadata discovery — uses raw track names directly. Great for obscure tracks not on Spotify/iTunes' },
{ title: 'Redesigned Notifications', desc: 'Compact pill toasts, notification bell with unread badge, history panel with last 50 notifications and Learn More links' },
{ title: 'Track Redownload & Source Info', desc: 'Fix mismatched downloads — search all metadata and download sources in columns, pick the right version, auto-replace. Source Info shows where tracks came from with blacklist option', page: 'library' },

@ -21498,6 +21498,7 @@ async function loadMetadataCacheBrowseStats() {
el('mcache-browse-itunes-count', itunesTotal);
el('mcache-browse-deezer-count', deezerTotal);
el('mcache-browse-beatport-count', beatportTotal);
el('mcache-browse-musicbrainz-count', stats.musicbrainz_total || 0);
el('mcache-browse-hits', stats.total_hits || 0);
el('mcache-browse-searches', stats.searches || 0);
} catch (e) { /* ignore */ }
@ -21520,22 +21521,35 @@ async function loadMetadataCacheBrowse() {
const search = document.getElementById('mcache-search')?.value || '';
const sort = document.getElementById('mcache-sort-filter')?.value || 'last_accessed_at';
const params = new URLSearchParams({
type: _mcacheCurrentTab,
sort: sort,
sort_dir: sort === 'name' ? 'asc' : 'desc',
offset: _mcachePage * MCACHE_PAGE_SIZE,
limit: MCACHE_PAGE_SIZE
});
if (source) params.set('source', source);
if (search) params.set('search', search);
grid.innerHTML = '<div class="mcache-empty"><div class="mcache-empty-icon">...</div><div class="mcache-empty-sub">Loading...</div></div>';
try {
const response = await fetch(`/api/metadata-cache/browse?${params}`);
if (!response.ok) throw new Error('Failed to load');
const data = await response.json();
let data;
if (source === 'musicbrainz') {
// MusicBrainz is a separate cache table — use dedicated endpoint
const params = new URLSearchParams({
entity_type: _mcacheCurrentTab,
page: _mcachePage + 1,
limit: MCACHE_PAGE_SIZE
});
if (search) params.set('search', search);
const response = await fetch(`/api/metadata-cache/browse-musicbrainz?${params}`);
if (!response.ok) throw new Error('Failed to load');
data = await response.json();
} else {
const params = new URLSearchParams({
type: _mcacheCurrentTab,
sort: sort,
sort_dir: sort === 'name' ? 'asc' : 'desc',
offset: _mcachePage * MCACHE_PAGE_SIZE,
limit: MCACHE_PAGE_SIZE
});
if (source) params.set('source', source);
if (search) params.set('search', search);
const response = await fetch(`/api/metadata-cache/browse?${params}`);
if (!response.ok) throw new Error('Failed to load');
data = await response.json();
}
if (!data.items || data.items.length === 0) {
grid.innerHTML = `
@ -21578,7 +21592,10 @@ function renderMetadataCacheGrid(items, entityType) {
let subText = '';
let metaText = '';
if (entityType === 'artist') {
if (source === 'musicbrainz') {
subText = item.artist_name || '';
metaText = item._mb_matched ? `MBID: ${(item._mb_id || '').substring(0, 8)}` : 'No match found';
} else if (entityType === 'artist') {
const genres = item.genres ? (typeof item.genres === 'string' ? JSON.parse(item.genres || '[]') : item.genres) : [];
subText = genres.length > 0 ? genres.slice(0, 2).join(', ') : '';
if (item.popularity) metaText = `Pop: ${item.popularity}`;
@ -21597,8 +21614,11 @@ function renderMetadataCacheGrid(items, entityType) {
metaText = parts.join(' · ');
}
const clickAttr = source === 'musicbrainz' ? '' : `onclick="openMetadataCacheDetail('${source}', '${entityType}', '${encodeURIComponent(item.entity_id)}')"`;
const mbStatusClass = source === 'musicbrainz' ? (item._mb_matched ? ' mb-matched' : ' mb-failed') : '';
return `
<div class="mcache-card" onclick="openMetadataCacheDetail('${source}', '${entityType}', '${encodeURIComponent(item.entity_id)}')">
<div class="mcache-card${mbStatusClass}" ${clickAttr}>
<div class="mcache-card-top">
${imageHtml}
<div class="mcache-card-info">
@ -21822,6 +21842,28 @@ async function clearMetadataCacheBySource(source) {
}
}
async function clearMusicBrainzCache(failedOnly = false) {
const label = failedOnly ? 'failed MusicBrainz lookups' : 'ALL MusicBrainz cache entries';
if (!confirm(`Clear ${label}?`)) return;
document.getElementById('mcache-clear-dropdown-menu').style.display = 'none';
try {
const url = failedOnly ? '/api/metadata-cache/clear-musicbrainz?failed_only=true' : '/api/metadata-cache/clear-musicbrainz';
const response = await fetch(url, { method: 'DELETE' });
const data = await response.json();
if (data.success) {
showToast(`Cleared ${data.cleared} MusicBrainz cache entries`, 'success');
loadMetadataCacheBrowseStats();
loadMetadataCacheBrowse();
loadMetadataCacheStats();
} else {
showToast('Failed to clear MusicBrainz cache', 'error');
}
} catch (e) {
showToast('Error clearing MusicBrainz cache', 'error');
}
}
function debouncedMetadataCacheSearch() {
if (_mcacheSearchTimeout) clearTimeout(_mcacheSearchTimeout);
_mcacheSearchTimeout = setTimeout(() => {
@ -57860,22 +57902,27 @@ async function openCacheHealthModal() {
</div>
<div class="cache-health-card">
<div class="cache-health-card-value ${s.stale_mb_nulls > 10 ? 'warn' : ''}">${s.stale_mb_nulls}</div>
<div class="cache-health-card-label">Failed Lookups</div>
<div class="cache-health-card-label">Failed MB Lookups</div>
</div>
</div>
<div class="cache-health-section">
<div class="cache-health-section-title">By Source</div>
<div class="cache-health-source-bars">
${Object.entries(s.by_source || {}).map(([src, count]) => {
const pct = s.total_entities > 0 ? Math.round(count / s.total_entities * 100) : 0;
const color = src === 'spotify' ? '#1DB954' : src === 'itunes' ? '#FC3C44' : src === 'deezer' ? '#A238FF' : '#666';
return `<div class="cache-health-source-row">
<span class="cache-health-source-name">${src}</span>
<div class="cache-health-source-track"><div class="cache-health-source-fill" style="width:${pct}%;background:${color}"></div></div>
<span class="cache-health-source-count">${count.toLocaleString()}</span>
</div>`;
}).join('')}
${(() => {
const allSources = {...(s.by_source || {})};
if (s.total_musicbrainz) allSources['musicbrainz'] = s.total_musicbrainz;
const maxCount = Math.max(...Object.values(allSources), 1);
return Object.entries(allSources).map(([src, count]) => {
const pct = Math.round(count / maxCount * 100);
const color = src === 'spotify' ? '#1DB954' : src === 'itunes' ? '#FC3C44' : src === 'deezer' ? '#A238FF' : src === 'musicbrainz' ? '#BA478F' : '#666';
return `<div class="cache-health-source-row">
<span class="cache-health-source-name">${src === 'musicbrainz' ? 'MusicBrainz' : src}</span>
<div class="cache-health-source-track"><div class="cache-health-source-fill" style="width:${pct}%;background:${color}"></div></div>
<span class="cache-health-source-count">${count.toLocaleString()}</span>
</div>`;
}).join('');
})()}
</div>
</div>
@ -57893,7 +57940,6 @@ async function openCacheHealthModal() {
<div class="cache-health-metric"><span class="cache-health-metric-label">Total Cache Hits</span><span class="cache-health-metric-value">${s.total_access_hits.toLocaleString()}</span></div>
<div class="cache-health-metric"><span class="cache-health-metric-label">Expiring in 24h</span><span class="cache-health-metric-value">${s.expiring_24h}</span></div>
<div class="cache-health-metric"><span class="cache-health-metric-label">Expiring in 7 days</span><span class="cache-health-metric-value">${s.expiring_7d}</span></div>
<div class="cache-health-metric"><span class="cache-health-metric-label">MusicBrainz Entries</span><span class="cache-health-metric-value">${s.total_musicbrainz}</span></div>
</div>
</div>
`;

@ -46534,6 +46534,20 @@ tr.tag-diff-same {
color: #00d278;
}
.mcache-source-badge.musicbrainz {
background: rgba(186, 71, 143, 0.15);
color: #BA478F;
}
.mcache-card.mb-matched {
border-left: 2px solid rgba(76, 175, 80, 0.5);
}
.mcache-card.mb-failed {
border-left: 2px solid rgba(255, 152, 0, 0.5);
cursor: default;
}
.mcache-card-cache-info {
font-size: 10px;
color: rgba(255, 255, 255, 0.25);

Loading…
Cancel
Save