mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-07-27 23:42:28 +02:00
feat: format multi-artist credits
This commit is contained in:
@@ -1358,6 +1358,7 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
|
|||||||
if artist == "" {
|
if artist == "" {
|
||||||
artist = jsonutil.NestedString(trackMeta, "artist", "name")
|
artist = jsonutil.NestedString(trackMeta, "artist", "name")
|
||||||
}
|
}
|
||||||
|
artistNames := stringSliceFromAny(trackMeta["artist_names"])
|
||||||
albumArtist := jsonutil.NestedString(trackMeta, "album", "artist", "name")
|
albumArtist := jsonutil.NestedString(trackMeta, "album", "artist", "name")
|
||||||
if albumArtist == "" {
|
if albumArtist == "" {
|
||||||
albumArtist = artist
|
albumArtist = artist
|
||||||
@@ -1469,6 +1470,7 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
|
|||||||
Title: title,
|
Title: title,
|
||||||
Album: album,
|
Album: album,
|
||||||
Artist: artist,
|
Artist: artist,
|
||||||
|
Artists: artistNames,
|
||||||
AlbumArtist: albumArtist,
|
AlbumArtist: albumArtist,
|
||||||
OmitDiscTags: opts.forPlaylist,
|
OmitDiscTags: opts.forPlaylist,
|
||||||
TrackNumber: trackNumber,
|
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 {
|
func normalizeInitialKey(in string) string {
|
||||||
s := strings.TrimSpace(in)
|
s := strings.TrimSpace(in)
|
||||||
if s == "" {
|
if s == "" {
|
||||||
|
|||||||
@@ -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) {
|
func TestNormalizeInitialKey(t *testing.T) {
|
||||||
tests := map[string]string{
|
tests := map[string]string{
|
||||||
"C Major": "C",
|
"C Major": "C",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package tag
|
package tag
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -13,6 +14,7 @@ type Metadata struct {
|
|||||||
Title string
|
Title string
|
||||||
Album string
|
Album string
|
||||||
Artist string
|
Artist string
|
||||||
|
Artists []string
|
||||||
AlbumArtist string
|
AlbumArtist string
|
||||||
OmitDiscTags bool
|
OmitDiscTags bool
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
@@ -71,6 +73,9 @@ func (t *Tagger) TagFLAC(path string, meta Metadata, coverPath string) error {
|
|||||||
_ = os.Remove(tmpPath)
|
_ = os.Remove(tmpPath)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err = applyMultiValueFLACTags(path, meta); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -195,6 +200,150 @@ func toTags(meta Metadata) map[string]string {
|
|||||||
return tags
|
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 {
|
func normalizeCopyright(in string) string {
|
||||||
out := strings.ReplaceAll(in, "(c)", "©")
|
out := strings.ReplaceAll(in, "(c)", "©")
|
||||||
out = strings.ReplaceAll(out, "(C)", "©")
|
out = strings.ReplaceAll(out, "(C)", "©")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package tag
|
package tag
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/binary"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"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) {
|
func TestBuildFFmpegArgsWithCover(t *testing.T) {
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
cover := filepath.Join(tmp, "cover.jpg")
|
cover := filepath.Join(tmp, "cover.jpg")
|
||||||
|
|||||||
@@ -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 {
|
func (c *Client) normalizeTrack(raw map[string]any) map[string]any {
|
||||||
release := mapAny(raw["release"])
|
release := mapAny(raw["release"])
|
||||||
artistName := c.joinArtists(raw["artists"])
|
trackArtistNames := artistNames(raw["artists"])
|
||||||
|
artistName := displayArtistNames(trackArtistNames)
|
||||||
artistID := firstArtistID(raw["artists"])
|
artistID := firstArtistID(raw["artists"])
|
||||||
albumArtist := c.releaseArtistName(release["artists"])
|
albumArtist := c.releaseArtistName(release["artists"])
|
||||||
if albumArtist == "" {
|
if albumArtist == "" {
|
||||||
@@ -524,6 +525,7 @@ func (c *Client) normalizeTrack(raw map[string]any) map[string]any {
|
|||||||
"version": jsonutil.StringFromAny(raw["mix_name"]),
|
"version": jsonutil.StringFromAny(raw["mix_name"]),
|
||||||
"artist": map[string]any{"id": artistID, "name": artistName},
|
"artist": map[string]any{"id": artistID, "name": artistName},
|
||||||
"performer": 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},
|
"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"]),
|
"track_number": jsonutil.IntFromAny(raw["number"]),
|
||||||
"tracks_count": jsonutil.IntFromAny(release["track_count"]),
|
"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 {
|
func (c *Client) normalizeTrackListItem(raw map[string]any) map[string]any {
|
||||||
|
names := artistNames(raw["artists"])
|
||||||
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": 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")},
|
"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"]),
|
||||||
}
|
}
|
||||||
@@ -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)
|
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 +668,20 @@ func (c *Client) joinArtists(v any) string {
|
|||||||
names = append(names, name)
|
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 {
|
func (c *Client) releaseArtistName(v any) string {
|
||||||
@@ -674,14 +690,7 @@ 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 c.joinArtists(v)
|
return displayArtistNames(artistNames(v))
|
||||||
}
|
|
||||||
|
|
||||||
func artistSeparator(separator string) string {
|
|
||||||
if separator == "" {
|
|
||||||
return "; "
|
|
||||||
}
|
|
||||||
return separator
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func firstArtistID(v any) string {
|
func firstArtistID(v any) string {
|
||||||
|
|||||||
@@ -164,21 +164,19 @@ 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) {
|
func TestDisplayArtistNamesUsesCreditPunctuation(t *testing.T) {
|
||||||
cfg := &config.Config{Session: config.DefaultConfigData(), File: config.DefaultConfigData()}
|
got := displayArtistNames(artistNames([]any{
|
||||||
cfg.Session.Metadata.ArtistSeparator = " / "
|
|
||||||
c := New(cfg)
|
|
||||||
got := c.joinArtists([]any{
|
|
||||||
map[string]any{"name": "A"},
|
map[string]any{"name": "A"},
|
||||||
map[string]any{"name": "B"},
|
map[string]any{"name": "B"},
|
||||||
})
|
map[string]any{"name": "C"},
|
||||||
if got != "A / B" {
|
}))
|
||||||
t.Fatalf("joinArtists() = %q", got)
|
if got != "A, B & C" {
|
||||||
|
t.Fatalf("displayArtistNames() = %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
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, artistSeparator)
|
artistNames := artistNames(raw)
|
||||||
|
artistName := displayArtistNames(artistNames)
|
||||||
albumArtist := joinAlbumArtists(albumRaw, artistSeparator)
|
albumArtist := joinAlbumArtists(albumRaw, artistSeparator)
|
||||||
if albumArtist == "" {
|
if albumArtist == "" {
|
||||||
albumArtist = artistName
|
albumArtist = artistName
|
||||||
@@ -589,6 +590,7 @@ func normalizeTrack(raw map[string]any, fallbackID string, artistSeparator strin
|
|||||||
"source_track_id": jsonutil.StringFromAny(raw["realId"]),
|
"source_track_id": jsonutil.StringFromAny(raw["realId"]),
|
||||||
"performer": map[string]any{"name": artistName},
|
"performer": map[string]any{"name": artistName},
|
||||||
"artist": map[string]any{"name": artistName, "id": firstArtistID(raw)},
|
"artist": map[string]any{"name": artistName, "id": firstArtistID(raw)},
|
||||||
|
"artist_names": artistNames,
|
||||||
"album": normalizeAlbumSummary(albumRaw, albumArtist),
|
"album": normalizeAlbumSummary(albumRaw, albumArtist),
|
||||||
"image": imageMapFromTrack(raw, albumRaw),
|
"image": imageMapFromTrack(raw, albumRaw),
|
||||||
"cover": 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 {
|
func joinArtists(raw map[string]any, artistSeparator string) string {
|
||||||
|
return displayArtistNames(artistNames(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
func artistNames(raw map[string]any) []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,13 +786,17 @@ func joinArtists(raw map[string]any, artistSeparator string) string {
|
|||||||
parts = append(parts, name)
|
parts = append(parts, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return strings.Join(parts, artistSeparator)
|
return parts
|
||||||
}
|
}
|
||||||
|
|
||||||
func joinAlbumArtists(albumRaw map[string]any, artistSeparator string) string {
|
func joinAlbumArtists(albumRaw map[string]any, artistSeparator string) string {
|
||||||
if len(albumRaw) == 0 {
|
if len(albumRaw) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
return displayArtistNames(albumArtistNames(albumRaw))
|
||||||
|
}
|
||||||
|
|
||||||
|
func albumArtistNames(albumRaw map[string]any) []string {
|
||||||
artists, _ := albumRaw["artists"].([]any)
|
artists, _ := albumRaw["artists"].([]any)
|
||||||
parts := make([]string, 0, len(artists))
|
parts := make([]string, 0, len(artists))
|
||||||
for _, entry := range artists {
|
for _, entry := range artists {
|
||||||
@@ -798,7 +808,20 @@ func joinAlbumArtists(albumRaw map[string]any, artistSeparator string) string {
|
|||||||
parts = append(parts, name)
|
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 {
|
func (c *Client) artistSeparator() string {
|
||||||
|
|||||||
@@ -155,12 +155,23 @@ 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" {
|
if artist := jsonutil.NestedString(meta, "artist", "name"); artist != "Kavinsky & Lovefoxxx" {
|
||||||
t.Fatalf("artist = %q", artist)
|
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)
|
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) {
|
func TestLegacyDirectURLBuildsPlayableMP3URL(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user