From e83ee239900853173a27662a5ac20bfe58464e68 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 10 Apr 2026 06:34:21 -0700 Subject: [PATCH 01/19] Add Reduce Visual Effects toggle for low-end devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New toggle in Settings > Appearance disables backdrop blur (220 instances), animations (238), transitions (961), and box shadows (804) across the entire UI via a single body class. Significantly reduces GPU/CPU usage on low-end devices. Default off — no change for existing users. Applied from localStorage on load to prevent flash. --- webui/index.html | 7 +++++++ webui/static/script.js | 31 +++++++++++++++++++++++++++++-- webui/static/style.css | 13 +++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/webui/index.html b/webui/index.html index 47ec78aa..051f90f1 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5640,6 +5640,13 @@ Dashboard header buttons animate as floating orbs. Hover the header to expand. Desktop only. +
+ + Disables backdrop blur, animations, transitions, and shadows. Significantly reduces GPU/CPU usage on low-end devices. +
diff --git a/webui/static/script.js b/webui/static/script.js index cf3a08b9..82f69186 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -892,10 +892,30 @@ function initAccentColorListeners() { applyWorkerOrbsSetting(workerOrbsCheckbox.checked); }); } + + // Reduce effects toggle — apply immediately on change + const reduceEffectsCheckbox = document.getElementById('reduce-effects-enabled'); + if (reduceEffectsCheckbox) { + reduceEffectsCheckbox.addEventListener('change', () => { + applyReduceEffects(reduceEffectsCheckbox.checked); + }); + } } -// Bootstrap accent from localStorage instantly (prevents default-color flash) +function applyReduceEffects(enabled) { + if (enabled) { + document.body.classList.add('reduce-effects'); + } else { + document.body.classList.remove('reduce-effects'); + } + localStorage.setItem('soulsync-reduce-effects', enabled ? '1' : '0'); +} + +// Bootstrap accent and reduce-effects from localStorage instantly (prevents flash) (function() { + if (localStorage.getItem('soulsync-reduce-effects') === '1') { + document.body.classList.add('reduce-effects'); + } const saved = localStorage.getItem('soulsync-accent'); if (saved) applyAccentColor(saved); // Bootstrap particles setting from localStorage @@ -6024,6 +6044,12 @@ async function loadSettingsData() { if (workerOrbsCheckbox) workerOrbsCheckbox.checked = workerOrbsEnabled; applyWorkerOrbsSetting(workerOrbsEnabled); + // Reduce effects toggle + const reduceEffects = settings.ui_appearance?.reduce_effects === true; // default false + const reduceCheckbox = document.getElementById('reduce-effects-enabled'); + if (reduceCheckbox) reduceCheckbox.checked = reduceEffects; + applyReduceEffects(reduceEffects); + // Populate Logging information (read-only) document.getElementById('log-level-display').textContent = settings.logging?.level || 'INFO'; document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log'; @@ -7154,7 +7180,8 @@ async function saveSettings(quiet = false) { accent_color: document.getElementById('accent-custom-color')?.value || '#1db954', sidebar_visualizer: document.getElementById('sidebar-visualizer-type')?.value || 'bars', particles_enabled: document.getElementById('particles-enabled')?.checked !== false, - worker_orbs_enabled: document.getElementById('worker-orbs-enabled')?.checked !== false + worker_orbs_enabled: document.getElementById('worker-orbs-enabled')?.checked !== false, + reduce_effects: document.getElementById('reduce-effects-enabled')?.checked === true }, youtube: { cookies_browser: document.getElementById('youtube-cookies-browser').value, diff --git a/webui/static/style.css b/webui/static/style.css index 4e17c6eb..b71cd982 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -54967,3 +54967,16 @@ tr:hover .enhanced-track-actions-group { opacity: 1; } font-size: 12px; flex-shrink: 0; transition: all 0.15s; } .blacklist-entry-remove:hover { background: rgba(239, 83, 80, 0.12); color: #ef5350; } + +/* ── Reduce Visual Effects ── Disables GPU-heavy properties globally */ +body.reduce-effects *, +body.reduce-effects *::before, +body.reduce-effects *::after { + animation-duration: 0s !important; + animation-delay: 0s !important; + transition-duration: 0s !important; + transition-delay: 0s !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + box-shadow: none !important; +} From dd5291456b26628758c57d6ac8cae4992bc3bf71 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:23:43 -0700 Subject: [PATCH 02/19] Fix playlist pipeline discovery data loss and Unknown Artist bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovery workers now respect the user's configured primary metadata source instead of always using Spotify when authenticated. This completes the intent of commit 3c211ea. The core fix addresses data loss in the discovery→sync→wishlist→download pipeline: the Track dataclass strips album metadata to a plain string, losing album ID, track_number, release_date, and images. Discovery workers now enrich results via get_track_details() to recover this data. Deezer's get_track_details() cache validation was incorrectly trusting search-result cache (which lacks track_position), returning track_number=0. Also fixes wishlist download processing where albums without IDs couldn't map to artists, and the fallback read 'artist' (singular) instead of 'artists' (plural), always producing "Unknown Artist". Includes a one-time migration to purge stale discovery cache entries. --- core/deezer_client.py | 8 +- database/music_database.py | 19 +++- web_server.py | 217 ++++++++++++++++++++++++++++++------- 3 files changed, 200 insertions(+), 44 deletions(-) diff --git a/core/deezer_client.py b/core/deezer_client.py index f6981240..38c1b4cc 100644 --- a/core/deezer_client.py +++ b/core/deezer_client.py @@ -392,9 +392,11 @@ class DeezerClient: cache = get_metadata_cache() cached = cache.get_entity('deezer', 'track', str(track_id)) if cached and cached.get('title'): - # Search results are cached with minimal data (no release_date, track_position). - # Only use cache if it has fields that the /track/{id} endpoint provides. - if 'release_date' in cached or 'track_position' in cached or 'isrc' in cached: + # Search results are cached with minimal data (no track_position). + # Only use cache if it has track_position — the key field from /track/{id}. + # Search results include 'isrc' and 'release_date' but NOT track_position, + # so those fields alone are not sufficient to distinguish full from partial data. + if 'track_position' in cached: return self._build_enhanced_track(cached) # Otherwise fall through to fetch full data from API diff --git a/database/music_database.py b/database/music_database.py index 34e51570..2f176efb 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -551,9 +551,26 @@ class MusicDatabase: except Exception: pass + # One-time migration: purge discovery cache entries that lack track_number. + # Prior versions cached discovery results without track_number/disc_number/release_date, + # causing incorrect file organization (all tracks as "01", missing album year). + # Purged entries get re-populated with complete data on next discovery. + try: + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='_discovery_cache_v2_migrated'") + if not cursor.fetchone(): + cursor.execute("DELETE FROM discovery_match_cache WHERE id IN (" + "SELECT id FROM discovery_match_cache WHERE " + "matched_data_json NOT LIKE '%track_number%')") + purged = cursor.rowcount + cursor.execute("CREATE TABLE _discovery_cache_v2_migrated (applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)") + if purged > 0: + logger.info(f"Purged {purged} stale discovery cache entries (missing track_number)") + except Exception: + pass + conn.commit() logger.info("Database initialized successfully") - + except Exception as e: logger.error(f"Error initializing database: {e}") raise diff --git a/web_server.py b/web_server.py index 1b519105..16d1d9be 100644 --- a/web_server.py +++ b/web_server.py @@ -855,13 +855,18 @@ def _register_automation_handlers(): md = extra['matched_data'] album_raw = md.get('album', '') album_obj = album_raw if isinstance(album_raw, dict) else {'name': album_raw or ''} - tracks_json.append({ + _track_entry = { 'name': md.get('name', ''), 'artists': md.get('artists', [{'name': t.get('artist_name', '')}]), 'album': album_obj, 'duration_ms': md.get('duration_ms', 0), 'id': md.get('id', ''), - }) + } + if md.get('track_number'): + _track_entry['track_number'] = md['track_number'] + if md.get('disc_number'): + _track_entry['disc_number'] = md['disc_number'] + tracks_json.append(_track_entry) else: # NOT discovered — try to include using available metadata so the # track can still be searched on Soulseek and added to wishlist. @@ -26943,6 +26948,9 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json): sp_data = {} album_val = sp_data.get('album') album_id = album_val.get('id') if isinstance(album_val, dict) else album_val if isinstance(album_val, str) else None + # Fallback album key: use album name when ID is missing (e.g. mirrored playlist tracks) + if not album_id and isinstance(album_val, dict) and album_val.get('name'): + album_id = f"_name_{album_val['name'].lower().strip()}" disc_num = sp_data.get('disc_number', t.get('disc_number', 1)) if album_id: wishlist_album_disc_counts[album_id] = max( @@ -26975,7 +26983,14 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json): _fa = _wl_track_artists[0] wishlist_album_artist_map[album_id] = _fa if isinstance(_fa, dict) else {'name': str(_fa)} else: - wishlist_album_artist_map[album_id] = {'name': t.get('artist', 'Unknown Artist')} + # Try top-level 'artists' (wishlist format uses plural) + _tl_artists = t.get('artists', []) + if _tl_artists: + _tla = _tl_artists[0] + _fallback_name = _tla.get('name', str(_tla)) if isinstance(_tla, dict) else str(_tla) + else: + _fallback_name = t.get('artist', '') + wishlist_album_artist_map[album_id] = {'name': _fallback_name or 'Unknown Artist'} print(f"🔗 [Wishlist Album Grouping] Album '{_wl_album.get('name', album_id)}' → artist: '{wishlist_album_artist_map[album_id].get('name', '?')}'") @@ -27015,11 +27030,22 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json): # Use pre-computed album-level artist for folder consistency. # All tracks from the same album get the same artist context, # preventing folder splits on collab albums (KPOP Demon Hunters, etc.) - album_id_for_lookup = s_album.get('id', 'wishlist_album') + album_id_for_lookup = s_album.get('id') + # Fallback album key: match first-pass logic for missing IDs + if not album_id_for_lookup and s_album.get('name'): + album_id_for_lookup = f"_name_{s_album['name'].lower().strip()}" + if not album_id_for_lookup: + album_id_for_lookup = 'wishlist_album' artist_ctx = wishlist_album_artist_map.get(album_id_for_lookup, {}) if not artist_ctx or not artist_ctx.get('name'): - # Fallback: per-track resolution (shouldn't happen, but safety) - artist_ctx = {'name': track_info.get('artist', 'Unknown Artist')} + # Fallback: per-track resolution from artists array + _fb_artists = track_info.get('artists', []) + if _fb_artists: + _fb_a = _fb_artists[0] + _fb_name = _fb_a.get('name', str(_fb_a)) if isinstance(_fb_a, dict) else str(_fb_a) + else: + _fb_name = track_info.get('artist', '') + artist_ctx = {'name': _fb_name or 'Unknown Artist'} # Construct minimal album context # Ensure images are preserved (important for artwork) @@ -28014,20 +28040,21 @@ def _attempt_download_with_candidates(task_id, candidates, track, batch_id=None) got_track_number = True print(f"🔢 [Context] Added track_number from API: {detailed_track['track_number']}, disc_number: {enhanced_payload['disc_number']}") - # Backfill album metadata from detailed track when fallback path - # produced incomplete data - if not has_explicit_context and isinstance(detailed_track.get('album'), dict): + # Backfill album metadata from detailed track when context + # has incomplete data (missing release_date, total_tracks, etc.) + if isinstance(detailed_track.get('album'), dict): dt_album = detailed_track['album'] if not spotify_album_context.get('release_date') and dt_album.get('release_date'): spotify_album_context['release_date'] = dt_album['release_date'] print(f"📅 [Context] Backfilled release_date from API: {dt_album['release_date']}") - if dt_album.get('album_type') and not fallback_album.get('album_type'): + if not spotify_album_context.get('album_type') and dt_album.get('album_type'): spotify_album_context['album_type'] = dt_album['album_type'] if not spotify_album_context.get('total_tracks') and dt_album.get('total_tracks'): spotify_album_context['total_tracks'] = dt_album['total_tracks'] - if not spotify_album_context.get('name') or spotify_album_context['name'] == track.album: - if dt_album.get('name'): - spotify_album_context['name'] = dt_album['name'] + if not spotify_album_context.get('id') and dt_album.get('id'): + spotify_album_context['id'] = dt_album['id'] + if not spotify_album_context.get('image_url') and dt_album.get('images'): + spotify_album_context['image_url'] = dt_album['images'][0].get('url', '') except Exception as e: print(f"⚠️ [Context] API track details failed: {e}") @@ -32324,8 +32351,8 @@ def _run_playlist_discovery_worker(playlists, automation_id=None): _ew_state = {} try: _ew_state = _pause_enrichment_workers('mirrored playlist discovery') - use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() + discovery_source = _get_active_discovery_source() + use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated() itunes_client_instance = None if not use_spotify: @@ -32500,6 +32527,35 @@ def _run_playlist_discovery_worker(playlists, automation_id=None): album_obj = {'name': album_name, 'release_date': getattr(best_match, 'release_date', '') or ''} if match_image: album_obj['images'] = [{'url': match_image, 'height': 600, 'width': 600}] + + # Enrich album data from metadata cache — search_tracks() caches the + # raw API response which has full album info (id, images, total_tracks) + # that the Track dataclass strips to just a name string + track_number = None + disc_number = None + if hasattr(best_match, 'id') and best_match.id: + try: + _raw = cache.get_entity(discovery_source if not use_spotify else 'spotify', 'track', best_match.id) + if _raw and isinstance(_raw.get('album'), dict): + _raw_album = _raw['album'] + if _raw_album.get('id'): + album_obj['id'] = _raw_album['id'] + if _raw_album.get('images') and not album_obj.get('images'): + album_obj['images'] = _raw_album['images'] + if _raw_album.get('total_tracks'): + album_obj['total_tracks'] = _raw_album['total_tracks'] + if _raw_album.get('album_type'): + album_obj['album_type'] = _raw_album['album_type'] + if _raw_album.get('release_date') and not album_obj.get('release_date'): + album_obj['release_date'] = _raw_album['release_date'] + if _raw_album.get('artists'): + album_obj['artists'] = _raw_album['artists'] + if _raw: + track_number = _raw.get('track_number') + disc_number = _raw.get('disc_number') + except Exception: + pass + matched_data = { 'id': best_match.id if hasattr(best_match, 'id') else '', 'name': best_match.name if hasattr(best_match, 'name') else '', @@ -32509,6 +32565,10 @@ def _run_playlist_discovery_worker(playlists, automation_id=None): 'image_url': match_image, 'source': discovery_source, } + if track_number: + matched_data['track_number'] = track_number + if disc_number: + matched_data['disc_number'] = disc_number extra_data = { 'discovered': True, @@ -32737,9 +32797,9 @@ def _run_tidal_discovery_worker(playlist_id): state = tidal_discovery_states[playlist_id] playlist = state['playlist'] - # Determine which provider to use - use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() + # Determine which provider to use — respect user's configured primary source + discovery_source = _get_active_discovery_source() + use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated() # Initialize fallback client if needed itunes_client_instance = None @@ -32838,6 +32898,11 @@ def _run_tidal_discovery_worker(playlist_id): 'image_url': _image_url, 'source': 'spotify' } + # Preserve track_number/disc_number from raw Spotify API data + if raw_track_data and raw_track_data.get('track_number'): + match_data['track_number'] = raw_track_data['track_number'] + if raw_track_data and raw_track_data.get('disc_number'): + match_data['disc_number'] = raw_track_data['disc_number'] result['spotify_data'] = match_data result['match_data'] = match_data result['status'] = 'found' @@ -33050,20 +33115,56 @@ def _search_spotify_for_tidal_track(tidal_track, use_spotify=True, itunes_client track_id = best_match.id if hasattr(best_match, 'id') else '' duration_ms = best_match.duration_ms if hasattr(best_match, 'duration_ms') else 0 - return { + # Fetch full track details to get album ID, track_number, etc. + # The Track dataclass strips this data — the API has it + album_obj = { + 'name': album_name, + 'album_type': 'album', + 'release_date': getattr(best_match, 'release_date', '') or '', + 'images': [{'url': image_url, 'height': 300, 'width': 300}] if image_url else [] + } + track_number = None + disc_number = None + if track_id: + try: + detailed = itunes_client.get_track_details(track_id) + if detailed and isinstance(detailed.get('album'), dict): + dt_album = detailed['album'] + if dt_album.get('id'): + album_obj['id'] = dt_album['id'] + if dt_album.get('total_tracks'): + album_obj['total_tracks'] = dt_album['total_tracks'] + if dt_album.get('release_date') and not album_obj.get('release_date'): + album_obj['release_date'] = dt_album['release_date'] + if dt_album.get('album_type'): + album_obj['album_type'] = dt_album['album_type'] + if dt_album.get('images') and not album_obj.get('images'): + album_obj['images'] = dt_album['images'] + if dt_album.get('artists'): + album_obj['artists'] = dt_album['artists'] + if detailed: + track_number = detailed.get('track_number') + disc_number = detailed.get('disc_number') + print(f"🔢 [Discovery Enrich] {result_name}: track_number={track_number}, disc={disc_number}") + else: + print(f"⚠️ [Discovery Enrich] get_track_details returned None for ID {track_id} ({result_name})") + except Exception as _enrich_err: + print(f"⚠️ [Discovery Enrich] Failed for {result_name} (ID {track_id}): {_enrich_err}") + + result_data = { 'id': track_id, 'name': result_name, 'artists': [result_artist], - 'album': { - 'name': album_name, - 'album_type': 'album', - 'release_date': getattr(best_match, 'release_date', '') or '', - 'images': [{'url': image_url, 'height': 300, 'width': 300}] if image_url else [] - }, + 'album': album_obj, 'duration_ms': duration_ms, 'source': _get_metadata_fallback_source(), 'confidence': best_confidence } + if track_number: + result_data['track_number'] = track_number + if disc_number: + result_data['disc_number'] = disc_number + return result_data else: print(f"❌ No suitable Tidal match found (best confidence was {best_confidence:.3f}, required {min_confidence:.3f})") return None @@ -33090,6 +33191,10 @@ def convert_tidal_results_to_spotify_tracks(discovery_results): 'album': spotify_data['album'], 'duration_ms': spotify_data.get('duration_ms', 0) } + if spotify_data.get('track_number'): + track['track_number'] = spotify_data['track_number'] + if spotify_data.get('disc_number'): + track['disc_number'] = spotify_data['disc_number'] spotify_tracks.append(track) elif result.get('spotify_track') and result.get('status_class') == 'found': # Build from individual fields (automatic discovery format) @@ -33756,8 +33861,8 @@ def _run_deezer_discovery_worker(playlist_id): playlist = state['playlist'] # Determine which provider to use - use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() + discovery_source = _get_active_discovery_source() + use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated() # Initialize fallback client if needed itunes_client_instance = None @@ -33894,6 +33999,11 @@ def _run_deezer_discovery_worker(playlist_id): 'image_url': _image_url, 'source': 'spotify' } + # Preserve track_number/disc_number from raw Spotify API data + if raw_track_data and raw_track_data.get('track_number'): + match_data['track_number'] = raw_track_data['track_number'] + if raw_track_data and raw_track_data.get('disc_number'): + match_data['disc_number'] = raw_track_data['disc_number'] result['spotify_data'] = match_data result['match_data'] = match_data result['status'] = '✅ Found' @@ -34010,6 +34120,10 @@ def convert_deezer_results_to_spotify_tracks(discovery_results): 'album': spotify_data['album'], 'duration_ms': spotify_data.get('duration_ms', 0) } + if spotify_data.get('track_number'): + track['track_number'] = spotify_data['track_number'] + if spotify_data.get('disc_number'): + track['disc_number'] = spotify_data['disc_number'] spotify_tracks.append(track) elif result.get('spotify_track') and result.get('status_class') == 'found': track = { @@ -34574,9 +34688,9 @@ def _run_spotify_public_discovery_worker(url_hash): state = spotify_public_discovery_states[url_hash] playlist = state['playlist'] - # Determine which provider to use - use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() + # Determine which provider to use — respect user's configured primary source + discovery_source = _get_active_discovery_source() + use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated() # Initialize fallback client if needed itunes_client_instance = None @@ -34724,6 +34838,11 @@ def _run_spotify_public_discovery_worker(url_hash): 'image_url': _image_url, 'source': 'spotify' } + # Preserve track_number/disc_number from raw Spotify API data + if raw_track_data and raw_track_data.get('track_number'): + match_data['track_number'] = raw_track_data['track_number'] + if raw_track_data and raw_track_data.get('disc_number'): + match_data['disc_number'] = raw_track_data['disc_number'] result['spotify_data'] = match_data result['match_data'] = match_data result['status'] = '✅ Found' @@ -34837,6 +34956,11 @@ def convert_spotify_public_results_to_spotify_tracks(discovery_results): 'album': spotify_data['album'], 'duration_ms': spotify_data.get('duration_ms', 0) } + # Preserve track_number/disc_number from discovery enrichment + if spotify_data.get('track_number'): + track['track_number'] = spotify_data['track_number'] + if spotify_data.get('disc_number'): + track['disc_number'] = spotify_data['disc_number'] spotify_tracks.append(track) elif result.get('spotify_track') and result.get('status_class') == 'found': track = { @@ -35286,8 +35410,8 @@ def _run_youtube_discovery_worker(url_hash): tracks = playlist['tracks'] # Determine which provider to use (Spotify preferred, iTunes fallback) - use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() + discovery_source = _get_active_discovery_source() + use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated() # Get fallback client itunes_client = _get_metadata_fallback_client() @@ -35600,8 +35724,8 @@ def _run_listenbrainz_discovery_worker(state_key): tracks = playlist['tracks'] # Determine which provider to use (Spotify preferred, iTunes fallback) - use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() + discovery_source = _get_active_discovery_source() + use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated() # Get fallback client itunes_client = _get_metadata_fallback_client() @@ -36190,6 +36314,10 @@ def convert_youtube_results_to_spotify_tracks(discovery_results): 'album': spotify_data['album'], 'duration_ms': spotify_data.get('duration_ms', 0) } + if spotify_data.get('track_number'): + track['track_number'] = spotify_data['track_number'] + if spotify_data.get('disc_number'): + track['disc_number'] = spotify_data['disc_number'] spotify_tracks.append(track) elif result.get('spotify_track') and result.get('status_class') == 'found': # Build from individual fields (automatic discovery format) @@ -43859,6 +43987,10 @@ def convert_listenbrainz_results_to_spotify_tracks(discovery_results): 'album': spotify_data['album'], 'duration_ms': spotify_data.get('duration_ms', 0) } + if spotify_data.get('track_number'): + track['track_number'] = spotify_data['track_number'] + if spotify_data.get('disc_number'): + track['disc_number'] = spotify_data['disc_number'] spotify_tracks.append(track) elif result.get('spotify_track') and result.get('status_class') == 'found': # Build from individual fields (automatic discovery format) @@ -46073,8 +46205,8 @@ def _run_beatport_discovery_worker(url_hash): tracks = chart['tracks'] # Determine which provider to use - use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() + discovery_source = _get_active_discovery_source() + use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated() # Initialize fallback client if needed itunes_client_instance = None @@ -46963,8 +47095,8 @@ def prepare_mirrored_discovery(playlist_id): }) # Determine current active metadata source for provider-mismatch detection - _use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - _current_provider = 'spotify' if _use_spotify else _get_metadata_fallback_source() + _current_provider = _get_active_discovery_source() + _use_spotify = (_current_provider == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated() # Check for cached discovery results in extra_data pre_discovered_results = [] @@ -47580,13 +47712,18 @@ def convert_beatport_results_to_spotify_tracks(discovery_results): # Convert from [{'name': 'Artist'}] to ['Artist'] artists = [artist['name'] for artist in artists] - spotify_tracks.append({ + track = { 'id': spotify_data['id'], 'name': spotify_data['name'], 'artists': artists, 'album': spotify_data['album'], 'source': 'beatport' - }) + } + if spotify_data.get('track_number'): + track['track_number'] = spotify_data['track_number'] + if spotify_data.get('disc_number'): + track['disc_number'] = spotify_data['disc_number'] + spotify_tracks.append(track) elif result.get('spotify_track') and result.get('status_class') == 'found': # Build from individual fields (automatic discovery format) album_val = result.get('spotify_album', '') From bbaa897cd26d6453efe5e52a6d9f2d2d0e2b6f86 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:43:45 -0700 Subject: [PATCH 03/19] Fix Deezer metadata cache storing incomplete album and track data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deezer's /artist/{id}/albums endpoint returns albums without an artist field, causing 98% of cached Deezer albums to have empty artist_name. Now injects the known artist before caching. Also fixes get_track_details cache validation — was trusting search result cache (which has isrc but no track_position), returning track_number=0. Now only trusts cache entries with track_position. --- core/deezer_client.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/core/deezer_client.py b/core/deezer_client.py index 38c1b4cc..5b89f868 100644 --- a/core/deezer_client.py +++ b/core/deezer_client.py @@ -636,7 +636,17 @@ class DeezerClient: albums.append(album) cache = get_metadata_cache() - entries = [(str(ad.get('id', '')), ad) for ad in data['data'] if ad.get('id')] + # Deezer's /artist/{id}/albums endpoint doesn't include artist info on each album. + # Inject it so cached album entities have artist_name for discover page display. + artist_stub = None + if albums and albums[0].artists: + artist_stub = {'id': int(artist_id) if artist_id.isdigit() else 0, 'name': albums[0].artists[0]} + entries = [] + for ad in data['data']: + if ad.get('id'): + if artist_stub and not ad.get('artist'): + ad['artist'] = artist_stub + entries.append((str(ad['id']), ad)) if entries: cache.store_entities_bulk('deezer', 'album', entries, skip_if_exists=True) From df14bbf745da1b5ecb6a2c2a947d0aa4de965861 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:45:47 -0700 Subject: [PATCH 04/19] Add one-time migration to purge stale Deezer metadata cache Deezer album entries cached from /artist/{id}/albums lack artist info, and track entries from search results lack track_position. Purges all Deezer album/track cache entries on first startup so they repopulate with complete data. --- database/music_database.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/database/music_database.py b/database/music_database.py index 2f176efb..fe6cbb2f 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -568,6 +568,23 @@ class MusicDatabase: except Exception: pass + # One-time migration: purge Deezer album/track cache entries with missing data. + # Deezer's /artist/{id}/albums returns albums without artist info, and search + # results cache tracks without track_position — both produce bad metadata. + try: + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='_deezer_cache_v2_migrated'") + if not cursor.fetchone(): + cursor.execute("""DELETE FROM metadata_cache_entities + WHERE source = 'deezer' AND entity_type IN ('album', 'track')""") + purged = cursor.rowcount + cursor.execute("""DELETE FROM metadata_cache_searches + WHERE source = 'deezer' AND search_type IN ('album', 'track')""") + cursor.execute("CREATE TABLE _deezer_cache_v2_migrated (applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)") + if purged > 0: + logger.info(f"Purged {purged} stale Deezer cache entries (missing artist/track_position)") + except Exception: + pass + conn.commit() logger.info("Database initialized successfully") From 7d21385ce9bf33dc19682a14291df598ca4d03a2 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:04:21 -0700 Subject: [PATCH 05/19] Show which tracks failed to match in sync completion toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a playlist sync has unmatched tracks sent to wishlist, the completion toast now shows the specific track names instead of just a count. Uses warning style so it stands out. The unmatched track list is included in the sync state result so it's available for both live status polling and notification history. Addresses #272 — silent sync failures where users couldn't tell which tracks out of 150+ failed to match their Plex library. --- web_server.py | 11 ++++++++++- webui/static/script.js | 6 +++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/web_server.py b/web_server.py index 16d1d9be..00d2f520 100644 --- a/web_server.py +++ b/web_server.py @@ -36657,12 +36657,21 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, # Update final state on completion # Convert result to JSON-serializable dict (datetime/errors can't be emitted via SocketIO) - # Exclude match_details — large, not needed for live status, saved to DB separately + # Exclude match_details (large) but include a summary of unmatched tracks result_dict = { k: (v.isoformat() if hasattr(v, 'isoformat') else v) for k, v in result.__dict__.items() if k != 'match_details' } + # Include unmatched track names so the frontend can show which tracks failed + match_details = getattr(result, 'match_details', None) + if match_details: + unmatched_summary = [ + {'name': d.get('name', ''), 'artist': d.get('artist', ''), 'image_url': d.get('image_url', '')} + for d in match_details if d.get('status') == 'not_found' + ] + if unmatched_summary: + result_dict['unmatched_tracks'] = unmatched_summary with sync_lock: sync_states[playlist_id] = { "status": "finished", diff --git a/webui/static/script.js b/webui/static/script.js index 82f69186..ae4aedc0 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -16296,9 +16296,13 @@ function updateCardToDefault(playlistId, finalState = null) { // Check if any tracks were added to wishlist const wishlistCount = finalState.progress?.wishlist_added_count || finalState.result?.wishlist_added_count || 0; + const unmatchedTracks = finalState.progress?.unmatched_tracks || finalState.result?.unmatched_tracks || []; const playlistName = card.querySelector('.playlist-card-name').textContent; - if (wishlistCount > 0) { + if (wishlistCount > 0 && unmatchedTracks.length > 0) { + const trackList = unmatchedTracks.map(t => `${t.artist} - ${t.name}`).join(', '); + showToast(`Sync complete for "${playlistName}". ${wishlistCount} not found in library: ${trackList}`, 'warning'); + } else if (wishlistCount > 0) { showToast(`Sync complete for "${playlistName}". Added ${wishlistCount} missing track${wishlistCount > 1 ? 's' : ''} to wishlist.`, 'success'); } else { showToast(`Sync complete for "${playlistName}"`, 'success'); From 57fc18f9942b3e810cf146a287c08ea8a456db15 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:43:06 -0700 Subject: [PATCH 06/19] Respect configured primary source in seasonal, playlists, and explorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seasonal discovery, personalized playlists, and playlist explorer all defaulted to Spotify when authenticated, ignoring the user's configured primary source. Now they read from config first. Spotify's related_artists API (no Deezer/iTunes equivalent) is preserved as a fallback for all users in personalized playlists. Artist discography endpoint intentionally unchanged — ID-based lookups need the source that owns the ID. --- core/personalized_playlists.py | 21 ++++++++++----------- core/seasonal_discovery.py | 14 ++++++++------ web_server.py | 7 +++---- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/core/personalized_playlists.py b/core/personalized_playlists.py index 6fd3c8cd..9bd97388 100644 --- a/core/personalized_playlists.py +++ b/core/personalized_playlists.py @@ -102,17 +102,16 @@ class PersonalizedPlaylistsService: def _get_active_source(self) -> str: """ - Determine which music source is active for discovery. - Returns 'spotify' if Spotify is authenticated, otherwise the configured fallback ('itunes' or 'deezer'). + Determine which music source is active — respects user's configured primary source. """ - if self.spotify_client and hasattr(self.spotify_client, 'is_spotify_authenticated'): - if self.spotify_client.is_spotify_authenticated(): - return 'spotify' try: from config.settings import config_manager - return config_manager.get('metadata.fallback_source', 'itunes') or 'itunes' + source = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer' + if source == 'spotify' and not (self.spotify_client and hasattr(self.spotify_client, 'is_spotify_authenticated') and self.spotify_client.is_spotify_authenticated()): + return 'deezer' + return source except Exception: - return 'itunes' + return 'deezer' def _build_track_dict(self, row, source: str) -> Dict: """Build a standardized track dictionary from a database row.""" @@ -882,8 +881,8 @@ class PersonalizedPlaylistsService: logger.error(f"Invalid seed artists count: {len(seed_artist_ids)}") return {'tracks': [], 'error': 'Must provide 1-5 seed artists'} - use_spotify = self.spotify_client and self.spotify_client.sp - active_source = 'spotify' if use_spotify else self._get_active_source() + active_source = self._get_active_source() + use_spotify = (active_source == 'spotify') and self.spotify_client and self.spotify_client.sp logger.info(f"Building custom playlist from {len(seed_artist_ids)} seed artists (source: {active_source})") # Step 1: Get similar artists for each seed @@ -914,8 +913,8 @@ class PersonalizedPlaylistsService: seen_artist_ids.add(artist_id) if len(all_similar_artists) >= 25: break - elif use_spotify: - # Fallback: fetch related artists from Spotify API + elif self.spotify_client and self.spotify_client.sp: + # Fallback: fetch related artists from Spotify API (no Deezer/iTunes equivalent) logger.info(f"No cached similar artists for {seed_artist_id}, trying Spotify related artists API") try: related = self.spotify_client.sp.artist_related_artists(seed_artist_id) diff --git a/core/seasonal_discovery.py b/core/seasonal_discovery.py index a3295d4e..827da764 100644 --- a/core/seasonal_discovery.py +++ b/core/seasonal_discovery.py @@ -96,14 +96,16 @@ class SeasonalDiscoveryService: self._ensure_database_schema() def _get_source(self): - """Determine active music source (matches _get_active_discovery_source in web_server)""" - if self.spotify_client and self.spotify_client.is_spotify_authenticated(): - return 'spotify' + """Determine active music source — respects user's configured primary source""" try: - from core.metadata_service import _get_configured_fallback_source - return _get_configured_fallback_source() + from config.settings import config_manager + source = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer' + # If user selected spotify, verify it's actually authenticated + if source == 'spotify' and not (self.spotify_client and self.spotify_client.is_spotify_authenticated()): + return 'deezer' + return source except Exception: - return 'itunes' + return 'deezer' def _ensure_database_schema(self): """Create seasonal content tables if they don't exist""" diff --git a/web_server.py b/web_server.py index 00d2f520..8da844fe 100644 --- a/web_server.py +++ b/web_server.py @@ -47377,11 +47377,10 @@ def playlist_explorer_build_tree(): if not tracks: return jsonify({"success": False, "error": "Playlist has no tracks"}), 400 - # Determine active metadata source - spotify_available = spotify_client and spotify_client.is_spotify_authenticated() - if spotify_available: + # Determine active metadata source — respect user's configured primary + source_name = _get_active_discovery_source() + if source_name == 'spotify' and spotify_client and spotify_client.is_spotify_authenticated(): active_client = spotify_client - source_name = 'spotify' else: active_client = _get_metadata_fallback_client() source_name = _get_metadata_fallback_source() From 52a5d93018a90da8abbeea14aa268729d74474bc Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:17:11 -0700 Subject: [PATCH 07/19] Add Fix Unknown Artists maintenance job New repair job that scans the library for tracks filed under "Unknown Artist" and corrects them. Resolves correct metadata by: 1. Reading embedded file tags (if file has correct artist) 2. Looking up by source track ID (Spotify/Deezer/iTunes) 3. Searching by title as last resort Dry run mode (default) creates findings for review. Live mode re-tags the audio file, moves it to the correct folder structure, and updates the database. Includes fix handler for applying individual findings. --- core/repair_jobs/__init__.py | 1 + core/repair_jobs/unknown_artist_fixer.py | 496 +++++++++++++++++++++++ core/repair_worker.py | 104 +++++ 3 files changed, 601 insertions(+) create mode 100644 core/repair_jobs/unknown_artist_fixer.py diff --git a/core/repair_jobs/__init__.py b/core/repair_jobs/__init__.py index 4760e35d..159dc7bc 100644 --- a/core/repair_jobs/__init__.py +++ b/core/repair_jobs/__init__.py @@ -42,6 +42,7 @@ _JOB_MODULES = [ 'core.repair_jobs.lossy_converter', 'core.repair_jobs.album_tag_consistency', 'core.repair_jobs.live_commentary_cleaner', + 'core.repair_jobs.unknown_artist_fixer', ] diff --git a/core/repair_jobs/unknown_artist_fixer.py b/core/repair_jobs/unknown_artist_fixer.py new file mode 100644 index 00000000..353039fa --- /dev/null +++ b/core/repair_jobs/unknown_artist_fixer.py @@ -0,0 +1,496 @@ +"""Unknown Artist Fixer Job — finds tracks tagged as 'Unknown Artist' and corrects metadata. + +Resolves the correct artist/album/track metadata from file tags or metadata API, +re-tags the audio file, moves it to the correct folder, and updates the database. +""" + +import os +import re +import shutil +import sys +import time + +from core.repair_jobs import register_job +from core.repair_jobs.base import JobContext, JobResult, RepairJob +from utils.logging_config import get_logger + +logger = get_logger("repair_job.unknown_artist_fixer") + +_UNKNOWN_NAMES = {'unknown artist', 'unknown', ''} + +# Sidecar extensions to move alongside audio files +_SIDECAR_EXTS = {'.lrc', '.jpg', '.jpeg', '.png', '.nfo', '.txt', '.cue'} + + +@register_job +class UnknownArtistFixerJob(RepairJob): + job_id = 'unknown_artist_fixer' + display_name = 'Fix Unknown Artists' + description = 'Finds tracks tagged as "Unknown Artist" and corrects metadata, tags, and file paths' + help_text = ( + 'Scans your library for tracks filed under "Unknown Artist" — a common result of ' + 'incomplete metadata during playlist pipeline downloads.\n\n' + 'For each affected track, the job resolves the correct artist, album, and track number by:\n' + '1. Reading embedded file tags (if the file itself has correct metadata)\n' + '2. Looking up the track by ID on your configured metadata source\n' + '3. Searching by track title as a last resort\n\n' + 'When a match is found, the job can re-tag the file, move it to the correct folder, ' + 'and update the database.\n\n' + 'Settings:\n' + '- Dry Run: Preview changes without applying them (default: on)\n' + '- Fix file tags: Write corrected metadata to audio file tags\n' + '- Reorganize files: Move files to the correct folder structure' + ) + icon = 'repair-icon-artist' + default_enabled = False + default_interval_hours = 168 # Weekly + default_settings = { + 'dry_run': True, + 'fix_tags': True, + 'reorganize_files': True, + } + auto_fix = True + + def estimate_scope(self, context: JobContext) -> int: + try: + conn = context.db._get_connection() + try: + cursor = conn.cursor() + cursor.execute(""" + SELECT COUNT(*) FROM tracks t + JOIN artists ar ON ar.id = t.artist_id + WHERE LOWER(TRIM(ar.name)) IN ('unknown artist', 'unknown', '') + AND t.file_path IS NOT NULL AND t.file_path != '' + """) + return cursor.fetchone()[0] + finally: + conn.close() + except Exception: + return 0 + + def scan(self, context: JobContext) -> JobResult: + result = JobResult() + settings = self._get_settings(context) + dry_run = settings.get('dry_run', True) + fix_tags = settings.get('fix_tags', True) + reorganize_files = settings.get('reorganize_files', True) + + mode_label = 'DRY RUN' if dry_run else 'LIVE' + if context.report_progress: + context.report_progress(phase=f'Scanning ({mode_label})...', + log_line=f'Mode: {mode_label}', log_type='info') + + # Query all tracks under Unknown Artist + conn = context.db._get_connection() + try: + cursor = conn.cursor() + cursor.execute(""" + SELECT t.id, t.title, t.file_path, t.track_number, t.duration, + ar.id as artist_id, ar.name as artist_name, + al.id as album_id, al.title as album_title, al.year, + al.thumb_url as album_thumb, + t.spotify_track_id, t.itunes_track_id, t.deezer_track_id + FROM tracks t + JOIN artists ar ON ar.id = t.artist_id + JOIN albums al ON al.id = t.album_id + WHERE LOWER(TRIM(ar.name)) IN ('unknown artist', 'unknown', '') + AND t.file_path IS NOT NULL AND t.file_path != '' + ORDER BY al.title, t.track_number + LIMIT 500 + """) + tracks = [dict(row) for row in cursor.fetchall()] + finally: + conn.close() + + total = len(tracks) + if total == 0: + if context.report_progress: + context.report_progress(phase='No Unknown Artist tracks found', + log_line='No tracks to fix', log_type='success') + return result + + if context.report_progress: + context.report_progress(phase=f'Found {total} Unknown Artist tracks', + total=total, log_line=f'Processing {total} tracks...', + log_type='info') + + # Get file path templates for reorganization + transfer = context.transfer_folder + templates = {} + if context.config_manager: + templates = context.config_manager.get('file_organization.templates', {}) + album_template = templates.get('album_path', '$albumartist/$albumartist - $album/$track - $title') + + for i, track in enumerate(tracks): + if context.check_stop(): + return result + if i % 20 == 0 and context.wait_if_paused(): + return result + + result.scanned += 1 + track_id = track['id'] + title = track['title'] or '' + file_path = track['file_path'] + + # Resolve actual file on disk + from core.repair_worker import _resolve_file_path + resolved = _resolve_file_path(file_path, transfer) + if not resolved or not os.path.exists(resolved): + result.skipped += 1 + continue + + # Try to resolve correct metadata + corrected = self._resolve_metadata(context, track, resolved) + if not corrected: + result.skipped += 1 + if context.report_progress: + context.report_progress( + scanned=i + 1, total=total, + log_line=f'Could not resolve: {title}', log_type='warning') + continue + + # Compute expected file path + expected_rel = None + if reorganize_files and corrected.get('artist') and corrected.get('album'): + from core.repair_jobs.library_reorganize import _build_path_from_template, _get_audio_quality + quality = _get_audio_quality(resolved) + tmpl_ctx = { + 'artist': corrected['artist'], + 'albumartist': corrected['artist'], + 'album': corrected['album'], + 'title': corrected.get('title', title), + 'track_number': corrected.get('track_number', 1), + 'disc_number': corrected.get('disc_number', 1), + 'year': corrected.get('year', ''), + 'quality': quality, + 'albumtype': 'Album', + } + folder, fname_base = _build_path_from_template(album_template, tmpl_ctx) + file_ext = os.path.splitext(resolved)[1] + if quality and f'[{quality}]' not in fname_base: + fname_base = f"{fname_base} [{quality}]" + expected_rel = os.path.join(folder, fname_base + file_ext) + + if dry_run: + # Create finding for review + desc_parts = [f'Artist: Unknown Artist → {corrected["artist"]}'] + if corrected.get('album'): + desc_parts.append(f'Album: {track.get("album_title", "?")} → {corrected["album"]}') + if corrected.get('track_number'): + desc_parts.append(f'Track #: {track.get("track_number", "?")} → {corrected["track_number"]}') + if expected_rel: + desc_parts.append(f'Path: → {expected_rel}') + + if context.create_finding: + context.create_finding( + job_id=self.job_id, + finding_type='unknown_artist', + severity='warning', + entity_type='track', + entity_id=str(track_id), + file_path=file_path, + title=f'{corrected["artist"]} - {corrected.get("title", title)}', + description='\n'.join(desc_parts), + details={ + 'track_id': track_id, + 'artist_id': track['artist_id'], + 'album_id': track['album_id'], + 'current_artist': track['artist_name'], + 'corrected_artist': corrected['artist'], + 'corrected_album': corrected.get('album', ''), + 'corrected_track_number': corrected.get('track_number'), + 'corrected_year': corrected.get('year', ''), + 'corrected_title': corrected.get('title', title), + 'source': corrected.get('source', ''), + 'confidence': corrected.get('confidence', 0), + 'file_path': resolved, + 'expected_path': expected_rel, + 'album_thumb_url': corrected.get('image_url') or track.get('album_thumb'), + 'cover_url': corrected.get('image_url', ''), + } + ) + result.findings_created += 1 + else: + # Live mode — apply fix + try: + fixed = self._apply_fix(context, track, corrected, resolved, + expected_rel, transfer, fix_tags, reorganize_files) + if fixed: + result.auto_fixed += 1 + else: + result.errors += 1 + except Exception as e: + logger.error(f"Failed to fix track {track_id}: {e}") + result.errors += 1 + + if context.report_progress: + context.report_progress( + scanned=i + 1, total=total, + log_line=f'{"[Preview]" if dry_run else "[Fixed]"} {corrected["artist"]} - {corrected.get("title", title)}', + log_type='info' if dry_run else 'success') + + if context.report_progress: + if dry_run: + context.report_progress( + phase=f'Preview complete — {result.findings_created} fixable tracks', + log_line=f'Done: {result.findings_created} can be fixed, {result.skipped} unresolvable', + log_type='success') + else: + context.report_progress( + phase=f'Fixed {result.auto_fixed} tracks', + log_line=f'Done: {result.auto_fixed} fixed, {result.errors} errors, {result.skipped} skipped', + log_type='success') + + return result + + def _resolve_metadata(self, context, track, resolved_path): + """Try to resolve correct metadata for an Unknown Artist track. + Returns dict with artist, album, track_number, year, etc. or None.""" + + title = track['title'] or '' + + # Priority 1: Read embedded file tags + try: + from core.tag_writer import read_file_tags + tags = read_file_tags(resolved_path) + tag_artist = tags.get('artist') or tags.get('album_artist') + if tag_artist and tag_artist.strip().lower() not in _UNKNOWN_NAMES: + return { + 'artist': tag_artist.strip(), + 'album': (tags.get('album') or '').strip() or track.get('album_title', ''), + 'title': (tags.get('title') or '').strip() or title, + 'track_number': tags.get('track_number') or track.get('track_number'), + 'disc_number': tags.get('disc_number') or 1, + 'year': (tags.get('year') or '').strip(), + 'source': 'file_tags', + 'confidence': 1.0, + } + except Exception as e: + logger.debug(f"Failed to read tags from {resolved_path}: {e}") + + # Priority 2: Look up by source track ID + source_id = (track.get('spotify_track_id') or track.get('deezer_track_id') + or track.get('itunes_track_id')) + if source_id and context.spotify_client: + try: + details = context.spotify_client.get_track_details(str(source_id)) + if details and details.get('primary_artist'): + artist = details['primary_artist'] + if artist.lower() not in _UNKNOWN_NAMES: + album = details.get('album', {}) + album_name = album.get('name', '') if isinstance(album, dict) else str(album) + return { + 'artist': artist, + 'album': album_name, + 'title': details.get('name', title), + 'track_number': details.get('track_number'), + 'disc_number': details.get('disc_number', 1), + 'year': (album.get('release_date', '') or '')[:4] if isinstance(album, dict) else '', + 'image_url': album.get('images', [{}])[0].get('url', '') if isinstance(album, dict) and album.get('images') else '', + 'source': 'track_id_lookup', + 'confidence': 0.95, + } + except Exception as e: + logger.debug(f"Track ID lookup failed for {source_id}: {e}") + + # Priority 3: Search by title + if title and context.spotify_client: + try: + results = context.spotify_client.search_tracks(title, limit=5) + if results: + # Score candidates + from difflib import SequenceMatcher + best = None + best_score = 0 + for r in results: + name_sim = SequenceMatcher(None, title.lower(), r.name.lower()).ratio() + # Boost if album matches + album_name = r.album if hasattr(r, 'album') else '' + if album_name and track.get('album_title'): + album_sim = SequenceMatcher(None, track['album_title'].lower(), album_name.lower()).ratio() + name_sim = (name_sim * 0.7) + (album_sim * 0.3) + if name_sim > best_score: + best_score = name_sim + best = r + + if best and best_score >= 0.7: + artist = best.artists[0] if best.artists else '' + if artist and artist.lower() not in _UNKNOWN_NAMES: + # Get full details for track_number + full_details = None + try: + full_details = context.spotify_client.get_track_details(best.id) + except Exception: + pass + album_data = full_details.get('album', {}) if full_details else {} + return { + 'artist': artist, + 'album': best.album if hasattr(best, 'album') else '', + 'title': best.name, + 'track_number': full_details.get('track_number') if full_details else None, + 'disc_number': full_details.get('disc_number', 1) if full_details else 1, + 'year': (album_data.get('release_date', '') or '')[:4] if isinstance(album_data, dict) else '', + 'image_url': getattr(best, 'image_url', '') or '', + 'source': 'title_search', + 'confidence': round(best_score, 3), + } + except Exception as e: + logger.debug(f"Title search failed for '{title}': {e}") + # Rate limit courtesy + time.sleep(0.2) + + return None + + def _apply_fix(self, context, track, corrected, resolved_path, + expected_rel, transfer, fix_tags, reorganize_files): + """Apply the fix: re-tag file, move to correct path, update DB.""" + track_id = track['id'] + + # Step 1: Write corrected tags to file + if fix_tags: + try: + from core.tag_writer import write_tags_to_file + db_data = { + 'title': corrected.get('title', track['title']), + 'artist_name': corrected['artist'], + 'album_title': corrected.get('album', ''), + 'year': corrected.get('year', ''), + 'track_number': corrected.get('track_number'), + 'disc_number': corrected.get('disc_number', 1), + } + tag_result = write_tags_to_file( + resolved_path, db_data, + embed_cover=True, + cover_url=corrected.get('image_url') or None + ) + if tag_result.get('success'): + logger.info(f"Re-tagged: {corrected['artist']} - {corrected.get('title', track['title'])}") + else: + logger.warning(f"Tag write failed for track {track_id}: {tag_result.get('error')}") + except Exception as e: + logger.error(f"Tag write error for track {track_id}: {e}") + + # Step 2: Move file to correct location + final_path = resolved_path + if reorganize_files and expected_rel: + expected_abs = os.path.normpath(os.path.join(transfer, expected_rel)) + current_norm = os.path.normpath(resolved_path) + + if current_norm.lower() != expected_abs.lower(): + try: + os.makedirs(os.path.dirname(expected_abs), exist_ok=True) + + # Handle case rename on case-insensitive FS + if sys.platform in ('win32', 'darwin') and os.path.exists(expected_abs): + tmp = expected_abs + '.tmp_rename' + shutil.move(current_norm, tmp) + shutil.move(tmp, expected_abs) + else: + shutil.move(current_norm, expected_abs) + + final_path = expected_abs + logger.info(f"Moved: {os.path.basename(current_norm)} → {expected_rel}") + + # Move sidecars + src_dir = os.path.dirname(current_norm) + dst_dir = os.path.dirname(expected_abs) + src_stem = os.path.splitext(os.path.basename(current_norm))[0] + dst_stem = os.path.splitext(os.path.basename(expected_abs))[0] + for ext in _SIDECAR_EXTS: + sidecar_src = os.path.join(src_dir, src_stem + ext) + if os.path.isfile(sidecar_src): + sidecar_dst = os.path.join(dst_dir, dst_stem + ext) + if not os.path.exists(sidecar_dst): + try: + shutil.move(sidecar_src, sidecar_dst) + except Exception: + pass + + # Also move cover.jpg from old album folder + cover_src = os.path.join(src_dir, 'cover.jpg') + cover_dst = os.path.join(dst_dir, 'cover.jpg') + if os.path.isfile(cover_src) and not os.path.exists(cover_dst): + try: + shutil.copy2(cover_src, cover_dst) + except Exception: + pass + + # Clean up empty directories + parent = os.path.dirname(current_norm) + transfer_norm = os.path.normpath(transfer) + for _ in range(5): + if (parent and os.path.isdir(parent) + and os.path.normpath(parent) != transfer_norm + and not os.listdir(parent)): + os.rmdir(parent) + parent = os.path.dirname(parent) + else: + break + + except Exception as e: + logger.error(f"File move failed for track {track_id}: {e}") + # Continue with DB update even if move failed + + # Step 3: Update database + try: + conn = context.db._get_connection() + try: + cursor = conn.cursor() + + # Find or create the correct artist + corrected_artist = corrected['artist'] + cursor.execute("SELECT id FROM artists WHERE LOWER(name) = LOWER(?)", + (corrected_artist,)) + artist_row = cursor.fetchone() + if artist_row: + new_artist_id = artist_row[0] + else: + cursor.execute("INSERT INTO artists (name) VALUES (?)", (corrected_artist,)) + new_artist_id = cursor.lastrowid + + # Update track's artist_id and file_path + cursor.execute(""" + UPDATE tracks SET artist_id = ?, file_path = ? + WHERE id = ? + """, (new_artist_id, final_path, track_id)) + + # Update track_number if we have it + if corrected.get('track_number'): + cursor.execute("UPDATE tracks SET track_number = ? WHERE id = ?", + (corrected['track_number'], track_id)) + + # Update album title if corrected + if corrected.get('album') and corrected['album'] != track.get('album_title'): + cursor.execute("UPDATE albums SET title = ? WHERE id = ?", + (corrected['album'], track['album_id'])) + + # Update album year if we have it + if corrected.get('year') and corrected['year'].isdigit(): + cursor.execute("UPDATE albums SET year = ? WHERE id = ?", + (int(corrected['year']), track['album_id'])) + + # Update album artist_id to match + cursor.execute("UPDATE albums SET artist_id = ? WHERE id = ?", + (new_artist_id, track['album_id'])) + + conn.commit() + logger.info(f"DB updated: track {track_id} → artist '{corrected_artist}'") + finally: + conn.close() + except Exception as e: + logger.error(f"DB update failed for track {track_id}: {e}") + return False + + return True + + def _get_settings(self, context): + if not context.config_manager: + return self.default_settings.copy() + cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {}) + merged = self.default_settings.copy() + if isinstance(cfg, dict): + merged.update(cfg) + return merged + + def _get_setting(self, context, key, default=None): + return self._get_settings(context).get(key, default) diff --git a/core/repair_worker.py b/core/repair_worker.py index 2b75f763..eb3e3c53 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -816,6 +816,7 @@ class RepairWorker: 'path_mismatch': self._fix_path_mismatch, 'missing_lossy_copy': self._fix_missing_lossy_copy, 'unwanted_content': self._fix_unwanted_content, + 'unknown_artist': self._fix_unknown_artist, } handler = handlers.get(finding_type) if not handler: @@ -1382,6 +1383,109 @@ class RepairWorker: msg += ' (file deleted)' return {'success': True, 'action': 'removed_content', 'message': msg} + def _fix_unknown_artist(self, entity_type, entity_id, file_path, details): + """Fix an Unknown Artist track — re-tag, move to correct path, update DB.""" + track_id = details.get('track_id') + corrected_artist = details.get('corrected_artist', '') + corrected_album = details.get('corrected_album', '') + corrected_title = details.get('corrected_title', '') + corrected_track_number = details.get('corrected_track_number') + corrected_year = details.get('corrected_year', '') + cover_url = details.get('cover_url', '') + expected_path = details.get('expected_path', '') + + if not corrected_artist or not track_id: + return {'success': False, 'error': 'Missing corrected artist or track ID'} + + # Resolve file + download_folder = self._config_manager.get('soulseek.download_path', '') if self._config_manager else '' + resolved = _resolve_file_path(file_path, self.transfer_folder, download_folder) if file_path else None + if not resolved or not os.path.exists(resolved): + return {'success': False, 'error': f'File not found: {file_path}'} + + # Step 1: Re-tag file + try: + from core.tag_writer import write_tags_to_file + db_data = { + 'title': corrected_title, + 'artist_name': corrected_artist, + 'album_title': corrected_album, + 'year': corrected_year, + 'track_number': corrected_track_number, + } + write_tags_to_file(resolved, db_data, embed_cover=bool(cover_url), cover_url=cover_url or None) + except Exception as e: + logger.warning(f"Tag write failed during unknown artist fix: {e}") + + # Step 2: Move file if expected path differs + final_path = resolved + if expected_path: + expected_abs = os.path.normpath(os.path.join(self.transfer_folder, expected_path)) + if os.path.normpath(resolved).lower() != expected_abs.lower(): + try: + os.makedirs(os.path.dirname(expected_abs), exist_ok=True) + if sys.platform in ('win32', 'darwin') and os.path.exists(expected_abs): + tmp = expected_abs + '.tmp_rename' + shutil.move(resolved, tmp) + shutil.move(tmp, expected_abs) + else: + shutil.move(resolved, expected_abs) + final_path = expected_abs + + # Move sidecars + src_dir = os.path.dirname(resolved) + dst_dir = os.path.dirname(expected_abs) + src_stem = os.path.splitext(os.path.basename(resolved))[0] + dst_stem = os.path.splitext(os.path.basename(expected_abs))[0] + for ext in ('.lrc', '.jpg', '.jpeg', '.png', '.txt'): + s = os.path.join(src_dir, src_stem + ext) + if os.path.isfile(s): + d = os.path.join(dst_dir, dst_stem + ext) + if not os.path.exists(d): + try: + shutil.move(s, d) + except Exception: + pass + + # Clean up empty dirs + self._cleanup_empty_parents(resolved) + except Exception as e: + logger.error(f"File move failed: {e}") + + # Step 3: Update DB + try: + conn = self.db._get_connection() + try: + cursor = conn.cursor() + # Find or create artist + cursor.execute("SELECT id FROM artists WHERE LOWER(name) = LOWER(?)", (corrected_artist,)) + row = cursor.fetchone() + new_artist_id = row[0] if row else None + if not new_artist_id: + cursor.execute("INSERT INTO artists (name) VALUES (?)", (corrected_artist,)) + new_artist_id = cursor.lastrowid + + cursor.execute("UPDATE tracks SET artist_id = ?, file_path = ? WHERE id = ?", + (new_artist_id, final_path, track_id)) + if corrected_track_number: + cursor.execute("UPDATE tracks SET track_number = ? WHERE id = ?", + (corrected_track_number, track_id)) + album_id = details.get('album_id') + if album_id: + if corrected_album: + cursor.execute("UPDATE albums SET title = ? WHERE id = ?", (corrected_album, album_id)) + if corrected_year and corrected_year.isdigit(): + cursor.execute("UPDATE albums SET year = ? WHERE id = ?", (int(corrected_year), album_id)) + cursor.execute("UPDATE albums SET artist_id = ? WHERE id = ?", (new_artist_id, album_id)) + conn.commit() + finally: + conn.close() + except Exception as e: + return {'success': False, 'error': f'DB update failed: {e}'} + + return {'success': True, 'action': 'fixed_unknown_artist', + 'message': f'Fixed: {corrected_artist} - {corrected_title}'} + def _fix_mbid_mismatch(self, entity_type, entity_id, file_path, details): """Remove the mismatched MusicBrainz recording ID from the audio file.""" if not file_path: From 498c22e7c3576a36dd69c5d0ec950f97b5b3db75 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:34:25 -0700 Subject: [PATCH 08/19] Centralize metadata source selection in core/metadata_service.py All metadata source decisions now flow through get_primary_source() and get_primary_client() in core/metadata_service.py. Previously 6 different files reimplemented this logic with inconsistent defaults ('itunes' vs 'deezer') and auth checks, causing bugs when any one was missed. Changes: - metadata_service.py: Added canonical get_primary_source/get_primary_client - web_server.py: _get_metadata_fallback_source() and _get_active_discovery_source() are now thin wrappers delegating to metadata_service - seasonal_discovery.py: _get_source() delegates to metadata_service - personalized_playlists.py: _get_active_source() delegates to metadata_service - spotify_client.py: Fixed _fallback_source default from 'itunes' to 'deezer' - watchlist_scanner.py: _get_fallback_metadata_client() delegates to metadata_service Future changes to source selection only need to update one file. --- core/metadata_service.py | 108 +++++++++++++++++++++++++++------ core/personalized_playlists.py | 14 +---- core/seasonal_discovery.py | 13 +--- core/spotify_client.py | 6 +- core/watchlist_scanner.py | 15 +---- web_server.py | 19 +++--- 6 files changed, 110 insertions(+), 65 deletions(-) diff --git a/core/metadata_service.py b/core/metadata_service.py index 2721a13d..b2ec0db4 100644 --- a/core/metadata_service.py +++ b/core/metadata_service.py @@ -1,9 +1,10 @@ """ -Metadata Service - Hot-swappable Spotify/iTunes/Deezer provider +Metadata Service - Centralized metadata source selection -Automatically uses Spotify when authenticated, falls back to the configured -fallback source (iTunes or Deezer) when not. -Provides unified interface for all metadata operations. +ALL metadata source decisions flow through this module. Other files import +get_primary_source() and get_primary_client() instead of reimplementing +the logic. This prevents bugs where different files have different defaults +or auth checks. """ from typing import List, Optional, Dict, Any, Literal @@ -16,37 +17,106 @@ logger = get_logger("metadata_service") MetadataProvider = Literal["spotify", "itunes", "auto"] -def _get_configured_fallback_source(): - """Get the configured metadata fallback source ('itunes' or 'deezer').""" +# ============================================================================= +# CANONICAL SOURCE SELECTION — all code should use these two functions +# ============================================================================= + +def get_primary_source() -> str: + """Get the user's configured primary metadata source. + + Returns 'spotify', 'deezer', 'itunes', 'discogs', or 'hydrabase'. + If the user selected Spotify but it's not authenticated, falls back to 'deezer'. + + This is THE single source of truth for "which metadata source should I use?" + All other modules should import this function instead of reading config directly. + """ try: from config.settings import config_manager - return config_manager.get('metadata.fallback_source', 'itunes') or 'itunes' + source = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer' except Exception: - return 'itunes' + return 'deezer' + + # Validate Spotify selection — can't use it if not authenticated + if source == 'spotify': + try: + import importlib + ws = importlib.import_module('web_server') + sc = getattr(ws, 'spotify_client', None) + if not sc or not sc.is_spotify_authenticated(): + return 'deezer' + except Exception: + return 'deezer' + + return source -def _create_fallback_client(): - """Create the configured fallback metadata client.""" - source = _get_configured_fallback_source() +def get_primary_client(): + """Get the client object for the user's configured primary metadata source. + + Returns a SpotifyClient, DeezerClient, iTunesClient, DiscogsClient, + or HydrabaseClient instance. + + This is THE single source of truth for "which client should I call?" + """ + source = get_primary_source() + + if source == 'spotify': + try: + import importlib + ws = importlib.import_module('web_server') + sc = getattr(ws, 'spotify_client', None) + if sc and sc.is_spotify_authenticated(): + return sc + except Exception: + pass + # Spotify selected but unavailable — fall back to Deezer + from core.deezer_client import DeezerClient + return DeezerClient() + if source == 'deezer': from core.deezer_client import DeezerClient return DeezerClient() + + if source == 'discogs': + try: + from config.settings import config_manager + token = config_manager.get('discogs.token', '') + if token: + from core.discogs_client import DiscogsClient + return DiscogsClient(token=token) + except Exception: + pass + return iTunesClient() + if source == 'hydrabase': try: - from core.hydrabase_client import HydrabaseClient - # Hydrabase client is managed globally — try to import the running instance import importlib - ws_module = importlib.import_module('web_server') - client = getattr(ws_module, 'hydrabase_client', None) + ws = importlib.import_module('web_server') + client = getattr(ws, 'hydrabase_client', None) if client and client.is_connected(): return client except Exception: pass - # Hydrabase not available — fall back to iTunes return iTunesClient() + + # Default: iTunes return iTunesClient() +# ============================================================================= +# LEGACY ALIASES — kept for backward compatibility, delegate to canonical funcs +# ============================================================================= + +def _get_configured_fallback_source(): + """Legacy alias for get_primary_source(). Use get_primary_source() instead.""" + return get_primary_source() + + +def _create_fallback_client(): + """Legacy alias for get_primary_client(). Use get_primary_client() instead.""" + return get_primary_client() + + class MetadataService: """ Unified metadata service that seamlessly switches between Spotify and @@ -94,10 +164,8 @@ class MetadataService: return "spotify" elif self.preferred_provider == "itunes": return self._fallback_source - else: # auto - # Use is_spotify_authenticated() to check actual Spotify auth status - # (is_authenticated() always returns True due to fallback) - return "spotify" if self.spotify.is_spotify_authenticated() else self._fallback_source + else: # auto — use the centralized source selection + return get_primary_source() def _get_client(self): """Get the appropriate client based on provider selection""" diff --git a/core/personalized_playlists.py b/core/personalized_playlists.py index 9bd97388..a00c72d7 100644 --- a/core/personalized_playlists.py +++ b/core/personalized_playlists.py @@ -101,17 +101,9 @@ class PersonalizedPlaylistsService: self.spotify_client = spotify_client def _get_active_source(self) -> str: - """ - Determine which music source is active — respects user's configured primary source. - """ - try: - from config.settings import config_manager - source = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer' - if source == 'spotify' and not (self.spotify_client and hasattr(self.spotify_client, 'is_spotify_authenticated') and self.spotify_client.is_spotify_authenticated()): - return 'deezer' - return source - except Exception: - return 'deezer' + """Determine which music source is active — delegates to centralized metadata_service.""" + from core.metadata_service import get_primary_source + return get_primary_source() def _build_track_dict(self, row, source: str) -> Dict: """Build a standardized track dictionary from a database row.""" diff --git a/core/seasonal_discovery.py b/core/seasonal_discovery.py index 827da764..e1d31e21 100644 --- a/core/seasonal_discovery.py +++ b/core/seasonal_discovery.py @@ -96,16 +96,9 @@ class SeasonalDiscoveryService: self._ensure_database_schema() def _get_source(self): - """Determine active music source — respects user's configured primary source""" - try: - from config.settings import config_manager - source = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer' - # If user selected spotify, verify it's actually authenticated - if source == 'spotify' and not (self.spotify_client and self.spotify_client.is_spotify_authenticated()): - return 'deezer' - return source - except Exception: - return 'deezer' + """Determine active music source — delegates to centralized metadata_service.""" + from core.metadata_service import get_primary_source + return get_primary_source() def _ensure_database_schema(self): """Create seasonal content tables if they don't exist""" diff --git a/core/spotify_client.py b/core/spotify_client.py index 8462417c..2017dfa4 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -506,11 +506,11 @@ class SpotifyClient: @property def _fallback_source(self) -> str: - """Get configured metadata fallback source ('itunes', 'deezer', or 'discogs')""" + """Get configured primary metadata source for internal fallback routing.""" try: - return config_manager.get('metadata.fallback_source', 'itunes') or 'itunes' + return config_manager.get('metadata.fallback_source', 'deezer') or 'deezer' except Exception: - return 'itunes' + return 'deezer' @property def _fallback(self): diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index 89f744f1..bb57e6e5 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -30,18 +30,9 @@ ITUNES_BASE_DELAY = 1.0 # Base delay in seconds for exponential backoff def _get_fallback_metadata_client(): - """Get the configured metadata fallback client (iTunes or Deezer).""" - try: - from config.settings import config_manager - source = config_manager.get('metadata.fallback_source', 'itunes') or 'itunes' - if source == 'deezer': - from core.deezer_client import DeezerClient - return DeezerClient(), 'deezer' - from core.itunes_client import iTunesClient - return iTunesClient(), 'itunes' - except Exception: - from core.itunes_client import iTunesClient - return iTunesClient(), 'itunes' + """Get the configured metadata client — delegates to centralized metadata_service.""" + from core.metadata_service import get_primary_source, get_primary_client + return get_primary_client(), get_primary_source() def itunes_api_call_with_retry(func, *args, max_retries=ITUNES_MAX_RETRIES, **kwargs): diff --git a/web_server.py b/web_server.py index 8da844fe..31699986 100644 --- a/web_server.py +++ b/web_server.py @@ -33369,11 +33369,12 @@ def _get_deezer_client(): def _get_metadata_fallback_source(): """Get the configured primary metadata source. - Returns 'spotify', 'itunes', 'deezer', 'discogs', or 'hydrabase'.""" - try: - return config_manager.get('metadata.fallback_source', 'deezer') or 'deezer' - except Exception: - return 'deezer' + Returns 'spotify', 'itunes', 'deezer', 'discogs', or 'hydrabase'. + + NOTE: This is a thin wrapper — canonical logic lives in core.metadata_service.get_primary_source(). + Kept as a local function because 70+ callers reference it by name.""" + from core.metadata_service import get_primary_source + return get_primary_source() def _get_metadata_fallback_client(): """Get the active metadata client based on settings. @@ -40240,11 +40241,11 @@ def _get_active_discovery_source(): Determine which music source is active for discovery. Returns the user's configured primary metadata source. If the selected source requires auth and isn't available, falls back. + + NOTE: Thin wrapper — canonical logic lives in core.metadata_service.get_primary_source(). """ - source = _get_metadata_fallback_source() - if source == 'spotify' and not (spotify_client and spotify_client.is_spotify_authenticated()): - return 'deezer' - return source + from core.metadata_service import get_primary_source + return get_primary_source() @app.route('/api/discover/hero', methods=['GET']) From 10a276655756543afa532c9f03883ddf6f2d8bbf Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:47:16 -0700 Subject: [PATCH 09/19] Fix remaining Spotify-first source selection in seasonal discovery and search API Seasonal discovery had 3 use_spotify checks using is_authenticated() (always True) instead of deriving from the configured source. Search API (tracks, albums, artists) also defaulted to Spotify when authenticated. All now check configured primary source first via get_primary_source(). --- api/search.py | 27 +++++++++++++++------------ core/seasonal_discovery.py | 16 ++++++++-------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/api/search.py b/api/search.py index 321f144d..48a237c7 100644 --- a/api/search.py +++ b/api/search.py @@ -42,16 +42,17 @@ def register_routes(bp): pass spotify = ctx.get("spotify_client") - if source in ("spotify", "auto") and spotify and spotify.is_authenticated(): + from core.metadata_service import get_primary_source, get_primary_client + primary = get_primary_source() + if source in ("spotify", "auto") and primary == 'spotify' and spotify and spotify.is_spotify_authenticated(): results = spotify.search_tracks(query, limit=limit) if results: tracks = [_serialize_track(t) for t in results] return api_success({"tracks": tracks, "source": "spotify"}) if source in ("itunes", "deezer", "auto"): - from core.metadata_service import _create_fallback_client, _get_configured_fallback_source - fallback = _create_fallback_client() - fallback_source = _get_configured_fallback_source() + fallback = get_primary_client() + fallback_source = get_primary_source() results = fallback.search_tracks(query, limit=limit) if results: tracks = [_serialize_track(t) for t in results] @@ -78,7 +79,9 @@ def register_routes(bp): try: ctx = current_app.soulsync spotify = ctx.get("spotify_client") - if spotify and spotify.is_authenticated(): + from core.metadata_service import get_primary_source, get_primary_client + primary = get_primary_source() + if primary == 'spotify' and spotify and spotify.is_spotify_authenticated(): results = spotify.search_albums(query, limit=limit) if results: return api_success({ @@ -86,9 +89,8 @@ def register_routes(bp): "source": "spotify", }) - from core.metadata_service import _create_fallback_client, _get_configured_fallback_source - fallback = _create_fallback_client() - fallback_source = _get_configured_fallback_source() + fallback = get_primary_client() + fallback_source = get_primary_source() results = fallback.search_albums(query, limit=limit) return api_success({ "albums": [_serialize_album(a) for a in results] if results else [], @@ -114,7 +116,9 @@ def register_routes(bp): try: ctx = current_app.soulsync spotify = ctx.get("spotify_client") - if spotify and spotify.is_authenticated(): + from core.metadata_service import get_primary_source, get_primary_client + primary = get_primary_source() + if primary == 'spotify' and spotify and spotify.is_spotify_authenticated(): results = spotify.search_artists(query, limit=limit) if results: return api_success({ @@ -122,9 +126,8 @@ def register_routes(bp): "source": "spotify", }) - from core.metadata_service import _create_fallback_client, _get_configured_fallback_source - fallback = _create_fallback_client() - fallback_source = _get_configured_fallback_source() + fallback = get_primary_client() + fallback_source = get_primary_source() results = fallback.search_artists(query, limit=limit) return api_success({ "artists": [_serialize_artist(a) for a in results] if results else [], diff --git a/core/seasonal_discovery.py b/core/seasonal_discovery.py index e1d31e21..e7f5d4ac 100644 --- a/core/seasonal_discovery.py +++ b/core/seasonal_discovery.py @@ -442,14 +442,14 @@ class SeasonalDiscoveryService: seasonal_albums = [] source = self._get_source() - use_spotify = self.spotify_client and self.spotify_client.is_authenticated() + use_spotify = (source == 'spotify') and self.spotify_client and self.spotify_client.is_spotify_authenticated() # IMPROVED: Sample 20 random watchlist artists (up from 10) for more variety sampled_artists = random.sample(watchlist_artists, min(20, len(watchlist_artists))) - from core.metadata_service import _create_fallback_client, _get_configured_fallback_source - fallback_client = _create_fallback_client() - fallback_source = _get_configured_fallback_source() + from core.metadata_service import get_primary_client, get_primary_source + fallback_client = get_primary_client() + fallback_source = get_primary_source() for artist in sampled_artists: try: @@ -507,7 +507,7 @@ class SeasonalDiscoveryService: config = SEASONAL_CONFIG[season_key] keywords = config['keywords'] source = self._get_source() - use_spotify = self.spotify_client and self.spotify_client.is_authenticated() + use_spotify = (source == 'spotify') and self.spotify_client and self.spotify_client.is_spotify_authenticated() seasonal_albums = [] seen_album_ids = set() @@ -767,10 +767,10 @@ class SeasonalDiscoveryService: # Get tracks from seasonal albums (filtered by source) seasonal_albums = self.get_seasonal_albums(season_key, limit=50, source=source) - use_spotify = self.spotify_client and self.spotify_client.is_authenticated() + use_spotify = (source == 'spotify') and self.spotify_client and self.spotify_client.is_spotify_authenticated() if not use_spotify: - from core.metadata_service import _create_fallback_client - fallback_client = _create_fallback_client() + from core.metadata_service import get_primary_client + fallback_client = get_primary_client() for album in seasonal_albums: try: From a6117d51741f8d944cd49da3c96d40fcf70327ee Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:52:36 -0700 Subject: [PATCH 10/19] Replace all legacy metadata_service imports with canonical functions All callers of _create_fallback_client() and _get_configured_fallback_source() now use get_primary_client() and get_primary_source() directly. No more legacy alias usage anywhere in the codebase. --- core/metadata_service.py | 8 ++++---- core/personalized_playlists.py | 8 ++++---- core/repair_jobs/library_reorganize.py | 4 ++-- core/repair_worker.py | 4 ++-- core/seasonal_discovery.py | 4 ++-- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/core/metadata_service.py b/core/metadata_service.py index b2ec0db4..9173f809 100644 --- a/core/metadata_service.py +++ b/core/metadata_service.py @@ -140,8 +140,8 @@ class MetadataService: """ self.preferred_provider = preferred_provider self.spotify = SpotifyClient() - self._fallback_source = _get_configured_fallback_source() - self.itunes = _create_fallback_client() # May be iTunesClient or DeezerClient + self._fallback_source = get_primary_source() + self.itunes = get_primary_client() # May be iTunesClient or DeezerClient self._log_initialization() @@ -312,10 +312,10 @@ class MetadataService: logger.info("Reloading metadata service configuration") self.spotify.reload_config() # Re-create fallback client in case the setting changed - new_source = _get_configured_fallback_source() + new_source = get_primary_source() if new_source != self._fallback_source: self._fallback_source = new_source - self.itunes = _create_fallback_client() + self.itunes = get_primary_client() elif hasattr(self.itunes, 'reload_config'): self.itunes.reload_config() self._log_initialization() diff --git a/core/personalized_playlists.py b/core/personalized_playlists.py index a00c72d7..b4c5aed7 100644 --- a/core/personalized_playlists.py +++ b/core/personalized_playlists.py @@ -954,8 +954,8 @@ class PersonalizedPlaylistsService: logger.warning(f"Error getting albums for {artist.get('name', artist['id'])}: {e}") continue else: - from core.metadata_service import _create_fallback_client - itunes = _create_fallback_client() + from core.metadata_service import get_primary_client + itunes = get_primary_client() for artist in artists_for_albums: try: albums = itunes.get_artist_albums(artist['id'], limit=10) @@ -1010,8 +1010,8 @@ class PersonalizedPlaylistsService: logger.warning(f"Error getting tracks from album: {e}") continue else: - from core.metadata_service import _create_fallback_client - itunes = _create_fallback_client() + from core.metadata_service import get_primary_client + itunes = get_primary_client() for album in selected_albums: try: album_data = itunes.get_album(album.id, include_tracks=True) diff --git a/core/repair_jobs/library_reorganize.py b/core/repair_jobs/library_reorganize.py index e64465d5..8c6debe0 100644 --- a/core/repair_jobs/library_reorganize.py +++ b/core/repair_jobs/library_reorganize.py @@ -785,8 +785,8 @@ class LibraryReorganizeJob(RepairJob): if not search_client: # Try fallback (iTunes/Deezer) try: - from core.metadata_service import _create_fallback_client - search_client = _create_fallback_client() + from core.metadata_service import get_primary_client + search_client = get_primary_client() source_name = 'fallback' except Exception: pass diff --git a/core/repair_worker.py b/core/repair_worker.py index eb3e3c53..163fe461 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -161,8 +161,8 @@ class RepairWorker: def itunes_client(self): if self._itunes_client is None: try: - from core.metadata_service import _create_fallback_client - self._itunes_client = _create_fallback_client() + from core.metadata_service import get_primary_client + self._itunes_client = get_primary_client() except Exception as e: logger.error("Failed to initialize fallback metadata client: %s", e) return self._itunes_client diff --git a/core/seasonal_discovery.py b/core/seasonal_discovery.py index e7f5d4ac..67859607 100644 --- a/core/seasonal_discovery.py +++ b/core/seasonal_discovery.py @@ -551,8 +551,8 @@ class SeasonalDiscoveryService: continue else: # Fallback metadata source (iTunes or Deezer) - from core.metadata_service import _create_fallback_client - fallback_client = _create_fallback_client() + from core.metadata_service import get_primary_client + fallback_client = get_primary_client() for keyword in search_keywords: try: From 603d66ba5d9055f5c2049939ab603232e8906c25 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:45:07 -0700 Subject: [PATCH 11/19] Add artist gate and fix substring matching in matching engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents downloading tracks from completely wrong artists by adding minimum artist score gates: - Soulseek: artist_score < 0.25 → reject (catches Belvedere vs Periphery) - YouTube: artist_score < 0.15 → reject (catches lizzylou06 vs Muse) Fixes artist substring matching to use word boundaries instead of plain containment — "muse" no longer matches "museum", "art" no longer matches "heart". This was causing false positives where wrong artists passed with artist_score=1.0 due to accidental substring containment. Improves similarity fallback by comparing against individual path segments instead of the full filename, so misspelled artist names (Radiohedd vs Radiohead) still match correctly. Adjusts YouTube weights from Title 70%/Artist 10% to Title 60%/Artist 20% to give artist more influence in YouTube matching. Addresses user reports of unreleased albums being downloaded with garbage content from wrong artists on Soulseek and YouTube. --- core/matching_engine.py | 69 ++++++++++++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/core/matching_engine.py b/core/matching_engine.py index ae5bb066..9fd4f65f 100644 --- a/core/matching_engine.py +++ b/core/matching_engine.py @@ -602,23 +602,42 @@ class MusicMatchingEngine: # No word boundary match - rely on similarity ratio only title_score = title_ratio - # 2. Artist Score: Keep substring matching for artists (they're more unique) - # But add similarity-based fallback for better matching + # 2. Artist Score: Word-boundary matching for artists to prevent false positives + # like "muse" matching "museum" or "art" matching "heart". + # Falls back to similarity matching for misspellings/variations. artist_score = 0.0 best_artist_similarity = 0.0 + # Split original filename into segments for per-segment matching. + # Handles path separators (/, \) and YouTube's || delimiter. + _artist_segments = re.split(r'[/\\|]+', slskd_track.filename) + _artist_segments_norm = [self.normalize_string(s) for s in _artist_segments if s.strip()] + for artist in spotify_artists_norm: - # Skip containment for very short names (≤2 chars) — "b" matches everything - if artist and len(artist) > 2 and artist in slskd_filename_norm: - artist_score = 1.0 # Perfect match if any artist is found - break - elif artist and len(artist) <= 2 and re.search(r'\b' + re.escape(artist) + r'\b', slskd_filename_norm): + if not artist: + continue + # Word boundary match against each segment — "muse" matches "muse" but not "museum" + found_boundary = False + for seg_norm in _artist_segments_norm: + if re.search(r'\b' + re.escape(artist) + r'\b', seg_norm): + found_boundary = True + break + # Also check full normalized string (handles flat filenames without separators) + if not found_boundary and re.search(r'\b' + re.escape(artist) + r'\b', slskd_filename_norm): + found_boundary = True + + if found_boundary: artist_score = 1.0 break else: - # Try similarity matching as fallback for misspellings/variations - artist_ratio = SequenceMatcher(None, artist, slskd_filename_norm).ratio() - best_artist_similarity = max(best_artist_similarity, artist_ratio) + # Try similarity matching per path segment for misspellings/variations. + # Comparing against the full filename dilutes the score because the artist + # name is a small fraction of "artist/album/track.flac". + for seg_norm in _artist_segments_norm: + if not seg_norm: + continue + seg_ratio = SequenceMatcher(None, artist, seg_norm).ratio() + best_artist_similarity = max(best_artist_similarity, seg_ratio) # If no exact artist match, use best similarity with penalty if artist_score == 0.0 and best_artist_similarity > 0: @@ -672,13 +691,35 @@ class MusicMatchingEngine: ) return 0.0 + # --- Minimum Artist Gate --- + # Reject matches where the artist has no resemblance to the target. + # Without this, a perfect title match + good duration can push a completely + # wrong artist past the confidence threshold (e.g. "Hexagons" by lizzylou06 + # when searching for "Hexagons" by Muse, or "Subhuman Nature" by Belvedere + # when searching for "Subhuman" by Periphery). + if not is_youtube and artist_score < 0.25: + logger.debug( + f"Artist gate reject: '{spotify_track.name}' by {spotify_track.artists} " + f"vs '{slskd_track.filename[:60]}' (artist_score={artist_score:.2f} < 0.25)" + ) + return 0.0 + + # Softer artist gate for YouTube — artist extraction from video titles is + # unreliable, but completely wrong uploaders should still be caught. + if is_youtube and artist_score < 0.15: + logger.debug( + f"YouTube artist gate reject: '{spotify_track.name}' by {spotify_track.artists} " + f"vs '{slskd_track.filename[:60]}' (artist_score={artist_score:.2f} < 0.15)" + ) + return 0.0 + # --- Final Weighted Score --- if is_youtube: - # For YouTube, rely more on Title and Duration since Artist is often missing from video titles - # and the search query already filtered by artist to some extent. - # New weights: Title 70%, Artist 10%, Duration 20% - final_confidence = (title_score * 0.70) + (artist_score * 0.10) + (duration_score * 0.20) + # For YouTube, artist gets more weight than before to reduce wrong-uploader matches. + # Previous: Title 70%, Artist 10%, Duration 20% — artist was nearly irrelevant. + # New: Title 60%, Artist 20%, Duration 20% + final_confidence = (title_score * 0.60) + (artist_score * 0.20) + (duration_score * 0.20) else: # Standard weights for Soulseek (Artist is critical for correctness) # Rebalanced weights: Artist matching is now more important to prevent false positives From 94e0671eb4972c96514c58030094a0c7ce1007cc Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:21:37 -0700 Subject: [PATCH 12/19] Update What's New with metadata pipeline and matching engine fixes Adds two new sections to the version modal covering the Unknown Artist fix, centralized metadata source selection, Deezer cache fix, sync completion feedback, Fix Unknown Artists maintenance job, and the matching engine artist gate improvements. --- web_server.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/web_server.py b/web_server.py index 31699986..57d5373a 100644 --- a/web_server.py +++ b/web_server.py @@ -21083,6 +21083,32 @@ def get_version_info(): "title": "What's New in SoulSync", "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes", "sections": [ + { + "title": "🔧 Metadata Pipeline Overhaul — Fix Unknown Artist & Source Selection", + "description": "Major fix for tracks downloading as 'Unknown Artist' and Spotify being used when Deezer/iTunes was selected", + "features": [ + "• Fixed playlist pipeline (discover → sync → wishlist → download) losing artist, track number, and album year data", + "• All discovery workers now respect your configured primary metadata source instead of always using Spotify", + "• Centralized metadata source selection in core/metadata_service.py — one source of truth for all features", + "• Fixed Deezer metadata cache returning incomplete data (missing track_number, release_date) from search result cache", + "• Sync completion toast now shows which specific tracks failed to match (not just a count)", + "• New 'Fix Unknown Artists' maintenance job — scans library for Unknown Artist tracks and corrects metadata, tags, and file paths", + "• One-time migration purges stale discovery and Deezer cache entries on first startup after update" + ], + "usage_note": "If you have existing Unknown Artist tracks, run the Fix Unknown Artists job from Settings > Maintenance." + }, + { + "title": "🛡️ Matching Engine — Artist Verification Gate", + "description": "Prevents downloading tracks from completely wrong artists on Soulseek and YouTube", + "features": [ + "• New artist gate rejects candidates where the artist doesn't match the target (Soulseek: < 0.25, YouTube: < 0.15)", + "• Fixed artist substring matching — 'muse' no longer matches 'museum', 'art' no longer matches 'heart'", + "• Artist similarity now compared per path segment instead of full filename — misspelled artist names still match correctly", + "• YouTube artist weight increased from 10% to 20% to reduce wrong-uploader matches", + "• Seasonal discovery, personalized playlists, and playlist explorer all use configured source instead of Spotify" + ], + "usage_note": "No action needed — matching improvements apply automatically to all new downloads." + }, { "title": "🎵 Deezer User Playlists — Browse & Download Your Library", "description": "New Deezer tab on the Sync page shows your personal playlists via ARL token — same flow as Spotify", From acb44793138917d441043af7d64c664f28e29f80 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:11:42 -0700 Subject: [PATCH 13/19] Re-discover tracks with incomplete metadata in playlist pipeline The discovery worker skipped already-discovered tracks even when their matched_data was incomplete (missing track_number, release_date, album ID). These stale discoveries from before the enrichment fix would persist forever, causing the automation pipeline to keep producing tracks with no year, no track numbers, and no cover art. Now treats discovered tracks as undiscovered if they're missing track_number AND have no release_date or album ID, so the enriched discovery pipeline fills in the gaps on the next run. --- web_server.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/web_server.py b/web_server.py index 57d5373a..b029d03c 100644 --- a/web_server.py +++ b/web_server.py @@ -32432,8 +32432,20 @@ def _run_playlist_discovery_worker(playlists, automation_id=None): except (json.JSONDecodeError, TypeError): pass if existing_extra.get('discovered'): - pl_skipped += 1 - total_skipped += 1 + # Check if matched_data is complete — old discoveries may be missing + # track_number/release_date due to the Track dataclass stripping them. + # Re-discover these so the enriched pipeline fills in the gaps. + md = existing_extra.get('matched_data', {}) + album = md.get('album', {}) + has_track_num = md.get('track_number') + has_release = album.get('release_date') if isinstance(album, dict) else None + has_album_id = album.get('id') if isinstance(album, dict) else None + if has_track_num and (has_release or has_album_id): + pl_skipped += 1 + total_skipped += 1 + else: + # Incomplete discovery — re-discover to get full metadata + undiscovered_tracks.append(track) else: undiscovered_tracks.append(track) From 5be6a46fb04df0f40f317c90f1ed5a76fd8c8793 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:42:39 -0700 Subject: [PATCH 14/19] Fix dismissed findings reappearing and reduce false orphan detections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The finding dedup check only looked for 'pending' and 'resolved' status, missing 'dismissed'. Dismissed findings were recreated as new entries on every scan. Now includes 'dismissed' in the dedup check. Orphan file detector improvements: - Increased path suffix matching depth from 3 to 4 segments (covers Genre/Artist/Album/track.flac paths) - Added filename-based fallback when Mutagen can't read file tags — parses title from "NN - Title [Quality].ext" pattern and matches against parent/grandparent folder names as artist --- core/repair_jobs/orphan_file_detector.py | 34 +++++++++++++++++++++--- core/repair_worker.py | 4 +-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/core/repair_jobs/orphan_file_detector.py b/core/repair_jobs/orphan_file_detector.py index 875b7665..b9f5371b 100644 --- a/core/repair_jobs/orphan_file_detector.py +++ b/core/repair_jobs/orphan_file_detector.py @@ -61,8 +61,9 @@ class OrphanFileDetectorJob(RepairJob): cursor.execute("SELECT file_path FROM tracks WHERE file_path IS NOT NULL AND file_path != ''") for row in cursor.fetchall(): parts = row[0].replace('\\', '/').split('/') - # Store last 1, 2, and 3 path components as lowercase suffixes - for depth in range(1, min(4, len(parts) + 1)): + # Store last 1-4 path components as lowercase suffixes. + # Depth 4 covers Genre/Artist/Album/track.flac scenarios. + for depth in range(1, min(5, len(parts) + 1)): suffix = '/'.join(parts[-depth:]).lower() known_suffixes.add(suffix) @@ -127,7 +128,7 @@ class OrphanFileDetectorJob(RepairJob): # Check if this file matches any known DB path via suffix matching fpath_parts = fpath.replace('\\', '/').split('/') is_known = False - for depth in range(1, min(4, len(fpath_parts) + 1)): + for depth in range(1, min(5, len(fpath_parts) + 1)): suffix = '/'.join(fpath_parts[-depth:]).lower() if suffix in known_suffixes: is_known = True @@ -161,6 +162,33 @@ class OrphanFileDetectorJob(RepairJob): except Exception: pass + # Last resort: parse title from filename pattern "NN - Title [Quality].ext" + # and match against known titles. Catches files with unreadable tags. + if not is_known and known_titles: + try: + fname_base = os.path.splitext(os.path.basename(fpath))[0] + # Strip quality tags like [FLAC 16bit], [MP3-320] + fname_clean = re.sub(r'\s*\[.*?\]\s*$', '', fname_base).strip() + # Strip leading track number: "01 - Title" → "Title" + fname_clean = re.sub(r'^\d{1,3}\s*[-–.]\s*', '', fname_clean).strip() + if fname_clean: + fname_lower = fname_clean.lower() + # Extract artist from parent folder + parent_folder = os.path.basename(os.path.dirname(fpath)).lower().strip() + # Try artist from grandparent (Artist/Album/track.flac) + grandparent = os.path.basename(os.path.dirname(os.path.dirname(fpath))).lower().strip() + for folder_artist in [parent_folder, grandparent]: + if (fname_lower, folder_artist) in known_titles: + is_known = True + break + clean_fn = _strip_extras(fname_lower) + clean_fa = _strip_extras(folder_artist) + if clean_fn and (clean_fn, clean_fa) in known_titles_clean: + is_known = True + break + except Exception: + pass + if not is_known: orphan_files.append(fpath) diff --git a/core/repair_worker.py b/core/repair_worker.py index 163fe461..ff3bd531 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -632,11 +632,11 @@ class RepairWorker: conn = self.db._get_connection() cursor = conn.cursor() - # Dedup check: skip if same finding already exists (pending OR recently resolved) + # Dedup check: skip if same finding already exists (pending, resolved, OR dismissed) cursor.execute(""" SELECT id FROM repair_findings WHERE job_id = ? AND finding_type = ? - AND status IN ('pending', 'resolved') + AND status IN ('pending', 'resolved', 'dismissed') AND ((entity_type = ? AND entity_id = ?) OR (file_path = ? AND file_path IS NOT NULL)) LIMIT 1 """, (job_id, finding_type, entity_type, entity_id, file_path)) From 1f0ef08b48dc90d6b64037fdd7a03748508b25ae Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:01:27 -0700 Subject: [PATCH 15/19] Add Music Videos directory setting for Plex music video support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New configurable path for storing music videos separately from audio files, following Plex's global music video folder convention. - Settings: library.music_videos_path (default: ./MusicVideos) - UI: Music Videos Dir field on Settings Downloads tab with lock/unlock - Docker: /app/MusicVideos volume mount in Dockerfile and docker-compose - Added 'library' to settings save whitelist (was missing — music_paths also wasn't persisting through main settings save) - No download functionality yet — path infrastructure only --- Dockerfile | 4 ++-- config/settings.py | 3 ++- docker-compose.yml | 1 + web_server.py | 2 +- webui/index.html | 8 ++++++++ webui/static/script.js | 7 +++++-- 6 files changed, 19 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index a8c246b9..97437292 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,7 +35,7 @@ COPY . . # Create necessary directories with proper permissions # NOTE: /app/data is for database FILES, /app/database is the Python package -RUN mkdir -p /app/config /app/data /app/logs /app/downloads /app/Transfer /app/scripts && \ +RUN mkdir -p /app/config /app/data /app/logs /app/downloads /app/Transfer /app/MusicVideos /app/scripts && \ chown -R soulsync:soulsync /app # Create defaults directory and copy template files @@ -47,7 +47,7 @@ RUN mkdir -p /defaults && \ # Create volume mount points # NOTE: Changed /app/database to /app/data to avoid overwriting Python package -VOLUME ["/app/config", "/app/data", "/app/logs", "/app/downloads", "/app/Transfer", "/app/scripts"] +VOLUME ["/app/config", "/app/data", "/app/logs", "/app/downloads", "/app/Transfer", "/app/MusicVideos", "/app/scripts"] # Copy and set up entrypoint script COPY entrypoint.sh /entrypoint.sh diff --git a/config/settings.py b/config/settings.py index 85272c93..af1844b3 100644 --- a/config/settings.py +++ b/config/settings.py @@ -461,7 +461,8 @@ class ConfigManager: "poll_interval": 30 }, "library": { - "music_paths": [] + "music_paths": [], + "music_videos_path": "./MusicVideos" }, "scripts": { "path": "./scripts", diff --git a/docker-compose.yml b/docker-compose.yml index 15f0a61d..d51b278c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,7 @@ services: - ./logs:/app/logs - ./downloads:/app/downloads - ./Staging:/app/Staging + - ./MusicVideos:/app/MusicVideos - ./scripts:/app/scripts # Use named volume for database persistence (separate from host database) # NOTE: Changed from /app/database to /app/data to avoid overwriting Python package diff --git a/web_server.py b/web_server.py index b029d03c..488b32c1 100644 --- a/web_server.py +++ b/web_server.py @@ -5184,7 +5184,7 @@ def handle_settings(): if 'active_media_server' in new_settings: config_manager.set_active_media_server(new_settings['active_media_server']) - for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs']: + for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs', 'library']: if service in new_settings: for key, value in new_settings[service].items(): config_manager.set(f'{service}.{key}', value) diff --git a/webui/index.html b/webui/index.html index 051f90f1..d2783f65 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4595,6 +4595,14 @@ +
+ +
+ + +
+
+