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
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.1.0"
".": "0.2.0"
}
8 changes: 4 additions & 4 deletions .stats.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
configured_endpoints: 89
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/stainless-commons%2Fspotify-139a4fc2ce0b9db4ceab46e2f76e0700a847f4e65d155f6aa7829d9753a30daf.yml
openapi_spec_hash: e96ace8fb2e27de2e776bd575bcf93a1
config_hash: f9595b42469865c2e89f2922b389ff04
configured_endpoints: 97
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/stainless-commons%2Fspotify-7ac9fe2ee73e38b2892f0393435f2d3a275d04b1d0728708382dd752da1d44de.yml
openapi_spec_hash: 6be3d4faa079ee82335208bec39c917a
config_hash: 656921a0de616cc4f5d5c0de5c5a64e7
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## 0.2.0 (2026-02-17)

Full Changelog: [v0.1.0...v0.2.0](https://github.com/stainless-commons/spotify-typescript/compare/v0.1.0...v0.2.0)

### Features

* **api:** manual updates ([186fb31](https://github.com/stainless-commons/spotify-typescript/commit/186fb31ff5953da67c9bdd793dc854bc90990b01))
* **api:** manual updates ([a147d29](https://github.com/stainless-commons/spotify-typescript/commit/a147d29e80f471cf69736dbc95c18665f1e6fc58))
* **api:** manual updates ([cef5db7](https://github.com/stainless-commons/spotify-typescript/commit/cef5db7f206aad549423261e53e9f5681d88ce54))

## 0.1.0 (2026-02-16)

Full Changelog: [v0.0.1...v0.1.0](https://github.com/stainless-commons/spotify-typescript/compare/v0.0.1...v0.1.0)
Expand Down
10 changes: 3 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

This library provides convenient access to the Spotify REST API from server-side TypeScript or JavaScript.

The REST API documentation can be found on [spotify.cjav.dev](https://spotify.cjav.dev). The full API of this library can be found in [api.md](api.md).
The REST API documentation can be found on [stainless.com](https://stainless.com). The full API of this library can be found in [api.md](api.md).

It is generated with [Stainless](https://www.stainless.com/).

Expand All @@ -25,9 +25,7 @@ The full API of this library can be found in [api.md](api.md).
```js
import Spotify from '@stainless-commons/spotify';

const client = new Spotify({
accessToken: process.env['SPOTIFY_ACCESS_TOKEN'], // This is the default and can be omitted
});
const client = new Spotify();

const album = await client.albums.retrieve('4aawyAB9vmqN3uQ7FjRGTy');

Expand All @@ -42,9 +40,7 @@ This library includes TypeScript definitions for all request params and response
```ts
import Spotify from '@stainless-commons/spotify';

const client = new Spotify({
accessToken: process.env['SPOTIFY_ACCESS_TOKEN'], // This is the default and can be omitted
});
const client = new Spotify();

const album: Spotify.AlbumRetrieveResponse = await client.albums.retrieve('4aawyAB9vmqN3uQ7FjRGTy');
```
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ or products provided by Spotify, please follow the respective company's security

### Spotify Terms and Policies

Please contact wave@cjav.dev for any questions or concerns regarding the security of our services.
Please contact commons@stainless.com for any questions or concerns regarding the security of our services.

---

Expand Down
32 changes: 32 additions & 0 deletions api.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,13 @@ Methods:

## Playlists

Types:

- <code><a href="./src/resources/me/playlists.ts">PlaylistCreateResponse</a></code>

Methods:

- <code title="post /me/playlists">client.me.playlists.<a href="./src/resources/me/playlists.ts">create</a>({ ...params }) -> PlaylistCreateResponse</code>
- <code title="get /me/playlists">client.me.playlists.<a href="./src/resources/me/playlists.ts">list</a>({ ...params }) -> SimplifiedPlaylistObjectsCursorURLPage</code>

## Top
Expand Down Expand Up @@ -242,6 +247,18 @@ Methods:
- <code title="post /me/player/queue">client.me.player.queue.<a href="./src/resources/me/player/queue.ts">add</a>({ ...params }) -> void</code>
- <code title="get /me/player/queue">client.me.player.queue.<a href="./src/resources/me/player/queue.ts">get</a>() -> QueueGetResponse</code>

## Library

Types:

- <code><a href="./src/resources/me/library.ts">LibraryCheckSavedItemsResponse</a></code>

Methods:

- <code title="get /me/library/contains">client.me.library.<a href="./src/resources/me/library.ts">checkSavedItems</a>({ ...params }) -> LibraryCheckSavedItemsResponse</code>
- <code title="delete /me/library">client.me.library.<a href="./src/resources/me/library.ts">removeItems</a>({ ...params }) -> void</code>
- <code title="put /me/library">client.me.library.<a href="./src/resources/me/library.ts">saveItems</a>({ ...params }) -> void</code>

# Chapters

Types:
Expand Down Expand Up @@ -324,6 +341,21 @@ Methods:
- <code title="put /playlists/{playlist_id}/images">client.playlists.images.<a href="./src/resources/playlists/images.ts">update</a>(playlistID, body) -> Response</code>
- <code title="get /playlists/{playlist_id}/images">client.playlists.images.<a href="./src/resources/playlists/images.ts">list</a>(playlistID) -> ImageListResponse</code>

## Items

Types:

- <code><a href="./src/resources/playlists/items.ts">ItemUpdateResponse</a></code>
- <code><a href="./src/resources/playlists/items.ts">ItemAddResponse</a></code>
- <code><a href="./src/resources/playlists/items.ts">ItemRemoveResponse</a></code>

Methods:

- <code title="put /playlists/{playlist_id}/items">client.playlists.items.<a href="./src/resources/playlists/items.ts">update</a>(playlistID, { ...params }) -> ItemUpdateResponse</code>
- <code title="get /playlists/{playlist_id}/items">client.playlists.items.<a href="./src/resources/playlists/items.ts">list</a>(playlistID, { ...params }) -> PlaylistTrackObjectsCursorURLPage</code>
- <code title="post /playlists/{playlist_id}/items">client.playlists.items.<a href="./src/resources/playlists/items.ts">add</a>(playlistID, { ...params }) -> ItemAddResponse</code>
- <code title="delete /playlists/{playlist_id}/items">client.playlists.items.<a href="./src/resources/playlists/items.ts">remove</a>(playlistID, { ...params }) -> ItemRemoveResponse</code>

# Users

Types:
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"name": "@stainless-commons/spotify",
"version": "0.1.0",
"version": "0.2.0",
"description": "The official TypeScript library for the Spotify API",
"author": "Spotify <wave@cjav.dev>",
"author": "Spotify <commons@stainless.com>",
"types": "dist/index.d.ts",
"main": "dist/index.js",
"type": "commonjs",
Expand Down
108 changes: 93 additions & 15 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ import { UserRetrieveProfileResponse, Users } from './resources/users/users';
import { type Fetch } from './internal/builtin-types';
import { HeadersLike, NullableHeaders, buildHeaders } from './internal/headers';
import { FinalRequestOptions, RequestOptions } from './internal/request-options';
import { toBase64 } from './internal/utils/base64';
import { readEnv } from './internal/utils/env';
import {
type LogLevel,
Expand All @@ -120,9 +121,14 @@ import { isEmptyObj } from './internal/utils/values';

export interface ClientOptions {
/**
* Defaults to process.env['SPOTIFY_ACCESS_TOKEN'].
* Defaults to process.env['SPOTIFY_CLIENT_ID'].
*/
accessToken?: string | undefined;
clientID?: string | null | undefined;

/**
* Defaults to process.env['SPOTIFY_CLIENT_SECRET'].
*/
clientSecret?: string | null | undefined;

/**
* Override the default base URL for the API, e.g., "https://api.example.com/v2/"
Expand Down Expand Up @@ -197,7 +203,8 @@ export interface ClientOptions {
* API Client for interfacing with the Spotify API.
*/
export class Spotify {
accessToken: string;
clientID: string | null;
clientSecret: string | null;

baseURL: string;
maxRetries: number;
Expand All @@ -214,7 +221,8 @@ export class Spotify {
/**
* API Client for interfacing with the Spotify API.
*
* @param {string | undefined} [opts.accessToken=process.env['SPOTIFY_ACCESS_TOKEN'] ?? undefined]
* @param {string | null | undefined} [opts.clientID=process.env['SPOTIFY_CLIENT_ID'] ?? null]
* @param {string | null | undefined} [opts.clientSecret=process.env['SPOTIFY_CLIENT_SECRET'] ?? null]
* @param {string} [opts.baseURL=process.env['SPOTIFY_BASE_URL'] ?? https://api.spotify.com/v1] - Override the default base URL for the API.
* @param {number} [opts.timeout=1 minute] - The maximum amount of time (in milliseconds) the client will wait for a response before timing out.
* @param {MergedRequestInit} [opts.fetchOptions] - Additional `RequestInit` options to be passed to `fetch` calls.
Expand All @@ -225,17 +233,13 @@ export class Spotify {
*/
constructor({
baseURL = readEnv('SPOTIFY_BASE_URL'),
accessToken = readEnv('SPOTIFY_ACCESS_TOKEN'),
clientID = readEnv('SPOTIFY_CLIENT_ID') ?? null,
clientSecret = readEnv('SPOTIFY_CLIENT_SECRET') ?? null,
...opts
}: ClientOptions = {}) {
if (accessToken === undefined) {
throw new Errors.SpotifyError(
"The SPOTIFY_ACCESS_TOKEN environment variable is missing or empty; either provide it, or instantiate the Spotify client with an accessToken option, like new Spotify({ accessToken: 'My Access Token' }).",
);
}

const options: ClientOptions = {
accessToken,
clientID,
clientSecret,
...opts,
baseURL: baseURL || `https://api.spotify.com/v1`,
};
Expand All @@ -257,7 +261,8 @@ export class Spotify {

this._options = options;

this.accessToken = accessToken;
this.clientID = clientID;
this.clientSecret = clientSecret;
}

/**
Expand All @@ -273,9 +278,11 @@ export class Spotify {
logLevel: this.logLevel,
fetch: this.fetch,
fetchOptions: this.fetchOptions,
accessToken: this.accessToken,
clientID: this.clientID,
clientSecret: this.clientSecret,
...options,
});
client.oauth2_0AuthState = this.oauth2_0AuthState;
return client;
}

Expand All @@ -294,8 +301,72 @@ export class Spotify {
return;
}

private oauth2_0AuthState:
| {
promise: Promise<{
access_token: string;
token_type: string;
expires_in: number;
expires_at: Date;
refresh_token?: string;
}>;
clientID: string;
clientSecret: string;
}
| undefined;
protected async authHeaders(opts: FinalRequestOptions): Promise<NullableHeaders | undefined> {
return buildHeaders([{ Authorization: `Bearer ${this.accessToken}` }]);
if (!this.clientID || !this.clientSecret) {
return undefined;
}

// Invalidate the cache if the token is expired
if (this.oauth2_0AuthState && +(await this.oauth2_0AuthState.promise).expires_at < Date.now()) {
this.oauth2_0AuthState = undefined;
}

// Invalidate the cache if the relevant state has been changed
if (
this.oauth2_0AuthState &&
this.oauth2_0AuthState.clientID !== this.clientID &&
this.oauth2_0AuthState.clientSecret !== this.clientSecret
) {
this.oauth2_0AuthState = undefined;
}

if (!this.oauth2_0AuthState) {
this.oauth2_0AuthState = {
promise: this.fetch(this.buildURL('https://accounts.spotify.com/api/token', {}), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${toBase64(`${this.clientID}:${this.clientSecret}`)}`,
},
body: 'grant_type=client_credentials',
}).then(async (res) => {
if (!res.ok) {
const errText = await res.text().catch(() => '');
const errJSON = errText ? safeJSON(errText) : undefined;
const errMessage = errJSON ? undefined : errText;
throw this.makeStatusError(res.status, errJSON, errMessage, res.headers);
}
const json = (await res.json()) as {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
};
const now = new Date();
now.setSeconds(now.getSeconds() + json.expires_in);
return { ...json, expires_at: now };
}),
clientID: this.clientID,
clientSecret: this.clientSecret,
};
}

const token = await this.oauth2_0AuthState.promise;

return buildHeaders([{ Authorization: `Bearer ${token.access_token}` }]);
}

protected stringifyQuery(query: Record<string, unknown>): string {
Expand Down Expand Up @@ -622,6 +693,13 @@ export class Spotify {
if (shouldRetryHeader === 'true') return true;
if (shouldRetryHeader === 'false') return false;

// Retry if the token has expired
const oauth2_0Auth = await this.oauth2_0AuthState?.promise;
if (response.status === 401 && oauth2_0Auth && +oauth2_0Auth.expires_at - Date.now() < 10 * 1000) {
this.oauth2_0AuthState = undefined;
return true;
}

// Retry on request timeouts.
if (response.status === 408) return true;

Expand Down
22 changes: 12 additions & 10 deletions src/resources/albums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export class Albums extends APIResource {
/**
* Get Spotify catalog information for multiple albums identified by their Spotify
* IDs.
*
* @deprecated
*/
bulkRetrieve(
query: AlbumBulkRetrieveParams,
Expand Down Expand Up @@ -60,7 +62,7 @@ export interface AlbumRetrieveResponse {
album_type: 'album' | 'single' | 'compilation';

/**
* The markets in which the album is available:
* @deprecated The markets in which the album is available:
* [ISO 3166-1 alpha-2 country codes](http://en.wikipedia.org/wiki/ISO_3166-1_alpha-2).
* _**NOTE**: an album is considered available in a market when at least 1 of its
* tracks is available in that market._
Expand Down Expand Up @@ -126,7 +128,7 @@ export interface AlbumRetrieveResponse {
copyrights?: Array<Shared.CopyrightObject>;

/**
* Known external IDs for the album.
* @deprecated Known external IDs for the album.
*/
external_ids?: Shared.ExternalIDObject;

Expand All @@ -136,13 +138,13 @@ export interface AlbumRetrieveResponse {
genres?: Array<string>;

/**
* The label associated with the album.
* @deprecated The label associated with the album.
*/
label?: string;

/**
* The popularity of the album. The value will be between 0 and 100, with 100 being
* the most popular.
* @deprecated The popularity of the album. The value will be between 0 and 100,
* with 100 being the most popular.
*/
popularity?: number;

Expand Down Expand Up @@ -232,7 +234,7 @@ export namespace AlbumBulkRetrieveResponse {
album_type: 'album' | 'single' | 'compilation';

/**
* The markets in which the album is available:
* @deprecated The markets in which the album is available:
* [ISO 3166-1 alpha-2 country codes](http://en.wikipedia.org/wiki/ISO_3166-1_alpha-2).
* _**NOTE**: an album is considered available in a market when at least 1 of its
* tracks is available in that market._
Expand Down Expand Up @@ -298,7 +300,7 @@ export namespace AlbumBulkRetrieveResponse {
copyrights?: Array<Shared.CopyrightObject>;

/**
* Known external IDs for the album.
* @deprecated Known external IDs for the album.
*/
external_ids?: Shared.ExternalIDObject;

Expand All @@ -308,13 +310,13 @@ export namespace AlbumBulkRetrieveResponse {
genres?: Array<string>;

/**
* The label associated with the album.
* @deprecated The label associated with the album.
*/
label?: string;

/**
* The popularity of the album. The value will be between 0 and 100, with 100 being
* the most popular.
* @deprecated The popularity of the album. The value will be between 0 and 100,
* with 100 being the most popular.
*/
popularity?: number;

Expand Down
Loading
Loading