1-click feed maintenance

Mark a video in homepage feed as already watched, not interested, don't recommend in 1 click instead of 5

As of 26.11.2021. See апошняя версія.

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      1.0
// @description  Mark a video in homepage feed as already watched, not interested, don't recommend in 1 click instead of 5
// @author       lwkjef
// @match        https://www.youtube.com/
// @icon         https://www.google.com/s2/favicons?domain=youtube.com
// @grant        none
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js
// @license      MIT
// ==/UserScript==

/* eslint-env jquery */

// jQuery selectors for Youtube elements
const contentsGridSelector = '#contents.ytd-rich-grid-renderer';
const videoSelector = 'ytd-rich-item-renderer';
const menuButtonSelector = '#menu button';
const popupContainerSelector = 'ytd-popup-container'; // parent of ironDropdownSelector and paperDialogSelector
const ironDropdownSelector = 'tp-yt-iron-dropdown'
const paperDialogSelector = 'tp-yt-paper-dialog';
const notInterestedSelector = 'tp-yt-paper-item:contains("Not interested")';
const dontRecommendSelector = 'tp-yt-paper-item:contains("Don\'t recommend channel")';
const tellUsWhySelector = 'ytd-button-renderer:contains("Tell us why")';
const iDontLikeTheVideoSelector = 'tp-yt-paper-checkbox:contains("I don\'t like the video")';
const iveAlreadyWatchedTheVideoSelector = 'tp-yt-paper-checkbox:contains("I\'ve already watched the video")';
const submitSelector = 'tp-yt-paper-button:contains("Submit")';

// internal constants
const oneClickButtonId = 'one-click-feed-button';
const alreadyWatchedButtonText = 'already watched';
const iDontLikeTheVideoButtonText = 'block video';
const dontRecommendText = 'block channel';

(function(){
    'use strict'
    $(document).ready(function(){
        let observer = new MutationObserver(function contentsMutated(mutationsList, observer) {
            for (let mutation of mutationsList) {
                for (let addedNode of mutation.addedNodes) {
                    addButtons(addedNode);
                }
            }
        });
        $(contentsGridSelector).each(function() {
            // hook up observer first, so we don't miss any new video nodes
            observer.observe(this, {attributes: false, childList: true, subtree: false});

            // immediately backfill all existing video nodes
            addButtons(this);
        });
    });
}())

/*
 * add 1-click buttons to all video nodes contained in the given node that don't already have buttons
 */
function addButtons(node) {
    $(node).find(videoSelector).not(`:has(#${oneClickButtonId})`).each(function() {
        this.appendChild(createButton(alreadyWatchedButtonText, alreadyWatchedOnClick));
        this.appendChild(createButton(iDontLikeTheVideoButtonText, iDontLikeTheVideoOnClick));
        this.appendChild(createButton(dontRecommendText, dontRecommendOnClick));
    });
}

/*
 * create and return a new 1-click button with the given text and onclick callback
 */
function createButton(text, onclick, cssObj) {
    let button = document.createElement('button');
    button.setAttribute ('id', oneClickButtonId);
    button.setAttribute ('type', 'button');
    button.innerHTML = text;
    button.onclick = onclick;

    //let btnStyle = button.style;
    //cssObj = cssObj || {position: 'absolute', bottom: '7%', left:'4%', 'z-index': 3};
    //Object.keys(cssObj).forEach(key => {btnStyle[key] = cssObj[key]});

    return button;
}

/*
 * "already watched" 1-click button callback
 */
async function alreadyWatchedOnClick(e) {
    e.preventDefault();
    let videoNode = e.target.parentNode;
    let ironDropdownNode = await openMenu(videoNode);
    await clickNotInterested(ironDropdownNode);
    let paperDialogNode = await clickTellUsWhy(videoNode);
    await clickAlreadyWatched(paperDialogNode);
    videoNode.style.visibility = 'hidden';
}

/*
 * "I dont like the video" 1-click button callback
 */
async function iDontLikeTheVideoOnClick(e) {
    e.preventDefault();
    let videoNode = e.target.parentNode;
    let ironDropdownNode = await openMenu(videoNode);
    await clickNotInterested(ironDropdownNode);
    let paperDialogNode = await clickTellUsWhy(videoNode);
    await clickIDontLikeTheVideo(paperDialogNode);
    videoNode.style.visibility = 'hidden';
}

/*
 * "dont recommend" 1-click button callback
 */
async function dontRecommendOnClick(e) {
    e.preventDefault();
    let videoNode = e.target.parentNode;
    let ironDropdownNode = await openMenu(videoNode);
    await clickDontRecommend(ironDropdownNode);
    videoNode.style.visibility = 'hidden';
}

/*
 * click menu button on the given video node, wait for iron dropdown to be added, and return iron dropdown node.
 */
async function openMenu(videoNode) {
    return await doAndAwaitNodeAdded(ironDropdownSelector, popupContainerSelector, function() {
        $(videoNode).find(menuButtonSelector).trigger('click');
    });
}

/*
 * click not interested button on the given iron dropdown node, and wait for the iron dropdown to be removed.
 */
async function clickNotInterested(ironDropdownNode) {
    return await doAndAwaitNodeRemoved(ironDropdownSelector, popupContainerSelector, function() {
        $(ironDropdownNode).find(notInterestedSelector).trigger('click');
    });
}

/*
 * click dont recommend channel button on the given popup node, and wait for the popup to be removed.
 */
async function clickDontRecommend(ironDropdownNode) {
    return await doAndAwaitNodeRemoved(ironDropdownSelector, popupContainerSelector, function() {
        $(ironDropdownNode).find(dontRecommendSelector).trigger('click');
    });
}

/*
 * click tell us why button on the given video node, wait for popup to be added, and return popup node.
 */
async function clickTellUsWhy(videoNode) {
    return await doAndAwaitNodeAdded(paperDialogSelector, popupContainerSelector, function() {
        $(videoNode).find(tellUsWhySelector).trigger('click');
    });
}

/*
 * click I dont like the video button on the given popup node, click submit button on the given popup node, and wait for the popup to be removed.
 */
async function clickIDontLikeTheVideo(paperDialogNode) {
    return await doAndAwaitNodeRemoved(paperDialogSelector, popupContainerSelector, function() {
        $(paperDialogNode).find(iDontLikeTheVideoSelector).trigger('click');
        $(paperDialogNode).find(submitSelector).trigger('click');
    });
}

/*
 * click Ive already watched the video button on the given popup node, click submit button on the given popup node, and wait for the popup to be removed.
 */
async function clickAlreadyWatched(paperDialogNode) {
    return await doAndAwaitNodeRemoved(paperDialogSelector, popupContainerSelector, function() {
        $(paperDialogNode).find(iveAlreadyWatchedTheVideoSelector).trigger('click');
        $(paperDialogNode).find(submitSelector).trigger('click');
    });
}

/*
 * 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 (let mutation of mutationsList) {
                if (mutation.type === 'childList') {
                    for (let addedNode of mutation.addedNodes) {
                        if (equalsIgnoreCase(addedNode.nodeName, nodeName) && addedNode.style.display !== 'none') {
                            // entire dropdown node added
                            node = addedNode;
                            resolve(addedNode);
                        }
                    }
                } else if (mutation.type === 'attributes') {
                    if (equalsIgnoreCase(mutation.target.nodeName, nodeName) && mutation.target.style.display !== 'none') {
                        // dropdown added by unsetting display:none
                        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 (let mutation of mutationsList) {
                if (mutation.type === 'childList') {
                    for (let removedNode of mutation.removedNodes) {
                        if (equalsIgnoreCase(removedNode.nodeName, nodeName)) {
                            // entire node removed
                            resolve(removedNode);
                        }
                    }
                } else if (mutation.type === 'attributes') {
                    if (equalsIgnoreCase(mutation.target.nodeName, nodeName) && mutation.target.style.display === 'none') {
                        // removed by setting display:none
                        resolve(mutation.target);
                    }
                }
            }
        });
        $(ancestorSelector).each(function() {
            observer.observe(this, {attributes: true, childList: true, subtree: true});
        });
        f();
    });
    observer.disconnect();
}

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