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