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:
parent
70005968b6
commit
4f5025d526
5 changed files with 459 additions and 7 deletions
413
core/musicbrainz_search.py
Normal file
413
core/musicbrainz_search.py
Normal 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 []
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue