// ==UserScript==
// @name YouTube Repeated Recommendations Hider
// @description Hide any videos that are recommended more than twice. You can also hide by channel or by partial title. Works on both YouTube's desktop and mobile layouts.
// @version 2.0.0
// @author BLBC (github.com/hjk789, greasyfork.org/users/679182-hjk789)
// @copyright 2020+, BLBC (github.com/hjk789, greasyfork.org/users/679182-hjk789)
// @homepage https://github.com/hjk789/Userscripts/tree/master/YouTube-Repeated-Recommendations-Hider
// @license https://github.com/hjk789/Userscripts/tree/master/YouTube-Repeated-Recommendations-Hider#license
// @match https://m.youtube.com/*
// @match https://www.youtube.com/*
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.listValues
// @grant GM.deleteValue
// @namespace https://greasyfork.org/users/679182
// ==/UserScript==
//********** SETTINGS ***********
const maxRepetitions = 2 // The maximum number of times that the same recommended video is allowed to appear
// before starting to get hidden. Set this to 1 if you want one-time recommendations.
const filterRelated = true // Whether the related videos (the ones below/beside the video you are watching) should also be filtered. Set this to false if you want to keep them untouched.
const countRelated = true // When false, new related videos are ignored in the countings and are allowed to appear any number of times, as long as they don't
// appear in the homepage recommendations. If set to true, the related videos are counted even if they never appeared in the homepage.
const filterPremiere = false // Whether to include in the filtering repeated videos yet to be premiered. If set to false, these recommendations won't get "remembered"
// until the video is finally released, then it will start counting as any other video. Set this to true if you want to hide them anyway.
const filterLives = true // Same as above but for ongoing live streams. If set to false, the recommended live stream will start to be counted only after the stream ends and becomes a whole video.
const dimFilteredHomepage = false // Whether the repeated recommendations in the homepage should get dimmed (partially faded) instead of completely hidden.
const dimFilteredRelated = true // Same thing, but for the related videos.
const dimWatchedVideos = true // Whether the title of videos already watched should be dimmed, to differentiate from the ones you didn't watched yet. The browser itself is responsible for checking whether the
// link was already visited or not, so if you delete a video from the browser history it will be treated as "not watched", the same if you watch them in a private window (incognito).
//*******************************
let channelsToHide, partialTitlesToHide, selectedChannel
let hideChannelButton, hidePartialTitleButton
let processedVideosList, isHomepage
let currentPage = location.href, url
const isMobile = !/www/.test(location.hostname)
const waitForURLchange = setInterval(function() // Because YouTube is a single-page web app, everything happens in the same page, only changing the URL.
{ // So the script needs to check when the URL changes so it can be reassigned to the page and be able to work.
url = location.href.split("#")[0] // In the mobile layout, when a menu is open, a hash is added to the URL. This hash need to be ignored to prevent incorrect detections.
if (url != currentPage)
{
currentPage = url
if (location.pathname != "/" && location.pathname != "/watch") // Only run on the homepage and video page.
return
main()
}
}, 500)
const onViewObserver = new IntersectionObserver((entries) => // An intersection observer is being used so that the recommendations are counted only
{ // when the user actually sees them on the screen, instead of when they are loaded.
entries.forEach(entry =>
{
if (entry.isIntersecting)
processRecommendation(entry.target, isHomepage)
})
}, {threshold: 1.0}) // Only trigger the observer when the recommendation is completely visible.
if (dimWatchedVideos)
{
// Add the style for dimming watched videos
const style = document.createElement("style")
style.innerHTML = ":visited, :visited h3, :visited #video-title { color: #aaa !important; }"
document.head.appendChild(style)
}
/* Create the desktop version's hover styles for the recommendation menu items */
if (!isMobile)
{
const style = document.createElement("style")
style.innerHTML = "#hideChannelButton:hover, #hidePartialTitleButton:hover { background-color: #e7e7e7 !important; }"
document.head.appendChild(style)
}
getGMsettings()
async function getGMsettings()
{
let value = await GM.getValue("channels")
if (!value)
{
value = "[]"
GM.setValue("channels", value)
GM.setValue("partialTitles", value)
}
channelsToHide = JSON.parse(value)
value = await GM.getValue("partialTitles")
partialTitlesToHide = JSON.parse(value)
processedVideosList = await GM.listValues() // Get in an array all the items currently in the script's storage. Searching for a value in
// an array is much faster and lighter than calling GM.getValue for every recommendation.
main()
}
function main()
{
isHomepage = location.pathname == "/"
if (isHomepage)
{
const waitForRecommendationsContainer = setInterval(function()
{
let recommendationsContainer
if (isMobile)
{
recommendationsContainer = document.querySelector(".rich-grid-renderer-contents")
try { recommendationsContainer.children[0].querySelector("h3, h4").nextSibling.firstChild.firstChild.textContent } // In some very specific cases an error may occur while getting the video title because it's not available yet. This try-catch
catch(e) { return } // is a straight-forward way of making sure that all elements of the path are available, instead of checking each one.
}
else
{
recommendationsContainer = document.querySelector("#contents ytd-rich-grid-row")
if (recommendationsContainer)
recommendationsContainer = recommendationsContainer.parentElement
}
if (!recommendationsContainer)
return
clearInterval(waitForRecommendationsContainer)
recommendationsContainer.style.marginLeft = "100px"
const videosSelector = isMobile ? "ytm-rich-item-renderer" : "ytd-rich-item-renderer"
let firstVideos = recommendationsContainer.querySelectorAll(videosSelector) // Because a mutation observer is being used and the script is run after the page is fully
// loaded, the observer isn't triggered with the recommendations that appear first.
for (let i=0; i < firstVideos.length; i++) // This does the processing manually to these first ones.
processRecommendation(firstVideos[i])
if (!isMobile) // Also, in the desktop layout, the first few recommendations require some layout tweaks to display them correctly.
{
const firstRows = recommendationsContainer.children
for (let i=0; i < firstRows.length; i++)
{
if (firstRows[i].clientHeight == 0) // Only apply the tweaks if there's any recommendation visible in the row.
continue
const row = firstRows[i]
row.style.justifyContent = "left" // These two lines remove an extra space on the left side, to make them align correctly with the other ones.
row.firstElementChild.style.margin = "0px"
firstVideos = row.querySelectorAll(videosSelector)
for (let j=0; j < firstVideos.length; j++)
firstVideos[j].style.width = "360px" // Force the recommendations to be displayed with this size, otherwise they get "crushed".
}
}
let loadedRecommendedVideosObserver
if (isMobile)
{
loadedRecommendedVideosObserver = new MutationObserver(function(mutations) // A mutation observer is being used so that all processings happen only
{ // when actually needed, which is when more recommendations are loaded.
for (let i=0; i < mutations.length; i++)
{
for (let j=0; j < mutations[i].addedNodes.length; j++)
{
const node = mutations[i].addedNodes[j]
if (node.tagName == "YTM-RICH-SECTION-RENDERER") // The Covid-19 info and Breaking News section is loaded as a container of videos which need to be processed separatedly.
{
const sectionVideos = node.querySelectorAll(videosSelector)
for (let k=0; k < sectionVideos.length; k++)
processRecommendation(sectionVideos[k])
}
else processRecommendation(node)
}
}
})
}
else
{
loadedRecommendedVideosObserver = new MutationObserver(function(mutations)
{
for (let i=0; i < mutations.length; i++)
{
for (let j=0; j < mutations[i].addedNodes.length; j++)
{
const row = mutations[i].addedNodes[j] // Different from the mobile layout in which the recommendations are displayed as a list, in the desktop
// layout the recommendations are displayed in containers that each serve as a row with 4-5 videos.
if (row.querySelector("ytd-notification-text-renderer, ytd-compact-promoted-item-renderer")) // Ignore notices and such, otherwise the layout gets messed up.
continue
row.style.width = "max-content" // Make the row take only the space needed to hold the recommendations within. If the next row fits in the freed space, it will move to beside it.
row.firstElementChild.style = "margin-right: 0px; margin-left: 0px;" // Remove the gap between different row containers in the same line.
const recommendations = row.querySelectorAll("ytd-rich-item-renderer")
for (let k=0; k < recommendations.length; k++)
{
recommendations[k].style.width = "360px"
processRecommendation(recommendations[k])
}
}
}
})
}
loadedRecommendedVideosObserver.observe(recommendationsContainer, {childList: true})
addRecommendationMenuItems()
}, 500)
}
else
{
const waitForRelatedVideosContainer = setInterval(function()
{
const videosSelector = isMobile ? "ytm-video-with-context-renderer, ytm-compact-show-renderer, ytm-radio-renderer, ytm-compact-playlist-renderer" : "ytd-compact-video-renderer, ytd-compact-radio-renderer, ytd-compact-movie-renderer, ytd-compact-playlist-renderer"
let relatedVideosContainer = document.querySelector(videosSelector)
if (!relatedVideosContainer)
return
clearInterval(waitForRelatedVideosContainer)
relatedVideosContainer = relatedVideosContainer.parentElement
const firstRelatedVideos = relatedVideosContainer.querySelectorAll(videosSelector)
for (let i=0; i < firstRelatedVideos.length; i++)
processRecommendation(firstRelatedVideos[i])
const loadedRelatedVideosObserver = new MutationObserver(function(mutations)
{
for (let i=0; i < mutations.length; i++)
{
for (let j=0; j < mutations[i].addedNodes.length; j++)
processRecommendation(mutations[i].addedNodes[j])
}
})
loadedRelatedVideosObserver.observe(relatedVideosContainer, {childList: true})
addRecommendationMenuItems()
}, 500)
}
/* Create the "Hide videos from this channel" and "Hide videos that include a text" buttons */
{
hideChannelButton = document.createElement(isMobile ? "button" : "div")
hideChannelButton.id = "hideChannelButton"
hideChannelButton.className = "menu-item-button"
hideChannelButton.style = !isMobile ? "background-color: white; font-size: 14px; padding: 9px 0px 9px 56px; cursor: pointer; min-width: max-content;" : ""
hideChannelButton.innerHTML = "Hide videos from this channel"
hideChannelButton.onclick = function()
{
if (confirm("Are you sure you want to hide all videos from the channel ''" + selectedChannel + "''?"))
{
channelsToHide.push(selectedChannel)
GM.setValue("channels", JSON.stringify(channelsToHide))
}
document.body.click() // Dismiss the menu.
}
hidePartialTitleButton = document.createElement(isMobile ? "button" : "div")
hidePartialTitleButton.id = "hidePartialTitleButton"
hidePartialTitleButton.className = hideChannelButton.className
hidePartialTitleButton.style = hideChannelButton.style.cssText
hidePartialTitleButton.innerHTML = "Hide videos that include a text"
hidePartialTitleButton.onclick = function()
{
const partialText = prompt("Specify the partial title of the videos to hide. All videos that contain this text in the title will get hidden.")
if (partialText)
{
partialTitlesToHide.push(partialText.toLowerCase().trim())
GM.setValue("partialTitles", JSON.stringify(partialTitlesToHide))
}
document.body.click()
}
}
}
async function processRecommendation(node)
{
if (!node || node.className.includes("processed"))
return
const videoTitleEll = node.querySelector(isMobile ? "h3, h4" : "#video-title, #movie-title")
if (!videoTitleEll)
return
const videoTitleText = videoTitleEll.textContent.toLowerCase() // Convert the title's text to lowercase so that there's no distinction with uppercase letters.
const videoMenuBtn = node.querySelector(isMobile ? "ytm-menu" : "ytd-menu-renderer")
const timeLabelEll = node.querySelector("yt" + (isMobile ? "m" : "d") + "-thumbnail-overlay-time-status-renderer")
let videoType = ""
if (timeLabelEll)
videoType = timeLabelEll.attributes[(isMobile ? "data" : "overlay") + "-style"].value // Get the type of the video, which can be a normal video, a live stream or a premiere.
else if (node.querySelector(".badge-style-type-live-now")) // In the homepage of the desktop layout, the live indicator is in a different element.
videoType = "LIVE"
let videoChannel, videoUrl
if (isMobile)
videoChannel = videoTitleEll.nextSibling.firstChild.firstChild.textContent
else
videoChannel = node.querySelector(".ytd-channel-name#text").textContent
if (isMobile || isHomepage)
videoUrl = cleanVideoUrl(videoTitleEll.parentElement.href) // The mix playlists and the promoted videos include ID parameters, along with the video id,
else // which changes everytime it's recommended. These IDs need to be ignored to filter it correctly.
videoUrl = cleanVideoUrl(node.querySelector(".details a").href)
// Because the recommendation's side-menu is separated from the recommendations container, this listens to clicks on each three-dot
// button and store in a variable in what recommendation it was clicked, to then be used by the "Hide videos from this channel" button.
if (videoMenuBtn)
{
videoMenuBtn.onclick = function()
{
selectedChannel = videoChannel
addRecommendationMenuItems()
}
}
if (channelsToHide.includes(videoChannel) || partialTitlesToHide.some(p => videoTitleText.includes(p)))
{
node.style.display = "none"
node.classList.add("processed")
}
else if (processedVideosList.includes("hide::"+videoUrl))
{
if (!isHomepage && !filterRelated)
return
hideOrDimm(node, isHomepage)
node.classList.add("processed")
}
else
{
if (!node.classList.contains("offView"))
{
node.classList.add("offView") // Add this class to mark the recommendations waiting to be counted.
onViewObserver.observe(node) // Wait for the recommendation to appear on screen.
return // And don't do anything else until that happens.
}
else
{
node.classList.remove("offView")
onViewObserver.unobserve(node) // When the recommendation finally appears on the screen and is processed, stop observing it so it doesn't trigger the observer again.
}
if (maxRepetitions == 1) // If the script is set to show only one-time recommendations, to avoid unnecessary processings,
{ // rightaway mark to hide, in the next time the page is loaded, every video not found in the storage.
if (!isHomepage && !countRelated)
return
if (videoType == "DEFAULT" || videoType == "UPCOMING" && filterPremier || videoType == "LIVE" && filterLives || !videoType)
{
GM.setValue("hide::"+videoUrl,"")
processedVideosList.push("hide::"+videoUrl)
node.classList.add("processed")
}
return
}
else
var value = await GM.getValue(videoUrl)
if (typeof value == "undefined")
{
if (!isHomepage && !countRelated)
return
value = 1
}
else
{
if (value >= maxRepetitions)
{
if (!isHomepage && !filterRelated)
return
hideOrDimm(node, isHomepage)
GM.deleteValue(videoUrl)
GM.setValue("hide::"+videoUrl,"")
processedVideosList.push("hide::"+videoUrl)
node.classList.add("processed")
return
}
if (!isHomepage && !countRelated)
return
value++
}
if (videoType == "DEFAULT" || videoType == "UPCOMING" && filterPremier || videoType == "LIVE" && filterLives || !videoType)
GM.setValue(videoUrl, value)
node.classList.add("processed")
}
}
function addRecommendationMenuItems()
{
const waitForRecommendationMenu = setInterval(function()
{
const recommendationMenu = isMobile ? document.getElementById("menu") : document.querySelector("ytd-menu-renderer yt-icon.ytd-menu-renderer")
if (!recommendationMenu)
return
clearInterval(waitForRecommendationMenu)
if (document.getElementById("hideChannelButton") || document.getElementById("hidePartialTitleButton"))
return
if (isMobile)
{
recommendationMenu.firstChild.appendChild(hideChannelButton)
recommendationMenu.firstChild.appendChild(hidePartialTitleButton)
}
else
{
if (!document.querySelector("ytd-menu-popup-renderer"))
{
recommendationMenu.click()
recommendationMenu.click() // The recommendation menu doesn't exist in the HTML before it's clicked for the first time. This forces it to be created and dismisses it immediately.
}
const blockUserParent = document.querySelector("ytd-menu-popup-renderer")
blockUserParent.style = "max-height: max-content !important; max-width: max-content !important; height: max-content !important; width: 260px !important;" // Change the max width and height so that the new item fits in the menu.
blockUserParent.firstElementChild.style = "width: inherit;"
const waitForRecommendationMenuItem = setInterval(function()
{
const recommendationMenuItem = blockUserParent.querySelector("ytd-menu-service-item-renderer")
if (!recommendationMenuItem)
return
clearInterval(waitForRecommendationMenuItem)
recommendationMenuItem.parentElement.appendChild(hideChannelButton)
recommendationMenuItem.parentElement.appendChild(hidePartialTitleButton)
}, 100)
}
}, 100)
}
function cleanVideoUrl(fullUrl)
{
const urlSplit = fullUrl.split("?") // Separate the page path from the parameters.
const paramsSplit = urlSplit[1].split("&") // Separate each parameter.
for (let i=0; i < paramsSplit.length; i++)
{
if (paramsSplit[i].includes("v=")) // Get the video's id.
return urlSplit[0]+"?"+paramsSplit[i] // Return the cleaned video URL.
}
}
function hideOrDimm(node)
{
if (isHomepage && dimFilteredHomepage || !isHomepage && dimFilteredRelated)
node.style.opacity = 0.4
else
node.style.display = "none"
}