Created
September 6, 2024 19:34
-
-
Save glektarssza/b1119e689df4135918a217061f6ea20e to your computer and use it in GitHub Desktop.
A little script to sync two YouTube Music playlists.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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