Greasy Fork is available in English.

YouTube / Spotify Playlists Converter

Convert your music playlists between YouTube & Spotify with a single click.

// ==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;
    }
})();