diff --git a/web_server.py b/web_server.py index 536de5eb..bad80699 100644 --- a/web_server.py +++ b/web_server.py @@ -16129,9 +16129,36 @@ def _build_final_path_for_track(context, spotify_artist, album_info, file_ext): if isinstance(_ext, dict) and _ext.get('itunes_artist_id'): _itunes_aid = _ext['itunes_artist_id'] + # Resolve album-level artist name for $albumartist. + # Per-track spotify_artist may vary on collab albums or after artist name changes. + # Prefer stable album-level sources so all tracks land in the same folder. + _artist_name = spotify_artist["name"] if isinstance(spotify_artist, dict) else spotify_artist.name + _album_artist_name = _artist_name # default: same as track artist + + # Build album-level artists list for collab mode resolution. + # Using album-level artists (instead of per-track _artists) ensures collab mode + # produces the SAME result for every track, preventing folder/tag splits. + _album_artists_for_collab = None # None = fall back to per-track _artists + _explicit_artist_ctx = track_info.get('_explicit_artist_context') if isinstance(track_info, dict) else None + if isinstance(_explicit_artist_ctx, dict) and _explicit_artist_ctx.get('name'): + _album_artist_name = _explicit_artist_ctx['name'] + _album_artists_for_collab = [_explicit_artist_ctx] + elif isinstance(_explicit_artist_ctx, str) and _explicit_artist_ctx: + _album_artist_name = _explicit_artist_ctx + _album_artists_for_collab = [{'name': _explicit_artist_ctx}] + else: + _sa_artists = _spotify_album.get('artists', []) if _spotify_album else [] + if _sa_artists: + _first_sa = _sa_artists[0] + if isinstance(_first_sa, dict) and _first_sa.get('name'): + _album_artist_name = _first_sa['name'] + elif isinstance(_first_sa, str) and _first_sa: + _album_artist_name = _first_sa + _album_artists_for_collab = _sa_artists + template_context = { - 'artist': spotify_artist["name"] if isinstance(spotify_artist, dict) else spotify_artist.name, - 'albumartist': spotify_artist["name"] if isinstance(spotify_artist, dict) else spotify_artist.name, + 'artist': _artist_name, + 'albumartist': _album_artist_name, 'album': album_info['album_name'], 'title': clean_track_name, 'track_number': track_number, @@ -16139,7 +16166,7 @@ def _build_final_path_for_track(context, spotify_artist, album_info, file_ext): 'year': year, 'quality': context.get('_audio_quality', ''), 'albumtype': album_type_display, - '_artists_list': _artists, + '_artists_list': _album_artists_for_collab if _album_artists_for_collab else _artists, '_itunes_artist_id': _itunes_aid, } spotify_album = context.get('spotify_album', {}) @@ -16162,8 +16189,8 @@ def _build_final_path_for_track(context, spotify_artist, album_info, file_ext): os.makedirs(os.path.join(transfer_dir, folder_path), exist_ok=True) return final_path, True else: - # Fallback - artist_name_sanitized = _sanitize_filename(template_context['artist']) + # Fallback — use albumartist for folder consistency (same as template) + artist_name_sanitized = _sanitize_filename(template_context['albumartist']) album_name_sanitized = _sanitize_filename(album_info['album_name']) artist_dir = os.path.join(transfer_dir, artist_name_sanitized) album_folder_name = f"{artist_name_sanitized} - {album_name_sanitized}" @@ -17112,12 +17139,42 @@ def _extract_spotify_metadata(context: dict, artist: dict, album_info: dict) -> metadata['artist'] = artist.get('name', '') print(f"🎵 Metadata: Using primary artist: '{metadata['artist']}'") - # Resolve album_artist for collab albums (match folder path logic) + # Resolve album_artist for consistent tagging across all tracks in an album. + # Priority: 1) explicit batch artist context (same artist for whole album) + # 2) album-level artists from spotify_album + # 3) context-level artist (spotify_artist parameter) + # 4) collab mode first-artist resolution (per-track, last resort) + # Using album-level artist prevents media server album splits when an artist + # changed names (Kanye West → Ye) and Spotify returns different per-track artists. _raw_album_artist = artist.get('name', '') + _track_info_ctx = context.get('track_info', {}) or {} + _explicit_aa = _track_info_ctx.get('_explicit_artist_context') if isinstance(_track_info_ctx, dict) else None + + # Build album-level artists list for collab mode resolution. + # Using album-level artists (instead of per-track) ensures collab mode produces + # the SAME album_artist tag for every track, preventing media server album splits. + _album_artists_for_collab = None + if isinstance(_explicit_aa, dict) and _explicit_aa.get('name'): + _raw_album_artist = _explicit_aa['name'] + _album_artists_for_collab = [_explicit_aa] + elif isinstance(_explicit_aa, str) and _explicit_aa: + _raw_album_artist = _explicit_aa + _album_artists_for_collab = [{'name': _explicit_aa}] + elif spotify_album and isinstance(spotify_album, dict): + _sa_aa = spotify_album.get('artists', []) + if _sa_aa: + _first_aa = _sa_aa[0] + if isinstance(_first_aa, dict) and _first_aa.get('name'): + _raw_album_artist = _first_aa['name'] + elif isinstance(_first_aa, str) and _first_aa: + _raw_album_artist = _first_aa + _album_artists_for_collab = _sa_aa + collab_mode = config_manager.get('file_organization.collab_artist_mode', 'first') if collab_mode == 'first' and _raw_album_artist: original_search = context.get("original_search_result", {}) - _ctx_artists = original_search.get('artists') or context.get('track_info', {}).get('artists') or [] + # Prefer album-level artists for collab resolution (consistent per album) + _ctx_artists = _album_artists_for_collab or original_search.get('artists') or _track_info_ctx.get('artists') or [] if len(_ctx_artists) > 1: # Multiple artist objects (Spotify) — use first first = _ctx_artists[0] @@ -17125,7 +17182,7 @@ def _extract_spotify_metadata(context: dict, artist: dict, album_info: dict) -> elif len(_ctx_artists) == 1 and (',' in _raw_album_artist or ' & ' in _raw_album_artist): # Single combined string (iTunes) — resolve via artist ID _aid = str(artist.get('id', '')) - _src = original_search.get('_source') or context.get('track_info', {}).get('_source', '') + _src = original_search.get('_source') or _track_info_ctx.get('_source', '') if _aid.isdigit() and _src != 'deezer': try: from core.itunes_client import iTunesClient @@ -25726,12 +25783,13 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json): if total_discs > 1: print(f"💿 [Multi-Disc] Detected {total_discs} discs for album '{batch_album_context.get('name')}'") - # Pre-compute total_discs per album for wishlist tracks (grouped by album ID) + # Pre-compute per-album data for wishlist tracks (grouped by album ID) # Wishlist tracks aren't batch_is_album but each track has disc_number in spotify_data wishlist_album_disc_counts = {} + wishlist_album_artist_map = {} # album_id -> resolved artist context (consistent per album) if playlist_id == 'wishlist': import json as _json - # First pass: collect disc_number from stored spotify_data + # First pass: collect disc_number and resolve ONE artist per album for t in tracks_json: sp_data = t.get('spotify_data', {}) if isinstance(sp_data, str): @@ -25746,6 +25804,36 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json): wishlist_album_disc_counts[album_id] = max( wishlist_album_disc_counts.get(album_id, 1), disc_num ) + # Resolve album-level artist once per album (first track wins) + if album_id not in wishlist_album_artist_map: + _wl_source = t.get('source_info') or {} + if isinstance(_wl_source, str): + try: + _wl_source = _json.loads(_wl_source) + except: + _wl_source = {} + _wl_album = album_val if isinstance(album_val, dict) else {} + _wl_album_artists = _wl_album.get('artists', []) + # Priority: watchlist artist > album artists > track artists + if _wl_source.get('watchlist_artist_name'): + wishlist_album_artist_map[album_id] = { + 'name': _wl_source['watchlist_artist_name'], + 'id': _wl_source.get('watchlist_artist_id', '') + } + elif _wl_source.get('artist_name'): + wishlist_album_artist_map[album_id] = {'name': _wl_source['artist_name']} + elif _wl_album_artists: + _fa = _wl_album_artists[0] + wishlist_album_artist_map[album_id] = _fa if isinstance(_fa, dict) else {'name': str(_fa)} + else: + _wl_track_artists = sp_data.get('artists', []) + if _wl_track_artists: + _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')} + print(f"🔗 [Wishlist Album Grouping] Album '{_wl_album.get('name', album_id)}' → artist: '{wishlist_album_artist_map[album_id].get('name', '?')}'") + for res in missing_tracks: @@ -25780,45 +25868,13 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json): # We need at least an album name and artist if s_album and isinstance(s_album, dict) and s_album.get('name'): - # Construct artist context for folder path. - # Priority: 1) watchlist artist name (the artist the user monitors) - # 2) album.artists[0] 3) track artists - # Watchlist artist is preferred because collab singles have combined - # album artists like "Y2K & Ro Ransom" — but the user is monitoring - # just "Y2K", so the folder should be "Y2K". - artist_ctx = {} - s_album_artists = s_album.get('artists', []) - source_info = track_info.get('source_info') or {} - if isinstance(source_info, str): - try: - import json - source_info = json.loads(source_info) - except: - source_info = {} - - if source_info.get('watchlist_artist_name'): - # Best for watchlist tracks: the individual artist being monitored - artist_ctx = {'name': source_info['watchlist_artist_name'], - 'id': source_info.get('watchlist_artist_id', '')} - elif source_info.get('artist_name'): - # Other sources that set an explicit artist name - artist_ctx = {'name': source_info['artist_name']} - elif s_album_artists and len(s_album_artists) > 0: - # Album-level artists from Spotify (may be combined for collabs) - first_artist = s_album_artists[0] - if isinstance(first_artist, dict): - artist_ctx = first_artist - else: - artist_ctx = {'name': str(first_artist)} - elif s_artists and len(s_artists) > 0: - # Last resort: track-level artist (old wishlist entries without album artist data) - first_artist = s_artists[0] - if isinstance(first_artist, dict): - artist_ctx = first_artist - else: - artist_ctx = {'name': str(first_artist)} - else: - # Fallback if no artist at all + # 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') + 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')} # Construct minimal album context @@ -26707,7 +26763,8 @@ def _attempt_download_with_candidates(task_id, candidates, track, batch_id=None) 'image_url': album_image_url, 'total_tracks': explicit_album.get('total_tracks', 0), 'total_discs': explicit_album.get('total_discs', 1), - 'album_type': explicit_album.get('album_type', 'album') + 'album_type': explicit_album.get('album_type', 'album'), + 'artists': explicit_album.get('artists', [{'name': spotify_artist_context.get('name', '')}]) } print(f"🎵 [Explicit Context] Using real album data: '{spotify_album_context['name']}' ({spotify_album_context['album_type']}, {spotify_album_context['total_discs']} disc(s))") else: @@ -26725,6 +26782,10 @@ def _attempt_download_with_candidates(task_id, candidates, track, batch_id=None) elif fallback_images and isinstance(fallback_images, list) and len(fallback_images) > 0: fallback_image_url = fallback_images[0].get('url') if isinstance(fallback_images[0], dict) else None spotify_artist_context = {'id': 'from_sync_modal', 'name': track.artists[0] if track.artists else 'Unknown', 'genres': []} + # Preserve album-level artists for consistent folder naming + _fallback_album_artists = fallback_album.get('artists', []) + if not _fallback_album_artists: + _fallback_album_artists = [{'name': track.artists[0]}] if track.artists else [] spotify_album_context = { 'id': fallback_album.get('id', 'from_sync_modal'), 'name': fallback_album.get('name', '') or track.album, @@ -26732,7 +26793,8 @@ def _attempt_download_with_candidates(task_id, candidates, track, batch_id=None) 'image_url': fallback_image_url, 'album_type': fallback_album.get('album_type', 'album'), 'total_tracks': fallback_album.get('total_tracks', 0), - 'total_discs': fallback_album.get('total_discs', 1) + 'total_discs': fallback_album.get('total_discs', 1), + 'artists': _fallback_album_artists } download_payload = candidate.__dict__ diff --git a/webui/static/helper.js b/webui/static/helper.js index f20f5d4e..5fecc6e5 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3403,6 +3403,7 @@ function closeHelperSearch() { const WHATS_NEW = { '2.2': [ // Newest features first + { title: 'Fix Album Folder Splitting', desc: 'Collab albums and artist name changes no longer scatter tracks across multiple folders — $albumartist now uses album-level artist consistently' }, { title: 'Discogs Integration', desc: 'New metadata source — enrichment worker, fallback source, enhanced search tab, watchlist support, cache browser. Genres, styles, labels, bios, ratings from 400+ taxonomy', page: 'dashboard' }, { title: 'Webhook THEN Action', desc: 'Send HTTP POST to any URL when automations complete — integrate with Gotify, Home Assistant, Slack, n8n. Configurable headers and message template', page: 'automations' }, { title: 'API Rate Monitor', desc: 'Real-time speedometer gauges for all enrichment services on the Dashboard. Click any gauge for 24h history chart. Spotify shows per-endpoint breakdown', page: 'dashboard' },