Serverless Cron job to sync spotify playlists with youtube.
This entrypoint spins up a tiny Express app to perform the one‑time OAuth flows for both YouTube and Spotify, store the refresh tokens into your .env, and provide a manual “sync now” route. Run it locally once to authorize both providers; afterwards, the refresh tokens are persisted so your serverless job can work headlessly.
import express from "express";
import bodyParser from "body-parser";
import open from "open";
import { google } from "googleapis";
import fs from "fs/promises";
import path from "path";
import {
SPOTIFY_REDIRECT_URI,
SPOTIFY_SCOPES,
YOUTUBE_REDIRECT_URI,
YOUTUBE_SCOPES,
} from "./constants.js";
import { updateSpotifyPlaylists, failedTracks } from "./update-spotify-playlist.js";
import dotenv from "dotenv";
dotenv.config();
const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID;
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET;
const YOUTUBE_CLIENT_ID = process.env.YOUTUBE_CLIENT_ID;
const YOUTUBE_CLIENT_SECRET = process.env.YOUTUBE_CLIENT_SECRET;
let spotifyAccessToken = null;
// Express setup
const app = express();
app.use(bodyParser.json());
const youtubeOauth2Client = new google.auth.OAuth2(
YOUTUBE_CLIENT_ID,
YOUTUBE_CLIENT_SECRET,
YOUTUBE_REDIRECT_URI
);
// YouTube Authorization Routes
app.get("/youtube-login", async (req, res) => {
try {
const refreshTokenPath = path.resolve(".env");
const envContent = await fs
.readFile(refreshTokenPath, "utf-8")
.catch(() => "");
if (envContent.includes("YOUTUBE_REFRESH_TOKEN")) {
console.log(
"YouTube refresh token exists. No need to request a new token."
);
res.send(`
<h1>YouTube Authentication Already Set Up!</h1>
<p>Your refresh token already exists. You don't need to re-authenticate.</p>
<p><a href="/">Return to Home</a></p>
`);
} else {
const authUrl = youtubeOauth2Client.generateAuthUrl({
access_type: "offline",
scope: YOUTUBE_SCOPES,
prompt: "consent", // Force refresh token
});
res.redirect(authUrl);
}
} catch (error) {
console.error("Error during YouTube login setup:", error);
res.status(500).send(`
<h1>Error during YouTube Login</h1>
<p>An error occurred. Please try again.</p>
`);
}
});
app.get("/youtube-callback", async (req, res) => {
const code = req.query.code;
try {
const { tokens } = await youtubeOauth2Client.getToken(code);
youtubeOauth2Client.setCredentials(tokens);
if (tokens.refresh_token) {
const envPath = path.resolve(".env");
let envContent = await fs.readFile(envPath, "utf-8").catch(() => "");
envContent = envContent.replace(/^YOUTUBE_REFRESH_TOKEN=.*$/m, "");
envContent += `\nYOUTUBE_REFRESH_TOKEN=${tokens.refresh_token}\n`;
await fs.writeFile(envPath, envContent.trim());
console.log("YouTube refresh token saved successfully.");
}
res.send(`
<h1>YouTube Authentication Successful!</h1>
<p><a href="/">click here</a> to return to the home page.</p>
`);
} catch (error) {
console.error("Error during YouTube authentication:", error);
res.status(500).send(`
<h1>YouTube Authentication Failed</h1>
<p><a href="/">Return to Home</a></p>
`);
}
});
// Spotify Authorization Routes
app.get("/spotify-login", async (req, res) => {
try {
const refreshTokenPath = path.resolve(".env");
const envContent = await fs
.readFile(refreshTokenPath, "utf-8")
.catch(() => "");
if (envContent.includes("SPOTIFY_REFRESH_TOKEN")) {
console.log(
"Spotify refresh token exists. No need to request a new token."
);
res.send(`
<h1>Spotify Authentication Already Set Up!</h1>
<p>Your refresh token already exists. You don't need to re-authenticate.</p>
<p><a href="/">Return to Home</a></p>
`);
} else {
const authUrl = `https://accounts.spotify.com/authorize?client_id=${SPOTIFY_CLIENT_ID}&response_type=code&redirect_uri=${encodeURIComponent(
SPOTIFY_REDIRECT_URI
)}&scope=${encodeURIComponent(SPOTIFY_SCOPES.join(" "))}`;
res.redirect(authUrl);
}
} catch (error) {
console.error("Error during Spotify login setup:", error);
res.status(500).send(`
<h1>Error during Spotify Login</h1>
<p>An error occurred. Please try again.</p>
`);
}
});
app.get("/spotify-callback", async (req, res) => {
const code = req.query.code;
try {
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: SPOTIFY_REDIRECT_URI,
client_id: SPOTIFY_CLIENT_ID,
client_secret: SPOTIFY_CLIENT_SECRET,
});
const response = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!response.ok) {
throw new Error("Failed to exchange Spotify code for tokens");
}
const tokens = await response.json();
spotifyAccessToken = tokens.access_token;
if (tokens.refresh_token) {
const envPath = path.resolve(".env");
let envContent = await fs.readFile(envPath, "utf-8").catch(() => "");
envContent = envContent.replace(/^SPOTIFY_REFRESH_TOKEN=.*$/m, ""); // Remove existing token if present
envContent += `\nSPOTIFY_REFRESH_TOKEN=${tokens.refresh_token}\n`;
await fs.writeFile(envPath, envContent.trim());
console.log("Spotify refresh token saved successfully.");
}
res.send(`
<h1>Spotify Authentication Successful!</h1>
<p><a href="/">click here</a> to return to the home page.</p>
`);
} catch (error) {
console.error("Error during Spotify authentication:", error);
res.status(500).send(`
<h1>Spotify Authentication Failed</h1>
<p><a href="/">Return to Home</a></p>
`);
}
});
// Root route
app.get("/", (req, res) => {
res.send(
`<h1>Welcome</h1>
<p><a href="/youtube-login">Login to YouTube</a></p>
<p><a href="/spotify-login">Login to Spotify</a></p>
<p><a href="/sync-playlists">Manually Sync YouTube Playlists to Spotify</a></p>`
);
});
app.get("/sync-playlists", async (req, res) => {
try {
await updateSpotifyPlaylists(youtubeOauth2Client, spotifyAccessToken);
res.send("Spotify playlists updated successfully!");
if (failedTracks.length > 0) {
console.log("Failed Tracks:", failedTracks);
}
} catch (error) {
console.error("Error syncing playlists:", error);
res.status(500).send("Failed to sync playlists.");
}
});
// Start the server
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
open(`http://localhost:${PORT}`);
});This API route is the serverless worker you’ll deploy (e.g., Vercel/Netlify/Cloudflare). It refreshes the Spotify access token using the stored refresh token, reconstructs a YouTube OAuth client from its refresh token, and then calls the shared sync routine. Configure this route on a cron schedule in your platform.
import { updateSpotifyPlaylists } from "../update-spotify-playlist.js";
import { google } from "googleapis";
// Environment variables
const SPOTIFY_REFRESH_TOKEN = process.env.SPOTIFY_REFRESH_TOKEN;
const YOUTUBE_REFRESH_TOKEN = process.env.YOUTUBE_REFRESH_TOKEN;
// Serverless Function
export default async function handler(req, res) {
try {
// Refresh Spotify Access Token
const spotifyAccessToken = await refreshSpotifyToken(SPOTIFY_REFRESH_TOKEN);
// Set up YouTube OAuth client
const youtubeOauth2Client = new google.auth.OAuth2(
process.env.YOUTUBE_CLIENT_ID,
process.env.YOUTUBE_CLIENT_SECRET,
process.env.YOUTUBE_REDIRECT_URI
);
// Set YouTube credentials from refresh token
youtubeOauth2Client.setCredentials({
refresh_token: YOUTUBE_REFRESH_TOKEN,
});
// Sync Playlists
await updateSpotifyPlaylists(youtubeOauth2Client, spotifyAccessToken);
res.status(200).send("Automatic Playlist syncing successfully!");
} catch (error) {
console.error("Error syncing playlists:", error);
res.status(500).send("Error syncing playlists.");
}
}
async function refreshSpotifyToken(refreshToken) {
try {
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: process.env.SPOTIFY_CLIENT_ID,
client_secret: process.env.SPOTIFY_CLIENT_SECRET,
});
const response = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!response.ok) {
throw new Error("Failed to refresh Spotify token");
}
const data = await response.json();
return data.access_token;
} catch (error) {
console.error("Error refreshing Spotify token:", error);
throw error;
}
}This file queries your YouTube account for all playlists and filters those whose titles start with “music/”. It then fetches every track for each playlist, flattening the data into a convenient structure the sync routine can consume.
import { google } from "googleapis";
// Fetch playlists with their tracks
export async function fetchPlaylistsWithTracks(oauth2Client) {
const youtube = google.youtube({ version: "v3", auth: oauth2Client });
let musicPlaylists = [];
let nextPageToken = null;
try {
// Fetch all playlists
do {
const response = await youtube.playlists.list({
part: "snippet",
mine: true,
maxResults: 50,
pageToken: nextPageToken,
});
const filteredPlaylists = response.data.items.filter((playlist) =>
playlist.snippet.title.toLowerCase().startsWith("music/")
);
// Simplify playlist structure
musicPlaylists = musicPlaylists.concat(
filteredPlaylists.map((playlist) => ({
id: playlist.id,
title: playlist.snippet.title,
tracks: [],
}))
);
nextPageToken = response.data.nextPageToken;
} while (nextPageToken);
// Fetch tracks for each playlist
for (const playlist of musicPlaylists) {
const tracks = await fetchTracksForPlaylist(oauth2Client, playlist.id);
playlist.tracks = tracks; // Update tracks for the playlist
}
console.log("Playlists with Tracks Fetched:", musicPlaylists);
return musicPlaylists;
} catch (error) {
console.error("Error fetching playlists or tracks:", error);
return [];
}
}
// Fetch tracks for a specific playlist
async function fetchTracksForPlaylist(oauth2Client, playlistId) {
const youtube = google.youtube({ version: "v3", auth: oauth2Client });
let tracks = [];
let nextPageToken = null;
try {
do {
const response = await youtube.playlistItems.list({
part: "snippet",
playlistId: playlistId,
maxResults: 50,
pageToken: nextPageToken,
});
// Simplify track structure
tracks = tracks.concat(
response.data.items.map((item) => ({
title: item.snippet.title,
videoId: item.snippet.resourceId.videoId,
}))
);
nextPageToken = response.data.nextPageToken;
} while (nextPageToken);
return tracks;
} catch (error) {
console.error(`Error fetching tracks for playlist ${playlistId}:`, error);
return [];
}
}This module contains the core synchronization logic. For each YouTube playlist, it searches Spotify for the best matching track, collects their URIs, and creates or updates a same‑named Spotify playlist with those tracks. It also records any lookup failures so you can review and refine your matching later.
import fetch from "node-fetch";
import { fetchPlaylistsWithTracks } from "./get-youtube-playlist.js";
const failedTracks = [];
async function updateSpotifyPlaylists(oauth2Client, spotifyAccessToken) {
const playlists = await fetchPlaylistsWithTracks(oauth2Client);
for (const playlist of playlists) {
console.log(`Syncing playlist: ${playlist.title}`);
const trackUris = [];
for (const track of playlist.tracks) {
const trackId = await searchSpotifyTrack(track.title, spotifyAccessToken);
if (trackId) {
trackUris.push(`spotify:track:${trackId}`);
}
}
await createOrUpdateSpotifyPlaylist(
playlist.title,
trackUris,
spotifyAccessToken
);
}
}
async function searchSpotifyTrack(query, spotifyAccessToken) {
try {
const response = await fetch(
`https://api.spotify.com/v1/search?q=${encodeURIComponent(
query
)}&type=track&limit=1`,
{
headers: { Authorization: `Bearer ${spotifyAccessToken}` },
}
);
if (!response.ok) {
throw new Error(`Failed to search Spotify for query: ${query}`);
}
const data = await response.json();
const track = data.tracks.items[0];
return track ? track.id : null;
} catch (error) {
console.error("Error searching Spotify track:", error);
failedTracks.push({ query, error: error.message });
return null;
}
}
async function createOrUpdateSpotifyPlaylist(
title,
trackUris,
spotifyAccessToken
) {
try {
const userProfileResponse = await fetch("https://api.spotify.com/v1/me", {
headers: { Authorization: `Bearer ${spotifyAccessToken}` },
});
const userProfile = await userProfileResponse.json();
const playlistsResponse = await fetch(
"https://api.spotify.com/v1/me/playlists",
{
headers: { Authorization: `Bearer ${spotifyAccessToken}` },
}
);
const playlists = await playlistsResponse.json();
const existingPlaylist = playlists.items.find((p) => p.name === title);
let playlistId;
if (existingPlaylist) {
playlistId = existingPlaylist.id;
console.log(`Updating playlist: ${title}`);
} else {
const createResponse = await fetch(
`https://api.spotify.com/v1/users/${userProfile.id}/playlists`,
{
method: "POST",
headers: {
Authorization: `Bearer ${spotifyAccessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: title, public: false }),
}
);
const newPlaylist = await createResponse.json();
playlistId = newPlaylist.id;
console.log(`Created playlist: ${title}`);
}
if (trackUris.length > 0) {
await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/tracks`, {
method: "PUT",
headers: {
Authorization: `Bearer ${spotifyAccessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ uris: trackUris }),
});
console.log(`Playlist "${title}" updated.`);
}
console.log('Creating playlists and adding tracks is done.')
} catch (error) {
console.error(`Error updating playlist "${title}":`, error);
}
}
export { updateSpotifyPlaylists, failedTracks };
