Files
bjarneo--cliamp/external/spotify/provider.go
T
Luguin 49c2cc7983 Enable Spotify support on Windows (#323)
go-librespot gained first-party WASAPI output for Windows in v0.9.0
(cliamp was pinned to v0.7.1, which doesn't compile on Windows). Bump
the dependency and remove the Windows CGO stub so the real provider
builds on all platforms.

CI now installs a MinGW toolchain via MSYS2 and builds/tests with
CGO_ENABLED=1 on windows-2025, including a workaround for an MSYS2
libogg packaging issue where libogg-0.dll's export table is missing
ogg_stream_iovecin even though it's present in the static libogg.a.

Verified locally end-to-end on Windows: native CGO build, full test
suite, and real Spotify Premium playback.

Fixes #299

Not in scope for this PR: release.yml still builds Windows with
CGO_ENABLED=0, so Releases binaries won't include Spotify until that
pipeline is updated separately (needs a packaging decision: bundle the
MSYS2 DLLs or pursue a fully static build).
2026-08-20 17:57:01 +02:00

751 lines
23 KiB
Go

package spotify
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"sort"
"strconv"
"strings"
"sync"
"time"
librespot "github.com/devgianlu/go-librespot"
"github.com/devgianlu/go-librespot/audio"
"github.com/gopxl/beep/v2"
"github.com/bjarneo/cliamp/applog"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
)
// Compile-time interface checks.
var (
_ provider.Searcher = (*SpotifyProvider)(nil)
_ provider.PlaylistWriter = (*SpotifyProvider)(nil)
_ provider.PlaylistCreator = (*SpotifyProvider)(nil)
_ provider.CustomStreamer = (*SpotifyProvider)(nil)
_ provider.Closer = (*SpotifyProvider)(nil)
)
// maxResponseBody limits JSON API responses to 10 MB.
// SpotifyProvider implements playlist.Provider using the Spotify Web API
// for playlist/track metadata and go-librespot for audio streaming.
// playlistCache holds a snapshot_id and the fetched tracks for a playlist,
// allowing us to skip re-fetching playlists that haven't changed.
type playlistCache struct {
snapshotID string
tracks []playlist.Track
}
type SpotifyProvider struct {
session *Session
clientID string
bitrate int
userID string // Spotify user ID, fetched lazily on first Playlists() call
meFetched bool // /v1/me has been attempted this session; suppresses retry on failure
mu sync.Mutex
trackCache map[string]*playlistCache // playlist ID → cache entry
authCancel context.CancelFunc // cancels any in-progress OAuth flow
// Playlist list cache to avoid redundant API calls on provider switch.
listCache []playlist.PlaylistInfo
listCacheAt time.Time
}
const playlistListCacheTTL = 5 * time.Minute
// New creates a SpotifyProvider. If session is nil, authentication is
// deferred until the user first selects the Spotify provider.
// bitrate sets the preferred Spotify stream quality in kbps (96, 160, or 320).
func New(session *Session, clientID string, bitrate int) *SpotifyProvider {
return &SpotifyProvider{
session: session,
clientID: clientID,
bitrate: bitrate,
trackCache: make(map[string]*playlistCache),
}
}
// ensureSession tries to create a session using stored credentials only
// (no browser). Returns playlist.ErrNeedsAuth if interactive sign-in is needed.
func (p *SpotifyProvider) ensureSession() error {
p.mu.Lock()
if p.session != nil {
p.mu.Unlock()
return nil
}
clientID := p.clientID
p.mu.Unlock()
if clientID == "" {
return fmt.Errorf("spotify: no client ID available")
}
sess, err := NewSessionSilent(context.Background(), clientID)
if err != nil {
return playlist.ErrNeedsAuth
}
p.mu.Lock()
p.session = sess
p.resetSessionScopedStateLocked()
p.mu.Unlock()
return nil
}
// Authenticate runs the interactive sign-in flow (opens browser, waits for callback).
// Any previous in-progress OAuth flow is cancelled first to free the callback port.
func (p *SpotifyProvider) Authenticate() error {
p.mu.Lock()
if p.session != nil {
p.mu.Unlock()
return nil
}
if p.authCancel != nil {
p.authCancel()
p.authCancel = nil
}
clientID := p.clientID
p.mu.Unlock()
if clientID == "" {
return fmt.Errorf("spotify: no client ID available")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
p.mu.Lock()
p.authCancel = cancel
p.mu.Unlock()
sess, err := NewSession(ctx, clientID)
p.mu.Lock()
p.authCancel = nil
p.mu.Unlock()
cancel()
if err != nil {
return err
}
p.mu.Lock()
p.session = sess
p.resetSessionScopedStateLocked()
p.mu.Unlock()
return nil
}
// Close releases the session if one was created.
func (p *SpotifyProvider) Close() {
p.mu.Lock()
defer p.mu.Unlock()
if p.authCancel != nil {
p.authCancel()
p.authCancel = nil
}
if p.session != nil {
p.session.Close()
p.session = nil
p.resetSessionScopedStateLocked()
}
}
// resetSessionScopedStateLocked clears /v1/me-derived caches when the session
// changes. p.mu must be held.
func (p *SpotifyProvider) resetSessionScopedStateLocked() {
p.userID = ""
p.meFetched = false
}
func (p *SpotifyProvider) Name() string { return "Spotify" }
// currentUserID returns the authenticated user's Spotify ID, fetched from
// /v1/me at most once per session. Failures are remembered so a network blip
// during the first call doesn't trigger a request on every later use.
func (p *SpotifyProvider) currentUserID(ctx context.Context) string {
p.mu.Lock()
if p.meFetched {
id := p.userID
p.mu.Unlock()
return id
}
p.mu.Unlock()
var me struct {
ID string `json:"id"`
}
if resp, err := p.webAPI(ctx, "GET", "/v1/me", nil); err == nil {
_ = decodeBody(resp, &me)
}
p.mu.Lock()
defer p.mu.Unlock()
p.userID = me.ID
p.meFetched = true
return p.userID
}
// Playlists returns all playlists in the authenticated user's Spotify library.
func (p *SpotifyProvider) Playlists() ([]playlist.PlaylistInfo, error) {
if err := p.ensureSession(); err != nil {
return nil, err
}
p.mu.Lock()
if p.listCache != nil && time.Since(p.listCacheAt) < playlistListCacheTTL {
cached := slices.Clone(p.listCache)
p.mu.Unlock()
return cached, nil
}
p.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
userID := p.currentUserID(ctx)
var all []playlist.PlaylistInfo
offset := 0
limit := spotifyPlaylistPageSize
// List of Playlists only includes created playlists by the User.
// This doesn't include the 'Liked Songs' playlist.
resp, err := p.webAPI(ctx, "GET", "/v1/me/tracks", nil)
if err != nil {
return nil, fmt.Errorf("spotify: your music: %w", err)
}
var result struct {
Total int `json:"total"`
}
if err := decodeBody(resp, &result); err != nil {
return nil, fmt.Errorf("spotify: parse playlists: %w", err)
}
// Unfortunately, the Spotify API doesn't expose the localized display name.
// i.e. 'Liked Songs' or 'Lieblingssongs' etc.
// For the moment, "Your Music" must sufficice without adding a localization
// map.
all = append(all, playlist.PlaylistInfo{
ID: "YOUR MUSIC",
Name: "Your Music",
TrackCount: result.Total,
Section: "Library",
})
for {
query := url.Values{
"limit": {fmt.Sprintf("%d", limit)},
"offset": {fmt.Sprintf("%d", offset)},
"fields": {"items(id,name,snapshot_id,owner(id),items.total),total"},
}
resp, err := p.webAPI(ctx, "GET", "/v1/me/playlists", query)
if err != nil {
return nil, fmt.Errorf("spotify: list playlists: %w", err)
}
var result struct {
Items []spotifyPlaylistItem `json:"items"`
Total int `json:"total"`
}
if err := decodeBody(resp, &result); err != nil {
return nil, fmt.Errorf("spotify: parse playlists: %w", err)
}
p.mu.Lock()
for _, item := range result.Items {
count := 0
if item.Items != nil {
count = item.Items.Total
}
section := "Followed playlists"
if userID != "" && item.Owner.ID == userID {
section = "Your playlists"
}
all = append(all, playlist.PlaylistInfo{
ID: item.ID,
Name: item.Name,
TrackCount: count,
Section: section,
})
// Update snapshot_id in cache; if it changed, invalidate cached tracks.
if cached, ok := p.trackCache[item.ID]; ok {
if cached.snapshotID != item.SnapshotID {
delete(p.trackCache, item.ID)
}
}
// Store snapshot_id for later cache checks in Tracks().
if _, ok := p.trackCache[item.ID]; !ok && item.SnapshotID != "" {
p.trackCache[item.ID] = &playlistCache{snapshotID: item.SnapshotID}
}
}
p.mu.Unlock()
if offset+limit >= result.Total {
break
}
offset += limit
}
// Group playlists by section so the UI can emit one header per group.
// Library first, then owned, then followed; preserve API order within.
sectionOrder := map[string]int{
"Library": 0,
"Your playlists": 1,
"Followed playlists": 2,
}
sort.SliceStable(all, func(i, j int) bool {
return sectionOrder[all[i].Section] < sectionOrder[all[j].Section]
})
p.mu.Lock()
p.listCache = all
p.listCacheAt = time.Now()
p.mu.Unlock()
return slices.Clone(all), nil
}
// Tracks returns all tracks for the given Spotify playlist ID.
// Track.Path is set to the canonical spotify: URI for the player to resolve.
// Results are cached by snapshot_id; unchanged playlists skip the API call.
func (p *SpotifyProvider) Tracks(playlistID string) ([]playlist.Track, error) {
if err := p.ensureSession(); err != nil {
return nil, err
}
// Check cache — if we have tracks and the snapshot_id hasn't changed, return cached.
p.mu.Lock()
if cached, ok := p.trackCache[playlistID]; ok && cached.tracks != nil {
tracks := slices.Clone(cached.tracks)
p.mu.Unlock()
return tracks, nil
}
p.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
var all []playlist.Track
offset := 0
limit := spotifyTrackPageSize
for {
var (
resp *http.Response
err error
)
if playlistID == "YOUR MUSIC" {
query := url.Values{
"limit": {fmt.Sprintf("%d", limit)},
"offset": {fmt.Sprintf("%d", offset)},
}
resp, err = p.webAPI(ctx, "GET", "/v1/me/tracks", query)
} else {
query := url.Values{
"limit": {fmt.Sprintf("%d", limit)},
"offset": {fmt.Sprintf("%d", offset)},
"fields": {"items(item(id,name,type,uri,artists(name),album(name,release_date),show(name),release_date,duration_ms,track_number,is_playable,restrictions(reason))),total"},
}
path := fmt.Sprintf("/v1/playlists/%s/items", playlistID)
resp, err = p.webAPI(ctx, "GET", path, query)
}
if err != nil {
return nil, fmt.Errorf("spotify: list tracks: %w", err)
}
var result struct {
Items []struct {
Item *spotifyItem `json:"item"`
Track *spotifyItem `json:"track"`
} `json:"items"`
Total int `json:"total"`
}
if err := decodeBody(resp, &result); err != nil {
return nil, fmt.Errorf("spotify: parse tracks: %w", err)
}
for _, item := range result.Items {
t := item.Item
if t == nil {
t = item.Track
}
if t == nil || t.ID == "" {
continue // skip local/unavailable tracks
}
all = append(all, trackFromItem(t))
}
if offset+limit >= result.Total {
break
}
offset += limit
}
// Cache the fetched tracks.
p.mu.Lock()
if cached, ok := p.trackCache[playlistID]; ok {
cached.tracks = all
} else {
p.trackCache[playlistID] = &playlistCache{tracks: all}
}
p.mu.Unlock()
return slices.Clone(all), nil
}
// isAuthError returns true if the error is an authentication/session-related
// failure that can be resolved by re-authenticating.
func isAuthError(err error) bool {
if err == nil {
return false
}
// context.DeadlineExceeded and context.Canceled are NOT auth errors.
// They commonly fire during rapid track skipping when a previous NewStream's
// network fetch is interrupted, and previously caused spurious re-auth
// attempts (which then escalated to opening a browser tab mid-skip).
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return false
}
var keyErr *audio.KeyProviderError
return errors.As(err, &keyErr)
}
// URISchemes returns the URI prefixes handled by this provider.
// Implements provider.CustomStreamer.
func (p *SpotifyProvider) URISchemes() []string { return []string{"spotify:"} }
// NewStreamer creates a SpotifyStreamer for the given spotify: URI (track or
// episode).
// If the stream fails due to an auth error (e.g. expired session, AES key
// rejection), the player tries a silent reconnect from cached credentials.
// If that fails — or the retry still hits an auth error — the streamer
// surfaces playlist.ErrNeedsAuth so the UI can prompt the user to sign in.
// We deliberately do NOT auto-launch a browser-based OAuth flow from this
// path: rapid track skipping can produce transient stream errors and a
// browser tab popping up mid-skip.
//
// Implements provider.CustomStreamer.
func (p *SpotifyProvider) NewStreamer(uri string) (beep.StreamSeekCloser, beep.Format, time.Duration, error) {
if err := p.ensureSession(); err != nil {
return nil, beep.Format{}, 0, err
}
spotID, err := librespot.SpotifyIdFromUri(uri)
if err != nil {
return nil, beep.Format{}, 0, fmt.Errorf("spotify: invalid URI %q: %w", uri, err)
}
tryStream := func() (*spotifyStreamer, error) {
ctx, setupCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer setupCancel()
stream, streamCancel, err := p.session.NewStream(ctx, *spotID, p.bitrate)
if err != nil {
return nil, err
}
return newSpotifyStreamer(stream, streamCancel), nil
}
s, err := tryStream()
if err == nil {
return s, s.Format(), s.Duration(), nil
}
if !isAuthError(err) {
return nil, beep.Format{}, 0, fmt.Errorf("spotify: new stream: %w", err)
}
// Auth error — try a silent reconnect from cached credentials.
applog.UserWarn("spotify: stream auth error (%v), attempting silent reconnect...", err)
reconnCtx, reconnCancel := context.WithTimeout(context.Background(), 30*time.Second)
reconnErr := p.session.Reconnect(reconnCtx)
reconnCancel()
if reconnErr != nil {
applog.UserWarn("spotify: silent reconnect failed (%v); sign-in required", reconnErr)
return nil, beep.Format{}, 0, fmt.Errorf("spotify: stream auth error, silent reconnect failed: %w", playlist.ErrNeedsAuth)
}
s, err = tryStream()
if err == nil {
return s, s.Format(), s.Duration(), nil
}
if !isAuthError(err) {
return nil, beep.Format{}, 0, fmt.Errorf("spotify: new stream after silent reconnect: %w", err)
}
// Still failing after a silent reconnect — surface ErrNeedsAuth so the
// UI can prompt the user to sign in. Do NOT open a browser from here.
applog.UserWarn("spotify: stream still failing after silent reconnect (%v); sign-in required", err)
return nil, beep.Format{}, 0, fmt.Errorf("spotify: stream auth error after silent reconnect: %w", playlist.ErrNeedsAuth)
}
// webAPI calls the Spotify Web API via the session with retry on 429.
func (p *SpotifyProvider) webAPI(ctx context.Context, method, path string, query url.Values) (*http.Response, error) {
return p.webAPIWithBody(ctx, method, path, query, nil, "", http.StatusOK)
}
// webAPIWithBody is like webAPI but accepts an optional request body, content type,
// and a set of acceptable HTTP status codes (e.g. 200, 201). Retries 429 with
// exponential backoff (honoring Retry-After when present).
func (p *SpotifyProvider) webAPIWithBody(ctx context.Context, method, path string, query url.Values, body io.Reader, contentType string, acceptStatus ...int) (*http.Response, error) {
const maxRetries = 8
// Buffer the body so it can be replayed on retry.
var bodyBytes []byte
if body != nil {
var err error
bodyBytes, err = io.ReadAll(body)
if err != nil {
return nil, fmt.Errorf("read request body: %w", err)
}
}
for attempt := range maxRetries {
var reqBody io.Reader
if bodyBytes != nil {
reqBody = bytes.NewReader(bodyBytes)
}
resp, err := p.session.webApiWithBody(ctx, method, path, query, reqBody, contentType)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close()
// On the last attempt there's no retry after the wait, so don't
// sleep (up to 128s) just to give up; fail now.
if attempt == maxRetries-1 {
break
}
wait := time.Duration(1<<uint(attempt)) * time.Second
if ra := resp.Header.Get("Retry-After"); ra != "" {
if secs, err := strconv.Atoi(ra); err == nil && secs > 0 {
wait = time.Duration(secs) * time.Second
}
}
applog.UserWarn("spotify: web api rate-limited on %s, retrying in %v (attempt %d/%d)", path, wait, attempt+1, maxRetries)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
continue
}
}
ok := slices.Contains(acceptStatus, resp.StatusCode)
if !ok {
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 512))
resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("http status %s (failed to read body: %v)", resp.Status, readErr)
}
return nil, fmt.Errorf("http status %s: %s", resp.Status, string(respBody))
}
return resp, nil
}
return nil, fmt.Errorf("spotify: web api rate-limited on %s after %d retries (try re-authenticating)", path, maxRetries)
}
// devModeSearchLimit is the largest per-request limit /v1/search accepts for an
// app still in Spotify's Development Mode. Anything above it comes back as
// 400 "Invalid limit", which reads like a bug in the value we picked but is
// simply the cap. Measured against a Development Mode app: 10 succeeds, 11 does
// not, and offset paging past the cap works fine.
const devModeSearchLimit = 10
// isInvalidLimit reports whether err is Spotify's 400 "Invalid limit" reply,
// i.e. the app is capped at devModeSearchLimit results per search request.
func isInvalidLimit(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "400") && strings.Contains(msg, "Invalid limit")
}
// friendlySearchError turns a failed /v1/search into something actionable.
// A surviving "Invalid limit" means the cap moved below devModeSearchLimit,
// since SearchTracks already retries in pages of that size.
func friendlySearchError(err error) error {
if err == nil {
return nil
}
if isInvalidLimit(err) {
return fmt.Errorf("spotify: search rejected even a limit of %d; Spotify's cap for Development Mode apps appears to have changed (%w)", devModeSearchLimit, err)
}
return fmt.Errorf("spotify: search: %w", err)
}
// spotifySearchPage is one page of /v1/search results.
type spotifySearchPage struct {
Tracks struct {
Items []*spotifyItem `json:"items"`
} `json:"tracks"`
Episodes struct {
Items []*spotifyItem `json:"items"`
} `json:"episodes"`
}
// searchPage runs a single /v1/search request.
//
// No market parameter: when the request carries a user OAuth token, Spotify
// implicitly scopes results to the account's country.
func (p *SpotifyProvider) searchPage(ctx context.Context, query string, limit, offset int) (*spotifySearchPage, error) {
q := url.Values{
"q": {query},
"type": {"track,episode"},
"limit": {strconv.Itoa(limit)},
}
if offset > 0 {
q.Set("offset", strconv.Itoa(offset))
}
resp, err := p.webAPI(ctx, "GET", "/v1/search", q)
if err != nil {
return nil, err
}
var page spotifySearchPage
if err := decodeBody(resp, &page); err != nil {
return nil, fmt.Errorf("spotify: parse search: %w", err)
}
return &page, nil
}
// searchPaged collects up to limit results in pages of devModeSearchLimit, for
// apps that cannot ask for more in one go. Any page failure aborts the search so
// callers never mistake partial results for a complete response.
func (p *SpotifyProvider) searchPaged(ctx context.Context, query string, limit int) (*spotifySearchPage, error) {
combined := &spotifySearchPage{}
for offset := 0; offset < limit; offset += devModeSearchLimit {
size := min(devModeSearchLimit, limit-offset)
page, err := p.searchPage(ctx, query, size, offset)
if err != nil {
return nil, fmt.Errorf("page at offset %d: %w", offset, err)
}
combined.Tracks.Items = append(combined.Tracks.Items, page.Tracks.Items...)
combined.Episodes.Items = append(combined.Episodes.Items, page.Episodes.Items...)
// Both result kinds exhausted, so further pages are empty.
if len(page.Tracks.Items) < size && len(page.Episodes.Items) < size {
break
}
}
return combined, nil
}
// SearchTracks searches Spotify for tracks and podcast episodes, returning up
// to limit results of each. Episodes (e.g. podcasts) are routed through their
// spotify:episode: URI so they play correctly.
// limit is clamped to Spotify's accepted range of 1..50.
//
// Apps in Development Mode cap /v1/search at devModeSearchLimit results per
// request, so a rejected limit is retried as several smaller pages instead of
// being reported as a blocked search.
func (p *SpotifyProvider) SearchTracks(ctx context.Context, query string, limit int) ([]playlist.Track, error) {
if err := p.ensureSession(); err != nil {
return nil, err
}
if limit < 1 {
limit = 1
} else if limit > 50 {
limit = 50
}
// Try the whole thing in one request first: an app with Extended Quota Mode
// takes any limit up to 50 and needs no paging.
result, err := p.searchPage(ctx, query, limit, 0)
if err != nil && isInvalidLimit(err) && limit > devModeSearchLimit {
result, err = p.searchPaged(ctx, query, limit)
}
if err != nil {
return nil, friendlySearchError(err)
}
var tracks []playlist.Track
for _, items := range [][]*spotifyItem{result.Tracks.Items, result.Episodes.Items} {
for _, t := range items {
if t == nil || t.ID == "" {
continue // skip null/unavailable results
}
tracks = append(tracks, trackFromItem(t))
}
}
return tracks, nil
}
// AddTrackToPlaylist adds a track to an existing Spotify playlist.
// The track's Path is used as the Spotify URI (e.g. "spotify:track:..." or
// "spotify:episode:..."); the Spotify API accepts either.
// Implements provider.PlaylistWriter.
func (p *SpotifyProvider) AddTrackToPlaylist(ctx context.Context, playlistID string, track playlist.Track) error {
trackURI := track.Path
if err := p.ensureSession(); err != nil {
return err
}
body, _ := json.Marshal(map[string]any{"uris": []string{trackURI}})
path := fmt.Sprintf("/v1/playlists/%s/tracks", playlistID)
resp, err := p.webAPIWithBody(ctx, "POST", path, nil, bytes.NewReader(body), "application/json", http.StatusOK, http.StatusCreated)
if err != nil {
return fmt.Errorf("spotify: add track: %w", err)
}
resp.Body.Close()
// Invalidate caches for this playlist.
p.mu.Lock()
delete(p.trackCache, playlistID)
p.listCache = nil
p.mu.Unlock()
return nil
}
// CreatePlaylist creates a new private Spotify playlist and returns its ID.
func (p *SpotifyProvider) CreatePlaylist(ctx context.Context, name string) (string, error) {
if err := p.ensureSession(); err != nil {
return "", err
}
userID := p.currentUserID(ctx)
if userID == "" {
return "", fmt.Errorf("spotify: could not determine user ID")
}
body, _ := json.Marshal(map[string]any{"name": name, "public": false})
path := fmt.Sprintf("/v1/users/%s/playlists", userID)
resp, err := p.webAPIWithBody(ctx, "POST", path, nil, bytes.NewReader(body), "application/json", http.StatusOK, http.StatusCreated)
if err != nil {
return "", fmt.Errorf("spotify: create playlist: %w", err)
}
var result struct {
ID string `json:"id"`
}
if err := decodeBody(resp, &result); err != nil {
return "", fmt.Errorf("spotify: parse created playlist: %w", err)
}
// Invalidate playlist list cache.
p.mu.Lock()
p.listCache = nil
p.mu.Unlock()
return result.ID, nil
}
// decodeBody reads and decodes a JSON response body, then closes it.
func decodeBody(resp *http.Response, v any) error {
defer resp.Body.Close()
return json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(v)
}