1-click feed maintenance

Remove a video or channel from your homepage feed forever, even if you're not logged in to youtube.

Version au 29/11/2021. Voir la dernière version.

// ==UserScript==
// @name         1-click feed maintenance
// @namespace    https://greasyfork.org/en/scripts/436097-1-click-feed-maintenance
// @version      1.2
// @description  Remove a video or channel from your homepage feed forever, even if you're not logged in to youtube.
// @author       lwkjef
// @match        https://www.youtube.com/
// @icon         https://www.google.com/s2/favicons?domain=youtube.com
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_listValues
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js
// @license      MIT
// ==/UserScript==

/* eslint-env jquery */

// global constants
const buttonTag = 'button';
const divTag = 'div';
const inputTag = 'input';
const labelTag = 'label';
const checkboxType = 'checkbox';
const buttonType = 'button';

// youtube constants
const ytPopupContainerClass = 'ytd-popup-container';
const ytPaperDialogNodeName = 'tp-yt-paper-dialog';
const ytIronDropdownNodeName = 'tp-yt-iron-dropdown'

// internal element ids
const ocButtonId = 'oc-button';
const ocMenuId = 'oc-menu';
const ocMenuOpenButtonId = 'oc-menu-open-button';
const ocMenuCheckboxId = 'oc-menu-checkbox';
const ocMenuLabelId = 'oc-menu-label';
const ocMenuButtonContainerId = 'oc-menu-buttons';
const ocMenuExportButtonId = 'oc-menu-export-button';
const ocMenuSaveButtonId = 'oc-menu-save-button';
const ocMenuCancelButtonId = 'oc-menu-cancel-button';

// internal text
const ocMenuButtonText = '1-Click Config';
const ocMenuExportButtonText = 'Export';
const ocMenuSaveButtonText = 'Save';
const ocMenuCancelButtonText = 'Cancel';
const ocAlreadyWatchedButtonText = 'Already Watched';
const ocIDontLikeTheVideoButtonText = 'Block Video';
const ocDontRecommendText = 'Block Channel';
const ocDebugText = 'Debug';

// internal GM keys
const gmWatchedVideo = 'watched';
const gmDontLikeVideo = 'dontlike';
const gmDontRecommendChannel = 'dontrecommend';
const gmDebug = 'debug';
const gmAutomark = 'automark';
const gmHidepopups = 'hidepopups';

// youtube jQuery selectors
const ytPopupContainerSelector = 'ytd-popup-container';
const ytMastheadSelector = 'div#buttons.ytd-masthead';
const ytContentsGridSelector = '#contents.ytd-rich-grid-renderer';

const ytTellUsWhySelector = 'tp-yt-paper-button:contains("Tell us why")';
const ytRichItemWithoutOCButtonsSelector = `ytd-rich-item-renderer:not(:has(#${ocButtonId}))`;

const ytPaperDialogSelector = 'ytd-popup-container tp-yt-paper-dialog';
const ytPaperDialogIDontLikeTheVideoSelector = 'ytd-popup-container tp-yt-paper-dialog tp-yt-paper-checkbox:contains("I don\'t like the video")';
const ytPaperDialogIveAlreadyWatchedSelector = 'ytd-popup-container tp-yt-paper-dialog tp-yt-paper-checkbox:contains("I\'ve already watched the video")';
const ytPaperDialogSubmitSelector = 'ytd-popup-container tp-yt-paper-dialog tp-yt-paper-button:contains("Submit")';
const ytPaperDialogCancelSelector = 'ytd-popup-container tp-yt-paper-dialog tp-yt-paper-button:contains("Cancel")';

const ytIronDropdownButtonSelector = '#menu button';
const ytIronDropdownSelector = 'ytd-popup-container tp-yt-iron-dropdown';
const ytIronDropdownNotInterestedSelector = 'ytd-popup-container tp-yt-iron-dropdown tp-yt-paper-item:contains("Not interested")';
const ytIronDropdownDontRecommendSelector = 'ytd-popup-container tp-yt-iron-dropdown tp-yt-paper-item:contains("Don\'t recommend channel")';

// internal jQuery selectors
const ocMenuSelector = `#${ocMenuId}`;

(function(){
    'use strict'
    $(document).ready(function(){
        $(ytPopupContainerSelector).each(async function() {
            this.appendChild(await createOCMenu());
        });
        $(ytMastheadSelector).each(function() {
            this.appendChild(createButton(ocMenuOpenButtonId, ocMenuButtonText, ocMenuOpen));
        });
        $(ytContentsGridSelector).each(function() {
            const observer = new MutationObserver(function contentsMutated(mutationsList, observer) {
                for (const mutation of mutationsList) {
                    for (const addedNode of mutation.addedNodes) {
                        initNewRichItems(addedNode);
                    }
                }
            });
            observer.observe(this, {attributes: false, childList: true, subtree: false});
            initNewRichItems(this);
        });
    });
}())

async function createOCMenu() {
    const menu = document.createElement(divTag);
    menu.id = ocMenuId;
    menu.class = ytPopupContainerClass;
    menu.style.background = 'white';
    menu.style.position = 'fixed';
    menu.style.width = '200px';
    menu.style.height = '100px';
    menu.style.zIndex = 10000;
    menu.style.display = 'none';

    menu.appendChild(createCheckboxOCMenuItem(gmDebug));
    menu.appendChild(createCheckboxOCMenuItem(gmAutomark));
    menu.appendChild(createCheckboxOCMenuItem(gmHidepopups));

    const menuButtonContainer = createContainer(ocMenuButtonContainerId);
    menuButtonContainer.appendChild(createButton(ocMenuExportButtonId, ocMenuExportButtonText, ocMenuExport));
    menuButtonContainer.appendChild(createButton(ocMenuSaveButtonId, ocMenuSaveButtonText, ocMenuSave));
    menuButtonContainer.appendChild(createButton(ocMenuCancelButtonId, ocMenuCancelButtonText, ocMenuCancel));
    menu.appendChild(menuButtonContainer);

    return menu;
}

async function ocMenuOpen() {
    $(ocMenuSelector).each(function() {
        this.style.display = '';
        this.style.left = `${getClientWidth() / 2}px`;
        this.style.top = `${getClientHeight() / 2}px`;
    });
    await loadOCMenuValues();
}

async function ocMenuExport() {
    await exportGMValues();
    ocMenuClose();
}



async function exportGMValues() {
    const csv = await getGMValuesCSV();
    exportTextFile(csv);
}

/*
 * Example: 'key,value\nval1,val2'
 */
async function getGMValuesCSV() {
    let csv = 'key,value\n';
    for (const gmKey of await GM_listValues()) {
        csv += `${gmKey},${await GM_getValue(gmKey)}\n`;
    }
    return csv;
}

async function ocMenuSave() {
    await saveOCMenuValues();
    ocMenuClose();
}

async function ocMenuCancel() {
    await loadOCMenuValues();
    ocMenuClose();
}

async function saveOCMenuValues() {
    await saveOCMenuItemCheckbox(gmDebug);
    await saveOCMenuItemCheckbox(gmAutomark);
    await saveOCMenuItemCheckbox(gmHidepopups);
}

async function loadOCMenuValues() {
    await loadOCMenuItemCheckbox(gmDebug);
    await loadOCMenuItemCheckbox(gmAutomark);
    await loadOCMenuItemCheckbox(gmHidepopups);
}

function ocMenuClose() {
    $(ocMenuSelector).each(function() {
        this.style.display = 'none';
    });
}

function createCheckboxOCMenuItem(config) {
    const ocMenuItemContainer = createContainer(getMenuItemContainerId(config));
    const checkbox = document.createElement(inputTag);
    checkbox.id = ocMenuCheckboxId;
    checkbox.type = checkboxType;
    ocMenuItemContainer.appendChild(checkbox);
    const label = document.createElement(labelTag);
    label.id = ocMenuLabelId;
    label.innerHTML = config;
    ocMenuItemContainer.appendChild(label);
    return ocMenuItemContainer;
}

function initNewRichItems(node) {
    // iterate over each rich item contained in the given node that don't already have buttons
    $(node).find(ytRichItemWithoutOCButtonsSelector).each(async function() {
        // add oc buttons
        this.appendChild(createButton(ocButtonId, ocAlreadyWatchedButtonText, alreadyWatchedOnClick));
        this.appendChild(createButton(ocButtonId, ocIDontLikeTheVideoButtonText, iDontLikeTheVideoOnClick));
        this.appendChild(createButton(ocButtonId, ocDontRecommendText, dontRecommendOnClick));
        if (await GM_getValue(gmDebug) === 'true') {
            this.appendChild(createButton(ocButtonId, ocDebugText, debugOnClick));
        }

        // apply any persisted state
        await applyPersistedVideoIdState(this);
        await applyPersistedChannelIdState(this);
    });
}

async function applyPersistedVideoIdState(videoNode) {
    const videoId = getVideoId(videoNode);
    if (!videoId) {
        return;
    }
    const videoIdValue = await GM_getValue(videoId);
    if (videoIdValue === gmWatchedVideo) {
        if (await GM_getValue(gmAutomark) === 'true') {
            await awaitNodeAdded(ytIronDropdownButtonSelector, videoNode);
            await markAlreadyWatched(videoNode);
        }
        hide(videoNode);
    } else if (videoIdValue === gmDontLikeVideo) {
        if (await GM_getValue(gmAutomark) === 'true') {
            await awaitNodeAdded(ytIronDropdownButtonSelector, videoNode);
            await markDontLike(videoNode);
        }
        hide(videoNode);
    }
}

async function applyPersistedChannelIdState(videoNode) {
    const channelId = getChannelId(videoNode);
    if (!channelId) {
        return;
    }
    const channelIdValue = await GM_getValue(channelId);
    if (channelIdValue === gmDontRecommendChannel) {
        if (await GM_getValue(gmAutomark) === 'true') {
            await awaitNodeAdded(ytIronDropdownButtonSelector, videoNode);
            await markDontRecommend(videoNode);
        }
        hide(videoNode);
    }
}

/*
 * "already watched" oc button callback
 */
async function alreadyWatchedOnClick(e) {
    e.preventDefault();
    const videoNode = e.target.parentNode;
    const videoId = getVideoId(videoNode);
    if (videoId) {
        await GM_setValue(videoId, gmWatchedVideo);
    }
    await markAlreadyWatched(videoNode);
    hide(videoNode);
}

async function markAlreadyWatched(videoNode) {
    await openIronDropdown(videoNode);
    await clickNotInterested(videoNode);
    await clickTellUsWhy(videoNode);
    await clickAlreadyWatched(videoNode);
}

/*
 * "I dont like the video" oc button callback
 */
async function iDontLikeTheVideoOnClick(e) {
    e.preventDefault();
    const videoNode = e.target.parentNode;
    const videoId = getVideoId(videoNode);
    if (videoId) {
        await GM_setValue(videoId, gmDontLikeVideo);
    }
    await markDontLike(videoNode);
    hide(videoNode);
}

async function markDontLike(videoNode) {
    await openIronDropdown(videoNode);
    await clickNotInterested(videoNode);
    await clickTellUsWhy(videoNode);
    await clickIDontLikeTheVideo(videoNode);
}

/*
 * "dont recommend" oc button callback
 */
async function dontRecommendOnClick(e) {
    e.preventDefault();
    const videoNode = e.target.parentNode;
    const channelId = getChannelId(videoNode);
    if (channelId) {
        await GM_setValue(channelId, gmDontRecommendChannel);
    }
    await markDontRecommend(videoNode);
    hide(videoNode);
}

async function markDontRecommend(videoNode) {
    await openIronDropdown(videoNode);
    await clickDontRecommend(videoNode);
}

/*
 * "debug" oc button callback
 */
async function debugOnClick(e) {
    e.preventDefault();
    const videoNode = e.target.parentNode;
    log(`video id: ${getVideoId(videoNode)}`);
    log(`channel id: ${getChannelId(videoNode)}`);
}

/*
 * click menu button on the given video node, wait for iron dropdown to be added, and return iron
 * dropdown node.
 */
async function openIronDropdown(videoNode) {
    // if iron dropdown button is completely missing, then nothing to do
    if (!$(videoNode).find(ytIronDropdownButtonSelector).length) {
        warn(`iron dropdown button not found for video id ${getVideoId(videoNode)}`);
        return;
    }

    // hide iron dropdown, so it doesn't flicker when clicking oc buttons
    if (await GM_getValue(gmHidepopups) === 'true') {
        $(ytIronDropdownSelector).each(function() {
            hide(this);
        });
    }

    await doAndAwaitNodeAdded(ytIronDropdownNodeName, ytPopupContainerSelector, function() {
        $(videoNode).find(ytIronDropdownButtonSelector).trigger('click');
    });

    // try to hide again, in case iron dropdown didn't exist the first time
    if (await GM_getValue(gmHidepopups) === 'true') {
        $(ytIronDropdownSelector).each(function() {
            hide(this);
        });
    }
}

/*
 * click not interested button on the given iron dropdown node, and wait for iron dropdown to be removed.
 */
async function clickNotInterested(videoNode) {
    try {
        if (!$(ytIronDropdownNotInterestedSelector).length) {
            warn(`not interested button not found for video id ${getVideoId(videoNode)}`);
            return;
        }
        await doAndAwaitNodeRemoved(ytIronDropdownNodeName, ytPopupContainerSelector, function() {
            $(ytIronDropdownNotInterestedSelector).trigger('click');
        });
    } finally {
        await closeIronDropdown(videoNode);
    }
}

/*
 * click dont recommend channel button on the given iron dropdown node, and wait for iron dropdown to be
 * removed.
 */
async function clickDontRecommend(videoNode) {
    try {
        if (!$(ytIronDropdownDontRecommendSelector).length) {
            warn(`dont recommend button not found for video id ${getVideoId(videoNode)}`);
            return;
        }
        await doAndAwaitNodeRemoved(ytIronDropdownNodeName, ytPopupContainerSelector, function() {
            $(ytIronDropdownDontRecommendSelector).trigger('click');
        });
    } finally {
        await closeIronDropdown(videoNode);
    }
}

/*
 * click tell us why button on the given video node, wait for paper dialog to be added, and return paper
 * dialog node.
 */
async function clickTellUsWhy(videoNode) {
    // if paper dialog button is completely missing, then nothing to do
    if (!$(videoNode).find(ytTellUsWhySelector).length) {
        warn(`tell us why button not found for video id ${getVideoId(videoNode)}`);
        return;
    }

    // hide paper dialog, so it doesn't flicker when clicking oc buttons
    if (await GM_getValue(gmHidepopups) === 'true') {
        $(ytPaperDialogSelector).each(function() {
            hide(this);
        });
    }

    await doAndAwaitNodeAdded(ytPaperDialogNodeName, ytPopupContainerSelector, function() {
        $(videoNode).find(ytTellUsWhySelector).trigger('click');
    });

    // try to hide again, in case paper dialog didn't exist the first time
    if (await GM_getValue(gmHidepopups) === 'true') {
        $(ytPaperDialogSelector).each(function() {
            hide(this);
        });
    }
}

/*
 * click I dont like the video button on the given paper dialog node, click submit button on paper dialog,
 * and wait for paper dialog to be removed.
 */
async function clickIDontLikeTheVideo(videoNode) {
    try {
        if (!$(ytPaperDialogIDontLikeTheVideoSelector).length) {
            warn(`i dont like the video button not found for video id ${getVideoId(videoNode)}`);
            return;
        }
        await doAndAwaitNodeRemoved(ytPaperDialogNodeName, ytPopupContainerSelector, function() {
            $(ytPaperDialogIDontLikeTheVideoSelector).trigger('click');
            $(ytPaperDialogSubmitSelector).trigger('click');
        });
    } finally {
        await closePaperDialog();
    }
}

/*
 * click Ive already watched the video button on the given paper dialog node, click submit button on paper
 * dialog, and wait for paper dialog to be removed.
 */
async function clickAlreadyWatched(videoNode) {
    try {
        if (!$(ytPaperDialogIveAlreadyWatchedSelector).length) {
            warn(`ive already watched the video button not found for video id ${getVideoId(videoNode)}`);
            return;
        }
        await doAndAwaitNodeRemoved(ytPaperDialogNodeName, ytPopupContainerSelector, function() {
            $(ytPaperDialogIveAlreadyWatchedSelector).trigger('click');
            $(ytPaperDialogSubmitSelector).trigger('click');
        });
    } finally {
        await closePaperDialog();
    }
}

async function awaitNodeAdded(nodeSelector, ancestorSelector) {
    let node;
    let observer;
    await new Promise(resolve => {
        observer = new MutationObserver(function(mutationsList, observer) {
            $(ancestorSelector).find(nodeSelector).each(function() {
                node = this;
                resolve(this);
            });
        });
        $(ancestorSelector).each(function() {
            observer.observe(this, {attributes: true, childList: true, subtree: true});

            // handle race condition where node added while initializing observer
            $(ancestorSelector).find(nodeSelector).each(function() {
                node = this;
                resolve(this);
            });
        });
    });
    observer.disconnect();
    return node;
}

/*
 * Execute the given function, then wait until node is added. ancestorSelector MUST exist and should be as
 * close as possible to target node for efficiency.
 */
async function doAndAwaitNodeAdded(nodeName, ancestorSelector, f) {
    let node;
    let observer;
    await new Promise(resolve => {
        observer = new MutationObserver(function(mutationsList, observer) {
            for (const mutation of mutationsList) {
                if (mutation.type === 'childList') {
                    for (const addedNode of mutation.addedNodes) {
                        if (equalsIgnoreCase(addedNode.nodeName, nodeName) &&
                            isDisplayed(addedNode)) {
                            node = addedNode;
                            resolve(addedNode);
                        }
                    }
                } else if (mutation.type === 'attributes') {
                    if (equalsIgnoreCase(mutation.target.nodeName, nodeName) &&
                        isDisplayed(mutation.target)) {
                        node = mutation.target;
                        resolve(mutation.target);
                    }
                }
            }
        });
        $(ancestorSelector).each(function() {
            observer.observe(this, {attributes: true, childList: true, subtree: true});
        });
        f();
    });
    observer.disconnect();
    return node;
}

/*
 * Execute the given function, then wait until given node is removed. ancestorSelector MUST exist and
 * should be as close as possible to target node for efficiency.
 */
async function doAndAwaitNodeRemoved(nodeName, ancestorSelector, f) {
    let observer;
    await new Promise(resolve => {
        observer = new MutationObserver(function(mutationsList, observer) {
            for (const mutation of mutationsList) {
                if (mutation.type === 'childList') {
                    for (const removedNode of mutation.removedNodes) {
                        if (equalsIgnoreCase(removedNode.nodeName, nodeName)) {
                            resolve(removedNode);
                        }
                    }
                } else if (mutation.type === 'attributes') {
                    if (equalsIgnoreCase(mutation.target.nodeName, nodeName) &&
                        (!isDisplayed(mutation.target))) {
                        resolve(mutation.target);
                    }
                }
            }
        });
        $(ancestorSelector).each(function() {
            observer.observe(this, {attributes: true, childList: true, subtree: true});
        });
        f();
    });
    observer.disconnect();
}

async function reflow() {
}

function getClientWidth() {
    return (window.innerWidth ||
            document.documentElement.clientWidth ||
            document.body.clientWidth);
}

function getClientHeight() {
    return (window.innerHeight ||
            document.documentElement.clientHeight ||
            document.body.clientHeight);
}

function getVideoId(videoNode) {
    const videoTitleLink = $(videoNode).find('a#video-title-link')[0];
    if (!videoTitleLink) {
        return null;
    }

    let match = videoTitleLink.href.match(/\/watch\?v=([^&]*)/);
    if (match) {
        return match[1];
    }

    return null;
}

function getChannelId(videoNode) {
    const channelName = $(videoNode).find('ytd-channel-name a.yt-simple-endpoint')[0];
    if (!channelName) {
        return null;
    }

    let match = channelName.href.match(/\/c\/([^&]*)/);
    if (match) {
        return match[1];
    }

    match = channelName.href.match(/\/channel\/([^&]*)/);
    if (match) {
        return match[1];
    }

    match = channelName.href.match(/\/user\/([^&]*)/);
    if (match) {
        return match[1];
    }

    return null;
}

/*
 * click iron dropdown button on the given video node, and wait for iron dropdown to be removed
 */
async function closeIronDropdown(videoNode) {
    await $(ytIronDropdownSelector).each(async function() {
        unhide(this); // reset visibility in case hidepopups is enabled
        while (isDisplayed(this)) {
            $('ytd-app').trigger('click');
            await delay(1000);
            /*
            await doAndAwaitNodeRemoved(ytIronDropdownNodeName, ytPopupContainerSelector, function() {
                $(videoNode).find(ytIronDropdownButtonSelector).trigger('click');
            });
            */
        }
    });
}

/*
 * click cancel button on the paper dialog, and wait for paper dialog to be removed
 */
async function closePaperDialog() {
    await $(ytPaperDialogSelector).each(async function() {
        unhide(this); // reset visibility in case hidepopups is enabled
        while (isDisplayed(this)) {
            $('ytd-app').trigger('click');
            await delay(1000);
            /*
            await doAndAwaitNodeRemoved(ytPaperDialogNodeName, ytPopupContainerSelector, function() {
                $(ytPaperDialogCancelSelector).trigger('click');
            });
            */
        }
    });
}

async function loadOCMenuItemCheckbox(gmKey) {
    $(getOCMenuItemCheckboxSelector(gmKey))[0].checked = await GM_getValue(gmKey) === 'true';
}

async function saveOCMenuItemCheckbox(gmKey) {
    await GM_setValue(gmKey, $(getOCMenuItemCheckboxSelector(gmKey))[0].checked.toString());
}

function getOCMenuItemCheckboxSelector(gmKey) {
    return `#${getMenuItemContainerId(gmKey)} #${ocMenuCheckboxId}`;
}

function getMenuItemContainerId(gmKey) {
    return `oc-menu-container-${gmKey}`
}

function createContainer(id) {
    const container = document.createElement(divTag);
    container.id = id;
    return container;
}

/*
 * create and return a new button with the given id, text, onclick callback, and css style
 */
function createButton(id, text, onclick, cssObj = {}) {
    const button = document.createElement(buttonTag);
    button.id = id;
    button.type = buttonType;
    button.innerHTML = text;
    button.onclick = onclick;
    Object.keys(cssObj).forEach(key => {button.style[key] = cssObj[key]});
    return button;
}

/*
 * true if node is displayed, false otherwise.
 */
function isDisplayed(node) {
    return node.style.display !== 'none';
}

function hide(node) {
    node.style.visibility = 'hidden';
}

function unhide(node) {
    node.style.visibility = '';
}

function log(text) {
    console.log(`1-click feed maintenance userscript: ${text}`);
}

function warn(text) {
    console.warn(`1-click feed maintenance userscript: ${text}`);
}

async function delay(duration_ms) {
    await new Promise((resolve, reject) => {
        setTimeout(_ => resolve(), duration_ms)
    });
}

/*
 * true if strings lowercased are equivalent, false otherwise.
 */
function equalsIgnoreCase(one, two) {
    return one.toLowerCase() === two.toLowerCase();
}

/*
 * Example: exportFile('col1,col2\nval1,val2', 'text/csv');
 */
function exportTextFile(data, type='text/csv', charset='utf-8', filename='data.csv') {
    const objectURL = createObjectURL(data, type, charset);

    const a = document.createElement('a');
    a.href = objectURL;
    //supported by chrome 14+ and firefox 20+
    a.download = filename;
    const body = document.getElementsByTagName('body')[0];
    //needed for firefox
    body.appendChild(a);
    //supported by chrome 20+ and firefox 5+
    a.click();

    // clean up
    body.removeChild(a);
    window.URL.revokeObjectURL(objectURL);
}

/*
 * type may be 'text/csv', 'text/html', 'text/vcard', 'text/txt', 'application/csv', etc.
 *
 * NOTE: caller must manually revoke the returned object URL to avoid memory leaks:
 *
 * if (textFile !== null) {
 *     window.URL.revokeObjectURL(textFile);
 * }
 */
function createObjectURL(data, type='text/csv', charset='utf-8') {
    return window.URL.createObjectURL(new Blob([data], {type: type}));
    //return `data:${type};charset=${charset},${encodeURIComponent(data)}`
}