From b177ceb54b6c89567436cff9fc0ac3c1ffef8d2c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 30 Aug 2026 23:38:16 -0700 Subject: [PATCH] fix(search): decode Spotify plural result containers Extract the search-parser fix from PR #57 commit 12fed0bc40c7ab2e747245e9dbab4f409c8894f1 without its OAuth changes. Read plural containers in both Web API search paths and cover all six supported search types with a synthetic documented-shape response fixture. Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com> --- CHANGELOG.md | 4 + docs/commands.md | 2 + internal/spotify/client.go | 2 +- internal/spotify/client_test.go | 2 +- internal/spotify/connect_pathfinder_test.go | 4 +- internal/spotify/connect_web.go | 2 +- internal/spotify/search_response_test.go | 68 ++++++++ .../spotify/testdata/search_response.json | 145 ++++++++++++++++++ 8 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 internal/spotify/search_response_test.go create mode 100644 internal/spotify/testdata/search_response.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 82a6f2c..32fc361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Refresh Go dependencies, including Kong, SweetCookie, SQLite, and formatting tools, and build with Go 1.26.7 for compatibility with GitHub's default CodeQL runner - Refresh GitHub Actions and CI tool pins, and build documentation with Node.js 26 +### Fixed + +- Decode Spotify's plural search result containers in the Web API client and Connect fallback, thanks @VACInc + ## 0.10.7 - 2026-08-23 ### Changed diff --git a/docs/commands.md b/docs/commands.md index 7a2cc56..8545101 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -89,6 +89,8 @@ Browse the catalog. Each subcommand takes a query plus `--limit N` and `--offset | `spogo search show ` | Podcast shows. | | `spogo search episode ` | Podcast episodes. | +Web API search and Connect's Web API fallback read Spotify's plural result containers (`tracks`, `albums`, `artists`, `playlists`, `shows`, `episodes`); CLI search types stay singular. + ## info Fetch a single item by ID, URI, or URL. diff --git a/internal/spotify/client.go b/internal/spotify/client.go index 051f6bd..323f832 100644 --- a/internal/spotify/client.go +++ b/internal/spotify/client.go @@ -70,7 +70,7 @@ func (c *Client) Search(ctx context.Context, kind, query string, limit, offset i if err := c.get(ctx, "/search", params, &response); err != nil { return SearchResult{}, err } - container, ok := response[kind] + container, ok := response[kind+"s"] if !ok { return SearchResult{}, fmt.Errorf("missing %s result", kind) } diff --git a/internal/spotify/client_test.go b/internal/spotify/client_test.go index 7b09fb7..634d681 100644 --- a/internal/spotify/client_test.go +++ b/internal/spotify/client_test.go @@ -37,7 +37,7 @@ func TestSearchTrack(t *testing.T) { return } payload := map[string]any{ - "track": map[string]any{ + "tracks": map[string]any{ "items": []map[string]any{{ "id": "t1", "uri": "spotify:track:t1", diff --git a/internal/spotify/connect_pathfinder_test.go b/internal/spotify/connect_pathfinder_test.go index 0ce04eb..b574891 100644 --- a/internal/spotify/connect_pathfinder_test.go +++ b/internal/spotify/connect_pathfinder_test.go @@ -128,7 +128,7 @@ func TestPathfinderFallbackToWeb(t *testing.T) { return textResponse(http.StatusInternalServerError, "fail"), nil } payload := map[string]any{ - "track": map[string]any{ + "tracks": map[string]any{ "items": []map[string]any{{ "id": "t1", "uri": "spotify:track:t1", @@ -157,7 +157,7 @@ func TestPathfinderFallbackToWeb(t *testing.T) { func TestSearchViaWebAPIDefaultClient(t *testing.T) { transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) { payload := map[string]any{ - "track": map[string]any{ + "tracks": map[string]any{ "items": []map[string]any{{ "id": "t1", "uri": "spotify:track:t1", diff --git a/internal/spotify/connect_web.go b/internal/spotify/connect_web.go index fd4543a..d7d2e18 100644 --- a/internal/spotify/connect_web.go +++ b/internal/spotify/connect_web.go @@ -52,7 +52,7 @@ func (c *ConnectClient) searchViaWebAPI(ctx context.Context, kind, query string, if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { return SearchResult{}, err } - container, ok := response[kind] + container, ok := response[kind+"s"] if !ok { return SearchResult{}, fmt.Errorf("missing %s result", kind) } diff --git a/internal/spotify/search_response_test.go b/internal/spotify/search_response_test.go new file mode 100644 index 0000000..e4b9a4d --- /dev/null +++ b/internal/spotify/search_response_test.go @@ -0,0 +1,68 @@ +package spotify + +import ( + "bytes" + "context" + "io" + "net/http" + "os" + "testing" +) + +func TestSearchDocumentedResponse(t *testing.T) { + // Synthetic catalog data using Spotify's documented search response shape: + // https://developer.spotify.com/documentation/web-api/reference/search + fixture, err := os.ReadFile("testdata/search_response.json") + if err != nil { + t.Fatal(err) + } + for _, kind := range []string{"track", "album", "artist", "playlist", "show", "episode"} { + for _, engine := range []string{"web", "connect-web-fallback"} { + t.Run(kind+"/"+engine, func(t *testing.T) { + transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodGet || req.URL.Path != "/v1/search" { + t.Errorf("unexpected request: %s %s", req.Method, req.URL.Path) + } + params := req.URL.Query() + if params.Get("type") != kind || params.Get("q") != "fixture" || params.Get("limit") != "1" || params.Get("offset") != "1" { + t.Errorf("unexpected search parameters: %v", params) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + ContentLength: int64(len(fixture)), + Body: io.NopCloser(bytes.NewReader(fixture)), + }, nil + }) + client, err := NewClient(Options{ + TokenProvider: staticTokenProvider{}, + HTTPClient: &http.Client{Transport: transport}, + }) + if err != nil { + t.Fatal(err) + } + search := client.Search + if engine == "connect-web-fallback" { + search = newConnectClientForTests(transport).searchViaWebAPI + } + result, err := search(context.Background(), kind, "fixture", 1, 1) + if err != nil { + t.Fatalf("search: %v", err) + } + if result.Type != kind || result.Limit != 1 || result.Offset != 1 || result.Total != 3 { + t.Errorf("unexpected result metadata: %#v", result) + } + if len(result.Items) != 1 { + t.Fatalf("expected one item, got %d", len(result.Items)) + } + item := result.Items[0] + if item.Type != kind || item.ID != kind+"1" || item.Name != "Fixture "+kind || item.URI != "spotify:"+kind+":"+kind+"1" { + t.Errorf("unexpected item: %#v", item) + } + if kind == "track" && (item.Album != "Fixture album" || len(item.Artists) != 1 || item.Artists[0] != "Fixture artist" || item.DurationMS != 123000) { + t.Errorf("unexpected track metadata: %#v", item) + } + }) + } + } +} diff --git a/internal/spotify/testdata/search_response.json b/internal/spotify/testdata/search_response.json new file mode 100644 index 0000000..89af7d2 --- /dev/null +++ b/internal/spotify/testdata/search_response.json @@ -0,0 +1,145 @@ +{ + "tracks": { + "href": "https://api.spotify.com/v1/search?q=fixture&type=track&limit=1&offset=1", + "limit": 1, + "offset": 1, + "total": 3, + "next": "https://api.spotify.com/v1/search?q=fixture&type=track&limit=1&offset=2", + "previous": "https://api.spotify.com/v1/search?q=fixture&type=track&limit=1&offset=0", + "items": [{ + "id": "track1", + "uri": "spotify:track:track1", + "type": "track", + "name": "Fixture track", + "href": "https://api.spotify.com/v1/tracks/track1", + "external_urls": {"spotify": "https://open.spotify.com/track/track1"}, + "album": { + "id": "album1", + "uri": "spotify:album:album1", + "type": "album", + "album_type": "album", + "name": "Fixture album", + "release_date": "2026-01-01", + "release_date_precision": "day", + "total_tracks": 10, + "images": [] + }, + "artists": [{"id": "artist1", "uri": "spotify:artist:artist1", "type": "artist", "name": "Fixture artist"}], + "disc_number": 1, + "track_number": 2, + "duration_ms": 123000, + "explicit": false, + "is_local": false, + "is_playable": true + }] + }, + "albums": { + "href": "https://api.spotify.com/v1/search?q=fixture&type=album&limit=1&offset=1", + "limit": 1, + "offset": 1, + "total": 3, + "next": "https://api.spotify.com/v1/search?q=fixture&type=album&limit=1&offset=2", + "previous": "https://api.spotify.com/v1/search?q=fixture&type=album&limit=1&offset=0", + "items": [{ + "id": "album1", + "uri": "spotify:album:album1", + "type": "album", + "album_type": "album", + "name": "Fixture album", + "external_urls": {"spotify": "https://open.spotify.com/album/album1"}, + "artists": [{"id": "artist1", "uri": "spotify:artist:artist1", "type": "artist", "name": "Fixture artist"}], + "release_date": "2026-01-01", + "release_date_precision": "day", + "total_tracks": 10, + "images": [] + }] + }, + "artists": { + "href": "https://api.spotify.com/v1/search?q=fixture&type=artist&limit=1&offset=1", + "limit": 1, + "offset": 1, + "total": 3, + "next": "https://api.spotify.com/v1/search?q=fixture&type=artist&limit=1&offset=2", + "previous": "https://api.spotify.com/v1/search?q=fixture&type=artist&limit=1&offset=0", + "items": [{ + "id": "artist1", + "uri": "spotify:artist:artist1", + "type": "artist", + "name": "Fixture artist", + "external_urls": {"spotify": "https://open.spotify.com/artist/artist1"}, + "followers": {"href": null, "total": 1234}, + "genres": ["rock"], + "images": [] + }] + }, + "playlists": { + "href": "https://api.spotify.com/v1/search?q=fixture&type=playlist&limit=1&offset=1", + "limit": 1, + "offset": 1, + "total": 3, + "next": "https://api.spotify.com/v1/search?q=fixture&type=playlist&limit=1&offset=2", + "previous": "https://api.spotify.com/v1/search?q=fixture&type=playlist&limit=1&offset=0", + "items": [{ + "id": "playlist1", + "uri": "spotify:playlist:playlist1", + "type": "playlist", + "name": "Fixture playlist", + "external_urls": {"spotify": "https://open.spotify.com/playlist/playlist1"}, + "description": "Synthetic search fixture playlist", + "owner": {"id": "fixture-owner", "display_name": "Fixture owner", "type": "user"}, + "tracks": {"href": "https://api.spotify.com/v1/playlists/playlist1/tracks", "total": 10}, + "collaborative": false, + "public": true, + "images": [] + }] + }, + "shows": { + "href": "https://api.spotify.com/v1/search?q=fixture&type=show&limit=1&offset=1", + "limit": 1, + "offset": 1, + "total": 3, + "next": "https://api.spotify.com/v1/search?q=fixture&type=show&limit=1&offset=2", + "previous": "https://api.spotify.com/v1/search?q=fixture&type=show&limit=1&offset=0", + "items": [{ + "id": "show1", + "uri": "spotify:show:show1", + "type": "show", + "name": "Fixture show", + "external_urls": {"spotify": "https://open.spotify.com/show/show1"}, + "description": "Synthetic search fixture show", + "html_description": "Synthetic search fixture show", + "publisher": "Fixture publisher", + "total_episodes": 10, + "languages": ["en"], + "media_type": "audio", + "explicit": false, + "is_externally_hosted": false, + "images": [] + }] + }, + "episodes": { + "href": "https://api.spotify.com/v1/search?q=fixture&type=episode&limit=1&offset=1", + "limit": 1, + "offset": 1, + "total": 3, + "next": "https://api.spotify.com/v1/search?q=fixture&type=episode&limit=1&offset=2", + "previous": "https://api.spotify.com/v1/search?q=fixture&type=episode&limit=1&offset=0", + "items": [{ + "id": "episode1", + "uri": "spotify:episode:episode1", + "type": "episode", + "name": "Fixture episode", + "external_urls": {"spotify": "https://open.spotify.com/episode/episode1"}, + "description": "Synthetic search fixture episode", + "html_description": "Synthetic search fixture episode", + "duration_ms": 1800000, + "release_date": "2026-01-01", + "release_date_precision": "day", + "languages": ["en"], + "explicit": false, + "is_externally_hosted": false, + "is_playable": true, + "images": [] + }] + } +}