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,25 @@
1
+ export type LyricLine = {
2
+ timeMs: number;
3
+ text: string;
4
+ };
5
+ export type Lyrics = {
6
+ synced: LyricLine[];
7
+ plain: string | null;
8
+ };
9
+ /**
10
+ * Fetches lyrics for a track from LRCLIB (https://lrclib.net).
11
+ * Deduplicates concurrent requests for the same track - if a fetch is already
12
+ * in flight, returns the same promise instead of starting a new request.
13
+ * @param trackName - Name of the track
14
+ * @param artistName - Name of the artist
15
+ * @param durationMs - Duration of the track in milliseconds
16
+ * @returns Lyrics object, or null if not found
17
+ */
18
+ export declare function fetchLyrics(trackName: string, artistName: string, durationMs: number): Promise<Lyrics | null>;
19
+ /**
20
+ * Finds the index of the current lyric line based on playback progress.
21
+ * @param lines - Sorted array of synced lyric lines
22
+ * @param progressMs - Current playback position in milliseconds
23
+ * @returns Index of the current line, or -1 if before the first line
24
+ */
25
+ export declare function getCurrentLineIndex(lines: LyricLine[], progressMs: number, offsetMs?: number): number;
@@ -0,0 +1,130 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { CONFIG_DIR } from '../config.js';
5
+ import { fetchWithRetry } from './fetch-with-retry.js';
6
+ const LYRICS_CACHE_DIR = join(CONFIG_DIR, 'lyrics-cache');
7
+ function getCachePath(trackName, artistName) {
8
+ const hash = createHash('sha256').update(`${trackName}::${artistName}`).digest('hex').slice(0, 16);
9
+ return join(LYRICS_CACHE_DIR, `${hash}.json`);
10
+ }
11
+ function readDiskCache(trackName, artistName) {
12
+ const path = getCachePath(trackName, artistName);
13
+ if (!existsSync(path))
14
+ return null;
15
+ try {
16
+ return JSON.parse(readFileSync(path, 'utf-8'));
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ function writeDiskCache(trackName, artistName, lyrics) {
23
+ mkdirSync(LYRICS_CACHE_DIR, {
24
+ recursive: true,
25
+ });
26
+ writeFileSync(getCachePath(trackName, artistName), JSON.stringify(lyrics));
27
+ }
28
+ /**
29
+ * Parses a single LRC-format line into a timestamped lyric.
30
+ * Format: "[mm:ss.xx] text"
31
+ * @returns Parsed lyric line, or null if the line doesn't match
32
+ */
33
+ function parseLrcLine(line) {
34
+ const match = /^\[(\d{2}):(\d{2})\.(\d{2,3})\]\s*(.*)$/.exec(line);
35
+ if (!match)
36
+ return null;
37
+ const minutes = Number.parseInt(match[1], 10);
38
+ const seconds = Number.parseInt(match[2], 10);
39
+ const centiseconds = match[3].length === 2 ? Number.parseInt(match[3], 10) * 10 : Number.parseInt(match[3], 10);
40
+ const timeMs = minutes * 60_000 + seconds * 1000 + centiseconds;
41
+ const text = match[4];
42
+ return {
43
+ timeMs,
44
+ text,
45
+ };
46
+ }
47
+ /**
48
+ * Parses a full LRC-format string into an array of timestamped lyrics.
49
+ * @param lrc - LRC format string with one "[mm:ss.xx] text" per line
50
+ * @returns Sorted array of lyric lines
51
+ */
52
+ function parseLrc(lrc) {
53
+ const lines = [];
54
+ for (const line of lrc.split('\n')) {
55
+ const parsed = parseLrcLine(line.trim());
56
+ if (parsed) {
57
+ lines.push(parsed);
58
+ }
59
+ }
60
+ return lines.sort((a, b) => a.timeMs - b.timeMs);
61
+ }
62
+ const inflight = new Map();
63
+ /**
64
+ * Fetches lyrics for a track from LRCLIB (https://lrclib.net).
65
+ * Deduplicates concurrent requests for the same track - if a fetch is already
66
+ * in flight, returns the same promise instead of starting a new request.
67
+ * @param trackName - Name of the track
68
+ * @param artistName - Name of the artist
69
+ * @param durationMs - Duration of the track in milliseconds
70
+ * @returns Lyrics object, or null if not found
71
+ */
72
+ export function fetchLyrics(trackName, artistName, durationMs) {
73
+ const cached = readDiskCache(trackName, artistName);
74
+ if (cached)
75
+ return Promise.resolve(cached);
76
+ const key = `${trackName}::${artistName}`;
77
+ const pending = inflight.get(key);
78
+ if (pending)
79
+ return pending;
80
+ const promise = doFetch(trackName, artistName, durationMs).finally(() => {
81
+ inflight.delete(key);
82
+ });
83
+ inflight.set(key, promise);
84
+ return promise;
85
+ }
86
+ async function doFetch(trackName, artistName, durationMs) {
87
+ const durationSecs = Math.round(durationMs / 1000);
88
+ const params = new URLSearchParams({
89
+ track_name: trackName,
90
+ artist_name: artistName,
91
+ duration: durationSecs.toString(),
92
+ });
93
+ try {
94
+ const response = await fetchWithRetry(`https://lrclib.net/api/get?${params.toString()}`);
95
+ if (!response.ok)
96
+ return null;
97
+ const data = (await response.json());
98
+ const synced = data.syncedLyrics ? parseLrc(data.syncedLyrics) : [];
99
+ const plain = data.plainLyrics ?? null;
100
+ if (synced.length === 0 && !plain)
101
+ return null;
102
+ const lyrics = {
103
+ synced,
104
+ plain,
105
+ };
106
+ writeDiskCache(trackName, artistName, lyrics);
107
+ return lyrics;
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ /**
114
+ * Finds the index of the current lyric line based on playback progress.
115
+ * @param lines - Sorted array of synced lyric lines
116
+ * @param progressMs - Current playback position in milliseconds
117
+ * @returns Index of the current line, or -1 if before the first line
118
+ */
119
+ export function getCurrentLineIndex(lines, progressMs, offsetMs = 0) {
120
+ let index = -1;
121
+ for (let i = 0; i < lines.length; i++) {
122
+ if (lines[i].timeMs - offsetMs <= progressMs) {
123
+ index = i;
124
+ }
125
+ else {
126
+ break;
127
+ }
128
+ }
129
+ return index;
130
+ }
@@ -0,0 +1,115 @@
1
+ import type { PlaybackState, SpotifyApi } from '@spotify/web-api-ts-sdk';
2
+ export type RepeatMode = 'off' | 'context' | 'track';
3
+ export type PlaybackContext = {
4
+ type: string;
5
+ uri: string;
6
+ };
7
+ export type TrackInfo = {
8
+ name: string;
9
+ artist: string;
10
+ album: string;
11
+ isPlaying: boolean;
12
+ progressMs: number;
13
+ durationMs: number;
14
+ deviceId: string | null;
15
+ volume: number | null;
16
+ shuffle: boolean;
17
+ repeat: RepeatMode;
18
+ albumImageUrl: string | null;
19
+ context: PlaybackContext | null;
20
+ uri: string;
21
+ };
22
+ /**
23
+ * Fetches the current playback state from Spotify.
24
+ * @param client - Authenticated Spotify API client
25
+ * @returns The current playback state, or `null` if no device is active
26
+ */
27
+ export declare function getPlaybackState(client: SpotifyApi): Promise<PlaybackState | null>;
28
+ /**
29
+ * Resumes playback on the specified device.
30
+ * @param client - Authenticated Spotify API client
31
+ * @param deviceId - Target device ID
32
+ */
33
+ export declare function play(client: SpotifyApi, deviceId: string): Promise<void>;
34
+ /**
35
+ * Plays a specific track within a playlist/album/collection context.
36
+ * Uses URI-based offset to jump directly to the track in one API call.
37
+ * @param client - Authenticated Spotify API client
38
+ * @param deviceId - Target device ID
39
+ * @param contextUri - Spotify URI of the context (e.g. "spotify:playlist:..." or "spotify:album:...")
40
+ * @param trackUri - Spotify URI of the track to play within the context
41
+ */
42
+ export declare function playInContext(client: SpotifyApi, deviceId: string, contextUri: string, trackUri: string): Promise<void>;
43
+ /**
44
+ * Starts playback of an entire context (album, playlist, or collection) from the beginning.
45
+ * @param client - Authenticated Spotify API client
46
+ * @param deviceId - Target device ID
47
+ * @param contextUri - Spotify URI of the context (e.g. "spotify:album:..." or "spotify:playlist:...")
48
+ */
49
+ export declare function playContext(client: SpotifyApi, deviceId: string, contextUri: string): Promise<void>;
50
+ /**
51
+ * Starts playback of a specific track by URI on the specified device.
52
+ * @param client - Authenticated Spotify API client
53
+ * @param deviceId - Target device ID
54
+ * @param uri - Spotify URI of the track to play (e.g. "spotify:track:...")
55
+ */
56
+ export declare function playUri(client: SpotifyApi, deviceId: string, uri: string): Promise<void>;
57
+ /**
58
+ * Pauses playback on the specified device.
59
+ * @param client - Authenticated Spotify API client
60
+ * @param deviceId - Target device ID
61
+ */
62
+ export declare function pause(client: SpotifyApi, deviceId: string): Promise<void>;
63
+ /**
64
+ * Skips to the next track on the specified device.
65
+ * @param client - Authenticated Spotify API client
66
+ * @param deviceId - Target device ID
67
+ */
68
+ export declare function skipNext(client: SpotifyApi, deviceId: string): Promise<void>;
69
+ /**
70
+ * Skips to the previous track on the specified device.
71
+ * @param client - Authenticated Spotify API client
72
+ * @param deviceId - Target device ID
73
+ */
74
+ export declare function skipPrevious(client: SpotifyApi, deviceId: string): Promise<void>;
75
+ /**
76
+ * Seeks to a position in the currently playing track.
77
+ * @param client - Authenticated Spotify API client
78
+ * @param positionMs - Position in milliseconds to seek to
79
+ * @param deviceId - Target device ID
80
+ */
81
+ export declare function seek(client: SpotifyApi, positionMs: number, deviceId?: string): Promise<void>;
82
+ /**
83
+ * Sets the playback volume.
84
+ * @param client - Authenticated Spotify API client
85
+ * @param volumePercent - Volume level from 0 to 100
86
+ * @param deviceId - Target device ID
87
+ */
88
+ export declare function setVolume(client: SpotifyApi, volumePercent: number, deviceId?: string): Promise<void>;
89
+ /**
90
+ * Sets the shuffle mode.
91
+ * @param client - Authenticated Spotify API client
92
+ * @param state - Whether shuffle should be enabled
93
+ * @param deviceId - Target device ID
94
+ */
95
+ export declare function setShuffle(client: SpotifyApi, state: boolean, deviceId?: string): Promise<void>;
96
+ /**
97
+ * Sets the repeat mode.
98
+ * @param client - Authenticated Spotify API client
99
+ * @param state - Repeat mode: 'off', 'context' (playlist/album), or 'track'
100
+ * @param deviceId - Target device ID
101
+ */
102
+ export declare function setRepeat(client: SpotifyApi, state: RepeatMode, deviceId?: string): Promise<void>;
103
+ /**
104
+ * Toggles playback between playing and paused on the active device.
105
+ * @param client - Authenticated Spotify API client
106
+ * @returns `true` if playback is now playing, `false` if paused
107
+ * @throws When no active Spotify device is found
108
+ */
109
+ export declare function togglePlayback(client: SpotifyApi): Promise<boolean>;
110
+ /**
111
+ * Fetches simplified info about the currently playing track.
112
+ * @param client - Authenticated Spotify API client
113
+ * @returns Current track info, or `null` if nothing is playing
114
+ */
115
+ export declare function getCurrentTrackInfo(client: SpotifyApi): Promise<TrackInfo | null>;
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Fetches the current playback state from Spotify.
3
+ * @param client - Authenticated Spotify API client
4
+ * @returns The current playback state, or `null` if no device is active
5
+ */
6
+ export async function getPlaybackState(client) {
7
+ try {
8
+ const state = await client.player.getPlaybackState();
9
+ return state ?? null;
10
+ }
11
+ catch (err) {
12
+ // The SDK's deserializer calls JSON.parse on any non-204 response.
13
+ // Spotify can briefly return non-JSON bodies during state transitions
14
+ // (e.g. right after pause/skip). Treat parse failures as "no state available".
15
+ if (err instanceof SyntaxError) {
16
+ return null;
17
+ }
18
+ throw err;
19
+ }
20
+ }
21
+ /**
22
+ * Resumes playback on the specified device.
23
+ * @param client - Authenticated Spotify API client
24
+ * @param deviceId - Target device ID
25
+ */
26
+ export async function play(client, deviceId) {
27
+ await ignoreSdkParseError(() => client.player.startResumePlayback(deviceId));
28
+ }
29
+ /**
30
+ * Plays a specific track within a playlist/album/collection context.
31
+ * Uses URI-based offset to jump directly to the track in one API call.
32
+ * @param client - Authenticated Spotify API client
33
+ * @param deviceId - Target device ID
34
+ * @param contextUri - Spotify URI of the context (e.g. "spotify:playlist:..." or "spotify:album:...")
35
+ * @param trackUri - Spotify URI of the track to play within the context
36
+ */
37
+ export async function playInContext(client, deviceId, contextUri, trackUri) {
38
+ await ignoreSdkParseError(() => client.player.startResumePlayback(deviceId, contextUri, undefined, {
39
+ uri: trackUri,
40
+ }));
41
+ }
42
+ /**
43
+ * Starts playback of an entire context (album, playlist, or collection) from the beginning.
44
+ * @param client - Authenticated Spotify API client
45
+ * @param deviceId - Target device ID
46
+ * @param contextUri - Spotify URI of the context (e.g. "spotify:album:..." or "spotify:playlist:...")
47
+ */
48
+ export async function playContext(client, deviceId, contextUri) {
49
+ await ignoreSdkParseError(() => client.player.startResumePlayback(deviceId, contextUri));
50
+ }
51
+ /**
52
+ * Starts playback of a specific track by URI on the specified device.
53
+ * @param client - Authenticated Spotify API client
54
+ * @param deviceId - Target device ID
55
+ * @param uri - Spotify URI of the track to play (e.g. "spotify:track:...")
56
+ */
57
+ export async function playUri(client, deviceId, uri) {
58
+ await ignoreSdkParseError(() => client.player.startResumePlayback(deviceId, undefined, [
59
+ uri,
60
+ ]));
61
+ }
62
+ /**
63
+ * Pauses playback on the specified device.
64
+ * @param client - Authenticated Spotify API client
65
+ * @param deviceId - Target device ID
66
+ */
67
+ export async function pause(client, deviceId) {
68
+ await ignoreSdkParseError(() => client.player.pausePlayback(deviceId));
69
+ }
70
+ /**
71
+ * Skips to the next track on the specified device.
72
+ * @param client - Authenticated Spotify API client
73
+ * @param deviceId - Target device ID
74
+ */
75
+ export async function skipNext(client, deviceId) {
76
+ await ignoreSdkParseError(() => client.player.skipToNext(deviceId));
77
+ }
78
+ /**
79
+ * Skips to the previous track on the specified device.
80
+ * @param client - Authenticated Spotify API client
81
+ * @param deviceId - Target device ID
82
+ */
83
+ export async function skipPrevious(client, deviceId) {
84
+ await ignoreSdkParseError(() => client.player.skipToPrevious(deviceId));
85
+ }
86
+ /**
87
+ * Seeks to a position in the currently playing track.
88
+ * @param client - Authenticated Spotify API client
89
+ * @param positionMs - Position in milliseconds to seek to
90
+ * @param deviceId - Target device ID
91
+ */
92
+ export async function seek(client, positionMs, deviceId) {
93
+ await ignoreSdkParseError(() => client.player.seekToPosition(positionMs, deviceId));
94
+ }
95
+ /**
96
+ * Sets the playback volume.
97
+ * @param client - Authenticated Spotify API client
98
+ * @param volumePercent - Volume level from 0 to 100
99
+ * @param deviceId - Target device ID
100
+ */
101
+ export async function setVolume(client, volumePercent, deviceId) {
102
+ await ignoreSdkParseError(() => client.player.setPlaybackVolume(volumePercent, deviceId));
103
+ }
104
+ /**
105
+ * Sets the shuffle mode.
106
+ * @param client - Authenticated Spotify API client
107
+ * @param state - Whether shuffle should be enabled
108
+ * @param deviceId - Target device ID
109
+ */
110
+ export async function setShuffle(client, state, deviceId) {
111
+ await ignoreSdkParseError(() => client.player.togglePlaybackShuffle(state, deviceId));
112
+ }
113
+ /**
114
+ * Sets the repeat mode.
115
+ * @param client - Authenticated Spotify API client
116
+ * @param state - Repeat mode: 'off', 'context' (playlist/album), or 'track'
117
+ * @param deviceId - Target device ID
118
+ */
119
+ export async function setRepeat(client, state, deviceId) {
120
+ await ignoreSdkParseError(() => client.player.setRepeatMode(state, deviceId));
121
+ }
122
+ /**
123
+ * Wraps an SDK call to swallow SyntaxErrors from the deserializer.
124
+ * The SDK calls JSON.parse on any non-204 response body. Spotify's player
125
+ * control endpoints sometimes return 200 with a non-JSON body instead of 204,
126
+ * which causes a SyntaxError. The action still succeeds server-side.
127
+ */
128
+ async function ignoreSdkParseError(fn) {
129
+ try {
130
+ await fn();
131
+ }
132
+ catch (err) {
133
+ if (err instanceof SyntaxError) {
134
+ return;
135
+ }
136
+ // Translate the SDK's misleading 403 message for player restriction errors
137
+ if (err instanceof Error && err.message.includes('Restriction violated')) {
138
+ console.error('Action failed due to Spotify playback restrictions:', err);
139
+ throw new Error('Action not available for the current playback context');
140
+ }
141
+ console.error('Unexpected error during playback control:', err);
142
+ throw err;
143
+ }
144
+ }
145
+ /**
146
+ * Toggles playback between playing and paused on the active device.
147
+ * @param client - Authenticated Spotify API client
148
+ * @returns `true` if playback is now playing, `false` if paused
149
+ * @throws When no active Spotify device is found
150
+ */
151
+ export async function togglePlayback(client) {
152
+ const state = await getPlaybackState(client);
153
+ if (!state) {
154
+ throw new Error('No active Spotify device found. Start playing on any Spotify client first.');
155
+ }
156
+ const deviceId = state.device.id;
157
+ if (!deviceId) {
158
+ throw new Error('Active device has no ID.');
159
+ }
160
+ if (state.is_playing) {
161
+ await pause(client, deviceId);
162
+ return false;
163
+ }
164
+ await play(client, deviceId);
165
+ return true;
166
+ }
167
+ /**
168
+ * Fetches simplified info about the currently playing track.
169
+ * @param client - Authenticated Spotify API client
170
+ * @returns Current track info, or `null` if nothing is playing
171
+ */
172
+ export async function getCurrentTrackInfo(client) {
173
+ const state = await getPlaybackState(client);
174
+ if (!state?.item) {
175
+ return null;
176
+ }
177
+ const { item } = state;
178
+ const artist = 'artists' in item ? item.artists.map((a) => a.name).join(', ') : 'Unknown';
179
+ const album = 'album' in item ? item.album.name : '';
180
+ const albumImageUrl = 'album' in item && item.album.images.length > 0 ? item.album.images[item.album.images.length - 1].url : null;
181
+ return {
182
+ name: item.name,
183
+ artist,
184
+ album,
185
+ isPlaying: state.is_playing,
186
+ progressMs: state.progress_ms,
187
+ durationMs: item.duration_ms,
188
+ deviceId: state.device.id,
189
+ volume: state.device.volume_percent,
190
+ shuffle: state.shuffle_state,
191
+ repeat: state.repeat_state,
192
+ albumImageUrl,
193
+ context: state.context
194
+ ? {
195
+ type: state.context.type,
196
+ uri: state.context.uri,
197
+ }
198
+ : null,
199
+ uri: item.uri,
200
+ };
201
+ }
@@ -0,0 +1,79 @@
1
+ import type { SpotifyApi } from '@spotify/web-api-ts-sdk';
2
+ export type SearchResultTrack = {
3
+ name: string;
4
+ artist: string;
5
+ album: string;
6
+ uri: string;
7
+ durationMs: number;
8
+ albumUri: string;
9
+ };
10
+ export type SearchResultAlbum = {
11
+ name: string;
12
+ artist: string;
13
+ uri: string;
14
+ id: string;
15
+ imageUrl: string | null;
16
+ totalTracks: number;
17
+ };
18
+ export type SearchResultPlaylist = {
19
+ name: string;
20
+ owner: string;
21
+ uri: string;
22
+ id: string;
23
+ totalTracks: number;
24
+ };
25
+ export type SearchResultArtist = {
26
+ name: string;
27
+ uri: string;
28
+ id: string;
29
+ };
30
+ export type SearchResults = {
31
+ tracks: SearchResultTrack[];
32
+ albums: SearchResultAlbum[];
33
+ artists: SearchResultArtist[];
34
+ playlists: SearchResultPlaylist[];
35
+ };
36
+ export type BrowseTrack = {
37
+ name: string;
38
+ artist: string;
39
+ uri: string;
40
+ durationMs: number;
41
+ };
42
+ export type AlbumDetail = {
43
+ name: string;
44
+ artist: string;
45
+ uri: string;
46
+ tracks: BrowseTrack[];
47
+ };
48
+ export type PlaylistDetail = {
49
+ name: string;
50
+ owner: string;
51
+ uri: string;
52
+ tracks: BrowseTrack[];
53
+ };
54
+ export declare function searchSpotify(client: SpotifyApi, query: string, limit?: number): Promise<SearchResults>;
55
+ /**
56
+ * Fetches full album details including track list.
57
+ * @param client - Authenticated Spotify API client
58
+ * @param albumId - Spotify album ID
59
+ * @returns Album metadata and track list
60
+ */
61
+ export declare function fetchAlbumTracks(client: SpotifyApi, albumId: string): Promise<AlbumDetail>;
62
+ /**
63
+ * Fetches full playlist details including track list.
64
+ * @param client - Authenticated Spotify API client
65
+ * @param playlistId - Spotify playlist ID
66
+ * @returns Playlist metadata and track list
67
+ */
68
+ export declare function fetchPlaylistTracks(client: SpotifyApi, playlistId: string): Promise<PlaylistDetail>;
69
+ /**
70
+ * Fetches the current user's playlists.
71
+ * @param client - Authenticated Spotify API client
72
+ * @param limit - Max number of playlists to return (default: USER_PLAYLISTS_LIMIT)
73
+ * @param offset - Pagination offset
74
+ * @returns List of simplified playlist items and total count
75
+ */
76
+ export declare function fetchUserPlaylists(client: SpotifyApi, limit?: number, offset?: number): Promise<{
77
+ items: SearchResultPlaylist[];
78
+ total: number;
79
+ }>;
@@ -0,0 +1,143 @@
1
+ import { SEARCH_RESULTS_LIMIT, USER_PLAYLISTS_LIMIT } from '../config.js';
2
+ /**
3
+ * Searches Spotify for tracks, albums, artists, and playlists matching the query.
4
+ * @param client - Authenticated Spotify API client
5
+ * @param query - Search query string
6
+ * @param limit - Max results per category (default: SEARCH_RESULTS_LIMIT)
7
+ * @returns Categorized search results
8
+ */
9
+ // Multi-type search reliably rejects values above this with `400 Invalid limit`,
10
+ // even though the Spotify docs claim `max=50` for single-type searches.
11
+ const MULTI_TYPE_SEARCH_MAX_LIMIT = 20;
12
+ export async function searchSpotify(client, query, limit = SEARCH_RESULTS_LIMIT) {
13
+ const safeLimit = Math.max(1, Math.min(limit, MULTI_TYPE_SEARCH_MAX_LIMIT));
14
+ const data = await client.search(query, [
15
+ 'track',
16
+ 'album',
17
+ 'artist',
18
+ 'playlist',
19
+ ], undefined, safeLimit);
20
+ // SDK returns null when the access token is empty or an error is silently handled
21
+ if (!data) {
22
+ return {
23
+ tracks: [],
24
+ albums: [],
25
+ artists: [],
26
+ playlists: [],
27
+ };
28
+ }
29
+ // Spotify search can return null entries in items arrays — filter them out
30
+ const tracks = (data.tracks?.items ?? []).filter(Boolean).map((t) => ({
31
+ name: t.name,
32
+ artist: t.artists.map((a) => a.name).join(', '),
33
+ album: t.album.name,
34
+ uri: t.uri,
35
+ durationMs: t.duration_ms,
36
+ albumUri: t.album.uri,
37
+ }));
38
+ const albums = (data.albums?.items ?? []).filter(Boolean).map((a) => ({
39
+ name: a.name,
40
+ artist: a.artists.map((ar) => ar.name).join(', '),
41
+ uri: a.uri,
42
+ id: a.id,
43
+ imageUrl: a.images.length > 0 ? a.images[a.images.length - 1].url : null,
44
+ totalTracks: a.total_tracks,
45
+ }));
46
+ const artists = (data.artists?.items ?? []).filter(Boolean).map((ar) => ({
47
+ name: ar.name,
48
+ uri: ar.uri,
49
+ id: ar.id,
50
+ }));
51
+ // SDK types search playlists as PlaylistBase (no tracks field),
52
+ // but the API response includes tracks.total — cast to access it.
53
+ const playlists = (data.playlists?.items ?? []).filter(Boolean).map((p) => {
54
+ const trackRef = p.tracks;
55
+ return {
56
+ name: p.name,
57
+ owner: p.owner.display_name ?? p.owner.id,
58
+ uri: p.uri,
59
+ id: p.id,
60
+ totalTracks: trackRef?.total ?? 0,
61
+ };
62
+ });
63
+ return {
64
+ tracks,
65
+ albums,
66
+ artists,
67
+ playlists,
68
+ };
69
+ }
70
+ /**
71
+ * Fetches full album details including track list.
72
+ * @param client - Authenticated Spotify API client
73
+ * @param albumId - Spotify album ID
74
+ * @returns Album metadata and track list
75
+ */
76
+ export async function fetchAlbumTracks(client, albumId) {
77
+ const album = await client.albums.get(albumId);
78
+ if (!album)
79
+ throw new Error('Failed to fetch album');
80
+ return {
81
+ name: album.name,
82
+ artist: album.artists.map((a) => a.name).join(', '),
83
+ uri: album.uri,
84
+ tracks: album.tracks.items.filter(Boolean).map((t) => ({
85
+ name: t.name,
86
+ artist: t.artists.map((a) => a.name).join(', '),
87
+ uri: t.uri,
88
+ durationMs: t.duration_ms,
89
+ })),
90
+ };
91
+ }
92
+ /**
93
+ * Fetches full playlist details including track list.
94
+ * @param client - Authenticated Spotify API client
95
+ * @param playlistId - Spotify playlist ID
96
+ * @returns Playlist metadata and track list
97
+ */
98
+ export async function fetchPlaylistTracks(client, playlistId) {
99
+ const playlist = await client.playlists.getPlaylist(playlistId);
100
+ if (!playlist)
101
+ throw new Error('Failed to fetch playlist');
102
+ return {
103
+ name: playlist.name,
104
+ owner: playlist.owner.display_name ?? playlist.owner.id,
105
+ uri: playlist.uri,
106
+ tracks: playlist.tracks.items
107
+ .filter((item) => item.track)
108
+ .map((item) => {
109
+ const t = item.track;
110
+ return {
111
+ name: t.name,
112
+ artist: 'artists' in t ? t.artists.map((a) => a.name).join(', ') : 'Unknown',
113
+ uri: t.uri,
114
+ durationMs: t.duration_ms,
115
+ };
116
+ }),
117
+ };
118
+ }
119
+ /**
120
+ * Fetches the current user's playlists.
121
+ * @param client - Authenticated Spotify API client
122
+ * @param limit - Max number of playlists to return (default: USER_PLAYLISTS_LIMIT)
123
+ * @param offset - Pagination offset
124
+ * @returns List of simplified playlist items and total count
125
+ */
126
+ export async function fetchUserPlaylists(client, limit = USER_PLAYLISTS_LIMIT, offset = 0) {
127
+ const page = await client.currentUser.playlists.playlists(limit, offset);
128
+ if (!page)
129
+ return {
130
+ items: [],
131
+ total: 0,
132
+ };
133
+ return {
134
+ items: page.items.filter(Boolean).map((p) => ({
135
+ name: p.name,
136
+ owner: p.owner.display_name ?? p.owner.id,
137
+ uri: p.uri,
138
+ id: p.id,
139
+ totalTracks: p.tracks?.total ?? 0,
140
+ })),
141
+ total: page.total,
142
+ };
143
+ }