diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c6585a..482fb41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/library.md b/docs/library.md index f93dc15..59070b2 100644 --- a/docs/library.md +++ b/docs/library.md @@ -102,7 +102,7 @@ Playlist mutations route through Connect by default — Connect avoids the Web A spogo playlist tracks [--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 diff --git a/internal/spotify/connect_collection_order_test.go b/internal/spotify/connect_collection_order_test.go new file mode 100644 index 0000000..0497cc5 --- /dev/null +++ b/internal/spotify/connect_collection_order_test.go @@ -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) + } + }) + } +} diff --git a/internal/spotify/connect_extract_collections.go b/internal/spotify/connect_extract_collections.go index 57ee037..5da7029 100644 --- a/internal/spotify/connect_extract_collections.go +++ b/internal/spotify/connect_extract_collections.go @@ -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 @@ -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 { @@ -54,17 +54,10 @@ 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) { @@ -72,44 +65,42 @@ func extractPlaylistContentItems(payload map[string]any, kind string) ([]Item, i 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) }