morille 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/README.md +82 -0
- package/dist/app.d.ts +5 -0
- package/dist/app.js +66 -0
- package/dist/components/animated-line.d.ts +12 -0
- package/dist/components/animated-line.js +14 -0
- package/dist/components/browse-detail-view.d.ts +14 -0
- package/dist/components/browse-detail-view.js +31 -0
- package/dist/components/keyboard-hints.d.ts +12 -0
- package/dist/components/keyboard-hints.js +8 -0
- package/dist/components/lyrics-panel.d.ts +10 -0
- package/dist/components/lyrics-panel.js +38 -0
- package/dist/components/lyrics-view.d.ts +14 -0
- package/dist/components/lyrics-view.js +50 -0
- package/dist/components/panel-content.d.ts +10 -0
- package/dist/components/panel-content.js +22 -0
- package/dist/components/playback-status.d.ts +16 -0
- package/dist/components/playback-status.js +22 -0
- package/dist/components/player.d.ts +9 -0
- package/dist/components/player.d.ts.map +1 -0
- package/dist/components/player.js +215 -0
- package/dist/components/player.js.map +1 -0
- package/dist/components/progress-bar.d.ts +11 -0
- package/dist/components/progress-bar.js +13 -0
- package/dist/components/queue-view.d.ts +9 -0
- package/dist/components/queue-view.js +54 -0
- package/dist/components/search-panel.d.ts +8 -0
- package/dist/components/search-panel.js +152 -0
- package/dist/components/shimmer.d.ts +12 -0
- package/dist/components/shimmer.js +34 -0
- package/dist/components/side-panel.d.ts +12 -0
- package/dist/components/side-panel.js +12 -0
- package/dist/components/track-info-skeleton.d.ts +9 -0
- package/dist/components/track-info-skeleton.js +10 -0
- package/dist/components/track-info.d.ts +10 -0
- package/dist/components/track-info.js +15 -0
- package/dist/config.d.ts +33 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +65 -0
- package/dist/config.js.map +1 -0
- package/dist/contexts/lyrics-context.d.ts +29 -0
- package/dist/contexts/lyrics-context.js +44 -0
- package/dist/contexts/panel-mode-context.d.ts +24 -0
- package/dist/contexts/panel-mode-context.js +45 -0
- package/dist/contexts/queue-context.d.ts +32 -0
- package/dist/contexts/queue-context.js +68 -0
- package/dist/contexts/search-context.d.ts +59 -0
- package/dist/contexts/search-context.js +338 -0
- package/dist/hooks/use-album-art.d.ts +8 -0
- package/dist/hooks/use-album-art.js +56 -0
- package/dist/hooks/use-browse.d.ts +29 -0
- package/dist/hooks/use-browse.js +98 -0
- package/dist/hooks/use-lyrics.d.ts +12 -0
- package/dist/hooks/use-lyrics.js +51 -0
- package/dist/hooks/use-playback.d.ts +24 -0
- package/dist/hooks/use-playback.js +282 -0
- package/dist/hooks/use-player-input.d.ts +18 -0
- package/dist/hooks/use-player-input.js +201 -0
- package/dist/hooks/use-queue.d.ts +28 -0
- package/dist/hooks/use-queue.js +194 -0
- package/dist/hooks/use-search.d.ts +16 -0
- package/dist/hooks/use-search.js +77 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +10 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +6 -0
- package/dist/spotify/auth.d.ts +36 -0
- package/dist/spotify/auth.js +183 -0
- package/dist/spotify/client.d.ts +18 -0
- package/dist/spotify/client.js +48 -0
- package/dist/spotify/fetch-with-retry.d.ts +6 -0
- package/dist/spotify/fetch-with-retry.js +70 -0
- package/dist/spotify/lyrics.d.ts +25 -0
- package/dist/spotify/lyrics.js +130 -0
- package/dist/spotify/playback.d.ts +115 -0
- package/dist/spotify/playback.js +201 -0
- package/dist/spotify/search.d.ts +79 -0
- package/dist/spotify/search.js +143 -0
- package/package.json +33 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
async function resolveQueueItems(client, context, cacheRef) {
|
|
3
|
+
if (context) {
|
|
4
|
+
if (cacheRef.current?.contextUri === context.uri) {
|
|
5
|
+
return {
|
|
6
|
+
items: cacheRef.current.items,
|
|
7
|
+
name: cacheRef.current.name,
|
|
8
|
+
subtitle: cacheRef.current.subtitle,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
const result = await fetchContextTracks(client, context).catch(() => null);
|
|
12
|
+
if (result && result.items.length > 0) {
|
|
13
|
+
cacheRef.current = {
|
|
14
|
+
contextUri: context.uri,
|
|
15
|
+
snapshotId: result.snapshotId,
|
|
16
|
+
items: result.items,
|
|
17
|
+
name: result.name,
|
|
18
|
+
subtitle: result.subtitle,
|
|
19
|
+
};
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// Fall back to queue API
|
|
24
|
+
const data = await client.player.getUsersQueue();
|
|
25
|
+
const items = [];
|
|
26
|
+
if (data.currently_playing) {
|
|
27
|
+
items.push(mapTrackItem(data.currently_playing, false));
|
|
28
|
+
}
|
|
29
|
+
for (const item of data.queue) {
|
|
30
|
+
items.push(mapTrackItem(item, false));
|
|
31
|
+
}
|
|
32
|
+
cacheRef.current = null;
|
|
33
|
+
return {
|
|
34
|
+
items,
|
|
35
|
+
name: 'Current Queue',
|
|
36
|
+
subtitle: null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function extractContextId(uri) {
|
|
40
|
+
// spotify:playlist:ID or spotify:album:ID
|
|
41
|
+
const parts = uri.split(':');
|
|
42
|
+
return parts.length >= 3 ? parts[2] : null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Fetches the current playlist/album content and marks the currently playing track.
|
|
46
|
+
* Falls back to the Spotify queue API if no context is available.
|
|
47
|
+
* @param client - Authenticated Spotify API client
|
|
48
|
+
* @param enabled - Whether to fetch data
|
|
49
|
+
* @param trackKey - Current track identity for refresh triggers
|
|
50
|
+
* @param context - Current playback context (playlist/album)
|
|
51
|
+
* @param currentTrackUri - URI of the currently playing track
|
|
52
|
+
* @returns Queue items with current track marked, loading state, and current track index
|
|
53
|
+
*/
|
|
54
|
+
export function useQueue(client, enabled, context, currentTrackUri) {
|
|
55
|
+
const [queue, setQueue] = useState([]);
|
|
56
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
57
|
+
const [currentIndex, setCurrentIndex] = useState(0);
|
|
58
|
+
const [contextName, setContextName] = useState(null);
|
|
59
|
+
const [contextSubtitle, setContextSubtitle] = useState(null);
|
|
60
|
+
const cacheRef = useRef(null);
|
|
61
|
+
const contextRef = useRef(context);
|
|
62
|
+
const trackUriRef = useRef(currentTrackUri);
|
|
63
|
+
contextRef.current = context;
|
|
64
|
+
trackUriRef.current = currentTrackUri;
|
|
65
|
+
const refresh = useCallback(async () => {
|
|
66
|
+
try {
|
|
67
|
+
const result = await resolveQueueItems(client, contextRef.current, cacheRef);
|
|
68
|
+
const uri = trackUriRef.current;
|
|
69
|
+
const currentIdx = uri ? result.items.findIndex((i) => i.uri === uri) : -1;
|
|
70
|
+
const marked = result.items.map((item, idx) => ({
|
|
71
|
+
...item,
|
|
72
|
+
isCurrent: idx === currentIdx,
|
|
73
|
+
isPrevious: currentIdx >= 0 && idx < currentIdx,
|
|
74
|
+
}));
|
|
75
|
+
setQueue(marked);
|
|
76
|
+
setCurrentIndex(Math.max(currentIdx, 0));
|
|
77
|
+
setContextName(result.name);
|
|
78
|
+
setContextSubtitle(result.subtitle);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Silently ignore - show stale data
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
setIsLoading(false);
|
|
85
|
+
}
|
|
86
|
+
}, [
|
|
87
|
+
client,
|
|
88
|
+
]);
|
|
89
|
+
const hasLoadedRef = useRef(false);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!enabled)
|
|
92
|
+
return;
|
|
93
|
+
if (!hasLoadedRef.current) {
|
|
94
|
+
setIsLoading(true);
|
|
95
|
+
}
|
|
96
|
+
refresh().then(() => {
|
|
97
|
+
hasLoadedRef.current = true;
|
|
98
|
+
});
|
|
99
|
+
}, [
|
|
100
|
+
enabled,
|
|
101
|
+
refresh,
|
|
102
|
+
]);
|
|
103
|
+
// Re-mark current/previous when the playing track changes (cache hit, no API call)
|
|
104
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: currentTrackUri is intentionally a trigger
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (enabled && hasLoadedRef.current) {
|
|
107
|
+
refresh();
|
|
108
|
+
}
|
|
109
|
+
}, [
|
|
110
|
+
enabled,
|
|
111
|
+
currentTrackUri,
|
|
112
|
+
refresh,
|
|
113
|
+
]);
|
|
114
|
+
return {
|
|
115
|
+
queue,
|
|
116
|
+
contextName,
|
|
117
|
+
contextSubtitle,
|
|
118
|
+
isLoading,
|
|
119
|
+
currentIndex,
|
|
120
|
+
refresh,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function mapTrackItem(item, isPrevious) {
|
|
124
|
+
const artist = item.artists ? item.artists.map((a) => a.name).join(', ') : 'Unknown';
|
|
125
|
+
return {
|
|
126
|
+
name: item.name,
|
|
127
|
+
artist,
|
|
128
|
+
durationMs: item.duration_ms,
|
|
129
|
+
uri: item.uri,
|
|
130
|
+
isCurrent: false,
|
|
131
|
+
isPrevious,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// type TrackLike = {
|
|
135
|
+
// name: string;
|
|
136
|
+
// uri: string;
|
|
137
|
+
// duration_ms: number;
|
|
138
|
+
// artists?: Array<{
|
|
139
|
+
// name: string;
|
|
140
|
+
// }>;
|
|
141
|
+
// };
|
|
142
|
+
async function fetchContextTracks(client, context) {
|
|
143
|
+
if (context.type === 'collection') {
|
|
144
|
+
const page = await client.currentUser.tracks.savedTracks(50, 0);
|
|
145
|
+
return {
|
|
146
|
+
items: page.items.map((e) => mapTrackItem(e.track, false)),
|
|
147
|
+
name: 'Liked Songs',
|
|
148
|
+
subtitle: null,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const id = extractContextId(context.uri);
|
|
152
|
+
if (!id)
|
|
153
|
+
return {
|
|
154
|
+
items: [],
|
|
155
|
+
name: null,
|
|
156
|
+
subtitle: null,
|
|
157
|
+
};
|
|
158
|
+
if (context.type === 'playlist') {
|
|
159
|
+
/**
|
|
160
|
+
*
|
|
161
|
+
* Temp disabling playlist fetching due to API issues
|
|
162
|
+
* KEEP THIS CODE
|
|
163
|
+
*
|
|
164
|
+
const playlist = await client.playlists.getPlaylist(
|
|
165
|
+
id,
|
|
166
|
+
undefined,
|
|
167
|
+
'name,snapshot_id,tracks.items(track(name,uri,duration_ms,artists(name)))',
|
|
168
|
+
);
|
|
169
|
+
const items = playlist.tracks.items.filter((e) => e.track).map((e) => mapTrackItem(e.track as TrackLike, false));
|
|
170
|
+
return {
|
|
171
|
+
items,
|
|
172
|
+
name: playlist.name,
|
|
173
|
+
snapshotId: playlist.snapshot_id,
|
|
174
|
+
}; */
|
|
175
|
+
}
|
|
176
|
+
if (context.type === 'album') {
|
|
177
|
+
const album = await client.albums.get(id);
|
|
178
|
+
return {
|
|
179
|
+
items: album.tracks.items.map((t) => mapTrackItem(t, false)),
|
|
180
|
+
name: `${album.name}`,
|
|
181
|
+
subtitle: `${album.artists
|
|
182
|
+
.filter((_, index) => {
|
|
183
|
+
return index < 3;
|
|
184
|
+
})
|
|
185
|
+
.map((a) => a.name)
|
|
186
|
+
.join(', ')}${album.tracks.total > 3 ? ` (+${album.tracks.total - 3} more)` : ''}`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
items: [],
|
|
191
|
+
name: null,
|
|
192
|
+
subtitle: null,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SpotifyApi } from '@spotify/web-api-ts-sdk';
|
|
2
|
+
import type { SearchResults } from '../spotify/search.js';
|
|
3
|
+
/**
|
|
4
|
+
* Manages search state with debounced API calls and deduplication.
|
|
5
|
+
* Only fetches when enabled (panel is active).
|
|
6
|
+
* @param client - Authenticated Spotify API client
|
|
7
|
+
* @param enabled - Whether the search panel is active
|
|
8
|
+
* @returns Search state and controls
|
|
9
|
+
*/
|
|
10
|
+
export declare function useSearch(client: SpotifyApi, enabled: boolean): {
|
|
11
|
+
query: string;
|
|
12
|
+
setQuery: import("react").Dispatch<import("react").SetStateAction<string>>;
|
|
13
|
+
results: SearchResults | null;
|
|
14
|
+
isLoading: boolean;
|
|
15
|
+
clear: () => void;
|
|
16
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { SEARCH_DEBOUNCE_MS } from '../config.js';
|
|
3
|
+
import { searchSpotify } from '../spotify/search.js';
|
|
4
|
+
/**
|
|
5
|
+
* Manages search state with debounced API calls and deduplication.
|
|
6
|
+
* Only fetches when enabled (panel is active).
|
|
7
|
+
* @param client - Authenticated Spotify API client
|
|
8
|
+
* @param enabled - Whether the search panel is active
|
|
9
|
+
* @returns Search state and controls
|
|
10
|
+
*/
|
|
11
|
+
export function useSearch(client, enabled) {
|
|
12
|
+
const [query, setQuery] = useState('');
|
|
13
|
+
const [results, setResults] = useState(null);
|
|
14
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
15
|
+
const timerRef = useRef(null);
|
|
16
|
+
const inflightRef = useRef(null);
|
|
17
|
+
const doSearch = useCallback(async (q) => {
|
|
18
|
+
if (inflightRef.current === q)
|
|
19
|
+
return;
|
|
20
|
+
inflightRef.current = q;
|
|
21
|
+
setIsLoading(true);
|
|
22
|
+
try {
|
|
23
|
+
const data = await searchSpotify(client, q);
|
|
24
|
+
if (inflightRef.current === q) {
|
|
25
|
+
setResults(data);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
console.error(`Search failed for query "${q}":`, error);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
if (inflightRef.current === q) {
|
|
33
|
+
setIsLoading(false);
|
|
34
|
+
inflightRef.current = null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}, [
|
|
38
|
+
client,
|
|
39
|
+
]);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!enabled)
|
|
42
|
+
return;
|
|
43
|
+
if (timerRef.current)
|
|
44
|
+
clearTimeout(timerRef.current);
|
|
45
|
+
if (query.trim().length === 0) {
|
|
46
|
+
setResults(null);
|
|
47
|
+
setIsLoading(false);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
timerRef.current = setTimeout(() => {
|
|
51
|
+
doSearch(query.trim());
|
|
52
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
53
|
+
return () => {
|
|
54
|
+
if (timerRef.current)
|
|
55
|
+
clearTimeout(timerRef.current);
|
|
56
|
+
};
|
|
57
|
+
}, [
|
|
58
|
+
query,
|
|
59
|
+
enabled,
|
|
60
|
+
doSearch,
|
|
61
|
+
]);
|
|
62
|
+
const clear = useCallback(() => {
|
|
63
|
+
setQuery('');
|
|
64
|
+
setResults(null);
|
|
65
|
+
setIsLoading(false);
|
|
66
|
+
if (timerRef.current)
|
|
67
|
+
clearTimeout(timerRef.current);
|
|
68
|
+
inflightRef.current = null;
|
|
69
|
+
}, []);
|
|
70
|
+
return {
|
|
71
|
+
query,
|
|
72
|
+
setQuery,
|
|
73
|
+
results,
|
|
74
|
+
isLoading,
|
|
75
|
+
clear,
|
|
76
|
+
};
|
|
77
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export type { RepeatMode, TrackInfo } from './spotify/playback.js';
|
|
3
|
+
export { getCurrentTrackInfo, getPlaybackState, pause, play, playInContext, playUri, seek, setRepeat, setShuffle, setVolume, skipNext, skipPrevious, togglePlayback, } from './spotify/playback.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { loadEnvFile } from 'node:process';
|
|
5
|
+
const envPath = join(import.meta.dirname, '../.env');
|
|
6
|
+
if (existsSync(envPath)) {
|
|
7
|
+
loadEnvFile(envPath);
|
|
8
|
+
}
|
|
9
|
+
export { getCurrentTrackInfo, getPlaybackState, pause, play, playInContext, playUri, seek, setRepeat, setShuffle, setVolume, skipNext, skipPrevious, togglePlayback, } from './spotify/playback.js';
|
|
10
|
+
await import('./main.js');
|
package/dist/main.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { AccessToken } from '@spotify/web-api-ts-sdk';
|
|
2
|
+
export type AuthenticateOptions = {
|
|
3
|
+
onAuthUrl?: (url: string, openBrowserFn: () => void) => void;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Runs the full Spotify PKCE OAuth flow: starts a local callback server,
|
|
7
|
+
* opens the browser for user authorization, and exchanges the code for tokens.
|
|
8
|
+
* @param clientId - Spotify application client ID
|
|
9
|
+
* @param options - Optional callbacks, including {@link AuthenticateOptions.onAuthUrl} fired with the authorization URL
|
|
10
|
+
* @returns Access token with refresh token and expiration
|
|
11
|
+
*/
|
|
12
|
+
export declare function authenticate(clientId: string, options?: AuthenticateOptions): Promise<AccessToken>;
|
|
13
|
+
/**
|
|
14
|
+
* Loads a previously stored access token from disk.
|
|
15
|
+
* @returns The stored token, or `null` if none exists or the file is invalid
|
|
16
|
+
*/
|
|
17
|
+
export declare function loadStoredToken(): AccessToken | null;
|
|
18
|
+
/**
|
|
19
|
+
* Persists an access token to disk at {@link TOKEN_PATH}.
|
|
20
|
+
* @param token - The access token to store
|
|
21
|
+
*/
|
|
22
|
+
export declare function storeToken(token: AccessToken): void;
|
|
23
|
+
/**
|
|
24
|
+
* Checks whether an access token has expired.
|
|
25
|
+
* @param token - The access token to check
|
|
26
|
+
* @returns `true` if the token is expired or has no expiration set
|
|
27
|
+
*/
|
|
28
|
+
export declare function isTokenExpired(token: AccessToken): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Refreshes an expired access token using its refresh token.
|
|
31
|
+
* The new token is automatically persisted to disk.
|
|
32
|
+
* @param clientId - Spotify application client ID
|
|
33
|
+
* @param token - The expired token containing a valid refresh token
|
|
34
|
+
* @returns A fresh access token
|
|
35
|
+
*/
|
|
36
|
+
export declare function refreshToken(clientId: string, token: AccessToken): Promise<AccessToken>;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import { AUTH_REDIRECT_PORT, AUTH_REDIRECT_URI, AUTH_SCOPES, CONFIG_DIR, TOKEN_PATH } from '../config.js';
|
|
6
|
+
function generateCodeVerifier() {
|
|
7
|
+
return randomBytes(64).toString('base64url');
|
|
8
|
+
}
|
|
9
|
+
function generateCodeChallenge(verifier) {
|
|
10
|
+
return createHash('sha256').update(verifier).digest('base64url');
|
|
11
|
+
}
|
|
12
|
+
function openBrowser(url) {
|
|
13
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
14
|
+
const args = process.platform === 'win32'
|
|
15
|
+
? [
|
|
16
|
+
'/c',
|
|
17
|
+
'start',
|
|
18
|
+
'',
|
|
19
|
+
url,
|
|
20
|
+
]
|
|
21
|
+
: [
|
|
22
|
+
url,
|
|
23
|
+
];
|
|
24
|
+
execFile(command, args);
|
|
25
|
+
}
|
|
26
|
+
function parseCallbackResult(url) {
|
|
27
|
+
const code = url.searchParams.get('code');
|
|
28
|
+
const error = url.searchParams.get('error');
|
|
29
|
+
const errorDescription = url.searchParams.get('error_description');
|
|
30
|
+
if (error) {
|
|
31
|
+
const message = `Spotify auth error: ${error}${errorDescription ? ` - ${errorDescription}` : ''}`;
|
|
32
|
+
return {
|
|
33
|
+
error: message,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (code) {
|
|
37
|
+
return {
|
|
38
|
+
code,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
error: 'No auth code received',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function waitForAuthCode() {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const server = createServer((req, res) => {
|
|
48
|
+
const url = new URL(req.url ?? '/', `http://127.0.0.1:${AUTH_REDIRECT_PORT}`);
|
|
49
|
+
if (url.pathname === '/callback') {
|
|
50
|
+
res.writeHead(200, {
|
|
51
|
+
'Content-Type': 'text/html',
|
|
52
|
+
});
|
|
53
|
+
res.end('<html><body><h1>Done! You can close this tab.</h1></body></html>');
|
|
54
|
+
server.close();
|
|
55
|
+
const result = parseCallbackResult(url);
|
|
56
|
+
if ('code' in result) {
|
|
57
|
+
resolve(result.code);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
reject(new Error(result.error));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
server.listen(AUTH_REDIRECT_PORT, '127.0.0.1');
|
|
65
|
+
server.on('error', reject);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
async function exchangeCodeForToken(clientId, code, codeVerifier) {
|
|
69
|
+
const response = await fetch('https://accounts.spotify.com/api/token', {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: {
|
|
72
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
73
|
+
},
|
|
74
|
+
body: new URLSearchParams({
|
|
75
|
+
grant_type: 'authorization_code',
|
|
76
|
+
code,
|
|
77
|
+
redirect_uri: AUTH_REDIRECT_URI,
|
|
78
|
+
client_id: clientId,
|
|
79
|
+
code_verifier: codeVerifier,
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
const text = await response.text();
|
|
84
|
+
throw new Error(`Token exchange failed (${response.status}): ${text}`);
|
|
85
|
+
}
|
|
86
|
+
const data = (await response.json());
|
|
87
|
+
return {
|
|
88
|
+
...data,
|
|
89
|
+
expires: Date.now() + data.expires_in * 1000,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Runs the full Spotify PKCE OAuth flow: starts a local callback server,
|
|
94
|
+
* opens the browser for user authorization, and exchanges the code for tokens.
|
|
95
|
+
* @param clientId - Spotify application client ID
|
|
96
|
+
* @param options - Optional callbacks, including {@link AuthenticateOptions.onAuthUrl} fired with the authorization URL
|
|
97
|
+
* @returns Access token with refresh token and expiration
|
|
98
|
+
*/
|
|
99
|
+
export async function authenticate(clientId, options) {
|
|
100
|
+
const codeVerifier = generateCodeVerifier();
|
|
101
|
+
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
102
|
+
const authUrl = new URL('https://accounts.spotify.com/authorize');
|
|
103
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
104
|
+
authUrl.searchParams.set('client_id', clientId);
|
|
105
|
+
authUrl.searchParams.set('redirect_uri', AUTH_REDIRECT_URI);
|
|
106
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
107
|
+
authUrl.searchParams.set('code_challenge', codeChallenge);
|
|
108
|
+
authUrl.searchParams.set('scope', AUTH_SCOPES);
|
|
109
|
+
const url = authUrl.toString();
|
|
110
|
+
const codePromise = waitForAuthCode();
|
|
111
|
+
options?.onAuthUrl?.(url, () => openBrowser(url));
|
|
112
|
+
const code = await codePromise;
|
|
113
|
+
return exchangeCodeForToken(clientId, code, codeVerifier);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Loads a previously stored access token from disk.
|
|
117
|
+
* @returns The stored token, or `null` if none exists or the file is invalid
|
|
118
|
+
*/
|
|
119
|
+
export function loadStoredToken() {
|
|
120
|
+
if (!existsSync(TOKEN_PATH)) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const data = readFileSync(TOKEN_PATH, 'utf-8');
|
|
125
|
+
return JSON.parse(data);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Persists an access token to disk at {@link TOKEN_PATH}.
|
|
133
|
+
* @param token - The access token to store
|
|
134
|
+
*/
|
|
135
|
+
export function storeToken(token) {
|
|
136
|
+
mkdirSync(CONFIG_DIR, {
|
|
137
|
+
recursive: true,
|
|
138
|
+
});
|
|
139
|
+
writeFileSync(TOKEN_PATH, JSON.stringify(token, null, 2));
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Checks whether an access token has expired.
|
|
143
|
+
* @param token - The access token to check
|
|
144
|
+
* @returns `true` if the token is expired or has no expiration set
|
|
145
|
+
*/
|
|
146
|
+
export function isTokenExpired(token) {
|
|
147
|
+
if (token.expires == null) {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
return Date.now() >= token.expires;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Refreshes an expired access token using its refresh token.
|
|
154
|
+
* The new token is automatically persisted to disk.
|
|
155
|
+
* @param clientId - Spotify application client ID
|
|
156
|
+
* @param token - The expired token containing a valid refresh token
|
|
157
|
+
* @returns A fresh access token
|
|
158
|
+
*/
|
|
159
|
+
export async function refreshToken(clientId, token) {
|
|
160
|
+
const response = await fetch('https://accounts.spotify.com/api/token', {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
headers: {
|
|
163
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
164
|
+
},
|
|
165
|
+
body: new URLSearchParams({
|
|
166
|
+
grant_type: 'refresh_token',
|
|
167
|
+
refresh_token: token.refresh_token,
|
|
168
|
+
client_id: clientId,
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
if (!response.ok) {
|
|
172
|
+
const text = await response.text();
|
|
173
|
+
throw new Error(`Token refresh failed (${response.status}): ${text}`);
|
|
174
|
+
}
|
|
175
|
+
const data = (await response.json());
|
|
176
|
+
const refreshed = {
|
|
177
|
+
...data,
|
|
178
|
+
refresh_token: data.refresh_token || token.refresh_token,
|
|
179
|
+
expires: Date.now() + data.expires_in * 1000,
|
|
180
|
+
};
|
|
181
|
+
storeToken(refreshed);
|
|
182
|
+
return refreshed;
|
|
183
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { SpotifyApi } from '@spotify/web-api-ts-sdk';
|
|
2
|
+
import type { AuthenticateOptions } from './auth.js';
|
|
3
|
+
/**
|
|
4
|
+
* Resolves the Spotify Client ID to use for authentication.
|
|
5
|
+
* Checks the `SPOTIFY_CLIENT_ID` environment variable first (allowing user overrides),
|
|
6
|
+
* then falls back to the default embedded at build time in `DEFAULT_SPOTIFY_CLIENT_ID`.
|
|
7
|
+
* @returns The client ID
|
|
8
|
+
* @throws When neither the environment variable nor the embedded default is set
|
|
9
|
+
*/
|
|
10
|
+
export declare function getClientId(): string;
|
|
11
|
+
/**
|
|
12
|
+
* Creates an authenticated Spotify API client. Loads a stored token if available,
|
|
13
|
+
* refreshes it if expired, or runs the full OAuth flow for first-time authentication.
|
|
14
|
+
* @param clientId - Spotify application client ID
|
|
15
|
+
* @param options - Optional auth callbacks forwarded to {@link authenticate}
|
|
16
|
+
* @returns An authenticated Spotify API client
|
|
17
|
+
*/
|
|
18
|
+
export declare function createSpotifyClient(clientId: string, options?: AuthenticateOptions): Promise<SpotifyApi>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { SpotifyApi } from '@spotify/web-api-ts-sdk';
|
|
2
|
+
import { DEFAULT_SPOTIFY_CLIENT_ID } from '../config.js';
|
|
3
|
+
import { authenticate, isTokenExpired, loadStoredToken, refreshToken, storeToken } from './auth.js';
|
|
4
|
+
import { fetchWithRetry } from './fetch-with-retry.js';
|
|
5
|
+
/**
|
|
6
|
+
* Resolves the Spotify Client ID to use for authentication.
|
|
7
|
+
* Checks the `SPOTIFY_CLIENT_ID` environment variable first (allowing user overrides),
|
|
8
|
+
* then falls back to the default embedded at build time in `DEFAULT_SPOTIFY_CLIENT_ID`.
|
|
9
|
+
* @returns The client ID
|
|
10
|
+
* @throws When neither the environment variable nor the embedded default is set
|
|
11
|
+
*/
|
|
12
|
+
export function getClientId() {
|
|
13
|
+
// biome-ignore lint/complexity/useLiteralKeys: TS noPropertyAccessFromIndexSignature requires bracket notation
|
|
14
|
+
const fromEnv = process.env['SPOTIFY_CLIENT_ID'];
|
|
15
|
+
const clientId = fromEnv?.trim() || DEFAULT_SPOTIFY_CLIENT_ID;
|
|
16
|
+
if (!clientId) {
|
|
17
|
+
throw new Error('No Spotify Client ID available.\n' +
|
|
18
|
+
'Either the embedded default is empty, or override it at runtime:\n' +
|
|
19
|
+
' 1. Create a Spotify app at https://developer.spotify.com/dashboard\n' +
|
|
20
|
+
' 2. Set: export SPOTIFY_CLIENT_ID=your_client_id');
|
|
21
|
+
}
|
|
22
|
+
return clientId;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Creates an authenticated Spotify API client. Loads a stored token if available,
|
|
26
|
+
* refreshes it if expired, or runs the full OAuth flow for first-time authentication.
|
|
27
|
+
* @param clientId - Spotify application client ID
|
|
28
|
+
* @param options - Optional auth callbacks forwarded to {@link authenticate}
|
|
29
|
+
* @returns An authenticated Spotify API client
|
|
30
|
+
*/
|
|
31
|
+
export async function createSpotifyClient(clientId, options) {
|
|
32
|
+
let token = loadStoredToken();
|
|
33
|
+
if (token && isTokenExpired(token) && token.refresh_token) {
|
|
34
|
+
try {
|
|
35
|
+
token = await refreshToken(clientId, token);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
token = null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (!token || isTokenExpired(token)) {
|
|
42
|
+
token = await authenticate(clientId, options);
|
|
43
|
+
storeToken(token);
|
|
44
|
+
}
|
|
45
|
+
return SpotifyApi.withAccessToken(clientId, token, {
|
|
46
|
+
fetch: fetchWithRetry,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch wrapper that retries on 429 (rate limit) responses, deduplicates
|
|
3
|
+
* concurrent GET requests, and injects the shared `USER_AGENT` header into
|
|
4
|
+
* every request (Spotify Web API + LRCLIB).
|
|
5
|
+
*/
|
|
6
|
+
export declare function fetchWithRetry(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { USER_AGENT } from '../config.js';
|
|
2
|
+
const MAX_RETRIES = 3;
|
|
3
|
+
const DEFAULT_RETRY_AFTER_S = 1;
|
|
4
|
+
const MAX_RETRY_AFTER_S = 5;
|
|
5
|
+
const inflight = new Map();
|
|
6
|
+
function getCacheKey(input, init) {
|
|
7
|
+
const url = input.toString();
|
|
8
|
+
const method = init?.method ?? 'GET';
|
|
9
|
+
return `${method}:${url}`;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Returns a new RequestInit with `User-Agent` set to the shared `USER_AGENT`
|
|
13
|
+
* constant unless the caller has already provided one.
|
|
14
|
+
*/
|
|
15
|
+
function withUserAgent(init) {
|
|
16
|
+
const headers = new Headers(init?.headers);
|
|
17
|
+
if (!headers.has('User-Agent')) {
|
|
18
|
+
headers.set('User-Agent', USER_AGENT);
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
...init,
|
|
22
|
+
headers,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Fetch wrapper that retries on 429 (rate limit) responses, deduplicates
|
|
27
|
+
* concurrent GET requests, and injects the shared `USER_AGENT` header into
|
|
28
|
+
* every request (Spotify Web API + LRCLIB).
|
|
29
|
+
*/
|
|
30
|
+
export function fetchWithRetry(input, init) {
|
|
31
|
+
const initWithUa = withUserAgent(init);
|
|
32
|
+
const key = getCacheKey(input, initWithUa);
|
|
33
|
+
console.error(`fetchWithRetry: ${key}`); // Log all fetch attempts for debugging
|
|
34
|
+
// Only deduplicate GET requests - mutations must always go through
|
|
35
|
+
const method = initWithUa.method ?? 'GET';
|
|
36
|
+
if (method !== 'GET') {
|
|
37
|
+
console.error(`fetchWithRetry: Body`, initWithUa.body); // Log request body for non-GET requests
|
|
38
|
+
return attempt(input, initWithUa, MAX_RETRIES);
|
|
39
|
+
}
|
|
40
|
+
const pending = inflight.get(key);
|
|
41
|
+
if (pending) {
|
|
42
|
+
return pending;
|
|
43
|
+
}
|
|
44
|
+
const promise = attempt(input, initWithUa, 0).finally(() => {
|
|
45
|
+
inflight.delete(key);
|
|
46
|
+
});
|
|
47
|
+
inflight.set(key, promise);
|
|
48
|
+
return promise;
|
|
49
|
+
}
|
|
50
|
+
async function attempt(input, init, retryCount) {
|
|
51
|
+
const response = await fetch(input, init);
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
// Clone the response so reading its body for logging doesn't consume the
|
|
54
|
+
// stream before the caller (e.g. the Spotify SDK's deserializer) can read it.
|
|
55
|
+
// Calling `.text()` on the original response would leave the body unusable
|
|
56
|
+
// and trigger "Body is unusable: Body has already been read" downstream.
|
|
57
|
+
const bodyText = await response.clone().text();
|
|
58
|
+
console.error(`Request failed with status ${response.status} for ${input.toString()}`, bodyText);
|
|
59
|
+
}
|
|
60
|
+
if (response.status !== 429 || retryCount >= MAX_RETRIES) {
|
|
61
|
+
return response;
|
|
62
|
+
}
|
|
63
|
+
const retryAfterHeader = response.headers.get('Retry-After');
|
|
64
|
+
const retryAfterS = retryAfterHeader ? Number.parseInt(retryAfterHeader, 10) : Number.NaN;
|
|
65
|
+
const clampedS = Number.isNaN(retryAfterS) ? DEFAULT_RETRY_AFTER_S : Math.min(retryAfterS, MAX_RETRY_AFTER_S);
|
|
66
|
+
const delayMs = clampedS * 1000;
|
|
67
|
+
console.error(`Received 429 for ${input.toString()}. Retrying after ${clampedS} seconds (retry ${retryCount + 1}/${MAX_RETRIES})`);
|
|
68
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
69
|
+
return attempt(input, init, retryCount + 1);
|
|
70
|
+
}
|