- // ==UserScript==
- // @name YouTube / Spotify Playlists Converter
- // @version 4.8
- // @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_openInTab
- // @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://greasyfork.org/users/1254768
- // ==/UserScript==
- (async () => {
- // UI FUNCTIONS:
- function createUI(operations) {
- function createSpanElements(textContent) {
- const spanElements = [];
- for (let i = 0; i < textContent.length; i++) {
- const span = document.createElement("span");
- span.textContent = textContent[i];
- span.classList.add(`op-${i}`);
- spanElements.push(span);
- }
- return spanElements;
- }
-
- function createButton(className, textContent, clickHandler) {
- const button = document.createElement('button');
- button.classList.add(className);
- button.textContent = textContent;
- button.onclick = clickHandler;
- return button;
- }
-
- function reloadPage() {
- location.reload();
- }
-
- // Remove existing UI
- const existingUI = document.querySelector('div.floating-div');
- if (existingUI) existingUI.remove();
-
- const floatingDiv = document.createElement('div');
- floatingDiv.classList.add('floating-div');
-
- const centerDiv = document.createElement('div');
- centerDiv.classList.add('center-div');
-
- const cancelButton = createButton('cancel-button', 'Cancel', reloadPage);
- const closeButton = createButton('close-button', '×', reloadPage); // Unicode character for the close symbol
-
- // Add UI to the page
- document.body.appendChild(floatingDiv);
- floatingDiv.appendChild(centerDiv);
- floatingDiv.appendChild(cancelButton);
- floatingDiv.appendChild(closeButton);
- floatingDiv.style.display = 'flex';
-
- // Add operations
- const spanElements = createSpanElements(operations);
- centerDiv.append(...spanElements);
-
- // 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
- };
- }
-
- // Fix 'TrustedHTML' assignment exception, ref: https://greasyfork.org/en/discussions/development/220765-this-document-requires-trustedhtml-assignment
- if (window.trustedTypes && window.trustedTypes.createPolicy) {
- try {
- window.trustedTypes.createPolicy('default', {
- createHTML: (string, sink) => string
- });
- } catch (error) {
- console.error(error);
- }
- }
-
- function closeConfirmation(event) {
- event.preventDefault();
- event.returnValue = null;
- return null;
- }
-
- function CustomError(obj) {
- this.response = obj.response;
- this.message = obj.message;
- this.details = obj.details;
- this.url = obj.url;
- this.popUp = obj.popUp;
- }
- CustomError.prototype = Error.prototype;
-
- function errorHandler(error) {
- // Add parentheses if details is not empty
- const errorDetails = error.details ? `(${error.details})` : '';
- if (error.popUp) {
- alert(`⛔ ${error.message} ${errorDetails}`);
- }
- }
-
-
- // GLOBALS:
- let address = window.location.href;
- const subdomain = address.slice(8).split('.')[0];
- const playlistIdRegEx = {
- YouTube: /list=(.{34})/,
- Spotify: { playlist: /playlist\/(.{22})/, saved: /collection\/tracks/ }
- };
-
- function addressChecker(address) {
- const isYouTube = address.includes('www.youtube.com');
- const isYouTubeMusic = address.includes('music.youtube.com');
- const isSpotify = address.includes('open.spotify.com');
-
- const isYouTubePlaylist = (isYouTube || isYouTubeMusic) && playlistIdRegEx.YouTube.test(address);
-
- const isSpotifyPlaylist = isSpotify && Object.values(playlistIdRegEx.Spotify).some(regex => regex.test(address));
- return {
- isYouTube,
- isYouTubeMusic,
- isYouTubePlaylist,
- isSpotify,
- isSpotifyPlaylist
- };
- }
-
- let page = addressChecker(address);
-
- function stringCleanup(input, options) {
- const defaultOptions = [
- 'removeSymbol',
- 'removeDiacritics',
- 'toLowerCase',
- 'removeBrackets',
- 'removeUnwantedChars',
- 'removeAllParentheses'
- ];
- // Use default options if none are passed
- options = options ? options : defaultOptions;
- const operations = {
- removeSymbol: inputString => inputString.replace(/・.+?(?=$|-)/,' '),
- removeDiacritics: inputString => inputString.normalize("NFKD").replace(/[\u0300-\u036f]/g, ''),
- toLowerCase: inputString => inputString.toLowerCase(),
- removeQuotes: inputString => inputString.replace(/"/g, ""),
- removeBrackets: inputString => inputString.replace(/(?:\[|【).+?(?:\]|】)/g, ''),
- removeAllParentheses: inputString => inputString.replace(/\([^)]+\)/g, ''),
- // Removes parentheses and its content if the content includes a space character, otherwise, just removes the parentheses
- removeParentheses: inputString => inputString.replace(/\(([^)]+)\)/g, (match, contents) => contents.includes(' ') ? '' : contents),
- removeDashes: inputString => inputString.replace(/(?<=\s)-(?=\s)/g, ''),
- removeUnwantedChars: inputString => inputString.replace(/[^\p{L}0-9\s&\(\)]+/ug, ''),
- removeUnwantedWords: inputString => {
- const unwantedWords = ['ft\\.?', 'feat\\.?', 'official'];
- const modifiedString = unwantedWords.reduce((str, pattern) => {
- const regex = new RegExp('\\b' + pattern + '(?!\w)', 'gi');
- return str.replace(regex, ' ');
- }, inputString);
- return modifiedString;
- }
- };
-
- if (typeof input === 'string') {
- return cleanup(input, options);
- } else if (Array.isArray(input)) {
- return input.map(inputString => cleanup(inputString, options));
- } else {
- console.error('Invalid input type. Expected string or array of strings.');
- }
-
- function cleanup(inputString, options) {
- try {
- for (const option of options) {
- if (operations[option]) {
- inputString = operations[option](inputString);
- }
- }
-
- inputString = inputString.replace(/ {2,}/g, " ").trim(); // Remove extra spaces and trim
-
- return inputString;
- } catch (error) {
- console.error(error);
- }
- }
- }
-
- function compareArrays(arr1, arr2) {
- for (let item1 of arr1) {
- for (let item2 of arr2) {
- if (item1 === item2) return true;
- }
- }
- return false;
- }
-
- const ENDPOINTS = {
- YOUTUBE: {
- GET_USER_ID: 'https://www.youtube.com/account',
- GET_PLAYLIST_CONTENT: `https://${subdomain}.youtube.com/youtubei/v1/browse`,
- MUSIC_SEARCH: 'https://music.youtube.com/youtubei/v1/search?key=&prettyPrint=false',
- CREATE_PLAYLIST: 'https://www.youtube.com/youtubei/v1/playlist/create?key=&prettyPrint=false'
- },
- SPOTIFY: {
- GET_USER_ID: 'https://api.spotify.com/v1/me',
- GET_AUTH_TOKEN: 'https://open.spotify.com/',
- SEARCH: 'https://api.spotify.com/v1/search',
- SEARCH_PROPRIETARY: 'https://api-partner.spotify.com/pathfinder/v1/query',
- GET_CONTENT: {
- PLAYLIST: 'https://api.spotify.com/v1/playlists/id/tracks',
- SAVED: 'https://api.spotify.com/v1/me/tracks',
- },
- CREATE_PLAYLIST: 'https://api.spotify.com/v1/users/userId/playlists',
- ADD_PLAYLIST: 'https://api.spotify.com/v1/playlists/playlistId/tracks',
- GET_LIKED_TRACKS: 'https://api.spotify.com/v1/me/tracks'
- }
- };
- const userAgent = navigator.userAgent + ',gzip(gfe)';
- const ytClient = {
- "userAgent": userAgent,
- "clientName": "WEB",
- "clientVersion": GM_getValue('YT_CLIENT_VERSION','2.20240123.06.00')
- };
- const ytmClient = {
- "userAgent": userAgent,
- "clientName": "WEB_REMIX",
- "clientVersion": GM_getValue('YTM_CLIENT_VERSION','1.20240205.00.00')
- };
- const goodSpotifyStatuses = [200, 201];
-
- // Update YouTube client versions
- if (page.isYouTube || page.isYouTubeMusic) {
- const clientVersion = yt.config_.INNERTUBE_CLIENT_VERSION;
- const clientPrefix = page.isYouTube ? 'YT' : 'YTM';
- GM_setValue(`${clientPrefix}_CLIENT_VERSION`, clientVersion);
- console.log(`${clientPrefix}_CLIENT_VERSION:\n${clientVersion}`);
- }
-
- let SPOTIFY_AUTH_TOKEN, SPOTIFY_USER_ID;
- const ytHashName = 'YT_SAPISIDHASH';
- const ytmHashName = 'YTM_SAPISIDHASH';
- let YT_SAPISIDHASH = await GM_getValue(ytHashName);
- let YTM_SAPISIDHASH = await GM_getValue(ytmHashName);
- let ytTokenFragment = '#get_yt_token';
-
- const SAPISIDHASH_OPS = {
- UPDATE: async () => {
- async function getSAPISIDHASH(origin) {
- 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}`;
- }
- if (page.isYouTube || page.isYouTubeMusic) {
- try {
- await GM_setValue(ytHashName, await getSAPISIDHASH('https://www.youtube.com'));
- await GM_setValue(ytmHashName, await getSAPISIDHASH('https://music.youtube.com'));
- YT_SAPISIDHASH = await GM_getValue(ytHashName);
- YTM_SAPISIDHASH = await GM_getValue(ytmHashName);
- if (address.includes(ytTokenFragment) && YT_SAPISIDHASH && YTM_SAPISIDHASH && await GM_getValue('closeTab')) window.close();
- } catch (error) {
- console.error(error);
- }
- }
- },
-
- FETCH: async () => {
- await GM_setValue('closeTab', true);
- const ytTab = await GM_openInTab(`http://www.youtube.com/${ytTokenFragment}`, { active: false });
- // Create a new Promise that resolves when the tab is closed
- await new Promise(resolve => {
- ytTab.onclose = async () => {
- YT_SAPISIDHASH = await GM_getValue(ytHashName);
- YTM_SAPISIDHASH = await GM_getValue(ytmHashName);
- await GM_setValue('closeTab', false);
- resolve();
- };
- });
- },
-
- VALIDATE: (SAPISIDHASH) => {
- if (SAPISIDHASH == undefined) return false;
- const timestamp = SAPISIDHASH.split('_')[0];
- const currentTime = Date.now();
- const limit = 3600000 * 12; // 3600000 (One hour in milliseconds)
- const hasNotExpired = currentTime - timestamp < limit;
- return hasNotExpired;
- }
- };
-
- SAPISIDHASH_OPS.UPDATE();
-
- // MENU SETUP:
- let MENU_COMMAND_ID, menuTitle, source, target;
- const callback = () => {
- page = addressChecker(window.location.href);
-
- source = page.isYouTubePlaylist ? 'YouTube' : page.isSpotifyPlaylist ? 'Spotify' : source;
- target = page.isYouTubePlaylist ? 'Spotify' : page.isSpotifyPlaylist ? 'YouTube' : target;
-
- if (page.isYouTubePlaylist || page.isSpotifyPlaylist) {
- if (MENU_COMMAND_ID) return; // If command already registered
- menuTitle = `🔄 ${source} to ${target} 🔄`;
- MENU_COMMAND_ID = GM_registerMenuCommand(menuTitle, () => { convertPlaylist(source, target); });
- } else {
- MENU_COMMAND_ID = GM_unregisterMenuCommand(MENU_COMMAND_ID);
- }
- };
- callback();
-
- // Register/unregister menu functions on address change
- const observer = new MutationObserver(() => {
- if (location.href !== address) { // If address changes
- address = location.href;
- callback();
- }
- });
- observer.observe(document, {subtree: true, childList: true});
-
-
- // Cache functions
- function checkCache(cacheObj) {
- // Get cache values
- const CACHED_TRACKS = GM_getValue('CACHED_TRACKS', []);
- const CACHED_NOT_FOUND = GM_getValue('CACHED_NOT_FOUND', []);
- const CACHE_ID = GM_getValue('CACHE_ID', {});
-
- const CACHED_INDEX = CACHED_TRACKS.length + CACHED_NOT_FOUND.length;
-
- const cacheConditions = CACHED_INDEX > 3 &&
- CACHE_ID.PLAYLIST_ID === cacheObj.playlistId &&
- CACHE_ID.PLAYLIST_CONTENT === JSON.stringify(cacheObj.playlistContent);
-
- // If cache conditions are met, return cached data
- if (cacheConditions) {
- return {
- tracks: CACHED_TRACKS,
- index: CACHED_INDEX
- };
- }
-
- // If no matching cache is detected, set cache for current conversion
- GM_setValue('CACHE_ID', {
- PLAYLIST_ID: cacheObj.playlistId,
- PLAYLIST_CONTENT: JSON.stringify(cacheObj.playlistContent)
- });
-
- return null;
- }
- function clearCache() {
- GM_setValue('CACHED_TRACKS', []);
- GM_setValue('CACHED_NOT_FOUND', []);
- }
-
-
-
- let UI, ytUserId, operations;
- let opIndex = 0;
- async function convertPlaylist(source, target) {
- try {
- // Get the title of the playlist
- let playlistTitle = await getPlaylistTitle(source);
- console.log(`${source} Playlist Title:`, playlistTitle);
-
- // User confirmation
- if (!confirm(`Convert "${playlistTitle}" to ${target}?`)) return;
-
- // Add close tab confirmation
- window.addEventListener("beforeunload", closeConfirmation);
- // Unregister the menu command
- MENU_COMMAND_ID = GM_unregisterMenuCommand(MENU_COMMAND_ID);
-
- // Set the operations variables
- let playlistContent, playlistId, totalTracks, newPlaylistId;
- let trackIds = [];
- let notFound = [];
- operations = [
- {
- name: `Getting YouTube & Spotify tokens`,
- op: async () => {
- // Get YouTube & Spotify tokens (required for both)
- const spotifyTokens = await getSpotifyTokens();
- SPOTIFY_USER_ID = spotifyTokens.usernameId;
- SPOTIFY_AUTH_TOKEN = spotifyTokens.accessToken;
-
- if (!SAPISIDHASH_OPS.VALIDATE(YT_SAPISIDHASH)) source == 'Spotify' ? await SAPISIDHASH_OPS.FETCH() : await SAPISIDHASH_OPS.UPDATE();
- }
- },
- {
- name: `Getting ${source} playlist songs`,
- op: async () => {
- // Playlist ID
- playlistId = getPlaylistId(source);
- console.log(`${source} Playlist ID:`, playlistId);
- // User ID (Needed for YouTube multiple accounts)
- ytUserId = await getYtUserId();
- console.log('YouTube User ID:', ytUserId);
- // Playlist content
- playlistContent = await getPlaylistContent(source, playlistId);
- totalTracks = playlistContent.length;
- UI.centerDiv.querySelector(`.op-${opIndex}`).textContent = `${operations[opIndex].name} (${totalTracks})`;
- if (totalTracks == 0) {
- throw new CustomError({
- response: '',
- message: 'Could not get playlist info: The playlist is empty!',
- details: '', url: '', popUp: true
- });
- }
- console.log(`${source} Playlist Content:`, playlistContent);
- }
- },
- {
- name: `Converting songs to ${target}`,
- op: async () => {
- let index = 0;
- let notFoundString = '';
-
- // Cache setup
- const cache = checkCache({
- playlistId: playlistId,
- playlistContent: playlistContent
- });
-
- if (cache !== null) {
- if(confirm(`💾 ${cache.tracks.length} Saved songs detected, continue from there?`)) {
- trackIds = cache.tracks;
- index = cache.index;
- playlistContent = playlistContent.slice(index);
- UI.centerDiv.querySelector(`.op-${opIndex}`).textContent += ` (${index}/${totalTracks})`;
- } else {
- // Clear cache if user clicks 'Cancel'
- clearCache();
- }
- }
-
- for (let [_, sourceTrackData] of playlistContent.entries()) {
- const targetTrackData = target == 'Spotify' ? await findOnSpotify(sourceTrackData) : await findOnYouTube(sourceTrackData);
-
- if (targetTrackData) {
- const targetTrackId = targetTrackData.trackId;
- trackIds.push(targetTrackId);
- console.log(`✅ ${target} Track ID:`, targetTrackId);
- GM_setValue('CACHED_TRACKS', trackIds);
- } else {
- const sourceTrackTitle = sourceTrackData.title;
- notFound.push(sourceTrackTitle);
- console.warn(`NOT FOUND ON ${target.toUpperCase()}:`, sourceTrackTitle);
- GM_setValue('CACHED_NOT_FOUND', notFound);
- }
-
- index++;
- notFoundString = notFound.length > 0 ? `(${notFound.length} not found)` : '';
- UI.centerDiv.querySelector(`.op-${opIndex}`).textContent = `${operations[opIndex].name} (${index}/${totalTracks}) ${notFoundString}`;
- }
- console.log(`${target} Tracks Found:`, trackIds);
- }
- },
- {
- name: `Adding playlist to ${target}`,
- op: async () => {
- // Create the playlist
- newPlaylistId = await createPlaylist(playlistTitle, trackIds, target);
- console.log(`${target} Playlist Created:`, newPlaylistId);
- }
- }
- ];
-
- // Create the UI
- UI = createUI(operations.map(op => op.name));
-
- for (const operation of operations) {
- UI.centerDiv.querySelector(`.op-${opIndex}`).style.opacity = 1;
-
- await operation.op();
-
- let doneEmoji = '✅';
- if (notFound.length && operation.name.includes('Converting songs')) {
- console.warn(`NOT FOUND ON ${target.toUpperCase()}:`, notFound);
- doneEmoji = '🟨';
- }
- UI.centerDiv.querySelector(`.op-${opIndex}`).textContent += ` ${doneEmoji}`;
-
- opIndex++;
- }
-
- // Update cancel & close buttons
- UI.cancelButton.onclick = () => {
- const url = target == 'Spotify' ? `https://open.${target.toLowerCase()}.com/playlist/${newPlaylistId}` : `https://www.${target.toLowerCase()}.com/playlist?list=${newPlaylistId}`;
- window.open(url);
- };
- UI.closeButton.onclick = () => {
- UI.floatingDiv.remove();
- };
- UI.cancelButton.style.backgroundColor = target == 'Spotify' ? '#1ed55f' : '#ff0000'; // Button background: Green, Red
- if (target == 'YouTube') UI.cancelButton.style.color = '#ffffff'; // Make text white
- UI.cancelButton.textContent = `Open in ${target}!`;
-
- // Re-register the menu command
- MENU_COMMAND_ID = GM_registerMenuCommand(menuTitle, () => { convertPlaylist(source, target); });
- // Remove close tab confirmation
- window.removeEventListener("beforeunload", closeConfirmation);
- clearCache();
- // Alert not found songs
- if (notFound.length) {
- const notFoundList = notFound.join('\n• ');
- alert(`⚠️ Song(s) that could not be found on ${target}:\n• ${notFoundList}`);
- }
- opIndex = 0;
- } catch (error) {
- console.error('🔄🔄🔄', error);
- errorHandler(error);
- }
- }
-
-
- // CONVERSION HELPER FUNCTIONS:
- async function getSpotifyTokens() {
- const getAccessToken = async () => {
- let htmlDoc = page.isSpotify ? document : undefined;
- if (page.isYouTube || page.isYouTubeMusic) {
- const tokenResponse = await GM.xmlHttpRequest({
- method: "GET",
- url: ENDPOINTS.SPOTIFY.GET_AUTH_TOKEN
- });
-
- if (tokenResponse.status !== 200) {
- throw new CustomError({
- response: tokenResponse,
- message: 'Could not get Spotify token: Make sure you are signed in to Spotify and try again..',
- details: `Unexpected status code: ${tokenResponse.status}`,
- url: tokenResponse.finalUrl,
- popUp: true
- });
- }
- const tokenResponseText = await tokenResponse.responseText;
- const parser = new DOMParser();
- htmlDoc = parser.parseFromString(tokenResponseText, 'text/html');
- }
-
- const sessionScript = htmlDoc.querySelector('script#session');
-
- if (sessionScript == null) {
- throw new CustomError({
- response: '',
- message: 'Could not find Spotify session script..',
- details: '',
- url: '',
- popUp: true
- });
- }
-
- const accessToken = JSON.parse(sessionScript.innerHTML).accessToken;
-
- if (accessToken == undefined) {
- throw new CustomError({
- response: '',
- message: 'Spotify access token is unfefined..',
- details: '',
- url: '',
- popUp: true
- });
- }
-
- return accessToken;
- };
-
- const accessToken = await getAccessToken();
-
- // Get the username ID
- const usernameResponse = await GM.xmlHttpRequest({
- method: 'GET',
- url: ENDPOINTS.SPOTIFY.GET_USER_ID,
- headers: {'Authorization': `Bearer ${accessToken}`}
- });
-
- if (!goodSpotifyStatuses.includes(usernameResponse.status)) {
- throw new CustomError({
- response: usernameResponse,
- message: 'Could not get Spotify User ID: Make sure you are signed in to Spotify and try again..',
- details: `Unexpected status code: ${usernameResponse.status}`,
- url: usernameResponse.finalUrl,
- popUp: true
- });
- }
-
- const usernameId = JSON.parse(usernameResponse.responseText).id;
- return {
- usernameId: usernameId,
- accessToken: accessToken
- };
- }
-
- async function getPlaylistTitle(source) {
- // YouTube
- function getYtPlaylistTitle() {
- const staticPlaylistSelectors = ['.metadata-wrapper yt-formatted-string', '#contents .title', '[id^="page-header"] [class*="header-title"]'];
- const playingPlaylistSelectors = ['#header-description a[href*="playlist?list="]', '#tab-renderer .subtitle'];
-
- const selectors = address.includes('watch?v=') ? playingPlaylistSelectors : staticPlaylistSelectors;
-
- // Find the first matching element and return its text
- for (const selector of selectors) {
- const element = document.querySelector(selector);
- if (element) return element.innerText;
- }
- // If title element is undefined
- return 'YouTube Playlist';
- }
-
- // Spotify
- function getSpotifyPlaylistTitle() {
- const element = document.querySelector('[data-testid="entityTitle"]');
- if (element) return element.innerText;
- // If title element is undefined
- return 'Spotify Playlist';
- }
-
- return source == 'Spotify' ? getSpotifyPlaylistTitle() : getYtPlaylistTitle();
- }
-
- function getPlaylistId(source) {
- // YouTube
- if (source == 'YouTube') {
- const match = address.match(playlistIdRegEx.YouTube);
- return match ? match[1] : null;
- }
-
- // Spotify
- const spotifyCategories = Object.entries(playlistIdRegEx.Spotify);
- for (const [category, regex] of spotifyCategories) {
- const match = address.match(regex);
- if (match) return { [category]: match[1] || category };
- }
- }
-
- async function getYtUserId() {
- const response = await GM.xmlHttpRequest({
- method: "GET",
- url: ENDPOINTS.YOUTUBE.GET_USER_ID,
- });
-
- if (response.finalUrl !== ENDPOINTS.YOUTUBE.GET_USER_ID) {
- const finalUrlHostname = new URL(response.finalUrl).hostname;
- throw new CustomError({
- response: response,
- message: 'Could not get YouTube User ID: Make sure you are signed in to YouTube and try again..',
- details: `Unexpected final URL: ${finalUrlHostname}`,
- url: response.finalUrl,
- popUp: true
- });
- }
-
- const userIdMatch = response.responseText.match(/myaccount\.google\.com\/u\/(\d)/);
-
- // Return the user ID if found, or 0 otherwise
- return userIdMatch ? userIdMatch[1] : 0;
- }
-
- async function getPlaylistContent(source, playlistId) {
- // Youtube
- async function getYtPlaylistContent(playlistId) {
- const requestUrl = ENDPOINTS.YOUTUBE.GET_PLAYLIST_CONTENT;
- const authorization = page.isYouTube ? `SAPISIDHASH ${YT_SAPISIDHASH}` : `SAPISIDHASH ${YTM_SAPISIDHASH}`;
- const headers = {
- "accept": "*/*",
- "authorization": authorization,
- "x-goog-authuser": ytUserId,
- };
- const context = {
- "client": ytmClient
- };
-
- let tracksData = [];
- playlistId = 'VL' + playlistId;
-
- let continuation;
- let requestParams = {
- requestUrl,
- headers,
- context,
- playlistId,
- continuation: null
- };
-
- async function fetchListedItems({requestUrl, headers, context, playlistId, continuation}) {
- const url = continuation ? `${requestUrl}?ctoken=${continuation}&continuation=${continuation}&type=next&prettyPrint=false` : `${requestUrl}?key=&prettyPrint=false`;
- const body = JSON.stringify({
- "context": context,
- "browseId": playlistId
- });
-
- return await fetch(url, {
- method: "POST",
- headers: headers,
- body: body
- });
- }
-
- const response = await fetchListedItems(requestParams);
- if (!response.ok) {
- throw new CustomError({
- response: response,
- message: 'Could not get YouTube playlist info..',
- details: `Bad response: ${response.status}`,
- url: response.finalUrl,
- popUp: true
- });
- }
-
- const responseJson = await response.json();
-
- let parsedResponse = parseYtResponse(responseJson);
-
- let index = parsedResponse.items.length;
- document.querySelector(`.op-${opIndex}`).textContent = `${operations[opIndex].name} (${index})`;
-
- continuation = parsedResponse.continuation;
-
- tracksData.push(...parsedResponse.items);
-
- while (continuation) {
- requestParams.continuation = continuation;
-
- const continuationResponse = await fetchListedItems(requestParams);
- if (!continuationResponse.ok) {
- throw new CustomError({
- response: continuationResponse,
- message: 'Could not get YouTube playlist info..',
- details: `Bad continuation response: ${continuationResponse.status}`,
- url: continuationResponse.finalUrl,
- popUp: true
- });
- }
-
- const continuationResponseJson = await continuationResponse.json();
- parsedResponse = parseYtResponse(continuationResponseJson);
-
- index += parsedResponse.items.length;
- document.querySelector(`.op-${opIndex}`).textContent = `${operations[opIndex].name} (${index})`;
-
- continuation = parsedResponse.continuation;
-
- tracksData.push(...parsedResponse.items);
- }
- return tracksData;
- }
-
- // Spotify
- async function getSpotifyPlaylistContent(playlistId) {
- const [category, id] = Object.entries(playlistId)[0];
- const limit = category == 'playlist' ? 100 : 50;
- const offset = 0;
-
- let requestUrl = category == 'playlist' ? ENDPOINTS.SPOTIFY.GET_CONTENT.PLAYLIST.replace('id', id) : ENDPOINTS.SPOTIFY.GET_CONTENT.SAVED;
-
- let next = `${requestUrl}?offset=${offset}&limit=${limit}`;
- const tracksData = [];
-
- const getPlaylistContent = async (url) => {
-
- const response = await GM.xmlHttpRequest({
- method: "GET",
- url: url,
- headers: {
- 'Authorization': `Bearer ${SPOTIFY_AUTH_TOKEN}`,
- 'Content-Type': 'application/json'
- }
- });
-
- if (!goodSpotifyStatuses.includes(response.status)) {
- throw new CustomError({
- response: response,
- message: 'Could not get Spotify playlist info..',
- details: `Error getting Spotify playlist content: ${response.status}`,
- url: ENDPOINTS.SPOTIFY.GET_PLAYLIST_CONTENT,
- popUp: true
- });
- }
-
- const responseJson = JSON.parse(response.responseText);
-
- const items = responseJson.items;
- for (const item of items) {
- const trackId = item.track.uri;
- const title = item.track.name;
- const artists = item.track.artists.map(artist => artist.name);
- const trackData = {
- trackId: trackId,
- title: title,
- artists: artists
- };
- tracksData.push(trackData);
- }
- return {next: responseJson.next, tracksData: tracksData};
- };
-
- // Get the playlist content
- while (next) {
- const playlistContent = await getPlaylistContent(next);
- next = playlistContent.next;
- }
-
- return tracksData;
- }
-
- return source == 'Spotify' ? getSpotifyPlaylistContent(playlistId) : getYtPlaylistContent(playlistId);
- }
-
- function parseYtResponse(responseJson) {
- responseJson = responseJson.contents ? responseJson.contents : responseJson;
- let shelf, continuations;
-
- const responseType = {
- playlist: 'twoColumnBrowseResultsRenderer' in responseJson,
- continuation: 'continuationContents' in responseJson,
- search: 'tabbedSearchResultsRenderer' in responseJson
- };
-
- // Get shelf based on response
- if (responseType.playlist) {
- shelf = responseJson.twoColumnBrowseResultsRenderer.secondaryContents.sectionListRenderer.contents[0].musicPlaylistShelfRenderer;
- continuations = shelf.continuations ? shelf.continuations[0].nextContinuationData.continuation : null;
- } else if (responseType.continuation) {
- shelf = responseJson.continuationContents.musicPlaylistShelfContinuation;
- continuations = shelf.continuations ? shelf.continuations[0].nextContinuationData.continuation : null;
- } else if (responseType.search) {
- const contents = responseJson.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents;
- shelf = contents.find(content => content.musicShelfRenderer); // Find musicShelfRenderer
- if (!shelf) return { items: null }; // No search results
- shelf = shelf.musicShelfRenderer;
- continuations = null;
- }
-
- if (!shelf) {
- throw new CustomError({
- response: '',
- message: 'Error accessing YouTube response JSON values',
- details: '',
- url: '',
- popUp: false
- });
- }
-
- const shelfContents = shelf.contents;
- const items = shelfContents.map(item => {
- try {
- const flexColumns = item.musicResponsiveListItemRenderer?.flexColumns;
- const column0 = flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer;
- const column1 = flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer;
- const textRuns = column0?.text?.runs[0];
- const endpoint = textRuns?.navigationEndpoint?.watchEndpoint;
- const configs = endpoint?.watchEndpointMusicSupportedConfigs?.watchEndpointMusicConfig;
-
- const trackId = endpoint?.videoId;
- let mvType = configs?.musicVideoType;
- if (mvType) mvType = mvType.replace('MUSIC_VIDEO_TYPE_','');
- const title = textRuns?.text;
- const artistRuns = column1?.text?.runs;
- const artists = [];
- for (let artist of artistRuns) {
- if (artist.text == ' • ') break;
- if (artist.text != ' & ' && artist.text != ', ') artists.push(artist.text);
- }
- return {
- trackId: trackId,
- title: title,
- artists: artists,
- mvType: mvType
- };
- } catch (error) {
- console.error(error);
- }
- });
-
- return {items: items, continuation: continuations};
- }
-
- async function createPlaylist(playlistTitle, trackIds, target) {
- // Youtube
- async function createYtPlaylist(playlistTitle, trackIds) {
- const headers = {
- "authorization": `SAPISIDHASH ${YT_SAPISIDHASH}`,
- "x-goog-authuser": ytUserId,
- "x-origin": "https://www.youtube.com"
- };
-
- const data = JSON.stringify({
- "context": {
- "client": ytClient
- },
- "title": playlistTitle,
- "videoIds": trackIds
- });
-
- const response = await GM.xmlHttpRequest({
- method: "POST",
- url: ENDPOINTS.YOUTUBE.CREATE_PLAYLIST,
- headers: headers,
- data: data
- });
-
- if (response.status !== 200) {
- throw new CustomError({
- response: response,
- message: 'Could not create YouTube playlist..',
- details: `Unexpected status code: ${response.status}`,
- url: response.finalUrl,
- popUp: true
- });
- }
-
- const responseJson = JSON.parse(response.responseText);
- return responseJson.playlistId;
- }
-
- // Spotify
- async function createSpotifyPlaylist(playlistTitle) {
- const requestUrl = ENDPOINTS.SPOTIFY.CREATE_PLAYLIST.replace('userId', SPOTIFY_USER_ID);
-
- const createPlaylist = async (title) => {
- const playlistData = JSON.stringify({
- name: title,
- 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 (!goodSpotifyStatuses.includes(response.status)) {
- throw new CustomError({
- response: response,
- message: 'Could not create Spotify playlist..',
- details: `Unexpected status code: ${response.status}`,
- url: ENDPOINTS.SPOTIFY.CREATE_PLAYLIST,
- popUp: true
- });
- }
-
- const responseJson = JSON.parse(response.responseText);
- return responseJson.uri.replace('spotify:playlist:', '');
- };
-
- const playlistId = await createPlaylist(playlistTitle);
- return playlistId;
- }
- async function addToSpotifyPlaylist(playlistId, trackIds) {
- const requestUrl = ENDPOINTS.SPOTIFY.ADD_PLAYLIST.replace('playlistId', playlistId);
-
- const addTracksToPlaylist = async (tracks) => {
- const trackData = JSON.stringify({ uris: tracks });
-
- const response = await GM.xmlHttpRequest({
- method: "POST",
- url: requestUrl,
- headers: {
- 'Authorization': `Bearer ${SPOTIFY_AUTH_TOKEN}`,
- 'Content-Type': 'application/json'
- },
- data: trackData
- });
-
- if (!goodSpotifyStatuses.includes(response.status)) {
- throw new CustomError({
- response: response,
- message: 'Could not add songs to Spotify playlist..',
- details: `Unexpected status code: ${response.status}`,
- url: ENDPOINTS.SPOTIFY.ADD_PLAYLIST,
- popUp: true
- });
- }
-
- return JSON.parse(response.responseText);
- };
-
- // Keep adding tracks until the array is empty
- while (trackIds.length) {
- const tracks = trackIds.splice(0, 100); // Get the first 100 tracks
- await addTracksToPlaylist(tracks);
- }
- }
-
- if (target == 'Spotify') {
- const spotifyPLaylistId = await createSpotifyPlaylist(playlistTitle);
- await addToSpotifyPlaylist(spotifyPLaylistId, trackIds);
- return spotifyPLaylistId;
- } else if (target == 'YouTube') {
- const ytPLaylistId = await createYtPlaylist(playlistTitle, trackIds);
- return ytPLaylistId;
- }
- }
-
- async function searchYtMusic(queryObj) {
- const { query, songsOnly } = queryObj;
- const params = songsOnly ? 'EgWKAQIIAWoKEAMQBBAKEBEQEA%3D%3D' : 'EgWKAQIQAWoQEBAQERADEAQQCRAKEAUQFQ%3D%3D'; // Songs only id, Videos only id
- const response = await GM.xmlHttpRequest({
- method: "POST",
- url: ENDPOINTS.YOUTUBE.MUSIC_SEARCH,
- headers: {
- "content-type": "application/json",
- },
- data: JSON.stringify({
- "context": {
- "client": ytmClient
- },
- "query": query,
- "params": params
- })
- });
- if (response.status !== 200) {
- throw new CustomError({
- response: response,
- message: '',
- details: `Error getting YouTube Music track data: ${response.status}`,
- url: response.finalUrl,
- popUp: false
- });
- }
-
- const responseJson = JSON.parse(response.responseText);
- const parsedResponse = parseYtResponse(responseJson);
- const searchResults = parsedResponse.items;
-
- return searchResults ? searchResults[0]: null;
- }
-
- async function findOnSpotify(trackData) {
- async function searchSpotify(queryObj) {
- let { query, topResultOnly } = queryObj;
- const topResultQuery = `${query.title} ${query.artists}`;
-
- // Define the functions to search Spotify
- async function topResultRequest(topResultQuery) {
- const variables = JSON.stringify({
- "searchTerm": topResultQuery,
- "offset": 0,
- "limit": 10,
- "numberOfTopResults": 10,
- "includeAudiobooks": true,
- "includeArtistHasConcertsField": false
- });
- const extensions = JSON.stringify({
- "persistedQuery": {
- "version": 1,
- "sha256Hash": "c8e90ff103ace95ecde0bcb4ba97a56d21c6f48427f87e7cc9a958ddbf46edd8"
- }
- });
-
- return await GM.xmlHttpRequest({
- method: "GET",
- url: `${ENDPOINTS.SPOTIFY.SEARCH_PROPRIETARY}?operationName=searchDesktop&variables=${encodeURIComponent(variables)}&extensions=${encodeURIComponent(extensions)}`,
- headers: {
- "accept": "application/json",
- "authorization": `Bearer ${SPOTIFY_AUTH_TOKEN}`
- },
- data: null
- });
- }
- async function apiSearchRequest(title, artists) {
- return await GM.xmlHttpRequest({
- method: "GET",
- url: `${ENDPOINTS.SPOTIFY.SEARCH}?q=track:"${title}" artist:"${artists}"&type=track&offset=0&limit=1`,
- headers: {
- 'Authorization': `Bearer ${SPOTIFY_AUTH_TOKEN}`,
- }
- });
- }
-
- const response = topResultOnly ? await topResultRequest(topResultQuery) : await apiSearchRequest(query.title, query.artists);
-
- if (!goodSpotifyStatuses.includes(response.status)) {
- console.error(new CustomError({
- response: response,
- message: '',
- details: `Error searching Spotify: ${response.status}`,
- url: response.finalUrl,
- popUp: false
- }));
- return null;
- }
-
- const responseJson = JSON.parse(response.responseText);
- const searchItems = topResultOnly ? responseJson.data.searchV2.topResultsV2.itemsV2 : responseJson.tracks.items;
-
- if (searchItems.length === 0) {
- return null;
- }
-
-
- if (topResultOnly) {
- const trackType = searchItems[0].item.data.__typename;
- if (trackType !== "Track") return null;
-
- const trackId = searchItems[0].item.data.uri;
- const title = searchItems[0].item.data.name;
- const artistsData = searchItems[0].item.data.artists.items;
- const artists = artistsData.map(artist => artist.profile.name);
-
- return {trackId: trackId, title: title, artists: artists};
- } else {
- const apiResults = searchItems.map(result => {
- const trackId = result.uri;
- const title = result.name;
- const artistsData = result.artists;
- const artists = artistsData.map(artist => artist.name);
- return {trackId: trackId, title: title, artists: artists};
- });
- return apiResults ? apiResults[0]: null;
- }
- }
-
- // Handling UGC YouTube songs
- if (trackData.mvType === 'UGC') {
- trackData.artists = [''];
- const ytmSearchResult = await searchYtMusic({query: trackData.title, songsOnly: true});
- if (ytmSearchResult) {
- const cleanTitle = stringCleanup(trackData.title);
- const cleanArtists = stringCleanup(ytmSearchResult.artists);
- trackData = cleanTitle.includes(cleanArtists?.[0]) ? ytmSearchResult : trackData;
- }
- }
-
- const modifiedTrackData = {
- title: stringCleanup(trackData.title, ['removeDiacritics', 'removeBrackets', 'removeQuotes', 'removeParentheses', 'removeDashes', 'removeUnwantedWords']),
- artists: stringCleanup(trackData.artists.join(' '), ['removeUnwantedWords'])
- };
-
- let spotifySearchResult;
- let queries = [
- {query: modifiedTrackData, topResultOnly: true},
- {query: trackData, topResultOnly: true},
- {query: trackData, topResultOnly: false}
- ];
-
- for (let query of queries) {
- spotifySearchResult = await searchSpotify(query);
- if (spotifySearchResult) break;
- }
-
- return spotifySearchResult || null;
- }
-
- async function findOnYouTube(trackData) {
- const ytmQuery = `${trackData.title} ${trackData.artists[0]}`;
-
- let ytmSearchResult = await searchYtMusic({query: ytmQuery, songsOnly: true});
-
- if (ytmSearchResult) {
- // Compare artists
- const cleanArtists1 = stringCleanup([trackData?.artists[0]]);
- const cleanArtists2 = stringCleanup(ytmSearchResult?.artists);
- const artistsMatch = compareArrays(cleanArtists1, cleanArtists2);
-
- // If YouTube Music songs only result is found and artists match
- if (ytmSearchResult && artistsMatch) {
- return ytmSearchResult;
- }
- }
-
- // Try video only search if songs only search fails
- ytmSearchResult = await searchYtMusic({query: ytmQuery, songsOnly: false});
- return ytmSearchResult || null;
- }
- })();