fix: use ID3v2 '/' separator for multi-artist tracks (Plex compatible)

Multi-artist tracks were using comma separator which Plex interprets as a
single artist name. Changed to '/' separator (ID3v2.4 standard) which Plex
and other media servers recognize for multi-artist tracks.

- Updated all artist joining to use '/' instead of ', '
- Added _split_multi_artist() to properly write multiple artist tag values
- ID3 (MP3): writes multiple values in TPE1 frame
- Vorbis (FLAC/OGG): writes multiple artist fields
- MP4 (M4A): writes multiple artist values

Affected files:
- web_server.py: _join_artist_names() and metadata assembly
- core/tag_writer.py: ID3/Vorbis/MP4 writer functions
- core/personalized_playlists.py: track artist joining
- core/seasonal_discovery.py: album artist joining
- core/hifi_client.py: track artist parsing
This commit is contained in:
GitHub Actions 2026-04-12 17:05:29 +00:00
parent 16fbba018f
commit a4fdb01ab0
5 changed files with 33 additions and 12 deletions

View file

@ -303,7 +303,8 @@ class HiFiClient:
names.append(a.get('name', ''))
elif isinstance(a, str):
names.append(a)
artist_name = ', '.join(n for n in names if n) or 'Unknown Artist'
# Use "/" as separator for multi-artist tracks (ID3v2 standard, recognized by Plex)
artist_name = '/'.join(n for n in names if n) or 'Unknown Artist'
elif isinstance(artists_raw, dict):
artist_name = artists_raw.get('name', 'Unknown Artist')
elif isinstance(artists_raw, str):

View file

@ -991,7 +991,7 @@ class PersonalizedPlaylistsService:
all_tracks.append({
'spotify_track_id': track['id'],
'track_name': track['name'],
'artist_name': ', '.join([a['name'] for a in track.get('artists', [])]),
'artist_name': '/'.join([a['name'] for a in track.get('artists', [])]),
'album_name': album_data.get('name', 'Unknown'),
'album_cover_url': album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None,
'duration_ms': track.get('duration_ms', 0),
@ -1029,7 +1029,7 @@ class PersonalizedPlaylistsService:
all_tracks.append({
'spotify_track_id': track_id,
'track_name': track.get('name', ''),
'artist_name': ', '.join(artist_names) if artist_names else 'Unknown',
'artist_name': '/'.join(artist_names) if artist_names else 'Unknown',
'album_name': album_name,
'album_cover_url': album_cover,
'duration_ms': track.get('duration_ms', 0),

View file

@ -537,7 +537,7 @@ class SeasonalDiscoveryService:
seasonal_albums.append({
'spotify_album_id': album.id,
'album_name': album.name,
'artist_name': ', '.join(album.artists) if album.artists else 'Various Artists',
'artist_name': '/'.join(album.artists) if album.artists else 'Various Artists',
'album_cover_url': album.image_url if hasattr(album, 'image_url') else None,
'release_date': album.release_date if hasattr(album, 'release_date') else None,
'popularity': getattr(album, 'popularity', 50)
@ -567,7 +567,7 @@ class SeasonalDiscoveryService:
seasonal_albums.append({
'spotify_album_id': album.id, # Column name is spotify_album_id but stores iTunes ID too
'album_name': album.name,
'artist_name': ', '.join(album.artists) if album.artists else 'Various Artists',
'artist_name': '/'.join(album.artists) if album.artists else 'Various Artists',
'album_cover_url': album.image_url if hasattr(album, 'image_url') else None,
'release_date': album.release_date if hasattr(album, 'release_date') else None,
'popularity': 50 # iTunes has no popularity — default mid-range

View file

@ -305,7 +305,8 @@ def _write_id3(audio, title, artist, album_artist, album, year, genre,
written.append('title')
if artist:
audio.tags.delall('TPE1')
audio.tags.add(TPE1(encoding=3, text=[artist]))
artists = _split_multi_artist(artist)
audio.tags.add(TPE1(encoding=3, text=artists))
written.append('artist')
if album_artist:
audio.tags.delall('TPE2')
@ -346,7 +347,8 @@ def _write_vorbis(audio, title, artist, album_artist, album, year, genre,
audio['title'] = [title]
written.append('title')
if artist:
audio['artist'] = [artist]
artists = _split_multi_artist(artist)
audio['artist'] = artists
written.append('artist')
if album_artist:
audio['albumartist'] = [album_artist]
@ -380,7 +382,8 @@ def _write_mp4(audio, title, artist, album_artist, album, year, genre,
audio['\xa9nam'] = [title]
written.append('title')
if artist:
audio['\xa9ART'] = [artist]
artists = _split_multi_artist(artist)
audio['\xa9ART'] = artists
written.append('artist')
if album_artist:
audio['aART'] = [album_artist]
@ -477,6 +480,19 @@ def _parse_track_num(val) -> Optional[int]:
return None
def _split_multi_artist(artist_str: str) -> List[str]:
"""Split a multi-artist string into individual artist names.
Handles the '/' separator used by ID3v2.4 for multi-artist tracks.
Returns a list of individual artist names.
"""
if not artist_str:
return []
if '/' in artist_str:
return [a.strip() for a in artist_str.split('/') if a.strip()]
return [artist_str]
def _normalize_for_compare(val) -> str:
"""Normalize a value for comparison."""
if val is None:

View file

@ -18035,8 +18035,11 @@ def _extract_spotify_metadata(context: dict, artist: dict, album_info: dict) ->
all_artists.append(a)
else:
all_artists.append(str(a))
metadata['artist'] = ', '.join(all_artists)
print(f"Metadata: Using all artists: '{metadata['artist']}'")
# Use "/" as separator for multi-artist tracks (ID3v2 standard, recognized by Plex)
# This allows Plex to properly identify individual artists instead of treating
# "Artist A, Artist B" as a single artist name
metadata['artist'] = '/'.join(all_artists)
print(f"🎵 Metadata: Using all artists: '{metadata['artist']}'")
else:
# Fallback to single artist
metadata['artist'] = artist.get('name', '')
@ -33143,8 +33146,9 @@ def _extract_artist_names(artists):
return [_extract_artist_name(a) for a in (artists or [])]
def _join_artist_names(artists):
"""Join artist names from a list that may contain dicts or strings."""
return ', '.join(_extract_artist_names(artists))
"""Join artist names from a list that may contain dicts or strings.
Uses '/' as separator for multi-artist tracks (ID3v2 standard, recognized by Plex)."""
return '/'.join(_extract_artist_names(artists))
def _get_discovery_cache_key(title, artist):
"""Normalize title/artist for discovery cache lookup using matching_engine."""