From 8882a2fa9472d48b37db878e8a08384b1acdf751 Mon Sep 17 00:00:00 2001 From: Joel Davies <114788106+joelbdavies@users.noreply.github.com> Date: Fri, 23 Jan 2026 01:34:03 +0800 Subject: [PATCH 1/2] Fix track artist extraction Problem: Connect getTrack only surfaced the first artist; otherArtists (e.g., remixers) were ignored. Solution: parse otherArtists in trackUnion and tighten artist name extraction to avoid unrelated names; add tests for firstArtist/otherArtists containers. --- internal/spotify/connect_mapping.go | 122 ++++++++++++++++++++--- internal/spotify/connect_mapping_test.go | 121 ++++++++++++++++++++++ 2 files changed, 231 insertions(+), 12 deletions(-) diff --git a/internal/spotify/connect_mapping.go b/internal/spotify/connect_mapping.go index cccd149..57d6830 100644 --- a/internal/spotify/connect_mapping.go +++ b/internal/spotify/connect_mapping.go @@ -22,6 +22,18 @@ func extractSearchItems(payload map[string]any, kind string) ([]Item, int) { } func extractItemFromPayload(payload map[string]any, kind string) (Item, bool) { + if kind == "track" { + if m, ok := getMap(payload, "data", "trackUnion"); ok { + if item, ok := extractItem(m, kind); ok { + return item, true + } + } + if m, ok := getMap(payload, "data", "track"); ok { + if item, ok := extractItem(m, kind); ok { + return item, true + } + } + } items := collectItemsByKind(payload, kind) if len(items) == 0 { return Item{}, false @@ -99,6 +111,11 @@ func extractItem(value any, kind string) (Item, bool) { if !ok { return Item{}, false } + if kind == "track" { + if inner, ok := m["track"].(map[string]any); ok { + m = inner + } + } uri := getString(m, "uri") if uri == "" && kind != "" { if id := getString(m, "id"); id != "" { @@ -217,25 +234,106 @@ func findFirstName(value any) string { func extractArtistNames(value any) []string { artists := []string{} - walkMap(value, func(m map[string]any) { - if list, ok := m["artists"].([]any); ok { - for _, entry := range list { - if name := findFirstName(entry); name != "" { - artists = append(artists, name) - } - } + m, ok := value.(map[string]any) + if !ok { + return nil + } + if list, ok := m["artists"].([]any); ok { + appendArtistNames(&artists, list) + } + if group, ok := m["artists"].(map[string]any); ok { + if list, ok := group["items"].([]any); ok { + appendArtistNames(&artists, list) } - }) + if list, ok := group["nodes"].([]any); ok { + appendArtistNames(&artists, list) + } + if list, ok := group["edges"].([]any); ok { + appendArtistNames(&artists, list) + } + } + if group, ok := m["firstArtist"].(map[string]any); ok { + if list, ok := group["items"].([]any); ok { + appendArtistNames(&artists, list) + } + if list, ok := group["nodes"].([]any); ok { + appendArtistNames(&artists, list) + } + if list, ok := group["edges"].([]any); ok { + appendArtistNames(&artists, list) + } + } + if group, ok := m["otherArtists"].(map[string]any); ok { + if list, ok := group["items"].([]any); ok { + appendArtistNames(&artists, list) + } + if list, ok := group["nodes"].([]any); ok { + appendArtistNames(&artists, list) + } + if list, ok := group["edges"].([]any); ok { + appendArtistNames(&artists, list) + } + } if len(artists) == 0 { - if m, ok := value.(map[string]any); ok { - if name := getString(m, "artistName"); name != "" { - artists = append(artists, name) - } + if name := getString(m, "artistName"); name != "" { + artists = append(artists, name) } } return dedupeStrings(artists) } +func appendArtistNames(artists *[]string, entries []any) { + for _, entry := range entries { + if name := artistNameFromValue(entry); name != "" { + *artists = append(*artists, name) + continue + } + } +} + +func artistNameFromValue(value any) string { + m, ok := value.(map[string]any) + if !ok { + return "" + } + if profile, ok := m["profile"].(map[string]any); ok { + if name := getString(profile, "name"); name != "" { + return name + } + } + if node, ok := m["node"].(map[string]any); ok { + if name := artistNameFromValue(node); name != "" { + return name + } + } + if artist, ok := m["artist"].(map[string]any); ok { + if name := artistNameFromValue(artist); name != "" { + return name + } + } + name := getString(m, "name") + if name == "" { + return "" + } + if len(m) == 1 || isArtistMap(m) { + return name + } + return "" +} + +func isArtistMap(m map[string]any) bool { + if uri := getString(m, "uri"); strings.HasPrefix(uri, "spotify:artist:") { + return true + } + if typ := getString(m, "type"); typ == "artist" { + return true + } + if _, ok := m["profile"]; ok { + return true + } + return false +} + func extractAlbumName(value any) string { var album string walkMap(value, func(m map[string]any) { diff --git a/internal/spotify/connect_mapping_test.go b/internal/spotify/connect_mapping_test.go index 24580ca..f85ec34 100644 --- a/internal/spotify/connect_mapping_test.go +++ b/internal/spotify/connect_mapping_test.go @@ -62,6 +62,127 @@ func TestExtractItemFallbacks(t *testing.T) { } } +func TestExtractItemArtistsContainers(t *testing.T) { + raw := map[string]any{ + "uri": "spotify:track:abc", + "name": "Song", + "artists": map[string]any{ + "items": []any{ + map[string]any{"name": "Artist One"}, + map[string]any{"name": "Artist Two"}, + }, + }, + } + item, ok := extractItem(raw, "track") + if !ok { + t.Fatalf("expected item") + } + if len(item.Artists) != 2 || item.Artists[0] != "Artist One" || item.Artists[1] != "Artist Two" { + t.Fatalf("unexpected artists: %#v", item.Artists) + } +} + +func TestExtractItemArtistsEdges(t *testing.T) { + raw := map[string]any{ + "uri": "spotify:track:abc", + "name": "Song", + "artists": map[string]any{ + "edges": []any{ + map[string]any{"node": map[string]any{"name": "Artist One"}}, + map[string]any{"node": map[string]any{"name": "Artist Two"}}, + }, + }, + } + item, ok := extractItem(raw, "track") + if !ok { + t.Fatalf("expected item") + } + if len(item.Artists) != 2 || item.Artists[0] != "Artist One" || item.Artists[1] != "Artist Two" { + t.Fatalf("unexpected artists: %#v", item.Artists) + } +} + +func TestExtractItemFirstArtistItems(t *testing.T) { + raw := map[string]any{ + "uri": "spotify:track:abc", + "name": "Song", + "firstArtist": map[string]any{ + "items": []any{ + map[string]any{"profile": map[string]any{"name": "Artist One"}}, + }, + }, + } + item, ok := extractItem(raw, "track") + if !ok { + t.Fatalf("expected item") + } + if len(item.Artists) != 1 || item.Artists[0] != "Artist One" { + t.Fatalf("unexpected artists: %#v", item.Artists) + } +} + +func TestExtractItemOtherArtistsItems(t *testing.T) { + raw := map[string]any{ + "uri": "spotify:track:abc", + "name": "Song", + "firstArtist": map[string]any{ + "items": []any{ + map[string]any{"profile": map[string]any{"name": "Artist One"}}, + }, + }, + "otherArtists": map[string]any{ + "items": []any{ + map[string]any{"profile": map[string]any{"name": "Artist Two"}}, + }, + }, + } + item, ok := extractItem(raw, "track") + if !ok { + t.Fatalf("expected item") + } + if len(item.Artists) != 2 || item.Artists[0] != "Artist One" || item.Artists[1] != "Artist Two" { + t.Fatalf("unexpected artists: %#v", item.Artists) + } +} +func TestExtractItemFromPayloadPrefersTrackUnion(t *testing.T) { + payload := map[string]any{ + "data": map[string]any{ + "trackUnion": map[string]any{ + "uri": "spotify:track:primary", + "name": "Primary", + "artists": []any{ + map[string]any{"name": "Main Artist"}, + }, + }, + "track": map[string]any{ + "uri": "spotify:track:secondary", + "name": "Secondary", + "artists": []any{ + map[string]any{"name": "Wrong Artist"}, + }, + }, + "other": map[string]any{ + "items": []any{ + map[string]any{ + "uri": "spotify:track:secondary", + "name": "Secondary", + "artists": []any{ + map[string]any{"name": "Wrong Artist"}, + }, + }, + }, + }, + }, + } + item, ok := extractItemFromPayload(payload, "track") + if !ok { + t.Fatalf("expected item") + } + if item.ID != "primary" || len(item.Artists) != 1 || item.Artists[0] != "Main Artist" { + t.Fatalf("unexpected item: %#v", item) + } +} + func TestExtractSearchItemsFallback(t *testing.T) { payload := map[string]any{ "data": map[string]any{ From 0027b07e9274fb09c5136c6dbf43e550abb5db94 Mon Sep 17 00:00:00 2001 From: Joel Davies <114788106+joelbdavies@users.noreply.github.com> Date: Fri, 23 Jan 2026 01:51:07 +0800 Subject: [PATCH 2/2] Handle artist id+name fragments Problem: some Connect artist fragments only include id+name, so artistNameFromValue ignored them. Solution: treat id as sufficient evidence of an artist map and add a regression test. --- internal/spotify/connect_mapping.go | 2 +- internal/spotify/connect_mapping_test.go | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/internal/spotify/connect_mapping.go b/internal/spotify/connect_mapping.go index 57d6830..3d0b0d8 100644 --- a/internal/spotify/connect_mapping.go +++ b/internal/spotify/connect_mapping.go @@ -315,7 +315,7 @@ func artistNameFromValue(value any) string { if name == "" { return "" } - if len(m) == 1 || isArtistMap(m) { + if len(m) == 1 || isArtistMap(m) || getString(m, "id") != "" { return name } return "" diff --git a/internal/spotify/connect_mapping_test.go b/internal/spotify/connect_mapping_test.go index f85ec34..41040ad 100644 --- a/internal/spotify/connect_mapping_test.go +++ b/internal/spotify/connect_mapping_test.go @@ -183,6 +183,23 @@ func TestExtractItemFromPayloadPrefersTrackUnion(t *testing.T) { } } +func TestExtractItemArtistsIDName(t *testing.T) { + raw := map[string]any{ + "uri": "spotify:track:abc", + "name": "Song", + "artists": []any{ + map[string]any{"id": "ar1", "name": "Artist One"}, + }, + } + item, ok := extractItem(raw, "track") + if !ok { + t.Fatalf("expected item") + } + if len(item.Artists) != 1 || item.Artists[0] != "Artist One" { + t.Fatalf("unexpected artists: %#v", item.Artists) + } +} + func TestExtractSearchItemsFallback(t *testing.T) { payload := map[string]any{ "data": map[string]any{