Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- Honor long Spotify cooldowns without premature retries, retain valid tokens on throttling, and keep cookie-token requests from changing shared HTTP clients
- Preserve literal CLI arguments and pass playback URIs to AppleScript as data; accept locale-prefixed and embedded Spotify URLs
- Preserve repeated tracks and their positions when listing Connect playlists while keeping library listings deduplicated
- Compatibility: reject malformed Spotify URIs, missing identifiers, and unrelated URLs instead of silently truncating or misidentifying them
- Let Windows config updates finish after concurrent readers close, including when replacement reports access denied
- Refresh the SQLite runtime dependency and preferred Go toolchain to 1.27.1 while retaining Go 1.26.7 support; validate macOS, Windows, the Go floor, race coverage, and docs in CI
Expand Down
2 changes: 1 addition & 1 deletion docs/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Playlist mutations route through Connect by default — Connect avoids the Web A
spogo playlist tracks <playlist> [--limit N]
```

Lists the items inside a playlist:
Lists the items inside a playlist in order, including repeated tracks. Library listings still deduplicate entities; playlist positions are preserved:

```bash
spogo playlist tracks spotify:playlist:37i9dQZF1DXcBWIGoYBM5M --plain | head
Expand Down
49 changes: 49 additions & 0 deletions internal/spotify/connect_collection_order_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package spotify

import "testing"

func TestPlaylistContentPreservesRepeatedTracks(t *testing.T) {
entries := make([]any, 0, 3)
for _, id := range []string{"a", "b", "a"} {
entries = append(entries, map[string]any{"itemV2": map[string]any{"data": map[string]any{"uri": "spotify:track:" + id, "name": id}}})
}
payload := map[string]any{"data": map[string]any{"playlistV2": map[string]any{"content": map[string]any{"items": entries, "totalCount": float64(3)}}}}
items, total := extractPlaylistContentItems(payload, "track")
if len(items) != 3 || total != 3 {
t.Fatalf("items=%+v total=%d; repeated positions must be retained", items, total)
}
for i, id := range []string{"a", "b", "a"} {
if items[i].ID != id {
t.Fatalf("position %d = %q, want %q", i, items[i].ID, id)
}
}
}

func TestLibraryContentStillDeduplicatesEntities(t *testing.T) {
entries := make([]any, 0, 3)
for _, id := range []string{"a", "b", "a"} {
entries = append(entries, map[string]any{"item": map[string]any{"data": map[string]any{"uri": "spotify:playlist:" + id, "name": id}}})
}
for _, tc := range []struct {
name string
present bool
total int
want int
}{
{"missing", false, 0, 2},
{"zero", true, 0, 2},
{"advertised", true, 3, 3},
} {
t.Run(tc.name, func(t *testing.T) {
library := map[string]any{"items": entries}
if tc.present {
library["totalCount"] = float64(tc.total)
}
payload := map[string]any{"data": map[string]any{"me": map[string]any{"libraryV3": library}}}
items, total := extractLibraryV3Items(payload, "playlist")
if len(items) != 2 || total != tc.want || items[0].ID != "a" || items[1].ID != "b" {
t.Fatalf("library set semantics changed: %+v total=%d, want %d", items, total, tc.want)
}
})
}
}
63 changes: 27 additions & 36 deletions internal/spotify/connect_extract_collections.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ func extractLibraryV3Items(payload map[string]any, kind string) ([]Item, int) {
if !ok {
return nil, 0
}
return extractWrappedCollectionItems(lib, "items", "item", "data", "totalCount", kind)
items := dedupeCollectionItems(extractWrappedCollectionItems(lib, "item", kind))
return items, collectionTotal(lib, items)
}

// extractFetchLibraryTracks navigates the fetchLibraryTracks response path
Expand All @@ -32,7 +33,6 @@ func extractFetchLibraryTracks(payload map[string]any) ([]Item, int, error) {
return nil, 0, fmt.Errorf("fetchLibraryTracks payload has invalid data.me.library.tracks.items")
}
items := make([]Item, 0, len(rawItems))
seen := map[string]struct{}{}
for _, raw := range rawItems {
m, ok := raw.(map[string]any)
if !ok {
Expand All @@ -54,62 +54,53 @@ func extractFetchLibraryTracks(payload map[string]any) ([]Item, int, error) {
if !ok {
continue
}
if _, dup := seen[item.URI]; dup {
continue
}
seen[item.URI] = struct{}{}
items = append(items, item)
}
total := getInt(tracks, "totalCount")
if total == 0 {
total = len(items)
}
return items, total, nil
items = dedupeCollectionItems(items)
return items, collectionTotal(tracks, items), nil
}

func extractPlaylistContentItems(payload map[string]any, kind string) ([]Item, int) {
content, ok := getMap(payload, "data", "playlistV2", "content")
if !ok {
return nil, 0
}
return extractWrappedCollectionItems(content, "items", "itemV2", "data", "totalCount", kind)
// A playlist is an ordered sequence; repeated URIs are distinct positions.
items := extractWrappedCollectionItems(content, "itemV2", kind)
return items, collectionTotal(content, items)
}

func extractWrappedCollectionItems(container map[string]any, itemsKey, wrapperKey, dataKey, totalKey, kind string) ([]Item, int) {
rawItems, _ := container[itemsKey].([]any)
func extractWrappedCollectionItems(container map[string]any, wrapperKey, kind string) []Item {
rawItems, _ := container["items"].([]any)
items := make([]Item, 0, len(rawItems))
seen := map[string]struct{}{}
for _, raw := range rawItems {
dataM, ok := extractWrappedData(raw, wrapperKey, dataKey)
data, ok := getMap(raw, wrapperKey, "data")
if !ok {
continue
}
item, ok := extractItem(dataM, kind)
if !ok {
continue
if item, ok := extractItem(data, kind); ok {
items = append(items, item)
}
if _, dup := seen[item.URI]; dup {
}
return items
}

func dedupeCollectionItems(items []Item) []Item {
seen := make(map[string]struct{}, len(items))
unique := items[:0]
for _, item := range items {
if _, exists := seen[item.URI]; exists {
continue
}
seen[item.URI] = struct{}{}
items = append(items, item)
}
total := getInt(container, totalKey)
if total == 0 {
total = len(items)
unique = append(unique, item)
}
return items, total
return unique
}

func extractWrappedData(raw any, wrapperKey, dataKey string) (map[string]any, bool) {
m, ok := raw.(map[string]any)
if !ok {
return nil, false
}
wrapper, ok := m[wrapperKey].(map[string]any)
if !ok {
return nil, false
func collectionTotal(container map[string]any, items []Item) int {
if total := getInt(container, "totalCount"); total != 0 {
return total
}
dataM, ok := wrapper[dataKey].(map[string]any)
return dataM, ok
return len(items)
}
Loading