Refactor post-processing metadata lookups + fix Hydrabase as fallback source

Post-processing: Extract 7 inline source lookup blocks into standalone
_pp_lookup_* functions called in configurable order via
metadata_enhancement.post_process_order config. Default order matches
original hardcoded sequence — zero behavioral change.

Hydrabase: Fix connected Hydrabase incorrectly becoming primary source.
_is_hydrabase_active() now only returns True in dev_mode (legacy).
Auto-connect no longer forces dev_mode. Hydrabase as fallback works
through normal _get_metadata_fallback_client path like iTunes/Deezer.
Settings button min-width fix.
This commit is contained in:
Broque Thomas 2026-03-20 15:48:48 -07:00
parent eab527224f
commit d7b9b3ba26
2 changed files with 382 additions and 83 deletions

View file

@ -414,7 +414,8 @@ class ConfigManager:
}, },
"metadata_enhancement": { "metadata_enhancement": {
"enabled": True, "enabled": True,
"embed_album_art": True "embed_album_art": True,
"post_process_order": ["musicbrainz", "deezer", "audiodb", "tidal", "qobuz", "lastfm", "genius"]
}, },
"musicbrainz": { "musicbrainz": {
"embed_tags": True "embed_tags": True

View file

@ -4559,16 +4559,14 @@ _COMPARISON_MAX_ENTRIES = 50
_comparison_lock = threading.Lock() _comparison_lock = threading.Lock()
def _is_hydrabase_active(): def _is_hydrabase_active():
"""Check if Hydrabase should be used as the primary metadata source. """Check if Hydrabase should be used as the PRIMARY metadata source (replaces Spotify).
Active when: (dev_mode OR selected as fallback source) AND client connected.""" Only active in dev mode the legacy 'Hydrabase replaces everything' behavior.
When selected as a fallback source, Hydrabase works through the normal fallback
path (_get_metadata_fallback_client) just like iTunes/Deezer not as primary."""
try: try:
if hydrabase_client is None or not hydrabase_client.is_connected(): if hydrabase_client is None or not hydrabase_client.is_connected():
return False return False
# Dev mode always enables Hydrabase (legacy behavior) return dev_mode_enabled
if dev_mode_enabled:
return True
# Selected as the fallback metadata source
return _get_metadata_fallback_source() == 'hydrabase'
except (NameError, Exception): except (NameError, Exception):
return False return False
@ -15357,85 +15355,70 @@ def _embed_source_ids(audio_file, metadata: dict):
if metadata.get('itunes_album_id'): if metadata.get('itunes_album_id'):
id_tags['ITUNES_ALBUM_ID'] = metadata['itunes_album_id'] id_tags['ITUNES_ALBUM_ID'] = metadata['itunes_album_id']
# ── 2a. MusicBrainz lookup for MBID, genres, and ISRC ── # Shared post-processing context for modular lookups
# The global rate limiter in musicbrainz_client.py serializes all API
# calls (worker + any number of post-processing threads) to 1 req/sec
# via _api_call_lock, so no pause/resume needed.
recording_mbid = None
artist_mbid = None
_rc_mbid = ''
mb_genres = []
isrc = None
track_title = metadata.get('title', '') track_title = metadata.get('title', '')
# Use album_artist (single primary artist) for MB lookup, not the
# comma-joined multi-artist field which would give bad search results
artist_name = metadata.get('album_artist', '') or metadata.get('artist', '') artist_name = metadata.get('album_artist', '') or metadata.get('artist', '')
pp = {
'id_tags': id_tags,
'track_title': track_title,
'artist_name': artist_name,
'metadata': metadata,
'recording_mbid': None,
'artist_mbid': None,
'release_mbid': '',
'mb_genres': [],
'isrc': None,
'deezer_bpm': None, 'deezer_isrc': None,
'audiodb_mood': None, 'audiodb_style': None, 'audiodb_genre': None,
'tidal_isrc': None, 'tidal_copyright': None,
'qobuz_isrc': None, 'qobuz_copyright': None, 'qobuz_label': None,
'lastfm_tags': [], 'lastfm_url': None,
'genius_url': None,
}
if not config_manager.get('musicbrainz.embed_tags', True): # Run each metadata source lookup in configured order
# Skip MB lookup, just write Spotify/iTunes IDs if any _pp_source_order = config_manager.get('metadata_enhancement.post_process_order', None)
pass if not _pp_source_order or not isinstance(_pp_source_order, list):
elif track_title and artist_name: _pp_source_order = ['musicbrainz', 'deezer', 'audiodb', 'tidal', 'qobuz', 'lastfm', 'genius']
try:
mb_service = mb_worker.mb_service if mb_worker else None
if mb_service:
result = mb_service.match_recording(track_title, artist_name)
if result and result.get('mbid'):
recording_mbid = result['mbid']
id_tags['MUSICBRAINZ_RECORDING_ID'] = recording_mbid
print(f"🎵 MusicBrainz recording matched: {recording_mbid}")
# Lookup recording details for ISRC and genres _pp_lookup_map = {
details = mb_service.mb_client.get_recording( 'musicbrainz': _pp_lookup_musicbrainz,
recording_mbid, includes=['isrcs', 'genres'] 'deezer': _pp_lookup_deezer,
) 'audiodb': _pp_lookup_audiodb,
if details: 'tidal': _pp_lookup_tidal,
isrcs = details.get('isrcs', []) 'qobuz': _pp_lookup_qobuz,
if isrcs: 'lastfm': _pp_lookup_lastfm,
isrc = isrcs[0] 'genius': _pp_lookup_genius,
mb_genres = [ }
g['name'] for g in sorted(
details.get('genres', []),
key=lambda x: x.get('count', 0),
reverse=True
)
]
# Use track artist (not album artist) for artist MBID tag for source_name in _pp_source_order:
track_artist_name = metadata.get('artist', '') or artist_name fn = _pp_lookup_map.get(source_name)
# For multi-artist tracks, use the first artist only if fn:
if ', ' in track_artist_name: fn(pp, _names_match)
track_artist_name = track_artist_name.split(', ')[0]
artist_result = mb_service.match_artist(track_artist_name)
if artist_result and artist_result.get('mbid'):
artist_mbid = artist_result['mbid']
id_tags['MUSICBRAINZ_ARTIST_ID'] = artist_mbid
# Get release (album) MBID via thread-safe in-memory cache. # Extract results from shared context after all lookups
# Without this, concurrent threads each call match_release recording_mbid = pp['recording_mbid']
# and get different release variants, splitting albums. artist_mbid = pp['artist_mbid']
album_name_for_mb = metadata.get('album', '') _rc_mbid = pp['release_mbid']
if album_name_for_mb: mb_genres = pp['mb_genres']
_rc_key = (album_name_for_mb.lower().strip(), artist_name.lower().strip()) isrc = pp['isrc']
with _mb_release_cache_lock: deezer_bpm = pp['deezer_bpm']
if _rc_key in _mb_release_cache: deezer_isrc = pp['deezer_isrc']
_rc_mbid = _mb_release_cache[_rc_key] audiodb_mood = pp['audiodb_mood']
else: audiodb_style = pp['audiodb_style']
try: audiodb_genre = pp['audiodb_genre']
_rc_result = mb_service.match_release(album_name_for_mb, artist_name) tidal_isrc = pp['tidal_isrc']
_rc_mbid = _rc_result.get('mbid', '') if _rc_result else '' tidal_copyright = pp['tidal_copyright']
except Exception: qobuz_isrc = pp['qobuz_isrc']
_rc_mbid = '' qobuz_copyright = pp['qobuz_copyright']
_mb_release_cache[_rc_key] = _rc_mbid qobuz_label = pp['qobuz_label']
if _rc_mbid: lastfm_tags = pp['lastfm_tags']
id_tags['MUSICBRAINZ_RELEASE_ID'] = _rc_mbid lastfm_url = pp['lastfm_url']
else: genius_url = pp['genius_url']
print("⚠️ MusicBrainz worker not available, skipping MBID lookup") id_tags = pp['id_tags']
except Exception as e:
print(f"⚠️ MusicBrainz lookup failed (non-fatal): {e}")
# ── 2a-2. MusicBrainz release details (release group, barcode, media, etc.) ── # (All source lookups now handled by _pp_lookup_* functions called via configurable order above)
# One API call per release, cached across all tracks on the same album. if False: # Dead code — old inline blocks preserved for reference during transition
if _rc_mbid:
try: try:
mb_service_for_detail = mb_worker.mb_service if mb_worker else None mb_service_for_detail = mb_worker.mb_service if mb_worker else None
if mb_service_for_detail: if mb_service_for_detail:
@ -16022,6 +16005,322 @@ def _match_track_to_spotify_title(slsk_track_meta: dict, spotify_tracks: list) -
# --- Post-Processing Logic --- # --- Post-Processing Logic ---
# ── Modular post-processing metadata lookup functions ──
# Each function receives a shared `pp` context dict and writes its results to it.
# The orchestrator in _post_process_matched_download calls them in configurable order.
def _pp_lookup_musicbrainz(pp, _names_match):
"""MusicBrainz: recording MBID, artist MBID, release MBID, ISRC, genres, release details."""
if not config_manager.get('musicbrainz.embed_tags', True):
return
track_title = pp['track_title']
artist_name = pp['artist_name']
metadata = pp['metadata']
id_tags = pp['id_tags']
if not track_title or not artist_name:
return
try:
mb_service = mb_worker.mb_service if mb_worker else None
if not mb_service:
print("⚠️ MusicBrainz worker not available, skipping MBID lookup")
return
result = mb_service.match_recording(track_title, artist_name)
if result and result.get('mbid'):
pp['recording_mbid'] = result['mbid']
id_tags['MUSICBRAINZ_RECORDING_ID'] = pp['recording_mbid']
print(f"🎵 MusicBrainz recording matched: {pp['recording_mbid']}")
details = mb_service.mb_client.get_recording(pp['recording_mbid'], includes=['isrcs', 'genres'])
if details:
isrcs = details.get('isrcs', [])
if isrcs:
pp['isrc'] = isrcs[0]
pp['mb_genres'] = [g['name'] for g in sorted(details.get('genres', []), key=lambda x: x.get('count', 0), reverse=True)]
track_artist_name = metadata.get('artist', '') or artist_name
if ', ' in track_artist_name:
track_artist_name = track_artist_name.split(', ')[0]
artist_result = mb_service.match_artist(track_artist_name)
if artist_result and artist_result.get('mbid'):
pp['artist_mbid'] = artist_result['mbid']
id_tags['MUSICBRAINZ_ARTIST_ID'] = pp['artist_mbid']
album_name_for_mb = metadata.get('album', '')
if album_name_for_mb:
_rc_key = (album_name_for_mb.lower().strip(), artist_name.lower().strip())
with _mb_release_cache_lock:
if _rc_key in _mb_release_cache:
pp['release_mbid'] = _mb_release_cache[_rc_key]
else:
try:
_rc_result = mb_service.match_release(album_name_for_mb, artist_name)
pp['release_mbid'] = _rc_result.get('mbid', '') if _rc_result else ''
except Exception:
pp['release_mbid'] = ''
_mb_release_cache[_rc_key] = pp['release_mbid']
if pp['release_mbid']:
id_tags['MUSICBRAINZ_RELEASE_ID'] = pp['release_mbid']
# Release details (group, barcode, media, etc.)
if pp['release_mbid']:
with _mb_release_detail_cache_lock:
release_detail = _mb_release_detail_cache.get(pp['release_mbid'])
if release_detail is None:
release_detail = mb_service.mb_client.get_release(
pp['release_mbid'], includes=['release-groups', 'labels', 'media', 'artist-credits', 'recordings']
) or {}
with _mb_release_detail_cache_lock:
_mb_release_detail_cache[pp['release_mbid']] = release_detail
if release_detail:
rg = release_detail.get('release-group', {})
if rg.get('id'): id_tags['MUSICBRAINZ_RELEASEGROUPID'] = rg['id']
ac = release_detail.get('artist-credit', [])
if ac and isinstance(ac[0], dict):
aa = ac[0].get('artist', {})
if aa.get('id'): id_tags['MUSICBRAINZ_ALBUMARTISTID'] = aa['id']
if rg.get('primary-type'): id_tags['RELEASETYPE'] = rg['primary-type']
if rg.get('first-release-date'): id_tags['ORIGINALDATE'] = rg['first-release-date']
if release_detail.get('status'): id_tags['RELEASESTATUS'] = release_detail['status']
if release_detail.get('country'): id_tags['RELEASECOUNTRY'] = release_detail['country']
if release_detail.get('barcode'): id_tags['BARCODE'] = release_detail['barcode']
media_list = release_detail.get('media', [])
if media_list:
fmt = media_list[0].get('format', '')
if fmt: id_tags['MEDIA'] = fmt
id_tags['TOTALDISCS'] = str(len(media_list))
label_info = release_detail.get('label-info', [])
if label_info and isinstance(label_info[0], dict):
cat = label_info[0].get('catalog-number', '')
if cat: id_tags['CATALOGNUMBER'] = cat
text_rep = release_detail.get('text-representation', {})
if isinstance(text_rep, dict) and text_rep.get('script'):
id_tags['SCRIPT'] = text_rep['script']
if release_detail.get('asin'): id_tags['ASIN'] = release_detail['asin']
_trk_num = metadata.get('track_number')
_disc_num = metadata.get('disc_number') or 1
if _trk_num and media_list:
try:
_trk_num_int, _disc_num_int = int(_trk_num), int(_disc_num)
for medium in media_list:
if medium.get('position', 1) == _disc_num_int:
for mtrack in (medium.get('tracks') or medium.get('track-list', [])):
if mtrack.get('position') == _trk_num_int and mtrack.get('id'):
id_tags['MUSICBRAINZ_RELEASETRACKID'] = mtrack['id']
break
break
except (ValueError, TypeError):
pass
except Exception as e:
print(f"⚠️ MusicBrainz lookup failed (non-fatal): {e}")
def _pp_lookup_deezer(pp, _names_match):
"""Deezer: BPM, ISRC fallback, track/artist IDs."""
if not config_manager.get('deezer.embed_tags', True):
return
track_title, artist_name = pp['track_title'], pp['artist_name']
id_tags = pp['id_tags']
if not track_title or not artist_name:
return
try:
dz_client = deezer_worker.client if deezer_worker else None
if not dz_client:
print("⚠️ Deezer worker not available, skipping Deezer lookup")
return
dz_result = dz_client.search_track(artist_name, track_title)
if dz_result and _names_match(dz_result.get('title', ''), track_title) and \
_names_match(dz_result.get('artist', {}).get('name', ''), artist_name):
dz_track_id = dz_result['id']
id_tags['DEEZER_TRACK_ID'] = str(dz_track_id)
dz_artist_id = dz_result.get('artist', {}).get('id')
if dz_artist_id:
id_tags['DEEZER_ARTIST_ID'] = str(dz_artist_id)
print(f"🎵 Deezer track matched: {dz_track_id}")
dz_details = dz_client.get_track_details(dz_track_id)
if dz_details:
bpm_val = dz_details.get('bpm')
if bpm_val and bpm_val > 0:
pp['deezer_bpm'] = bpm_val
dz_isrc = dz_details.get('isrc')
if dz_isrc:
pp['deezer_isrc'] = dz_isrc
except Exception as e:
print(f"⚠️ Deezer lookup failed (non-fatal): {e}")
def _pp_lookup_audiodb(pp, _names_match):
"""AudioDB: mood, style, genre, track ID, MusicBrainz ID fallbacks."""
if not config_manager.get('audiodb.embed_tags', True):
return
track_title, artist_name = pp['track_title'], pp['artist_name']
id_tags = pp['id_tags']
if not track_title or not artist_name:
return
try:
adb_client = audiodb_worker.client if audiodb_worker else None
if not adb_client:
print("⚠️ AudioDB worker not available, skipping AudioDB lookup")
return
adb_result = adb_client.search_track(artist_name, track_title)
if adb_result and _names_match(adb_result.get('strTrack', ''), track_title) and \
_names_match(adb_result.get('strArtist', ''), artist_name):
adb_track_id = adb_result.get('idTrack')
if adb_track_id:
id_tags['AUDIODB_TRACK_ID'] = str(adb_track_id)
print(f"🎵 AudioDB track matched: {adb_track_id}")
adb_mb_track = adb_result.get('strMusicBrainzID')
if adb_mb_track and 'MUSICBRAINZ_RECORDING_ID' not in id_tags:
id_tags['MUSICBRAINZ_RECORDING_ID'] = adb_mb_track
pp['recording_mbid'] = adb_mb_track
print(f"🎵 MusicBrainz recording ID from AudioDB fallback: {adb_mb_track}")
adb_mb_artist = adb_result.get('strMusicBrainzArtistID')
if adb_mb_artist and 'MUSICBRAINZ_ARTIST_ID' not in id_tags:
id_tags['MUSICBRAINZ_ARTIST_ID'] = adb_mb_artist
pp['artist_mbid'] = adb_mb_artist
print(f"🎵 MusicBrainz artist ID from AudioDB fallback: {adb_mb_artist}")
pp['audiodb_mood'] = adb_result.get('strMood') or None
pp['audiodb_style'] = adb_result.get('strStyle') or None
pp['audiodb_genre'] = adb_result.get('strGenre') or None
except Exception as e:
print(f"⚠️ AudioDB lookup failed (non-fatal): {e}")
def _pp_lookup_tidal(pp, _names_match):
"""Tidal: ISRC fallback, copyright, track/artist IDs."""
if not config_manager.get('tidal.embed_tags', True):
return
track_title, artist_name = pp['track_title'], pp['artist_name']
id_tags = pp['id_tags']
if not track_title or not artist_name:
return
try:
if not (tidal_client and tidal_client.is_authenticated()):
return
td_result = tidal_client.search_track(artist_name, track_title)
if td_result and _names_match(td_result.get('title', ''), track_title):
td_track_id = td_result.get('id')
if td_track_id:
id_tags['TIDAL_TRACK_ID'] = str(td_track_id)
print(f"🎵 Tidal track matched: {td_track_id}")
td_artist = td_result.get('artist', {})
if isinstance(td_artist, dict) and td_artist.get('id'):
id_tags['TIDAL_ARTIST_ID'] = str(td_artist['id'])
if td_track_id:
td_details = tidal_client.get_track(str(td_track_id))
if td_details:
td_isrc = td_details.get('isrc')
if td_isrc:
pp['tidal_isrc'] = td_isrc
td_copyright = td_details.get('copyright')
if isinstance(td_copyright, dict):
td_copyright = td_copyright.get('text', td_copyright.get('name', ''))
if td_copyright:
pp['tidal_copyright'] = td_copyright
except Exception as e:
print(f"⚠️ Tidal lookup failed (non-fatal): {e}")
def _pp_lookup_qobuz(pp, _names_match):
"""Qobuz: ISRC fallback, copyright, label, track/artist IDs."""
if not config_manager.get('qobuz.embed_tags', True):
return
track_title, artist_name = pp['track_title'], pp['artist_name']
id_tags = pp['id_tags']
if not track_title or not artist_name:
return
try:
qz_client = qobuz_enrichment_worker.client if qobuz_enrichment_worker else None
if not (qz_client and qz_client.is_authenticated()):
return
qz_result = qz_client.search_track(artist_name, track_title)
if qz_result:
qz_performer = (qz_result.get('performer') or {})
if not isinstance(qz_performer, dict):
qz_performer = {}
qz_artist_name = qz_performer.get('name', '')
if _names_match(qz_result.get('title', ''), track_title) and \
_names_match(qz_artist_name, artist_name):
qz_track_id = qz_result.get('id')
if qz_track_id:
id_tags['QOBUZ_TRACK_ID'] = str(qz_track_id)
print(f"🎵 Qobuz track matched: {qz_track_id}")
if isinstance(qz_performer, dict) and qz_performer.get('id'):
id_tags['QOBUZ_ARTIST_ID'] = str(qz_performer['id'])
qz_isrc = qz_result.get('isrc')
if isinstance(qz_isrc, dict):
qz_isrc = qz_isrc.get('value', qz_isrc.get('id', ''))
if qz_isrc:
pp['qobuz_isrc'] = qz_isrc
qz_copyright = qz_result.get('copyright')
if isinstance(qz_copyright, dict):
qz_copyright = qz_copyright.get('text', qz_copyright.get('name', ''))
if qz_copyright and isinstance(qz_copyright, str):
pp['qobuz_copyright'] = qz_copyright
qz_album = qz_result.get('album', {})
if isinstance(qz_album, dict):
qz_label_info = qz_album.get('label', {})
if isinstance(qz_label_info, dict) and qz_label_info.get('name'):
pp['qobuz_label'] = qz_label_info['name']
except Exception as e:
print(f"⚠️ Qobuz lookup failed (non-fatal): {e}")
def _pp_lookup_lastfm(pp, _names_match):
"""Last.fm: genre tags, track URL."""
if not config_manager.get('lastfm.embed_tags', True):
return
track_title, artist_name = pp['track_title'], pp['artist_name']
if not track_title or not artist_name:
return
try:
lf_client = lastfm_worker.client if lastfm_worker else None
if not lf_client:
return
lf_result = lf_client.get_track_info(artist_name, track_title)
if lf_result:
lf_url = lf_result.get('url')
if lf_url:
pp['lastfm_url'] = lf_url
lf_toptags = lf_result.get('toptags', {})
if isinstance(lf_toptags, dict):
tag_list = lf_toptags.get('tag', [])
if isinstance(tag_list, list):
pp['lastfm_tags'] = [t.get('name', '') for t in tag_list if isinstance(t, dict) and t.get('name')]
elif isinstance(tag_list, dict) and tag_list.get('name'):
pp['lastfm_tags'] = [tag_list['name']]
print(f"🎵 Last.fm track info found: {len(pp['lastfm_tags'])} tags")
except Exception as e:
print(f"⚠️ Last.fm lookup failed (non-fatal): {e}")
def _pp_lookup_genius(pp, _names_match):
"""Genius: track ID, URL."""
if not config_manager.get('genius.embed_tags', True):
return
track_title, artist_name = pp['track_title'], pp['artist_name']
id_tags = pp['id_tags']
if not track_title or not artist_name:
return
try:
import core.genius_client as _genius_module
if time.time() < _genius_module._rate_limit_until:
print("⏭️ Genius rate-limited, skipping (non-blocking)")
return
g_client = genius_worker.client if genius_worker else None
if not g_client:
return
g_result = g_client.search_song(artist_name, track_title)
if g_result:
g_id = g_result.get('id')
if g_id:
id_tags['GENIUS_TRACK_ID'] = str(g_id)
print(f"🎵 Genius song matched: {g_id}")
g_url = g_result.get('url')
if g_url:
pp['genius_url'] = g_url
except Exception as e:
print(f"⚠️ Genius lookup failed (non-fatal): {e}")
def _post_process_matched_download_with_verification(context_key, context, file_path, task_id, batch_id): def _post_process_matched_download_with_verification(context_key, context, file_path, task_id, batch_id):
""" """
NEW VERIFICATION WORKFLOW: Enhanced post-processing with file verification. NEW VERIFICATION WORKFLOW: Enhanced post-processing with file verification.
@ -40778,9 +41077,8 @@ try:
timeout=10 timeout=10
) )
_hydrabase_ws = _auto_ws _hydrabase_ws = _auto_ws
# Enable dev mode only if not using Hydrabase as a regular fallback source # Don't auto-enable dev mode — user must explicitly activate dev mode
if _get_metadata_fallback_source() != 'hydrabase': # Auto-connect just establishes the WebSocket for fallback/search tab use
dev_mode_enabled = True
print(f"✅ Hydrabase auto-connected to {_hydra_cfg['url']}") print(f"✅ Hydrabase auto-connected to {_hydra_cfg['url']}")
except Exception as e: except Exception as e:
print(f"⚠️ Hydrabase auto-reconnect failed: {e}") print(f"⚠️ Hydrabase auto-reconnect failed: {e}")