1-click feed maintenance

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         1-click feed maintenance
// @namespace    https://greasyfork.org/en/scripts/436097-1-click-feed-maintenance
// @version      4.0.0
// @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/
// @match        https://www.youtube.com/?*
// @match        https://www.youtube.com/watch?*
// @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.7.0/jquery.min.js
// @license      MIT
// ==/UserScript==

/* eslint-env jquery */

// script constants
const scriptName = '1-Click Feed Maintenance Userscript';
const mainPollPeriod_ms = 1000;

(function(){
    'use strict'
    $(document).ready(async function(){
        if (isWatchPage()) {
            if (await GM_getValue(gmTrackWatched) === 'true') {
                const videoId = getWatchPageVideoId();
                if (!videoId) {
                    return;
                }
                await debug(`watch page video id ${videoId}`);
                await rememberWatchedVideo(videoId);
            }
        } else {
            setInterval(mainPollCallback, mainPollPeriod_ms);
        }
    });
}())

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

async function mainPollCallback() {
    await addMenuToMasthead();
    await addButtonsToVideoNodes();
    await autohideVideoNodes();
    await automarkVideoNodes();
}

// =========================
// Mutex (https://blog.jcoglan.com/2016/07/12/mutexes-and-javascript/)
// =========================

let Mutex = class {
    constructor() {
        this._busy = false;
        this._queue = [];
    }
};

Mutex.prototype.synchronize = function(task) {
    this._queue.push(task);
    if (!this._busy) this._dequeue();
};

Mutex.prototype._dequeue = function() {
    this._busy = true;
    var next = this._queue.shift();

    if (next) {
        this._execute(next);
    } else {
        this._busy = false;
    }
};

Mutex.prototype._execute = function(task) {
    var self = this;

    task().then(function() {
        self._dequeue();
    }, function() {
        self._dequeue();
    });
};

const actionMutex = new Mutex();

// =================================
// 1-Click Feed Maintenance Features
// =================================

// internal GM keys
const gmWatchedVideo = 'watched';
const gmDontLikeVideo = 'dontlike';
const gmDontRecommendChannel = 'dontrecommend';
const gmAutomark = 'automark';
const gmHidepopups = 'hidepopups';
const gmHidelists = 'hidelists';
const gmHidelivestreams = 'hidelivestreams';
const gmHidefundraisers = 'hidefundraisers';
const gmTrackWatched = 'trackwatched';

async function addMenuToMasthead() {
    $(ytPopupContainerSelector).each(async function() {
        if (!$(this).find(`#${ocMenuId}`)[0]) {
            this.appendChild(await createOCMenu());
        }
    });
    $(ytMastheadSelector).each(async function() {
        if (!$(this).find(`#${ocMenuOpenButtonId}`)[0]) {
            this.appendChild(await createButton(ocMenuOpenButtonId, ocMenuButtonText, ocMenuOpen));
        }
    });
}

/*
 * Search the children of the given node for rich items that lack 1-Click Feed Maintenance
 * controls, and add controls to them.
 */
async function addButtonsToVideoNodes() {
    // iterate over each rich item contained in the given node that don't already have buttons
    $(ytContentsGridSelector).find(videoNodeWithoutOCButtonsSelector).each(async function() {
        if (!isShown(this)) {
            await error(`selected video node that is not shown: ${getVideoId(this)}`);
            return;
        }

        if (!$(this).find(`#${ocAlreadyWatchedButtonId}`)[0]) {
            this.appendChild(await createButton(ocAlreadyWatchedButtonId, ocAlreadyWatchedButtonText, alreadyWatchedOnClick));
        }
        if (!$(this).find(`#${ocIDontLikeTheVideoButtonId}`)[0]) {
            this.appendChild(await createButton(ocIDontLikeTheVideoButtonId, ocIDontLikeTheVideoButtonText, iDontLikeTheVideoOnClick));
        }
        if (!$(this).find(`#${ocDontRecommendId}`)[0]) {
            this.appendChild(await createButton(ocDontRecommendId, ocDontRecommendText, dontRecommendOnClick));
        }
        if (await GM_getValue(gmDebug) === 'true') {
            if (!$(this).find(`#${ocDebugId}`)[0]) {
                this.appendChild(await createButton(ocDebugId, ocDebugText, debugOnClick));
            }
        }
    });
}

/*
 * Double-check whether those videos have previously been marked as watched or blocked
 * by this script (and Youtube is ignoring that). If so, then re-mark them and hide them.
 */
async function autohideVideoNodes() {
    $(ytContentsGridSelector).find(videoNodeShownSelector).each(async function() {
        if (!isShown(this)) {
            await error(`selected video node that is not shown: ${getVideoId(this)}`);
            return;
        }

        if (await GM_getValue(gmHidelists) === 'true') {
            const listId = getListId(this);
            if (listId) {
                await debug(`hide list mode enabled; hiding list id ${listId}`);
                hide(this);
                return;
            }
        }

        if (await GM_getValue(gmHidelivestreams) === 'true') {
            const videoId = getLivestreamId(this);
            if (videoId) {
                await debug(`hide livestream mode enabled; hiding livestream id ${videoId}`);
                hide(this);
                return;
            }
        }

        if (await GM_getValue(gmHidefundraisers) === 'true') {
            const videoId = getFundraiserId(this);
            if (videoId) {
                await debug(`hide fundraiser mode enabled; hiding fundraiser id ${videoId}`);
                hide(this);
                return;
            }
        }
    });
}

async function automarkVideoNodes() {
    if (await GM_getValue(gmAutomark) === 'true') {
        $(ytContentsGridSelector).find(videoNodeShownSelector).each(async function() {
            await automarkByVideoIdState(this);
            await automarkByChannelIdState(this);
        });
    }
}

/*
 * Look up whether this userscript has previously watched or blocked the given video node
 * (and Youtube is ignoring that). If so, then re-mark it and hide it again.
 */
async function automarkByVideoIdState(videoNode) {
    if (!isShown(videoNode)) {
        await error(`automarkByVideoIdState given video node that is not shown: ${videoNode}`);
        return;
    }

    const videoId = getVideoId(videoNode);
    if (!videoId) {
        return;
    }

    const videoIdValue = await GM_getValue(videoId);
    if (!videoIdValue) {
        return;
    }

    await debug(`automarkByVideoIdState found persisted state for video id ${videoId}: ${videoIdValue}, waiting for node to ready`);
    const videoDropdownButtonSelector = getVideoDropdownButtonSelector(videoId);
    await awaitNodeAdded(videoDropdownButtonSelector);

    if (videoIdValue === gmWatchedVideo) {
        await debug(`automarkByVideoIdState marking watched video id ${videoId}`);
        await markAlreadyWatched(videoNode);

        await debug(`automarkByVideoIdState hiding watched video id ${videoId}`);
        hide(videoNode);
    } else if (videoIdValue === gmDontLikeVideo) {
        await debug(`automarkByVideoIdState marking blocked video id ${videoId}`);
        await markDontLike(videoNode);

        await debug(`automarkByVideoIdState hiding video for blocked video id ${videoId}`);
        hide(videoNode);
    }
}

/*
 * Look up whether this userscript has previously blocked the channel of the given video node
 * (and Youtube is ignoring that). If so, then re-mark it and hide it again.
 */
async function automarkByChannelIdState(videoNode) {
    if (!isShown(videoNode)) {
        await error(`automarkByChannelIdState given video node that is not shown: ${videoNode}`);
        return;
    }

    const videoId = getVideoId(videoNode);
    const channelId = getChannelId(videoNode);
    if (!videoId || !channelId) {
        return;
    }

    const channelIdValue = await GM_getValue(channelId);
    if (!channelIdValue) {
        return;
    }

    await debug(`automarkByChannelIdState found persisted state for channel id ${channelId} of video id ${videoId}: ${channelIdValue}, waiting for node to ready`);
    const videoDropdownButtonSelector = getVideoDropdownButtonSelector(videoId);
    await awaitNodeAdded(videoDropdownButtonSelector);

    if (channelIdValue === gmDontRecommendChannel) {
        await debug(`automarkByChannelIdState marking blocked channel id ${channelId}`);
        await markDontRecommend(videoNode);

        await debug(`automarkByChannelIdState hiding video ${videoId} for blocked channel id ${channelId}`);
        hide(videoNode);
    }
}

// =================================
// 1-Click Feed Maintenance Controls
// =================================

// oc control element ids
const ocAlreadyWatchedButtonId = 'oc-already-watched';
const ocIDontLikeTheVideoButtonId = 'oc-dont-like';
const ocDontRecommendId = 'oc-dont-recommend';
const ocDebugId = 'oc-debug';

// oc control labels
const ocAlreadyWatchedButtonText = 'Already Watched';
const ocIDontLikeTheVideoButtonText = 'Block Video';
const ocDontRecommendText = 'Block Channel';
const ocDebugText = 'Debug';

/*
 * "already watched" oc button callback
 */
async function alreadyWatchedOnClick(e) {
    e.preventDefault();
    const videoNode = e.target.parentNode;
    const videoId = getVideoId(videoNode);
    await markAlreadyWatched(videoNode);
    await debug(`hiding watched video id ${videoId}`);
    hide(videoNode);
    await rememberWatchedVideo(videoId);
}

/*
 * "I dont like the video" oc button callback
 */
async function iDontLikeTheVideoOnClick(e) {
    e.preventDefault();
    const videoNode = e.target.parentNode;
    const videoId = getVideoId(videoNode);
    await markDontLike(videoNode);
    await debug(`hiding blocked video id ${videoId}`);
    hide(videoNode);
    await rememberBlockedVideo(videoId);
}

/*
 * "dont recommend" oc button callback
 */
async function dontRecommendOnClick(e) {
    e.preventDefault();
    const videoNode = e.target.parentNode;
    const videoId = getVideoId(videoNode);
    const channelId = getChannelId(videoNode);
    await markDontRecommend(videoNode);
    await debug(`hiding blocked channel video id ${videoId}`);
    hide(videoNode);
    await rememberBlockedChannel(channelId);
}

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

async function rememberWatchedVideo(videoId) {
    if (videoId) {
        await debug(`remembering watched video id ${videoId}`);
        await GM_setValue(videoId, gmWatchedVideo);
    }
}

async function rememberBlockedVideo(videoId) {
    if (videoId) {
        await debug(`remembering blocked video id ${videoId}`);
        await GM_setValue(videoId, gmDontLikeVideo);
    }
}

async function rememberBlockedChannel(channelId) {
    if (channelId) {
        await debug(`remembering blocked channel id ${channelId}`);
        await GM_setValue(channelId, gmDontRecommendChannel);
    }
}

// =============================
// 1-Click Feed Maintenance Menu
// =============================

// oc menu element ids
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';

// oc menu labels
const ocMenuButtonText = '1-Click Config';
const ocMenuExportButtonText = 'Export';
const ocMenuSaveButtonText = 'Save';
const ocMenuCancelButtonText = 'Cancel';

// oc menu jQuery selectors
const ocMenuSelector = '#oc-menu';

/*
 * Create the oc menu.
 */
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 = '200px';
    menu.style.zIndex = 10000;
    menu.style.display = 'none';

    menu.appendChild(createCheckboxOCMenuItem(gmDebug));
    menu.appendChild(createCheckboxOCMenuItem(gmAutomark));
    menu.appendChild(createCheckboxOCMenuItem(gmHidepopups));
    menu.appendChild(createCheckboxOCMenuItem(gmHidelists));
    menu.appendChild(createCheckboxOCMenuItem(gmHidelivestreams));
    menu.appendChild(createCheckboxOCMenuItem(gmHidefundraisers));
    menu.appendChild(createCheckboxOCMenuItem(gmTrackWatched));

    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;
}

/*
 * Onclick for oc menu open button in yt masthead; open the oc menu.
 */
async function ocMenuOpen() {
    $(ocMenuSelector).each(async function() {
        this.style.display = '';
        this.style.left = `${getClientWidth() / 2}px`;
        this.style.top = `${getClientHeight() / 2}px`;
    });
    await restoreOCMenuValues();
}

/*
 * Onclick for export button in oc menu; export all GM values, then close the oc menu.
 */
async function ocMenuExport() {
    await exportGMValues();
    await ocMenuClose();
}

/*
 * Onclick for save button in oc menu; save values currently in oc menu to GM, then close the menu.
 */
async function ocMenuSave() {
    await saveOCMenuValues();
    await ocMenuClose();
}

/*
 * Onclick for cancel button in oc menu; restore the oc menu to existing GM values, then close the oc menu.
 */
async function ocMenuCancel() {
    await restoreOCMenuValues();
    await ocMenuClose();
}

/*
 * Close the oc menu.
 */
async function ocMenuClose() {
    $(ocMenuSelector).each(async function() {
        this.style.display = 'none';
    });
}

/*
 * Save values currently in oc menu to GM
 */
async function saveOCMenuValues() {
    await saveOCMenuItemCheckbox(gmDebug);
    await saveOCMenuItemCheckbox(gmAutomark);
    await saveOCMenuItemCheckbox(gmHidepopups);
    await saveOCMenuItemCheckbox(gmHidelists);
    await saveOCMenuItemCheckbox(gmHidelivestreams);
    await saveOCMenuItemCheckbox(gmHidefundraisers);
    await saveOCMenuItemCheckbox(gmTrackWatched);
}

/*
 * restore the oc menu to existing GM values
 */
async function restoreOCMenuValues() {
    await restoreOCMenuItemCheckbox(gmDebug);
    await restoreOCMenuItemCheckbox(gmAutomark);
    await restoreOCMenuItemCheckbox(gmHidepopups);
    await restoreOCMenuItemCheckbox(gmHidelists);
    await restoreOCMenuItemCheckbox(gmHidelivestreams);
    await restoreOCMenuItemCheckbox(gmHidefundraisers);
    await restoreOCMenuItemCheckbox(gmTrackWatched);
}

/*
 * Create and return a new checkbox for the oc menu.
 */
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;
}

async function restoreOCMenuItemCheckbox(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}`
}

// =================
// Youtube functions
// =================

// youtube constants
const ytPopupContainerClass = 'ytd-popup-container';

// youtube jQuery selectors
const ytAppSelector = 'ytd-app';

const videoNodeShownSelector = 'ytd-rich-item-renderer:shown';
const videoNodeWithoutOCButtonsSelector = 'ytd-rich-item-renderer:not(:has(#oc-button)):shown';

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 yt-button-shape button:contains("Submit")';
const ytPaperDialogCancelSelector = 'ytd-popup-container tp-yt-paper-dialog yt-button-shape button:contains("Cancel")';

const ytIronDropdownSelector = 'ytd-popup-container tp-yt-iron-dropdown:not(:has(ytd-notification-renderer))';
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")';

const attemptDuration = 100;
const maxAttempts = 30;
const resolveMutationTimeout_ms = 5000;

/*
 * Open the iron dropdown for the given video node, then click "not interested", "tell us why",
 * then "already watched"
 */
async function markAlreadyWatched(videoNode) {
    actionMutex.synchronize(async function() {
        try {
            const videoId = getVideoId(videoNode);
            await debug(`marking watched video id ${videoId}`);
            await openIronDropdown(videoNode);
            await clickNotInterested(videoNode);
            await clickTellUsWhy(videoNode);
            await clickAlreadyWatched(videoNode);
        } finally {
            // clean up any iron dropdown or paper dialog that were mistakenly left open
            await closeIronDropdown(videoNode);
            await closePaperDialog();
        }
    });
}

/*
 * Open the iron dropdown for the given video node, then click "not interested", "tell us why",
 * then "i don't like this video"
 */
async function markDontLike(videoNode) {
    actionMutex.synchronize(async function() {
        try {
            const videoId = getVideoId(videoNode);
            await debug(`marking blocked video id ${videoId}`);
            await openIronDropdown(videoNode);
            await clickNotInterested(videoNode);
            await clickTellUsWhy(videoNode);
            await clickIDontLikeTheVideo(videoNode);
        } finally {
            // clean up any iron dropdown or paper dialog that were mistakenly left open
            await closeIronDropdown(videoNode);
            await closePaperDialog();
        }
    });
}

/*
 * Open the iron dropdown for the given video node, then click "don't recommend"
 */
async function markDontRecommend(videoNode) {
    actionMutex.synchronize(async function() {
        try {
            const videoId = getVideoId(videoNode);
            await debug(`marking blocked channel video id ${videoId}`);
            await openIronDropdown(videoNode);
            await clickDontRecommend(videoNode);
        } finally {
            // clean up any iron dropdown or paper dialog that were mistakenly left open
            await closeIronDropdown(videoNode);
            await closePaperDialog();
        }
    });
}

/*
 * click menu button on the given video node, wait for iron dropdown to be added, and return iron
 * dropdown node.
 */
async function openIronDropdown(videoNode) {
    const videoId = getVideoId(videoNode);
    const videoDropdownButtonSelector = getVideoDropdownButtonSelector(videoId);

    // if iron dropdown button is completely missing, then nothing to do
    if (!$(videoDropdownButtonSelector).length) {
        warn(`iron dropdown button not found for selector ${videoDropdownButtonSelector}`);
        return;
    }

    // hide iron dropdown, so it doesn't flicker when clicking oc buttons
    if (await GM_getValue(gmHidepopups) === 'true') {
        await hideIronDropdown();
    }

    await doAndAwaitNodeAdded(ytIronDropdownSelector, async function() {
        $(videoDropdownButtonSelector).trigger('click');
    });

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

/*
 * click not interested button on the given iron dropdown node, and wait for tell us why button to be added.
 */
async function clickNotInterested(videoNode) {
    const videoId = getVideoId(videoNode);
    const tellUsWhyButtonSelector = getTellUsWhyButtonSelector(videoId);

    try {
        if (!$(ytIronDropdownNotInterestedSelector).length) {
            warn(`not interested button not found for video id ${getVideoId(videoNode)}`);
            return;
        }
        await doAndAwaitNodeAdded(tellUsWhyButtonSelector, async 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(ytIronDropdownSelector, async 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) {
    const videoId = getVideoId(videoNode);
    const tellUsWhyButtonSelector = getTellUsWhyButtonSelector(videoId);

    // if paper dialog button is completely missing, then nothing to do
    if (!$(tellUsWhyButtonSelector).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') {
        await hidePaperDialog();
    }

    await doAndAwaitNodeAdded(ytPaperDialogSelector, async function() {
        $(tellUsWhyButtonSelector).trigger('click');
    });

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

/*
 * 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(ytPaperDialogSelector, async function() {
            $(ytPaperDialogIDontLikeTheVideoSelector).trigger('click');
            await delay(500);
            $(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(ytPaperDialogSelector, async function() {
            $(ytPaperDialogIveAlreadyWatchedSelector).trigger('click');
            await delay(500);
            $(ytPaperDialogSubmitSelector).trigger('click');
        });
    } finally {
        await closePaperDialog();
    }
}

/*
 * If given video node is a list, then get the list id. Otherwise, null.
 */
function getListId(videoNode) {
    const videoTitleLink = $(videoNode).find('a#video-title-link')[0];
    if (!videoTitleLink) {
        return null;
    }

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

    return null;
}

/*
 * If given video node is a livestream, then get the video id. Otherwise, null.
 */
function getLivestreamId(videoNode) {
    const liveNowBadge = $(videoNode).find('div.badge-style-type-live-now')[0];
    if (!liveNowBadge) {
        return null;
    }

    return getVideoId(videoNode);
}

/*
 * If given video node is a fundraiser, then get the video id. Otherwise, null.
 */
function getFundraiserId(videoNode) {
    const fundraiserBadge = $(videoNode).find('div.badge-style-type-ypc')[0];
    if (!fundraiserBadge) {
        return null;
    }

    return getVideoId(videoNode);
}

function getVideoSelector(videoId) {
    return `ytd-rich-item-renderer:has(a#video-title-link[href*="${videoId}"])`;
}

function getVideoDropdownButtonSelector(videoId) {
    return `${getVideoSelector(videoId)} div#menu button`
}

function getTellUsWhyButtonSelector(videoId) {
    return `${getVideoSelector(videoId)} button:contains("Tell us why")`
}

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;
}

function isWatchPage() {
    const match = window.location.href.match(/\/watch/);
    if (!match) {
        return false;
    }
    return true;
}

/*
 * If this script is running on a video watch page, then get the video id from the watch url.
 */
function getWatchPageVideoId() {
    return window.location.href.match(/\/watch\?v=([^&]*)/)[1];
}

async function hideIronDropdown() {
    $(ytIronDropdownSelector).each(async function() {
        hide(this);
    });
}

/*
 * 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() {
        // reset visibility in case hidepopups is enabled
        unhide(this);

        // try to close it by clicking
        let attempts = 0;
        while (isShown(this)) {
            clickEmptySpace();
            await delay(attemptDuration);
            attempts = attempts + 1;
            if (attempts > maxAttempts) {
                // if it's still open, then just hide it so we don't block forever on this
                await hideIronDropdown();
                break;
            }
        }

        // even if it's closed, click empty space on the page to try to avoid the scroll-breaking bug
        clickEmptySpace();
    });
}

async function hidePaperDialog() {
    $(ytPaperDialogSelector).each(async function() {
        hide(this);
    });
}

/*
 * click cancel button on the paper dialog, and wait for paper dialog to be removed
 */
async function closePaperDialog() {
    await $(ytPaperDialogSelector).each(async function() {
        // reset visibility in case hidepopups is enabled
        unhide(this);

        // try to close it by clicking empty space on the page
        let attempts = 0;
        while (isShown(this)) {
            clickEmptySpace();
            await delay(attemptDuration);
            attempts = attempts + 1;
            if (attempts > maxAttempts) {
                // if it's still open, then just hide it so we don't block forever on this
                await hidePaperDialog();
                break;
            }
        }

        // even if it's closed, click empty space on the page to try to avoid the scroll-breaking bug
        clickEmptySpace();
    });
}

/*
 * click empty space on the page to trigger youtube events that close dialogs and unblock scrolling
 */
function clickEmptySpace() {
    $(ytAppSelector).trigger('click');
}

// =========================
// lwkjef's standard library
// =========================

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

// library GM keys
const gmDebug = 'debug';

// library selectors
const rootSelector = ':root';

/*
 * Log the given text if debug mode is enabled.
 */
async function debug(text) {
    if (await GM_getValue(gmDebug) === 'true') {
        log(text);
    }
}

/*
 * Export all GM values as a CSV string, and open browser save file dialog to download it as a file.
 */
async function exportGMValues() {
    const csv = await getGMValuesCSV();
    exportTextFile(csv);
}

/*
 * Export all GM values as a CSV string.
 *
 * Example: 'key,value\nval1,val2'
 */
async function getGMValuesCSV() {
    let csv = 'key,value\and';
    for (const gmKey of await GM_listValues()) {
        csv += `${gmKey},${await GM_getValue(gmKey)}\and`;
    }
    return csv;
}

/*
 * Wait until at least one node matching nodeSelector is added to ancestorNodeSelector.
 */
async function awaitNodeAdded(expectedSelector) {
    await debug(`awaitNodeAdded awaiting ${expectedSelector} added...`);
    const resultNode = await resolveMutation(async function(mutationsList, observer, resolve) {
        const expectedNode = $(expectedSelector)[0];
        if (expectedNode) {
            resolve(expectedNode);
        }
    }, _ => {});
    if (!resultNode) {
        await error(`awaitNodeAdded couldn't resolve addition of ${expectedSelector}`);
    }
    return resultNode;
}

/*
 * 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(expectedSelector, triggerMutation) {
    const resultNode = await resolveMutation(async function(mutationsList, observer, resolve) {
        const expectedNode = $(expectedSelector)[0];
        if (expectedNode &&
            isShown(expectedNode)) {
            resolve(expectedNode);
        }
    }, triggerMutation);
    if (!resultNode) {
        await error(`doAndAwaitNodeAdded couldn't resolve addition of ${expectedSelector}`);
    }
    return resultNode;
}

/*
 * 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(expectedSelector, triggerMutation) {
    const resultNode = await resolveMutation(async function(mutationsList, observer, resolve) {
        const expectedNode = $(expectedSelector)[0];
        if (expectedNode &&
            !isShown(expectedNode)) {
            resolve(expectedNode);
        }
    }, triggerMutation);
    if (!resultNode) {
        await error(`doAndAwaitNodeRemoved couldn't resolve removal of ${expectedSelector}`);
    }
    return resultNode;
}

async function resolveMutation(onMutation, triggerMutation) {
    await debug(`entering resolveMutation`);
    let observer;
    try {
        const resultNode = await new Promise(resolve => {
            observer = new MutationObserver(async function(mutationsList, observer) {
                onMutation(mutationsList, observer, resolve);
            });
            $(rootSelector).each(async function() {
                observer.observe(this, {attributes: true, childList: true, subtree: true});
            });
            setTimeout(async function() {
                observer.disconnect();
                resolve(null);
            }, resolveMutationTimeout_ms);
            triggerMutation();
        });
        if (!resultNode) {
            await error(`resolveMutation couldn't resolve mutation`);
        }
        return resultNode;
    } finally {
        observer.disconnect();
    }
}

/*
 * Get the current width of the browser, in pixels.
 */
function getClientWidth() {
    return (window.innerWidth ||
            document.documentElement.clientWidth ||
            document.body.clientWidth);
}

/*
 * Get the current height of the browser, in pixels.
 */
function getClientHeight() {
    return (window.innerHeight ||
            document.documentElement.clientHeight ||
            document.body.clientHeight);
}

/*
 * Create and return a new div container with the given id.
 */
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 = {'background': 'grey', 'margin-left': '2px', 'margin-right': '2px', 'padding': '2px', 'font-size': '14px'}) {
    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;
}

/*
 * Return true if node is displayed and visible, false otherwise.
 */
function isShown(node) {
    return node.style.display !== 'none' && node.style.visibility != 'hidden';
}

/*
 * Hide the given node by setting the visibility style to hidden
 */
function hide(node) {
    node.style.visibility = 'hidden';
}

/*
 * Unhide the given node by unsetting the visibility style (assuming it was set to hidden)
 */
function unhide(node) {
    node.style.visibility = '';
}

/*
 * Prepend the script name to the given text
 */
function prependScriptName(text) {
    return `[${scriptName}] ${text}`;
}

/*
 * Log the given text, prepended by the userscript name, to the browser console at standard log level
 */
function log(text) {
    console.log(prependScriptName(text));
}

/*
 * Log the given text, prepended by the userscript name, to the browser console at warn log level
 */
function warn(text) {
    console.warn(prependScriptName(text));
}

/*
 * Log the given text, prepended by the userscript name, to the browser console at error log level
 */
function error(text) {
    console.error(prependScriptName(text));
}

/*
 * Return a Promise which waits for the given millisecond duration.
 */
async function delay(duration_ms) {
    await new Promise((resolve, reject) => {
        setTimeout(_ => resolve(), duration_ms)
    });
}

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

/*
 * Export the given data as a text file and trigger browser to download it.
 *
 * type may be 'text/csv', 'text/html', 'text/vcard', 'text/txt', 'application/csv', etc.
 *
 * Example usage: exportFile('col1,col2\nval1,val2', 'text/csv');
 */
function exportTextFile(data, type='text/csv', charset='utf-8', filename='data.csv') {
    // IMPORTANT: we must manually revoke this object URL to avoid memory leaks!
    const objectURL = window.URL.createObjectURL(new Blob([data], {type: type}));
    // alternatively, we may create object URL manually
    //const objectURL = `data:${type};charset=${charset},${encodeURIComponent(data)}`
    try {
        triggerObjectURL(objectURL, filename);
    }
    finally {
        if (objectURL !== null) {
            window.URL.revokeObjectURL(objectURL);
        }
    }
}

/*
 * Trigger the browser to download the given objectURL as a file.
 */
function triggerObjectURL(objectURL, filename='data.csv') {
    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);
}

/*
 * jQuery extension to match elements that don't have display none nor css visibility hidden.
 * The built-in :visible selector stil matches elements with css visibility hidden because
 * they reserve space in the layout, but sometimes we want to actually match based on visibility
 * of the content rather than the layout space.
 *
 * https://stackoverflow.com/a/33689304
 */
jQuery.extend(jQuery.expr[':'], {
    shown: function (el, index, selector) {
        return $(el).css('visibility') != 'hidden' && $(el).css('display') != 'none' && !$(el).is(':hidden')
    }
});