// ==UserScript==
// @name Suno Playlist Sorter
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Shows the number of likes beside each music track on playlist pages on Suno.ai and allows sorting the playlist by likes.
// @author MahdeenSky
// @match https://suno.com/playlist/*/
// @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant none
// @license GNU GPLv3
// ==/UserScript==
(function() {
'use strict';
const domain = "https://suno.com/";
const songLink_xPath = `//div//p//a[contains(@class, "chakra-link")]`;
const likes_xPath = `//button[contains(@class, "chakra-button")]`;
const playPlaylistButton_xPath = `//div[descendant::img[contains(@alt, "Playlist cover art")]]//div//div//button[contains(@class, "chakra-button")]`;
let alreadyLikeFetched = {};
function setStyle(element, style) {
for (let property in style) {
element.style[property] = style[property];
}
}
function extractSongLink(songElement) {
return songElement.getAttribute("href");
}
function extractLikes(songLink) {
return fetch(domain + songLink)
.then(response => response.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const obfuscatedLikes = doc.evaluate(likes_xPath, doc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
const likes = obfuscatedLikes.innerText.match(/;}(\d+)/)[1];
return likes;
})
.catch(error => console.error(error));
}
function addLikesToSongs() {
const songSnapshots = document.evaluate(songLink_xPath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
const promises = [];
for (let i = 0; i < songSnapshots.snapshotLength; i++) {
const songElement = songSnapshots.snapshotItem(i);
const songLink = extractSongLink(songElement);
if (alreadyLikeFetched[songLink]) {
continue;
}
const promise = extractLikes(songLink).then(likes => {
const likesElement = document.createElement("span");
likesElement.textContent = ` ${likes}`;
fetchButtonDiv(songElement).then(buttonDiv => {
buttonDiv.insertBefore(likesElement, buttonDiv.children[1]);
alreadyLikeFetched[songLink] = likes;
});
});
promises.push(promise);
}
return Promise.allSettled(promises);
}
function getKthParent(element, k) {
let parent = element;
for (let i = 0; i < k; i++) {
parent = parent.parentNode;
}
return parent;
}
function fetchButtonDiv(songElement) {
return Promise.resolve(getKthParent(songElement, 4).children[1].querySelector("div > button"));
}
function fetchLikesFromSongElement(songElement) {
try {
const likesElement = getKthParent(songElement, 4).children[1].querySelector("div > button").parentNode.querySelector("span");
return Promise.resolve(likesElement ? likesElement.textContent : null);
} catch (error) {
return Promise.resolve(null);
}
}
function fetchSongGrid() {
const songSnapshots = document.evaluate(songLink_xPath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
const songElement = songSnapshots.snapshotItem(0);
return Promise.resolve(getKthParent(songElement, 9));
}
function sortSongsByLikes() {
fetchSongGrid().then(songGrid => {
let songRows = Array.from(songGrid.children);
let songSnapshots = document.evaluate(songLink_xPath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
let songElements = [];
for (let i = 0; i < songSnapshots.snapshotLength; i++) {
let songElement = songSnapshots.snapshotItem(i);
songElements.push(songElement);
}
// check if the likes are already fetched, if not fetch them
fetchLikesFromSongElement(songElements[songElements.length - 1]).then(likes => {
if (likes === null) {
addLikesToSongs().then(() => {
sortSongsByLikes();
});
} else {
let songElementsWithLikes = [];
let promises = [];
for (let i = 0; i < songElements.length; i++) {
let songElement = songElements[i];
let promise = fetchLikesFromSongElement(songElement).then(likes => {
songElementsWithLikes.push({
songElement: songElement,
songRow: songRows[i],
likes: likes
});
});
promises.push(promise);
}
Promise.all(promises).then(() => {
// All promises have resolved, songElementsWithLikes is now fully populated
// sort the songElementsWithLikes array by likes
songElementsWithLikes.sort((a, b) => {
return parseInt(b.likes) - parseInt(a.likes);
});
// replace each songRow with the sorted songRow
// make a clone of the songGrid without the children
let songGridClone = songGrid.cloneNode(false);
for (let i = 0; i < songElementsWithLikes.length; i++) {
songGridClone.appendChild(songElementsWithLikes[i].songRow);
}
// replace the songGrid with the sorted songGrid
songGrid.replaceWith(songGridClone);
});
}
});
});
}
function addSortButton() {
const button = document.createElement("button");
button.textContent = "Sort by Likes";
button.onclick = sortSongsByLikes;
setStyle(button, {
backgroundColor: "#4CAF50", // Green background
border: "none",
color: "white",
padding: "10px 24px",
textAlign: "center",
textDecoration: "none",
display: "inline-block",
fontSize: "16px",
margin: "4px 2px",
cursor: "pointer",
borderRadius: "8px", // Rounded corners
boxShadow: "0 8px 16px 0 rgba(0,0,0,0.2), 0 6px 20px 0 rgba(0,0,0,0.19)" // Add a shadow
});
const playlistPlayButton = document.evaluate(playPlaylistButton_xPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
playlistPlayButton.parentNode.appendChild(button);
}
function addLikesButton() {
const button = document.createElement("button");
button.textContent = "Show Likes";
button.onclick = addLikesToSongs;
setStyle(button, {
backgroundColor: "#008CBA", // Blue background
border: "none",
color: "white",
padding: "10px 24px",
textAlign: "center",
textDecoration: "none",
display: "inline-block",
fontSize: "16px",
margin: "4px 2px",
cursor: "pointer",
borderRadius: "8px", // Rounded corners
boxShadow: "0 8px 16px 0 rgba(0,0,0,0.2), 0 6px 20px 0 rgba(0,0,0,0.19)" // Add a shadow
});
let playlistPlayButton = document.evaluate(playPlaylistButton_xPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
playlistPlayButton.parentNode.appendChild(button);
}
// add button when the playlistPlayButton is loaded
let observer = new MutationObserver((mutations, observer) => {
let playlistPlayButton = document.evaluate(playPlaylistButton_xPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
if (playlistPlayButton) {
addLikesButton();
addSortButton();
observer.disconnect();
}
});
observer.observe(document.body, {childList: true, subtree: true});
})();