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:
parent
16fbba018f
commit
a4fdb01ab0
5 changed files with 33 additions and 12 deletions
|
|
@ -303,7 +303,8 @@ class HiFiClient:
|
||||||
names.append(a.get('name', ''))
|
names.append(a.get('name', ''))
|
||||||
elif isinstance(a, str):
|
elif isinstance(a, str):
|
||||||
names.append(a)
|
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):
|
elif isinstance(artists_raw, dict):
|
||||||
artist_name = artists_raw.get('name', 'Unknown Artist')
|
artist_name = artists_raw.get('name', 'Unknown Artist')
|
||||||
elif isinstance(artists_raw, str):
|
elif isinstance(artists_raw, str):
|
||||||
|
|
|
||||||
|
|
@ -991,7 +991,7 @@ class PersonalizedPlaylistsService:
|
||||||
all_tracks.append({
|
all_tracks.append({
|
||||||
'spotify_track_id': track['id'],
|
'spotify_track_id': track['id'],
|
||||||
'track_name': track['name'],
|
'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_name': album_data.get('name', 'Unknown'),
|
||||||
'album_cover_url': album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None,
|
'album_cover_url': album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None,
|
||||||
'duration_ms': track.get('duration_ms', 0),
|
'duration_ms': track.get('duration_ms', 0),
|
||||||
|
|
@ -1029,7 +1029,7 @@ class PersonalizedPlaylistsService:
|
||||||
all_tracks.append({
|
all_tracks.append({
|
||||||
'spotify_track_id': track_id,
|
'spotify_track_id': track_id,
|
||||||
'track_name': track.get('name', ''),
|
'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_name': album_name,
|
||||||
'album_cover_url': album_cover,
|
'album_cover_url': album_cover,
|
||||||
'duration_ms': track.get('duration_ms', 0),
|
'duration_ms': track.get('duration_ms', 0),
|
||||||
|
|
|
||||||
|
|
@ -537,7 +537,7 @@ class SeasonalDiscoveryService:
|
||||||
seasonal_albums.append({
|
seasonal_albums.append({
|
||||||
'spotify_album_id': album.id,
|
'spotify_album_id': album.id,
|
||||||
'album_name': album.name,
|
'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,
|
'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,
|
'release_date': album.release_date if hasattr(album, 'release_date') else None,
|
||||||
'popularity': getattr(album, 'popularity', 50)
|
'popularity': getattr(album, 'popularity', 50)
|
||||||
|
|
@ -567,7 +567,7 @@ class SeasonalDiscoveryService:
|
||||||
seasonal_albums.append({
|
seasonal_albums.append({
|
||||||
'spotify_album_id': album.id, # Column name is spotify_album_id but stores iTunes ID too
|
'spotify_album_id': album.id, # Column name is spotify_album_id but stores iTunes ID too
|
||||||
'album_name': album.name,
|
'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,
|
'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,
|
'release_date': album.release_date if hasattr(album, 'release_date') else None,
|
||||||
'popularity': 50 # iTunes has no popularity — default mid-range
|
'popularity': 50 # iTunes has no popularity — default mid-range
|
||||||
|
|
|
||||||
|
|
@ -305,7 +305,8 @@ def _write_id3(audio, title, artist, album_artist, album, year, genre,
|
||||||
written.append('title')
|
written.append('title')
|
||||||
if artist:
|
if artist:
|
||||||
audio.tags.delall('TPE1')
|
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')
|
written.append('artist')
|
||||||
if album_artist:
|
if album_artist:
|
||||||
audio.tags.delall('TPE2')
|
audio.tags.delall('TPE2')
|
||||||
|
|
@ -346,7 +347,8 @@ def _write_vorbis(audio, title, artist, album_artist, album, year, genre,
|
||||||
audio['title'] = [title]
|
audio['title'] = [title]
|
||||||
written.append('title')
|
written.append('title')
|
||||||
if artist:
|
if artist:
|
||||||
audio['artist'] = [artist]
|
artists = _split_multi_artist(artist)
|
||||||
|
audio['artist'] = artists
|
||||||
written.append('artist')
|
written.append('artist')
|
||||||
if album_artist:
|
if album_artist:
|
||||||
audio['albumartist'] = [album_artist]
|
audio['albumartist'] = [album_artist]
|
||||||
|
|
@ -380,7 +382,8 @@ def _write_mp4(audio, title, artist, album_artist, album, year, genre,
|
||||||
audio['\xa9nam'] = [title]
|
audio['\xa9nam'] = [title]
|
||||||
written.append('title')
|
written.append('title')
|
||||||
if artist:
|
if artist:
|
||||||
audio['\xa9ART'] = [artist]
|
artists = _split_multi_artist(artist)
|
||||||
|
audio['\xa9ART'] = artists
|
||||||
written.append('artist')
|
written.append('artist')
|
||||||
if album_artist:
|
if album_artist:
|
||||||
audio['aART'] = [album_artist]
|
audio['aART'] = [album_artist]
|
||||||
|
|
@ -477,6 +480,19 @@ def _parse_track_num(val) -> Optional[int]:
|
||||||
return None
|
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:
|
def _normalize_for_compare(val) -> str:
|
||||||
"""Normalize a value for comparison."""
|
"""Normalize a value for comparison."""
|
||||||
if val is None:
|
if val is None:
|
||||||
|
|
|
||||||
|
|
@ -18035,8 +18035,11 @@ def _extract_spotify_metadata(context: dict, artist: dict, album_info: dict) ->
|
||||||
all_artists.append(a)
|
all_artists.append(a)
|
||||||
else:
|
else:
|
||||||
all_artists.append(str(a))
|
all_artists.append(str(a))
|
||||||
metadata['artist'] = ', '.join(all_artists)
|
# Use "/" as separator for multi-artist tracks (ID3v2 standard, recognized by Plex)
|
||||||
print(f"Metadata: Using all artists: '{metadata['artist']}'")
|
# 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:
|
else:
|
||||||
# Fallback to single artist
|
# Fallback to single artist
|
||||||
metadata['artist'] = artist.get('name', '')
|
metadata['artist'] = artist.get('name', '')
|
||||||
|
|
@ -33143,8 +33146,9 @@ def _extract_artist_names(artists):
|
||||||
return [_extract_artist_name(a) for a in (artists or [])]
|
return [_extract_artist_name(a) for a in (artists or [])]
|
||||||
|
|
||||||
def _join_artist_names(artists):
|
def _join_artist_names(artists):
|
||||||
"""Join artist names from a list that may contain dicts or strings."""
|
"""Join artist names from a list that may contain dicts or strings.
|
||||||
return ', '.join(_extract_artist_names(artists))
|
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):
|
def _get_discovery_cache_key(title, artist):
|
||||||
"""Normalize title/artist for discovery cache lookup using matching_engine."""
|
"""Normalize title/artist for discovery cache lookup using matching_engine."""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue