diff --git a/config.example.yaml b/config.example.yaml index 809fc4a..63dbd4e 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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. diff --git a/index.js b/index.js index 3083a0f..7fefa98 100644 --- a/index.js +++ b/index.js @@ -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 @@ -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) { @@ -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) { @@ -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 --- @@ -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) { @@ -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); @@ -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) { @@ -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 @@ -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}`); + } } // ============================================================================= @@ -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(); @@ -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 @@ -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, @@ -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; @@ -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;