@benliam12/spotify-api-helper 1.0.0-DEV.6 → 1.0.0-DEV.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -1
- package/dist/core/SpotifyConfiguration.js +4 -4
- package/dist/core/SpotifyHelper.d.ts +40 -3
- package/dist/core/SpotifyHelper.js +276 -38
- package/dist/core/SpotifyTypes.d.ts +28 -0
- package/dist/core/SpotifyTypes.js +30 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,2 +1,48 @@
|
|
|
1
1
|
# Spotify API Helper
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
Help you use the Spotify API without having to implement everything.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
1. Install this helper using `npm install @benliam12/spotify-api-helper`
|
|
8
|
+
2. Add the helper to your project.
|
|
9
|
+
|
|
10
|
+
Here is a quick example using NodeJS, and NodeJS, using Module type project.
|
|
11
|
+
|
|
12
|
+
```javascript
|
|
13
|
+
import express from 'express';
|
|
14
|
+
import dotenv from 'dotenv';
|
|
15
|
+
import {
|
|
16
|
+
SpotifyHelper,
|
|
17
|
+
SpotifyConfiguration,
|
|
18
|
+
} from '@benliam12/spotify-api-helper';
|
|
19
|
+
|
|
20
|
+
dotenv.config();
|
|
21
|
+
|
|
22
|
+
const spotifyConfig = new SpotifyConfiguration({
|
|
23
|
+
clientId: process.env.SPOTIFY_CLIENT_ID,
|
|
24
|
+
clientSecret: process.env.SPOTIFY_CLIENT_SECRET,
|
|
25
|
+
redirectUri: process.env.SPOTIFY_REDIRECT_URI,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const spotifyHelperInstance = new SpotifyHelper(spotifyConfig, {
|
|
29
|
+
onError: (error) => {
|
|
30
|
+
console.error('Spotify API Error:', error);
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
app.get('/', async (req, res) => {
|
|
35
|
+
const trackData = await instance.getTrack('YOUR FAVORITE TRACK ID');
|
|
36
|
+
res.send('Hello World!');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
instance.initialize().then(() => {
|
|
40
|
+
app.listen(port, () => {
|
|
41
|
+
console.log(`Example app listening at http://localhost:${port}`);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Contribution
|
|
47
|
+
|
|
48
|
+
Please open issues for any feature request or bug report. As for contributing directly to the project, feel free to make pull requests.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
export class SpotifyConfiguration {
|
|
2
2
|
constructor(config) {
|
|
3
|
-
this.clientId = config.clientId ??
|
|
4
|
-
this.clientSecret = config.clientSecret ??
|
|
5
|
-
this.redirectUri = config.redirectUri ??
|
|
3
|
+
this.clientId = config.clientId ?? '';
|
|
4
|
+
this.clientSecret = config.clientSecret ?? '';
|
|
5
|
+
this.redirectUri = config.redirectUri ?? '';
|
|
6
6
|
}
|
|
7
7
|
isValid() {
|
|
8
|
-
return this.clientId !==
|
|
8
|
+
return this.clientId !== '' && this.clientSecret !== '' && this.redirectUri !== '';
|
|
9
9
|
}
|
|
10
10
|
getClientID() {
|
|
11
11
|
return this.clientId;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { SpotifyConfiguration } from
|
|
2
|
-
import { Album, Track, Artist, SearchType } from
|
|
1
|
+
import { SpotifyConfiguration } from './SpotifyConfiguration.js';
|
|
2
|
+
import { Album, Track, Artist, UserToken, SearchType, SpotifyScope } from './SpotifyTypes.js';
|
|
3
3
|
/**
|
|
4
4
|
* Result wrapper for operations that can fail
|
|
5
5
|
*/
|
|
@@ -75,7 +75,14 @@ export declare class SpotifyHelper {
|
|
|
75
75
|
* Returns null if the request fails.
|
|
76
76
|
*/
|
|
77
77
|
getAlbum(albumId: string): Promise<Album | null>;
|
|
78
|
+
/**
|
|
79
|
+
* Get a list of albums by their IDs.
|
|
80
|
+
* @param albumIds
|
|
81
|
+
* @returns
|
|
82
|
+
* @throws Error if more than 20 IDs are provided (Spotify API limit)
|
|
83
|
+
*/
|
|
78
84
|
getAlbums(albumIds: string[]): Promise<(Album | null)[]>;
|
|
85
|
+
getAlbumTracks(albumId: string, limit?: number, offset?: number): Promise<Track[] | null>;
|
|
79
86
|
/**
|
|
80
87
|
* Get a track by ID.
|
|
81
88
|
* Returns null if the request fails.
|
|
@@ -93,10 +100,40 @@ export declare class SpotifyHelper {
|
|
|
93
100
|
getPlaylist(playlistId: string): Promise<any | null>;
|
|
94
101
|
/**
|
|
95
102
|
* Get available markets.
|
|
96
|
-
* Returns empty array if the request fails.
|
|
103
|
+
* @returns Returns empty array if the request fails.
|
|
97
104
|
*/
|
|
98
105
|
getAvailableMarkets(): Promise<string[]>;
|
|
106
|
+
/**
|
|
107
|
+
* Use the search endpoint.
|
|
108
|
+
* @param query
|
|
109
|
+
* @param type
|
|
110
|
+
* @param limit
|
|
111
|
+
* @param offset
|
|
112
|
+
* @returns Parsed search results or null if request fails
|
|
113
|
+
*/
|
|
99
114
|
search(query: string, type: SearchType, limit?: number, offset?: number): Promise<any | null>;
|
|
115
|
+
/** Builds the Spotify authorization URL for user login. */
|
|
116
|
+
getAuthorizationUrl(scopes: SpotifyScope[], state?: string): string;
|
|
117
|
+
/** Exchanges an authorization code for a UserToken. */
|
|
118
|
+
exchangeAuthCode(code: string): Promise<UserToken | null>;
|
|
119
|
+
/** Refreshes an expired user token using its refresh token. */
|
|
120
|
+
refreshUserToken(refreshToken: string): Promise<UserToken | null>;
|
|
121
|
+
/** Makes an API request using a user-provided token. */
|
|
122
|
+
private makeUserRequest;
|
|
123
|
+
/** Get the current user's profile. Requires `user-read-private` scope. */
|
|
124
|
+
getCurrentUserProfile(userToken: UserToken): Promise<any | null>;
|
|
125
|
+
/** Get the current user's playlists. Requires `playlist-read-private` scope. */
|
|
126
|
+
getUserPlaylists(userToken: UserToken, limit?: number, offset?: number): Promise<any | null>;
|
|
127
|
+
/** Get the current user's top artists or tracks. Requires `user-top-read` scope. */
|
|
128
|
+
getUserTopItems(userToken: UserToken, type: 'artists' | 'tracks', limit?: number, offset?: number): Promise<any | null>;
|
|
129
|
+
/** Get the current user's saved tracks. Requires `user-library-read` scope. */
|
|
130
|
+
getUserSavedTracks(userToken: UserToken, limit?: number, offset?: number): Promise<any | null>;
|
|
131
|
+
/**
|
|
132
|
+
* Make Raw request to a given Spotify API URL.
|
|
133
|
+
* @param url
|
|
134
|
+
* @returns Parsed JSON data or null if request fails
|
|
135
|
+
*/
|
|
136
|
+
getDataFromURL(url: string): Promise<any | null>;
|
|
100
137
|
/**
|
|
101
138
|
* Check if the helper is properly authenticated.
|
|
102
139
|
* Useful for health checks.
|
|
@@ -6,7 +6,7 @@ export class SpotifyHelper {
|
|
|
6
6
|
this.options = {
|
|
7
7
|
tokenRefreshBufferMs: options.tokenRefreshBufferMs ?? 60000,
|
|
8
8
|
onError: options.onError ?? (() => { }),
|
|
9
|
-
logErrors: options.logErrors ?? false
|
|
9
|
+
logErrors: options.logErrors ?? false,
|
|
10
10
|
};
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
@@ -53,7 +53,7 @@ export class SpotifyHelper {
|
|
|
53
53
|
const now = new Date();
|
|
54
54
|
const expiryTime = this.clientToken.expires_at.getTime();
|
|
55
55
|
const currentTime = now.getTime();
|
|
56
|
-
return currentTime >=
|
|
56
|
+
return currentTime >= expiryTime - this.options.tokenRefreshBufferMs;
|
|
57
57
|
}
|
|
58
58
|
/**
|
|
59
59
|
* Fetches a new token from Spotify.
|
|
@@ -63,45 +63,45 @@ export class SpotifyHelper {
|
|
|
63
63
|
const params = new URLSearchParams();
|
|
64
64
|
params.append('grant_type', 'client_credentials');
|
|
65
65
|
try {
|
|
66
|
-
const response = await fetch(
|
|
67
|
-
method:
|
|
66
|
+
const response = await fetch('https://accounts.spotify.com/api/token', {
|
|
67
|
+
method: 'POST',
|
|
68
68
|
headers: {
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
70
|
+
Authorization: 'Basic ' + Buffer.from(this.config.clientId + ':' + this.config.clientSecret).toString('base64'),
|
|
71
71
|
},
|
|
72
|
-
body: params.toString()
|
|
72
|
+
body: params.toString(),
|
|
73
73
|
});
|
|
74
74
|
if (!response.ok) {
|
|
75
75
|
const errorText = await response.text();
|
|
76
76
|
this.handleError({
|
|
77
77
|
message: `Failed to get token: ${response.status} ${response.statusText} - ${errorText}`,
|
|
78
78
|
statusCode: response.status,
|
|
79
|
-
operation:
|
|
80
|
-
timestamp: new Date()
|
|
79
|
+
operation: 'fetchNewToken',
|
|
80
|
+
timestamp: new Date(),
|
|
81
81
|
});
|
|
82
82
|
return null;
|
|
83
83
|
}
|
|
84
84
|
const data = await response.json();
|
|
85
85
|
if (!data.access_token || !data.expires_in) {
|
|
86
86
|
this.handleError({
|
|
87
|
-
message:
|
|
88
|
-
operation:
|
|
89
|
-
timestamp: new Date()
|
|
87
|
+
message: 'Invalid token response from Spotify - missing access_token or expires_in',
|
|
88
|
+
operation: 'fetchNewToken',
|
|
89
|
+
timestamp: new Date(),
|
|
90
90
|
});
|
|
91
91
|
return null;
|
|
92
92
|
}
|
|
93
93
|
const token = {
|
|
94
94
|
access_token: data.access_token,
|
|
95
95
|
token_type: data.token_type,
|
|
96
|
-
expires_at: new Date(Date.now() + data.expires_in * 1000)
|
|
96
|
+
expires_at: new Date(Date.now() + data.expires_in * 1000),
|
|
97
97
|
};
|
|
98
98
|
return token;
|
|
99
99
|
}
|
|
100
100
|
catch (error) {
|
|
101
101
|
this.handleError({
|
|
102
102
|
message: `Network error fetching token: ${error instanceof Error ? error.message : String(error)}`,
|
|
103
|
-
operation:
|
|
104
|
-
timestamp: new Date()
|
|
103
|
+
operation: 'fetchNewToken',
|
|
104
|
+
timestamp: new Date(),
|
|
105
105
|
});
|
|
106
106
|
return null;
|
|
107
107
|
}
|
|
@@ -115,7 +115,7 @@ export class SpotifyHelper {
|
|
|
115
115
|
if (!token) {
|
|
116
116
|
return {
|
|
117
117
|
success: false,
|
|
118
|
-
error:
|
|
118
|
+
error: 'Failed to obtain valid authentication token',
|
|
119
119
|
};
|
|
120
120
|
}
|
|
121
121
|
try {
|
|
@@ -123,8 +123,8 @@ export class SpotifyHelper {
|
|
|
123
123
|
...options,
|
|
124
124
|
headers: {
|
|
125
125
|
...options.headers,
|
|
126
|
-
|
|
127
|
-
}
|
|
126
|
+
Authorization: `Bearer ${token.access_token}`,
|
|
127
|
+
},
|
|
128
128
|
});
|
|
129
129
|
if (!response.ok) {
|
|
130
130
|
const errorText = await response.text();
|
|
@@ -132,12 +132,12 @@ export class SpotifyHelper {
|
|
|
132
132
|
message: `API request failed: ${response.status} ${response.statusText} - ${errorText}`,
|
|
133
133
|
statusCode: response.status,
|
|
134
134
|
operation,
|
|
135
|
-
timestamp: new Date()
|
|
135
|
+
timestamp: new Date(),
|
|
136
136
|
});
|
|
137
137
|
return {
|
|
138
138
|
success: false,
|
|
139
139
|
error: `Request failed: ${response.status} ${response.statusText}`,
|
|
140
|
-
statusCode: response.status
|
|
140
|
+
statusCode: response.status,
|
|
141
141
|
};
|
|
142
142
|
}
|
|
143
143
|
const data = await response.json();
|
|
@@ -148,11 +148,11 @@ export class SpotifyHelper {
|
|
|
148
148
|
this.handleError({
|
|
149
149
|
message: `Network error during ${operation}: ${errorMessage}`,
|
|
150
150
|
operation,
|
|
151
|
-
timestamp: new Date()
|
|
151
|
+
timestamp: new Date(),
|
|
152
152
|
});
|
|
153
153
|
return {
|
|
154
154
|
success: false,
|
|
155
|
-
error: `Network error: ${errorMessage}
|
|
155
|
+
error: `Network error: ${errorMessage}`,
|
|
156
156
|
};
|
|
157
157
|
}
|
|
158
158
|
}
|
|
@@ -170,7 +170,7 @@ export class SpotifyHelper {
|
|
|
170
170
|
* Returns null if the request fails.
|
|
171
171
|
*/
|
|
172
172
|
async getAlbum(albumId) {
|
|
173
|
-
const result = await this.makeAuthenticatedRequest(`https://api.spotify.com/v1/albums/${albumId}`,
|
|
173
|
+
const result = await this.makeAuthenticatedRequest(`https://api.spotify.com/v1/albums/${albumId}`, 'getAlbum', { method: 'GET' });
|
|
174
174
|
if (!result.success) {
|
|
175
175
|
return null;
|
|
176
176
|
}
|
|
@@ -180,19 +180,25 @@ export class SpotifyHelper {
|
|
|
180
180
|
name: data.name,
|
|
181
181
|
album_type: data.album_type,
|
|
182
182
|
total_tracks: data.total_tracks,
|
|
183
|
-
data: data.data
|
|
183
|
+
data: data.data,
|
|
184
184
|
};
|
|
185
185
|
return album;
|
|
186
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Get a list of albums by their IDs.
|
|
189
|
+
* @param albumIds
|
|
190
|
+
* @returns
|
|
191
|
+
* @throws Error if more than 20 IDs are provided (Spotify API limit)
|
|
192
|
+
*/
|
|
187
193
|
async getAlbums(albumIds) {
|
|
188
|
-
const idsParam = albumIds.join(
|
|
194
|
+
const idsParam = albumIds.join(',');
|
|
189
195
|
if (albumIds.length === 0) {
|
|
190
196
|
return [];
|
|
191
197
|
}
|
|
192
198
|
if (albumIds.length > 20) {
|
|
193
|
-
throw new Error(
|
|
199
|
+
throw new Error('Spotify API allows a maximum of 20 album IDs per request.');
|
|
194
200
|
}
|
|
195
|
-
const result = await this.makeAuthenticatedRequest(`https://api.spotify.com/v1/albums?ids=${idsParam}`,
|
|
201
|
+
const result = await this.makeAuthenticatedRequest(`https://api.spotify.com/v1/albums?ids=${idsParam}`, 'getAlbums', { method: 'GET' });
|
|
196
202
|
if (result.success) {
|
|
197
203
|
return result.data.map((data) => {
|
|
198
204
|
if (!data) {
|
|
@@ -203,19 +209,53 @@ export class SpotifyHelper {
|
|
|
203
209
|
name: data.name,
|
|
204
210
|
album_type: data.album_type,
|
|
205
211
|
total_tracks: data.total_tracks,
|
|
206
|
-
data: data.data
|
|
212
|
+
data: data.data,
|
|
207
213
|
};
|
|
208
214
|
return album;
|
|
209
215
|
});
|
|
210
216
|
}
|
|
211
217
|
return [];
|
|
212
218
|
}
|
|
219
|
+
async getAlbumTracks(albumId, limit = 50, offset = 0) {
|
|
220
|
+
if (limit < 1 || limit > 50) {
|
|
221
|
+
throw new Error('Limit must be between 1 and 50');
|
|
222
|
+
}
|
|
223
|
+
const result = await this.makeAuthenticatedRequest(`https://api.spotify.com/v1/albums/${albumId}/tracks?limit=${limit}&offset=${offset}`, 'getAlbumTracks', { method: 'GET' });
|
|
224
|
+
if (!result.success) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
const tracks = result.data.items.map((item) => {
|
|
228
|
+
const track = {
|
|
229
|
+
id: item.id,
|
|
230
|
+
name: item.name,
|
|
231
|
+
artists: item.artists.map((artistData) => {
|
|
232
|
+
const { id, name, ...data } = artistData;
|
|
233
|
+
const artist = {
|
|
234
|
+
id: id,
|
|
235
|
+
name: name,
|
|
236
|
+
data: data,
|
|
237
|
+
};
|
|
238
|
+
return artist;
|
|
239
|
+
}),
|
|
240
|
+
album: {
|
|
241
|
+
id: albumId,
|
|
242
|
+
name: '',
|
|
243
|
+
album_type: '',
|
|
244
|
+
total_tracks: 0,
|
|
245
|
+
data: {},
|
|
246
|
+
},
|
|
247
|
+
data: item,
|
|
248
|
+
};
|
|
249
|
+
return track;
|
|
250
|
+
});
|
|
251
|
+
return tracks;
|
|
252
|
+
}
|
|
213
253
|
/**
|
|
214
254
|
* Get a track by ID.
|
|
215
255
|
* Returns null if the request fails.
|
|
216
256
|
*/
|
|
217
257
|
async getTrack(trackId) {
|
|
218
|
-
const result = await this.makeAuthenticatedRequest(`https://api.spotify.com/v1/tracks/${trackId}`,
|
|
258
|
+
const result = await this.makeAuthenticatedRequest(`https://api.spotify.com/v1/tracks/${trackId}`, 'getTrack', { method: 'GET' });
|
|
219
259
|
if (!result.success) {
|
|
220
260
|
return null;
|
|
221
261
|
}
|
|
@@ -226,14 +266,14 @@ export class SpotifyHelper {
|
|
|
226
266
|
name: name,
|
|
227
267
|
album_type: album_type,
|
|
228
268
|
total_tracks: total_tracks,
|
|
229
|
-
data: data
|
|
269
|
+
data: data,
|
|
230
270
|
};
|
|
231
271
|
const trackArtistData = result.data.artists.map((artistData) => {
|
|
232
272
|
const { id, name, ...data } = artistData;
|
|
233
273
|
const artist = {
|
|
234
274
|
id: id,
|
|
235
275
|
name: name,
|
|
236
|
-
data: data
|
|
276
|
+
data: data,
|
|
237
277
|
};
|
|
238
278
|
return artist;
|
|
239
279
|
});
|
|
@@ -242,7 +282,7 @@ export class SpotifyHelper {
|
|
|
242
282
|
name: result.data.name,
|
|
243
283
|
artists: trackArtistData,
|
|
244
284
|
album: trackAlbumData,
|
|
245
|
-
data: result.data
|
|
285
|
+
data: result.data,
|
|
246
286
|
};
|
|
247
287
|
return trackData;
|
|
248
288
|
}
|
|
@@ -252,7 +292,7 @@ export class SpotifyHelper {
|
|
|
252
292
|
* Returns null if the request fails.
|
|
253
293
|
*/
|
|
254
294
|
async getArtist(artistId) {
|
|
255
|
-
const result = await this.makeAuthenticatedRequest(`https://api.spotify.com/v1/artists/${artistId}`,
|
|
295
|
+
const result = await this.makeAuthenticatedRequest(`https://api.spotify.com/v1/artists/${artistId}`, 'getArtist', { method: 'GET' });
|
|
256
296
|
if (!result.success) {
|
|
257
297
|
return null;
|
|
258
298
|
}
|
|
@@ -265,7 +305,7 @@ export class SpotifyHelper {
|
|
|
265
305
|
* Returns null if the request fails.
|
|
266
306
|
*/
|
|
267
307
|
async getPlaylist(playlistId) {
|
|
268
|
-
const result = await this.makeAuthenticatedRequest(`https://api.spotify.com/v1/playlists/${playlistId}`,
|
|
308
|
+
const result = await this.makeAuthenticatedRequest(`https://api.spotify.com/v1/playlists/${playlistId}`, 'getPlaylist', { method: 'GET' });
|
|
269
309
|
if (!result.success) {
|
|
270
310
|
return null;
|
|
271
311
|
}
|
|
@@ -273,17 +313,215 @@ export class SpotifyHelper {
|
|
|
273
313
|
}
|
|
274
314
|
/**
|
|
275
315
|
* Get available markets.
|
|
276
|
-
* Returns empty array if the request fails.
|
|
316
|
+
* @returns Returns empty array if the request fails.
|
|
277
317
|
*/
|
|
278
318
|
async getAvailableMarkets() {
|
|
279
|
-
const result = await this.makeAuthenticatedRequest(`https://api.spotify.com/v1/markets`,
|
|
319
|
+
const result = await this.makeAuthenticatedRequest(`https://api.spotify.com/v1/markets`, 'getAvailableMarkets', { method: 'GET' });
|
|
280
320
|
if (!result.success) {
|
|
281
321
|
return [];
|
|
282
322
|
}
|
|
283
323
|
return result.data.markets || [];
|
|
284
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* Use the search endpoint.
|
|
327
|
+
* @param query
|
|
328
|
+
* @param type
|
|
329
|
+
* @param limit
|
|
330
|
+
* @param offset
|
|
331
|
+
* @returns Parsed search results or null if request fails
|
|
332
|
+
*/
|
|
285
333
|
async search(query, type, limit = 20, offset = 0) {
|
|
286
|
-
const result = await this.makeAuthenticatedRequest(`https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=${type}&limit=${limit}&offset=${offset}`,
|
|
334
|
+
const result = await this.makeAuthenticatedRequest(`https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=${type}&limit=${limit}&offset=${offset}`, 'search', { method: 'GET' });
|
|
335
|
+
if (!result.success) {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
return result.data;
|
|
339
|
+
}
|
|
340
|
+
// ─── Authorization Code Flow (User Token) ───
|
|
341
|
+
/** Builds the Spotify authorization URL for user login. */
|
|
342
|
+
getAuthorizationUrl(scopes, state) {
|
|
343
|
+
const params = new URLSearchParams({
|
|
344
|
+
response_type: 'code',
|
|
345
|
+
client_id: this.config.clientId,
|
|
346
|
+
redirect_uri: this.config.redirectUri,
|
|
347
|
+
scope: scopes.join(' '),
|
|
348
|
+
});
|
|
349
|
+
if (state) {
|
|
350
|
+
params.set('state', state);
|
|
351
|
+
}
|
|
352
|
+
return `https://accounts.spotify.com/authorize?${params.toString()}`;
|
|
353
|
+
}
|
|
354
|
+
/** Exchanges an authorization code for a UserToken. */
|
|
355
|
+
async exchangeAuthCode(code) {
|
|
356
|
+
const params = new URLSearchParams({
|
|
357
|
+
grant_type: 'authorization_code',
|
|
358
|
+
code,
|
|
359
|
+
redirect_uri: this.config.redirectUri,
|
|
360
|
+
});
|
|
361
|
+
try {
|
|
362
|
+
const response = await fetch('https://accounts.spotify.com/api/token', {
|
|
363
|
+
method: 'POST',
|
|
364
|
+
headers: {
|
|
365
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
366
|
+
Authorization: 'Basic ' + Buffer.from(this.config.clientId + ':' + this.config.clientSecret).toString('base64'),
|
|
367
|
+
},
|
|
368
|
+
body: params.toString(),
|
|
369
|
+
});
|
|
370
|
+
if (!response.ok) {
|
|
371
|
+
const errorText = await response.text();
|
|
372
|
+
this.handleError({
|
|
373
|
+
message: `Failed to exchange auth code: ${response.status} ${response.statusText} - ${errorText}`,
|
|
374
|
+
statusCode: response.status,
|
|
375
|
+
operation: 'exchangeAuthCode',
|
|
376
|
+
timestamp: new Date(),
|
|
377
|
+
});
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
const data = await response.json();
|
|
381
|
+
if (!data.access_token || !data.refresh_token) {
|
|
382
|
+
this.handleError({
|
|
383
|
+
message: 'Invalid token response - missing access_token or refresh_token',
|
|
384
|
+
operation: 'exchangeAuthCode',
|
|
385
|
+
timestamp: new Date(),
|
|
386
|
+
});
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
access_token: data.access_token,
|
|
391
|
+
refresh_token: data.refresh_token,
|
|
392
|
+
token_type: data.token_type,
|
|
393
|
+
expires_at: new Date(Date.now() + data.expires_in * 1000),
|
|
394
|
+
scope: data.scope ?? '',
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
this.handleError({
|
|
399
|
+
message: `Network error exchanging auth code: ${error instanceof Error ? error.message : String(error)}`,
|
|
400
|
+
operation: 'exchangeAuthCode',
|
|
401
|
+
timestamp: new Date(),
|
|
402
|
+
});
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/** Refreshes an expired user token using its refresh token. */
|
|
407
|
+
async refreshUserToken(refreshToken) {
|
|
408
|
+
const params = new URLSearchParams({
|
|
409
|
+
grant_type: 'refresh_token',
|
|
410
|
+
refresh_token: refreshToken,
|
|
411
|
+
});
|
|
412
|
+
try {
|
|
413
|
+
const response = await fetch('https://accounts.spotify.com/api/token', {
|
|
414
|
+
method: 'POST',
|
|
415
|
+
headers: {
|
|
416
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
417
|
+
Authorization: 'Basic ' + Buffer.from(this.config.clientId + ':' + this.config.clientSecret).toString('base64'),
|
|
418
|
+
},
|
|
419
|
+
body: params.toString(),
|
|
420
|
+
});
|
|
421
|
+
if (!response.ok) {
|
|
422
|
+
const errorText = await response.text();
|
|
423
|
+
this.handleError({
|
|
424
|
+
message: `Failed to refresh user token: ${response.status} ${response.statusText} - ${errorText}`,
|
|
425
|
+
statusCode: response.status,
|
|
426
|
+
operation: 'refreshUserToken',
|
|
427
|
+
timestamp: new Date(),
|
|
428
|
+
});
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
const data = await response.json();
|
|
432
|
+
if (!data.access_token) {
|
|
433
|
+
this.handleError({
|
|
434
|
+
message: 'Invalid refresh response - missing access_token',
|
|
435
|
+
operation: 'refreshUserToken',
|
|
436
|
+
timestamp: new Date(),
|
|
437
|
+
});
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
access_token: data.access_token,
|
|
442
|
+
refresh_token: data.refresh_token ?? refreshToken,
|
|
443
|
+
token_type: data.token_type,
|
|
444
|
+
expires_at: new Date(Date.now() + data.expires_in * 1000),
|
|
445
|
+
scope: data.scope ?? '',
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
this.handleError({
|
|
450
|
+
message: `Network error refreshing user token: ${error instanceof Error ? error.message : String(error)}`,
|
|
451
|
+
operation: 'refreshUserToken',
|
|
452
|
+
timestamp: new Date(),
|
|
453
|
+
});
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// ─── User-Scoped API Methods ───
|
|
458
|
+
/** Makes an API request using a user-provided token. */
|
|
459
|
+
async makeUserRequest(url, userToken, operation, options = {}) {
|
|
460
|
+
try {
|
|
461
|
+
const response = await fetch(url, {
|
|
462
|
+
...options,
|
|
463
|
+
headers: {
|
|
464
|
+
...options.headers,
|
|
465
|
+
Authorization: `Bearer ${userToken.access_token}`,
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
if (!response.ok) {
|
|
469
|
+
const errorText = await response.text();
|
|
470
|
+
this.handleError({
|
|
471
|
+
message: `API request failed: ${response.status} ${response.statusText} - ${errorText}`,
|
|
472
|
+
statusCode: response.status,
|
|
473
|
+
operation,
|
|
474
|
+
timestamp: new Date(),
|
|
475
|
+
});
|
|
476
|
+
return {
|
|
477
|
+
success: false,
|
|
478
|
+
error: `Request failed: ${response.status} ${response.statusText}`,
|
|
479
|
+
statusCode: response.status,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
const data = await response.json();
|
|
483
|
+
return { success: true, data };
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
487
|
+
this.handleError({
|
|
488
|
+
message: `Network error during ${operation}: ${errorMessage}`,
|
|
489
|
+
operation,
|
|
490
|
+
timestamp: new Date(),
|
|
491
|
+
});
|
|
492
|
+
return {
|
|
493
|
+
success: false,
|
|
494
|
+
error: `Network error: ${errorMessage}`,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/** Get the current user's profile. Requires `user-read-private` scope. */
|
|
499
|
+
async getCurrentUserProfile(userToken) {
|
|
500
|
+
const result = await this.makeUserRequest('https://api.spotify.com/v1/me', userToken, 'getCurrentUserProfile');
|
|
501
|
+
return result.success ? result.data : null;
|
|
502
|
+
}
|
|
503
|
+
/** Get the current user's playlists. Requires `playlist-read-private` scope. */
|
|
504
|
+
async getUserPlaylists(userToken, limit = 20, offset = 0) {
|
|
505
|
+
const result = await this.makeUserRequest(`https://api.spotify.com/v1/me/playlists?limit=${limit}&offset=${offset}`, userToken, 'getUserPlaylists');
|
|
506
|
+
return result.success ? result.data : null;
|
|
507
|
+
}
|
|
508
|
+
/** Get the current user's top artists or tracks. Requires `user-top-read` scope. */
|
|
509
|
+
async getUserTopItems(userToken, type, limit = 20, offset = 0) {
|
|
510
|
+
const result = await this.makeUserRequest(`https://api.spotify.com/v1/me/top/${type}?limit=${limit}&offset=${offset}`, userToken, 'getUserTopItems');
|
|
511
|
+
return result.success ? result.data : null;
|
|
512
|
+
}
|
|
513
|
+
/** Get the current user's saved tracks. Requires `user-library-read` scope. */
|
|
514
|
+
async getUserSavedTracks(userToken, limit = 20, offset = 0) {
|
|
515
|
+
const result = await this.makeUserRequest(`https://api.spotify.com/v1/me/tracks?limit=${limit}&offset=${offset}`, userToken, 'getUserSavedTracks');
|
|
516
|
+
return result.success ? result.data : null;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Make Raw request to a given Spotify API URL.
|
|
520
|
+
* @param url
|
|
521
|
+
* @returns Parsed JSON data or null if request fails
|
|
522
|
+
*/
|
|
523
|
+
async getDataFromURL(url) {
|
|
524
|
+
const result = await this.makeAuthenticatedRequest(url, 'getDataFromURL', { method: 'GET' });
|
|
287
525
|
if (!result.success) {
|
|
288
526
|
return null;
|
|
289
527
|
}
|
|
@@ -316,7 +554,7 @@ export class SpotifyHelper {
|
|
|
316
554
|
return {
|
|
317
555
|
hasToken: this.clientToken !== null,
|
|
318
556
|
expiresAt: this.clientToken?.expires_at || null,
|
|
319
|
-
isExpiringSoon: this.isTokenExpiringSoon()
|
|
557
|
+
isExpiringSoon: this.isTokenExpiringSoon(),
|
|
320
558
|
};
|
|
321
559
|
}
|
|
322
560
|
}
|
|
@@ -22,9 +22,37 @@ export interface ClientToken {
|
|
|
22
22
|
token_type: string;
|
|
23
23
|
expires_at: Date;
|
|
24
24
|
}
|
|
25
|
+
export interface UserToken {
|
|
26
|
+
access_token: string;
|
|
27
|
+
refresh_token: string;
|
|
28
|
+
token_type: string;
|
|
29
|
+
expires_at: Date;
|
|
30
|
+
scope: string;
|
|
31
|
+
}
|
|
25
32
|
export declare enum SearchType {
|
|
26
33
|
ALBUM = "album",
|
|
27
34
|
ARTIST = "artist",
|
|
28
35
|
TRACK = "track",
|
|
29
36
|
PLAYLIST = "playlist"
|
|
30
37
|
}
|
|
38
|
+
export declare enum SpotifyScope {
|
|
39
|
+
UGC_IMAGE_UPLOAD = "ugc-image-upload",
|
|
40
|
+
USER_READ_PLAYBACK_STATE = "user-read-playback-state",
|
|
41
|
+
USER_MODIFY_PLAYBACK_STATE = "user-modify-playback-state",
|
|
42
|
+
USER_READ_CURRENTLY_PLAYING = "user-read-currently-playing",
|
|
43
|
+
STREAMING = "streaming",
|
|
44
|
+
APP_REMOTE_CONTROL = "app-remote-control",
|
|
45
|
+
USER_READ_EMAIL = "user-read-email",
|
|
46
|
+
USER_READ_PRIVATE = "user-read-private",
|
|
47
|
+
PLAYLIST_READ_COLLABORATIVE = "playlist-read-collaborative",
|
|
48
|
+
PLAYLIST_MODIFY_PUBLIC = "playlist-modify-public",
|
|
49
|
+
PLAYLIST_READ_PRIVATE = "playlist-read-private",
|
|
50
|
+
PLAYLIST_MODIFY_PRIVATE = "playlist-modify-private",
|
|
51
|
+
USER_LIBRARY_MODIFY = "user-library-modify",
|
|
52
|
+
USER_LIBRARY_READ = "user-library-read",
|
|
53
|
+
USER_TOP_READ = "user-top-read",
|
|
54
|
+
USER_READ_RECENTLY_PLAYED = "user-read-recently-played",
|
|
55
|
+
USER_READ_PLAYBACK_POSITION = "user-read-playback-position",
|
|
56
|
+
USER_FOLLOW_READ = "user-follow-read",
|
|
57
|
+
USER_FOLLOW_MODIFY = "user-follow-modify"
|
|
58
|
+
}
|
|
@@ -5,3 +5,33 @@ export var SearchType;
|
|
|
5
5
|
SearchType["TRACK"] = "track";
|
|
6
6
|
SearchType["PLAYLIST"] = "playlist";
|
|
7
7
|
})(SearchType || (SearchType = {}));
|
|
8
|
+
export var SpotifyScope;
|
|
9
|
+
(function (SpotifyScope) {
|
|
10
|
+
// Images
|
|
11
|
+
SpotifyScope["UGC_IMAGE_UPLOAD"] = "ugc-image-upload";
|
|
12
|
+
// Spotify Connect
|
|
13
|
+
SpotifyScope["USER_READ_PLAYBACK_STATE"] = "user-read-playback-state";
|
|
14
|
+
SpotifyScope["USER_MODIFY_PLAYBACK_STATE"] = "user-modify-playback-state";
|
|
15
|
+
SpotifyScope["USER_READ_CURRENTLY_PLAYING"] = "user-read-currently-playing";
|
|
16
|
+
// Playback
|
|
17
|
+
SpotifyScope["STREAMING"] = "streaming";
|
|
18
|
+
SpotifyScope["APP_REMOTE_CONTROL"] = "app-remote-control";
|
|
19
|
+
// Users
|
|
20
|
+
SpotifyScope["USER_READ_EMAIL"] = "user-read-email";
|
|
21
|
+
SpotifyScope["USER_READ_PRIVATE"] = "user-read-private";
|
|
22
|
+
// Playlists
|
|
23
|
+
SpotifyScope["PLAYLIST_READ_COLLABORATIVE"] = "playlist-read-collaborative";
|
|
24
|
+
SpotifyScope["PLAYLIST_MODIFY_PUBLIC"] = "playlist-modify-public";
|
|
25
|
+
SpotifyScope["PLAYLIST_READ_PRIVATE"] = "playlist-read-private";
|
|
26
|
+
SpotifyScope["PLAYLIST_MODIFY_PRIVATE"] = "playlist-modify-private";
|
|
27
|
+
// Library
|
|
28
|
+
SpotifyScope["USER_LIBRARY_MODIFY"] = "user-library-modify";
|
|
29
|
+
SpotifyScope["USER_LIBRARY_READ"] = "user-library-read";
|
|
30
|
+
// Listening History
|
|
31
|
+
SpotifyScope["USER_TOP_READ"] = "user-top-read";
|
|
32
|
+
SpotifyScope["USER_READ_RECENTLY_PLAYED"] = "user-read-recently-played";
|
|
33
|
+
SpotifyScope["USER_READ_PLAYBACK_POSITION"] = "user-read-playback-position";
|
|
34
|
+
// Follow
|
|
35
|
+
SpotifyScope["USER_FOLLOW_READ"] = "user-follow-read";
|
|
36
|
+
SpotifyScope["USER_FOLLOW_MODIFY"] = "user-follow-modify";
|
|
37
|
+
})(SpotifyScope || (SpotifyScope = {}));
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export * from
|
|
2
|
-
export { SpotifyHelper } from
|
|
3
|
-
export { SpotifyConfiguration } from
|
|
1
|
+
export * from './core/SpotifyTypes.js';
|
|
2
|
+
export { SpotifyHelper } from './core/SpotifyHelper.js';
|
|
3
|
+
export { SpotifyConfiguration } from './core/SpotifyConfiguration.js';
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export * from
|
|
2
|
-
export { SpotifyHelper } from
|
|
3
|
-
export { SpotifyConfiguration } from
|
|
1
|
+
export * from './core/SpotifyTypes.js';
|
|
2
|
+
export { SpotifyHelper } from './core/SpotifyHelper.js';
|
|
3
|
+
export { SpotifyConfiguration } from './core/SpotifyConfiguration.js';
|