1-click feed maintenance

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

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