feat: add configurable artist separator

This commit is contained in:
2026-07-11 01:21:40 +02:00
parent 537959b6ec
commit d2fa098d69
8 changed files with 82 additions and 49 deletions
+2
View File
@@ -155,6 +155,8 @@ saved_max_width = -1
set_playlist_to_album = true set_playlist_to_album = true
# Use playlist position as tracknumber for playlist items # Use playlist position as tracknumber for playlist items
renumber_playlist_tracks = true renumber_playlist_tracks = true
# Separator used when a provider exposes multiple artists as separate values
artist_separator = "; "
# Metadata fields to exclude from tagging # Metadata fields to exclude from tagging
exclude = [] exclude = []
+1 -5
View File
@@ -177,11 +177,7 @@ func toTags(meta Metadata) map[string]string {
"source_artist_id": meta.SourceArtistID, "source_artist_id": meta.SourceArtistID,
} }
if meta.TrackNumber > 0 { if meta.TrackNumber > 0 {
if meta.TrackTotal > 0 { tags["track"] = fmt.Sprintf("%02d", meta.TrackNumber)
tags["track"] = fmt.Sprintf("%02d/%02d", meta.TrackNumber, meta.TrackTotal)
} else {
tags["track"] = fmt.Sprintf("%02d", meta.TrackNumber)
}
} }
if meta.TrackTotal > 0 { if meta.TrackTotal > 0 {
tags["tracktotal"] = strconv.Itoa(meta.TrackTotal) tags["tracktotal"] = strconv.Itoa(meta.TrackTotal)
+1 -1
View File
@@ -38,7 +38,7 @@ func TestToTagsTotalsAndSourceFields(t *testing.T) {
SourcePlatform: "qobuz", SourcePlatform: "qobuz",
SourceTrackID: "t1", SourceTrackID: "t1",
}) })
if tags["track"] != "03/12" { if tags["track"] != "03" {
t.Fatalf("track tag = %q", tags["track"]) t.Fatalf("track tag = %q", tags["track"])
} }
if tags["disc"] != "1/2" { if tags["disc"] != "1/2" {
+2
View File
@@ -142,6 +142,7 @@ type ArtworkConfig struct {
type MetadataConfig struct { type MetadataConfig struct {
SetPlaylistToAlbum bool `toml:"set_playlist_to_album"` SetPlaylistToAlbum bool `toml:"set_playlist_to_album"`
RenumberPlaylistTracks bool `toml:"renumber_playlist_tracks"` RenumberPlaylistTracks bool `toml:"renumber_playlist_tracks"`
ArtistSeparator string `toml:"artist_separator"`
Exclude []string `toml:"exclude"` Exclude []string `toml:"exclude"`
} }
@@ -297,6 +298,7 @@ func DefaultConfigData() ConfigData {
Metadata: MetadataConfig{ Metadata: MetadataConfig{
SetPlaylistToAlbum: true, SetPlaylistToAlbum: true,
RenumberPlaylistTracks: true, RenumberPlaylistTracks: true,
ArtistSeparator: "; ",
Exclude: []string{}, Exclude: []string{},
}, },
Filepaths: FilepathsConfig{ Filepaths: FilepathsConfig{
+19 -12
View File
@@ -122,7 +122,7 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
if err != nil { if err != nil {
return nil, err return nil, err
} }
return collectionMetadata(label, tracks), nil return c.collectionMetadata(label, tracks), nil
default: default:
return nil, fmt.Errorf("unsupported beatport media type %q", mediaType) 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 continue
} }
if track, ok := m["track"].(map[string]any); ok { 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 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)) normalized := make([]any, 0, len(tracks))
for _, raw := range tracks { for _, raw := range tracks {
if track, ok := raw.(map[string]any); ok { if track, ok := raw.(map[string]any); ok {
normalized = append(normalized, normalizeTrackListItem(track)) normalized = append(normalized, c.normalizeTrackListItem(track))
} }
} }
return playlistMetadata(chart, normalized), nil 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 { func (c *Client) normalizeTrack(raw map[string]any) map[string]any {
release := mapAny(raw["release"]) release := mapAny(raw["release"])
artistName := joinArtists(raw["artists"]) artistName := c.joinArtists(raw["artists"])
artistID := firstArtistID(raw["artists"]) artistID := firstArtistID(raw["artists"])
albumArtist := c.releaseArtistName(release["artists"]) albumArtist := c.releaseArtistName(release["artists"])
if albumArtist == "" { if albumArtist == "" {
@@ -543,25 +543,25 @@ func (c *Client) normalizeTrack(raw map[string]any) map[string]any {
return track 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{ return map[string]any{
"id": jsonutil.StringFromAny(raw["id"]), "id": jsonutil.StringFromAny(raw["id"]),
"title": jsonutil.StringFromAny(raw["name"]), "title": jsonutil.StringFromAny(raw["name"]),
"version": jsonutil.StringFromAny(raw["mix_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")}, "album": map[string]any{"id": jsonutil.NestedString(raw, "release", "id"), "title": jsonutil.NestedString(raw, "release", "name")},
"track_number": jsonutil.IntFromAny(raw["number"]), "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)) items := make([]any, 0, len(tracks))
for _, entry := range tracks { for _, entry := range tracks {
track, ok := entry.(map[string]any) track, ok := entry.(map[string]any)
if !ok { if !ok {
continue continue
} }
items = append(items, normalizeTrackListItem(track)) items = append(items, c.normalizeTrackListItem(track))
} }
return map[string]any{ return map[string]any{
"id": jsonutil.StringFromAny(raw["id"]), "id": jsonutil.StringFromAny(raw["id"]),
@@ -620,7 +620,7 @@ func (c *Client) normalizeSearchItems(mediaType string, items []any) []any {
} }
switch mediaType { switch mediaType {
case "track": case "track":
out = append(out, normalizeTrackListItem(m)) out = append(out, c.normalizeTrackListItem(m))
case "album": 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"])}) 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": 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) items := sliceAny(v)
names := make([]string, 0, len(items)) names := make([]string, 0, len(items))
for _, raw := range items { for _, raw := range items {
@@ -665,7 +665,7 @@ func joinArtists(v any) string {
names = append(names, name) 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 { func (c *Client) releaseArtistName(v any) string {
@@ -674,7 +674,14 @@ func (c *Client) releaseArtistName(v any) string {
if threshold >= 0 && len(items) > threshold { if threshold >= 0 && len(items) > threshold {
return "Various Artists" 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 { func firstArtistID(v any) string {
+14 -1
View File
@@ -164,11 +164,24 @@ func TestReleaseArtistCollapseCanBeDisabled(t *testing.T) {
map[string]any{"name": "C"}, map[string]any{"name": "C"},
map[string]any{"name": "D"}, map[string]any{"name": "D"},
}) })
if got != "A, B, C, D" { if got != "A; B; C; D" {
t.Fatalf("releaseArtistName() = %q", got) 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) { func TestArtistMetadataUsesReleases(t *testing.T) {
c, closeServer := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c, closeServer := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
+29 -22
View File
@@ -28,13 +28,13 @@ import (
) )
const ( const (
baseURL = "https://api.music.yandex.net" baseURL = "https://api.music.yandex.net"
desktopClientHeader = "YandexMusicDesktopAppWindows/5.13.2" desktopClientHeader = "YandexMusicDesktopAppWindows/5.13.2"
desktopOrigin = "music-application://desktop" desktopOrigin = "music-application://desktop"
requestAttempts = 3 requestAttempts = 3
desktopWindowsSignKey = "kzqU4XhfCaY6B6JTHODeq5" desktopWindowsSignKey = "kzqU4XhfCaY6B6JTHODeq5"
legacyMP3SignSalt = "XGRlBW9FXlekgbPrRHuSiA" legacyMP3SignSalt = "XGRlBW9FXlekgbPrRHuSiA"
defaultEstimatedKbps = 50000 defaultEstimatedKbps = 50000
) )
var ErrMissingYandexToken = errors.New("missing yandex access_token") 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 { if len(items) == 0 {
return nil, errors.New("yandex track metadata missing result") 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) { 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 { if len(result) == 0 {
return nil, errors.New("yandex album metadata missing result") 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) { 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 { if !ok {
continue continue
} }
appendItem(normalizeTrack(itm, canonicalTrackRefFromRaw(itm, ""))) appendItem(normalizeTrack(itm, canonicalTrackRefFromRaw(itm, ""), c.artistSeparator()))
} }
case "album": case "album":
for _, raw := range getResults("albums") { for _, raw := range getResults("albums") {
@@ -281,7 +281,7 @@ func (c *Client) normalizeSearchItems(mediaType string, result map[string]any) [
if !ok { if !ok {
continue continue
} }
appendItem(normalizeAlbumSearchItem(itm)) appendItem(normalizeAlbumSearchItem(itm, c.artistSeparator()))
} }
case "artist": case "artist":
for _, raw := range getResults("artists") { for _, raw := range getResults("artists") {
@@ -561,11 +561,11 @@ func resultMap(resp map[string]any) map[string]any {
return resp 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) trackID := canonicalTrackRefFromRaw(raw, fallbackID)
albumRaw := firstAlbum(raw) albumRaw := firstAlbum(raw)
artistName := joinArtists(raw) artistName := joinArtists(raw, artistSeparator)
albumArtist := joinAlbumArtists(albumRaw) albumArtist := joinAlbumArtists(albumRaw, artistSeparator)
if albumArtist == "" { if albumArtist == "" {
albumArtist = artistName albumArtist = artistName
} }
@@ -599,10 +599,10 @@ func normalizeTrack(raw map[string]any, fallbackID string) map[string]any {
return meta return meta
} }
func normalizeAlbum(raw map[string]any) map[string]any { func normalizeAlbum(raw map[string]any, artistSeparator string) map[string]any {
artistName := joinArtists(raw) artistName := joinArtists(raw, artistSeparator)
if artistName == "" { if artistName == "" {
artistName = joinAlbumArtists(raw) artistName = joinAlbumArtists(raw, artistSeparator)
} }
volumes := albumVolumes(raw) volumes := albumVolumes(raw)
items := make([]any, 0) 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 { func normalizeAlbumSearchItem(raw map[string]any, artistSeparator string) map[string]any {
artistName := joinArtists(raw) artistName := joinArtists(raw, artistSeparator)
return map[string]any{ return map[string]any{
"id": jsonutil.StringFromAny(raw["id"]), "id": jsonutil.StringFromAny(raw["id"]),
"title": jsonutil.StringFromAny(raw["title"]), "title": jsonutil.StringFromAny(raw["title"]),
@@ -768,7 +768,7 @@ func firstArtistID(raw map[string]any) string {
return jsonutil.StringFromAny(artist["id"]) 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) artists, _ := raw["artists"].([]any)
parts := make([]string, 0, len(artists)) parts := make([]string, 0, len(artists))
for _, entry := range artists { for _, entry := range artists {
@@ -780,10 +780,10 @@ func joinArtists(raw map[string]any) string {
parts = append(parts, name) 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 { if len(albumRaw) == 0 {
return "" return ""
} }
@@ -798,7 +798,14 @@ func joinAlbumArtists(albumRaw map[string]any) string {
parts = append(parts, name) 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) { func trackNumbers(albumRaw map[string]any) (int, int) {
+14 -8
View File
@@ -47,13 +47,13 @@ func TestGetDownloadableUsesModernGetFileInfo(t *testing.T) {
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"result": map[string]any{ "result": map[string]any{
"downloadInfo": map[string]any{ "downloadInfo": map[string]any{
"trackId": "32038184", "trackId": "32038184",
"quality": "lossless", "quality": "lossless",
"codec": "flac-mp4", "codec": "flac-mp4",
"transport": "encraw", "transport": "encraw",
"key": "00112233445566778899aabbccddeeff", "key": "00112233445566778899aabbccddeeff",
"bitrate": 0, "bitrate": 0,
"url": "https://strm.example/music-v2/crypt/x/flac-mp4", "url": "https://strm.example/music-v2/crypt/x/flac-mp4",
}, },
}, },
}) })
@@ -115,13 +115,13 @@ func TestGetMetadataTrackUsesModernTracksEndpoint(t *testing.T) {
"id": "9442712", "id": "9442712",
"realId": "9442712", "realId": "9442712",
"title": "Nightcall", "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{{ "albums": []map[string]any{{
"id": "1000856", "id": "1000856",
"title": "OutRun", "title": "OutRun",
"releaseDate": "2013-02-25T00:00:00+04:00", "releaseDate": "2013-02-25T00:00:00+04:00",
"trackCount": 13, "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{ "trackPosition": map[string]any{
"index": 0, "index": 0,
"volume": 1, "volume": 1,
@@ -155,6 +155,12 @@ func TestGetMetadataTrackUsesModernTracksEndpoint(t *testing.T) {
if album, _ := meta["album"].(map[string]any); jsonutil.StringFromAny(album["title"]) != "OutRun" { if album, _ := meta["album"].(map[string]any); jsonutil.StringFromAny(album["title"]) != "OutRun" {
t.Fatalf("unexpected album: %+v", album) 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) { func TestLegacyDirectURLBuildsPlayableMP3URL(t *testing.T) {