@benliam12/spotify-api-helper 1.0.0-DEV.5 → 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 +45 -3
- package/dist/core/SpotifyHelper.js +328 -35
- package/dist/core/SpotifyTypes.d.ts +37 -2
- package/dist/core/SpotifyTypes.js +30 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/package.json +5 -3
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,6 +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
|
+
*/
|
|
84
|
+
getAlbums(albumIds: string[]): Promise<(Album | null)[]>;
|
|
85
|
+
getAlbumTracks(albumId: string, limit?: number, offset?: number): Promise<Track[] | null>;
|
|
78
86
|
/**
|
|
79
87
|
* Get a track by ID.
|
|
80
88
|
* Returns null if the request fails.
|
|
@@ -92,10 +100,40 @@ export declare class SpotifyHelper {
|
|
|
92
100
|
getPlaylist(playlistId: string): Promise<any | null>;
|
|
93
101
|
/**
|
|
94
102
|
* Get available markets.
|
|
95
|
-
* Returns empty array if the request fails.
|
|
103
|
+
* @returns Returns empty array if the request fails.
|
|
96
104
|
*/
|
|
97
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
|
+
*/
|
|
98
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>;
|
|
99
137
|
/**
|
|
100
138
|
* Check if the helper is properly authenticated.
|
|
101
139
|
* Useful for health checks.
|
|
@@ -105,6 +143,10 @@ export declare class SpotifyHelper {
|
|
|
105
143
|
* Manually clear the token (useful for testing or forcing a refresh).
|
|
106
144
|
*/
|
|
107
145
|
clearToken(): void;
|
|
146
|
+
/**
|
|
147
|
+
* Get the current configuration.
|
|
148
|
+
*/
|
|
149
|
+
getConfig(): SpotifyConfiguration;
|
|
108
150
|
/**
|
|
109
151
|
* Get token info for debugging (without exposing the actual token).
|
|
110
152
|
*/
|
|
@@ -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
|
}
|
|
@@ -178,32 +178,121 @@ export class SpotifyHelper {
|
|
|
178
178
|
const album = {
|
|
179
179
|
id: data.id,
|
|
180
180
|
name: data.name,
|
|
181
|
-
market: data.market,
|
|
182
181
|
album_type: data.album_type,
|
|
183
182
|
total_tracks: data.total_tracks,
|
|
184
|
-
data: data
|
|
183
|
+
data: data.data,
|
|
185
184
|
};
|
|
186
185
|
return album;
|
|
187
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
|
+
*/
|
|
193
|
+
async getAlbums(albumIds) {
|
|
194
|
+
const idsParam = albumIds.join(',');
|
|
195
|
+
if (albumIds.length === 0) {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
if (albumIds.length > 20) {
|
|
199
|
+
throw new Error('Spotify API allows a maximum of 20 album IDs per request.');
|
|
200
|
+
}
|
|
201
|
+
const result = await this.makeAuthenticatedRequest(`https://api.spotify.com/v1/albums?ids=${idsParam}`, 'getAlbums', { method: 'GET' });
|
|
202
|
+
if (result.success) {
|
|
203
|
+
return result.data.map((data) => {
|
|
204
|
+
if (!data) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const album = {
|
|
208
|
+
id: data.id,
|
|
209
|
+
name: data.name,
|
|
210
|
+
album_type: data.album_type,
|
|
211
|
+
total_tracks: data.total_tracks,
|
|
212
|
+
data: data.data,
|
|
213
|
+
};
|
|
214
|
+
return album;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return [];
|
|
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
|
+
}
|
|
188
253
|
/**
|
|
189
254
|
* Get a track by ID.
|
|
190
255
|
* Returns null if the request fails.
|
|
191
256
|
*/
|
|
192
257
|
async getTrack(trackId) {
|
|
193
|
-
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' });
|
|
194
259
|
if (!result.success) {
|
|
195
260
|
return null;
|
|
196
261
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
262
|
+
else {
|
|
263
|
+
const { id, name, album_type, total_tracks, ...data } = result.data.album;
|
|
264
|
+
const trackAlbumData = {
|
|
265
|
+
id: id,
|
|
266
|
+
name: name,
|
|
267
|
+
album_type: album_type,
|
|
268
|
+
total_tracks: total_tracks,
|
|
269
|
+
data: data,
|
|
270
|
+
};
|
|
271
|
+
const trackArtistData = result.data.artists.map((artistData) => {
|
|
272
|
+
const { id, name, ...data } = artistData;
|
|
273
|
+
const artist = {
|
|
274
|
+
id: id,
|
|
275
|
+
name: name,
|
|
276
|
+
data: data,
|
|
277
|
+
};
|
|
278
|
+
return artist;
|
|
279
|
+
});
|
|
280
|
+
const trackData = {
|
|
281
|
+
id: result.data.id,
|
|
282
|
+
name: result.data.name,
|
|
283
|
+
artists: trackArtistData,
|
|
284
|
+
album: trackAlbumData,
|
|
285
|
+
data: result.data,
|
|
286
|
+
};
|
|
287
|
+
return trackData;
|
|
288
|
+
}
|
|
200
289
|
}
|
|
201
290
|
/**
|
|
202
291
|
* Get an artist by ID.
|
|
203
292
|
* Returns null if the request fails.
|
|
204
293
|
*/
|
|
205
294
|
async getArtist(artistId) {
|
|
206
|
-
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' });
|
|
207
296
|
if (!result.success) {
|
|
208
297
|
return null;
|
|
209
298
|
}
|
|
@@ -216,7 +305,7 @@ export class SpotifyHelper {
|
|
|
216
305
|
* Returns null if the request fails.
|
|
217
306
|
*/
|
|
218
307
|
async getPlaylist(playlistId) {
|
|
219
|
-
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' });
|
|
220
309
|
if (!result.success) {
|
|
221
310
|
return null;
|
|
222
311
|
}
|
|
@@ -224,17 +313,215 @@ export class SpotifyHelper {
|
|
|
224
313
|
}
|
|
225
314
|
/**
|
|
226
315
|
* Get available markets.
|
|
227
|
-
* Returns empty array if the request fails.
|
|
316
|
+
* @returns Returns empty array if the request fails.
|
|
228
317
|
*/
|
|
229
318
|
async getAvailableMarkets() {
|
|
230
|
-
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' });
|
|
231
320
|
if (!result.success) {
|
|
232
321
|
return [];
|
|
233
322
|
}
|
|
234
323
|
return result.data.markets || [];
|
|
235
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
|
+
*/
|
|
236
333
|
async search(query, type, limit = 20, offset = 0) {
|
|
237
|
-
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' });
|
|
238
525
|
if (!result.success) {
|
|
239
526
|
return null;
|
|
240
527
|
}
|
|
@@ -254,6 +541,12 @@ export class SpotifyHelper {
|
|
|
254
541
|
clearToken() {
|
|
255
542
|
this.clientToken = null;
|
|
256
543
|
}
|
|
544
|
+
/**
|
|
545
|
+
* Get the current configuration.
|
|
546
|
+
*/
|
|
547
|
+
getConfig() {
|
|
548
|
+
return this.config;
|
|
549
|
+
}
|
|
257
550
|
/**
|
|
258
551
|
* Get token info for debugging (without exposing the actual token).
|
|
259
552
|
*/
|
|
@@ -261,7 +554,7 @@ export class SpotifyHelper {
|
|
|
261
554
|
return {
|
|
262
555
|
hasToken: this.clientToken !== null,
|
|
263
556
|
expiresAt: this.clientToken?.expires_at || null,
|
|
264
|
-
isExpiringSoon: this.isTokenExpiringSoon()
|
|
557
|
+
isExpiringSoon: this.isTokenExpiringSoon(),
|
|
265
558
|
};
|
|
266
559
|
}
|
|
267
560
|
}
|
|
@@ -1,23 +1,58 @@
|
|
|
1
1
|
export interface Album {
|
|
2
2
|
id: string;
|
|
3
3
|
name: string;
|
|
4
|
-
market: string;
|
|
5
4
|
album_type: string;
|
|
6
5
|
total_tracks: number;
|
|
7
|
-
data
|
|
6
|
+
data: any;
|
|
8
7
|
}
|
|
9
8
|
export interface Track {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
artists: Artist[];
|
|
12
|
+
album: Album;
|
|
13
|
+
data: any;
|
|
10
14
|
}
|
|
11
15
|
export interface Artist {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
data: any;
|
|
12
19
|
}
|
|
13
20
|
export interface ClientToken {
|
|
14
21
|
access_token: string;
|
|
15
22
|
token_type: string;
|
|
16
23
|
expires_at: Date;
|
|
17
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
|
+
}
|
|
18
32
|
export declare enum SearchType {
|
|
19
33
|
ALBUM = "album",
|
|
20
34
|
ARTIST = "artist",
|
|
21
35
|
TRACK = "track",
|
|
22
36
|
PLAYLIST = "playlist"
|
|
23
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';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@benliam12/spotify-api-helper",
|
|
3
|
-
"version": "1.0.0-DEV.
|
|
3
|
+
"version": "1.0.0-DEV.7",
|
|
4
4
|
"description": "A utility package for Node.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
"clean": "rimraf dist",
|
|
17
17
|
"lint": "eslint src --ext .ts",
|
|
18
18
|
"prepare": "npm run build",
|
|
19
|
-
"release": "semantic-release --no-ci",
|
|
20
|
-
"release:dry": "semantic-release --dry-run --no-ci"
|
|
19
|
+
"release": "dotenv -e .env -- semantic-release --no-ci --dry-run=false",
|
|
20
|
+
"release:dry": "dotenv -e .env semantic-release --dry-run --no-ci"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|
|
23
23
|
"utility",
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
"@types/node": "^22.19.3",
|
|
39
39
|
"@typescript-eslint/eslint-plugin": "^8.50.1",
|
|
40
40
|
"@typescript-eslint/parser": "^8.50.1",
|
|
41
|
+
"conventional-changelog-conventionalcommits": "^9.1.0",
|
|
42
|
+
"dotenv-cli": "^11.0.0",
|
|
41
43
|
"eslint": "^9.39.2",
|
|
42
44
|
"jest": "^29.7.0",
|
|
43
45
|
"rimraf": "^6.1.2",
|