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.

// ==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});

})();