Skip to content

Instantly share code, notes, and snippets.

@glektarssza
Created September 6, 2024 19:34
Show Gist options
  • Save glektarssza/b1119e689df4135918a217061f6ea20e to your computer and use it in GitHub Desktop.
Save glektarssza/b1119e689df4135918a217061f6ea20e to your computer and use it in GitHub Desktop.
A little script to sync two YouTube Music playlists.
const { createInterface } = require('node:readline/promises');
const { request } = require('node:https');
/**
* The user agent to use when making requests to the YouTube API.
*/
const USER_AGENT = process.env['YOUTUBE_USER_AGENT'] ?? 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36';
/**
* The endpoint to call to perform operations on playlist items.
*/
const PLAYLIST_ITEMS_ENDPOINT = 'https://www.googleapis.com/youtube/v3/playlistItems';
/**
* My OAuth 2.0 access token.
*/
const OAUTH_TOKEN = process.env['YOUTUBE_OAUTH_TOKEN'];
/**
* The ID of the playlist to pull items from.
*/
const SOURCE_PLAYLIST_ID = process.env['YOUTUBE_SOURCE_PLAYLIST_ID'] ?? 'LM';
/**
* The ID of the playlist to push items to.
*/
const DEST_PLAYLIST_ID = process.env['YOUTUBE_DEST_PLAYLIST_ID'];
/**
* A YouTube playlist item.
*
* @typedef {Object} PlaylistItem
*
* @property {'youtube#playlistItem'} kind The type of the playlist item.
* @property {string} id The ID of the playlist item.
*/
/**
* A page of items from a playlist.
*
* @typedef {Object} PlaylistItemsPage
* @property {string} nextPageToken The token to retrieve the next page of
* items.
* @property {PlaylistItem[]} items The items in the playlist.
*/
/**
* Get a page of items from a playlist.
*
* @param {string} playlistId The ID of the playlist to get items from.
* @param {number | undefined} totalToRetrieve The number of items to retrieve
* in total or `undefined` to retrieve all items.
* @param {number} retrieved The number of items already retrieved.
* @param {string | undefined} nextPageToken The token to retrieve the next page
* of items or `undefined` to retrieve the first page.
*
* @returns {Promise<PlaylistItemsPage>} The next page of items in the playlist.
*/
async function getPlaylistItemsPage(playlistId, totalToRetrieve, retrieved, nextPageToken) {
const toRetrieve = totalToRetrieve === undefined ? 50 : Math.min(totalToRetrieve - retrieved, 50);
const url = `${PLAYLIST_ITEMS_ENDPOINT}?part=id,snippet&playlistId=${playlistId}&maxResults=${toRetrieve}${nextPageToken ? `&pageToken=${nextPageToken}` : ''}`;
const req = request(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${OAUTH_TOKEN}`,
'User-Agent': USER_AGENT
}
});
return new Promise((resolve, reject) => {
req.on('error', reject);
req.on('response', (res) => {
if (res.statusCode < 200 || res.statusCode >= 300) {
res.destroy(new Error(`Failed to retrieve playlist items (${res.statusCode} - ${res.statusMessage})`));
return;
}
let data = '';
res.on('data', (chunk) => {
data += Buffer.from(chunk).toString('utf-8');
});
res.on('end', () => {
resolve(JSON.parse(data));
});
});
req.end();
});
}
/**
* List the items in a playlist.
*
* @param {string} playlistId The ID of the playlist to list items from.
* @param {number | undefined} count The number of items to list. If not
* provided, all items will be listed.
*
* @returns {Promise<PlaylistItem[]>} The items in the playlist.
*/
async function listPlaylistItems(playlistId, count) {
const items = [];
let nextPageToken;
let retrieved = 0;
do {
const page = await getPlaylistItemsPage(playlistId, count, retrieved, nextPageToken);
items.push(...page.items);
retrieved += page.items.length;
nextPageToken = page.nextPageToken;
} while (nextPageToken && (count === undefined || retrieved < count));
return items;
}
/**
* Delete a playlist item.
*
* @param {string} itemId The ID of the item to delete.
*
* @returns {Promise<void>} A promise that resolves when the item has been
* deleted.
*/
async function deletePlaylistItem(itemId) {
const url = `${PLAYLIST_ITEMS_ENDPOINT}?id=${itemId}`;
const req = request(url, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${OAUTH_TOKEN}`,
'User-Agent': USER_AGENT
}
});
return new Promise((resolve, reject) => {
req.on('error', reject);
req.on('response', (res) => {
if (res.statusCode < 200 || res.statusCode >= 300) {
res.destroy(new Error(`Failed to delete playlist item (${res.statusCode} - ${res.statusMessage})`));
return;
}
resolve();
});
req.end();
});
}
/**
* Insert an item into a playlist.
*
* @param {string} playlistId The ID of the playlist to insert the item into.
* @param {string} videoId The ID of the video to insert into the playlist.
*
* @returns {Promise<void>} A promise that resolves when the item has been
* inserted.
*/
async function insertPlaylistItem(playlistId, videoId, position) {
const url = `${PLAYLIST_ITEMS_ENDPOINT}?part=snippet`;
const dataObj = {
snippet: {
playlistId,
resourceId: {
kind: 'youtube#video',
videoId
}
}
};
if (position !== undefined) {
dataObj.snippet.position = position;
}
const data = JSON.stringify(dataObj);
const req = request(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${OAUTH_TOKEN}`,
'User-Agent': USER_AGENT,
'Content-Type': 'application/json',
'Content-Length': data.length
}
});
return new Promise((resolve, reject) => {
req.on('error', reject);
req.on('response', (res) => {
if (res.statusCode < 200 || res.statusCode >= 300) {
res.destroy(new Error(`Failed to insert playlist item (${res.statusCode} - ${res.statusMessage})`));
return;
}
resolve();
});
req.end(data);
});
}
/**
* The main function.
*
* @returns {Promise<void>} A promise that resolves when the function has
* completed.
*/
async function main() {
const rl = createInterface({
input: process.stdin,
output: process.stdout
});
const sourceItems = await listPlaylistItems(SOURCE_PLAYLIST_ID);
console.info(`Retrieved ${sourceItems.length} items from the source playlist.`);
const destItems = await listPlaylistItems(DEST_PLAYLIST_ID);
console.info(`Retrieved ${destItems.length} items from the destination playlist.`);
console.info('Figuring out what items to delete...');
const itemsInDestButNotInSource = destItems.filter((destItem) => {
return !sourceItems.some((sourceItem) => {
return sourceItem.snippet.resourceId.videoId === destItem.snippet.resourceId.videoId;
});
});
if (itemsInDestButNotInSource.length !== 0) {
console.info(`Found ${itemsInDestButNotInSource.length} items in destination playlist that are not in the source playlist`);
const resp = await rl.question('Okay to delete these items? [y/N] ');
if (resp.trim().toLowerCase() === 'n' || resp.trim() === '') {
console.info('Aborting');
process.exit(0);
}
console.info('Deleting items...');
for (const item of itemsInDestButNotInSource) {
await deletePlaylistItem(item.id);
}
}
const itemsMissingFromDest = sourceItems.map((item, i) => ({ item, position: i })).filter(({ item }) => {
return !destItems.some((destItem) => {
return item.snippet.resourceId.videoId === destItem.snippet.resourceId.videoId;
});
});
if (itemsMissingFromDest.length !== 0) {
console.info(`Found ${itemsMissingFromDest.length} items in source playlist that are not in the desintation playlist`);
const resp = await rl.question('Okay to add these items? [y/N] ');
if (resp.trim().toLowerCase() === 'n' || resp.trim() === '') {
console.info('Aborting');
process.exit(0);
}
console.info('Adding items...');
for (const { item, position } of itemsMissingFromDest) {
try {
await insertPlaylistItem(DEST_PLAYLIST_ID, item.snippet.resourceId.videoId, position < destItems.length ? position : undefined);
} catch (err) {
if (err.message.includes('404 - Not Found')) {
console.warn(`Failed to add item "${item.snippet.title}" by "${item.snippet.channelTitle}" to playlist`);
console.debug(err);
} else {
throw err;
}
}
}
}
}
main().then(() => {
console.info('\x1B[32mSUCCESS\x1B[0m');
process.exit(0);
}).catch((err) => {
console.error('\x1B[91mFATAL ERROR\x1B[0m');
console.error(err);
process.exit(1);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment