From b49806a83ae443b8053832466057ecc8a1046ea8 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:39:27 -0700 Subject: [PATCH] Add collaborative album artist handling with per-source resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New setting in Settings → Library → File Organization: "Collaborative Album Artist" — choose between first listed artist (default) or all artists combined for $albumartist in folder paths and album_artist tag. Per-source resolution: - Spotify: artists array has separate objects — picks first directly - Deezer: API already returns first artist only — no change needed - iTunes: combined string ("Larry June, Curren$y & The Alchemist") — resolves via artistId API lookup to get primary name ("Larry June"). Safe for "Tyler, the Creator" and "Simon & Garfunkel" because their IDs resolve to the same combined name (no change). Applied to both folder path ($albumartist template) and album_artist metadata tag for consistency. Track artist tag always keeps all artists. iTunes lookup only fires when source is iTunes (numeric ID + not Deezer). --- core/itunes_client.py | 18 +++++++++++ web_server.py | 72 ++++++++++++++++++++++++++++++++++++++++-- webui/index.html | 11 +++++++ webui/static/script.js | 2 ++ 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/core/itunes_client.py b/core/itunes_client.py index 1d4ba022..2e0af7f8 100644 --- a/core/itunes_client.py +++ b/core/itunes_client.py @@ -194,6 +194,10 @@ class Album: if 'compilation' in collection_type.lower(): album_type = 'compilation' + # Store artistId for primary artist resolution (collab album support) + if album_data.get('artistId'): + external_urls['itunes_artist_id'] = str(album_data['artistId']) + return cls( id=str(album_data.get('collectionId', '')), name=_clean_itunes_album_name(album_data.get('collectionName', '')), @@ -843,6 +847,20 @@ class iTunesClient: return None @rate_limited + def resolve_primary_artist(self, artist_id: str) -> Optional[str]: + """Resolve an iTunes artist ID to the primary artist name. + For collab albums, iTunes uses the primary artist's ID but a combined display name. + Looking up the ID returns the real primary artist name. + e.g., artistId 675391681 → 'Larry June' (not 'Larry June, Curren$y & The Alchemist')""" + try: + results = self._lookup(id=artist_id) + for item in results: + if item.get('wrapperType') == 'artist' and item.get('artistName'): + return item['artistName'] + except Exception: + pass + return None + def search_artists(self, query: str, limit: int = 20) -> List[Artist]: """Search for artists using iTunes API. diff --git a/web_server.py b/web_server.py index a21dea3f..532ade78 100644 --- a/web_server.py +++ b/web_server.py @@ -14684,6 +14684,8 @@ def _build_final_path_for_track(context, spotify_artist, album_info, file_ext): playlist_name = track_info.get("_playlist_name", "Unknown Playlist") track_name = original_search.get('spotify_clean_title') or original_search.get('title', 'Unknown Track') + _artists = original_search.get('artists') or track_info.get('artists') or [] + 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, @@ -14695,6 +14697,8 @@ 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, + '_itunes_artist_id': str(spotify_artist.get('id', '')) if isinstance(spotify_artist, dict) and str(spotify_artist.get('id', '')).isdigit() and (original_search.get('_source') == 'itunes' or track_info.get('_source') == 'itunes') else None, } folder_path, filename_base = _get_file_path_from_template(template_context, 'playlist_path') @@ -14729,6 +14733,22 @@ def _build_final_path_for_track(context, spotify_artist, album_info, file_ext): # Multi-disc album subfolder support disc_number = album_info.get('disc_number', 1) + # Get structured artists list for collab artist handling + _artists = original_search.get('artists') or track_info.get('artists') or [] + # Extract iTunes artist ID for primary artist resolution (only for iTunes source) + _spotify_album = context.get('spotify_album', {}) + _itunes_aid = None + _track_source = original_search.get('_source') or track_info.get('_source', '') + _is_itunes = _track_source == 'itunes' or (isinstance(spotify_artist, dict) and str(spotify_artist.get('id', '')).isdigit() and _track_source != 'deezer') + if _is_itunes and isinstance(spotify_artist, dict): + _aid = spotify_artist.get('id', '') + if str(_aid).isdigit(): + _itunes_aid = str(_aid) + if not _itunes_aid and _spotify_album: + _ext = _spotify_album.get('external_urls', {}) + if isinstance(_ext, dict) and _ext.get('itunes_artist_id'): + _itunes_aid = _ext['itunes_artist_id'] + 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, @@ -14739,6 +14759,8 @@ 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, + '_itunes_artist_id': _itunes_aid, } spotify_album = context.get('spotify_album', {}) total_discs = spotify_album.get('total_discs', 1) if spotify_album else 1 @@ -15212,7 +15234,31 @@ def _apply_path_template(template: str, context: dict) -> str: # (e.g., $albumartist must be replaced before $album to prevent "Scorpionartist" from typo "$albumartis") # Longest variables first - result = result.replace('$albumartist', clean_context.get('albumartist', clean_context.get('artist', 'Unknown Artist'))) + album_artist_value = clean_context.get('albumartist', clean_context.get('artist', 'Unknown Artist')) + # Collaborative album artist handling: "first" uses only the primary artist for folder names + collab_mode = config_manager.get('file_organization.collab_artist_mode', 'first') + if collab_mode == 'first' and album_artist_value: + # Use structured artists list to safely extract first artist + # Only splits when we have multiple distinct artist objects (Spotify provides these) + # Avoids string splitting which breaks names like "Tyler, the Creator" or "Simon & Garfunkel" + artists_list = context.get('_artists_list') + if artists_list and len(artists_list) > 1: + # Multiple artist objects (Spotify) — use first + first = artists_list[0] + album_artist_value = first.get('name', first) if isinstance(first, dict) else str(first) + elif artists_list and len(artists_list) == 1: + # Single artist string — could be a combined name from iTunes + # Resolve via artistId if available (safe: "Simon & Garfunkel" ID → "Simon & Garfunkel") + itunes_artist_id = context.get('_itunes_artist_id') + if itunes_artist_id and (',' in album_artist_value or ' & ' in album_artist_value): + try: + from core.itunes_client import iTunesClient + resolved = iTunesClient().resolve_primary_artist(itunes_artist_id) + if resolved and resolved != album_artist_value: + album_artist_value = resolved + except Exception: + pass + result = result.replace('$albumartist', album_artist_value) result = result.replace('$albumtype', clean_context.get('albumtype', 'Album')) result = result.replace('$playlist', clean_context.get('playlist_name', '')) @@ -15639,7 +15685,29 @@ def _extract_spotify_metadata(context: dict, artist: dict, album_info: dict) -> metadata['artist'] = artist.get('name', '') print(f"🎵 Metadata: Using primary artist: '{metadata['artist']}'") - metadata['album_artist'] = artist.get('name', '') # Crucial for library organization + # Resolve album_artist for collab albums (match folder path logic) + _raw_album_artist = artist.get('name', '') + 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 [] + if len(_ctx_artists) > 1: + # Multiple artist objects (Spotify) — use first + first = _ctx_artists[0] + _raw_album_artist = first.get('name', first) if isinstance(first, dict) else str(first) + 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', '') + if _aid.isdigit() and _src != 'deezer': + try: + from core.itunes_client import iTunesClient + resolved = iTunesClient().resolve_primary_artist(_aid) + if resolved and resolved != _raw_album_artist: + _raw_album_artist = resolved + except Exception: + pass + metadata['album_artist'] = _raw_album_artist # Crucial for library organization if album_info.get('is_album'): metadata['album'] = album_info.get('album_name', 'Unknown Album') diff --git a/webui/index.html b/webui/index.html index 3c549ba3..d25519dc 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4506,6 +4506,17 @@ Label used for auto-created disc subfolders on multi-disc albums. +
+ + + How $albumartist resolves for albums with multiple artists. + "First Listed" uses only the primary artist for folder names (e.g., "Larry June" instead of "Larry June, Curren$y & The Alchemist"). + Full artist list is always preserved in file metadata tags. +
+