Suno Playlist Sorter

Shows the number of likes beside each music track on playlist pages on Suno.ai and allows sorting the playlist by likes.

  1. // ==UserScript==
  2. // @name Suno Playlist Sorter
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.0
  5. // @description Shows the number of likes beside each music track on playlist pages on Suno.ai and allows sorting the playlist by likes.
  6. // @author MahdeenSky
  7. // @match https://suno.com/playlist/*/
  8. // @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
  9. // @grant none
  10. // @license GNU GPLv3
  11. // ==/UserScript==
  12.  
  13. (function() {
  14. 'use strict';
  15.  
  16. const domain = "https://suno.com/";
  17. const songLink_xPath = `//div//p//a[contains(@class, "chakra-link")]`;
  18. const likes_xPath = `//button[contains(@class, "chakra-button")]`;
  19. const playPlaylistButton_xPath = `//div[descendant::img[contains(@alt, "Playlist cover art")]]//div//div//button[contains(@class, "chakra-button")]`;
  20.  
  21. let alreadyLikeFetched = {};
  22.  
  23. function setStyle(element, style) {
  24. for (let property in style) {
  25. element.style[property] = style[property];
  26. }
  27. }
  28.  
  29. function extractSongLink(songElement) {
  30. return songElement.getAttribute("href");
  31. }
  32.  
  33. function extractLikes(songLink) {
  34. return fetch(domain + songLink)
  35. .then(response => response.text())
  36. .then(html => {
  37. const parser = new DOMParser();
  38. const doc = parser.parseFromString(html, "text/html");
  39. const obfuscatedLikes = doc.evaluate(likes_xPath, doc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
  40. const likes = obfuscatedLikes.innerText.match(/;}(\d+)/)[1];
  41. return likes;
  42. })
  43. .catch(error => console.error(error));
  44. }
  45.  
  46. function addLikesToSongs() {
  47. const songSnapshots = document.evaluate(songLink_xPath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
  48. const promises = [];
  49.  
  50. for (let i = 0; i < songSnapshots.snapshotLength; i++) {
  51. const songElement = songSnapshots.snapshotItem(i);
  52. const songLink = extractSongLink(songElement);
  53.  
  54. if (alreadyLikeFetched[songLink]) {
  55. continue;
  56. }
  57.  
  58. const promise = extractLikes(songLink).then(likes => {
  59. const likesElement = document.createElement("span");
  60. likesElement.textContent = ` ${likes}`;
  61. fetchButtonDiv(songElement).then(buttonDiv => {
  62. buttonDiv.insertBefore(likesElement, buttonDiv.children[1]);
  63. alreadyLikeFetched[songLink] = likes;
  64. });
  65. });
  66. promises.push(promise);
  67. }
  68.  
  69. return Promise.allSettled(promises);
  70. }
  71.  
  72. function getKthParent(element, k) {
  73. let parent = element;
  74. for (let i = 0; i < k; i++) {
  75. parent = parent.parentNode;
  76. }
  77. return parent;
  78. }
  79.  
  80. function fetchButtonDiv(songElement) {
  81. return Promise.resolve(getKthParent(songElement, 4).children[1].querySelector("div > button"));
  82. }
  83.  
  84. function fetchLikesFromSongElement(songElement) {
  85. try {
  86. const likesElement = getKthParent(songElement, 4).children[1].querySelector("div > button").parentNode.querySelector("span");
  87. return Promise.resolve(likesElement ? likesElement.textContent : null);
  88. } catch (error) {
  89. return Promise.resolve(null);
  90. }
  91. }
  92.  
  93. function fetchSongGrid() {
  94. const songSnapshots = document.evaluate(songLink_xPath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
  95. const songElement = songSnapshots.snapshotItem(0);
  96. return Promise.resolve(getKthParent(songElement, 9));
  97. }
  98.  
  99. function sortSongsByLikes() {
  100. fetchSongGrid().then(songGrid => {
  101. let songRows = Array.from(songGrid.children);
  102. let songSnapshots = document.evaluate(songLink_xPath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
  103.  
  104. let songElements = [];
  105. for (let i = 0; i < songSnapshots.snapshotLength; i++) {
  106. let songElement = songSnapshots.snapshotItem(i);
  107. songElements.push(songElement);
  108. }
  109.  
  110. // check if the likes are already fetched, if not fetch them
  111. fetchLikesFromSongElement(songElements[songElements.length - 1]).then(likes => {
  112. if (likes === null) {
  113. addLikesToSongs().then(() => {
  114. sortSongsByLikes();
  115. });
  116. } else {
  117. let songElementsWithLikes = [];
  118. let promises = [];
  119. for (let i = 0; i < songElements.length; i++) {
  120. let songElement = songElements[i];
  121. let promise = fetchLikesFromSongElement(songElement).then(likes => {
  122. songElementsWithLikes.push({
  123. songElement: songElement,
  124. songRow: songRows[i],
  125. likes: likes
  126. });
  127. });
  128. promises.push(promise);
  129. }
  130.  
  131. Promise.all(promises).then(() => {
  132. // All promises have resolved, songElementsWithLikes is now fully populated
  133.  
  134. // sort the songElementsWithLikes array by likes
  135. songElementsWithLikes.sort((a, b) => {
  136. return parseInt(b.likes) - parseInt(a.likes);
  137. });
  138.  
  139. // replace each songRow with the sorted songRow
  140. // make a clone of the songGrid without the children
  141. let songGridClone = songGrid.cloneNode(false);
  142. for (let i = 0; i < songElementsWithLikes.length; i++) {
  143. songGridClone.appendChild(songElementsWithLikes[i].songRow);
  144. }
  145.  
  146. // replace the songGrid with the sorted songGrid
  147. songGrid.replaceWith(songGridClone);
  148. });
  149. }
  150. });
  151. });
  152. }
  153.  
  154. function addSortButton() {
  155. const button = document.createElement("button");
  156. button.textContent = "Sort by Likes";
  157. button.onclick = sortSongsByLikes;
  158. setStyle(button, {
  159. backgroundColor: "#4CAF50", // Green background
  160. border: "none",
  161. color: "white",
  162. padding: "10px 24px",
  163. textAlign: "center",
  164. textDecoration: "none",
  165. display: "inline-block",
  166. fontSize: "16px",
  167. margin: "4px 2px",
  168. cursor: "pointer",
  169. borderRadius: "8px", // Rounded corners
  170. boxShadow: "0 8px 16px 0 rgba(0,0,0,0.2), 0 6px 20px 0 rgba(0,0,0,0.19)" // Add a shadow
  171. });
  172.  
  173. const playlistPlayButton = document.evaluate(playPlaylistButton_xPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
  174. playlistPlayButton.parentNode.appendChild(button);
  175. }
  176.  
  177. function addLikesButton() {
  178. const button = document.createElement("button");
  179. button.textContent = "Show Likes";
  180. button.onclick = addLikesToSongs;
  181. setStyle(button, {
  182. backgroundColor: "#008CBA", // Blue background
  183. border: "none",
  184. color: "white",
  185. padding: "10px 24px",
  186. textAlign: "center",
  187. textDecoration: "none",
  188. display: "inline-block",
  189. fontSize: "16px",
  190. margin: "4px 2px",
  191. cursor: "pointer",
  192. borderRadius: "8px", // Rounded corners
  193. boxShadow: "0 8px 16px 0 rgba(0,0,0,0.2), 0 6px 20px 0 rgba(0,0,0,0.19)" // Add a shadow
  194. });
  195.  
  196. let playlistPlayButton = document.evaluate(playPlaylistButton_xPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
  197. playlistPlayButton.parentNode.appendChild(button);
  198. }
  199.  
  200. // add button when the playlistPlayButton is loaded
  201. let observer = new MutationObserver((mutations, observer) => {
  202. let playlistPlayButton = document.evaluate(playPlaylistButton_xPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
  203. if (playlistPlayButton) {
  204. addLikesButton();
  205. addSortButton();
  206. observer.disconnect();
  207. }
  208. });
  209.  
  210. observer.observe(document.body, {childList: true, subtree: true});
  211.  
  212. })();