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
29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ sptfy search artist 'Björk' --max 10
```text
ID | TRACK | ARTIST_IDS | ARTISTS | ALBUM_ID | ALBUM | DURATION
ID | ALBUM | ARTIST_IDS | ARTISTS | RELEASE_DATE | TOTAL_TRACKS
ID | ARTIST | GENRES
ID | ARTIST
```

Spotify development-mode search is limited to 10 results per page. When more
Expand All @@ -84,6 +84,28 @@ dimensions and URLs. Resource search intentionally has no JSON mode.
To keep every result on one line, carriage returns, newlines, and the reserved
` | ` separator sequence inside Spotify text are replaced with one space.

## Catalog

Get one track, album, or artist from a raw Spotify ID, matching Spotify URI, or
canonical `open.spotify.com` URL:

```sh
sptfy tracks get 11dFghVXANMlKmJXsNCbNl
sptfy albums get spotify:album:4aawyAB9vmqN3uQ7FjRGTy
sptfy artists get https://open.spotify.com/artist/0TnOYISbd1XYRBk9myaseg
```

```text
11dFghVXANMlKmJXsNCbNl Cut To The Feeling
Artist IDs: 6sFIWsNpZYqfjUpaCgueju Artists: Carly Rae Jepsen
Album ID: 0tGPJ0bkWOUmH7MEOR77qc Album: Cut To The Feeling
Duration: 3:27
```

Catalog reads are text-only. `--id` emits only the fetched ID and overrides
`--extended`, `--fields`, and `--include-artwork`. Selected identity fields
remain in the stable identity header instead of being duplicated below it.

## Development

Requires Go 1.26 or newer.
Expand Down Expand Up @@ -112,8 +134,9 @@ make live-smoke
```

The harness is interactive because Spotify authorization opens a browser. It
exercises setup, identity, refresh, search/pagination shapes, replacement,
clear, and re-initialization without exporting the stored OAuth credential.
exercises setup, identity, refresh, search/pagination shapes, catalog gets,
replacement, clear, and re-initialization without exporting the stored OAuth
credential.

## License

Expand Down
48 changes: 47 additions & 1 deletion internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"strconv"
"strings"
"time"

"github.com/open-cli-collective/spotify-cli/internal/spotifyref"
)

const (
Expand Down Expand Up @@ -50,7 +52,6 @@ type User struct {
type Artist struct {
ID string `json:"id"`
Name string `json:"name"`
Genres []string `json:"genres"`
URI string `json:"uri"`
ExternalURLs ExternalURLs `json:"external_urls"`
Images []Image `json:"images"`
Expand Down Expand Up @@ -142,6 +143,51 @@ func (client Client) Me(ctx context.Context) (User, error) {
return user, nil
}

// GetTrack returns one track by ID.
func (client Client) GetTrack(ctx context.Context, id string) (Track, error) {
var track Track
if !spotifyref.ValidID(id) {
return Track{}, ErrInvalidResponse
}
if err := client.getJSON(ctx, "/tracks/"+id, &track); err != nil {
return Track{}, err
}
if strings.TrimSpace(track.ID) == "" {
Comment thread
rianjs marked this conversation as resolved.
Resolved
Comment thread
rianjs marked this conversation as resolved.
Resolved
return Track{}, ErrInvalidResponse
}
return track, nil
}

// GetAlbum returns one album by ID.
func (client Client) GetAlbum(ctx context.Context, id string) (Album, error) {
var album Album
if !spotifyref.ValidID(id) {
return Album{}, ErrInvalidResponse
}
if err := client.getJSON(ctx, "/albums/"+id, &album); err != nil {
return Album{}, err
}
if strings.TrimSpace(album.ID) == "" {
return Album{}, ErrInvalidResponse
}
return album, nil
}

// GetArtist returns one artist by ID.
func (client Client) GetArtist(ctx context.Context, id string) (Artist, error) {
var artist Artist
if !spotifyref.ValidID(id) {
return Artist{}, ErrInvalidResponse
}
if err := client.getJSON(ctx, "/artists/"+id, &artist); err != nil {
return Artist{}, err
}
if strings.TrimSpace(artist.ID) == "" {
return Artist{}, ErrInvalidResponse
}
return artist, nil
}

type trackSearchResponse struct {
Tracks *struct {
Items *[]Track `json:"items"`
Expand Down
97 changes: 95 additions & 2 deletions internal/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,99 @@ func TestMePreservesContextCancellation(t *testing.T) {
}
}

func TestCatalogGetUsesOneExactPath(t *testing.T) {
const id = "0123456789ABCDEFGHIJKL"
for _, test := range []struct {
name string
path string
get func(Client) error
}{
{name: "track", path: "/v1/tracks/" + id, get: func(value Client) error { _, err := value.GetTrack(context.Background(), id); return err }},
{name: "album", path: "/v1/albums/" + id, get: func(value Client) error { _, err := value.GetAlbum(context.Background(), id); return err }},
{name: "artist", path: "/v1/artists/" + id, get: func(value Client) error { _, err := value.GetArtist(context.Background(), id); return err }},
} {
t.Run(test.name, func(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
calls++
if request.Method != http.MethodGet || request.URL.Path != test.path || request.URL.RawQuery != "" {
t.Fatalf("request = %s %s", request.Method, request.URL.RequestURI())
}
_, _ = io.WriteString(w, `{"id":"`+id+`"}`)
}))
defer server.Close()
if err := test.get(Client{HTTPClient: server.Client(), BaseURL: server.URL + "/v1"}); err != nil || calls != 1 {
t.Fatalf("error=%v calls=%d", err, calls)
}
})
}
}

func TestCatalogGetValidatesInputAndResponse(t *testing.T) {
calls := 0
httpClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
calls++
return response(http.StatusOK, `{}`), nil
})}
spotify := Client{HTTPClient: httpClient}
for _, id := range []string{" ", "short", "0123456789ABCDEFGHIJK!", "0123456789ABCDEFGHIJKL?market=US"} {
if _, err := spotify.GetTrack(context.Background(), id); !errors.Is(err, ErrInvalidResponse) || calls != 0 {
t.Fatalf("invalid ID %q error=%v calls=%d", id, err, calls)
}
}
if _, err := spotify.GetAlbum(context.Background(), "0123456789ABCDEFGHIJKL"); !errors.Is(err, ErrInvalidResponse) || calls != 1 {
t.Fatalf("missing response ID error=%v calls=%d", err, calls)
}
}

func TestCatalogGetInheritsBoundedTransportBehavior(t *testing.T) {
for _, test := range []struct {
name string
body string
code int
want error
}{
{name: "unauthorized", code: http.StatusUnauthorized, want: ErrUnauthorized},
{name: "forbidden", code: http.StatusForbidden, want: ErrForbidden},
{name: "malformed", body: "not-json", code: http.StatusOK, want: ErrInvalidResponse},
{name: "oversized", body: `{"id":"` + strings.Repeat("x", maxResponseBytes) + `"}`, code: http.StatusOK, want: ErrInvalidResponse},
} {
t.Run(test.name, func(t *testing.T) {
httpClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return response(test.code, test.body), nil
})}
_, err := (Client{HTTPClient: httpClient}).GetArtist(context.Background(), "0123456789ABCDEFGHIJKL")
if !errors.Is(err, test.want) {
t.Fatalf("error=%v want=%v", err, test.want)
}
})
}

calls := 0
spotify := Client{
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
calls++
if calls == 1 {
return response(http.StatusServiceUnavailable, ""), nil
}
return response(http.StatusOK, `{"id":"0123456789ABCDEFGHIJKL"}`), nil
})},
Wait: func(context.Context, time.Duration) error { return nil },
}
if _, err := spotify.GetTrack(context.Background(), "0123456789ABCDEFGHIJKL"); err != nil || calls != 2 {
t.Fatalf("retry error=%v calls=%d", err, calls)
}

ctx, cancel := context.WithCancel(context.Background())
cancel()
spotify.HTTPClient = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
return nil, request.Context().Err()
})}
if _, err := spotify.GetTrack(ctx, "0123456789ABCDEFGHIJKL"); !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation error=%v", err)
}
}

func TestSearchTracksEncodesQueryAndDecodesBreadcrumbs(t *testing.T) {
httpClient := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.Method != http.MethodGet || request.URL.Path != "/v1/search" {
Expand Down Expand Up @@ -148,13 +241,13 @@ func TestSearchArtistsEncodesQueryAndDecodesPage(t *testing.T) {
if request.Method != http.MethodGet || request.URL.Host != "api.spotify.invalid" || request.URL.Path != "/v1/search" || query.Get("q") != "Björk 東京" || query.Get("type") != "artist" || query.Get("limit") != "1" || query.Get("offset") != "0" {
t.Fatalf("request = %s %s query=%v", request.Method, request.URL.Path, query)
}
return response(http.StatusOK, `{"artists":{"items":[{"id":"artist","name":"Björk","genres":["art pop","electronic"],"uri":"spotify:artist:artist","external_urls":{"spotify":"https://open.spotify.com/artist/artist"},"images":[{"url":"https://image","width":320,"height":320}]}],"limit":1,"offset":0,"total":1,"next":"https://evil.invalid/follow-me"}}`), nil
return response(http.StatusOK, `{"artists":{"items":[{"id":"artist","name":"Björk","uri":"spotify:artist:artist","external_urls":{"spotify":"https://open.spotify.com/artist/artist"},"images":[{"url":"https://image","width":320,"height":320}]}],"limit":1,"offset":0,"total":1,"next":"https://evil.invalid/follow-me"}}`), nil
})}
page, err := (Client{HTTPClient: httpClient, BaseURL: "https://api.spotify.invalid/v1"}).SearchArtists(context.Background(), "Björk 東京", 1, 0)
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || page.Items[0].ID != "artist" || len(page.Items[0].Genres) != 2 || !page.HasNext || calls != 1 {
if len(page.Items) != 1 || page.Items[0].ID != "artist" || !page.HasNext || calls != 1 {
t.Fatalf("page = %+v calls=%d", page, calls)
}
}
Expand Down
Loading
Loading