Add MusicBrainz search tab, wider global search, bump to v2.32

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
This commit is contained in:
Broque Thomas 2026-04-18 02:06:27 -07:00
parent 70005968b6
commit 4f5025d526
5 changed files with 459 additions and 7 deletions

413
core/musicbrainz_search.py Normal file
View file

@ -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 []

View file

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

View file

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

View file

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

View file

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