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.
Files changed (78) hide show
  1. package/README.md +82 -0
  2. package/dist/app.d.ts +5 -0
  3. package/dist/app.js +66 -0
  4. package/dist/components/animated-line.d.ts +12 -0
  5. package/dist/components/animated-line.js +14 -0
  6. package/dist/components/browse-detail-view.d.ts +14 -0
  7. package/dist/components/browse-detail-view.js +31 -0
  8. package/dist/components/keyboard-hints.d.ts +12 -0
  9. package/dist/components/keyboard-hints.js +8 -0
  10. package/dist/components/lyrics-panel.d.ts +10 -0
  11. package/dist/components/lyrics-panel.js +38 -0
  12. package/dist/components/lyrics-view.d.ts +14 -0
  13. package/dist/components/lyrics-view.js +50 -0
  14. package/dist/components/panel-content.d.ts +10 -0
  15. package/dist/components/panel-content.js +22 -0
  16. package/dist/components/playback-status.d.ts +16 -0
  17. package/dist/components/playback-status.js +22 -0
  18. package/dist/components/player.d.ts +9 -0
  19. package/dist/components/player.d.ts.map +1 -0
  20. package/dist/components/player.js +215 -0
  21. package/dist/components/player.js.map +1 -0
  22. package/dist/components/progress-bar.d.ts +11 -0
  23. package/dist/components/progress-bar.js +13 -0
  24. package/dist/components/queue-view.d.ts +9 -0
  25. package/dist/components/queue-view.js +54 -0
  26. package/dist/components/search-panel.d.ts +8 -0
  27. package/dist/components/search-panel.js +152 -0
  28. package/dist/components/shimmer.d.ts +12 -0
  29. package/dist/components/shimmer.js +34 -0
  30. package/dist/components/side-panel.d.ts +12 -0
  31. package/dist/components/side-panel.js +12 -0
  32. package/dist/components/track-info-skeleton.d.ts +9 -0
  33. package/dist/components/track-info-skeleton.js +10 -0
  34. package/dist/components/track-info.d.ts +10 -0
  35. package/dist/components/track-info.js +15 -0
  36. package/dist/config.d.ts +33 -0
  37. package/dist/config.d.ts.map +1 -0
  38. package/dist/config.js +65 -0
  39. package/dist/config.js.map +1 -0
  40. package/dist/contexts/lyrics-context.d.ts +29 -0
  41. package/dist/contexts/lyrics-context.js +44 -0
  42. package/dist/contexts/panel-mode-context.d.ts +24 -0
  43. package/dist/contexts/panel-mode-context.js +45 -0
  44. package/dist/contexts/queue-context.d.ts +32 -0
  45. package/dist/contexts/queue-context.js +68 -0
  46. package/dist/contexts/search-context.d.ts +59 -0
  47. package/dist/contexts/search-context.js +338 -0
  48. package/dist/hooks/use-album-art.d.ts +8 -0
  49. package/dist/hooks/use-album-art.js +56 -0
  50. package/dist/hooks/use-browse.d.ts +29 -0
  51. package/dist/hooks/use-browse.js +98 -0
  52. package/dist/hooks/use-lyrics.d.ts +12 -0
  53. package/dist/hooks/use-lyrics.js +51 -0
  54. package/dist/hooks/use-playback.d.ts +24 -0
  55. package/dist/hooks/use-playback.js +282 -0
  56. package/dist/hooks/use-player-input.d.ts +18 -0
  57. package/dist/hooks/use-player-input.js +201 -0
  58. package/dist/hooks/use-queue.d.ts +28 -0
  59. package/dist/hooks/use-queue.js +194 -0
  60. package/dist/hooks/use-search.d.ts +16 -0
  61. package/dist/hooks/use-search.js +77 -0
  62. package/dist/index.d.ts +3 -0
  63. package/dist/index.js +10 -0
  64. package/dist/main.d.ts +1 -0
  65. package/dist/main.js +6 -0
  66. package/dist/spotify/auth.d.ts +36 -0
  67. package/dist/spotify/auth.js +183 -0
  68. package/dist/spotify/client.d.ts +18 -0
  69. package/dist/spotify/client.js +48 -0
  70. package/dist/spotify/fetch-with-retry.d.ts +6 -0
  71. package/dist/spotify/fetch-with-retry.js +70 -0
  72. package/dist/spotify/lyrics.d.ts +25 -0
  73. package/dist/spotify/lyrics.js +130 -0
  74. package/dist/spotify/playback.d.ts +115 -0
  75. package/dist/spotify/playback.js +201 -0
  76. package/dist/spotify/search.d.ts +79 -0
  77. package/dist/spotify/search.js +143 -0
  78. 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
+ }
@@ -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,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from 'ink';
3
+ import { App } from './app.js';
4
+ render(_jsx(App, {}), {
5
+ alternateScreen: true,
6
+ });
@@ -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
+ }