YouTube / Spotify Playlists Converter

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

目前為 2024-03-08 提交的版本,檢視 最新版本

// ==UserScript==
// @name         YouTube / Spotify Playlists Converter
// @version      2.5
// @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;
        const errorMessages = {
            1: '⛔ Could not get playlist info: The playlist is empty!',
            2: '⛔ Could not get YouTube playlist info..',
            3: '⛔ Could not get YouTube playlist info..',
            5: '⛔ Could not get YouTube User ID: Make sure you are signed in to YouTube and try again..',
            6: '⛔ Could not create YouTube playlist..',
            7: '⛔ Could not get Spotify playlist info..',
            8: '⛔ Could not create Spotify playlist..',
            9: '⛔ Could not add songs to Spotify playlist..',
            10: '⛔ Could not get Spotify token: Make sure you are signed in to Spotify and try again..',
            11: '⛔ Could not get Spotify User ID: Make sure you are signed in to Spotify and try again..'
        };

        if (errorCode === 0) {
            window.location.href = 'https://www.youtube.com/#go_back_fragment';
        }
        if (errorMessages[errorCode]) {
            alert(errorMessages[errorCode]);
        }
    }



    // 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) {
        isYouTube = address.includes('www.youtube.com');
        isYouTubeMusic = address.includes('music.youtube.com');
        isSpotify = address.includes('open.spotify.com');

        isYouTubePlaylist = (isYouTube || isYouTubeMusic) && ytPlaylistIdRegEx.test(address);
        isSpotifyPlaylist = isSpotify && spotifyPlaylistRegEx.test(address);
    }

    addressChecker(address);

    function stringCleanup(input, options) {
        const defaultOptions = [
            'removeSymbol',
            'removeDiacritics',
            'toLowerCase',
            'removeBrackets',
            'removeUnwantedChars',
            'removeExtraSpaces',
            'removeUnwantedPhrases'
        ];
        // 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(),
            removeBrackets: inputString => inputString.replace(/(?:\[|【).+?(?:\]|】)/g, ""),
            removeUnwantedChars: inputString => inputString.replace(/[^\p{L}0-9\s&\(\)]+/ug, ""),
            removeExtraSpaces: inputString => inputString.replace(/ {2,}/g, " ")
        };

        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.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 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`,
        ytmSearch: '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',
        spotifyTrackData: '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');
    async function updateSApiSidHash(platform, url) {
        try {
            const hash = await getSApiSidHash(url);
            GM_setValue(`${platform}_SAPISIDHASH`, hash);
            console.log(`${platform}_SAPISIDHASH:\n${hash}`);
        } catch (error) {
            console.error(error);
        }
    }

    if (isYouTube || isYouTubeMusic) {
        updateSApiSidHash('YT', 'https://www.youtube.com');
        updateSApiSidHash('YTM', 'https://music.youtube.com');
    }

    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) {
        // 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 === obj.playlistId &&
              CACHE_ID.PLAYLIST_CONTENT === JSON.stringify(obj.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: obj.playlistId,
            PLAYLIST_CONTENT: JSON.stringify(obj.playlistContent)
        });

        return null;
    }

    function clearCache() {
        GM_setValue('CACHED_TRACKS', []);
        GM_setValue('CACHED_NOT_FOUND', []);
    }


    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(`💾 ${cache.tracks.length} Saved songs detected, continue from there?`)) {
                        spotify_trackIds = cache.tracks;
                        index = cache.index;
                        yt_playlistContent = yt_playlistContent.slice(index);
                    } else {
                        // Clear cache if user clicks 'Cancel'
                        clearCache();
                    }
                }

                const spotify_NotFound = [];
                for (let entry of yt_playlistContent.entries()) {

                    UI.centerDiv.querySelector('.op-3').textContent = `${operations[2]} (${index + 1}/${yt_totalTracks})`;

                    const ytm_trackData = entry[1];

                    // Remove 'artists' fpr UGC YouTube songs
                    if (ytm_trackData.mvType == 'UGC') ytm_trackData.artists = [''];

                    let spotify_searchResult = await searchSpotify({query: ytm_trackData, topResultOnly: true});
                    // Try regular Spotify API search if 'top result' fails
                    if (spotify_searchResult == null) {
                        spotify_searchResult = await searchSpotify({query: ytm_trackData, topResultOnly: false});
                    }

                    const spotify_trackId = spotify_searchResult ? spotify_searchResult.trackId : null;

                    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(ytm_trackData);
                        console.warn('⚠️ NOT FOUND ON SPOTIFY:', ytm_trackData.title);
                        GM_setValue('CACHED_NOT_FOUND', spotify_NotFound);
                    }
                    index++;
                }
                console.log('Spotify Tracks Found:', spotify_trackIds);
                if (spotify_NotFound.length) console.warn('⚠️ 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
                clearCache();
            }
        } 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(`💾 ${cache.tracks.length} Saved songs detected, continue from there?`)) {
                        yt_trackIds = cache.tracks;
                        index = cache.index;
                        spotify_playlistContent = spotify_playlistContent.slice(index);
                    } else {
                        // Clear cache if user clicks 'Cancel'
                        clearCache();
                    }
                }

                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_trackData = await getSpotifyTrackData(entry);

                    const ytmQuery = spotify_trackData.title + ' ' + spotify_trackData.artists;

                    let ytm_searchResult = await searchYtMusic({query: ytmQuery, songsOnly: true});

                    // Compare artists
                    const spotify_cleanArtists = stringCleanup(spotify_trackData.artists);
                    const ytm_cleanArtists = stringCleanup(ytm_searchResult.artists);
                    const artistsMatch = compareArrays(ytm_cleanArtists, spotify_cleanArtists);

                    let ytm_trackId;
                    // If YouTube Music songs only result is found and artists match
                    if (ytm_searchResult && artistsMatch) {
                        ytm_trackId = ytm_searchResult.videoId;
                    } else {
                        // Try video only search if songs only search fails
                        ytm_searchResult = await searchYtMusic({query: ytmQuery, songsOnly: false});
                        if (ytm_searchResult) ytm_trackId = ytm_searchResult.videoId;
                    }

                    if (ytm_trackId) {
                        console.log('✅ YouTube Music Track ID:', ytm_trackId);
                        yt_trackIds.push(ytm_trackId);
                        GM_setValue('CACHED_TRACKS', yt_trackIds);
                    } else {
                        console.warn('⚠️ NOT FOUND ON YOUTUBE:', spotify_trackData);
                        yt_NotFound.push(spotify_trackData);
                        GM_setValue('CACHED_NOT_FOUND', yt_NotFound);
                    }
                    index++;
                }
                UI.centerDiv.querySelector('.op-3').textContent += ' ✅';

                if (yt_NotFound.length) console.warn('⚠️ 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
                clearCache();
            }
        } catch (error) {
            console.log('🔄🔄🔄', error);
            errorHandler(error);
        }
    }



    // YOUTUBE FUNCTIONS:
    async function getYtPlaylistTitle() {
        const staticPlaylistSelectors = ['.metadata-wrapper yt-formatted-string', '#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;
        }
    }

    function parseYtResponse(responseJson) {
        responseJson = responseJson.contents ? responseJson.contents : responseJson;
        let shelf, continuations;

        // Get shelf based on response reponse
        if (responseJson.singleColumnBrowseResultsRenderer) {
            shelf = responseJson.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicPlaylistShelfRenderer;
            continuations = shelf.continuations ? continuations[0].nextContinuationData.continuation : null;
        } else if (responseJson.continuationContents) {
            shelf = responseJson.continuationContents.musicPlaylistShelfContinuation;
            continuations = shelf.continuations ? continuations[0].nextContinuationData.continuation : null;
        } else if (responseJson.tabbedSearchResultsRenderer) {
            const contents = responseJson.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents;
            // Find the first musicShelfRenderer
            shelf = contents.find(content => content.musicShelfRenderer)?.musicShelfRenderer;
            continuations = null;
        }

        if (!shelf) {
            throw new CustomError({
                response: 'N/A',
                message:'Error getting JSON values',
                url:'N/A',
                code: 15
            });
        }

        let index = 1;
        const shelfContents = shelf.contents;
        const items = shelfContents.map(item => {
            try {
                if (isYouTubePlaylist) document.querySelector('.op-2').textContent = `${operations[1]} (${index})`;
                const flexColumns = item.musicResponsiveListItemRenderer.flexColumns;
                const videoId = flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId;
                let mvType = flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType;
                if (mvType) mvType = mvType.split("_")[3];
                const title = flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text;
                const artistRuns = flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs;
                const artists = [];
                for (let artist of artistRuns) {
                    if (artist.text == ' • ') break;
                    if (artist.text != ' & ' && artist.text != ', ') artists.push(artist.text);
                }
                index++;
                return {
                    videoId: videoId,
                    title: title,
                    artists: artists,
                    mvType: mvType
                };
            } catch (error) {
                console.log(error);
            }
        });
        return {items: items, continuation: continuations};
    }

    async function getYtPlaylistContent(playlistId) {
        const authorization = isYouTube ? `SAPISIDHASH ${YT_SAPISIDHASH}` : `SAPISIDHASH ${YTM_SAPISIDHASH}`;
        const requestUrl = APIs.ytGetPlaylist;
        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:'',
                url:response.finalUrl,
                code:2
            });
        }

        const responseJson = await response.json();

        let parsedResponse = parseYtResponse(responseJson);
        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:'',
                    url:continuationResponse.finalUrl,
                    code:3
                });
            }

            const continuationResponseJson = await continuationResponse.json();
            parsedResponse = parseYtResponse(continuationResponseJson);
            continuation = parsedResponse.continuation;

            tracksData.push(...parsedResponse.items);
        }
        return tracksData;
    }

    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: APIs.ytmSearch,
            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:'Error getting YouTube Music track info',
                url:response.finalUrl,
                code:4
            });
        }

        const responseJson = JSON.parse(response.responseText);
        const parsedResponse = parseYtResponse(responseJson);
        const searchResults = parsedResponse.items;

        return searchResults ? searchResults[0]: null;
    }

    async function getYtUserId() {
        const response = await GM.xmlHttpRequest({
            method: "GET",
            url: APIs.ytUserId,
        });

        if (response.finalUrl !== APIs.ytUserId) {
            throw new CustomError({
                response: response,
                message: 'Unexpected final URL',
                url: response.finalUrl,
                code: 5
            });
        }

        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 createYtPlaylist(playlistTitle, videoIds) {
        const 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"
        };

        const data = JSON.stringify({
            "context": {
                "client": ytClient
            },
            "title": playlistTitle,
            "videoIds": videoIds
        });

        const response = await GM.xmlHttpRequest({
            method: "POST",
            url: APIs.ytCreatePlaylist,
            headers: headers,
            data: data
        });

        if (response.status !== 200) {
            throw new CustomError({
                response: response,
                message: 'Unexpected status code',
                url: response.finalUrl,
                code: 6
            });
        }

        const responseJson = JSON.parse(response.responseText);
        return responseJson.playlistId;
    }

    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) {
        const limit = 100;
        const offset = 0;
        let requestUrl = APIs.spotifyPlaylistContent.replace('playlistId', playlistId);
        let next = `${requestUrl}?offset=${offset}&limit=${limit}`;
        let trackIds = [];

        // Define the method to get playlist content
        const getPlaylistContent = async (url) => {
            const response = await GM.xmlHttpRequest({
                method: "GET",
                url: url,
                headers: {
                    'Authorization': `Bearer ${SPOTIFY_AUTH_TOKEN}`,
                    'Content-Type': 'application/json'
                }
            });

            if (response.status !== 200) {
                throw new CustomError({
                    response: response,
                    message: 'Error getting Spotify playlist content',
                    url: APIs.spotifyPlaylistContent,
                    code: 7
                });
            }

            const responseJson = JSON.parse(response.responseText);
            const trackUris = responseJson.items.map(item => item.track.uri);
            return {next: responseJson.next, trackUris: trackUris};
        };

        // Get the playlist content
        while (next) {
            const playlistContent = await getPlaylistContent(next);
            next = playlistContent.next;
            trackIds = trackIds.concat(playlistContent.trackUris);
        }

        return trackIds;
    }

    async function createSpotifyPlaylist(playlistTitle) {
        const requestUrl = APIs.spotifyCreatePlaylist.replace('userId', SPOTIFY_USER_ID);

        // Define the method to create a playlist
        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 (response.status !== 200 && response.status !== 201) {
                throw new CustomError({
                    response: response,
                    message: 'Unexpected status code',
                    url: APIs.spotifyCreatePlaylist,
                    code: 8
                });
            }

            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 = APIs.spotifyAddPlaylist.replace('playlistId', playlistId);

        // Define the method to add tracks to the playlist
        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 (response.status !== 200 && response.status !== 201) {
                throw new CustomError({
                    response: response,
                    message: 'Unexpected status code',
                    url: APIs.spotifyAddPlaylist,
                    code: 9
                });
            }

            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);
        }
    }

    async function getSpotifyTokens() {
        let accessToken;

        // Define the method to get access token
        const getAccessToken = async () => {
            const tokenResponse = await GM.xmlHttpRequest({
                method: "GET",
                url: APIs.spotifyToken
            });

            if (tokenResponse.status !== 200) {
                throw new CustomError({
                    response: tokenResponse,
                    message: 'Unexpected status code',
                    url: tokenResponse.finalUrl,
                    code: 10
                });
            }

            const tokenResponseText = await tokenResponse.responseText;
            const parser = new DOMParser();
            const htmlDoc = parser.parseFromString(tokenResponseText, 'text/html');
            return JSON.parse(htmlDoc.querySelector('script#session').innerHTML).accessToken;
        };

        if (isYouTube || isYouTubeMusic) {
            accessToken = await getAccessToken();
        } else {
            accessToken = JSON.parse(document.querySelector('script#session').innerHTML).accessToken;
        }

        // Get the username ID
        const usernameResponse = await GM.xmlHttpRequest({
            method: 'GET',
            url: APIs.spotifyUserId,
            headers: {'Authorization': `Bearer ${accessToken}`}
        });

        if (usernameResponse.status !== 200) {
            console.log(usernameResponse);
            throw new CustomError({
                response: usernameResponse,
                message: 'Unexpected status code',
                url: usernameResponse.finalUrl,
                code: 11
            });
        }

        const usernameId = JSON.parse(usernameResponse.responseText).id;
        return {
            usernameId: usernameId,
            accessToken: accessToken
        };
    }

    async function searchSpotify(queryObj) {
        let { query, topResultOnly } = queryObj;
        const topResultQuery = `${stringCleanup(query.title)} ${query.artists.join(' ')}`;

        // Define the functions to search Spotify
        async function topResultRequest(topResultQuery) {
            const variables = encodeURIComponent(`{"searchTerm":"${topResultQuery}","offset":0,"limit":10,"numberOfTopResults":10,"includeAudiobooks":true,"includeArtistHasConcertsField":false}`);
            const extensions = encodeURIComponent(`{"persistedQuery":{"version":1,"sha256Hash":"c8e90ff103ace95ecde0bcb4ba97a56d21c6f48427f87e7cc9a958ddbf46edd8"}}`);

            return await GM.xmlHttpRequest({
                method: "GET",
                url: `${APIs.spotifySearchProprietary}?operationName=searchDesktop&variables=${variables}&extensions=${extensions}`,
                headers: {
                    "accept": "application/json",
                    "authorization": `Bearer ${SPOTIFY_AUTH_TOKEN}`,
                    "sec-ch-ua": "\"Chromium\";v=\"122\", \"Not(A:Brand\";v=\"24\", \"Google Chrome\";v=\"122\"",
                    "spotify-app-version": "1.2.34.0-unknown"
                },
                data: null
            });
        }
        async function apiSearchRequest(title, artists) {
            return await GM.xmlHttpRequest({
                method: "GET",
                url: `${APIs.spotifySearch}?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 (response.status !== 200) {
            console.error(new CustomError({
                response: response,
                message:'Error searching Spotify',
                url:response.finalUrl,
                code: 12
            }));
            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;
        }
    }

    async function getSpotifyTrackData(trackId) {
        trackId = trackId.replace('spotify:track:', '');
        const requestUrl = `${APIs.spotifyTrackData}/${trackId}`;

        // Define the method to get track info
        const getTrackInfo = async (id) => {
            const response = await GM.xmlHttpRequest({
                method: "GET",
                url: requestUrl,
                headers: {'Authorization': `Bearer ${SPOTIFY_AUTH_TOKEN}`}
            });

            if (response.status !== 200) {
                console.error(new CustomError({
                    response: response,
                    message: 'Error getting Spotify track info',
                    url: response.finalUrl,
                    code: 14
                }));
                return null;
            }

            const responseJson = JSON.parse(response.responseText);
            return {
                title: responseJson.name,
                artists: [responseJson.artists[0].name]
            };
        };

        const trackData = await getTrackInfo(trackId);
        return trackData;
    }

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址