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
This commit is contained in:
parent
800727299f
commit
7acf7a7d80
6 changed files with 217 additions and 27 deletions
|
|
@ -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…
Reference in a new issue