Fix album artist splitting on collab albums and artist name changes
Feed collab mode album-level artists instead of per-track artists so $albumartist and the album_artist tag are consistent across all tracks in an album. Fixes media servers (Navidrome/Jellyfin/Plex) showing one album split under multiple artist names (e.g. KPOP Demon Hunters). - _build_final_path_for_track: resolve $albumartist from explicit batch context or spotify_album.artists, pass album-level _artists_list to collab mode instead of per-track artists - _extract_spotify_metadata: same album-level artists for album_artist tag collab resolution - Wishlist path: pre-compute per-album artist map so all tracks from the same album get the same artist context - Download worker: propagate album artists array in spotify_album_context
This commit is contained in:
parent
8c6b7bc014
commit
30d5f76e3d
2 changed files with 114 additions and 51 deletions
164
web_server.py
164
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__
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue