您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Convert your music playlists between YouTube & Spotify with a single click.
当前为
// ==UserScript== // @name YouTube / Spotify Playlists Converter // @version 5.0 // @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', 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 generateSpotifyAccessTokenUrl() { // reference: https://github.com/KRTirtho/spotube/issues/2494#issuecomment-2728511342 // Secret generation from cipher bytes const secretCipherBytes = [12, 56, 76, 33, 88, 44, 88, 33, 78, 78, 11, 66, 22, 22, 55, 69, 54].map((e, t) => e ^ t % 33 + 9); // Convert to hex const bufferFromUtf8ToHex = (str) => { const encoder = new TextEncoder(); return Array.from(encoder.encode(str)) .map(b => b.toString(16).padStart(2, '0')) .join(''); }; // Clean buffer const cleanBuffer = (hexStr) => { hexStr = hexStr.replace(/ /g, ""); const buffer = new ArrayBuffer(hexStr.length / 2); const bytes = new Uint8Array(buffer); for (let i = 0; i < hexStr.length; i += 2) { bytes[i / 2] = parseInt(hexStr.substring(i, i + 2), 16); } return bytes; }; // Base32 from bytes const base32FromBytes = (bytes) => { const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; let bits = 0; let value = 0; let output = ""; for (let i = 0; i < bytes.length; i++) { value = (value << 8) | bytes[i]; bits += 8; while (bits >= 5) { output += charset[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) { output += charset[(value << (5 - bits)) & 31]; } return output; }; // Base32 decode const base32Decode = (encoded) => { const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; let bits = 0; let value = 0; let output = new Uint8Array(Math.ceil(encoded.length * 5 / 8)); let index = 0; for (let i = 0; i < encoded.length; i++) { const c = encoded.charAt(i).toUpperCase(); const charValue = charset.indexOf(c); if (charValue === -1) continue; // Skip non-base32 chars value = (value << 5) | charValue; bits += 5; if (bits >= 8) { output[index++] = (value >>> (bits - 8)) & 0xff; bits -= 8; } } return output.slice(0, index); }; // Create counter buffer const createCounterBuffer = (counter) => { const buf = new ArrayBuffer(8); const view = new DataView(buf); let temp = counter; for (let i = 7; i >= 0; i--) { view.setUint8(i, temp & 0xff); temp = temp >> 8; } return new Uint8Array(buf); }; // Random hash generator const randomHash = (length) => { const characters = 'abcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; const charactersLength = characters.length; for (let i = 0; i < length; i++) { result += characters.charAt(Math.floor(Math.random() * charactersLength)); } return result; }; // Process secret const secretBytes = cleanBuffer(bufferFromUtf8ToHex(secretCipherBytes.join(""))); const secret = base32FromBytes(secretBytes); // Get server time const serverTimeResponse = await GM.xmlHttpRequest({ method: 'GET', url: 'https://open.spotify.com/server-time' }); const serverTime = JSON.parse(serverTimeResponse.responseText)?.serverTime; // Get current timestamp in milliseconds and seconds const cTime = Date.now(); const sTime = Math.floor(cTime / 1000); // Generate TOTP const period = 30; const counter = Math.floor(serverTime / period); const counterBuffer = createCounterBuffer(counter); const keyBytes = base32Decode(secret); // Create HMAC using SubtleCrypto API const cryptoKey = await window.crypto.subtle.importKey( 'raw', keyBytes, { name: 'HMAC', hash: { name: 'SHA-1' } }, false, ['sign'] ); const signature = await window.crypto.subtle.sign( 'HMAC', cryptoKey, counterBuffer ); // Get offset and truncated hash const hash = new Uint8Array(signature); const offset = hash[hash.length - 1] & 0xf; const binary = ((hash[offset] & 0x7f) << 24) | ((hash[offset + 1] & 0xff) << 16) | ((hash[offset + 2] & 0xff) << 8) | (hash[offset + 3] & 0xff); // Calculate OTP const digits = 6; const otp = binary % Math.pow(10, digits); const totp = otp.toString().padStart(digits, '0'); // Get build info const buildDate = new Date().toISOString().split('T')[0]; const buildVer = `web-player_${buildDate}_${cTime}_${randomHash(7)}`; // Construct URL return `https://open.spotify.com/get_access_token?reason=init&productType=web-player&totp=${totp}&totpServer=${totp}&totpVer=5&sTime=${sTime}&cTime=${cTime}&buildVer=${buildVer}&buildDate=${buildDate}`; } async function getSpotifyTokens() { const getAccessToken = async () => { const endpoint = await generateSpotifyAccessTokenUrl(); const tokenResponse = await GM.xmlHttpRequest({ method: 'GET', url: endpoint, anonymous: false }); 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 accessToken = JSON.parse(tokenResponse.responseText).accessToken; 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; } })();