Logo Manvith Reddy Dayam
Transfer playlists from Spotify to Youtube

Transfer playlists from Spotify to Youtube

June 30, 2024
Gthub

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.

index.js
 
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.

api/sync.js
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.

get-youtube-playlist.js
 
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.

update-spotify-playlist.js
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 };