diff --git a/core/metadata_cache.py b/core/metadata_cache.py index e6f8139b..5e6544eb 100644 --- a/core/metadata_cache.py +++ b/core/metadata_cache.py @@ -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: diff --git a/web_server.py b/web_server.py index 8a6bed22..e5b0f009 100644 --- a/web_server.py +++ b/web_server.py @@ -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 == # =============================== diff --git a/webui/index.html b/webui/index.html index 1b0b0dcf..008876b6 100644 --- a/webui/index.html +++ b/webui/index.html @@ -6557,6 +6557,9 @@
+ + +
@@ -6580,6 +6583,10 @@ Beatport 0 +
+ MusicBrainz + 0 +
Total Hits 0 @@ -6602,6 +6609,7 @@ +