Deezer Blacklist Songs

Blacklists songs from being played in deezer

// ==UserScript==
// @name        Deezer Blacklist Songs
// @description Blacklists songs from being played in deezer
// @author      bertigert
// @version     1.1.0
// @icon        https://www.google.com/s2/favicons?sz=64&domain=deezer.com
// @namespace   Violentmonkey Scripts
// @match       https://www.deezer.com/*
// @grant       none
// ==/UserScript==


(function() {
    "use strict";
    class Logger {
        static LOG_VERY_MANY_THINGS_YES_YES = true; // set to false if you dont want the console getting spammed

        constructor() {
            this.log_textarea = null;
            this.PREFIXES = Object.freeze({
                INFO: "?",
                WARN: "⚠",
                ERROR: "!",
                SUCCESS: "*",
                CONSOLE: "[Blacklist Songs]"
            });
            this.console = {
                log: (...args) => console.log(this.PREFIXES.CONSOLE, ...args),
                warn: (...args) => console.warn(this.PREFIXES.CONSOLE, ...args),
                error: (...args) => console.error(this.PREFIXES.CONSOLE, ...args),
                debug: (...args) => {if (Logger.LOG_VERY_MANY_THINGS_YES_YES) console.debug(this.PREFIXES.CONSOLE, ...args)}
            };
        }
    }


    class Blacklist {
        static BLACKLIST_TYPES = Object.freeze({
            SONG: 0,
            ARTIST: 1
        });

        constructor(type=Blacklist.BLACKLIST_TYPES.SONG) {
            this.type = type === Blacklist.BLACKLIST_TYPES.ARTIST ? Blacklist.BLACKLIST_TYPES.ARTIST : Blacklist.BLACKLIST_TYPES.SONG;
            this.remote_blacklist = null;
            this.local_blacklist = null;
        }

        _get_id() {
            let data = dzPlayer.getCurrentSong();
            if (data) {
                logger.console.log(data);
                return this.type === Blacklist.BLACKLIST_TYPES.SONG ? data.SNG_ID : data.ART_ID;
            }

            const wait_for_getCurrentSong = setInterval(() => {
                data = dzPlayer.getCurrentSong();
                if (data) {
                    logger.console.log(data);
                    clearInterval(wait_for_getCurrentSong);
                    return this.type === Blacklist.BLACKLIST_TYPES.SONG ? data.SNG_ID : data.ART_ID;
                }
            }, 1);
        }

        get_local_blacklist() {
            return this.local_blacklist;
        }
        get_remote_blacklist() {
            return this.remote_blacklist;
        }
        get_blacklist () {
            return {
                local: this.local_blacklist,
                remote: this.remote_blacklist
            };
        }

        async load_blacklist() {
            this.remote_blacklist = await deezer.get_blacklisted_tracks_or_artists(this.type) || {};
            this.local_blacklist = local_config.config[`blacklisted_${this.type === Blacklist.BLACKLIST_TYPES.SONG ? "songs" : "artists"}`] || {};
        }

        is_local_blacklisted(id) {
            id = parseInt(id) || this._get_id();
            return this.local_blacklist[id] !== undefined;
        }
        is_remote_blacklisted(id) {
            id = parseInt(id) || this._get_id();
            return this.remote_blacklist[id] !== undefined;
        }
        is_blacklisted(id) {
            return this.is_local_blacklisted(id) || this.is_remote_blacklisted(id);
        }

        add(id, local=false) {
            id = parseInt(id) || this._get_id();
            if (this.is_blacklisted(id)) {
                logger.console.warn(`Element ${id} is already blacklisted.`);
                return false;
            }
            if (local) {
                this.local_blacklist[id] = 1;
            }
            logger.console.debug(`Added element ${id} to blacklist.`);
            return true;
        }
        remove(id, local=false) {
            id = parseInt(id) || this._get_id();
            if (!this.is_blacklisted(id)) {
                logger.console.warn(`Element ${id} was not blacklisted.`);
                return false;
            }
            if (local) {
                delete this.local_blacklist[id];
                local_config.save();
            }
            logger.console.debug(`Removed element ${id} from blacklist.`);
            return true;
        }
        // returns true if the element was blacklisted, false if it was unblacklisted, null if an error occurred
        toggle(id, local=false) {
            if (this.is_blacklisted(id)) {
                return this.remove(id, local) ? false : null;
            } else {
                return this.add(id, local) ? true : null;
            }
        }
    }


    class Deezer {
        constructor() {
            this.auth_token = null;
        }

        async get_auth_token() {
            const r = await fetch("https://auth.deezer.com/login/renew?jo=p&rto=c&i=c", {
                "method": "POST",
                "credentials": "include"
            });
            const resp = await r.json();
            this.auth_token = resp.jwt
            return resp.jwt;
        }

        async get_blacklisted_tracks_or_artists(type) {
            const strings = type === Blacklist.BLACKLIST_TYPES.ARTIST ? ["Artist", "artist", "ArtistBase"] : ["Track", "track", "TrackBase"];

            if (!this.auth_token) {
                await this.get_auth_token();
            }

            const fetch_batch = async (amount, cursor) => {
                const r = await fetch("https://pipe.deezer.com/api", {
                    "headers": {
                        "accept": "*/*",
                        "authorization": "Bearer " + this.auth_token,
                        "content-type": "application/json",
                    },
                    "body": JSON.stringify({
                        "operationName": `${strings[0]}ExclusionsTab`,
                        "variables": {
                            [`${strings[1]}First`]: Math.min(amount, 2000),
                            [`${strings[1]}Cursor`]: cursor
                        },
                        "query": `query ${strings[0]}ExclusionsTab($${strings[1]}First: Int, $${strings[1]}Cursor: String) {
                            me {
                                id
                                bannedFromRecommendation {
                                    ${strings[1]}s(first: $${strings[1]}First, after: $${strings[1]}Cursor) {
                                        pageInfo {
                                            hasNextPage
                                            endCursor
                                        }
                                        edges {
                                            node {
                                                ...${strings[2]}
                                            }
                                        }
                                    }
                                estimated${strings[0]}sCount
                                }
                            }
                        }
                        fragment ${strings[2]} on ${strings[0]} {
                            id
                        }`
                    }),
                    "method": "POST",
                });

                if (!r.ok) return null;
                const data = await r.json();
                if (data.errors && data.errors.some(error => error.type === "JwtTokenExpiredError")) {
                    await this.get_auth_token();
                    return fetch_batch(amount, cursor);
                }

                return data.data;
            };

            const all_items = [];
            let initial_data = await fetch_batch(0, null);

            const estimated_count = initial_data.me.bannedFromRecommendation[`estimated${strings[0]}sCount`] || 0;
            let remaining_count = estimated_count;

            let current_cursor = initial_data.me.bannedFromRecommendation[`${strings[1]}s`].pageInfo.endCursor;
            let has_next_page = initial_data.me.bannedFromRecommendation[`${strings[1]}s`].pageInfo.hasNextPage;

            while (has_next_page && remaining_count > 0) {
                const next_amount = Math.min(remaining_count, 2000);
                const batch_data = await fetch_batch(next_amount, current_cursor);

                const edges = batch_data.me.bannedFromRecommendation[`${strings[1]}s`].edges;
                edges.forEach(edge => {
                    all_items.push(edge.node.id);
                });

                remaining_count -= edges.length;
                current_cursor = batch_data.me.bannedFromRecommendation[`${strings[1]}s`].pageInfo.endCursor;
                has_next_page = batch_data.me.bannedFromRecommendation[`${strings[1]}s`].pageInfo.hasNextPage;
            }

            const tracks = {};
            all_items.forEach(id => {
                tracks[id] = 1;
            });
            return tracks;
        }
    }

    class Hooks {
        static HOOK_INDEXES = Object.freeze({
            SET_TRACKLIST: 0,
            FETCH: 1,
            ALL: 2
        });

        // we use this approach to unhook to avoid unhooking hooks created after our hooks
        static is_hooked = [false, false];

        static hook_set_tracklist() {
            const orig_set_tracklist = dzPlayer.setTrackList;
            dzPlayer.setTrackList = function (...args) {
                if (!Hooks.is_hooked[Hooks.HOOK_INDEXES.SET_TRACKLIST]) return orig_set_tracklist.apply(this, args);
                try {
                    let filtered_tracks = [];
                    const tracklist = args[0].data;
                    const orig_index = args[0].index;
                    for (let i = 0; i < tracklist.length; i++) {
                        const track = tracklist[i];
                        if (i === orig_index || (!config.Song_blacklist.is_blacklisted(track.SNG_ID) && !config.Artist_blacklist.is_blacklisted(track.ART_ID))) {
                            filtered_tracks.push(track);
                        } else {
                            // the tracklist is always the entire playlist/album and the index is the song the user clicked on,
                            // so if there is a blacklisted song before the current index, we need to adjust the index
                            if (i < orig_index) {
                                args[0].index--;
                            }
                        }
                    }
                    args[0].data = filtered_tracks;

                    return orig_set_tracklist.apply(this, args);
                } catch (error) {
                    logger.console.error("Error in setTrackList hook:", error);
                }
            };
        }

        static hook_fetch() {
            //logger.console.debug("Hooking window.fetch");
            const orig_fetch = window.fetch;
            function hooked_fetch(...args) {
                if (!Hooks.is_hooked[Hooks.HOOK_INDEXES.FETCH]) return orig_fetch.apply(this, args);
                // logger.console.debug("Fetch hook called with args:", args);
                try {
                    if (args.length !== 2 || args[1].method !== "POST" || !args[1].body) {
                        return orig_fetch.apply(this, args);
                    }
                    const orig_request = orig_fetch.apply(this, args); // async
                    if (args[0].startsWith("https://www.deezer.com/ajax/gw-light.php?method=favorite_dislike.add")) {
                        const payload = JSON.parse(args[1].body);
                        if (payload?.TYPE === "song") {
                            config.Song_blacklist.add(payload.ID);
                        } else if (payload?.TYPE === "artist") {
                            config.Artist_blacklist.add(payload.ID);
                        }
                    } else if (args[0].startsWith("https://www.deezer.com/ajax/gw-light.php?method=favorite_dislike.removeMulti")) {
                        const payload = JSON.parse(args[1].body);
                        if (payload?.TYPE === "song") {
                            payload.IDS.forEach(id => config.Song_blacklist.remove(id));
                        } else if (payload?.TYPE === "artist") {
                            payload.IDS.forEach(id => config.Artist_blacklist.remove(id));
                        }
                    } else if (args[0].startsWith("https://www.deezer.com/ajax/gw-light.php?method=favorite_dislike.remove")) {
                        const payload = JSON.parse(args[1].body);
                        if (payload?.TYPE === "song") {
                            config.Song_blacklist.remove(payload.ID);
                        } else if (payload?.TYPE === "artist") {
                            config.Artist_blacklist.remove(payload.ID);
                        }
                    }
                    return orig_request;
                } catch (error) {
                    logger.console.error("Error in fetch hook:", error);
                    return orig_fetch.apply(this, args);
                }
            }
            // only change the function which gets called, not the attributes of the original fetch function
            Object.setPrototypeOf(hooked_fetch, orig_fetch);
            Object.getOwnPropertyNames(orig_fetch).forEach(prop => {
                try {
                    hooked_fetch[prop] = orig_fetch[prop];
                } catch (e) {
                }
            });
            window.fetch = hooked_fetch;
            window.fetch._modified_by_blacklist_plugin = true;
        }

        static ensure_hooks() {
            if (!window.fetch._modified_by_blacklist_plugin) {
                Hooks.hook_fetch();
            }
            window.history.pushState = new Proxy(window.history.pushState, {
                apply: (target, thisArg, argArray) => {
                    if (!window.fetch._modified_by_blacklist_plugin) {
                        Hooks.hook_fetch();
                    }
                    return target.apply(thisArg, argArray);
            },
            });
            window.addEventListener("popstate", (e) => {
                if (!window.fetch._modified_by_blacklist_plugin) {
                    Hooks.hook_fetch();
                }
            });
        }

        static toggle_hooks(enabled, ...args) {
            for (const arg of args) {
                switch (arg) {
                    case Hooks.HOOK_INDEXES.ALL:
                        Hooks.is_hooked.fill(enabled);
                        return;
                    case Hooks.HOOK_INDEXES.FETCH:
                        Hooks.is_hooked[Hooks.HOOK_INDEXES.FETCH] = enabled;
                        break;
                    case Hooks.HOOK_INDEXES.SET_TRACKLIST:
                        Hooks.is_hooked[Hooks.HOOK_INDEXES.SET_TRACKLIST] = enabled;
                        break;
                }
            }
        }
    }


    class UI {
        static create_ui() {
            let parent_div = document.querySelector("#page_player > div > div.chakra-button__group")
            if (parent_div) {
                UI.create_css();
                parent_div.prepend(UI.create_main_button());
                logger.console.debug("UI created");
            } else {
                logger.console.debug("Waiting for parent");
                const observer = new MutationObserver(mutations => {
                    for (let mutation of mutations) {
                        if (mutation.type === 'childList') {
                            parent_div = document.querySelector("#page_player > div > div.chakra-button__group")
                            if (parent_div) {
                                observer.disconnect();
                                if (document.querySelector("button.blacklist_songs")) return;
                                UI.create_css();
                                parent_div.prepend(UI.create_main_button());
                                logger.console.debug("UI created");
                            }
                        }
                    }
                });
                observer.observe(document.body, {childList: true, subtree: true});
            }
        }

        static create_main_button() {
            const button = document.createElement("button");
            button.title = "Left-Click to (un-)blacklist the current song. Right-Click to (un-)blacklist the current artist. This does not set the song/artist as disliked, it's only applied locally. Useful for when you don't want to influence your algorithm.";
            button.className = "blacklist_songs";
            button.innerHTML = `<svg viewBox="0 0 24 24" focusable="false">
                <path fill-rule="evenodd"
                    d="M16 4.78v4.726l-.855-.252a14.771 14.771 0 0 0-4.182-.584c-.058 0-.116.002-.173.004a2.526 2.526 0 0 1-.123.004v7.986c0 2.142-1.193 3.336-3.334 3.336C5.193 20 4 18.806 4 16.665c0-2.142 1.193-3.336 3.333-3.336.806 0 1.476.17 2 .494V4.065l.629-.036c.33-.019.662-.029 1-.029a16.1 16.1 0 0 1 4.56.639l.478.14ZM5.333 16.664c0 1.403.598 2.002 2 2.002 1.402 0 2-.599 2-2.002 0-1.402-.598-2-2-2-1.402 0-2 .598-2 2Zm5.63-9.329c1.277 0 2.52.14 3.704.414V5.787a15.093 15.093 0 0 0-4-.45v2.001c.098-.002.197-.003.296-.003Z"
                    clip-rule="evenodd"></path>
                <path fill-rule="evenodd"
                    d="M16.5 13a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Zm-2.17 3.5c0 .357.086.694.239.99l2.922-2.921a2.17 2.17 0 0 0-3.16 1.931Zm2.17 2.17a2.15 2.15 0 0 1-.99-.239l2.921-2.922a2.17 2.17 0 0 1-1.931 3.16Z"
                    clip-rule="evenodd"></path>
            </svg>`;

            document.querySelector("#page_player > div > div.chakra-button__group").prepend(button);

            const onclick = (mouse_btn) => {
                const existing_popup = document.querySelector("span.blacklist_songs_popup");
                if (existing_popup) existing_popup.remove();

                const popup = UI.create_popup();
                button.parentElement.appendChild(popup);

                // check if it was left or right click
                let is_blacklisted_now = null;
                let popup_text = "";
                if (mouse_btn === 0) { // left click
                    is_blacklisted_now = config.Song_blacklist.toggle(config.Song_blacklist._get_id(), true);
                    popup_text = is_blacklisted_now ? "Blacklisted song." : "Unblacklisted song.";

                } else if (mouse_btn === 1) { // right click
                    is_blacklisted_now = config.Artist_blacklist.toggle(config.Artist_blacklist._get_id(), true);
                    popup_text = is_blacklisted_now ? "Blacklisted artist." : "Unblacklisted artist.";
                }

                if (is_blacklisted_now === null) {
                    logger.console.error("An error occurred while toggling the blacklist status of the song.");
                    popup_text = "Failed to toggle blacklist status.";
                }
                // dzPlayer.removeTracks(dzPlayer.getTrackListIndex());

                UI.show_popup(popup, popup_text, 2000, button.offsetLeft-button.clientWidth*1.25, button.offsetTop-button.clientHeight*1.25);
            }

            button.onclick = onclick.bind(button, 0);
            button.oncontextmenu = (e) => {
                e.preventDefault();
                onclick(1);
            };
            return button;
        }

        static create_popup() {
            const popup = document.createElement("span");
            popup.className = "blacklist_songs_popup";
            return popup;
        }
        static show_popup(popup, text, duration=2000, x, y) {
            popup.textContent = text;
            popup.style.left = `${x}px`;
            popup.style.top = `${y}px`;
            popup.style.opacity = "1";
            popup.style.animation = "fadeIn 0.2s linear";
            setTimeout(() => {
                UI.fade_out_popup(popup);
            }, duration);
        }
        static fade_out_popup(popup) {
            popup.style.opacity = "0";
            setTimeout(() => {
                popup.remove();
            }, 500);
        }

        static create_css() {
            const css = `
                .blacklist_songs_hidden {
                    display: none !important;
                }

                button.blacklist_songs {
                    display: inline-flex;
                    align-items: center;
                    justify-content: center;
                    position: relative;
                    min-height: var(--tempo-sizes-size-m);
                    min-width: var(--tempo-sizes-size-m);
                    color: var(--tempo-colors-text-neutral-primary-default);
                    background: var(--tempo-colors-transparent);
                    border-radius: var(--tempo-radii-full);
                }
                button.blacklist_songs:hover {
                    background: var(--tempo-colors-background-neutral-tertiary-hovered);
                    color: var(--tempo-colors-text-neutral-primary-hovered);
                }
                button.blacklist_songs:active {
                    color: var(--tempo-colors-icon-accent-primary-default);
                }
                button.blacklist_songs > svg {
                    width: 24px;
                    height: 24px;
                    fill: currentcolor;
                }

                span.blacklist_songs_popup {
                    height: fit-content;
                    width: fit-content;
                    position: absolute;
                    padding: 5px;
                    color: var(--tempo-colors-text-neutral-secondary-default);
                    font-size: 12px;
                    background-color: var(--tempo-colors-background-neutral-secondary-default);
                    border-radius: var(--tempo-radii-lg);
                    box-shadow: rgba(0, 0, 0, 0.4) 0px 0px 25px 10px, rgba(0, 0, 0, 0.04) 0px 10px 10px -5px;
                    z-index: 9999;
                    transition: opacity 0.5s linear;
                }
                @keyframes fadeIn {
                    from { opacity: 0; }
                    to { opacity: 1; }
                }
                @keyframes fadeOut {
                    from { opacity: 1; }
                    to { opacity: 0; }
                }
            `;
            const style = document.createElement("style");
            style.type = "text/css";
            style.textContent = css;
            document.querySelector("head").appendChild(style);
        }
    }

    class LocalConfig {
        static CONFIG_PATH = "blacklist_songs_config";
        CURRENT_CONFIG_VERSION = 1;

        StringConfig = class {
            // functions to traverse and edit a json based on string paths
            static get_value(obj, path) {
                return path.split(".").reduce((acc, key) => acc && acc[key], obj);
            }
            static set_key(obj, path, value) {
                let current = obj;
                const keys = path.split(".");
                keys.slice(0, -1).forEach(key => {
                    current[key] = current[key] ?? (/^\d+$/.test(key) ? [] : {});
                    current = current[key];
                });
                current[keys[keys.length - 1]] = value;
            }
            static delete_key(obj, path) {
                let current = obj;
                const keys = path.split(".");
                keys.slice(0, -1).forEach(key => {
                    if (!current[key]) return;
                    current = current[key];
                });
                delete current[keys[keys.length - 1]];
            }
            static move_key(obj, from, to) {
                const value = this.get_value(obj, from);
                if (value !== undefined) {
                    this.set_key(obj, to, value);
                    this.delete_key(obj, from);
                }
            }
        }

        constructor() {
            this.config = this.setter_proxy(this.get());
        }

        retrieve() {
            return JSON.parse(localStorage.getItem(LocalConfig.CONFIG_PATH)) || {
                config_version: this.CURRENT_CONFIG_VERSION,
                blacklisted_songs: {},
                blacklisted_artists: {}
            };
        }

        get() {
            const config = this.retrieve();
            if (config.config_version !== this.CURRENT_CONFIG_VERSION) {
                return this.migrate_config(config);
            }
            return config;
        }

        save() {
            localStorage.setItem(LocalConfig.CONFIG_PATH, JSON.stringify(this.config));
        }
        static static_save(config) {
            localStorage.setItem(LocalConfig.CONFIG_PATH, JSON.stringify(config));
        }

        setter_proxy(obj) {
            return new Proxy(obj, {
                set: (target, key, value) => {
                    target[key] = value;
                    this.save();
                    return true;
                },
                get: (target, key) => {
                    if (typeof target[key] === 'object' && target[key] !== null) {
                        return this.setter_proxy(target[key]); // Ensure nested objects are also proxied
                    }
                    return target[key];
                }
            });
        }

        migrate_config(config) {
            // patch structure
            // [from, to, ?value]
                // if both "from" and "to" exist, we change the path from "from" to "to"
                // if "from" is null, "value" is required as we create/update the key and set the value to "value"
                // if "to" is null, we delete the key
            const patches = [
                [],
                [
                    [null, "blacklisted_artists", {}]
                ]
            ]

            const old_cfg_version = config.config_version === undefined ? -1 : config.config_version;
            for (let patch = old_cfg_version+1; patch <= this.CURRENT_CONFIG_VERSION; patch++) {
                if (patch !== 0) { // we add the config_version key in the first patch
                    config.config_version++;
                }
                patches[patch].forEach(([from, to, value]) => {
                    if (from && to) {
                        this.StringConfig.move_key(config, from, to);
                    }
                    else if (!from && to) {
                        this.StringConfig.set_key(config, to, value);
                    }
                    else if (from && !to) {
                        this.StringConfig.delete_key(config, from);
                    }
                });
                logger.console.debug("Migrated to version", patch);
            }
            logger.console.log("Migrated config to version", this.CURRENT_CONFIG_VERSION);
            return config;
        }
    }

    const logger = new Logger();
    const deezer = new Deezer();
    const local_config = new LocalConfig();
    const config = {
        Song_blacklist: new Blacklist(Blacklist.BLACKLIST_TYPES.SONG),
        Artist_blacklist: new Blacklist(Blacklist.BLACKLIST_TYPES.ARTIST),
    };

    (async function main() {
        UI.create_ui();
        window.blacklist_plugin = config;
        await config.Song_blacklist.load_blacklist();
        await config.Artist_blacklist.load_blacklist();

        logger.console.log("Hooking dzplayer.setTrackList and window.fetch");
        const wait_for_dz_player_interval = setInterval(() => {
            if (window.dzPlayer) {
                clearInterval(wait_for_dz_player_interval);
                Hooks.toggle_hooks(true, Hooks.HOOK_INDEXES.ALL);
                Hooks.hook_set_tracklist();
                setTimeout(() => {
                    Hooks.hook_fetch();
                    setTimeout(Hooks.ensure_hooks, 5000);
                }, 1000);
            }
        }, 100);
    })();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址