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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ Browse the catalog. Each subcommand takes a query plus `--limit N` and `--offset
| `spogo search show <query>` | Podcast shows. |
| `spogo search episode <query>` | 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.
Expand Down
2 changes: 1 addition & 1 deletion internal/spotify/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/spotify/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions internal/spotify/connect_pathfinder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion internal/spotify/connect_web.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
68 changes: 68 additions & 0 deletions internal/spotify/search_response_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
}
145 changes: 145 additions & 0 deletions internal/spotify/testdata/search_response.json
Original file line number Diff line number Diff line change
@@ -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": []
}]
}
}