Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ spotify:
client_secret: "your-client-secret-here"
# IMPORTANT: Must use 127.0.0.1, NOT localhost (Spotify removed localhost support Nov 2025)
redirect_uri: "http://127.0.0.1:8888/callback"
# Optional market/region for podcast episodes, track search, and music availability
# Use ISO 3166-1 alpha-2 country codes: US, GB, IT, FR, DE, ES, SE, AU, CA, etc.
# Default: US (United States)
# Examples:
# US → United States
market: "US"

# --- Your Playlist ---
# The playlist where Daily Drive content will be placed.
Expand Down
125 changes: 108 additions & 17 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,50 @@ function shuffle(array) {
return arr;
}

/**
* Removes duplicate items from an array based on their URI.
* Keeps the first occurrence of each URI; subsequent occurrences are removed.
*
* This prevents the same song or episode from appearing multiple times in the
* playlist when it's fetched from different sources (e.g., top tracks + playlists,
* or familiar music + genre discovery).
*
* @param {Array} items - Array of objects with a `uri` property
* @param {string} label - Optional label for logging (e.g., "tracks", "episodes")
* @returns {Array} - Deduplicated array with logging output
*/
function removeDuplicates(items, label = "items") {
const seen = new Set();
const deduplicated = [];
let duplicateCount = 0;

for (const item of items) {
const dedupeKey = item.type === "track" && item.isrc ? item.isrc : item.uri;

if (!dedupeKey) {
// Items without a usable dedupe key are kept (shouldn't happen, but be safe)
deduplicated.push(item);
continue;
}

if (seen.has(dedupeKey)) {
duplicateCount++;
console.log(
` ⏭️ Skipping duplicate: ${item.name} (${item.type === "episode" ? item.show : item.artist})`
);
} else {
seen.add(dedupeKey);
deduplicated.push(item);
}
}

if (duplicateCount > 0) {
console.log(` 🔄 Removed ${duplicateCount} duplicate ${label}`);
}

return deduplicated;
}

/**
* 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
Expand Down Expand Up @@ -133,7 +177,7 @@ async function refreshTokenIfNeeded(spotifyApi, token) {
* quickly on Spotify. If you see "[unavailable]" in your playlist, run the
* script again to fetch the latest episode.
*/
async function fetchPodcastEpisodes(spotifyApi, podcasts) {
async function fetchPodcastEpisodes(spotifyApi, podcasts, market) {
const episodes = [];

for (const podcast of podcasts) {
Expand All @@ -145,7 +189,7 @@ async function fetchPodcastEpisodes(spotifyApi, podcasts) {
// Ask Spotify for the most recent episodes of this show
const data = await spotifyApi.getShowEpisodes(podcast.id, {
limit: count,
market: "US", // Required for episode availability
market: market || "US", // Use configured market or default to US
});

for (const episode of data.body.items) {
Expand Down Expand Up @@ -174,7 +218,7 @@ async function fetchPodcastEpisodes(spotifyApi, podcasts) {
*
* Tracks are shuffled and trimmed to the requested count.
*/
async function fetchMusicTracks(spotifyApi, musicConfig) {
async function fetchMusicTracks(spotifyApi, musicConfig, market) {
let allTracks = [];

// --- Source 1: Pull tracks from user-specified playlists ---
Expand Down Expand Up @@ -276,6 +320,9 @@ async function fetchMusicTracks(spotifyApi, musicConfig) {
}
}

// Remove duplicate tracks before shuffling and trimming
allTracks = removeDuplicates(allTracks, "tracks");

// Shuffle and trim to the desired total number of songs
const totalSongs = musicConfig.total_songs || 15;
if (musicConfig.shuffle !== false) {
Expand All @@ -294,7 +341,7 @@ async function fetchMusicTracks(spotifyApi, musicConfig) {
*
* Tracks are split evenly across genres, then shuffled and trimmed.
*/
async function fetchGenreTracks(spotifyApi, genres, count) {
async function fetchGenreTracks(spotifyApi, genres, count, market) {
const tracks = [];
// Divide the target count evenly among configured genres
const perGenre = Math.ceil(count / genres.length);
Expand All @@ -305,7 +352,7 @@ async function fetchGenreTracks(spotifyApi, genres, count) {
// Use Spotify's search with a "genre:" filter
const data = await spotifyApi.searchTracks(`genre:${genre}`, {
limit: Math.min(perGenre, 10), // Spotify Dev Mode caps search at 10 results per query
market: "US",
market: market || "US", // Use configured market or default to US
});

for (const track of data.body.tracks.items) {
Expand Down Expand Up @@ -389,7 +436,7 @@ function mixContent(episodes, tracks, pattern) {
* PUT replaces the first 100 items; POST appends additional batches if needed.
* This endpoint accepts both track and episode URIs.
*/
async function updatePlaylist(spotifyApi, playlistId, items) {
async function updatePlaylist(spotifyApi, playlistId, items, mode) {
const uris = items.map((item) => item.uri);

// In dry-run mode, just print what would happen and return
Expand Down Expand Up @@ -436,8 +483,38 @@ async function updatePlaylist(spotifyApi, playlistId, items) {
}

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`);
const episodeCount = items.filter((i) => i.type === "episode").length;
const trackCount = items.filter((i) => i.type === "track").length;
console.log(` 🎙️ ${episodeCount} podcast episodes`);
console.log(` 🎵 ${trackCount} songs\n`);

const now = new Date();
const updatedAt = now.toISOString().replace("T", " ").split(".")[0] + " UTC";
const modeLabel = mode === "podcast-only" ? "hourly podcast refresh" : "full refresh";
const description = `Daily Drive updated: ${trackCount} songs, ${episodeCount} episodes, ${modeLabel}. Last updated ${updatedAt}.`;

await setPlaylistDescription(spotifyApi, playlistId, description);
console.log("📝 Playlist description updated.");
}

/**
* Updates the Spotify playlist description with a short debug summary.
*/
async function setPlaylistDescription(spotifyApi, playlistId, description) {
const accessToken = spotifyApi.getAccessToken();
const res = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ description }),
});

if (!res.ok) {
const err = await res.text();
throw new Error(`Failed to update playlist description: ${res.status} ${err}`);
}
}

// =============================================================================
Expand All @@ -446,7 +523,9 @@ async function updatePlaylist(spotifyApi, playlistId, items) {

async function main() {
const mode = PODCAST_ONLY ? "podcast-only" : "full";
console.log(`\n🚗 Daily Drive — ${PODCAST_ONLY ? "Hourly podcast refresh" : "Full playlist rebuild"}...\n`);
const runTime = new Date().toISOString().replace("T", " ").split(".")[0] + " UTC";
console.log(`\n🚗 Daily Drive — ${PODCAST_ONLY ? "Hourly podcast refresh" : "Full playlist rebuild"}...`);
console.log(` 🕒 Run started: ${runTime}\n`);

// Step 1: Load configuration and authentication token
const config = loadConfig();
Expand All @@ -473,7 +552,7 @@ async function main() {
}

// Step 5: Fetch the latest podcast episodes
const episodes = await fetchPodcastEpisodes(spotifyApi, config.podcasts || []);
const episodes = await fetchPodcastEpisodes(spotifyApi, config.podcasts || [], config.spotify.market);

// Step 6: Check if episodes have changed since last run
// This prevents unnecessary playlist updates that would reset your listening position
Expand Down Expand Up @@ -532,10 +611,14 @@ async function main() {
console.log(`\n🔀 Mixing with pattern: ${config.mix_pattern || "PMMM"}`);
const mixed = [...pinnedFirst, ...mixContent(mixableEpisodes, tracks, config.mix_pattern)];

// Step 9a: Final deduplication pass (safety net)
// This catches any duplicates that might have appeared during mixing
const finalMixed = removeDuplicates(mixed, "playlist items");

// Step 10: Push the final mixed playlist to Spotify
await updatePlaylist(spotifyApi, config.playlist_id, mixed);
await updatePlaylist(spotifyApi, config.playlist_id, finalMixed, mode);

// Step 11: Save state so the next run can detect if episodes have changed
// Step 11: Save state with detailed item metadata for debugging
if (!DRY_RUN) {
const newState = {
episode_uris: currentEpisodeUris,
Expand Down Expand Up @@ -564,6 +647,7 @@ async function main() {
*/
async function fetchAllMusicTracks(spotifyApi, config) {
const musicConfig = config.music || {};
const market = config.spotify?.market || "US";
const totalSongs = musicConfig.total_songs || 15;
const hasGenres = musicConfig.genres && musicConfig.genres.length > 0;

Expand All @@ -575,17 +659,24 @@ async function fetchAllMusicTracks(spotifyApi, config) {

// Fetch familiar tracks (your top tracks + any source playlists)
const familiarConfig = { ...musicConfig, total_songs: familiarCount };
let tracks = await fetchMusicTracks(spotifyApi, familiarConfig);
let tracks = await fetchMusicTracks(spotifyApi, familiarConfig, market);

// Fetch discovery tracks (genre-based search for new music)
if (hasGenres && discoveryCount > 0) {
const genreTracks = await fetchGenreTracks(spotifyApi, musicConfig.genres, discoveryCount);
const genreTracks = await fetchGenreTracks(spotifyApi, musicConfig.genres, discoveryCount, market);

// 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`);
const uniqueGenreTracks = genreTracks.filter((t) => !familiarUris.has(t.uri));

// Log deduplication result
if (uniqueGenreTracks.length < genreTracks.length) {
const deduped = genreTracks.length - uniqueGenreTracks.length;
console.log(` 🔄 Removed ${deduped} duplicate genre track(s) already in familiar music`);
}

tracks = [...tracks, ...uniqueGenreTracks.slice(0, discoveryCount)];
console.log(`🎵 Music mix: ${familiarCount} familiar + ${uniqueGenreTracks.slice(0, discoveryCount).length} discovery = ${tracks.length} total`);
}

return tracks;
Expand Down