您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Convert your music playlists between YouTube & Spotify with a single click.
当前为
// ==UserScript== // @name YouTube / Spotify Playlists Converter // @version 3.7.2 // @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) { 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 }; } 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]; 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); let ytTokenFragment = '#get_yt_token'; 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 { 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) window.close(); } catch (error) { console.error(error); } } } await updateSAPISIDHASH(); async function checkSAPISIDHASH() { if (YT_SAPISIDHASH == undefined) { 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); resolve(); }; }); } } // 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 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}?`)) { // Add close tab confirmation window.addEventListener("beforeunload", closeConfirmation); // Unregister the menu command 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 Spotify tokens (same for both source & target) const spotifyTokens = await getSpotifyTokens(); SPOTIFY_USER_ID = spotifyTokens.usernameId; SPOTIFY_AUTH_TOKEN = spotifyTokens.accessToken; if (YT_SAPISIDHASH == undefined) await updateSAPISIDHASH(); if (source == 'Spotify') await checkSAPISIDHASH(); } }, { name: `Getting ${source} playlist songs`, op: async () => { // Playlist ID 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 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 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}`); } // Reset opIndex opIndex = 0; } } 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-${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 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; shelf = contents.find(content => content.musicShelfRenderer); // Find musicShelfRenderer if (shelf) { shelf = shelf.musicShelfRenderer; continuations = null; } else { return {items: null}; // No search results } } 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; } })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址