From 3413de1daab5d2d6307a12aebfb995bd3e0077d7 Mon Sep 17 00:00:00 2001 From: Joren Date: Sat, 11 Jul 2026 04:44:54 +0200 Subject: [PATCH] feat: format multi-artist credits --- internal/app/app.go | 20 +++ internal/app/app_test.go | 16 +++ internal/audio/tag/tagger.go | 149 ++++++++++++++++++++++ internal/audio/tag/tagger_test.go | 97 ++++++++++++++ internal/provider/beatport/client.go | 33 +++-- internal/provider/beatport/client_test.go | 16 +-- internal/provider/yandex/client.go | 29 ++++- internal/provider/yandex/client_test.go | 15 ++- 8 files changed, 349 insertions(+), 26 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 9a5e165..3050bc5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1358,6 +1358,7 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o if artist == "" { artist = jsonutil.NestedString(trackMeta, "artist", "name") } + artistNames := stringSliceFromAny(trackMeta["artist_names"]) albumArtist := jsonutil.NestedString(trackMeta, "album", "artist", "name") if albumArtist == "" { albumArtist = artist @@ -1469,6 +1470,7 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o Title: title, Album: album, Artist: artist, + Artists: artistNames, AlbumArtist: albumArtist, OmitDiscTags: opts.forPlaylist, TrackNumber: trackNumber, @@ -1494,6 +1496,24 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o } } +func stringSliceFromAny(v any) []string { + items, ok := v.([]string) + if ok { + return append([]string(nil), items...) + } + rawItems, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(rawItems)) + for _, raw := range rawItems { + if s := strings.TrimSpace(jsonutil.StringFromAny(raw)); s != "" { + out = append(out, s) + } + } + return out +} + func normalizeInitialKey(in string) string { s := strings.TrimSpace(in) if s == "" { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index f60c1fd..b54f447 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -877,6 +877,22 @@ func TestBuildTagMetadataInitialKey(t *testing.T) { } } +func TestBuildTagMetadataArtistList(t *testing.T) { + meta := map[string]any{ + "artist_names": []string{"Lost Frequencies", "Calum Scott", "Kungs"}, + "performer": map[string]any{"name": "Lost Frequencies, Calum Scott & Kungs"}, + "album": map[string]any{"title": "Album"}, + } + + tags := buildTagMetadata(meta, "Song", "beatport", "42", ripTrackOptions{}) + if tags.Artist != "Lost Frequencies, Calum Scott & Kungs" { + t.Fatalf("artist=%q", tags.Artist) + } + if got := strings.Join(tags.Artists, ";"); got != "Lost Frequencies;Calum Scott;Kungs" { + t.Fatalf("artists=%q", got) + } +} + func TestNormalizeInitialKey(t *testing.T) { tests := map[string]string{ "C Major": "C", diff --git a/internal/audio/tag/tagger.go b/internal/audio/tag/tagger.go index 97d30c2..4d83842 100644 --- a/internal/audio/tag/tagger.go +++ b/internal/audio/tag/tagger.go @@ -1,6 +1,7 @@ package tag import ( + "encoding/binary" "fmt" "os" "os/exec" @@ -13,6 +14,7 @@ type Metadata struct { Title string Album string Artist string + Artists []string AlbumArtist string OmitDiscTags bool TrackNumber int @@ -71,6 +73,9 @@ func (t *Tagger) TagFLAC(path string, meta Metadata, coverPath string) error { _ = os.Remove(tmpPath) return err } + if err = applyMultiValueFLACTags(path, meta); err != nil { + return err + } return nil } @@ -195,6 +200,150 @@ func toTags(meta Metadata) map[string]string { return tags } +func applyMultiValueFLACTags(path string, meta Metadata) error { + if strings.ToLower(strings.TrimPrefix(filepath.Ext(path), ".")) != "flac" || len(meta.Artists) == 0 { + return nil + } + st, err := os.Stat(path) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + updated, err := replaceFLACVorbisComments(data, "ARTISTS", meta.Artists) + if err != nil { + return err + } + if updated == nil { + return nil + } + return os.WriteFile(path, updated, st.Mode()) +} + +type flacMetadataBlock struct { + isLast bool + blockType byte + data []byte +} + +func replaceFLACVorbisComments(data []byte, key string, values []string) ([]byte, error) { + if len(data) < 4 || string(data[:4]) != "fLaC" { + return nil, fmt.Errorf("not a FLAC file") + } + + blocks := []flacMetadataBlock{} + pos := 4 + vorbisIndex := -1 + for { + if pos+4 > len(data) { + return nil, fmt.Errorf("truncated FLAC metadata header") + } + header := data[pos] + blockType := header & 0x7f + length := int(data[pos+1])<<16 | int(data[pos+2])<<8 | int(data[pos+3]) + pos += 4 + if pos+length > len(data) { + return nil, fmt.Errorf("truncated FLAC metadata block") + } + if blockType == 4 { + vorbisIndex = len(blocks) + } + blocks = append(blocks, flacMetadataBlock{isLast: header&0x80 != 0, blockType: blockType, data: data[pos : pos+length]}) + pos += length + if header&0x80 != 0 { + break + } + } + if vorbisIndex < 0 { + return nil, nil + } + + commentBlock, err := replaceVorbisCommentValues(blocks[vorbisIndex].data, key, values) + if err != nil { + return nil, err + } + blocks[vorbisIndex].data = commentBlock + + out := make([]byte, 0, len(data)+len(commentBlock)-len(blocks[vorbisIndex].data)) + out = append(out, data[:4]...) + for _, block := range blocks { + if len(block.data) > 0xffffff { + return nil, fmt.Errorf("FLAC metadata block too large") + } + header := block.blockType + if block.isLast { + header |= 0x80 + } + out = append(out, header, byte(len(block.data)>>16), byte(len(block.data)>>8), byte(len(block.data))) + out = append(out, block.data...) + } + out = append(out, data[pos:]...) + return out, nil +} + +func replaceVorbisCommentValues(data []byte, key string, values []string) ([]byte, error) { + if len(data) < 8 { + return nil, fmt.Errorf("truncated Vorbis comment block") + } + pos := 0 + vendorLength := int(binary.LittleEndian.Uint32(data[pos:])) + pos += 4 + if pos+vendorLength+4 > len(data) { + return nil, fmt.Errorf("truncated Vorbis vendor string") + } + vendor := data[pos : pos+vendorLength] + pos += vendorLength + commentCount := int(binary.LittleEndian.Uint32(data[pos:])) + pos += 4 + + comments := make([][]byte, 0, commentCount+len(values)) + for i := 0; i < commentCount; i++ { + if pos+4 > len(data) { + return nil, fmt.Errorf("truncated Vorbis comment length") + } + commentLength := int(binary.LittleEndian.Uint32(data[pos:])) + pos += 4 + if pos+commentLength > len(data) { + return nil, fmt.Errorf("truncated Vorbis comment") + } + comment := data[pos : pos+commentLength] + pos += commentLength + if !vorbisCommentKeyEqual(comment, key) { + comments = append(comments, comment) + } + } + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + comments = append(comments, []byte(key+"="+value)) + } + } + + out := make([]byte, 0, len(data)) + out = appendUint32LE(out, uint32(len(vendor))) + out = append(out, vendor...) + out = appendUint32LE(out, uint32(len(comments))) + for _, comment := range comments { + out = appendUint32LE(out, uint32(len(comment))) + out = append(out, comment...) + } + return out, nil +} + +func vorbisCommentKeyEqual(comment []byte, key string) bool { + idx := strings.IndexByte(string(comment), '=') + if idx < 0 { + return false + } + return strings.EqualFold(string(comment[:idx]), key) +} + +func appendUint32LE(out []byte, v uint32) []byte { + return append(out, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)) +} + func normalizeCopyright(in string) string { out := strings.ReplaceAll(in, "(c)", "©") out = strings.ReplaceAll(out, "(C)", "©") diff --git a/internal/audio/tag/tagger_test.go b/internal/audio/tag/tagger_test.go index 3aa4695..59c0a64 100644 --- a/internal/audio/tag/tagger_test.go +++ b/internal/audio/tag/tagger_test.go @@ -1,6 +1,7 @@ package tag import ( + "encoding/binary" "os" "path/filepath" "testing" @@ -64,6 +65,102 @@ func TestToTagsTotalsAndSourceFields(t *testing.T) { } } +func TestToTagsArtistList(t *testing.T) { + tags := toTags(Metadata{Artist: "A, B & C", Artists: []string{"A", "B", "C"}}) + if tags["artist"] != "A, B & C" { + t.Fatalf("artist tags = %+v", tags) + } + if _, ok := tags["ARTISTS"]; ok { + t.Fatalf("ARTISTS should be written as repeated FLAC comments, got %+v", tags) + } +} + +func TestReplaceVorbisCommentValuesWritesRepeatedTags(t *testing.T) { + block := vorbisCommentBlock("vendor", []string{ + "ARTIST=A, B & C", + "ARTISTS=A;B;C", + "TITLE=Song", + }) + + updated, err := replaceVorbisCommentValues(block, "ARTISTS", []string{"A", "B", "C"}) + if err != nil { + t.Fatalf("replaceVorbisCommentValues() error = %v", err) + } + comments := readVorbisComments(t, updated) + want := []string{"ARTIST=A, B & C", "TITLE=Song", "ARTISTS=A", "ARTISTS=B", "ARTISTS=C"} + if len(comments) != len(want) { + t.Fatalf("comments=%#v want %#v", comments, want) + } + for i := range want { + if comments[i] != want[i] { + t.Fatalf("comments=%#v want %#v", comments, want) + } + } +} + +func TestReplaceFLACVorbisComments(t *testing.T) { + vorbis := vorbisCommentBlock("vendor", []string{"ARTISTS=A;B", "TITLE=Song"}) + flac := append([]byte("fLaC"), flacBlockHeader(true, 4, len(vorbis))...) + flac = append(flac, vorbis...) + flac = append(flac, []byte("audio")...) + + updated, err := replaceFLACVorbisComments(flac, "ARTISTS", []string{"A", "B"}) + if err != nil { + t.Fatalf("replaceFLACVorbisComments() error = %v", err) + } + if string(updated[len(updated)-5:]) != "audio" { + t.Fatalf("audio payload not preserved") + } + length := int(updated[5])<<16 | int(updated[6])<<8 | int(updated[7]) + comments := readVorbisComments(t, updated[8:8+length]) + want := []string{"TITLE=Song", "ARTISTS=A", "ARTISTS=B"} + if len(comments) != len(want) { + t.Fatalf("comments=%#v want %#v", comments, want) + } + for i := range want { + if comments[i] != want[i] { + t.Fatalf("comments=%#v want %#v", comments, want) + } + } +} + +func vorbisCommentBlock(vendor string, comments []string) []byte { + out := []byte{} + out = appendUint32LE(out, uint32(len(vendor))) + out = append(out, vendor...) + out = appendUint32LE(out, uint32(len(comments))) + for _, comment := range comments { + out = appendUint32LE(out, uint32(len(comment))) + out = append(out, comment...) + } + return out +} + +func flacBlockHeader(last bool, blockType byte, length int) []byte { + header := blockType + if last { + header |= 0x80 + } + return []byte{header, byte(length >> 16), byte(length >> 8), byte(length)} +} + +func readVorbisComments(t *testing.T, block []byte) []string { + t.Helper() + pos := 0 + vendorLength := int(binary.LittleEndian.Uint32(block[pos:])) + pos += 4 + vendorLength + count := int(binary.LittleEndian.Uint32(block[pos:])) + pos += 4 + comments := make([]string, 0, count) + for i := 0; i < count; i++ { + length := int(binary.LittleEndian.Uint32(block[pos:])) + pos += 4 + comments = append(comments, string(block[pos:pos+length])) + pos += length + } + return comments +} + func TestBuildFFmpegArgsWithCover(t *testing.T) { tmp := t.TempDir() cover := filepath.Join(tmp, "cover.jpg") diff --git a/internal/provider/beatport/client.go b/internal/provider/beatport/client.go index 93810b8..dc0f126 100644 --- a/internal/provider/beatport/client.go +++ b/internal/provider/beatport/client.go @@ -507,7 +507,8 @@ 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 := c.joinArtists(raw["artists"]) + trackArtistNames := artistNames(raw["artists"]) + artistName := displayArtistNames(trackArtistNames) artistID := firstArtistID(raw["artists"]) albumArtist := c.releaseArtistName(release["artists"]) if albumArtist == "" { @@ -524,6 +525,7 @@ func (c *Client) normalizeTrack(raw map[string]any) map[string]any { "version": jsonutil.StringFromAny(raw["mix_name"]), "artist": map[string]any{"id": artistID, "name": artistName}, "performer": map[string]any{"id": artistID, "name": artistName}, + "artist_names": trackArtistNames, "album": map[string]any{"id": jsonutil.StringFromAny(release["id"]), "title": jsonutil.StringFromAny(release["name"]), "artist": map[string]any{"name": albumArtist}, "image": imageMap(release["image"]), "release_date_original": date}, "track_number": jsonutil.IntFromAny(raw["number"]), "tracks_count": jsonutil.IntFromAny(release["track_count"]), @@ -544,11 +546,12 @@ func (c *Client) normalizeTrack(raw map[string]any) map[string]any { } func (c *Client) normalizeTrackListItem(raw map[string]any) map[string]any { + names := artistNames(raw["artists"]) 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": c.joinArtists(raw["artists"])}, + "artist": map[string]any{"name": displayArtistNames(names)}, "album": map[string]any{"id": jsonutil.NestedString(raw, "release", "id"), "title": jsonutil.NestedString(raw, "release", "name")}, "track_number": jsonutil.IntFromAny(raw["number"]), } @@ -653,7 +656,7 @@ func audioProfile(quality, streamQuality string) (provider.AudioProfile, string) } } -func (c *Client) joinArtists(v any) string { +func artistNames(v any) []string { items := sliceAny(v) names := make([]string, 0, len(items)) for _, raw := range items { @@ -665,7 +668,20 @@ func (c *Client) joinArtists(v any) string { names = append(names, name) } } - return strings.Join(names, artistSeparator(c.cfg.Session.Metadata.ArtistSeparator)) + return names +} + +func displayArtistNames(names []string) string { + switch len(names) { + case 0: + return "" + case 1: + return names[0] + case 2: + return names[0] + " & " + names[1] + default: + return strings.Join(names[:len(names)-1], ", ") + " & " + names[len(names)-1] + } } func (c *Client) releaseArtistName(v any) string { @@ -674,14 +690,7 @@ func (c *Client) releaseArtistName(v any) string { if threshold >= 0 && len(items) > threshold { return "Various Artists" } - return c.joinArtists(v) -} - -func artistSeparator(separator string) string { - if separator == "" { - return "; " - } - return separator + return displayArtistNames(artistNames(v)) } func firstArtistID(v any) string { diff --git a/internal/provider/beatport/client_test.go b/internal/provider/beatport/client_test.go index c86f710..4ed98ad 100644 --- a/internal/provider/beatport/client_test.go +++ b/internal/provider/beatport/client_test.go @@ -164,21 +164,19 @@ 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{ +func TestDisplayArtistNamesUsesCreditPunctuation(t *testing.T) { + got := displayArtistNames(artistNames([]any{ map[string]any{"name": "A"}, map[string]any{"name": "B"}, - }) - if got != "A / B" { - t.Fatalf("joinArtists() = %q", got) + map[string]any{"name": "C"}, + })) + if got != "A, B & C" { + t.Fatalf("displayArtistNames() = %q", got) } } diff --git a/internal/provider/yandex/client.go b/internal/provider/yandex/client.go index f4ded88..789336c 100644 --- a/internal/provider/yandex/client.go +++ b/internal/provider/yandex/client.go @@ -564,7 +564,8 @@ func resultMap(resp map[string]any) 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, artistSeparator) + artistNames := artistNames(raw) + artistName := displayArtistNames(artistNames) albumArtist := joinAlbumArtists(albumRaw, artistSeparator) if albumArtist == "" { albumArtist = artistName @@ -589,6 +590,7 @@ func normalizeTrack(raw map[string]any, fallbackID string, artistSeparator strin "source_track_id": jsonutil.StringFromAny(raw["realId"]), "performer": map[string]any{"name": artistName}, "artist": map[string]any{"name": artistName, "id": firstArtistID(raw)}, + "artist_names": artistNames, "album": normalizeAlbumSummary(albumRaw, albumArtist), "image": imageMapFromTrack(raw, albumRaw), "cover": imageMapFromTrack(raw, albumRaw), @@ -769,6 +771,10 @@ func firstArtistID(raw map[string]any) string { } func joinArtists(raw map[string]any, artistSeparator string) string { + return displayArtistNames(artistNames(raw)) +} + +func artistNames(raw map[string]any) []string { artists, _ := raw["artists"].([]any) parts := make([]string, 0, len(artists)) for _, entry := range artists { @@ -780,13 +786,17 @@ func joinArtists(raw map[string]any, artistSeparator string) string { parts = append(parts, name) } } - return strings.Join(parts, artistSeparator) + return parts } func joinAlbumArtists(albumRaw map[string]any, artistSeparator string) string { if len(albumRaw) == 0 { return "" } + return displayArtistNames(albumArtistNames(albumRaw)) +} + +func albumArtistNames(albumRaw map[string]any) []string { artists, _ := albumRaw["artists"].([]any) parts := make([]string, 0, len(artists)) for _, entry := range artists { @@ -798,7 +808,20 @@ func joinAlbumArtists(albumRaw map[string]any, artistSeparator string) string { parts = append(parts, name) } } - return strings.Join(parts, artistSeparator) + return parts +} + +func displayArtistNames(names []string) string { + switch len(names) { + case 0: + return "" + case 1: + return names[0] + case 2: + return names[0] + " & " + names[1] + default: + return strings.Join(names[:len(names)-1], ", ") + " & " + names[len(names)-1] + } } func (c *Client) artistSeparator() string { diff --git a/internal/provider/yandex/client_test.go b/internal/provider/yandex/client_test.go index c1d09bd..fd88a30 100644 --- a/internal/provider/yandex/client_test.go +++ b/internal/provider/yandex/client_test.go @@ -155,12 +155,23 @@ 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" { + 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" { + if albumArtist := jsonutil.NestedString(meta, "album", "artist", "name"); albumArtist != "Kavinsky & Lovefoxxx" { t.Fatalf("album artist = %q", albumArtist) } + artists, _ := meta["artist_names"].([]string) + if strings.Join(artists, ";") != "Kavinsky;Lovefoxxx" { + t.Fatalf("artist_names = %#v", artists) + } +} + +func TestDisplayArtistNamesUsesCreditPunctuation(t *testing.T) { + got := displayArtistNames([]string{"A", "B", "C"}) + if got != "A, B & C" { + t.Fatalf("displayArtistNames() = %q", got) + } } func TestLegacyDirectURLBuildsPlayableMP3URL(t *testing.T) {