From 4f5025d526cce3d372765fcb3b31d86881ffeb24 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:06:27 -0700 Subject: [PATCH] Add MusicBrainz search tab, wider global search, bump to v2.32 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New MusicBrainz tab in Enhanced and Global search — finds tracks and albums on MusicBrainz's community database with Cover Art Archive images. Covers obscure tracks that Spotify/Deezer/iTunes miss. - core/musicbrainz_search.py: search adapter with Track/Artist/Album dataclasses, Cover Art Archive integration, smart query parsing - Albums deduplicated (keeps best version with date and art) - No artist results shown (MusicBrainz has no artist images) - Album detail with full tracklist for download modal - Smart word-boundary splitting for queries without separators - Global search results container widened from 620px to 920px - UI version bumped to 2.32 --- core/musicbrainz_search.py | 413 +++++++++++++++++++++++++++++++++++++ web_server.py | 37 +++- webui/static/helper.js | 10 +- webui/static/script.js | 3 +- webui/static/style.css | 3 +- 5 files changed, 459 insertions(+), 7 deletions(-) create mode 100644 core/musicbrainz_search.py diff --git a/core/musicbrainz_search.py b/core/musicbrainz_search.py new file mode 100644 index 00000000..f9d93aab --- /dev/null +++ b/core/musicbrainz_search.py @@ -0,0 +1,413 @@ +"""MusicBrainz Search Adapter — provides enhanced search tab integration. + +Wraps the existing MusicBrainzClient with search methods that return the +same Track/Artist/Album dataclass format used by Deezer/iTunes/Discogs, +enabling MusicBrainz as a search tab in enhanced and global search. +Album art is fetched from Cover Art Archive (free, linked by release MBID). +""" + +import requests +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from utils.logging_config import get_logger + +logger = get_logger("musicbrainz_search") + +COVER_ART_ARCHIVE_URL = "https://coverartarchive.org" + + +@dataclass +class Track: + id: str + name: str + artists: List[str] + album: str + duration_ms: int + popularity: int + preview_url: Optional[str] = None + external_urls: Optional[Dict[str, str]] = None + image_url: Optional[str] = None + release_date: Optional[str] = None + track_number: Optional[int] = None + disc_number: Optional[int] = None + album_type: Optional[str] = None + total_tracks: Optional[int] = None + album_id: Optional[str] = None + + +@dataclass +class Artist: + id: str + name: str + popularity: int + genres: List[str] + followers: int + image_url: Optional[str] = None + external_urls: Optional[Dict[str, str]] = None + + +@dataclass +class Album: + id: str + name: str + artists: List[str] + release_date: str + total_tracks: int + album_type: str + image_url: Optional[str] = None + external_urls: Optional[Dict[str, str]] = None + + +def _get_cover_art_url(release_mbid: str) -> Optional[str]: + """Fetch album art URL from Cover Art Archive. Returns None if not available.""" + try: + # CAA redirects to the actual image URL — just get the front image URL + url = f"{COVER_ART_ARCHIVE_URL}/release/{release_mbid}/front-250" + resp = requests.head(url, timeout=3, allow_redirects=True) + if resp.status_code == 200: + return resp.url # The redirect target is the actual image + return None + except Exception: + return None + + +def _get_release_group_art(release_group_mbid: str) -> Optional[str]: + """Fetch album art from release group (covers all editions).""" + try: + url = f"{COVER_ART_ARCHIVE_URL}/release-group/{release_group_mbid}/front-250" + resp = requests.head(url, timeout=3, allow_redirects=True) + if resp.status_code == 200: + return resp.url + return None + except Exception: + return None + + +def _extract_artist_credit(artist_credit) -> List[str]: + """Extract artist names from MusicBrainz artist-credit array.""" + if not artist_credit: + return [] + names = [] + for credit in artist_credit: + if isinstance(credit, dict) and 'artist' in credit: + names.append(credit['artist'].get('name', '')) + elif isinstance(credit, dict) and 'name' in credit: + names.append(credit['name']) + return [n for n in names if n] + + +def _map_release_type(primary_type: str, secondary_types: List[str] = None) -> str: + """Map MusicBrainz release group type to standard album_type.""" + pt = (primary_type or '').lower() + if pt == 'album': + return 'album' + elif pt == 'single': + return 'single' + elif pt == 'ep': + return 'ep' + elif pt == 'compilation' or 'compilation' in (secondary_types or []): + return 'compilation' + return 'album' + + +class MusicBrainzSearchClient: + """Search adapter for MusicBrainz — compatible with enhanced search tab system.""" + + def __init__(self): + from core.musicbrainz_client import MusicBrainzClient + self._client = MusicBrainzClient("SoulSync", "2.3") + self._art_cache: Dict[str, Optional[str]] = {} # mbid -> url + + def _cached_art(self, release_mbid: str, release_group_mbid: str = '') -> Optional[str]: + """Get cover art with caching. Tries release first, then release group.""" + if release_mbid in self._art_cache: + return self._art_cache[release_mbid] + + url = _get_cover_art_url(release_mbid) + if not url and release_group_mbid: + url = _get_release_group_art(release_group_mbid) + self._art_cache[release_mbid] = url + return url + + def search_artists(self, query: str, limit: int = 10) -> List[Artist]: + """MusicBrainz search tab doesn't show artists — only albums and tracks.""" + return [] + + def search_albums(self, query: str, limit: int = 10) -> List[Album]: + """Search MusicBrainz for releases (albums).""" + try: + # Try to split "Artist Album" for better matching + artist_name = None + album_name = query + for sep in [' - ', ' – ', ' — ']: + if sep in query: + parts = query.split(sep, 1) + artist_name = parts[0].strip() + album_name = parts[1].strip() + break + + results = self._client.search_release(album_name, artist_name=artist_name, limit=limit) + + # If no separator, try word-boundary splitting + if not results and not artist_name: + words = query.split() + for i in range(1, len(words)): + possible_artist = ' '.join(words[:i]) + possible_album = ' '.join(words[i:]) + if len(possible_album) >= 2: + results = self._client.search_release(possible_album, artist_name=possible_artist, limit=limit) + if results: + break + + albums = [] + for r in results: + mbid = r.get('id', '') + title = r.get('title', '') + if not title: + continue + + artists = _extract_artist_credit(r.get('artist-credit', [])) + release_date = r.get('date', '') or '' + + # Track count from media + total_tracks = 0 + media = r.get('media', []) + for m in media: + total_tracks += m.get('track-count', 0) + + # Release type + rg = r.get('release-group', {}) + primary_type = rg.get('primary-type', '') or '' + secondary_types = rg.get('secondary-types', []) or [] + album_type = _map_release_type(primary_type, secondary_types) + + # Cover art (non-blocking — skip if slow) + rg_mbid = rg.get('id', '') + image_url = self._cached_art(mbid, rg_mbid) + + external_urls = {'musicbrainz': f'https://musicbrainz.org/release/{mbid}'} if mbid else {} + + albums.append(Album( + id=mbid, + name=title, + artists=artists if artists else ['Unknown Artist'], + release_date=release_date, + total_tracks=total_tracks, + album_type=album_type, + image_url=image_url, + external_urls=external_urls, + )) + # Deduplicate: keep best version of each title+artist combo + # (prefer ones with release dates and cover art) + seen = {} + deduped = [] + for album in albums: + key = (album.name.lower().strip(), ', '.join(album.artists).lower().strip()) + if key not in seen: + seen[key] = album + deduped.append(album) + else: + existing = seen[key] + # Prefer: has date > no date, has art > no art + better = False + if not existing.release_date and album.release_date: + better = True + elif not existing.image_url and album.image_url: + better = True + if better: + deduped[deduped.index(existing)] = album + seen[key] = album + return deduped + except Exception as e: + logger.warning(f"MusicBrainz album search failed: {e}") + return [] + + def search_tracks(self, query: str, limit: int = 10) -> List[Track]: + """Search MusicBrainz for recordings (tracks).""" + try: + # Try to split "Artist - Title" for better matching + artist_name = None + track_name = query + for sep in [' - ', ' – ', ' — ']: + if sep in query: + parts = query.split(sep, 1) + artist_name = parts[0].strip() + track_name = parts[1].strip() + break + + results = self._client.search_recording(track_name, artist_name=artist_name, limit=limit) + + # If no separator found or structured search failed, try the full query + # as both a recording search and an artist+recording combined search + if not results and not artist_name: + # Try each word split as potential artist/title boundary + words = query.split() + for i in range(1, len(words)): + possible_artist = ' '.join(words[:i]) + possible_track = ' '.join(words[i:]) + if len(possible_track) >= 2: + results = self._client.search_recording(possible_track, artist_name=possible_artist, limit=limit) + if results: + break + tracks = [] + for r in results: + mbid = r.get('id', '') + title = r.get('title', '') + if not title: + continue + + artists = _extract_artist_credit(r.get('artist-credit', [])) + duration_ms = r.get('length', 0) or 0 + + # Get album from first release + album_name = '' + album_id = '' + release_date = '' + image_url = None + album_type = 'single' + total_tracks = 1 + track_number = None + + releases = r.get('releases', []) + if releases: + rel = releases[0] + album_name = rel.get('title', '') + album_id = rel.get('id', '') + release_date = rel.get('date', '') or '' + + rg = rel.get('release-group', {}) + primary_type = rg.get('primary-type', '') or '' + secondary_types = rg.get('secondary-types', []) or [] + album_type = _map_release_type(primary_type, secondary_types) + + media = rel.get('media', []) + for m in media: + total_tracks += m.get('track-count', 0) + # Find track number + for t in m.get('tracks', []): + if t.get('id') == mbid or t.get('recording', {}).get('id') == mbid: + try: + track_number = int(t.get('number', t.get('position', 0))) + except (ValueError, TypeError): + pass + + # Cover art + rg_mbid = rg.get('id', '') + image_url = self._cached_art(album_id, rg_mbid) if album_id else None + + external_urls = {'musicbrainz': f'https://musicbrainz.org/recording/{mbid}'} if mbid else {} + + tracks.append(Track( + id=mbid, + name=title, + artists=artists if artists else ['Unknown Artist'], + album=album_name or title, + duration_ms=duration_ms, + popularity=r.get('score', 0), + image_url=image_url, + release_date=release_date, + external_urls=external_urls, + track_number=track_number, + album_type=album_type, + total_tracks=total_tracks, + album_id=album_id, + )) + return tracks + except Exception as e: + logger.warning(f"MusicBrainz track search failed: {e}") + return [] + + def get_album(self, release_mbid: str) -> Optional[Dict[str, Any]]: + """Get full album details with track listing for download modal.""" + try: + release = self._client.get_release(release_mbid, includes=['recordings', 'artist-credits', 'release-groups']) + if not release: + return None + + title = release.get('title', '') + artists_raw = _extract_artist_credit(release.get('artist-credit', [])) + release_date = release.get('date', '') or '' + + rg = release.get('release-group', {}) + primary_type = rg.get('primary-type', '') or '' + secondary_types = rg.get('secondary-types', []) or [] + album_type = _map_release_type(primary_type, secondary_types) + + # Cover art + rg_mbid = rg.get('id', '') + image_url = self._cached_art(release_mbid, rg_mbid) + + # Build tracks from media + tracks = [] + total_tracks = 0 + media_list = release.get('media', []) + for media_idx, media in enumerate(media_list): + disc_number = media.get('position', media_idx + 1) + for track in media.get('tracks', []): + total_tracks += 1 + recording = track.get('recording', {}) + track_artists = _extract_artist_credit(recording.get('artist-credit', [])) + if not track_artists: + track_artists = artists_raw + + try: + track_num = int(track.get('number', track.get('position', total_tracks))) + except (ValueError, TypeError): + track_num = total_tracks + + tracks.append({ + 'id': recording.get('id', track.get('id', '')), + 'name': recording.get('title', track.get('title', '')), + 'artists': [{'name': a} for a in track_artists], + 'duration_ms': recording.get('length', 0) or track.get('length', 0) or 0, + 'track_number': track_num, + 'disc_number': disc_number, + }) + + images = [{'url': image_url, 'height': 250, 'width': 250}] if image_url else [] + + return { + 'id': release_mbid, + 'name': title, + 'artists': [{'name': a, 'id': ''} for a in (artists_raw or ['Unknown Artist'])], + 'release_date': release_date, + 'total_tracks': total_tracks, + 'album_type': album_type, + 'images': images, + 'tracks': tracks, + 'external_urls': {'musicbrainz': f'https://musicbrainz.org/release/{release_mbid}'}, + } + except Exception as e: + logger.error(f"MusicBrainz album detail failed for {release_mbid}: {e}") + return None + + def get_artist_albums(self, artist_mbid: str, album_type: str = 'album,single') -> List: + """Get artist's releases for discography view.""" + try: + artist = self._client.get_artist(artist_mbid, includes=['release-groups']) + if not artist or 'release-groups' not in artist: + return [] + + albums = [] + for rg in artist.get('release-groups', []): + primary_type = rg.get('primary-type', '') or '' + rg_type = _map_release_type(primary_type, rg.get('secondary-types', [])) + + rg_mbid = rg.get('id', '') + image_url = self._cached_art(rg_mbid, rg_mbid) + + albums.append(Album( + id=rg_mbid, + name=rg.get('title', ''), + artists=[artist.get('name', 'Unknown Artist')], + release_date=rg.get('first-release-date', '') or '', + total_tracks=0, + album_type=rg_type, + image_url=image_url, + external_urls={'musicbrainz': f'https://musicbrainz.org/release-group/{rg_mbid}'}, + )) + return albums + except Exception as e: + logger.warning(f"MusicBrainz artist albums failed: {e}") + return [] diff --git a/web_server.py b/web_server.py index 2527ed9e..7b47b66a 100644 --- a/web_server.py +++ b/web_server.py @@ -34,7 +34,7 @@ _log_path = config_manager.get('logging.path', 'logs/app.log') logger = setup_logging(_log_level, _log_path) # App version — single source of truth for backup metadata, version-info endpoint, etc. -SOULSYNC_VERSION = "2.31" +SOULSYNC_VERSION = "2.32" # Dedicated source reuse logger — writes to logs/source_reuse.log import logging as _logging @@ -8567,6 +8567,8 @@ def enhanced_search(): alternate_sources.append('hydrabase') # YouTube music videos always available (uses yt-dlp, no auth needed) alternate_sources.append('youtube_videos') + # MusicBrainz always available (public API, no auth) + alternate_sources.append('musicbrainz') logger.info(f"Enhanced search results ({primary_source}): {len(db_artists)} DB artists, " f"{len(primary_results['artists'])} artists, {len(primary_results['albums'])} albums, " @@ -8653,7 +8655,7 @@ def enhanced_search_source(source_name): This prevents slow sources (iTunes with 3s rate limit) from blocking the UI. Falls back to single JSON response if streaming not supported. """ - if source_name not in ('spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'youtube_videos'): + if source_name not in ('spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'youtube_videos', 'musicbrainz'): return jsonify({"error": f"Unknown source: {source_name}"}), 400 data = request.get_json() @@ -8714,6 +8716,13 @@ def enhanced_search_source(source_name): client = hydrabase_client else: return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) + elif source_name == 'musicbrainz': + try: + from core.musicbrainz_search import MusicBrainzSearchClient + client = MusicBrainzSearchClient() + except Exception as e: + logger.warning(f"MusicBrainz search client init failed: {e}") + return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) def generate(): # Stream each search type as it completes @@ -22486,6 +22495,19 @@ def get_version_info(): "title": "What's New in SoulSync", "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes", "sections": [ + { + "title": "MusicBrainz Search Tab", + "description": "Find tracks and albums on MusicBrainz's community database — covers obscure music that Spotify/Deezer/iTunes miss", + "features": [ + "• New tab in Enhanced Search and Global Search alongside existing sources", + "• Searches recordings, releases, and artists on MusicBrainz", + "• Cover art from Cover Art Archive (free, linked by release ID)", + "• Click results to open download modal with full tracklist — same flow as other sources", + "• Smart query parsing splits 'Artist Title' into structured artist + title search", + "• Deduplicates album results (keeps best version with date and art)", + "• Always available — public API, no authentication needed", + ], + }, { "title": "SoulSync Standalone Library", "description": "Use SoulSync without Plex, Jellyfin, or Navidrome — manage your library directly", @@ -32998,6 +33020,17 @@ def get_album_tracks(album_id): client = _get_deezer_client() elif source_override == 'discogs': client = _get_discogs_client() + elif source_override == 'musicbrainz': + try: + from core.musicbrainz_search import MusicBrainzSearchClient + mb_search = MusicBrainzSearchClient() + album_data = mb_search.get_album(album_id) + if not album_data: + return jsonify({"error": "Album not found on MusicBrainz"}), 404 + return jsonify(album_data) + except Exception as e: + logger.error(f"MusicBrainz album detail failed: {e}") + return jsonify({"error": str(e)}), 500 album_data = client.get_album(album_id) if not album_data: diff --git a/webui/static/helper.js b/webui/static/helper.js index 7672af6b..1f88fd73 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3599,7 +3599,11 @@ function closeHelperSearch() { // ═══════════════════════════════════════════════════════════════════════════ const WHATS_NEW = { - '2.31': [ + '2.32': [ + // --- April 18, 2026 --- + { date: 'April 18, 2026' }, + { title: 'MusicBrainz Search Tab', desc: 'New search tab in Enhanced and Global search — find tracks and albums on MusicBrainz\'s community database. Cover art from Cover Art Archive. Click results to open download modal with full tracklist. Finds obscure tracks that Spotify/Deezer/iTunes miss', page: 'downloads' }, + // --- April 17, 2026 --- { date: 'April 17, 2026' }, { title: 'SoulSync Standalone Library', desc: 'New "Standalone" server option — manage your library without Plex, Jellyfin, or Navidrome. Downloads and imports write directly to the library database with pre-populated enrichment IDs. Deep scan finds untracked files and cleans stale records. Select in Settings → Connections', page: 'settings' }, @@ -3695,12 +3699,12 @@ const WHATS_NEW = { function _getCurrentVersion() { const btn = document.querySelector('.version-button'); - return btn ? btn.textContent.trim().replace('v', '') : '2.31'; + return btn ? btn.textContent.trim().replace('v', '') : '2.32'; } function _getLatestWhatsNewVersion() { const versions = Object.keys(WHATS_NEW).sort((a, b) => parseFloat(b) - parseFloat(a)); - return versions[0] || '2.31'; + return versions[0] || '2.32'; } function openWhatsNew() { diff --git a/webui/static/script.js b/webui/static/script.js index a9e48260..fc19122a 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -8573,6 +8573,7 @@ function initializeSearchModeToggle() { discogs: { text: 'Discogs', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' }, hydrabase: { text: 'Hydrabase', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase' }, youtube_videos: { text: 'Music Videos', tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube' }, + musicbrainz: { text: 'MusicBrainz', tabClass: 'enh-tab-musicbrainz', badgeClass: 'enh-badge-musicbrainz' }, }; // Live search with debouncing @@ -18069,7 +18070,7 @@ function _gsRender(data) { return; } - const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase', youtube_videos: 'Music Videos' }; + const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase', youtube_videos: 'Music Videos', musicbrainz: 'MusicBrainz' }; const srcLabel = sourceLabels[_gsState.activeSource] || _gsState.activeSource || ''; let h = ''; diff --git a/webui/static/style.css b/webui/static/style.css index 52d1b3cf..6a507008 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -5359,7 +5359,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { bottom: 76px; left: 50%; transform: translateX(-50%); - width: 620px; + width: 920px; max-width: 95vw; max-height: 60vh; background: rgba(14, 14, 20, 0.97); @@ -33075,6 +33075,7 @@ div.artist-hero-badge { .enh-source-tab.enh-tab-discogs.active { background: rgba(212, 165, 116, 0.2); color: #D4A574; } .enh-source-tab.enh-tab-hydrabase.active { background: rgba(0, 180, 216, 0.2); color: #00b4d8; } .enh-source-tab.enh-tab-youtube.active { background: rgba(255, 0, 0, 0.2); color: #ff4444; } +.enh-source-tab.enh-tab-musicbrainz.active { background: rgba(186, 51, 88, 0.2); color: #BA3358; } /* Music Video Grid */ .enh-video-grid {