@bzenky/spoti 0.1.0
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/LICENSE +21 -0
- package/README.md +137 -0
- package/dist/app.d.ts +14 -0
- package/dist/app.js +122 -0
- package/dist/app.js.map +1 -0
- package/dist/auth/callback.d.ts +7 -0
- package/dist/auth/callback.js +79 -0
- package/dist/auth/callback.js.map +1 -0
- package/dist/auth/config.d.ts +7 -0
- package/dist/auth/config.js +31 -0
- package/dist/auth/config.js.map +1 -0
- package/dist/auth/index.d.ts +8 -0
- package/dist/auth/index.js +5 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/pkce.d.ts +7 -0
- package/dist/auth/pkce.js +15 -0
- package/dist/auth/pkce.js.map +1 -0
- package/dist/auth/spotify-auth-client.d.ts +38 -0
- package/dist/auth/spotify-auth-client.js +152 -0
- package/dist/auth/spotify-auth-client.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +25 -0
- package/dist/cli.js.map +1 -0
- package/dist/services/auth.service.d.ts +36 -0
- package/dist/services/auth.service.js +127 -0
- package/dist/services/auth.service.js.map +1 -0
- package/dist/services/models.d.ts +20 -0
- package/dist/services/models.js +2 -0
- package/dist/services/models.js.map +1 -0
- package/dist/services/player.service.d.ts +12 -0
- package/dist/services/player.service.js +45 -0
- package/dist/services/player.service.js.map +1 -0
- package/dist/services/search.service.d.ts +7 -0
- package/dist/services/search.service.js +28 -0
- package/dist/services/search.service.js.map +1 -0
- package/dist/spotify/client.d.ts +27 -0
- package/dist/spotify/client.js +113 -0
- package/dist/spotify/client.js.map +1 -0
- package/dist/spotify/scopes.d.ts +1 -0
- package/dist/spotify/scopes.js +5 -0
- package/dist/spotify/scopes.js.map +1 -0
- package/dist/spotify/types.d.ts +50 -0
- package/dist/spotify/types.js +2 -0
- package/dist/spotify/types.js.map +1 -0
- package/dist/storage/credentials.d.ts +28 -0
- package/dist/storage/credentials.js +94 -0
- package/dist/storage/credentials.js.map +1 -0
- package/dist/storage/index.d.ts +2 -0
- package/dist/storage/index.js +2 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/ui/output.d.ts +8 -0
- package/dist/ui/output.js +21 -0
- package/dist/ui/output.js.map +1 -0
- package/dist/ui/prompts.d.ts +2 -0
- package/dist/ui/prompts.js +22 -0
- package/dist/ui/prompts.js.map +1 -0
- package/dist/utils/errors.d.ts +26 -0
- package/dist/utils/errors.js +45 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/time.d.ts +2 -0
- package/dist/utils/time.js +13 -0
- package/dist/utils/time.js.map +1 -0
- package/package.json +60 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { AppError, toError } from '../utils/errors.js';
|
|
3
|
+
const TOKEN_ENDPOINT = 'https://accounts.spotify.com/api/token';
|
|
4
|
+
const CURRENT_USER_ENDPOINT = 'https://api.spotify.com/v1/me';
|
|
5
|
+
const MAX_RATE_LIMIT_RETRIES = 3;
|
|
6
|
+
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
7
|
+
const tokenResponseSchema = z.object({
|
|
8
|
+
access_token: z.string().min(1),
|
|
9
|
+
refresh_token: z.string().min(1).optional(),
|
|
10
|
+
expires_in: z.number().int().positive(),
|
|
11
|
+
});
|
|
12
|
+
const userProfileSchema = z.object({
|
|
13
|
+
id: z.string().min(1),
|
|
14
|
+
display_name: z.string().nullable(),
|
|
15
|
+
product: z.string().optional(),
|
|
16
|
+
});
|
|
17
|
+
export class OAuthTokenError extends AppError {
|
|
18
|
+
code;
|
|
19
|
+
constructor(code, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export class SpotifyAuthClient {
|
|
25
|
+
fetchImplementation;
|
|
26
|
+
sleeper;
|
|
27
|
+
constructor(fetchImplementation = globalThis.fetch, sleeper = sleep) {
|
|
28
|
+
this.fetchImplementation = fetchImplementation;
|
|
29
|
+
this.sleeper = sleeper;
|
|
30
|
+
}
|
|
31
|
+
async exchangeCode(input) {
|
|
32
|
+
return this.requestToken(new URLSearchParams({
|
|
33
|
+
grant_type: 'authorization_code',
|
|
34
|
+
client_id: input.clientId,
|
|
35
|
+
code: input.code,
|
|
36
|
+
redirect_uri: input.redirectUri,
|
|
37
|
+
code_verifier: input.codeVerifier,
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
async refreshAccessToken(input) {
|
|
41
|
+
return this.requestToken(new URLSearchParams({
|
|
42
|
+
grant_type: 'refresh_token',
|
|
43
|
+
client_id: input.clientId,
|
|
44
|
+
refresh_token: input.refreshToken,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
async getCurrentUser(accessToken) {
|
|
48
|
+
const response = await this.fetchWithTimeout(CURRENT_USER_ENDPOINT, {
|
|
49
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
50
|
+
});
|
|
51
|
+
const body = await readJson(response);
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
throw new AppError(getErrorMessage(body, 'Unable to fetch the current Spotify user.'));
|
|
54
|
+
}
|
|
55
|
+
const profile = userProfileSchema.safeParse(body);
|
|
56
|
+
if (!profile.success) {
|
|
57
|
+
throw new AppError('Spotify returned an invalid user profile response.');
|
|
58
|
+
}
|
|
59
|
+
const result = {
|
|
60
|
+
id: profile.data.id,
|
|
61
|
+
displayName: profile.data.display_name ?? profile.data.id,
|
|
62
|
+
};
|
|
63
|
+
if (profile.data.product !== undefined)
|
|
64
|
+
result.product = profile.data.product;
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
async requestToken(parameters) {
|
|
68
|
+
const response = await this.fetchWithTimeout(TOKEN_ENDPOINT, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
71
|
+
body: parameters,
|
|
72
|
+
});
|
|
73
|
+
const body = await readJson(response);
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
const oauthErrorCode = getOAuthErrorCode(body);
|
|
76
|
+
const message = getErrorMessage(body, 'Spotify token request failed.');
|
|
77
|
+
if (oauthErrorCode)
|
|
78
|
+
throw new OAuthTokenError(oauthErrorCode, message);
|
|
79
|
+
throw new AppError(message);
|
|
80
|
+
}
|
|
81
|
+
const token = tokenResponseSchema.safeParse(body);
|
|
82
|
+
if (!token.success) {
|
|
83
|
+
throw new AppError('Spotify returned an invalid token response.');
|
|
84
|
+
}
|
|
85
|
+
const result = {
|
|
86
|
+
accessToken: token.data.access_token,
|
|
87
|
+
expiresIn: token.data.expires_in,
|
|
88
|
+
};
|
|
89
|
+
if (token.data.refresh_token !== undefined) {
|
|
90
|
+
result.refreshToken = token.data.refresh_token;
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
async fetchWithTimeout(url, init) {
|
|
95
|
+
for (let attempt = 0;; attempt += 1) {
|
|
96
|
+
let response;
|
|
97
|
+
try {
|
|
98
|
+
response = await this.fetchImplementation(url, {
|
|
99
|
+
...init,
|
|
100
|
+
signal: AbortSignal.timeout(15_000),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
const normalizedError = toError(error);
|
|
105
|
+
if (normalizedError.name === 'TimeoutError') {
|
|
106
|
+
throw new AppError('Spotify did not respond within 15 seconds. Try again.');
|
|
107
|
+
}
|
|
108
|
+
throw new AppError(`Unable to reach Spotify: ${normalizedError.message}`);
|
|
109
|
+
}
|
|
110
|
+
if (response.status !== 429 || attempt >= MAX_RATE_LIMIT_RETRIES)
|
|
111
|
+
return response;
|
|
112
|
+
const retryAfterSeconds = Number(response.headers.get('retry-after'));
|
|
113
|
+
const retryAfterMs = Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0
|
|
114
|
+
? retryAfterSeconds * 1_000
|
|
115
|
+
: 0;
|
|
116
|
+
await this.sleeper(Math.max(retryAfterMs, 500 * 2 ** attempt));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function readJson(response) {
|
|
121
|
+
const text = await response.text();
|
|
122
|
+
if (!text)
|
|
123
|
+
return {};
|
|
124
|
+
try {
|
|
125
|
+
return JSON.parse(text);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
throw new AppError('Spotify returned a malformed response.');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function getOAuthErrorCode(body) {
|
|
132
|
+
if (!body || typeof body !== 'object')
|
|
133
|
+
return null;
|
|
134
|
+
const error = body.error;
|
|
135
|
+
return typeof error === 'string' ? error : null;
|
|
136
|
+
}
|
|
137
|
+
function getErrorMessage(body, fallback) {
|
|
138
|
+
if (!body || typeof body !== 'object')
|
|
139
|
+
return fallback;
|
|
140
|
+
const record = body;
|
|
141
|
+
if (typeof record.error_description === 'string')
|
|
142
|
+
return record.error_description;
|
|
143
|
+
if (typeof record.error === 'string')
|
|
144
|
+
return `Spotify request failed: ${record.error}`;
|
|
145
|
+
if (record.error && typeof record.error === 'object') {
|
|
146
|
+
const nestedError = record.error;
|
|
147
|
+
if (typeof nestedError.message === 'string')
|
|
148
|
+
return nestedError.message;
|
|
149
|
+
}
|
|
150
|
+
return fallback;
|
|
151
|
+
}
|
|
152
|
+
//# sourceMappingURL=spotify-auth-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spotify-auth-client.js","sourceRoot":"","sources":["../../src/auth/spotify-auth-client.ts"],"names":[],"mappings":"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"}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createProgram } from './app.js';
|
|
3
|
+
import { AuthService } from './services/auth.service.js';
|
|
4
|
+
import { PlayerService } from './services/player.service.js';
|
|
5
|
+
import { SearchService } from './services/search.service.js';
|
|
6
|
+
import { SpotifyClient } from './spotify/client.js';
|
|
7
|
+
import { consoleOutput } from './ui/output.js';
|
|
8
|
+
import { AppError, toError } from './utils/errors.js';
|
|
9
|
+
const auth = new AuthService();
|
|
10
|
+
const spotify = new SpotifyClient(auth);
|
|
11
|
+
const program = createProgram({
|
|
12
|
+
auth,
|
|
13
|
+
player: new PlayerService(spotify),
|
|
14
|
+
search: new SearchService(spotify),
|
|
15
|
+
output: consoleOutput,
|
|
16
|
+
});
|
|
17
|
+
try {
|
|
18
|
+
await program.parseAsync(process.argv);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
const normalizedError = toError(error);
|
|
22
|
+
consoleOutput.error(normalizedError.message);
|
|
23
|
+
process.exitCode = error instanceof AppError ? error.exitCode : 1;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AACzD,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAEtD,MAAM,IAAI,GAAG,IAAI,WAAW,EAAE,CAAC;AAC/B,MAAM,OAAO,GAAG,IAAI,aAAa,CAAC,IAAI,CAAC,CAAC;AACxC,MAAM,OAAO,GAAG,aAAa,CAAC;IAC5B,IAAI;IACJ,MAAM,EAAE,IAAI,aAAa,CAAC,OAAO,CAAC;IAClC,MAAM,EAAE,IAAI,aAAa,CAAC,OAAO,CAAC;IAClC,MAAM,EAAE,aAAa;CACtB,CAAC,CAAC;AAEH,IAAI,CAAC;IACH,MAAM,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;AACzC,CAAC;AAAC,OAAO,KAAK,EAAE,CAAC;IACf,MAAM,eAAe,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC;IACvC,aAAa,CAAC,KAAK,CAAC,eAAe,CAAC,OAAO,CAAC,CAAC;IAC7C,OAAO,CAAC,QAAQ,GAAG,KAAK,YAAY,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;AACpE,CAAC"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { UserProfile } from './models.js';
|
|
2
|
+
import { type PkcePair } from '../auth/pkce.js';
|
|
3
|
+
import { type AuthConfig } from '../auth/config.js';
|
|
4
|
+
import { type SpotifyAuthApi } from '../auth/spotify-auth-client.js';
|
|
5
|
+
import { type CredentialStore } from '../storage/credentials.js';
|
|
6
|
+
type AuthorizationCodeProvider = (authorizationUrl: string, redirectUri: string, expectedState: string) => Promise<string>;
|
|
7
|
+
export interface AuthServiceDependencies {
|
|
8
|
+
credentialStore?: CredentialStore;
|
|
9
|
+
spotifyAuthApi?: SpotifyAuthApi;
|
|
10
|
+
loadConfig?: () => AuthConfig;
|
|
11
|
+
requestAuthorizationCode?: AuthorizationCodeProvider;
|
|
12
|
+
createPkcePair?: () => PkcePair;
|
|
13
|
+
createState?: () => string;
|
|
14
|
+
now?: () => number;
|
|
15
|
+
}
|
|
16
|
+
export declare class AuthService {
|
|
17
|
+
private readonly credentialStore;
|
|
18
|
+
private readonly spotifyAuthApi;
|
|
19
|
+
private readonly configLoader;
|
|
20
|
+
private readonly authorizationCodeProvider;
|
|
21
|
+
private readonly pkceFactory;
|
|
22
|
+
private readonly stateFactory;
|
|
23
|
+
private readonly clock;
|
|
24
|
+
private refreshPromise;
|
|
25
|
+
constructor(dependencies?: AuthServiceDependencies);
|
|
26
|
+
login(): Promise<UserProfile>;
|
|
27
|
+
logout(): Promise<void>;
|
|
28
|
+
getAccessToken(): Promise<string>;
|
|
29
|
+
forceRefreshAccessToken(): Promise<string>;
|
|
30
|
+
getCurrentUser(): Promise<UserProfile>;
|
|
31
|
+
isAuthenticated(): Promise<boolean>;
|
|
32
|
+
private startTokenRefresh;
|
|
33
|
+
private performTokenRefresh;
|
|
34
|
+
}
|
|
35
|
+
export declare function createAuthorizationUrl(config: AuthConfig, codeChallenge: string, state: string): string;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { SPOTIFY_SCOPES } from '../spotify/scopes.js';
|
|
2
|
+
import { generateOAuthState, generatePkcePair, } from '../auth/pkce.js';
|
|
3
|
+
import { loadAuthConfig } from '../auth/config.js';
|
|
4
|
+
import { requestAuthorizationCode } from '../auth/callback.js';
|
|
5
|
+
import { OAuthTokenError, SpotifyAuthClient, } from '../auth/spotify-auth-client.js';
|
|
6
|
+
import { FileCredentialStore, isTokenExpired, } from '../storage/credentials.js';
|
|
7
|
+
import { AuthenticationRequiredError, ConfigurationError, } from '../utils/errors.js';
|
|
8
|
+
const AUTHORIZE_ENDPOINT = 'https://accounts.spotify.com/authorize';
|
|
9
|
+
export class AuthService {
|
|
10
|
+
credentialStore;
|
|
11
|
+
spotifyAuthApi;
|
|
12
|
+
configLoader;
|
|
13
|
+
authorizationCodeProvider;
|
|
14
|
+
pkceFactory;
|
|
15
|
+
stateFactory;
|
|
16
|
+
clock;
|
|
17
|
+
refreshPromise;
|
|
18
|
+
constructor(dependencies = {}) {
|
|
19
|
+
this.credentialStore = dependencies.credentialStore ?? new FileCredentialStore();
|
|
20
|
+
this.spotifyAuthApi = dependencies.spotifyAuthApi ?? new SpotifyAuthClient();
|
|
21
|
+
this.configLoader = dependencies.loadConfig ?? loadAuthConfig;
|
|
22
|
+
this.authorizationCodeProvider =
|
|
23
|
+
dependencies.requestAuthorizationCode ?? requestAuthorizationCode;
|
|
24
|
+
this.pkceFactory = dependencies.createPkcePair ?? generatePkcePair;
|
|
25
|
+
this.stateFactory = dependencies.createState ?? generateOAuthState;
|
|
26
|
+
this.clock = dependencies.now ?? Date.now;
|
|
27
|
+
}
|
|
28
|
+
async login() {
|
|
29
|
+
const config = this.configLoader();
|
|
30
|
+
const pkce = this.pkceFactory();
|
|
31
|
+
const state = this.stateFactory();
|
|
32
|
+
const authorizationUrl = createAuthorizationUrl(config, pkce.challenge, state);
|
|
33
|
+
const code = await this.authorizationCodeProvider(authorizationUrl, config.redirectUri, state);
|
|
34
|
+
const token = await this.spotifyAuthApi.exchangeCode({
|
|
35
|
+
clientId: config.clientId,
|
|
36
|
+
code,
|
|
37
|
+
redirectUri: config.redirectUri,
|
|
38
|
+
codeVerifier: pkce.verifier,
|
|
39
|
+
});
|
|
40
|
+
if (!token.refreshToken) {
|
|
41
|
+
throw new ConfigurationError('Spotify did not return a refresh token during login.');
|
|
42
|
+
}
|
|
43
|
+
const credentials = {
|
|
44
|
+
accessToken: token.accessToken,
|
|
45
|
+
refreshToken: token.refreshToken,
|
|
46
|
+
expiresAt: this.clock() + token.expiresIn * 1_000,
|
|
47
|
+
};
|
|
48
|
+
const user = await this.spotifyAuthApi.getCurrentUser(credentials.accessToken);
|
|
49
|
+
await this.credentialStore.write(credentials);
|
|
50
|
+
return user;
|
|
51
|
+
}
|
|
52
|
+
async logout() {
|
|
53
|
+
await this.credentialStore.delete();
|
|
54
|
+
}
|
|
55
|
+
async getAccessToken() {
|
|
56
|
+
const credentials = await this.credentialStore.read();
|
|
57
|
+
if (!credentials)
|
|
58
|
+
throw new AuthenticationRequiredError();
|
|
59
|
+
if (!isTokenExpired(credentials, this.clock()))
|
|
60
|
+
return credentials.accessToken;
|
|
61
|
+
return this.startTokenRefresh(credentials);
|
|
62
|
+
}
|
|
63
|
+
async forceRefreshAccessToken() {
|
|
64
|
+
const credentials = await this.credentialStore.read();
|
|
65
|
+
if (!credentials)
|
|
66
|
+
throw new AuthenticationRequiredError();
|
|
67
|
+
return this.startTokenRefresh(credentials);
|
|
68
|
+
}
|
|
69
|
+
async getCurrentUser() {
|
|
70
|
+
const accessToken = await this.getAccessToken();
|
|
71
|
+
return this.spotifyAuthApi.getCurrentUser(accessToken);
|
|
72
|
+
}
|
|
73
|
+
async isAuthenticated() {
|
|
74
|
+
return (await this.credentialStore.read()) !== null;
|
|
75
|
+
}
|
|
76
|
+
startTokenRefresh(credentials) {
|
|
77
|
+
if (!this.refreshPromise) {
|
|
78
|
+
const refreshPromise = this.performTokenRefresh(credentials);
|
|
79
|
+
this.refreshPromise = refreshPromise;
|
|
80
|
+
void refreshPromise
|
|
81
|
+
.finally(() => {
|
|
82
|
+
if (this.refreshPromise === refreshPromise)
|
|
83
|
+
this.refreshPromise = undefined;
|
|
84
|
+
})
|
|
85
|
+
.catch(() => undefined);
|
|
86
|
+
}
|
|
87
|
+
return this.refreshPromise;
|
|
88
|
+
}
|
|
89
|
+
async performTokenRefresh(credentials) {
|
|
90
|
+
const config = this.configLoader();
|
|
91
|
+
let token;
|
|
92
|
+
try {
|
|
93
|
+
token = await this.spotifyAuthApi.refreshAccessToken({
|
|
94
|
+
clientId: config.clientId,
|
|
95
|
+
refreshToken: credentials.refreshToken,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (error instanceof OAuthTokenError && error.code === 'invalid_grant') {
|
|
100
|
+
await this.credentialStore.delete();
|
|
101
|
+
throw new AuthenticationRequiredError('Your Spotify session has expired or was revoked.\n\nRun: spoti login');
|
|
102
|
+
}
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
const updatedCredentials = {
|
|
106
|
+
accessToken: token.accessToken,
|
|
107
|
+
refreshToken: token.refreshToken ?? credentials.refreshToken,
|
|
108
|
+
expiresAt: this.clock() + token.expiresIn * 1_000,
|
|
109
|
+
};
|
|
110
|
+
await this.credentialStore.write(updatedCredentials);
|
|
111
|
+
return updatedCredentials.accessToken;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export function createAuthorizationUrl(config, codeChallenge, state) {
|
|
115
|
+
const url = new URL(AUTHORIZE_ENDPOINT);
|
|
116
|
+
url.search = new URLSearchParams({
|
|
117
|
+
client_id: config.clientId,
|
|
118
|
+
response_type: 'code',
|
|
119
|
+
redirect_uri: config.redirectUri,
|
|
120
|
+
code_challenge_method: 'S256',
|
|
121
|
+
code_challenge: codeChallenge,
|
|
122
|
+
state,
|
|
123
|
+
scope: SPOTIFY_SCOPES.join(' '),
|
|
124
|
+
}).toString();
|
|
125
|
+
return url.toString();
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=auth.service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.service.js","sourceRoot":"","sources":["../../src/services/auth.service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAEtD,OAAO,EACL,kBAAkB,EAClB,gBAAgB,GAEjB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,cAAc,EAAmB,MAAM,mBAAmB,CAAC;AACpE,OAAO,EAAE,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AAC/D,OAAO,EACL,eAAe,EACf,iBAAiB,GAGlB,MAAM,gCAAgC,CAAC;AACxC,OAAO,EACL,mBAAmB,EACnB,cAAc,GAGf,MAAM,2BAA2B,CAAC;AACnC,OAAO,EACL,2BAA2B,EAC3B,kBAAkB,GACnB,MAAM,oBAAoB,CAAC;AAE5B,MAAM,kBAAkB,GAAG,wCAAwC,CAAC;AAkBpE,MAAM,OAAO,WAAW;IACL,eAAe,CAAkB;IACjC,cAAc,CAAiB;IAC/B,YAAY,CAAmB;IAC/B,yBAAyB,CAA4B;IACrD,WAAW,CAAiB;IAC5B,YAAY,CAAe;IAC3B,KAAK,CAAe;IAC7B,cAAc,CAA8B;IAEpD,YAAY,eAAwC,EAAE;QACpD,IAAI,CAAC,eAAe,GAAG,YAAY,CAAC,eAAe,IAAI,IAAI,mBAAmB,EAAE,CAAC;QACjF,IAAI,CAAC,cAAc,GAAG,YAAY,CAAC,cAAc,IAAI,IAAI,iBAAiB,EAAE,CAAC;QAC7E,IAAI,CAAC,YAAY,GAAG,YAAY,CAAC,UAAU,IAAI,cAAc,CAAC;QAC9D,IAAI,CAAC,yBAAyB;YAC5B,YAAY,CAAC,wBAAwB,IAAI,wBAAwB,CAAC;QACpE,IAAI,CAAC,WAAW,GAAG,YAAY,CAAC,cAAc,IAAI,gBAAgB,CAAC;QACnE,IAAI,CAAC,YAAY,GAAG,YAAY,CAAC,WAAW,IAAI,kBAAkB,CAAC;QACnE,IAAI,CAAC,KAAK,GAAG,YAAY,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC;IAC5C,CAAC;IAED,KAAK,CAAC,KAAK;QACT,MAAM,MAAM,GAAG,IAAI,CAAC,YAAY,EAAE,CAAC;QACnC,MAAM,IAAI,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;QAChC,MAAM,KAAK,GAAG,IAAI,CAAC,YAAY,EAAE,CAAC;QAClC,MAAM,gBAAgB,GAAG,sBAAsB,CAAC,MAAM,EAAE,IAAI,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;QAC/E,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,yBAAyB,CAC/C,gBAAgB,EAChB,MAAM,CAAC,WAAW,EAClB,KAAK,CACN,CAAC;QACF,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,cAAc,CAAC,YAAY,CAAC;YACnD,QAAQ,EAAE,MAAM,CAAC,QAAQ;YACzB,IAAI;YACJ,WAAW,EAAE,MAAM,CAAC,WAAW;YAC/B,YAAY,EAAE,IAAI,CAAC,QAAQ;SAC5B,CAAC,CAAC;QAEH,IAAI,CAAC,KAAK,CAAC,YAAY,EAAE,CAAC;YACxB,MAAM,IAAI,kBAAkB,CAAC,sDAAsD,CAAC,CAAC;QACvF,CAAC;QAED,MAAM,WAAW,GAAgB;YAC/B,WAAW,EAAE,KAAK,CAAC,WAAW;YAC9B,YAAY,EAAE,KAAK,CAAC,YAAY;YAChC,SAAS,EAAE,IAAI,CAAC,KAAK,EAAE,GAAG,KAAK,CAAC,SAAS,GAAG,KAAK;SAClD,CAAC;QACF,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,WAAW,CAAC,WAAW,CAAC,CAAC;QAC/E,MAAM,IAAI,CAAC,eAAe,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;QAC9C,OAAO,IAAI,CAAC;IACd,CAAC;IAED,KAAK,CAAC,MAAM;QACV,MAAM,IAAI,CAAC,eAAe,CAAC,MAAM,EAAE,CAAC;IACtC,CAAC;IAED,KAAK,CAAC,cAAc;QAClB,MAAM,WAAW,GAAG,MAAM,IAAI,CAAC,eAAe,CAAC,IAAI,EAAE,CAAC;QACtD,IAAI,CAAC,WAAW;YAAE,MAAM,IAAI,2BAA2B,EAAE,CAAC;QAC1D,IAAI,CAAC,cAAc,CAAC,WAAW,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC;YAAE,OAAO,WAAW,CAAC,WAAW,CAAC;QAE/E,OAAO,IAAI,CAAC,iBAAiB,CAAC,WAAW,CAAC,CAAC;IAC7C,CAAC;IAED,KAAK,CAAC,uBAAuB;QAC3B,MAAM,WAAW,GAAG,MAAM,IAAI,CAAC,eAAe,CAAC,IAAI,EAAE,CAAC;QACtD,IAAI,CAAC,WAAW;YAAE,MAAM,IAAI,2BAA2B,EAAE,CAAC;QAC1D,OAAO,IAAI,CAAC,iBAAiB,CAAC,WAAW,CAAC,CAAC;IAC7C,CAAC;IAED,KAAK,CAAC,cAAc;QAClB,MAAM,WAAW,GAAG,MAAM,IAAI,CAAC,cAAc,EAAE,CAAC;QAChD,OAAO,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,WAAW,CAAC,CAAC;IACzD,CAAC;IAED,KAAK,CAAC,eAAe;QACnB,OAAO,CAAC,MAAM,IAAI,CAAC,eAAe,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAC;IACtD,CAAC;IAEO,iBAAiB,CAAC,WAAwB;QAChD,IAAI,CAAC,IAAI,CAAC,cAAc,EAAE,CAAC;YACzB,MAAM,cAAc,GAAG,IAAI,CAAC,mBAAmB,CAAC,WAAW,CAAC,CAAC;YAC7D,IAAI,CAAC,cAAc,GAAG,cAAc,CAAC;YACrC,KAAK,cAAc;iBAChB,OAAO,CAAC,GAAG,EAAE;gBACZ,IAAI,IAAI,CAAC,cAAc,KAAK,cAAc;oBAAE,IAAI,CAAC,cAAc,GAAG,SAAS,CAAC;YAC9E,CAAC,CAAC;iBACD,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;QAC5B,CAAC;QACD,OAAO,IAAI,CAAC,cAAc,CAAC;IAC7B,CAAC;IAEO,KAAK,CAAC,mBAAmB,CAAC,WAAwB;QACxD,MAAM,MAAM,GAAG,IAAI,CAAC,YAAY,EAAE,CAAC;QACnC,IAAI,KAAe,CAAC;QACpB,IAAI,CAAC;YACH,KAAK,GAAG,MAAM,IAAI,CAAC,cAAc,CAAC,kBAAkB,CAAC;gBACnD,QAAQ,EAAE,MAAM,CAAC,QAAQ;gBACzB,YAAY,EAAE,WAAW,CAAC,YAAY;aACvC,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,KAAK,YAAY,eAAe,IAAI,KAAK,CAAC,IAAI,KAAK,eAAe,EAAE,CAAC;gBACvE,MAAM,IAAI,CAAC,eAAe,CAAC,MAAM,EAAE,CAAC;gBACpC,MAAM,IAAI,2BAA2B,CACnC,sEAAsE,CACvE,CAAC;YACJ,CAAC;YACD,MAAM,KAAK,CAAC;QACd,CAAC;QACD,MAAM,kBAAkB,GAAgB;YACtC,WAAW,EAAE,KAAK,CAAC,WAAW;YAC9B,YAAY,EAAE,KAAK,CAAC,YAAY,IAAI,WAAW,CAAC,YAAY;YAC5D,SAAS,EAAE,IAAI,CAAC,KAAK,EAAE,GAAG,KAAK,CAAC,SAAS,GAAG,KAAK;SAClD,CAAC;QACF,MAAM,IAAI,CAAC,eAAe,CAAC,KAAK,CAAC,kBAAkB,CAAC,CAAC;QACrD,OAAO,kBAAkB,CAAC,WAAW,CAAC;IACxC,CAAC;CACF;AAED,MAAM,UAAU,sBAAsB,CACpC,MAAkB,EAClB,aAAqB,EACrB,KAAa;IAEb,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,kBAAkB,CAAC,CAAC;IACxC,GAAG,CAAC,MAAM,GAAG,IAAI,eAAe,CAAC;QAC/B,SAAS,EAAE,MAAM,CAAC,QAAQ;QAC1B,aAAa,EAAE,MAAM;QACrB,YAAY,EAAE,MAAM,CAAC,WAAW;QAChC,qBAAqB,EAAE,MAAM;QAC7B,cAAc,EAAE,aAAa;QAC7B,KAAK;QACL,KAAK,EAAE,cAAc,CAAC,IAAI,CAAC,GAAG,CAAC;KAChC,CAAC,CAAC,QAAQ,EAAE,CAAC;IACd,OAAO,GAAG,CAAC,QAAQ,EAAE,CAAC;AACxB,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface UserProfile {
|
|
2
|
+
id: string;
|
|
3
|
+
displayName: string;
|
|
4
|
+
product?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface Track {
|
|
7
|
+
id: string;
|
|
8
|
+
uri: string;
|
|
9
|
+
name: string;
|
|
10
|
+
artists: string[];
|
|
11
|
+
album: string;
|
|
12
|
+
durationMs: number;
|
|
13
|
+
externalUrl?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface CurrentPlayback {
|
|
16
|
+
isPlaying: boolean;
|
|
17
|
+
track: Track;
|
|
18
|
+
progressMs: number;
|
|
19
|
+
deviceName?: string;
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"models.js","sourceRoot":"","sources":["../../src/services/models.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SpotifyApi } from '../spotify/client.js';
|
|
2
|
+
import type { CurrentPlayback } from './models.js';
|
|
3
|
+
export declare class PlayerService {
|
|
4
|
+
private readonly spotify;
|
|
5
|
+
constructor(spotify: SpotifyApi);
|
|
6
|
+
getCurrentPlayback(): Promise<CurrentPlayback | null>;
|
|
7
|
+
playTrack(uri: string): Promise<void>;
|
|
8
|
+
pause(): Promise<void>;
|
|
9
|
+
resume(): Promise<void>;
|
|
10
|
+
next(): Promise<void>;
|
|
11
|
+
previous(): Promise<void>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export class PlayerService {
|
|
2
|
+
spotify;
|
|
3
|
+
constructor(spotify) {
|
|
4
|
+
this.spotify = spotify;
|
|
5
|
+
}
|
|
6
|
+
async getCurrentPlayback() {
|
|
7
|
+
const playback = await this.spotify.get('/me/player');
|
|
8
|
+
if (!playback?.item)
|
|
9
|
+
return null;
|
|
10
|
+
return {
|
|
11
|
+
isPlaying: playback.is_playing,
|
|
12
|
+
track: mapTrack(playback.item),
|
|
13
|
+
progressMs: playback.progress_ms ?? 0,
|
|
14
|
+
deviceName: playback.device.name,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
async playTrack(uri) {
|
|
18
|
+
await this.spotify.put('/me/player/play', { body: { uris: [uri] } });
|
|
19
|
+
}
|
|
20
|
+
async pause() {
|
|
21
|
+
await this.spotify.put('/me/player/pause');
|
|
22
|
+
}
|
|
23
|
+
async resume() {
|
|
24
|
+
await this.spotify.put('/me/player/play');
|
|
25
|
+
}
|
|
26
|
+
async next() {
|
|
27
|
+
await this.spotify.post('/me/player/next');
|
|
28
|
+
}
|
|
29
|
+
async previous() {
|
|
30
|
+
await this.spotify.post('/me/player/previous');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function mapTrack(track) {
|
|
34
|
+
const externalUrl = track.external_urls?.spotify;
|
|
35
|
+
return {
|
|
36
|
+
id: track.id,
|
|
37
|
+
uri: track.uri,
|
|
38
|
+
name: track.name,
|
|
39
|
+
artists: track.artists.map((artist) => artist.name),
|
|
40
|
+
album: track.album.name,
|
|
41
|
+
durationMs: track.duration_ms,
|
|
42
|
+
...(externalUrl ? { externalUrl } : {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=player.service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"player.service.js","sourceRoot":"","sources":["../../src/services/player.service.ts"],"names":[],"mappings":"AAIA,MAAM,OAAO,aAAa;IACK;IAA7B,YAA6B,OAAmB;QAAnB,YAAO,GAAP,OAAO,CAAY;IAAG,CAAC;IAEpD,KAAK,CAAC,kBAAkB;QACtB,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,GAAG,CAAmC,YAAY,CAAC,CAAC;QACxF,IAAI,CAAC,QAAQ,EAAE,IAAI;YAAE,OAAO,IAAI,CAAC;QACjC,OAAO;YACL,SAAS,EAAE,QAAQ,CAAC,UAAU;YAC9B,KAAK,EAAE,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC;YAC9B,UAAU,EAAE,QAAQ,CAAC,WAAW,IAAI,CAAC;YACrC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,IAAI;SACjC,CAAC;IACJ,CAAC;IAED,KAAK,CAAC,SAAS,CAAC,GAAW;QACzB,MAAM,IAAI,CAAC,OAAO,CAAC,GAAG,CAAO,iBAAiB,EAAE,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC;IAC7E,CAAC;IAED,KAAK,CAAC,KAAK;QACT,MAAM,IAAI,CAAC,OAAO,CAAC,GAAG,CAAO,kBAAkB,CAAC,CAAC;IACnD,CAAC;IAED,KAAK,CAAC,MAAM;QACV,MAAM,IAAI,CAAC,OAAO,CAAC,GAAG,CAAO,iBAAiB,CAAC,CAAC;IAClD,CAAC;IAED,KAAK,CAAC,IAAI;QACR,MAAM,IAAI,CAAC,OAAO,CAAC,IAAI,CAAO,iBAAiB,CAAC,CAAC;IACnD,CAAC;IAED,KAAK,CAAC,QAAQ;QACZ,MAAM,IAAI,CAAC,OAAO,CAAC,IAAI,CAAO,qBAAqB,CAAC,CAAC;IACvD,CAAC;CACF;AAED,SAAS,QAAQ,CAAC,KAAwC;IACxD,MAAM,WAAW,GAAG,KAAK,CAAC,aAAa,EAAE,OAAO,CAAC;IACjD,OAAO;QACL,EAAE,EAAE,KAAK,CAAC,EAAE;QACZ,GAAG,EAAE,KAAK,CAAC,GAAG;QACd,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,OAAO,EAAE,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC;QACnD,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,IAAI;QACvB,UAAU,EAAE,KAAK,CAAC,WAAW;QAC7B,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACxC,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SpotifyApi } from '../spotify/client.js';
|
|
2
|
+
import type { Track } from './models.js';
|
|
3
|
+
export declare class SearchService {
|
|
4
|
+
private readonly spotify;
|
|
5
|
+
constructor(spotify: SpotifyApi);
|
|
6
|
+
searchTracks(query: string, limit?: number): Promise<Track[]>;
|
|
7
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export class SearchService {
|
|
2
|
+
spotify;
|
|
3
|
+
constructor(spotify) {
|
|
4
|
+
this.spotify = spotify;
|
|
5
|
+
}
|
|
6
|
+
async searchTracks(query, limit = 10) {
|
|
7
|
+
const normalizedQuery = query.trim();
|
|
8
|
+
if (!normalizedQuery)
|
|
9
|
+
return [];
|
|
10
|
+
const response = await this.spotify.get('/search', {
|
|
11
|
+
query: { q: normalizedQuery, type: 'track', limit },
|
|
12
|
+
});
|
|
13
|
+
return response.tracks.items.map(mapTrack);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function mapTrack(track) {
|
|
17
|
+
const externalUrl = track.external_urls?.spotify;
|
|
18
|
+
return {
|
|
19
|
+
id: track.id,
|
|
20
|
+
uri: track.uri,
|
|
21
|
+
name: track.name,
|
|
22
|
+
artists: track.artists.map((artist) => artist.name),
|
|
23
|
+
album: track.album.name,
|
|
24
|
+
durationMs: track.duration_ms,
|
|
25
|
+
...(externalUrl ? { externalUrl } : {}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=search.service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search.service.js","sourceRoot":"","sources":["../../src/services/search.service.ts"],"names":[],"mappings":"AAIA,MAAM,OAAO,aAAa;IACK;IAA7B,YAA6B,OAAmB;QAAnB,YAAO,GAAP,OAAO,CAAY;IAAG,CAAC;IAEpD,KAAK,CAAC,YAAY,CAAC,KAAa,EAAE,KAAK,GAAG,EAAE;QAC1C,MAAM,eAAe,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;QACrC,IAAI,CAAC,eAAe;YAAE,OAAO,EAAE,CAAC;QAChC,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,GAAG,CAAwB,SAAS,EAAE;YACxE,KAAK,EAAE,EAAE,CAAC,EAAE,eAAe,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE;SACpD,CAAC,CAAC;QACH,OAAO,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IAC7C,CAAC;CACF;AAED,SAAS,QAAQ,CAAC,KAAmB;IACnC,MAAM,WAAW,GAAG,KAAK,CAAC,aAAa,EAAE,OAAO,CAAC;IACjD,OAAO;QACL,EAAE,EAAE,KAAK,CAAC,EAAE;QACZ,GAAG,EAAE,KAAK,CAAC,GAAG;QACd,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,OAAO,EAAE,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC;QACnD,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,IAAI;QACvB,UAAU,EAAE,KAAK,CAAC,WAAW;QAC7B,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACxC,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
type Sleep = (milliseconds: number) => Promise<void>;
|
|
2
|
+
export interface RequestOptions {
|
|
3
|
+
query?: Record<string, string | number | boolean | undefined>;
|
|
4
|
+
body?: unknown;
|
|
5
|
+
}
|
|
6
|
+
export interface SpotifyApi {
|
|
7
|
+
get<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
8
|
+
post<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
9
|
+
put<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
10
|
+
delete<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
11
|
+
}
|
|
12
|
+
export interface AccessTokenProvider {
|
|
13
|
+
getAccessToken(): Promise<string>;
|
|
14
|
+
forceRefreshAccessToken(): Promise<string>;
|
|
15
|
+
}
|
|
16
|
+
export declare class SpotifyClient implements SpotifyApi {
|
|
17
|
+
private readonly authService;
|
|
18
|
+
private readonly fetcher;
|
|
19
|
+
private readonly sleeper;
|
|
20
|
+
constructor(authService: AccessTokenProvider, fetcher?: typeof fetch, sleeper?: Sleep);
|
|
21
|
+
get<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
22
|
+
post<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
23
|
+
put<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
24
|
+
delete<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
25
|
+
private request;
|
|
26
|
+
}
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { AppError, AuthenticationRequiredError, NoActiveDeviceError, PremiumRequiredError, RateLimitedError, SpotifyApiError, toError, } from '../utils/errors.js';
|
|
2
|
+
const API_BASE_URL = 'https://api.spotify.com/v1';
|
|
3
|
+
const MAX_RATE_LIMIT_RETRIES = 3;
|
|
4
|
+
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
5
|
+
export class SpotifyClient {
|
|
6
|
+
authService;
|
|
7
|
+
fetcher;
|
|
8
|
+
sleeper;
|
|
9
|
+
constructor(authService, fetcher = fetch, sleeper = sleep) {
|
|
10
|
+
this.authService = authService;
|
|
11
|
+
this.fetcher = fetcher;
|
|
12
|
+
this.sleeper = sleeper;
|
|
13
|
+
}
|
|
14
|
+
get(path, options) {
|
|
15
|
+
return this.request('GET', path, options);
|
|
16
|
+
}
|
|
17
|
+
post(path, options) {
|
|
18
|
+
return this.request('POST', path, options);
|
|
19
|
+
}
|
|
20
|
+
put(path, options) {
|
|
21
|
+
return this.request('PUT', path, options);
|
|
22
|
+
}
|
|
23
|
+
delete(path, options) {
|
|
24
|
+
return this.request('DELETE', path, options);
|
|
25
|
+
}
|
|
26
|
+
async request(method, path, options = {}) {
|
|
27
|
+
const url = new URL(`${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`);
|
|
28
|
+
for (const [key, value] of Object.entries(options.query ?? {})) {
|
|
29
|
+
if (value !== undefined)
|
|
30
|
+
url.searchParams.set(key, String(value));
|
|
31
|
+
}
|
|
32
|
+
const headers = new Headers();
|
|
33
|
+
let body;
|
|
34
|
+
if (options.body !== undefined) {
|
|
35
|
+
headers.set('Content-Type', 'application/json');
|
|
36
|
+
body = JSON.stringify(options.body);
|
|
37
|
+
}
|
|
38
|
+
const execute = async (token) => {
|
|
39
|
+
headers.set('Authorization', `Bearer ${token}`);
|
|
40
|
+
try {
|
|
41
|
+
return await this.fetcher(url, {
|
|
42
|
+
method,
|
|
43
|
+
headers,
|
|
44
|
+
signal: AbortSignal.timeout(15_000),
|
|
45
|
+
...(body === undefined ? {} : { body }),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
const normalizedError = toError(error);
|
|
50
|
+
if (normalizedError.name === 'TimeoutError') {
|
|
51
|
+
throw new AppError('Spotify did not respond within 15 seconds. Try again.');
|
|
52
|
+
}
|
|
53
|
+
throw new AppError(`Unable to reach Spotify: ${normalizedError.message}`);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
let accessToken = await this.authService.getAccessToken();
|
|
57
|
+
let refreshedToken = false;
|
|
58
|
+
let rateLimitRetries = 0;
|
|
59
|
+
let response;
|
|
60
|
+
while (true) {
|
|
61
|
+
response = await execute(accessToken);
|
|
62
|
+
if (response.status === 401 && !refreshedToken) {
|
|
63
|
+
accessToken = await this.authService.forceRefreshAccessToken();
|
|
64
|
+
refreshedToken = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (response.status === 429 && rateLimitRetries < MAX_RATE_LIMIT_RETRIES) {
|
|
68
|
+
const retryAfterMs = getRetryAfterMilliseconds(response);
|
|
69
|
+
const exponentialBackoffMs = 500 * 2 ** rateLimitRetries;
|
|
70
|
+
rateLimitRetries += 1;
|
|
71
|
+
await this.sleeper(Math.max(retryAfterMs, exponentialBackoffMs));
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
if (!response.ok)
|
|
77
|
+
throw await mapSpotifyError(response);
|
|
78
|
+
if (response.status === 204)
|
|
79
|
+
return undefined;
|
|
80
|
+
const text = await response.text();
|
|
81
|
+
return (text ? JSON.parse(text) : undefined);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function getRetryAfterMilliseconds(response) {
|
|
85
|
+
const seconds = Number(response.headers.get('retry-after'));
|
|
86
|
+
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1_000 : 0;
|
|
87
|
+
}
|
|
88
|
+
async function mapSpotifyError(response) {
|
|
89
|
+
const body = await readErrorBody(response);
|
|
90
|
+
const spotifyMessage = typeof body.error === 'object' ? body.error?.message : body.error_description;
|
|
91
|
+
const message = spotifyMessage || `Spotify API request failed with HTTP ${response.status}.`;
|
|
92
|
+
if (response.status === 401) {
|
|
93
|
+
return new AuthenticationRequiredError('Spotify authentication has expired or was revoked.\n\nRun: spoti login');
|
|
94
|
+
}
|
|
95
|
+
if (response.status === 403 && /premium/i.test(message))
|
|
96
|
+
return new PremiumRequiredError();
|
|
97
|
+
if (response.status === 404 && /device/i.test(message))
|
|
98
|
+
return new NoActiveDeviceError();
|
|
99
|
+
if (response.status === 429) {
|
|
100
|
+
const retryAfter = Number(response.headers.get('retry-after') ?? '1');
|
|
101
|
+
return new RateLimitedError(Number.isFinite(retryAfter) ? retryAfter : 1);
|
|
102
|
+
}
|
|
103
|
+
return new SpotifyApiError(message, response.status);
|
|
104
|
+
}
|
|
105
|
+
async function readErrorBody(response) {
|
|
106
|
+
try {
|
|
107
|
+
return (await response.json());
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return {};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=client.js.map
|