您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Convert your music playlists between YouTube & Spotify with a single click.
当前为
// ==UserScript== // @name YouTube / Spotify Playlists Converter // @version 3.6 // @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://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 + 1}`); 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 }; } 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.message.includes('SAPISIDHASH')) { window.location.href = 'https://www.youtube.com/#go_back_fragment'; } if (error.popUp) { alert(`⛔ ${error.message} ${errorDetails}`); } } // GLOBALS: let address = window.location.href; const subdomain = address.slice(8).split('.')[0]; let isYouTube, isYouTubeMusic, isSpotify, isYouTubePlaylist, isSpotifyPlaylist; const playlistIdRegEx = { YouTube: /list=(.{34})/, Spotify: /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) && playlistIdRegEx.YouTube.test(address); isSpotifyPlaylist = isSpotify && playlistIdRegEx.Spotify.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(), removeQuotes: inputString => inputString.replace(/"/g, ""), removeBrackets: inputString => inputString.replace(/(?:\[|【).+?(?:\]|】)/g, ""), removeParentheses: 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 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_PLAYLIST_CONTENT: 'https://api.spotify.com/v1/playlists/playlistId/tracks', CREATE_PLAYLIST: 'https://api.spotify.com/v1/users/userId/playlists', ADD_PLAYLIST: 'https://api.spotify.com/v1/playlists/playlistId/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 (isYouTube || isYouTubeMusic) { const clientVersion = yt.config_.INNERTUBE_CLIENT_VERSION; const clientPrefix = 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); async function updateSAPISIDHASH() { 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}`; } if (isYouTube || isYouTubeMusic) { try { GM_setValue(ytHashName, await getSAPISIDHASH('https://www.youtube.com')); GM_setValue(ytmHashName, await getSAPISIDHASH('https://music.youtube.com')); YT_SAPISIDHASH = GM_getValue(ytHashName); YTM_SAPISIDHASH = GM_getValue(ytmHashName); } catch (error) { console.error(error); } } } await updateSAPISIDHASH(); function checkSAPISIDHASH() { 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: '', message: 'SAPISIDHASH not found', details: '', url: '', popUp: false}); } } let goBackFragment = '#go_back_fragment'; let autoRunFragment = '#sty_autorun'; if (address.includes(goBackFragment)) window.location.href = await GM_getValue('backUrl'); // MENU SETUP: let MENU_COMMAND_ID, menuTitle, source, target; const callback = () => { addressChecker(window.location.href); if (isYouTubePlaylist) { menuTitle = '🔄 YouTube to Spotify 🔄'; source = 'YouTube'; target = 'Spotify'; } else if (isSpotifyPlaylist) { menuTitle = '🔄 Spotify to YouTube 🔄'; source = 'Spotify'; target = 'YouTube'; } if (isYouTubePlaylist || isSpotifyPlaylist) { MENU_COMMAND_ID = GM_registerMenuCommand(menuTitle, () => { convertPlaylist(source, target); }); } else { GM_unregisterMenuCommand(MENU_COMMAND_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"]') && address.includes(autoRunFragment) && GM_getValue('backUrl'); if (autoRunConditions) { GM_setValue('backUrl', undefined); // Clear backUrl convertPlaylist('Spotify', 'YouTube'); } } 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; async function convertPlaylist(source, target) { try { if (source == 'Spotify') checkSAPISIDHASH(); // 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}?`)) { if (YT_SAPISIDHASH == undefined) await updateSAPISIDHASH(); // Add close tab confirmation window.addEventListener("beforeunload", closeConfirmation); // Unregister the menu command GM_unregisterMenuCommand(MENU_COMMAND_ID); // Set the operations operations = [ `Getting Spotify tokens`, `Getting ${source} playlist songs`, `Converting songs to ${target}`, `Adding playlist to ${target}` ]; // Create the UI UI = createUI(operations); // OP-1 UI.centerDiv.querySelector('.op-1').style.opacity = 1; // Get Spotify tokens (same for both source & target) 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; // Playlist ID const playlistId = address.match(playlistIdRegEx[source])[1]; console.log(`${source} Playlist ID:`, playlistId); // User ID (Needed for YouTube multiple accounts) ytUserId = await getYtUserId(); console.log('YouTube User ID:', ytUserId); // Playlist content let playlistContent = await getPlaylistContent(source, playlistId); const totalTracks = playlistContent.length; 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); UI.centerDiv.querySelector('.op-2').textContent = `${operations[1]} (${totalTracks}) ✅`; // OP-3 UI.centerDiv.querySelector('.op-3').style.opacity = 1; let trackIds = []; let index = 0; const notFound = []; 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-3').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-3').textContent = `${operations[2]} (${index}/${totalTracks}) ${notFoundString}`; } let doneEmoji = '✅'; console.log(`${target} Tracks Found:`, trackIds); if (notFound.length) { console.warn(`⚠️ NOT FOUND ON ${target.toUpperCase()}:`, notFound); doneEmoji = '🟨'; } UI.centerDiv.querySelector('.op-3').textContent += ` ${doneEmoji}`; // OP-4 UI.centerDiv.querySelector('.op-4').style.opacity = 1; // Create the playlist const newPlaylistId = await createPlaylist(playlistTitle, trackIds, target); console.log(`${target} Playlist Created:`, newPlaylistId); UI.centerDiv.querySelector('.op-4').textContent += ' ✅'; // Update cancel button 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); // Clear cache 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}`); } } } catch (error) { console.error('🔄🔄🔄', error); errorHandler(error); } } // CONVERSION HELPER FUNCTIONS: async function getSpotifyTokens() { // Define the method to get access token const getAccessToken = async () => { let htmlDoc = isSpotify ? document : undefined; if (isYouTube || 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', '#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; } } // Spotify function getSpotifyPlaylistTitle() { return document.querySelector('[data-testid="entityTitle"]').innerText; } return source == 'Spotify' ? getSpotifyPlaylistTitle() : getYtPlaylistTitle(); } 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 = 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-2').textContent = `${operations[1]} (${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-2').textContent = `${operations[1]} (${index})`; continuation = parsedResponse.continuation; tracksData.push(...parsedResponse.items); } return tracksData; } // Spotify async function getSpotifyPlaylistContent(playlistId) { const limit = 100; const offset = 0; let requestUrl = ENDPOINTS.SPOTIFY.GET_PLAYLIST_CONTENT.replace('playlistId', playlistId); let next = `${requestUrl}?offset=${offset}&limit=${limit}`; const tracksData = []; // 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 (!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: 'singleColumnBrowseResultsRenderer' in responseJson, continuation: 'continuationContents' in responseJson, search: 'tabbedSearchResultsRenderer' in responseJson }; // Get shelf based on response if (responseType.playlist) { shelf = responseJson.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.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; if (contents.some(content => content.itemSectionRenderer) && contents.length == 1) return {items: null}; // No search results // Find musicShelfRenderer shelf = contents.find(content => content.musicShelfRenderer)?.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 = { "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": 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); // 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 (!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); // 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 (!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']), artists: trackData.artists.join(' ') }; 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}); // 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; } })();