diff --git a/config.toml.example b/config.toml.example index a2e6712..d3857d7 100644 --- a/config.toml.example +++ b/config.toml.example @@ -155,6 +155,8 @@ saved_max_width = -1 set_playlist_to_album = true # Use playlist position as tracknumber for playlist items renumber_playlist_tracks = true +# Separator used when a provider exposes multiple artists as separate values +artist_separator = "; " # Metadata fields to exclude from tagging exclude = [] diff --git a/internal/audio/tag/tagger.go b/internal/audio/tag/tagger.go index 9d2f217..97d30c2 100644 --- a/internal/audio/tag/tagger.go +++ b/internal/audio/tag/tagger.go @@ -177,11 +177,7 @@ func toTags(meta Metadata) map[string]string { "source_artist_id": meta.SourceArtistID, } if meta.TrackNumber > 0 { - if meta.TrackTotal > 0 { - tags["track"] = fmt.Sprintf("%02d/%02d", meta.TrackNumber, meta.TrackTotal) - } else { - tags["track"] = fmt.Sprintf("%02d", meta.TrackNumber) - } + tags["track"] = fmt.Sprintf("%02d", meta.TrackNumber) } if meta.TrackTotal > 0 { tags["tracktotal"] = strconv.Itoa(meta.TrackTotal) diff --git a/internal/audio/tag/tagger_test.go b/internal/audio/tag/tagger_test.go index 828d037..3aa4695 100644 --- a/internal/audio/tag/tagger_test.go +++ b/internal/audio/tag/tagger_test.go @@ -38,7 +38,7 @@ func TestToTagsTotalsAndSourceFields(t *testing.T) { SourcePlatform: "qobuz", SourceTrackID: "t1", }) - if tags["track"] != "03/12" { + if tags["track"] != "03" { t.Fatalf("track tag = %q", tags["track"]) } if tags["disc"] != "1/2" { diff --git a/internal/config/config.go b/internal/config/config.go index 80994e4..559e2f8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -142,6 +142,7 @@ type ArtworkConfig struct { type MetadataConfig struct { SetPlaylistToAlbum bool `toml:"set_playlist_to_album"` RenumberPlaylistTracks bool `toml:"renumber_playlist_tracks"` + ArtistSeparator string `toml:"artist_separator"` Exclude []string `toml:"exclude"` } @@ -297,6 +298,7 @@ func DefaultConfigData() ConfigData { Metadata: MetadataConfig{ SetPlaylistToAlbum: true, RenumberPlaylistTracks: true, + ArtistSeparator: "; ", Exclude: []string{}, }, Filepaths: FilepathsConfig{ diff --git a/internal/provider/beatport/client.go b/internal/provider/beatport/client.go index 11fc8b6..93810b8 100644 --- a/internal/provider/beatport/client.go +++ b/internal/provider/beatport/client.go @@ -122,7 +122,7 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s if err != nil { return nil, err } - return collectionMetadata(label, tracks), nil + return c.collectionMetadata(label, tracks), nil default: return nil, fmt.Errorf("unsupported beatport media type %q", mediaType) } @@ -213,7 +213,7 @@ func (c *Client) getPlaylistMetadata(ctx context.Context, id string) (map[string continue } if track, ok := m["track"].(map[string]any); ok { - tracks = append(tracks, normalizeTrackListItem(track)) + tracks = append(tracks, c.normalizeTrackListItem(track)) } } return playlistMetadata(playlist, tracks), nil @@ -231,7 +231,7 @@ func (c *Client) getChartMetadata(ctx context.Context, id string) (map[string]an normalized := make([]any, 0, len(tracks)) for _, raw := range tracks { if track, ok := raw.(map[string]any); ok { - normalized = append(normalized, normalizeTrackListItem(track)) + normalized = append(normalized, c.normalizeTrackListItem(track)) } } return playlistMetadata(chart, normalized), nil @@ -507,7 +507,7 @@ func (c *Client) normalizeRelease(raw map[string]any, tracks []any) map[string]a func (c *Client) normalizeTrack(raw map[string]any) map[string]any { release := mapAny(raw["release"]) - artistName := joinArtists(raw["artists"]) + artistName := c.joinArtists(raw["artists"]) artistID := firstArtistID(raw["artists"]) albumArtist := c.releaseArtistName(release["artists"]) if albumArtist == "" { @@ -543,25 +543,25 @@ func (c *Client) normalizeTrack(raw map[string]any) map[string]any { return track } -func normalizeTrackListItem(raw map[string]any) map[string]any { +func (c *Client) normalizeTrackListItem(raw map[string]any) map[string]any { return map[string]any{ "id": jsonutil.StringFromAny(raw["id"]), "title": jsonutil.StringFromAny(raw["name"]), "version": jsonutil.StringFromAny(raw["mix_name"]), - "artist": map[string]any{"name": joinArtists(raw["artists"])}, + "artist": map[string]any{"name": c.joinArtists(raw["artists"])}, "album": map[string]any{"id": jsonutil.NestedString(raw, "release", "id"), "title": jsonutil.NestedString(raw, "release", "name")}, "track_number": jsonutil.IntFromAny(raw["number"]), } } -func collectionMetadata(raw map[string]any, tracks []any) map[string]any { +func (c *Client) collectionMetadata(raw map[string]any, tracks []any) map[string]any { items := make([]any, 0, len(tracks)) for _, entry := range tracks { track, ok := entry.(map[string]any) if !ok { continue } - items = append(items, normalizeTrackListItem(track)) + items = append(items, c.normalizeTrackListItem(track)) } return map[string]any{ "id": jsonutil.StringFromAny(raw["id"]), @@ -620,7 +620,7 @@ func (c *Client) normalizeSearchItems(mediaType string, items []any) []any { } switch mediaType { case "track": - out = append(out, normalizeTrackListItem(m)) + out = append(out, c.normalizeTrackListItem(m)) case "album": out = append(out, map[string]any{"id": jsonutil.StringFromAny(m["id"]), "title": jsonutil.StringFromAny(m["name"]), "artist": map[string]any{"name": c.releaseArtistName(m["artists"])}, "tracks_count": jsonutil.IntFromAny(m["track_count"]), "release_date_original": jsonutil.StringFromAny(m["new_release_date"])}) case "label": @@ -653,7 +653,7 @@ func audioProfile(quality, streamQuality string) (provider.AudioProfile, string) } } -func joinArtists(v any) string { +func (c *Client) joinArtists(v any) string { items := sliceAny(v) names := make([]string, 0, len(items)) for _, raw := range items { @@ -665,7 +665,7 @@ func joinArtists(v any) string { names = append(names, name) } } - return strings.Join(names, ", ") + return strings.Join(names, artistSeparator(c.cfg.Session.Metadata.ArtistSeparator)) } func (c *Client) releaseArtistName(v any) string { @@ -674,7 +674,14 @@ func (c *Client) releaseArtistName(v any) string { if threshold >= 0 && len(items) > threshold { return "Various Artists" } - return joinArtists(v) + return c.joinArtists(v) +} + +func artistSeparator(separator string) string { + if separator == "" { + return "; " + } + return separator } func firstArtistID(v any) string { diff --git a/internal/provider/beatport/client_test.go b/internal/provider/beatport/client_test.go index 86e0f82..c86f710 100644 --- a/internal/provider/beatport/client_test.go +++ b/internal/provider/beatport/client_test.go @@ -164,11 +164,24 @@ func TestReleaseArtistCollapseCanBeDisabled(t *testing.T) { map[string]any{"name": "C"}, map[string]any{"name": "D"}, }) - if got != "A, B, C, D" { + if got != "A; B; C; D" { t.Fatalf("releaseArtistName() = %q", got) } } +func TestArtistSeparatorCanBeConfigured(t *testing.T) { + cfg := &config.Config{Session: config.DefaultConfigData(), File: config.DefaultConfigData()} + cfg.Session.Metadata.ArtistSeparator = " / " + c := New(cfg) + got := c.joinArtists([]any{ + map[string]any{"name": "A"}, + map[string]any{"name": "B"}, + }) + if got != "A / B" { + t.Fatalf("joinArtists() = %q", got) + } +} + func TestArtistMetadataUsesReleases(t *testing.T) { c, closeServer := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { diff --git a/internal/provider/yandex/client.go b/internal/provider/yandex/client.go index b8f97d7..f4ded88 100644 --- a/internal/provider/yandex/client.go +++ b/internal/provider/yandex/client.go @@ -28,13 +28,13 @@ import ( ) const ( - baseURL = "https://api.music.yandex.net" - desktopClientHeader = "YandexMusicDesktopAppWindows/5.13.2" - desktopOrigin = "music-application://desktop" - requestAttempts = 3 + baseURL = "https://api.music.yandex.net" + desktopClientHeader = "YandexMusicDesktopAppWindows/5.13.2" + desktopOrigin = "music-application://desktop" + requestAttempts = 3 desktopWindowsSignKey = "kzqU4XhfCaY6B6JTHODeq5" - legacyMP3SignSalt = "XGRlBW9FXlekgbPrRHuSiA" - defaultEstimatedKbps = 50000 + legacyMP3SignSalt = "XGRlBW9FXlekgbPrRHuSiA" + defaultEstimatedKbps = 50000 ) var ErrMissingYandexToken = errors.New("missing yandex access_token") @@ -183,7 +183,7 @@ func (c *Client) getTrackMetadata(ctx context.Context, item string) (map[string] if len(items) == 0 { return nil, errors.New("yandex track metadata missing result") } - return normalizeTrack(items[0], trackRef), nil + return normalizeTrack(items[0], trackRef, c.artistSeparator()), nil } func (c *Client) getAlbumMetadata(ctx context.Context, item string) (map[string]any, error) { @@ -198,7 +198,7 @@ func (c *Client) getAlbumMetadata(ctx context.Context, item string) (map[string] if len(result) == 0 { return nil, errors.New("yandex album metadata missing result") } - return normalizeAlbum(result), nil + return normalizeAlbum(result, c.artistSeparator()), nil } func (c *Client) getArtistMetadata(ctx context.Context, item string) (map[string]any, error) { @@ -273,7 +273,7 @@ func (c *Client) normalizeSearchItems(mediaType string, result map[string]any) [ if !ok { continue } - appendItem(normalizeTrack(itm, canonicalTrackRefFromRaw(itm, ""))) + appendItem(normalizeTrack(itm, canonicalTrackRefFromRaw(itm, ""), c.artistSeparator())) } case "album": for _, raw := range getResults("albums") { @@ -281,7 +281,7 @@ func (c *Client) normalizeSearchItems(mediaType string, result map[string]any) [ if !ok { continue } - appendItem(normalizeAlbumSearchItem(itm)) + appendItem(normalizeAlbumSearchItem(itm, c.artistSeparator())) } case "artist": for _, raw := range getResults("artists") { @@ -561,11 +561,11 @@ func resultMap(resp map[string]any) map[string]any { return resp } -func normalizeTrack(raw map[string]any, fallbackID string) map[string]any { +func normalizeTrack(raw map[string]any, fallbackID string, artistSeparator string) map[string]any { trackID := canonicalTrackRefFromRaw(raw, fallbackID) albumRaw := firstAlbum(raw) - artistName := joinArtists(raw) - albumArtist := joinAlbumArtists(albumRaw) + artistName := joinArtists(raw, artistSeparator) + albumArtist := joinAlbumArtists(albumRaw, artistSeparator) if albumArtist == "" { albumArtist = artistName } @@ -599,10 +599,10 @@ func normalizeTrack(raw map[string]any, fallbackID string) map[string]any { return meta } -func normalizeAlbum(raw map[string]any) map[string]any { - artistName := joinArtists(raw) +func normalizeAlbum(raw map[string]any, artistSeparator string) map[string]any { + artistName := joinArtists(raw, artistSeparator) if artistName == "" { - artistName = joinAlbumArtists(raw) + artistName = joinAlbumArtists(raw, artistSeparator) } volumes := albumVolumes(raw) items := make([]any, 0) @@ -698,8 +698,8 @@ func normalizeAlbumSummary(albumRaw map[string]any, artistName string) map[strin } } -func normalizeAlbumSearchItem(raw map[string]any) map[string]any { - artistName := joinArtists(raw) +func normalizeAlbumSearchItem(raw map[string]any, artistSeparator string) map[string]any { + artistName := joinArtists(raw, artistSeparator) return map[string]any{ "id": jsonutil.StringFromAny(raw["id"]), "title": jsonutil.StringFromAny(raw["title"]), @@ -768,7 +768,7 @@ func firstArtistID(raw map[string]any) string { return jsonutil.StringFromAny(artist["id"]) } -func joinArtists(raw map[string]any) string { +func joinArtists(raw map[string]any, artistSeparator string) string { artists, _ := raw["artists"].([]any) parts := make([]string, 0, len(artists)) for _, entry := range artists { @@ -780,10 +780,10 @@ func joinArtists(raw map[string]any) string { parts = append(parts, name) } } - return strings.Join(parts, ", ") + return strings.Join(parts, artistSeparator) } -func joinAlbumArtists(albumRaw map[string]any) string { +func joinAlbumArtists(albumRaw map[string]any, artistSeparator string) string { if len(albumRaw) == 0 { return "" } @@ -798,7 +798,14 @@ func joinAlbumArtists(albumRaw map[string]any) string { parts = append(parts, name) } } - return strings.Join(parts, ", ") + return strings.Join(parts, artistSeparator) +} + +func (c *Client) artistSeparator() string { + if c.cfg.Session.Metadata.ArtistSeparator == "" { + return "; " + } + return c.cfg.Session.Metadata.ArtistSeparator } func trackNumbers(albumRaw map[string]any) (int, int) { diff --git a/internal/provider/yandex/client_test.go b/internal/provider/yandex/client_test.go index ff005d3..c1d09bd 100644 --- a/internal/provider/yandex/client_test.go +++ b/internal/provider/yandex/client_test.go @@ -47,13 +47,13 @@ func TestGetDownloadableUsesModernGetFileInfo(t *testing.T) { _ = json.NewEncoder(w).Encode(map[string]any{ "result": map[string]any{ "downloadInfo": map[string]any{ - "trackId": "32038184", - "quality": "lossless", - "codec": "flac-mp4", + "trackId": "32038184", + "quality": "lossless", + "codec": "flac-mp4", "transport": "encraw", - "key": "00112233445566778899aabbccddeeff", - "bitrate": 0, - "url": "https://strm.example/music-v2/crypt/x/flac-mp4", + "key": "00112233445566778899aabbccddeeff", + "bitrate": 0, + "url": "https://strm.example/music-v2/crypt/x/flac-mp4", }, }, }) @@ -115,13 +115,13 @@ func TestGetMetadataTrackUsesModernTracksEndpoint(t *testing.T) { "id": "9442712", "realId": "9442712", "title": "Nightcall", - "artists": []map[string]any{{"id": "1433871", "name": "Kavinsky"}}, + "artists": []map[string]any{{"id": "1433871", "name": "Kavinsky"}, {"id": "42", "name": "Lovefoxxx"}}, "albums": []map[string]any{{ "id": "1000856", "title": "OutRun", "releaseDate": "2013-02-25T00:00:00+04:00", "trackCount": 13, - "artists": []map[string]any{{"id": "1433871", "name": "Kavinsky"}}, + "artists": []map[string]any{{"id": "1433871", "name": "Kavinsky"}, {"id": "42", "name": "Lovefoxxx"}}, "trackPosition": map[string]any{ "index": 0, "volume": 1, @@ -155,6 +155,12 @@ func TestGetMetadataTrackUsesModernTracksEndpoint(t *testing.T) { if album, _ := meta["album"].(map[string]any); jsonutil.StringFromAny(album["title"]) != "OutRun" { t.Fatalf("unexpected album: %+v", album) } + if artist := jsonutil.NestedString(meta, "artist", "name"); artist != "Kavinsky; Lovefoxxx" { + t.Fatalf("artist = %q", artist) + } + if albumArtist := jsonutil.NestedString(meta, "album", "artist", "name"); albumArtist != "Kavinsky; Lovefoxxx" { + t.Fatalf("album artist = %q", albumArtist) + } } func TestLegacyDirectURLBuildsPlayableMP3URL(t *testing.T) {