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.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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

})();