// ==UserScript==
// @name YouTube / Spotify Playlists Converter
// @version 2.3
// @description Convert your music playlists between YouTube & Spotify with a single click.
// @author bobsaget1990
// @match https://www.youtube.com/*
// @match https://music.youtube.com/*
// @match https://open.spotify.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM.xmlHttpRequest
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @connect spotify.com
// @connect youtube.com
// @connect accounts.google.com
// @icon64 https://i.imgur.com/zjGIQn4.png
// @compatible chrome
// @compatible edge
// @compatible firefox
// @license GNU GPLv3
// @namespace https://gf.qytechs.cn/users/1254768
// ==/UserScript==
(async () => {
// UI FUNCTIONS:
function createUI(operations) {
// Remove existing UI
const oldUI = document.querySelector('div.floating-div');
if (!!oldUI) oldUI.remove();
const floatingDiv = document.createElement('div');
floatingDiv.classList.add('floating-div');
const centerDiv = document.createElement('div');
centerDiv.classList.add('center-div');
const cancelButton = document.createElement('button');
cancelButton.classList.add('cancel-button');
cancelButton.textContent = 'Cancel';
function reload() {
location.reload();
}
cancelButton.onclick = reload;
const closeButton = document.createElement('button');
closeButton.classList.add('close-button');
closeButton.innerHTML = '×'; // Unicode character for the close symbol
closeButton.onclick = reload;
// Add UI to the page
document.body.appendChild(floatingDiv);
floatingDiv.appendChild(centerDiv);
floatingDiv.appendChild(cancelButton);
floatingDiv.appendChild(closeButton);
floatingDiv.style.display = 'flex';
// Add opertations
let spans = generateSpans(operations);
centerDiv.append(...spans);
// CSS
const css = `
.floating-div {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
width: 400px;
height: auto;
display: none;
flex-direction: column;
justify-content: space-between;
align-items: center;
border-radius: 10px;
box-shadow: 0 0 0 1px #3a3a3a;
background-color: #0f0f0f;
line-height: 50px;
}
.center-div span {
display: block;
height: 30px;
margin: 10px;
font-family: 'Roboto', sans-serif;
font-size: 14px;
color: white;
opacity: 0.3;
}
.cancel-button {
width: auto;
height: 30px;
padding-left: 25px;
padding-right: 25px;
margin-top: 20px;
margin-bottom: 20px;
background-color: white;
color: #0f0f0f;
border-radius: 50px;
border: unset;
font-family: 'Roboto', sans-serif;
font-size: 16px;
}
.cancel-button:hover {
box-shadow: inset 0px 0px 0 2000px rgba(0,0,0,0.25);
}
.cancel-button:active {
box-shadow: inset 0px 0px 0 2000px rgba(0,0,0,0.5);
}
.close-button {
position: absolute;
top: 10px;
right: 10px;
width: 25px;
height: 25px;
border-radius: 50%;
background-color: #393939;
color: #7e7e7e;
border: unset;
font-family: math;
font-size: 17px;
text-align: center;
}
.close-button:hover {
box-shadow: inset 0px 0px 0 2000px rgba(255,255,255,0.05);
}
.close-button:active {
box-shadow: inset 0px 0px 0 2000px rgba(255,255,255,0.1);
}
`;
// Add the CSS to the page
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
return {
floatingDiv: floatingDiv,
centerDiv: centerDiv,
cancelButton: cancelButton,
closeButton: closeButton
};
}
function generateSpans(spanTextContent) {
let spans = [];
for (let i = 0; i < spanTextContent.length; i++) {
let span = document.createElement("span");
span.textContent = spanTextContent[i];
span.classList.add(`op-${i + 1}`);
spans.push(span);
}
return spans;
}
function closeConfirmation(event) {
event.preventDefault();
event.returnValue = null;
return null;
}
function CustomError(obj) {
this.response = obj.response;
this.message = obj.message;
this.url = obj.url;
this.code = obj.code;
}
CustomError.prototype = Error.prototype;
function errorHandler(error) {
const errorCode = error.code;
if (isYouTube || isYouTubeMusic) {
if (errorCode == 10) alert('⛔ Could not get Spotify token: Make sure you are signed in to Spotify and try again..');
if (errorCode == 11) alert('⛔ Could not get Spotify User ID: Make sure you are signed in to Spotify and try again..');
if (errorCode == 2 || errorCode == 3) alert('⛔ Could not get YouTube playlist info..');
if (errorCode == 8) alert('⛔ Could not create Spotify playlist..');
if (errorCode == 9) alert('⛔ Could not add songs to Spotify playlist..');
}
if (isSpotify) {
if (errorCode == 0) window.location.href = 'https://www.youtube.com/#go_back_fragment';
if (errorCode == 1) alert('⛔ Could not get playlist info: The playlist is empty!');
if (errorCode == 10) alert('⛔ Could not get Spotify token: Make sure you are signed in to Spotify and try again..');
if (errorCode == 11) alert('⛔ Could not get Spotify User ID: Make sure you are signed in to Spotify and try again..');
if (errorCode == 7) alert('⛔ Could not get Spotify playlist info..');
if (errorCode == 5) alert('⛔ Could not get YouTube User ID: Make sure you are signed in to YouTube and try again..');
if (errorCode == 6) alert('⛔ Could not create YouTube playlist..');
}
}
// GLOBALS:
let address = window.location.href;
const subdomain = address.slice(8).split('.')[0];
let isYouTube, isYouTubeMusic, isSpotify, isYouTubePlaylist, isSpotifyPlaylist;
const ytPlaylistIdRegEx = /list=(.{34})/;
const spotifyPlaylistRegEx = /playlist\/(.{22})/;
function addressChecker(address) {
if (address.includes('www.youtube.com')) isYouTube = true;
if (address.includes('music.youtube.com')) isYouTubeMusic = true;
if (address.includes('open.spotify.com')) isSpotify = true;
if (isYouTube || isYouTubeMusic) {
isYouTubePlaylist = ytPlaylistIdRegEx.test(address) ? true : false;
}
if (isSpotify) {
isSpotifyPlaylist = spotifyPlaylistRegEx.test(address) ? true : false;
}
}
addressChecker(address);
function stringCleanup(inputString) {
try {
// Remove weird ・ symbol YouTube sometimes adds to song title
inputString = inputString.replace(/・.+?(?=$|-)/,' ');
if(inputString.length > 3) {
// Remove diacritics (accent marks) https://stackoverflow.com/questions/990904/remove-accents-diacritics-in-a-string-in-javascript
inputString = inputString.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
inputString = inputString.toLowerCase();
// Remove words between brackets (including brackets)
inputString = inputString.replace(/\[[^\]]+\]/g, "");
// Remove unwanted characters, https://stackoverflow.com/a/6067606
inputString = inputString.replace(/[^\p{L}0-9\s&]+/ug, "");
}
// Remove extra spaces
inputString = inputString.replace(/ {2,}/g, " ");
// Remove unwanted phrases
const officialVideoTranslations = [
'official video', // English
'video oficial', // Spanish
'vidéo officielle', // French
'offizielles Video', // German
'video ufficiale', // Italian
'vídeo oficial', // Portuguese
'officiële video', // Dutch
'officiel video', // Danish
'oficiální video', // Czech
'hivatalós videó', // Hungarian
'וידאו רשמי', // Hebrew
'официальное видео', // Russian
'官方视频', // Chinese (Simplified)
'公式ビデオ', // Japanese
'فيديو رسمي', // Arabic
'비디오 공식', // Korean
'วิดีโออย่างเป็นทางการ', // Thai
'video resmi', // Turkish
'відео офіційне', // Ukrainian
'आधिकारिक वीडियो', // Hindi
'అధికారిక వీడియో', // Telugu
'அதிகார வீடியோ', // Tamil
'ಅಧಿಕೃತ ವೀಡಿಯೊ', // Kannada
'അധികൃത വീഡിയോ', // Malayalam
'અધિકૃત વિડિઓ', // Gujarati
'ਆਧਿਕਾਰਿਕ ਵੀਡੀਓ', // Punjabi
'আধিকারিক ভিডিও', // Bengali
'ଆଧିକାରିକ ଭିଡିଓ', // Odia
'अधिकृत व्हिडिओ', // Marathi
];
for (const translation of officialVideoTranslations) {
inputString = inputString.replace(translation, '');
}
inputString = inputString.trim();
return inputString;
} catch (error) {
console.error(error);
}
}
const userAgent = navigator.userAgent + ",gzip(gfe)";
const ytClient = {
"userAgent": userAgent,
"clientName": "WEB",
"clientVersion": "2.20240123.06.00",
};
const ytmClient = {
"userAgent": userAgent,
"clientName": "WEB_REMIX",
"clientVersion": "1.20240205.00.00"
};
const APIs = {
// YouTube:
ytUserId: 'https://www.youtube.com/account',
ytGetPlaylist: `https://${subdomain}.youtube.com/youtubei/v1/browse`,
ytGetMusicTrackInfo: 'https://music.youtube.com/youtubei/v1/search?key=&prettyPrint=false',
ytCreatePlaylist: 'https://www.youtube.com/youtubei/v1/playlist/create?key=&prettyPrint=false',
// Spotify:
spotifyUserId: 'https://api.spotify.com/v1/me',
spotifyToken: 'https://open.spotify.com/',
spotifySearch: 'https://api.spotify.com/v1/search',
spotifySearchProprietary: 'https://api-partner.spotify.com/pathfinder/v1/query',
spotifyGetTrackInfo: 'https://api.spotify.com/v1/tracks',
spotifyPlaylistContent: 'https://api.spotify.com/v1/playlists/playlistId/tracks',
spotifyCreatePlaylist: 'https://api.spotify.com/v1/users/userId/playlists',
spotifyAddPlaylist: 'https://api.spotify.com/v1/playlists/playlistId/tracks'
};
let SPOTIFY_AUTH_TOKEN, SPOTIFY_USER_ID;
let YT_SAPISIDHASH = await GM_getValue('YT_SAPISIDHASH');
let YTM_SAPISIDHASH = await GM_getValue('YTM_SAPISIDHASH');
if (isYouTube || isYouTubeMusic) {
try {
YT_SAPISIDHASH = await getSApiSidHash('https://www.youtube.com');
GM_setValue('YT_SAPISIDHASH', YT_SAPISIDHASH);
YTM_SAPISIDHASH = await getSApiSidHash('https://music.youtube.com');
GM_setValue('YTM_SAPISIDHASH', YTM_SAPISIDHASH);
console.log(`YT_SAPISIDHASH:\n${YT_SAPISIDHASH}\n\nYTM_SAPISIDHASH:\n${YTM_SAPISIDHASH}`);
} catch (error) {
console.error(error);
}
}
let goBackFragment = '#go_back_fragment';
let autoRunFragment = '#sty_autorun';
if (address.includes(goBackFragment)) window.location.href = await GM_getValue('backUrl');
// MENU SETUP:
let menuTitles = {
YtS: '🔄 YouTube to Spotify 🔄',
StY: '🔄 Spotify to YouTube 🔄'
};
let YtS_ID, StY_ID;
const callback = () => {
addressChecker(window.location.href);
if (isYouTubePlaylist) {
YtS_ID = GM_registerMenuCommand(menuTitles.YtS, YtS);
} else {
GM_unregisterMenuCommand(YtS_ID);
}
if (isSpotifyPlaylist) {
StY_ID = GM_registerMenuCommand(menuTitles.StY, StY);
} else {
GM_unregisterMenuCommand(StY_ID);
}
};
callback();
// Register/unregister menu functions on address change, and auto run logic
let autoRunConditions;
const observer = new MutationObserver(() => {
if (isSpotify){
autoRunConditions = document.querySelector('[data-testid="entityTitle"]') && GM_getValue('backUrl');
if (autoRunConditions) {
GM_setValue('backUrl', undefined); // Clear backUrl
StY();
}
}
if (location.href !== address) {
address = location.href;
callback();
}
});
observer.observe(document, {
subtree: true,
childList: true
});
function checkCache(obj) {
const CACHED_TRACKS = GM_getValue('CACHED_TRACKS', []);
const CACHED_NOT_FOUND = GM_getValue('CACHED_NOT_FOUND', []);
const CACHED_INDEX = CACHED_TRACKS.length + CACHED_NOT_FOUND.length;
const CACHE_ID = GM_getValue('CACHE_ID', {});
// Compare two arrays: https://stackoverflow.com/a/42186143
const cacheConditions = CACHED_INDEX > 3 &&
CACHE_ID.PLAYLIST_ID === obj.playlistId &&
CACHE_ID.PLAYLIST_CONTENT == '' + obj.playlistContent;
if (cacheConditions) {
return {
tracks: CACHED_TRACKS,
index: CACHED_INDEX,
clear: function() {
GM_setValue('CACHED_TRACKS', []);
GM_setValue('CACHED_NOT_FOUND', []);
}};
}
// Set cache for current conversion, if no matching cache is detected
GM_setValue('CACHE_ID', {
PLAYLIST_ID: obj.playlistId,
PLAYLIST_CONTENT: obj.playlistContent
});
return null;
}
let UI, ytUserId, operations;
// YouTube to Spotify
async function YtS() {
try {
// Get the title of the YouTube playlist
let yt_playlistTitle = await getYtPlaylistTitle();
console.log('YouTube Playlist Title:', yt_playlistTitle);
// User confirmation
if (confirm(`Convert "${yt_playlistTitle}" to Spotify?`)) {
// Add close tab confirmation
window.addEventListener("beforeunload", closeConfirmation);
// Unregister the menu command
GM_unregisterMenuCommand(YtS_ID);
// Set the operations
operations = [
`Getting Spotify tokens`,
`Getting YouTube playlist songs`,
`Converting songs to Spotify`,
`Adding playlist to Spotify`
];
// Create the UI
UI = createUI(operations);
// OP-1
UI.centerDiv.querySelector('.op-1').style.opacity = 1;
// Spotify tokens
const spotifyTokens = await getSpotifyTokens();
SPOTIFY_USER_ID = spotifyTokens.usernameId;
SPOTIFY_AUTH_TOKEN = spotifyTokens.accessToken;
UI.centerDiv.querySelector('.op-1').textContent += ' ✅';
// OP-2
UI.centerDiv.querySelector('.op-2').style.opacity = 1;
// YouTube Playlist ID
const yt_playlistId = address.match(ytPlaylistIdRegEx)[1];
console.log('YouTube Playlist ID:', yt_playlistId);
// YouTube User ID (Needed for multiple accounts)
ytUserId = await getYtUserId();
console.log('YouTube User ID:', ytUserId);
// Playlist video titles
let yt_playlistContent = await getYtPlaylistContent(yt_playlistId);
const yt_totalTracks = yt_playlistContent.length;
console.log('YouTube Playlist Content:', yt_playlistContent);
UI.centerDiv.querySelector('.op-2').textContent += ` ✅`;
// OP-3
UI.centerDiv.querySelector('.op-3').style.opacity = 1;
let spotify_trackIds = [];
let index = 0;
// Cache setup
const cache = checkCache({
playlistId: yt_playlistId,
playlistContent: yt_playlistContent
});
if (cache !== null) {
if(confirm(`💾 Saved songs detected, continue from ${cache.index + 1}?`)) {
spotify_trackIds = cache.tracks;
index = cache.index;
yt_playlistContent = yt_playlistContent.slice(index);
} else {
// Clear cache if user clicks 'Cancel'
cache.clear();
}
}
const spotify_NotFound = [];
for (let entry of yt_playlistContent.entries()) {
UI.centerDiv.querySelector('.op-3').textContent = `${operations[2]} (${index + 1}/${yt_totalTracks})`;
entry = entry[1];
// YouTube Music songs only search
let ytm_songsSearch = await searchYtMusic({query: entry, songsOnly: true});
console.log('YouTube Music Song Search Result:', ytm_songsSearch);
// Try YouTube Music video only search if songs only fails
let ytm_videoSearch;
if (ytm_songsSearch == null) {
ytm_videoSearch = await searchYtMusic({query: entry, songsOnly: false});
console.log('YouTube Music All Search Result:', ytm_videoSearch);
}
let spotify_trackId;
if (ytm_songsSearch || ytm_videoSearch) {
const spotifyQuery = ytm_songsSearch || ytm_videoSearch;
console.log('Spotify Query:', spotifyQuery);
spotify_trackId = await searchSpotify({query: spotifyQuery, proprietary: false});
// Try proprietary search if regular Spotify search fails
if (spotify_trackId == null) {
spotify_trackId = await searchSpotify({query: spotifyQuery, proprietary: true});
}
} else {
let spotifyQuery = entry.replace(/-[^-]*$/, ''); // Remove YouTube channel name
spotifyQuery = stringCleanup(spotifyQuery);
console.log('Spotify Query:', spotifyQuery);
spotify_trackId = await searchSpotify({query: spotifyQuery, proprietary: true});
}
if (spotify_trackId) {
spotify_trackIds.push(spotify_trackId);
console.log('✅ Spotify Track ID:', spotify_trackId);
GM_setValue('CACHED_TRACKS', spotify_trackIds);
} else {
spotify_NotFound.push(entry);
console.log('⚠️ NOT FOUND ON SPOTIFY:', entry);
GM_setValue('CACHED_NOT_FOUND', spotify_NotFound);
}
index++;
}
console.log('Spotify Tracks Found:', spotify_trackIds);
if (spotify_NotFound.length) console.log('⚠️ NOT FOUND ON SPOTIFY:', spotify_NotFound);
UI.centerDiv.querySelector('.op-3').textContent += ' ✅';
// OP-4
UI.centerDiv.querySelector('.op-4').style.opacity = 1;
// Create the Spotify playlist
const spotify_playlistId = await createSpotifyPlaylist(yt_playlistTitle);
console.log('Spotify Playlist ID Created:', spotify_playlistId);
// Add the tracks to the Spotify playlist
await addToSpotifyPlaylist(spotify_playlistId, spotify_trackIds);
UI.centerDiv.querySelector('.op-4').textContent += ' ✅';
// Update cancel button
UI.cancelButton.onclick = () => {
window.open(`https://open.spotify.com/playlist/${spotify_playlistId.replace('spotify:playlist:','')}`);
};
UI.closeButton.onclick = () => {
UI.floatingDiv.remove();
};
UI.cancelButton.style.backgroundColor = '#1ed55f';
UI.cancelButton.textContent = 'Open in Spotify!';
// Re-register the menu command
YtS_ID = GM_registerMenuCommand(menuTitles.YtS, YtS);
// Remove close tab confirmation
window.removeEventListener("beforeunload", closeConfirmation);
// Clear cache
cache.clear();
}
} catch (error) {
console.log('🔄🔄🔄', error);
errorHandler(error);
}
}
// Spotify to YouTube
async function StY() {
try {
// SAPISIDHASH check
if (YT_SAPISIDHASH == undefined) {
alert(`To collect a token this page will be redirected to YouTube then back here after you click 'Ok' (this will only be needed once), please make sure you are signed in to YouTube for this to work..`);
GM_setValue('backUrl', address + autoRunFragment); // Add autorun fragment
throw new CustomError({response: 'N/A', message:'SAPISIDHASH not found', url:'', code:0});
}
// Spotify Playlist ID
let spotify_playlistTitle = document.querySelector('[data-testid="entityTitle"]').innerText;
console.log('Spotify Playlist Title:', spotify_playlistTitle);
// User confirmation
if (confirm(`Convert playlist "${spotify_playlistTitle}" to YouTube?`)) {
// Add close tab confirmation
window.addEventListener("beforeunload", closeConfirmation);
// Unregister the menu command
GM_unregisterMenuCommand(StY_ID);
// Set the operations
operations = [
`Getting Spotify tokens`,
`Getting Spotify playlist info`,
`Converting songs to YouTube`,
`Adding playlist to YouTube`
];
// Create the UI
UI = createUI(operations);
// OP-1
UI.centerDiv.querySelector('.op-1').style.opacity = 1;
// Spotify tokens
const spotifyTokens = await getSpotifyTokens();
SPOTIFY_USER_ID = spotifyTokens.usernameId;
SPOTIFY_AUTH_TOKEN = spotifyTokens.accessToken;
UI.centerDiv.querySelector('.op-1').textContent += ' ✅';
// OP-2
UI.centerDiv.querySelector('.op-2').style.opacity = 1;
const spotify_playlistId = address.match(spotifyPlaylistRegEx)[1];
console.log('Spotify Playlist ID:', spotify_playlistId);
// Get the Spotify playlist content
let spotify_playlistContent = await getSpotifyPlaylistContent(spotify_playlistId);
const spotify_totalTracks = spotify_playlistContent.length;
if (spotify_totalTracks == 0) throw new CustomError({response: 'N/A', message:'Empty playlist', url:'', code:1});
console.log('Spotify Playlist Content:', spotify_playlistContent);
UI.centerDiv.querySelector('.op-2').textContent += ` (${spotify_totalTracks}/${spotify_totalTracks}) ✅`;
// OP-3
UI.centerDiv.querySelector('.op-3').style.opacity = 1;
let yt_trackIds = [];
let index = 0;
// Cache setup
const cache = checkCache({
playlistId: spotify_playlistId,
playlistContent: spotify_playlistContent
});
if (cache !== null) {
if(confirm(`💾 Saved songs detected, continue from ${cache.index + 1}?`)) {
yt_trackIds = cache.tracks;
index = cache.index;
spotify_playlistContent = spotify_playlistContent.slice(index);
} else {
// Clear cache if user clicks 'Cancel'
cache.clear();
}
}
const yt_NotFound = [];
for (let entry of spotify_playlistContent.entries()) {
UI.centerDiv.querySelector('.op-3').textContent = `${operations[2]} (${index + 1}/${spotify_totalTracks})`;
entry = entry[1];
const spotify_trackInfo = await getSpotifyTrackInfo(entry);
console.log('Spotify Track Info:', spotify_trackInfo);
const ytmQuery = spotify_trackInfo.title + ' ' + spotify_trackInfo.artist;
console.log('YouTube Music Query:', ytmQuery);
let ytm_songsSearch = await searchYtMusic({query: ytmQuery, songsOnly:true});
let ytm_trackId;
// If YouTube Music songs only result is found
if (ytm_songsSearch) {
ytm_trackId = ytm_songsSearch.ytmId;
} else {
// Try video only search if songs only search fails
const ytm_videoSearch = await searchYtMusic({query: ytmQuery, songsOnly:false});
if (ytm_videoSearch) ytm_trackId = ytm_videoSearch.ytmId;
}
if (ytm_trackId) {
console.log('✅ YouTube Music Track ID:', ytm_trackId);
yt_trackIds.push(ytm_trackId);
GM_setValue('CACHED_TRACKS', yt_trackIds);
} else {
console.log('⚠️ NOT FOUND ON YOUTUBE:', entry);
yt_NotFound.push(entry);
GM_setValue('CACHED_NOT_FOUND', yt_NotFound);
}
index++;
}
UI.centerDiv.querySelector('.op-3').textContent += ' ✅';
console.log('YouTube Tracks Found:', yt_trackIds);
if (yt_NotFound.length) console.log('⚠️ NOT FOUND ON YOUTUBE:', yt_NotFound);
// OP-4
UI.centerDiv.querySelector('.op-4').style.opacity = 1;
// YouTube User ID (Needed for multiple accounts)
ytUserId = await getYtUserId();
console.log('YouTube User ID:', ytUserId);
// Create the YouTube playlist
const yt_playlistId = await createYtPlaylist(spotify_playlistTitle, yt_trackIds);
console.log('YouTube Playlist ID:', yt_playlistId);
UI.centerDiv.querySelector('.op-4').textContent += ' ✅';
// Update cancel button
UI.cancelButton.onclick = () => {
window.open(`https://www.youtube.com/playlist?list=${yt_playlistId}`);
};
UI.closeButton.onclick = () => {
UI.floatingDiv.remove();
};
UI.cancelButton.style.backgroundColor = '#ff0000';
UI.cancelButton.style.color = '#ffffff';
UI.cancelButton.textContent = 'Open in YouTube!';
// Re-register the menu command
StY_ID = GM_registerMenuCommand(menuTitles.StY, StY);
// Remove close tab confirmation
window.removeEventListener("beforeunload", closeConfirmation);
// Clear cache
cache.clear();
}
} catch (error) {
console.log('🔄🔄🔄', error);
errorHandler(error);
}
}
// YOUTUBE FUNCTIONS:
async function getYtPlaylistTitle() {
// Static playlist page
let playlistTitle = document.querySelector('.metadata-wrapper yt-formatted-string') || document.querySelector('#header .title');
// Playing playlist page
if (address.includes('watch?v=')) playlistTitle = document.querySelector('#header-description a[href*="playlist?list="]') || document.querySelector('#tab-renderer .subtitle');
playlistTitle = playlistTitle.innerText;
return playlistTitle;
}
async function getYtPlaylistContent(playlistId) {
let authorization;
if (isYouTube) authorization = `SAPISIDHASH ${YT_SAPISIDHASH}`;
if (isYouTubeMusic) authorization = `SAPISIDHASH ${YTM_SAPISIDHASH}`;
const requestUrl = APIs.ytGetPlaylist;
const headers = {
"accept": "*/*",
"authorization": authorization,
"x-goog-authuser": ytUserId,
};
const context = {
"client": ytmClient
};
let index = 1;
let trackNames = [];
playlistId = 'VL' + playlistId;
let continuation, matches;
const trackNamesRegEx = /(?:"label":"Play\s)(.+?)(?:\s-\s\d+\s(?:minutes?|hours?))/g;
const continuationRegEx = /(?<="continuation":").+?(?=")/g;
const response = await fetch(`${requestUrl}?key=&prettyPrint=false`, {
method: "POST",
headers: headers,
body: JSON.stringify({
"context": context,
"browseId": playlistId
})
});
if (response.status == 200) {
const responseText = await response.text();
matches = responseText.matchAll(trackNamesRegEx);
continuation = responseText.match(continuationRegEx);
if (continuation.length == 1) {
continuation = false;
} else {
continuation = continuation[0];
}
for (let title of matches) {
document.querySelector('.op-2').textContent = `${operations[1]} (${index})`;
trackNames.push(title[1]);
index++;
}
} else {
throw new CustomError({response: response, message:'', url:response.finalUrl, code:2});
}
while (continuation) {
const continuationResponse = await fetch(`${requestUrl}?ctoken=${continuation}&continuation=${continuation}&type=next&prettyPrint=false`, {
method: "POST",
headers: headers,
body: JSON.stringify({
"context": context
})
});
if (continuationResponse.status == 200) {
const continuationResponseText = await continuationResponse.text();
if (continuationResponseText.includes('musicPlaylistShelfContinuation')) matches = continuationResponseText.matchAll(trackNamesRegEx);
continuation = continuationResponseText.match(continuationRegEx);
for (let title of matches) {
document.querySelector('.op-2').textContent = `${operations[1]} (${index})`;
trackNames.push(title[1]);
index++;
}
} else {
throw new CustomError({response: continuationResponse, message:'', url:continuationResponse.finalUrl, code:3});
}
}
return trackNames;
}
async function searchYtMusic(query) {
let queryText = stringCleanup(query.query);
const songsOnly = query.songsOnly;
let params;
if (songsOnly) {
params = 'EgWKAQIIAWoKEAMQBBAKEBEQEA%3D%3D'; // Songs only id
} else {
params = 'EgWKAQIQAWoQEBAQERADEAQQCRAKEAUQFQ%3D%3D'; // Videos only id
}
const response = await
GM.xmlHttpRequest({
method: "POST",
url: APIs.ytGetMusicTrackInfo,
headers: {
"content-type": "application/json",
},
data: JSON.stringify({
"context": {
"client": ytmClient
},
"query": queryText,
"params": params
})
});
if (response.status == 200) {
const responseText = response.responseText;
let ytmId, accessibilityTitle, accessibilityArtist, flexTitle, flexArtist, queryCheck;
const accessibilityLabel = responseText.match(/"accessibilityData":{"label":"Play (.+?)"}/);
const flexColumns = responseText.matchAll(/(?<="flexColumns":).+?(?=,"menu")/g);
if (flexColumns && accessibilityLabel) {
const flexColumnsJson = JSON.parse(flexColumns.next().value[0]);
ytmId = flexColumnsJson[0]?.musicResponsiveListItemFlexColumnRenderer?.text?.runs[0]?.navigationEndpoint?.watchEndpoint?.videoId;
flexTitle = flexColumnsJson[0]?.musicResponsiveListItemFlexColumnRenderer?.text?.runs[0]?.text;
flexArtist = flexColumnsJson[1]?.musicResponsiveListItemFlexColumnRenderer?.text?.runs[0]?.text;
flexTitle = stringCleanup(flexTitle);
flexArtist = stringCleanup(flexArtist);
if (songsOnly) {
let words = accessibilityLabel[1].split(' - ');
accessibilityArtist = words.pop();
accessibilityTitle = words.join(' - ');
accessibilityTitle = stringCleanup(accessibilityTitle.replace(/\(.+\)/,''));
accessibilityArtist = stringCleanup(accessibilityArtist);
queryCheck = queryText.includes(accessibilityTitle);
} else {
ytmId = responseText.match(/(?:videoId":")(.+?)(?:")/);
if (ytmId) ytmId = ytmId[1];
queryCheck = true; // Ignore check for video only search
}
if (queryCheck) return {ytmId: ytmId, title: flexTitle, artist: flexArtist};
}
return null;
} else {
console.error(new CustomError({response: response, message:'Error getting YouTube Music track info', url:response.finalUrl, code:4}));
}
}
async function getYtUserId() { // Needed for multiple YouTube accounts
const response = await
GM.xmlHttpRequest({
method: "GET",
url: APIs.ytUserId,
});
if (response.finalUrl == APIs.ytUserId) {
const responseText = response.responseText;
const userId = responseText.match(/myaccount\.google\.com\/u\/(\d)/);
if (userId) return userId[1];
return 0;
} else {
throw new CustomError({response: response, message:'', url:response.finalUrl, code:5});
}
}
async function createYtPlaylist(playlistTitle, videoIds) {
const response = await GM.xmlHttpRequest({
method: "POST",
url: APIs.ytCreatePlaylist,
"headers": {
"accept": "*/*",
"accept-language": "en-US,en;q=0.9",
"authorization": `SAPISIDHASH ${YT_SAPISIDHASH}`,
"content-type": "application/json",
"x-goog-authuser": ytUserId,
"x-origin": "https://www.youtube.com",
"x-youtube-bootstrap-logged-in": "true"
},
data: JSON.stringify({
"context": {
"client": ytClient
},
"title": playlistTitle,
"videoIds": videoIds
})
});
if (response.status == 200) {
const responseJson = JSON.parse(response.responseText);
const playlistId = responseJson.playlistId;
return playlistId;
} else {
throw new CustomError({response: response, message:'', url:response.finalUrl, code:6});
}
}
async function getSApiSidHash(origin) { // https://gist.github.com/eyecatchup/2d700122e24154fdc985b7071ec7764a
function sha1(str) {
return window.crypto.subtle.digest("SHA-1", new TextEncoder("utf-8").encode(str)).then(buf => {
return Array.prototype.map.call(new Uint8Array(buf), x => (('00' + x.toString(16)).slice(-2))).join('');
});
}
const TIMESTAMP_MS = Date.now();
const digest = await sha1(`${TIMESTAMP_MS} ${document.cookie.split('SAPISID=')[1].split('; ')[0]} ${origin}`);
return `${TIMESTAMP_MS}_${digest}`;
}
// SPOTIFY FUNCTIONS:
async function getSpotifyPlaylistContent(playlistId) { // For multiple accounts
let requestUrl = APIs.spotifyPlaylistContent.replace('playlistId', playlistId);
const limit = 100;
const offset = 0;
const params = `?offset=${offset}&limit=${limit}`;
let next = requestUrl + params;
let trackIds = [];
while (next) { // Keep looping until next page is null
const response = await
GM.xmlHttpRequest({
method: "GET",
url: next,
headers: {
'Authorization': `Bearer ${SPOTIFY_AUTH_TOKEN}`,
'Content-Type': 'application/json'
}
});
if (response.status == 200) {
const responseJson = JSON.parse(response.responseText);
next = responseJson.next; // Next page of tracks
trackIds = trackIds.concat((responseJson.items.map(item => item.track.uri)));
} else {
throw new CustomError({response: response, message:'', url:APIs.spotifyPlaylistContent, code:7});
}
}
return trackIds;
}
async function createSpotifyPlaylist(playlistTitle) {
let requestUrl = APIs.spotifyCreatePlaylist.replace('userId', SPOTIFY_USER_ID);
const playlistData = JSON.stringify({
name: playlistTitle,
description: '',
public: false,
});
const response = await GM.xmlHttpRequest({
method: "POST",
url: requestUrl,
headers: {
'Authorization': `Bearer ${SPOTIFY_AUTH_TOKEN}`,
'Content-Type': 'application/json'
},
data: playlistData
});
if (response.status == 200 || response.status == 201) {
const responseJson = JSON.parse(response.responseText);
const playlistId = responseJson.uri.replace('spotify:playlist:', '');
return playlistId;
} else {
throw new CustomError({response: response, message:'', url:APIs.spotifyCreatePlaylist, code:8});
}
}
async function addToSpotifyPlaylist(playlistId, trackIds) {
let requestUrl = APIs.spotifyAddPlaylist.replace('playlistId', playlistId);
while (trackIds.length) { // Keep looping until array is empty
const trackData = JSON.stringify({
uris: trackIds.splice(0, 100), // Get first 100 tracks
});
const response = await GM.xmlHttpRequest({
method: "POST",
url: requestUrl,
headers: {
'Authorization': `Bearer ${SPOTIFY_AUTH_TOKEN}`,
'Content-Type': 'application/json'
},
data: trackData
});
if (response.status == 200 || response.status == 201) {
const responseJson = JSON.parse(response.responseText);
} else {
throw new CustomError({response: response, message:'', url:APIs.spotifyAddPlaylist, code:9});
}
}
}
async function getSpotifyTokens() {
let accessToken;
if (isYouTube || isYouTubeMusic) {
const tokenResponse = await
GM.xmlHttpRequest({
method: "GET",
url: APIs.spotifyToken
});
if (tokenResponse.status == 200) {
const tokenResponseText = await tokenResponse.responseText;
const parser = new DOMParser();
const htmlDoc = parser.parseFromString(tokenResponseText, 'text/html');
accessToken = JSON.parse(htmlDoc.querySelector('script#session').innerHTML).accessToken;
} else {
throw new CustomError({response: tokenResponse, message:'', url:tokenResponse.finalUrl, code:10});
}
} else {
accessToken = JSON.parse(document.querySelector('script#session').innerHTML).accessToken;
}
const usernameResponse = await GM.xmlHttpRequest({
method: 'GET',
url: APIs.spotifyUserId,
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (usernameResponse.status == 200) {
const usernameId = JSON.parse(usernameResponse.responseText).id;
return {
usernameId: usernameId,
accessToken: accessToken
};
} else {
console.log(usernameResponse);
throw new CustomError({response: usernameResponse, message:'', url:usernameResponse.finalUrl, code:11});
}
}
async function searchSpotify(queryObj) {
const query = queryObj.query;
const proprietarySearch = queryObj.proprietary;
let cleanTitle, cleanArtist, proprietaryQuery;
if (typeof(query) == 'object') {
cleanTitle = stringCleanup(query.title);
cleanArtist = stringCleanup(query.artist);
proprietaryQuery = cleanTitle + ' ' + cleanArtist;
} else {
proprietaryQuery = stringCleanup(query);
}
if (proprietarySearch) {
const variables = escape(`{"searchTerm":"${proprietaryQuery}","offset":0,"limit":10,"numberOfTopResults":0,"includeAudiobooks":false}`);
const extensions = escape(`{"persistedQuery":{"version":1,"sha256Hash":"16c02d6304f5f721fc2eb39dacf2361a4543815112506a9c05c9e0bc9733a679"}}`);
const response = await GM.xmlHttpRequest({
method: "GET",
url: `${APIs.spotifySearchProprietary}?operationName=searchTracks&variables=${variables}&extensions=${extensions}`,
headers: {
accept: "application/json",
authorization: `Bearer ${SPOTIFY_AUTH_TOKEN}`,
"content-type": "application/json;charset=UTF-8",
"sec-ch-ua": "\"Not A(Brand\";v=\"99\", \"Google Chrome\";v=\"121\", \"Chromium\";v=\"121\"",
"spotify-app-version": "1.2.31.0-unknown"
}
});
if (response.status == 200) {
const responseText = response.responseText;
const responseJson = JSON.parse(responseText);
if (responseJson.data.searchV2.tracksV2.items.length !== 0) {
const firstTrack = responseJson.data.searchV2.tracksV2.items[0].item.data;
const trackId = firstTrack.uri;
const trackArtists = firstTrack.artists.items.map(artist => stringCleanup(artist.profile.name));
let returnCheck = !!trackId;
// Artist check
if (typeof(query) == 'object') {
const artistMatch = trackArtists.some(item => cleanArtist.includes(item));
returnCheck = returnCheck && artistMatch;
}
if (returnCheck) return trackId;
}
return null;
} else {
console.error(new CustomError({response: response, message:'Error searching Spotify (proprietary)', url:response.finalUrl, code:12}));
}
} else {
const response = await GM.xmlHttpRequest({
method: "GET",
url: `${APIs.spotifySearch}?q=track:${cleanTitle} artist:${cleanArtist}&type=track,artist`,
headers: {
'Authorization': `Bearer ${SPOTIFY_AUTH_TOKEN}`,
}
});
if (response.status == 200) {
const responseText = response.responseText;
const responseJson = JSON.parse(responseText);
if (responseJson.tracks.items.length !== 0) {
const firstTrack = responseJson.tracks.items[0];
const trackTitle = stringCleanup(firstTrack.name);
const trackArtists = firstTrack.artists.map(artist => stringCleanup(artist.name));
const artistMatch = trackArtists.some(item => cleanArtist.includes(item));
const trackId = responseJson.tracks.items[0].uri;
if (trackId && artistMatch) return trackId;
}
return null;
} else {
console.error(new CustomError({response: response, message:'Error searching Spotify', url:response.finalUrl, code:13}));
}
}
}
async function getSpotifyTrackInfo(trackId) {
trackId = trackId.replace('spotify:track:', '');
const response = await GM.xmlHttpRequest({
method: "GET",
url: `${APIs.spotifyGetTrackInfo}/${trackId}`,
headers: {
'Authorization': `Bearer ${SPOTIFY_AUTH_TOKEN}`,
}
});
if (response.status == 200) {
const responseJson = JSON.parse(response.responseText);
const spotifyTitle = responseJson.name;
const spotifyArtist = responseJson.artists[0].name;
if (spotifyTitle && spotifyArtist) return {title: spotifyTitle, artist: spotifyArtist};
return null;
} else {
console.error(new CustomError({response: response, message:'Error getting Spotify track info', url:response.finalUrl, code:14}));
}
}
})();