YouTube / Spotify Playlists Converter

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

  1. // ==UserScript==
  2. // @name YouTube / Spotify Playlists Converter
  3. // @version 4.8
  4. // @description Convert your music playlists between YouTube & Spotify with a single click.
  5. // @author bobsaget1990
  6. // @match https://www.youtube.com/*
  7. // @match https://music.youtube.com/*
  8. // @match https://open.spotify.com/*
  9. // @grant GM_setValue
  10. // @grant GM_getValue
  11. // @grant GM_openInTab
  12. // @grant GM.xmlHttpRequest
  13. // @grant GM_registerMenuCommand
  14. // @grant GM_unregisterMenuCommand
  15. // @connect spotify.com
  16. // @connect youtube.com
  17. // @connect accounts.google.com
  18. // @icon64 https://i.imgur.com/zjGIQn4.png
  19. // @compatible chrome
  20. // @compatible edge
  21. // @compatible firefox
  22. // @license GNU GPLv3
  23. // @namespace https://greasyfork.org/users/1254768
  24. // ==/UserScript==
  25. (async () => {
  26. // UI FUNCTIONS:
  27. function createUI(operations) {
  28. function createSpanElements(textContent) {
  29. const spanElements = [];
  30. for (let i = 0; i < textContent.length; i++) {
  31. const span = document.createElement("span");
  32. span.textContent = textContent[i];
  33. span.classList.add(`op-${i}`);
  34. spanElements.push(span);
  35. }
  36. return spanElements;
  37. }
  38.  
  39. function createButton(className, textContent, clickHandler) {
  40. const button = document.createElement('button');
  41. button.classList.add(className);
  42. button.textContent = textContent;
  43. button.onclick = clickHandler;
  44. return button;
  45. }
  46.  
  47. function reloadPage() {
  48. location.reload();
  49. }
  50.  
  51. // Remove existing UI
  52. const existingUI = document.querySelector('div.floating-div');
  53. if (existingUI) existingUI.remove();
  54.  
  55. const floatingDiv = document.createElement('div');
  56. floatingDiv.classList.add('floating-div');
  57.  
  58. const centerDiv = document.createElement('div');
  59. centerDiv.classList.add('center-div');
  60.  
  61. const cancelButton = createButton('cancel-button', 'Cancel', reloadPage);
  62. const closeButton = createButton('close-button', '×', reloadPage); // Unicode character for the close symbol
  63.  
  64. // Add UI to the page
  65. document.body.appendChild(floatingDiv);
  66. floatingDiv.appendChild(centerDiv);
  67. floatingDiv.appendChild(cancelButton);
  68. floatingDiv.appendChild(closeButton);
  69. floatingDiv.style.display = 'flex';
  70.  
  71. // Add operations
  72. const spanElements = createSpanElements(operations);
  73. centerDiv.append(...spanElements);
  74.  
  75. // CSS
  76. const css = `
  77. .floating-div {
  78. position: fixed;
  79. top: 50%;
  80. left: 50%;
  81. transform: translate(-50%, -50%);
  82. z-index: 9999;
  83. width: 400px;
  84. height: auto;
  85. display: none;
  86. flex-direction: column;
  87. justify-content: space-between;
  88. align-items: center;
  89. border-radius: 10px;
  90. box-shadow: 0 0 0 1px #3a3a3a;
  91. background-color: #0f0f0f;
  92. line-height: 50px;
  93. }
  94.  
  95. .center-div span {
  96. display: block;
  97. height: 30px;
  98. margin: 10px;
  99. font-family: 'Roboto', sans-serif;
  100. font-size: 14px;
  101. color: white;
  102. opacity: 0.3;
  103. }
  104.  
  105. .cancel-button {
  106. width: auto;
  107. height: 30px;
  108. padding-left: 25px;
  109. padding-right: 25px;
  110. margin-top: 20px;
  111. margin-bottom: 20px;
  112. background-color: white;
  113. color: #0f0f0f;
  114. border-radius: 50px;
  115. border: unset;
  116. font-family: 'Roboto', sans-serif;
  117. font-size: 16px;
  118. }
  119.  
  120. .cancel-button:hover {
  121. box-shadow: inset 0px 0px 0 2000px rgba(0,0,0,0.25);
  122. }
  123.  
  124. .cancel-button:active {
  125. box-shadow: inset 0px 0px 0 2000px rgba(0,0,0,0.5);
  126. }
  127.  
  128. .close-button {
  129. position: absolute;
  130. top: 10px;
  131. right: 10px;
  132. width: 25px;
  133. height: 25px;
  134. border-radius: 50%;
  135. background-color: #393939;
  136. color: #7e7e7e;
  137. border: unset;
  138. font-family: math;
  139. font-size: 17px;
  140. text-align: center;
  141. }
  142.  
  143. .close-button:hover {
  144. box-shadow: inset 0px 0px 0 2000px rgba(255,255,255,0.05);
  145. }
  146.  
  147. .close-button:active {
  148. box-shadow: inset 0px 0px 0 2000px rgba(255,255,255,0.1);
  149. }`;
  150.  
  151. // Add the CSS to the page
  152. const style = document.createElement('style');
  153. style.textContent = css;
  154. document.head.appendChild(style);
  155.  
  156. return {
  157. floatingDiv: floatingDiv,
  158. centerDiv: centerDiv,
  159. cancelButton: cancelButton,
  160. closeButton: closeButton
  161. };
  162. }
  163.  
  164. // Fix 'TrustedHTML' assignment exception, ref: https://greasyfork.org/en/discussions/development/220765-this-document-requires-trustedhtml-assignment
  165. if (window.trustedTypes && window.trustedTypes.createPolicy) {
  166. try {
  167. window.trustedTypes.createPolicy('default', {
  168. createHTML: (string, sink) => string
  169. });
  170. } catch (error) {
  171. console.error(error);
  172. }
  173. }
  174.  
  175. function closeConfirmation(event) {
  176. event.preventDefault();
  177. event.returnValue = null;
  178. return null;
  179. }
  180.  
  181. function CustomError(obj) {
  182. this.response = obj.response;
  183. this.message = obj.message;
  184. this.details = obj.details;
  185. this.url = obj.url;
  186. this.popUp = obj.popUp;
  187. }
  188. CustomError.prototype = Error.prototype;
  189.  
  190. function errorHandler(error) {
  191. // Add parentheses if details is not empty
  192. const errorDetails = error.details ? `(${error.details})` : '';
  193. if (error.popUp) {
  194. alert(`⛔ ${error.message} ${errorDetails}`);
  195. }
  196. }
  197.  
  198.  
  199. // GLOBALS:
  200. let address = window.location.href;
  201. const subdomain = address.slice(8).split('.')[0];
  202. const playlistIdRegEx = {
  203. YouTube: /list=(.{34})/,
  204. Spotify: { playlist: /playlist\/(.{22})/, saved: /collection\/tracks/ }
  205. };
  206.  
  207. function addressChecker(address) {
  208. const isYouTube = address.includes('www.youtube.com');
  209. const isYouTubeMusic = address.includes('music.youtube.com');
  210. const isSpotify = address.includes('open.spotify.com');
  211.  
  212. const isYouTubePlaylist = (isYouTube || isYouTubeMusic) && playlistIdRegEx.YouTube.test(address);
  213.  
  214. const isSpotifyPlaylist = isSpotify && Object.values(playlistIdRegEx.Spotify).some(regex => regex.test(address));
  215. return {
  216. isYouTube,
  217. isYouTubeMusic,
  218. isYouTubePlaylist,
  219. isSpotify,
  220. isSpotifyPlaylist
  221. };
  222. }
  223.  
  224. let page = addressChecker(address);
  225.  
  226. function stringCleanup(input, options) {
  227. const defaultOptions = [
  228. 'removeSymbol',
  229. 'removeDiacritics',
  230. 'toLowerCase',
  231. 'removeBrackets',
  232. 'removeUnwantedChars',
  233. 'removeAllParentheses'
  234. ];
  235. // Use default options if none are passed
  236. options = options ? options : defaultOptions;
  237. const operations = {
  238. removeSymbol: inputString => inputString.replace(/・.+?(?=$|-)/,' '),
  239. removeDiacritics: inputString => inputString.normalize("NFKD").replace(/[\u0300-\u036f]/g, ''),
  240. toLowerCase: inputString => inputString.toLowerCase(),
  241. removeQuotes: inputString => inputString.replace(/"/g, ""),
  242. removeBrackets: inputString => inputString.replace(/(?:\[|【).+?(?:\]|】)/g, ''),
  243. removeAllParentheses: inputString => inputString.replace(/\([^)]+\)/g, ''),
  244. // Removes parentheses and its content if the content includes a space character, otherwise, just removes the parentheses
  245. removeParentheses: inputString => inputString.replace(/\(([^)]+)\)/g, (match, contents) => contents.includes(' ') ? '' : contents),
  246. removeDashes: inputString => inputString.replace(/(?<=\s)-(?=\s)/g, ''),
  247. removeUnwantedChars: inputString => inputString.replace(/[^\p{L}0-9\s&\(\)]+/ug, ''),
  248. removeUnwantedWords: inputString => {
  249. const unwantedWords = ['ft\\.?', 'feat\\.?', 'official'];
  250. const modifiedString = unwantedWords.reduce((str, pattern) => {
  251. const regex = new RegExp('\\b' + pattern + '(?!\w)', 'gi');
  252. return str.replace(regex, ' ');
  253. }, inputString);
  254. return modifiedString;
  255. }
  256. };
  257.  
  258. if (typeof input === 'string') {
  259. return cleanup(input, options);
  260. } else if (Array.isArray(input)) {
  261. return input.map(inputString => cleanup(inputString, options));
  262. } else {
  263. console.error('Invalid input type. Expected string or array of strings.');
  264. }
  265.  
  266. function cleanup(inputString, options) {
  267. try {
  268. for (const option of options) {
  269. if (operations[option]) {
  270. inputString = operations[option](inputString);
  271. }
  272. }
  273.  
  274. inputString = inputString.replace(/ {2,}/g, " ").trim(); // Remove extra spaces and trim
  275.  
  276. return inputString;
  277. } catch (error) {
  278. console.error(error);
  279. }
  280. }
  281. }
  282.  
  283. function compareArrays(arr1, arr2) {
  284. for (let item1 of arr1) {
  285. for (let item2 of arr2) {
  286. if (item1 === item2) return true;
  287. }
  288. }
  289. return false;
  290. }
  291.  
  292. const ENDPOINTS = {
  293. YOUTUBE: {
  294. GET_USER_ID: 'https://www.youtube.com/account',
  295. GET_PLAYLIST_CONTENT: `https://${subdomain}.youtube.com/youtubei/v1/browse`,
  296. MUSIC_SEARCH: 'https://music.youtube.com/youtubei/v1/search?key=&prettyPrint=false',
  297. CREATE_PLAYLIST: 'https://www.youtube.com/youtubei/v1/playlist/create?key=&prettyPrint=false'
  298. },
  299. SPOTIFY: {
  300. GET_USER_ID: 'https://api.spotify.com/v1/me',
  301. GET_AUTH_TOKEN: 'https://open.spotify.com/',
  302. SEARCH: 'https://api.spotify.com/v1/search',
  303. SEARCH_PROPRIETARY: 'https://api-partner.spotify.com/pathfinder/v1/query',
  304. GET_CONTENT: {
  305. PLAYLIST: 'https://api.spotify.com/v1/playlists/id/tracks',
  306. SAVED: 'https://api.spotify.com/v1/me/tracks',
  307. },
  308. CREATE_PLAYLIST: 'https://api.spotify.com/v1/users/userId/playlists',
  309. ADD_PLAYLIST: 'https://api.spotify.com/v1/playlists/playlistId/tracks',
  310. GET_LIKED_TRACKS: 'https://api.spotify.com/v1/me/tracks'
  311. }
  312. };
  313. const userAgent = navigator.userAgent + ',gzip(gfe)';
  314. const ytClient = {
  315. "userAgent": userAgent,
  316. "clientName": "WEB",
  317. "clientVersion": GM_getValue('YT_CLIENT_VERSION','2.20240123.06.00')
  318. };
  319. const ytmClient = {
  320. "userAgent": userAgent,
  321. "clientName": "WEB_REMIX",
  322. "clientVersion": GM_getValue('YTM_CLIENT_VERSION','1.20240205.00.00')
  323. };
  324. const goodSpotifyStatuses = [200, 201];
  325.  
  326. // Update YouTube client versions
  327. if (page.isYouTube || page.isYouTubeMusic) {
  328. const clientVersion = yt.config_.INNERTUBE_CLIENT_VERSION;
  329. const clientPrefix = page.isYouTube ? 'YT' : 'YTM';
  330. GM_setValue(`${clientPrefix}_CLIENT_VERSION`, clientVersion);
  331. console.log(`${clientPrefix}_CLIENT_VERSION:\n${clientVersion}`);
  332. }
  333.  
  334. let SPOTIFY_AUTH_TOKEN, SPOTIFY_USER_ID;
  335. const ytHashName = 'YT_SAPISIDHASH';
  336. const ytmHashName = 'YTM_SAPISIDHASH';
  337. let YT_SAPISIDHASH = await GM_getValue(ytHashName);
  338. let YTM_SAPISIDHASH = await GM_getValue(ytmHashName);
  339. let ytTokenFragment = '#get_yt_token';
  340.  
  341. const SAPISIDHASH_OPS = {
  342. UPDATE: async () => {
  343. async function getSAPISIDHASH(origin) {
  344. function sha1(str) {
  345. return window.crypto.subtle.digest("SHA-1", new TextEncoder("utf-8").encode(str)).then(buf => {
  346. return Array.prototype.map.call(new Uint8Array(buf), x => (('00' + x.toString(16)).slice(-2))).join('');
  347. });
  348. }
  349. const TIMESTAMP_MS = Date.now();
  350. const digest = await sha1(`${TIMESTAMP_MS} ${document.cookie.split('SAPISID=')[1].split('; ')[0]} ${origin}`);
  351. return `${TIMESTAMP_MS}_${digest}`;
  352. }
  353. if (page.isYouTube || page.isYouTubeMusic) {
  354. try {
  355. await GM_setValue(ytHashName, await getSAPISIDHASH('https://www.youtube.com'));
  356. await GM_setValue(ytmHashName, await getSAPISIDHASH('https://music.youtube.com'));
  357. YT_SAPISIDHASH = await GM_getValue(ytHashName);
  358. YTM_SAPISIDHASH = await GM_getValue(ytmHashName);
  359. if (address.includes(ytTokenFragment) && YT_SAPISIDHASH && YTM_SAPISIDHASH && await GM_getValue('closeTab')) window.close();
  360. } catch (error) {
  361. console.error(error);
  362. }
  363. }
  364. },
  365.  
  366. FETCH: async () => {
  367. await GM_setValue('closeTab', true);
  368. const ytTab = await GM_openInTab(`http://www.youtube.com/${ytTokenFragment}`, { active: false });
  369. // Create a new Promise that resolves when the tab is closed
  370. await new Promise(resolve => {
  371. ytTab.onclose = async () => {
  372. YT_SAPISIDHASH = await GM_getValue(ytHashName);
  373. YTM_SAPISIDHASH = await GM_getValue(ytmHashName);
  374. await GM_setValue('closeTab', false);
  375. resolve();
  376. };
  377. });
  378. },
  379.  
  380. VALIDATE: (SAPISIDHASH) => {
  381. if (SAPISIDHASH == undefined) return false;
  382. const timestamp = SAPISIDHASH.split('_')[0];
  383. const currentTime = Date.now();
  384. const limit = 3600000 * 12; // 3600000 (One hour in milliseconds)
  385. const hasNotExpired = currentTime - timestamp < limit;
  386. return hasNotExpired;
  387. }
  388. };
  389.  
  390. SAPISIDHASH_OPS.UPDATE();
  391.  
  392. // MENU SETUP:
  393. let MENU_COMMAND_ID, menuTitle, source, target;
  394. const callback = () => {
  395. page = addressChecker(window.location.href);
  396.  
  397. source = page.isYouTubePlaylist ? 'YouTube' : page.isSpotifyPlaylist ? 'Spotify' : source;
  398. target = page.isYouTubePlaylist ? 'Spotify' : page.isSpotifyPlaylist ? 'YouTube' : target;
  399.  
  400. if (page.isYouTubePlaylist || page.isSpotifyPlaylist) {
  401. if (MENU_COMMAND_ID) return; // If command already registered
  402. menuTitle = `🔄 ${source} to ${target} 🔄`;
  403. MENU_COMMAND_ID = GM_registerMenuCommand(menuTitle, () => { convertPlaylist(source, target); });
  404. } else {
  405. MENU_COMMAND_ID = GM_unregisterMenuCommand(MENU_COMMAND_ID);
  406. }
  407. };
  408. callback();
  409.  
  410. // Register/unregister menu functions on address change
  411. const observer = new MutationObserver(() => {
  412. if (location.href !== address) { // If address changes
  413. address = location.href;
  414. callback();
  415. }
  416. });
  417. observer.observe(document, {subtree: true, childList: true});
  418.  
  419.  
  420. // Cache functions
  421. function checkCache(cacheObj) {
  422. // Get cache values
  423. const CACHED_TRACKS = GM_getValue('CACHED_TRACKS', []);
  424. const CACHED_NOT_FOUND = GM_getValue('CACHED_NOT_FOUND', []);
  425. const CACHE_ID = GM_getValue('CACHE_ID', {});
  426.  
  427. const CACHED_INDEX = CACHED_TRACKS.length + CACHED_NOT_FOUND.length;
  428.  
  429. const cacheConditions = CACHED_INDEX > 3 &&
  430. CACHE_ID.PLAYLIST_ID === cacheObj.playlistId &&
  431. CACHE_ID.PLAYLIST_CONTENT === JSON.stringify(cacheObj.playlistContent);
  432.  
  433. // If cache conditions are met, return cached data
  434. if (cacheConditions) {
  435. return {
  436. tracks: CACHED_TRACKS,
  437. index: CACHED_INDEX
  438. };
  439. }
  440.  
  441. // If no matching cache is detected, set cache for current conversion
  442. GM_setValue('CACHE_ID', {
  443. PLAYLIST_ID: cacheObj.playlistId,
  444. PLAYLIST_CONTENT: JSON.stringify(cacheObj.playlistContent)
  445. });
  446.  
  447. return null;
  448. }
  449. function clearCache() {
  450. GM_setValue('CACHED_TRACKS', []);
  451. GM_setValue('CACHED_NOT_FOUND', []);
  452. }
  453.  
  454.  
  455.  
  456. let UI, ytUserId, operations;
  457. let opIndex = 0;
  458. async function convertPlaylist(source, target) {
  459. try {
  460. // Get the title of the playlist
  461. let playlistTitle = await getPlaylistTitle(source);
  462. console.log(`${source} Playlist Title:`, playlistTitle);
  463.  
  464. // User confirmation
  465. if (!confirm(`Convert "${playlistTitle}" to ${target}?`)) return;
  466.  
  467. // Add close tab confirmation
  468. window.addEventListener("beforeunload", closeConfirmation);
  469. // Unregister the menu command
  470. MENU_COMMAND_ID = GM_unregisterMenuCommand(MENU_COMMAND_ID);
  471.  
  472. // Set the operations variables
  473. let playlistContent, playlistId, totalTracks, newPlaylistId;
  474. let trackIds = [];
  475. let notFound = [];
  476. operations = [
  477. {
  478. name: `Getting YouTube & Spotify tokens`,
  479. op: async () => {
  480. // Get YouTube & Spotify tokens (required for both)
  481. const spotifyTokens = await getSpotifyTokens();
  482. SPOTIFY_USER_ID = spotifyTokens.usernameId;
  483. SPOTIFY_AUTH_TOKEN = spotifyTokens.accessToken;
  484.  
  485. if (!SAPISIDHASH_OPS.VALIDATE(YT_SAPISIDHASH)) source == 'Spotify' ? await SAPISIDHASH_OPS.FETCH() : await SAPISIDHASH_OPS.UPDATE();
  486. }
  487. },
  488. {
  489. name: `Getting ${source} playlist songs`,
  490. op: async () => {
  491. // Playlist ID
  492. playlistId = getPlaylistId(source);
  493. console.log(`${source} Playlist ID:`, playlistId);
  494. // User ID (Needed for YouTube multiple accounts)
  495. ytUserId = await getYtUserId();
  496. console.log('YouTube User ID:', ytUserId);
  497. // Playlist content
  498. playlistContent = await getPlaylistContent(source, playlistId);
  499. totalTracks = playlistContent.length;
  500. UI.centerDiv.querySelector(`.op-${opIndex}`).textContent = `${operations[opIndex].name} (${totalTracks})`;
  501. if (totalTracks == 0) {
  502. throw new CustomError({
  503. response: '',
  504. message: 'Could not get playlist info: The playlist is empty!',
  505. details: '', url: '', popUp: true
  506. });
  507. }
  508. console.log(`${source} Playlist Content:`, playlistContent);
  509. }
  510. },
  511. {
  512. name: `Converting songs to ${target}`,
  513. op: async () => {
  514. let index = 0;
  515. let notFoundString = '';
  516.  
  517. // Cache setup
  518. const cache = checkCache({
  519. playlistId: playlistId,
  520. playlistContent: playlistContent
  521. });
  522.  
  523. if (cache !== null) {
  524. if(confirm(`💾 ${cache.tracks.length} Saved songs detected, continue from there?`)) {
  525. trackIds = cache.tracks;
  526. index = cache.index;
  527. playlistContent = playlistContent.slice(index);
  528. UI.centerDiv.querySelector(`.op-${opIndex}`).textContent += ` (${index}/${totalTracks})`;
  529. } else {
  530. // Clear cache if user clicks 'Cancel'
  531. clearCache();
  532. }
  533. }
  534.  
  535. for (let [_, sourceTrackData] of playlistContent.entries()) {
  536. const targetTrackData = target == 'Spotify' ? await findOnSpotify(sourceTrackData) : await findOnYouTube(sourceTrackData);
  537.  
  538. if (targetTrackData) {
  539. const targetTrackId = targetTrackData.trackId;
  540. trackIds.push(targetTrackId);
  541. console.log(`✅ ${target} Track ID:`, targetTrackId);
  542. GM_setValue('CACHED_TRACKS', trackIds);
  543. } else {
  544. const sourceTrackTitle = sourceTrackData.title;
  545. notFound.push(sourceTrackTitle);
  546. console.warn(`NOT FOUND ON ${target.toUpperCase()}:`, sourceTrackTitle);
  547. GM_setValue('CACHED_NOT_FOUND', notFound);
  548. }
  549.  
  550. index++;
  551. notFoundString = notFound.length > 0 ? `(${notFound.length} not found)` : '';
  552. UI.centerDiv.querySelector(`.op-${opIndex}`).textContent = `${operations[opIndex].name} (${index}/${totalTracks}) ${notFoundString}`;
  553. }
  554. console.log(`${target} Tracks Found:`, trackIds);
  555. }
  556. },
  557. {
  558. name: `Adding playlist to ${target}`,
  559. op: async () => {
  560. // Create the playlist
  561. newPlaylistId = await createPlaylist(playlistTitle, trackIds, target);
  562. console.log(`${target} Playlist Created:`, newPlaylistId);
  563. }
  564. }
  565. ];
  566.  
  567. // Create the UI
  568. UI = createUI(operations.map(op => op.name));
  569.  
  570. for (const operation of operations) {
  571. UI.centerDiv.querySelector(`.op-${opIndex}`).style.opacity = 1;
  572.  
  573. await operation.op();
  574.  
  575. let doneEmoji = '✅';
  576. if (notFound.length && operation.name.includes('Converting songs')) {
  577. console.warn(`NOT FOUND ON ${target.toUpperCase()}:`, notFound);
  578. doneEmoji = '🟨';
  579. }
  580. UI.centerDiv.querySelector(`.op-${opIndex}`).textContent += ` ${doneEmoji}`;
  581.  
  582. opIndex++;
  583. }
  584.  
  585. // Update cancel & close buttons
  586. UI.cancelButton.onclick = () => {
  587. const url = target == 'Spotify' ? `https://open.${target.toLowerCase()}.com/playlist/${newPlaylistId}` : `https://www.${target.toLowerCase()}.com/playlist?list=${newPlaylistId}`;
  588. window.open(url);
  589. };
  590. UI.closeButton.onclick = () => {
  591. UI.floatingDiv.remove();
  592. };
  593. UI.cancelButton.style.backgroundColor = target == 'Spotify' ? '#1ed55f' : '#ff0000'; // Button background: Green, Red
  594. if (target == 'YouTube') UI.cancelButton.style.color = '#ffffff'; // Make text white
  595. UI.cancelButton.textContent = `Open in ${target}!`;
  596.  
  597. // Re-register the menu command
  598. MENU_COMMAND_ID = GM_registerMenuCommand(menuTitle, () => { convertPlaylist(source, target); });
  599. // Remove close tab confirmation
  600. window.removeEventListener("beforeunload", closeConfirmation);
  601. clearCache();
  602. // Alert not found songs
  603. if (notFound.length) {
  604. const notFoundList = notFound.join('\n• ');
  605. alert(`⚠️ Song(s) that could not be found on ${target}:\n ${notFoundList}`);
  606. }
  607. opIndex = 0;
  608. } catch (error) {
  609. console.error('🔄🔄🔄', error);
  610. errorHandler(error);
  611. }
  612. }
  613.  
  614.  
  615. // CONVERSION HELPER FUNCTIONS:
  616. async function getSpotifyTokens() {
  617. const getAccessToken = async () => {
  618. let htmlDoc = page.isSpotify ? document : undefined;
  619. if (page.isYouTube || page.isYouTubeMusic) {
  620. const tokenResponse = await GM.xmlHttpRequest({
  621. method: "GET",
  622. url: ENDPOINTS.SPOTIFY.GET_AUTH_TOKEN
  623. });
  624.  
  625. if (tokenResponse.status !== 200) {
  626. throw new CustomError({
  627. response: tokenResponse,
  628. message: 'Could not get Spotify token: Make sure you are signed in to Spotify and try again..',
  629. details: `Unexpected status code: ${tokenResponse.status}`,
  630. url: tokenResponse.finalUrl,
  631. popUp: true
  632. });
  633. }
  634. const tokenResponseText = await tokenResponse.responseText;
  635. const parser = new DOMParser();
  636. htmlDoc = parser.parseFromString(tokenResponseText, 'text/html');
  637. }
  638.  
  639. const sessionScript = htmlDoc.querySelector('script#session');
  640.  
  641. if (sessionScript == null) {
  642. throw new CustomError({
  643. response: '',
  644. message: 'Could not find Spotify session script..',
  645. details: '',
  646. url: '',
  647. popUp: true
  648. });
  649. }
  650.  
  651. const accessToken = JSON.parse(sessionScript.innerHTML).accessToken;
  652.  
  653. if (accessToken == undefined) {
  654. throw new CustomError({
  655. response: '',
  656. message: 'Spotify access token is unfefined..',
  657. details: '',
  658. url: '',
  659. popUp: true
  660. });
  661. }
  662.  
  663. return accessToken;
  664. };
  665.  
  666. const accessToken = await getAccessToken();
  667.  
  668. // Get the username ID
  669. const usernameResponse = await GM.xmlHttpRequest({
  670. method: 'GET',
  671. url: ENDPOINTS.SPOTIFY.GET_USER_ID,
  672. headers: {'Authorization': `Bearer ${accessToken}`}
  673. });
  674.  
  675. if (!goodSpotifyStatuses.includes(usernameResponse.status)) {
  676. throw new CustomError({
  677. response: usernameResponse,
  678. message: 'Could not get Spotify User ID: Make sure you are signed in to Spotify and try again..',
  679. details: `Unexpected status code: ${usernameResponse.status}`,
  680. url: usernameResponse.finalUrl,
  681. popUp: true
  682. });
  683. }
  684.  
  685. const usernameId = JSON.parse(usernameResponse.responseText).id;
  686. return {
  687. usernameId: usernameId,
  688. accessToken: accessToken
  689. };
  690. }
  691.  
  692. async function getPlaylistTitle(source) {
  693. // YouTube
  694. function getYtPlaylistTitle() {
  695. const staticPlaylistSelectors = ['.metadata-wrapper yt-formatted-string', '#contents .title', '[id^="page-header"] [class*="header-title"]'];
  696. const playingPlaylistSelectors = ['#header-description a[href*="playlist?list="]', '#tab-renderer .subtitle'];
  697.  
  698. const selectors = address.includes('watch?v=') ? playingPlaylistSelectors : staticPlaylistSelectors;
  699.  
  700. // Find the first matching element and return its text
  701. for (const selector of selectors) {
  702. const element = document.querySelector(selector);
  703. if (element) return element.innerText;
  704. }
  705. // If title element is undefined
  706. return 'YouTube Playlist';
  707. }
  708.  
  709. // Spotify
  710. function getSpotifyPlaylistTitle() {
  711. const element = document.querySelector('[data-testid="entityTitle"]');
  712. if (element) return element.innerText;
  713. // If title element is undefined
  714. return 'Spotify Playlist';
  715. }
  716.  
  717. return source == 'Spotify' ? getSpotifyPlaylistTitle() : getYtPlaylistTitle();
  718. }
  719.  
  720. function getPlaylistId(source) {
  721. // YouTube
  722. if (source == 'YouTube') {
  723. const match = address.match(playlistIdRegEx.YouTube);
  724. return match ? match[1] : null;
  725. }
  726.  
  727. // Spotify
  728. const spotifyCategories = Object.entries(playlistIdRegEx.Spotify);
  729. for (const [category, regex] of spotifyCategories) {
  730. const match = address.match(regex);
  731. if (match) return { [category]: match[1] || category };
  732. }
  733. }
  734.  
  735. async function getYtUserId() {
  736. const response = await GM.xmlHttpRequest({
  737. method: "GET",
  738. url: ENDPOINTS.YOUTUBE.GET_USER_ID,
  739. });
  740.  
  741. if (response.finalUrl !== ENDPOINTS.YOUTUBE.GET_USER_ID) {
  742. const finalUrlHostname = new URL(response.finalUrl).hostname;
  743. throw new CustomError({
  744. response: response,
  745. message: 'Could not get YouTube User ID: Make sure you are signed in to YouTube and try again..',
  746. details: `Unexpected final URL: ${finalUrlHostname}`,
  747. url: response.finalUrl,
  748. popUp: true
  749. });
  750. }
  751.  
  752. const userIdMatch = response.responseText.match(/myaccount\.google\.com\/u\/(\d)/);
  753.  
  754. // Return the user ID if found, or 0 otherwise
  755. return userIdMatch ? userIdMatch[1] : 0;
  756. }
  757.  
  758. async function getPlaylistContent(source, playlistId) {
  759. // Youtube
  760. async function getYtPlaylistContent(playlistId) {
  761. const requestUrl = ENDPOINTS.YOUTUBE.GET_PLAYLIST_CONTENT;
  762. const authorization = page.isYouTube ? `SAPISIDHASH ${YT_SAPISIDHASH}` : `SAPISIDHASH ${YTM_SAPISIDHASH}`;
  763. const headers = {
  764. "accept": "*/*",
  765. "authorization": authorization,
  766. "x-goog-authuser": ytUserId,
  767. };
  768. const context = {
  769. "client": ytmClient
  770. };
  771.  
  772. let tracksData = [];
  773. playlistId = 'VL' + playlistId;
  774.  
  775. let continuation;
  776. let requestParams = {
  777. requestUrl,
  778. headers,
  779. context,
  780. playlistId,
  781. continuation: null
  782. };
  783.  
  784. async function fetchListedItems({requestUrl, headers, context, playlistId, continuation}) {
  785. const url = continuation ? `${requestUrl}?ctoken=${continuation}&continuation=${continuation}&type=next&prettyPrint=false` : `${requestUrl}?key=&prettyPrint=false`;
  786. const body = JSON.stringify({
  787. "context": context,
  788. "browseId": playlistId
  789. });
  790.  
  791. return await fetch(url, {
  792. method: "POST",
  793. headers: headers,
  794. body: body
  795. });
  796. }
  797.  
  798. const response = await fetchListedItems(requestParams);
  799. if (!response.ok) {
  800. throw new CustomError({
  801. response: response,
  802. message: 'Could not get YouTube playlist info..',
  803. details: `Bad response: ${response.status}`,
  804. url: response.finalUrl,
  805. popUp: true
  806. });
  807. }
  808.  
  809. const responseJson = await response.json();
  810.  
  811. let parsedResponse = parseYtResponse(responseJson);
  812.  
  813. let index = parsedResponse.items.length;
  814. document.querySelector(`.op-${opIndex}`).textContent = `${operations[opIndex].name} (${index})`;
  815.  
  816. continuation = parsedResponse.continuation;
  817.  
  818. tracksData.push(...parsedResponse.items);
  819.  
  820. while (continuation) {
  821. requestParams.continuation = continuation;
  822.  
  823. const continuationResponse = await fetchListedItems(requestParams);
  824. if (!continuationResponse.ok) {
  825. throw new CustomError({
  826. response: continuationResponse,
  827. message: 'Could not get YouTube playlist info..',
  828. details: `Bad continuation response: ${continuationResponse.status}`,
  829. url: continuationResponse.finalUrl,
  830. popUp: true
  831. });
  832. }
  833.  
  834. const continuationResponseJson = await continuationResponse.json();
  835. parsedResponse = parseYtResponse(continuationResponseJson);
  836.  
  837. index += parsedResponse.items.length;
  838. document.querySelector(`.op-${opIndex}`).textContent = `${operations[opIndex].name} (${index})`;
  839.  
  840. continuation = parsedResponse.continuation;
  841.  
  842. tracksData.push(...parsedResponse.items);
  843. }
  844. return tracksData;
  845. }
  846.  
  847. // Spotify
  848. async function getSpotifyPlaylistContent(playlistId) {
  849. const [category, id] = Object.entries(playlistId)[0];
  850. const limit = category == 'playlist' ? 100 : 50;
  851. const offset = 0;
  852.  
  853. let requestUrl = category == 'playlist' ? ENDPOINTS.SPOTIFY.GET_CONTENT.PLAYLIST.replace('id', id) : ENDPOINTS.SPOTIFY.GET_CONTENT.SAVED;
  854.  
  855. let next = `${requestUrl}?offset=${offset}&limit=${limit}`;
  856. const tracksData = [];
  857.  
  858. const getPlaylistContent = async (url) => {
  859.  
  860. const response = await GM.xmlHttpRequest({
  861. method: "GET",
  862. url: url,
  863. headers: {
  864. 'Authorization': `Bearer ${SPOTIFY_AUTH_TOKEN}`,
  865. 'Content-Type': 'application/json'
  866. }
  867. });
  868.  
  869. if (!goodSpotifyStatuses.includes(response.status)) {
  870. throw new CustomError({
  871. response: response,
  872. message: 'Could not get Spotify playlist info..',
  873. details: `Error getting Spotify playlist content: ${response.status}`,
  874. url: ENDPOINTS.SPOTIFY.GET_PLAYLIST_CONTENT,
  875. popUp: true
  876. });
  877. }
  878.  
  879. const responseJson = JSON.parse(response.responseText);
  880.  
  881. const items = responseJson.items;
  882. for (const item of items) {
  883. const trackId = item.track.uri;
  884. const title = item.track.name;
  885. const artists = item.track.artists.map(artist => artist.name);
  886. const trackData = {
  887. trackId: trackId,
  888. title: title,
  889. artists: artists
  890. };
  891. tracksData.push(trackData);
  892. }
  893. return {next: responseJson.next, tracksData: tracksData};
  894. };
  895.  
  896. // Get the playlist content
  897. while (next) {
  898. const playlistContent = await getPlaylistContent(next);
  899. next = playlistContent.next;
  900. }
  901.  
  902. return tracksData;
  903. }
  904.  
  905. return source == 'Spotify' ? getSpotifyPlaylistContent(playlistId) : getYtPlaylistContent(playlistId);
  906. }
  907.  
  908. function parseYtResponse(responseJson) {
  909. responseJson = responseJson.contents ? responseJson.contents : responseJson;
  910. let shelf, continuations;
  911.  
  912. const responseType = {
  913. playlist: 'twoColumnBrowseResultsRenderer' in responseJson,
  914. continuation: 'continuationContents' in responseJson,
  915. search: 'tabbedSearchResultsRenderer' in responseJson
  916. };
  917.  
  918. // Get shelf based on response
  919. if (responseType.playlist) {
  920. shelf = responseJson.twoColumnBrowseResultsRenderer.secondaryContents.sectionListRenderer.contents[0].musicPlaylistShelfRenderer;
  921. continuations = shelf.continuations ? shelf.continuations[0].nextContinuationData.continuation : null;
  922. } else if (responseType.continuation) {
  923. shelf = responseJson.continuationContents.musicPlaylistShelfContinuation;
  924. continuations = shelf.continuations ? shelf.continuations[0].nextContinuationData.continuation : null;
  925. } else if (responseType.search) {
  926. const contents = responseJson.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents;
  927. shelf = contents.find(content => content.musicShelfRenderer); // Find musicShelfRenderer
  928. if (!shelf) return { items: null }; // No search results
  929. shelf = shelf.musicShelfRenderer;
  930. continuations = null;
  931. }
  932.  
  933. if (!shelf) {
  934. throw new CustomError({
  935. response: '',
  936. message: 'Error accessing YouTube response JSON values',
  937. details: '',
  938. url: '',
  939. popUp: false
  940. });
  941. }
  942.  
  943. const shelfContents = shelf.contents;
  944. const items = shelfContents.map(item => {
  945. try {
  946. const flexColumns = item.musicResponsiveListItemRenderer?.flexColumns;
  947. const column0 = flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer;
  948. const column1 = flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer;
  949. const textRuns = column0?.text?.runs[0];
  950. const endpoint = textRuns?.navigationEndpoint?.watchEndpoint;
  951. const configs = endpoint?.watchEndpointMusicSupportedConfigs?.watchEndpointMusicConfig;
  952.  
  953. const trackId = endpoint?.videoId;
  954. let mvType = configs?.musicVideoType;
  955. if (mvType) mvType = mvType.replace('MUSIC_VIDEO_TYPE_','');
  956. const title = textRuns?.text;
  957. const artistRuns = column1?.text?.runs;
  958. const artists = [];
  959. for (let artist of artistRuns) {
  960. if (artist.text == ' • ') break;
  961. if (artist.text != ' & ' && artist.text != ', ') artists.push(artist.text);
  962. }
  963. return {
  964. trackId: trackId,
  965. title: title,
  966. artists: artists,
  967. mvType: mvType
  968. };
  969. } catch (error) {
  970. console.error(error);
  971. }
  972. });
  973.  
  974. return {items: items, continuation: continuations};
  975. }
  976.  
  977. async function createPlaylist(playlistTitle, trackIds, target) {
  978. // Youtube
  979. async function createYtPlaylist(playlistTitle, trackIds) {
  980. const headers = {
  981. "authorization": `SAPISIDHASH ${YT_SAPISIDHASH}`,
  982. "x-goog-authuser": ytUserId,
  983. "x-origin": "https://www.youtube.com"
  984. };
  985.  
  986. const data = JSON.stringify({
  987. "context": {
  988. "client": ytClient
  989. },
  990. "title": playlistTitle,
  991. "videoIds": trackIds
  992. });
  993.  
  994. const response = await GM.xmlHttpRequest({
  995. method: "POST",
  996. url: ENDPOINTS.YOUTUBE.CREATE_PLAYLIST,
  997. headers: headers,
  998. data: data
  999. });
  1000.  
  1001. if (response.status !== 200) {
  1002. throw new CustomError({
  1003. response: response,
  1004. message: 'Could not create YouTube playlist..',
  1005. details: `Unexpected status code: ${response.status}`,
  1006. url: response.finalUrl,
  1007. popUp: true
  1008. });
  1009. }
  1010.  
  1011. const responseJson = JSON.parse(response.responseText);
  1012. return responseJson.playlistId;
  1013. }
  1014.  
  1015. // Spotify
  1016. async function createSpotifyPlaylist(playlistTitle) {
  1017. const requestUrl = ENDPOINTS.SPOTIFY.CREATE_PLAYLIST.replace('userId', SPOTIFY_USER_ID);
  1018.  
  1019. const createPlaylist = async (title) => {
  1020. const playlistData = JSON.stringify({
  1021. name: title,
  1022. description: '',
  1023. public: false,
  1024. });
  1025.  
  1026. const response = await GM.xmlHttpRequest({
  1027. method: "POST",
  1028. url: requestUrl,
  1029. headers: {
  1030. 'Authorization': `Bearer ${SPOTIFY_AUTH_TOKEN}`,
  1031. 'Content-Type': 'application/json'
  1032. },
  1033. data: playlistData
  1034. });
  1035.  
  1036. if (!goodSpotifyStatuses.includes(response.status)) {
  1037. throw new CustomError({
  1038. response: response,
  1039. message: 'Could not create Spotify playlist..',
  1040. details: `Unexpected status code: ${response.status}`,
  1041. url: ENDPOINTS.SPOTIFY.CREATE_PLAYLIST,
  1042. popUp: true
  1043. });
  1044. }
  1045.  
  1046. const responseJson = JSON.parse(response.responseText);
  1047. return responseJson.uri.replace('spotify:playlist:', '');
  1048. };
  1049.  
  1050. const playlistId = await createPlaylist(playlistTitle);
  1051. return playlistId;
  1052. }
  1053. async function addToSpotifyPlaylist(playlistId, trackIds) {
  1054. const requestUrl = ENDPOINTS.SPOTIFY.ADD_PLAYLIST.replace('playlistId', playlistId);
  1055.  
  1056. const addTracksToPlaylist = async (tracks) => {
  1057. const trackData = JSON.stringify({ uris: tracks });
  1058.  
  1059. const response = await GM.xmlHttpRequest({
  1060. method: "POST",
  1061. url: requestUrl,
  1062. headers: {
  1063. 'Authorization': `Bearer ${SPOTIFY_AUTH_TOKEN}`,
  1064. 'Content-Type': 'application/json'
  1065. },
  1066. data: trackData
  1067. });
  1068.  
  1069. if (!goodSpotifyStatuses.includes(response.status)) {
  1070. throw new CustomError({
  1071. response: response,
  1072. message: 'Could not add songs to Spotify playlist..',
  1073. details: `Unexpected status code: ${response.status}`,
  1074. url: ENDPOINTS.SPOTIFY.ADD_PLAYLIST,
  1075. popUp: true
  1076. });
  1077. }
  1078.  
  1079. return JSON.parse(response.responseText);
  1080. };
  1081.  
  1082. // Keep adding tracks until the array is empty
  1083. while (trackIds.length) {
  1084. const tracks = trackIds.splice(0, 100); // Get the first 100 tracks
  1085. await addTracksToPlaylist(tracks);
  1086. }
  1087. }
  1088.  
  1089. if (target == 'Spotify') {
  1090. const spotifyPLaylistId = await createSpotifyPlaylist(playlistTitle);
  1091. await addToSpotifyPlaylist(spotifyPLaylistId, trackIds);
  1092. return spotifyPLaylistId;
  1093. } else if (target == 'YouTube') {
  1094. const ytPLaylistId = await createYtPlaylist(playlistTitle, trackIds);
  1095. return ytPLaylistId;
  1096. }
  1097. }
  1098.  
  1099. async function searchYtMusic(queryObj) {
  1100. const { query, songsOnly } = queryObj;
  1101. const params = songsOnly ? 'EgWKAQIIAWoKEAMQBBAKEBEQEA%3D%3D' : 'EgWKAQIQAWoQEBAQERADEAQQCRAKEAUQFQ%3D%3D'; // Songs only id, Videos only id
  1102. const response = await GM.xmlHttpRequest({
  1103. method: "POST",
  1104. url: ENDPOINTS.YOUTUBE.MUSIC_SEARCH,
  1105. headers: {
  1106. "content-type": "application/json",
  1107. },
  1108. data: JSON.stringify({
  1109. "context": {
  1110. "client": ytmClient
  1111. },
  1112. "query": query,
  1113. "params": params
  1114. })
  1115. });
  1116. if (response.status !== 200) {
  1117. throw new CustomError({
  1118. response: response,
  1119. message: '',
  1120. details: `Error getting YouTube Music track data: ${response.status}`,
  1121. url: response.finalUrl,
  1122. popUp: false
  1123. });
  1124. }
  1125.  
  1126. const responseJson = JSON.parse(response.responseText);
  1127. const parsedResponse = parseYtResponse(responseJson);
  1128. const searchResults = parsedResponse.items;
  1129.  
  1130. return searchResults ? searchResults[0]: null;
  1131. }
  1132.  
  1133. async function findOnSpotify(trackData) {
  1134. async function searchSpotify(queryObj) {
  1135. let { query, topResultOnly } = queryObj;
  1136. const topResultQuery = `${query.title} ${query.artists}`;
  1137.  
  1138. // Define the functions to search Spotify
  1139. async function topResultRequest(topResultQuery) {
  1140. const variables = JSON.stringify({
  1141. "searchTerm": topResultQuery,
  1142. "offset": 0,
  1143. "limit": 10,
  1144. "numberOfTopResults": 10,
  1145. "includeAudiobooks": true,
  1146. "includeArtistHasConcertsField": false
  1147. });
  1148. const extensions = JSON.stringify({
  1149. "persistedQuery": {
  1150. "version": 1,
  1151. "sha256Hash": "c8e90ff103ace95ecde0bcb4ba97a56d21c6f48427f87e7cc9a958ddbf46edd8"
  1152. }
  1153. });
  1154.  
  1155. return await GM.xmlHttpRequest({
  1156. method: "GET",
  1157. url: `${ENDPOINTS.SPOTIFY.SEARCH_PROPRIETARY}?operationName=searchDesktop&variables=${encodeURIComponent(variables)}&extensions=${encodeURIComponent(extensions)}`,
  1158. headers: {
  1159. "accept": "application/json",
  1160. "authorization": `Bearer ${SPOTIFY_AUTH_TOKEN}`
  1161. },
  1162. data: null
  1163. });
  1164. }
  1165. async function apiSearchRequest(title, artists) {
  1166. return await GM.xmlHttpRequest({
  1167. method: "GET",
  1168. url: `${ENDPOINTS.SPOTIFY.SEARCH}?q=track:"${title}" artist:"${artists}"&type=track&offset=0&limit=1`,
  1169. headers: {
  1170. 'Authorization': `Bearer ${SPOTIFY_AUTH_TOKEN}`,
  1171. }
  1172. });
  1173. }
  1174.  
  1175. const response = topResultOnly ? await topResultRequest(topResultQuery) : await apiSearchRequest(query.title, query.artists);
  1176.  
  1177. if (!goodSpotifyStatuses.includes(response.status)) {
  1178. console.error(new CustomError({
  1179. response: response,
  1180. message: '',
  1181. details: `Error searching Spotify: ${response.status}`,
  1182. url: response.finalUrl,
  1183. popUp: false
  1184. }));
  1185. return null;
  1186. }
  1187.  
  1188. const responseJson = JSON.parse(response.responseText);
  1189. const searchItems = topResultOnly ? responseJson.data.searchV2.topResultsV2.itemsV2 : responseJson.tracks.items;
  1190.  
  1191. if (searchItems.length === 0) {
  1192. return null;
  1193. }
  1194.  
  1195.  
  1196. if (topResultOnly) {
  1197. const trackType = searchItems[0].item.data.__typename;
  1198. if (trackType !== "Track") return null;
  1199.  
  1200. const trackId = searchItems[0].item.data.uri;
  1201. const title = searchItems[0].item.data.name;
  1202. const artistsData = searchItems[0].item.data.artists.items;
  1203. const artists = artistsData.map(artist => artist.profile.name);
  1204.  
  1205. return {trackId: trackId, title: title, artists: artists};
  1206. } else {
  1207. const apiResults = searchItems.map(result => {
  1208. const trackId = result.uri;
  1209. const title = result.name;
  1210. const artistsData = result.artists;
  1211. const artists = artistsData.map(artist => artist.name);
  1212. return {trackId: trackId, title: title, artists: artists};
  1213. });
  1214. return apiResults ? apiResults[0]: null;
  1215. }
  1216. }
  1217.  
  1218. // Handling UGC YouTube songs
  1219. if (trackData.mvType === 'UGC') {
  1220. trackData.artists = [''];
  1221. const ytmSearchResult = await searchYtMusic({query: trackData.title, songsOnly: true});
  1222. if (ytmSearchResult) {
  1223. const cleanTitle = stringCleanup(trackData.title);
  1224. const cleanArtists = stringCleanup(ytmSearchResult.artists);
  1225. trackData = cleanTitle.includes(cleanArtists?.[0]) ? ytmSearchResult : trackData;
  1226. }
  1227. }
  1228.  
  1229. const modifiedTrackData = {
  1230. title: stringCleanup(trackData.title, ['removeDiacritics', 'removeBrackets', 'removeQuotes', 'removeParentheses', 'removeDashes', 'removeUnwantedWords']),
  1231. artists: stringCleanup(trackData.artists.join(' '), ['removeUnwantedWords'])
  1232. };
  1233.  
  1234. let spotifySearchResult;
  1235. let queries = [
  1236. {query: modifiedTrackData, topResultOnly: true},
  1237. {query: trackData, topResultOnly: true},
  1238. {query: trackData, topResultOnly: false}
  1239. ];
  1240.  
  1241. for (let query of queries) {
  1242. spotifySearchResult = await searchSpotify(query);
  1243. if (spotifySearchResult) break;
  1244. }
  1245.  
  1246. return spotifySearchResult || null;
  1247. }
  1248.  
  1249. async function findOnYouTube(trackData) {
  1250. const ytmQuery = `${trackData.title} ${trackData.artists[0]}`;
  1251.  
  1252. let ytmSearchResult = await searchYtMusic({query: ytmQuery, songsOnly: true});
  1253.  
  1254. if (ytmSearchResult) {
  1255. // Compare artists
  1256. const cleanArtists1 = stringCleanup([trackData?.artists[0]]);
  1257. const cleanArtists2 = stringCleanup(ytmSearchResult?.artists);
  1258. const artistsMatch = compareArrays(cleanArtists1, cleanArtists2);
  1259.  
  1260. // If YouTube Music songs only result is found and artists match
  1261. if (ytmSearchResult && artistsMatch) {
  1262. return ytmSearchResult;
  1263. }
  1264. }
  1265.  
  1266. // Try video only search if songs only search fails
  1267. ytmSearchResult = await searchYtMusic({query: ytmQuery, songsOnly: false});
  1268. return ytmSearchResult || null;
  1269. }
  1270. })();