-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.js
More file actions
661 lines (574 loc) · 24.4 KB
/
Copy pathindex.js
File metadata and controls
661 lines (574 loc) · 24.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
#!/usr/bin/env node
// =============================================================================
// Daily Drive — Main Script
// =============================================================================
// Builds your custom Daily Drive playlist by mixing podcasts and music.
// This recreates Spotify's discontinued "Daily Drive" feature.
//
// Usage: npm start (full refresh — new music + podcasts)
// npm test (dry run — shows what would happen)
// node index.js --dry-run
// node index.js --podcast-only (hourly mode — fresh podcasts, reuses today's music)
// =============================================================================
// --- Node.js built-in modules ---
const fs = require("fs");
// --- Third-party libraries (installed via npm install) ---
const yaml = require("js-yaml"); // Parses YAML config files
const { SpotifyApi } = require("@spotify/web-api-ts-sdk");
// --- File paths used by the script ---
const TOKEN_FILE = ".spotify-token.json"; // Stores your Spotify OAuth tokens (created by setup.js)
const CONFIG_FILE = "config.yaml"; // Your configuration (podcasts, music, schedule, etc.)
const STATE_FILE = "state.json"; // Caches last run's episode URIs to detect changes
// Check command-line flags
const DRY_RUN = process.argv.includes("--dry-run"); // Shows what would happen without changing the playlist
const PODCAST_ONLY = process.argv.includes("--podcast-only"); // Hourly mode: only refresh podcasts, reuse saved music
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Loads and parses config.yaml. Exits with an error if the file doesn't exist.
* This file contains your Spotify credentials, podcast list, music preferences, etc.
*/
function loadConfig() {
if (!fs.existsSync(CONFIG_FILE)) {
console.error("❌ config.yaml not found! Run: cp config.example.yaml config.yaml");
process.exit(1);
}
return yaml.load(fs.readFileSync(CONFIG_FILE, "utf8"));
}
/**
* Loads the saved OAuth token from disk. Exits if not found.
* The token file is created when you run `npm run setup` for the first time.
*/
function loadToken() {
if (!fs.existsSync(TOKEN_FILE)) {
console.error("❌ Not authenticated! Run: npm run setup");
process.exit(1);
}
return JSON.parse(fs.readFileSync(TOKEN_FILE, "utf8"));
}
/**
* Saves the OAuth token back to disk (called after a token refresh).
*/
function saveToken(tokenData) {
fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokenData, null, 2));
}
/**
* Loads the state file that tracks which episodes were in the last playlist update.
* Returns an empty object if the file doesn't exist or is corrupted.
*/
function loadState() {
if (!fs.existsSync(STATE_FILE)) return {};
try {
return JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
} catch {
return {};
}
}
/**
* Saves state to disk so the next run can compare episodes and skip if nothing changed.
*/
function saveState(state) {
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
}
/**
* Fisher-Yates shuffle — randomizes an array in-place.
* Used to shuffle music tracks so the playlist feels fresh each time.
*/
function shuffle(array) {
const arr = [...array]; // Create a copy so we don't modify the original
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]]; // Swap elements
}
return arr;
}
/**
* Returns true when a track should be excluded from Daily Drive music.
* We support a configurable keyword list plus a classical-music heuristic.
*/
function shouldExcludeTrack(track, musicConfig) {
const title = (track.name || "").toLowerCase();
const artist = (track.artist || "").toLowerCase();
const searchable = `${title} ${artist}`;
const configuredKeywords = (musicConfig.exclude_keywords || []).map((k) => String(k).toLowerCase());
for (const keyword of configuredKeywords) {
if (keyword && searchable.includes(keyword)) return true;
}
if (musicConfig.exclude_classical) {
const classicalKeywords = [
"classical", "symphony", "symphonic", "concerto", "sonata", "suite", "adagio", "orchestra",
"philarmonic", "philharmonic", "philharmoniker", "movement", "act ", "opus", "op.", "no.",
"mozart", "beethoven", "bach", "chopin", "debussy",
"tchaikovsky", "rachmaninoff", "vivaldi", "brahms", "handel", "stravinsky", "mahler",
"schubert", "ravel", "liszt", "mendelssohn", "dvorak", "satie", "prokofiev", "shostakovich",
"khachaturian", "bernstein",
];
for (const keyword of classicalKeywords) {
if (searchable.includes(keyword)) return true;
}
}
return false;
}
/**
* Spotify access tokens expire after 1 hour. This function checks if the token
* is about to expire (within 5 minutes) and refreshes it automatically using
* the long-lived refresh token. You don't need to re-authenticate manually.
*/
async function refreshTokenIfNeeded(token, clientId, clientSecret) {
if (Date.now() > token.expires_at - 5 * 60 * 1000) {
console.log("🔄 Refreshing access token...");
const authBody = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: token.refresh_token,
client_id: clientId,
client_secret: clientSecret,
});
const response = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: authBody.toString(),
});
if (!response.ok) {
throw new Error(`Token refresh failed: ${response.status} ${await response.text()}`);
}
const data = await response.json();
// Update the token in memory
token.access_token = data.access_token;
token.expires_at = Date.now() + data.expires_in * 1000;
// Spotify sometimes rotates the refresh token too — save it if provided
if (data.refresh_token) {
token.refresh_token = data.refresh_token;
}
// Persist to disk and update the API client
saveToken(token);
console.log("✅ Token refreshed");
}
}
// =============================================================================
// Core Logic
// =============================================================================
/**
* Fetches the latest episodes for each podcast listed in your config.
* Returns an array of episode objects with uri, name, show name, and position.
*
* Note: Some podcasts (like NPR News Now) publish hourly episodes that expire
* quickly on Spotify. If you see "[unavailable]" in your playlist, run the
* script again to fetch the latest episode.
*/
async function fetchPodcastEpisodes(spotifyApi, podcasts) {
const episodes = [];
for (const podcast of podcasts) {
// How many recent episodes to grab (default: 1, configurable per podcast)
const count = podcast.episodes || 1;
console.log(`🎙️ Fetching ${count} episode(s) from: ${podcast.name}`);
try {
// Ask Spotify for the most recent episodes of this show
const data = await spotifyApi.shows.episodes(podcast.id, "US", count, 0);
for (const episode of data.items) {
episodes.push({
uri: episode.uri, // Spotify URI like "spotify:episode:abc123"
name: episode.name,
show: podcast.name,
type: "episode",
position: podcast.position || null, // "first" = pinned to top of playlist
});
console.log(` 📌 ${episode.name}`);
}
} catch (err) {
// Don't crash if one podcast fails — just warn and continue with the rest
console.error(` ⚠️ Failed to fetch ${podcast.name}: ${err.message}`);
}
}
return episodes;
}
/**
* Fetches music tracks from two "familiar" sources:
* 1. Source playlists — songs from playlists you specify in config.yaml
* 2. Top tracks — your most-played songs on Spotify
*
* Tracks are shuffled and trimmed to the requested count.
*/
async function fetchMusicTracks(spotifyApi, musicConfig, accessToken) {
let allTracks = [];
// --- Source 1: Pull tracks from user-specified playlists ---
if (musicConfig.playlists) {
for (const playlist of musicConfig.playlists) {
// Skip placeholder entries from the example config
if (!playlist.id || playlist.id === "your-playlist-id") continue;
console.log(`🎵 Fetching songs from playlist: ${playlist.name}`);
try {
// Spotify returns max 100 items per request, so we paginate through
// larger playlists by incrementing the offset
let offset = 0;
let hasMore = true;
while (hasMore) {
// IMPORTANT: We use the /items endpoint directly via fetch() because
// the spotify-web-api-node library's getPlaylistTracks() still hits
// the old /tracks endpoint, which Spotify deprecated in Feb 2026 and
// now returns 403 Forbidden.
const res = await fetch(
`https://api.spotify.com/v1/playlists/${playlist.id}/items?limit=100&offset=${offset}`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
}
const data = await res.json();
for (const entry of data.items) {
// The /items endpoint returns the content in entry.item (not entry.track)
const track = entry.item;
if (track && track.uri && track.type === "track") {
const mappedTrack = {
uri: track.uri,
name: track.name,
artist: track.artists?.map((a) => a.name).join(", ") || "Unknown",
type: "track",
};
if (!shouldExcludeTrack(mappedTrack, musicConfig)) {
allTracks.push(mappedTrack);
}
}
}
offset += 100;
hasMore = offset < data.total;
}
console.log(
` Found ${allTracks.length} tracks so far`
);
} catch (err) {
console.error(
` ⚠️ Failed to fetch playlist ${playlist.name}: ${err.message}`
);
}
}
}
// --- Source 2: Pull from user's top tracks (most-played songs) ---
if (musicConfig.top_tracks && musicConfig.top_tracks.enabled) {
// time_range controls the window:
// "short_term" = last ~4 weeks
// "medium_term" = last ~6 months
// "long_term" = all time
const timeRange = musicConfig.top_tracks.time_range || "short_term";
const count = musicConfig.top_tracks.count || 30;
const totalSongs = musicConfig.total_songs || 15;
const targetNonExcluded = Math.max(count, totalSongs * 2);
console.log(`🎵 Fetching top tracks (${timeRange})...`);
try {
let offset = 0;
const limit = 50;
let acceptedFromTopTracks = 0;
// Keep paging until we have a solid non-excluded pool, or Spotify runs out.
while (acceptedFromTopTracks < targetNonExcluded) {
const data = await spotifyApi.currentUser.topItems("tracks", timeRange, limit, offset);
if (!data.items.length) break;
for (const track of data.items) {
const mappedTrack = {
uri: track.uri,
name: track.name,
artist: track.artists?.map((a) => a.name).join(", ") || "Unknown",
type: "track",
};
if (!shouldExcludeTrack(mappedTrack, musicConfig)) {
allTracks.push(mappedTrack);
acceptedFromTopTracks++;
}
}
// If we got fewer tracks than requested, there are no more
if (data.items.length < limit) break;
offset += limit;
if (offset >= 1000) break;
}
console.log(` Found ${allTracks.length} tracks from top tracks`);
} catch (err) {
console.error(` ⚠️ Failed to fetch top tracks: ${err.message}`);
}
}
// Shuffle and trim to the desired total number of songs
const totalSongs = musicConfig.total_songs || 15;
if (musicConfig.shuffle !== false) {
allTracks = shuffle(allTracks);
}
allTracks = allTracks.slice(0, totalSongs);
console.log(`🎵 Selected ${allTracks.length} songs`);
return allTracks;
}
/**
* Fetches "discovery" tracks by searching Spotify for songs matching your
* configured genres (e.g., "dance pop", "indie rock"). This helps you discover
* new music outside your usual listening habits.
*
* Tracks are split evenly across genres, then shuffled and trimmed.
*/
async function fetchGenreTracks(spotifyApi, genres, count, musicConfig) {
const tracks = [];
// Divide the target count evenly among configured genres
const perGenre = Math.ceil(count / genres.length);
for (const genre of genres) {
console.log(`🎵 Searching for ${genre} tracks...`);
try {
// Use Spotify's search with a "genre:" filter
const data = await spotifyApi.search(`genre:${genre}`, ["track"], "US", Math.min(perGenre, 10), 0);
for (const track of data.tracks.items) {
const mappedTrack = {
uri: track.uri,
name: track.name,
artist: track.artists?.map((a) => a.name).join(", ") || "Unknown",
type: "track",
};
if (!shouldExcludeTrack(mappedTrack, musicConfig)) {
tracks.push(mappedTrack);
}
}
console.log(` Found ${data.tracks.items.length} tracks`);
} catch (err) {
console.error(` ⚠️ Failed to search genre ${genre}: ${err.message}`);
}
}
// Shuffle so we don't always get the same top results, then trim to count
return shuffle(tracks).slice(0, count);
}
/**
* Interleaves podcast episodes and music tracks according to a pattern string.
*
* Pattern example: "PMMM" means: 1 podcast, 3 music, 1 podcast, 3 music, ...
* P = podcast episode slot
* M = music track slot
*
* The pattern repeats cyclically. When one content type runs out, the remaining
* items of the other type are appended at the end.
*/
function mixContent(episodes, tracks, pattern) {
const mixed = [];
let episodeIndex = 0;
let trackIndex = 0;
let patternIndex = 0;
const mixPattern = pattern || "PMMM";
// Walk through the pattern, placing content in the appropriate slots
while (episodeIndex < episodes.length || trackIndex < tracks.length) {
// Which slot are we on? The pattern repeats using modulo (%)
const slot = mixPattern[patternIndex % mixPattern.length];
if (slot === "P" || slot === "p") {
// Podcast slot — place next episode if available
if (episodeIndex < episodes.length) {
mixed.push(episodes[episodeIndex++]);
}
} else {
// Music slot (M) — place next track if available
if (trackIndex < tracks.length) {
mixed.push(tracks[trackIndex++]);
}
}
patternIndex++;
// Safety valve: if one type is exhausted, dump all remaining items of the other
// This prevents an infinite loop when the pattern asks for content we don't have
if (episodeIndex >= episodes.length && trackIndex < tracks.length) {
while (trackIndex < tracks.length) {
mixed.push(tracks[trackIndex++]);
}
break;
}
if (trackIndex >= tracks.length && episodeIndex < episodes.length) {
while (episodeIndex < episodes.length) {
mixed.push(episodes[episodeIndex++]);
}
break;
}
}
return mixed;
}
/**
* Replaces the entire playlist with the given items.
*
* Uses the Spotify /items endpoint (NOT /tracks, which was deprecated in Feb 2026).
* PUT replaces the first 100 items; POST appends additional batches if needed.
* This endpoint accepts both track and episode URIs.
*/
async function updatePlaylist(playlistId, items, accessToken) {
const uris = items.map((item) => item.uri);
// In dry-run mode, just print what would happen and return
if (DRY_RUN) {
console.log("\n🧪 DRY RUN — would update playlist with:\n");
items.forEach((item, i) => {
const icon = item.type === "episode" ? "🎙️ " : "🎵";
const detail =
item.type === "episode"
? `[${item.show}] ${item.name}`
: `${item.name} — ${item.artist}`;
console.log(` ${String(i + 1).padStart(2)}. ${icon} ${detail}`);
});
console.log(`\n✅ Dry run complete. ${items.length} items would be added.\n`);
return;
}
// PUT replaces the entire playlist with up to 100 items at once
const clearRes = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/items`, {
method: "PUT",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ uris: uris.slice(0, 100) }),
});
if (!clearRes.ok) {
const err = await clearRes.text();
throw new Error(`Failed to update playlist: ${clearRes.status} ${err}`);
}
// If we have more than 100 items, POST the remaining in batches of 100
for (let i = 100; i < uris.length; i += 100) {
const batch = uris.slice(i, i + 100);
const addRes = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/items`, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ uris: batch }),
});
if (!addRes.ok) {
const err = await addRes.text();
throw new Error(`Failed to add batch: ${addRes.status} ${err}`);
}
}
console.log(`\n✅ Playlist updated with ${items.length} items!`);
console.log(` 🎙️ ${items.filter((i) => i.type === "episode").length} podcast episodes`);
console.log(` 🎵 ${items.filter((i) => i.type === "track").length} songs\n`);
}
// =============================================================================
// Main — Entry point that orchestrates everything
// =============================================================================
async function main() {
const mode = PODCAST_ONLY ? "podcast-only" : "full";
console.log(`\n🚗 Daily Drive — ${PODCAST_ONLY ? "Hourly podcast refresh" : "Full playlist rebuild"}...\n`);
// Step 1: Load configuration and authentication token
const config = loadConfig();
const token = loadToken();
// Step 2: Refresh access token if needed before creating the SDK client
await refreshTokenIfNeeded(token, config.spotify.client_id, config.spotify.client_secret);
// Step 3: Create Spotify API client with the (possibly refreshed) token
const spotifyApi = SpotifyApi.withAccessToken(
config.spotify.client_id,
{
access_token: token.access_token,
token_type: "Bearer",
expires_in: Math.max(1, Math.floor((token.expires_at - Date.now()) / 1000)),
refresh_token: token.refresh_token,
},
[
"playlist-modify-public",
"playlist-modify-private",
"playlist-read-private",
"playlist-read-collaborative",
"user-library-read",
"user-read-private",
"user-read-recently-played",
"user-top-read",
]
);
// Step 4: Make sure the user has set a real playlist ID
if (!config.playlist_id || config.playlist_id === "your-playlist-id-here") {
console.error("❌ Please set your playlist_id in config.yaml");
process.exit(1);
}
// Step 5: Fetch the latest podcast episodes
const episodes = await fetchPodcastEpisodes(spotifyApi, config.podcasts || []);
// Step 6: Check if episodes have changed since last run
// This prevents unnecessary playlist updates that would reset your listening position
const state = loadState();
const currentEpisodeUris = episodes.map((e) => e.uri).sort().join(",");
const previousEpisodeUris = state.episode_uris || "";
// In podcast-only mode, skip if episodes haven't changed (no point reshuffling)
// In full refresh mode, ALWAYS proceed — we want fresh music even if podcasts are the same
if (!DRY_RUN && PODCAST_ONLY && currentEpisodeUris === previousEpisodeUris && episodes.length > 0) {
console.log("\n⏭️ No new podcast episodes detected. Playlist unchanged.");
console.log(" (Same episodes as last update — skipping to avoid disruption)\n");
process.exit(0);
}
// Step 7: Get music tracks
let tracks;
if (PODCAST_ONLY) {
// --- Podcast-only mode (hourly) ---
// Reuse the music tracks saved from the last full refresh.
// This keeps your music stable all day while swapping in fresh podcast episodes.
if (state.music_tracks && state.music_tracks.length > 0) {
tracks = state.music_tracks;
console.log(`🎵 Reusing ${tracks.length} saved music tracks from last full refresh`);
} else {
// No saved music — fall back to a full music fetch
// This happens on the very first run, or if state.json was deleted
console.log("⚠️ No saved music tracks found — falling back to full music fetch");
tracks = await fetchAllMusicTracks(spotifyApi, config, token.access_token);
}
} else {
// --- Full refresh mode (daily) ---
// Fetch fresh music from all sources (top tracks, playlists, genre discovery)
tracks = await fetchAllMusicTracks(spotifyApi, config, token.access_token);
}
if (episodes.length === 0 && tracks.length === 0) {
console.error("❌ No content found! Check your config.yaml settings.");
process.exit(1);
}
// Step 8: Separate pinned episodes (position: "first") from mixable ones
// Pinned episodes go at the very top of the playlist, before the mix pattern starts
const pinnedFirst = [];
const mixableEpisodes = [];
for (const ep of episodes) {
if (ep.position === "first") {
pinnedFirst.push(ep);
} else {
mixableEpisodes.push(ep);
}
}
// Step 9: Mix podcasts and music according to the configured pattern
console.log(`\n🔀 Mixing with pattern: ${config.mix_pattern || "PMMM"}`);
const mixed = [...pinnedFirst, ...mixContent(mixableEpisodes, tracks, config.mix_pattern)];
// Step 10: Push the final mixed playlist to Spotify
await updatePlaylist(config.playlist_id, mixed, token.access_token);
// Step 11: Save state so the next run can detect if episodes have changed
if (!DRY_RUN) {
const newState = {
episode_uris: currentEpisodeUris,
last_updated: new Date().toISOString(),
};
if (PODCAST_ONLY) {
// In podcast-only mode, preserve the saved music tracks from the full refresh
newState.music_tracks = state.music_tracks || tracks;
newState.last_full_refresh = state.last_full_refresh || null;
} else {
// In full refresh mode, save the music tracks for hourly podcast-only runs to reuse
newState.music_tracks = tracks;
newState.last_full_refresh = new Date().toISOString();
}
saveState(newState);
console.log("💾 State saved to state.json");
}
}
/**
* Fetches all music tracks (familiar + discovery) based on config.
* Used by full refresh mode, and as a fallback for podcast-only mode
* when no saved tracks exist yet.
*/
async function fetchAllMusicTracks(spotifyApi, config, accessToken) {
const musicConfig = config.music || {};
const totalSongs = musicConfig.total_songs || 15;
const hasGenres = musicConfig.genres && musicConfig.genres.length > 0;
// When genres are configured, split total_songs 50/50:
// - Half "familiar" (your top tracks + source playlists)
// - Half "discovery" (genre search results — new music for you)
const familiarCount = hasGenres ? Math.ceil(totalSongs / 2) : totalSongs;
const discoveryCount = hasGenres ? totalSongs - familiarCount : 0;
// Fetch familiar tracks (your top tracks + any source playlists)
const familiarConfig = { ...musicConfig, total_songs: familiarCount };
let tracks = await fetchMusicTracks(spotifyApi, familiarConfig, accessToken);
// Fetch discovery tracks (genre-based search for new music)
if (hasGenres && discoveryCount > 0) {
const genreTracks = await fetchGenreTracks(spotifyApi, musicConfig.genres, discoveryCount, musicConfig);
// Remove any genre tracks that duplicate songs already in the familiar set
const familiarUris = new Set(tracks.map((t) => t.uri));
const newGenreTracks = genreTracks.filter((t) => !familiarUris.has(t.uri));
tracks = [...tracks, ...newGenreTracks.slice(0, discoveryCount)];
console.log(`🎵 Music mix: ${familiarCount} familiar + ${newGenreTracks.slice(0, discoveryCount).length} discovery = ${tracks.length} total`);
}
return tracks;
}
// Run the main function and handle any uncaught errors
main().catch((err) => {
console.error("\n❌ Error:", err.message);
if (err.statusCode === 401) {
console.error(" Your token may have expired. Run: npm run setup\n");
}
process.exit(1);
});