// ==UserScript== // @name YouTube - Hide force-pushed low-view videos // @namespace https://github.com/BobbyWibowo // @version 1.2.7 // @description Hide videos matching thresholds, in home page, and watch page's sidebar. CONFIGURABLE! // @author Bobby Wibowo // @license MIT // @match *://www.youtube.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com // @run-at document-start // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @require https://cdn.jsdelivr.net/npm/sentinel-js@0.0.7/dist/sentinel.min.js // @noframes // @downloadURL https://update.greasyfork.icu/scripts/535120/YouTube%20-%20Hide%20force-pushed%20low-view%20videos.user.js // @updateURL https://update.greasyfork.icu/scripts/535120/YouTube%20-%20Hide%20force-pushed%20low-view%20videos.meta.js // ==/UserScript== /* global sentinel */ (function () { 'use strict'; const _LOG_TIME_FORMAT = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3 }); const log = (message, ...args) => { const prefix = `[${_LOG_TIME_FORMAT.format(Date.now())}]: `; if (typeof message === 'string') { return console.log(prefix + message, ...args); } else { return console.log(prefix, message, ...args); } }; /** CONFIG **/ /* It's recommended to edit these values through your userscript manager's storage/values editor. * Visit YouTube once after installing the script to allow it to populate its storage with default values. * Especially necessary for Tampermonkey to show the script's Storage tab when Advanced mode is turned on. */ const ENV_DEFAULTS = { MODE: 'PROD', VIEWS_THRESHOLD: 999, VIEWS_THRESHOLD_NEW: null, // this may only work for EN locale VIEWS_THRESHOLD_LIVE: null, // based on the livestream's accumulative views count reported by YouTube API ALLOWED_CHANNEL_IDS: [], DISABLE_STYLES: false, SELECTORS_ALLOWED_PAGE: null, SELECTORS_VIDEO: null }; /* Hard-coded preset values. * Specifying custom values will extend instead of replacing them. */ const PRESETS = { // To ensure any custom values will be inserted into array, or combined together if also an array. ALLOWED_CHANNEL_IDS: [], // Keys that starts with "SELECTORS_", and in array, will automatically be converted to single-line strings. SELECTORS_ALLOWED_PAGE: [ 'ytd-browse[page-subtype="home"]:not([hidden])', // home 'ytd-watch-flexy:not([hidden])' // watch page ], SELECTORS_VIDEO: [ 'ytd-compact-video-renderer:has(#dimissible)', 'ytd-rich-item-renderer:has(#dismissible)', 'yt-lockup-view-model', 'ytd-rich-item-renderer:has(ytm-shorts-lockup-view-model-v2)', '#items > ytm-shorts-lockup-view-model-v2' ] }; const ENV = {}; // Store default values. for (const key of Object.keys(ENV_DEFAULTS)) { const stored = GM_getValue(key); if (stored === null || stored === undefined) { ENV[key] = ENV_DEFAULTS[key]; GM_setValue(key, ENV_DEFAULTS[key]); } else { ENV[key] = stored; } } const _DOCUMENT_FRAGMENT = document.createDocumentFragment(); const queryCheck = selector => _DOCUMENT_FRAGMENT.querySelector(selector); const isSelectorValid = selector => { try { queryCheck(selector); } catch { return false; } return true; }; const CONFIG = {}; // Extend hard-coded preset values with user-defined custom values, if applicable. for (const key of Object.keys(ENV)) { if (key.startsWith('SELECTORS_')) { if (Array.isArray(PRESETS[key])) { CONFIG[key] = PRESETS[key].join(', '); } else { CONFIG[key] = PRESETS[key] || ''; } if (ENV[key]) { CONFIG[key] += `, ${Array.isArray(ENV[key]) ? ENV[key].join(', ') : ENV[key]}`; } if (!isSelectorValid(CONFIG[key])) { console.error(`${key} contains invalid selector =`, CONFIG[key]); return; } } else if (Array.isArray(PRESETS[key])) { CONFIG[key] = PRESETS[key]; if (ENV[key]) { const customValues = Array.isArray(ENV[key]) ? ENV[key] : ENV[key].split(',').map(s => s.trim()); CONFIG[key].push(...customValues); } } else { CONFIG[key] = PRESETS[key] || null; if (ENV[key] !== null) { CONFIG[key] = ENV[key]; } } } let logDebug = () => {}; if (CONFIG.MODE !== 'PROD') { logDebug = log; for (const key of Object.keys(CONFIG)) { logDebug(`${key} =`, CONFIG[key]); } } /** STYLES **/ // Styling that must always be enabled for the script's core functionalities. GM_addStyle(/*css*/` :is(${CONFIG.SELECTORS_ALLOWED_PAGE}) :is(${CONFIG.SELECTORS_VIDEO}) { transition: 0.25s opacity; } /* Visually hide, while still letting the element occupy the space. * To prevent YouTube from infinitely loading more videos. */ :is(${CONFIG.SELECTORS_ALLOWED_PAGE}) :is(${CONFIG.SELECTORS_VIDEO}):not([data-noview_views], [data-noview_allowed_channel]) { visibility: hidden; opacity: 0; } [data-noview_threshold_unmet] { display: none !important; } `); if (!CONFIG.DISABLE_STYLES) { GM_addStyle(/*css*/` [data-noview_allowed_channel] #metadata-line span:nth-last-child(2 of .inline-metadata-item), [data-noview_allowed_channel] yt-content-metadata-view-model div:nth-child(2) span:nth-last-child(2 of .yt-core-attributed-string) { font-style: italic !important; } /* Fix YouTube's home styling when some videos are hidden. */ ytd-browse[page-subtype="home"] ytd-rich-item-renderer[rendered-from-rich-grid][is-in-first-column], ytd-browse[page-subtype="home"] #content.ytd-rich-section-renderer { margin-left: calc(var(--ytd-rich-grid-item-margin) / 2) !important; } ytd-browse[page-subtype="home"] #contents.ytd-rich-grid-renderer { padding-left: calc(var(--ytd-rich-grid-item-margin) / 2 + var(--ytd-rich-grid-gutter-margin)) !important; } `); } /** UTILS **/ const waitPageLoaded = () => { return new Promise(resolve => { if (document.readyState === 'complete' || document.readyState === 'loaded' || document.readyState === 'interactive') { resolve(); } else { document.addEventListener('DOMContentLoaded', resolve); } }); }; class DataCache { cache; init; cacheLimit; constructor (init, cacheLimit = 2000) { this.cache = {}; this.init = init; this.cacheLimit = cacheLimit; } getFromCache (key) { return this.cache[key]; } setupCache (key) { if (!this.cache[key]) { this.cache[key] = { ...this.init(), lastUsed: Date.now() }; if (Object.keys(this.cache).length > this.cacheLimit) { const oldest = Object.entries(this.cache).reduce((a, b) => a[1].lastUsed < b[1].lastUsed ? a : b); delete this.cache[oldest[0]]; } } return this.cache[key]; } cacheUsed (key) { if (this.cache[key]) this.cache[key].lastUsed = Date.now(); return !!this.cache[key]; } } const isPartialElementInViewport = element => { if (element.style.display === 'none') { return false; } const rect = element.getBoundingClientRect(); const windowHeight = window.innerHeight || document.documentElement.clientHeight; const windowWidth = window.innerWidth || document.documentElement.clientWidth; const vertInView = (rect.top <= windowHeight) && ((rect.top + rect.height) >= 0); const horzInView = (rect.left <= windowWidth) && ((rect.left + rect.width) >= 0); return (vertInView && horzInView); }; let intersectionObserver = null; let isPageAllowed = false; window.addEventListener('yt-navigate-start', event => { isPageAllowed = false; // Clear previous intersection observer. if (intersectionObserver !== null) { intersectionObserver.disconnect(); intersectionObserver = null; } }); window.addEventListener('yt-page-data-updated', event => { isPageAllowed = Boolean(document.querySelector(CONFIG.SELECTORS_ALLOWED_PAGE)); if (isPageAllowed) { // Re-init intersection observer. intersectionObserver = new IntersectionObserver(entries => { for (const entry of entries) { if (entry.isIntersecting) { doVideo(entry.target); intersectionObserver.unobserve(entry.target); } } }, { delay: 100, threshold: 0 }); logDebug('Page allowed, waiting for videos\u2026'); } else { logDebug('Page not allowed.'); } }); /** MAIN **/ const emptyMetadata = { channelIDs: null, author: null, isLive: null, isUpcoming: null, viewCount: null }; const fetchVideoDataDesktopClient = async videoID => { const url = 'https://www.youtube.com/youtubei/v1/player'; const data = { context: { client: { clientName: 'WEB', clientVersion: '2.20230327.07.00' } }, videoId: videoID }; try { const result = await fetch(url, { body: JSON.stringify(data), headers: { 'Content-Type': 'application/json' }, method: 'POST' }); if (result.ok) { const response = await result.json(); const newVideoID = response?.videoDetails?.videoId ?? null; if (newVideoID !== videoID) { return structuredClone(emptyMetadata); } const channelIds = new Set(); if (response?.videoDetails?.channelId) { channelIds.add(response?.videoDetails?.channelId); } // To get IDs of parent channel for auto-generated topic channels. const subscribeChannelIds = response?.playerConfig?.webPlayerConfig?.webPlayerActionsPorting?.subscribeCommand?.subscribeEndpoint?.channelIds; if (subscribeChannelIds?.length) { for (const id of subscribeChannelIds) { channelIds.add(id); } } const author = response?.videoDetails?.author ?? null; const isLive = response?.videoDetails?.isLive ?? null; const isUpcoming = response?.videoDetails?.isUpcoming ?? null; const viewCount = response?.videoDetails?.viewCount ?? null; const playabilityStatus = response?.playabilityStatus?.status ?? null; return { channelIDs: channelIds, author, isLive, isUpcoming, viewCount, playabilityStatus }; } } catch (e) {} return structuredClone(emptyMetadata); }; const videoMetadataCache = new DataCache(() => (structuredClone(emptyMetadata))); const waitingForMetadata = []; function setupMetadataOnRecieve () { const onMessage = event => { if (event.data?.type === 'youtube-noview:video-metadata-received') { const data = event.data; if (data.videoID && data.metadata && !videoMetadataCache.getFromCache(data.videoID)) { const metadata = data.metadata; const cachedData = videoMetadataCache.setupCache(data.videoID); cachedData.channelIDs = metadata.channelIDs; cachedData.author = metadata.author; cachedData.isLive = metadata.isLive; cachedData.isUpcoming = metadata.isUpcoming; cachedData.viewCount = metadata.viewCount; const index = waitingForMetadata.findIndex((item) => item.videoID === data.videoID); if (index !== -1) { waitingForMetadata[index].callbacks.forEach((callback) => { callback(data.metadata); }); waitingForMetadata.splice(index, 1); } } } else if (event.data?.type === 'youtube-noview:video-metadata-requested' && !(event.data.videoID in activeRequests)) { waitingForMetadata.push({ videoID: event.data.videoID, callbacks: [] }); } }; window.addEventListener('message', onMessage); } const activeRequests = {}; const fetchVideoMetadata = async videoID => { const cachedData = videoMetadataCache.getFromCache(videoID); if (cachedData && cachedData.viewCount !== null) { return cachedData; } let waiting = waitingForMetadata.find(item => item.videoID === videoID); if (waiting) { return new Promise((resolve) => { if (!waiting) { waiting = { videoID, callbacks: [] }; waitingForMetadata.push(waiting); } waiting.callbacks.push(metadata => { videoMetadataCache.cacheUsed(videoID); resolve(metadata); }); }); } try { const result = activeRequests[videoID] ?? (async () => { window.postMessage({ type: 'maze-utils:video-metadata-requested', videoID }, '*'); const metadata = await fetchVideoDataDesktopClient(videoID).catch(() => null); if (metadata) { const videoCache = videoMetadataCache.setupCache(videoID); videoCache.channelIDs = metadata.channelIDs; videoCache.author = metadata.author; videoCache.isLive = metadata.isLive; videoCache.isUpcoming = metadata.isUpcoming; videoCache.viewCount = metadata.viewCount; // Remove this from active requests after it's been dealt with in other places setTimeout(() => delete activeRequests[videoID], 500); window.postMessage({ type: 'youtube-noview:video-metadata-received', videoID, metadata: videoCache }, '*'); return videoCache; } const _emptyMetadata = structuredClone(emptyMetadata); window.postMessage({ type: 'youtube-noview:video-metadata-received', videoID, metadata: _emptyMetadata }, '*'); return _emptyMetadata; })(); activeRequests[videoID] = result; return await result; } catch (e) { } return structuredClone(emptyMetadata); }; const getVideoData = async element => { const videoLink = element.querySelector('a[href]'); if (!videoLink?.href) { return null; } const url = videoLink.href; let urlObject; try { urlObject = new URL(url); } catch (error) { log('Unable to parse URL:', url); return null; } let videoID; if (urlObject.searchParams.has('v') && ['/watch', '/watch/'].includes(urlObject.pathname)) { videoID = urlObject.searchParams.get('v'); } else if (urlObject.pathname.match(/^\/embed\/|^\/shorts\/|^\/live\//)) { try { const id = urlObject.pathname.split('/')[2]; if (id?.length >= 11) { videoID = id.slice(0, 11); } } catch (e) { log('Video ID not valid for:', url); } } if (!videoID) { return null; } let channelId; if (CONFIG.ALLOWED_CHANNEL_IDS.length) { // Attempt to get channel ID early. if (element.tagName === 'YT-LOCKUP-VIEW-MODEL') { const symbols = Object.getOwnPropertySymbols(element.componentProps?.data ?? {}); if (symbols.length) { const metadata = element.componentProps.data[symbols[0]].value?.metadata?.lockupMetadataViewModel; channelId = metadata?.image?.decoratedAvatarViewModel?.rendererContext?.commandContext?.onTap ?.innertubeCommand?.browseEndpoint?.browseId; } } else { const dismissible = element.querySelector('#dismissible'); if (dismissible) { const data = dismissible.__dataHost?.__data?.data; channelId = data?.owner?.navigationEndpoint?.browseEndpoint?.browseId || data?.longBylineText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId; } } } if (channelId && CONFIG.ALLOWED_CHANNEL_IDS.includes(channelId)) { logDebug('Skipped metadata fetch due to allowed channel', element); return { videoID, allowedChannel: channelId }; } const metadata = await fetchVideoMetadata(videoID); return { videoID, metadata }; }; const isVideoNew = element => { if (element.tagName === 'YT-LOCKUP-VIEW-MODEL') { const badges = Array.from(element.querySelectorAll('yt-content-metadata-view-model .badge-shape-wiz__text')); return badges.some(badge => badge?.innerText === 'New'); } else { return Boolean(element.querySelector('#dismissible .badge[aria-label="New"]')); } }; function handleVideoUpdate () { const element = this; if (element.dataset.noview_threshold_unmet) { logDebug(`Resetting old statuses (${element.dataset.noview_views} < ${element.dataset.noview_threshold_unmet})`, element); delete element.dataset.noview_views; // Deleting this removes "display: none", which will trigger doVideo() via sentinel. delete element.dataset.noview_threshold_unmet; } else { delete element.dataset.noview_views; doVideo(element); } }; const doVideo = async element => { // Listen to this event to handle dynamic update (during page navigation). element.addEventListener('yt-enable-lockup-interaction', handleVideoUpdate); const data = await getVideoData(element); if (!data) { return false; } if (CONFIG.ALLOWED_CHANNEL_IDS.length) { delete element.dataset.noview_allowed_channel; if (data.allowedChannel) { // Through early check via DOM properties. element.dataset.noview_channel_ids = JSON.stringify([data.allowedChannel]); element.dataset.noview_allowed_channel = true; return false; } else if (data.metadata.channelIDs?.size) { // Through metadata fetch from API. element.dataset.noview_channel_ids = JSON.stringify([...data.metadata.channelIDs]); if (CONFIG.ALLOWED_CHANNEL_IDS.some(id => data.metadata.channelIDs.has(id))) { element.dataset.noview_allowed_channel = true; return false; } } } if (data.metadata.viewCount === null) { logDebug('Unable to access views data', element); return false; } const viewCount = parseInt(data.metadata.viewCount); let thresholdUnmet = null; if (CONFIG.VIEWS_THRESHOLD_LIVE !== null && data.metadata.isLive) { if (viewCount <= CONFIG.VIEWS_THRESHOLD_LIVE) { thresholdUnmet = CONFIG.VIEWS_THRESHOLD_LIVE; } } else { // Do not look for New badge if thresholds are identical. const isNew = CONFIG.VIEWS_THRESHOLD_NEW !== null && (CONFIG.VIEWS_THRESHOLD_NEW !== CONFIG.VIEWS_THRESHOLD) && isVideoNew(element); if (isNew) { if (viewCount <= CONFIG.VIEWS_THRESHOLD_NEW) { thresholdUnmet = CONFIG.VIEWS_THRESHOLD_NEW; } } else { if (viewCount <= CONFIG.VIEWS_THRESHOLD) { thresholdUnmet = CONFIG.VIEWS_THRESHOLD; } } } if (thresholdUnmet !== null) { log(`Hid video (${viewCount} <= ${thresholdUnmet})`, element); element.dataset.noview_threshold_unmet = thresholdUnmet; } element.dataset.noview_views = viewCount; return true; }; /** SENTINEL */ waitPageLoaded().then(() => { setupMetadataOnRecieve(); sentinel.on(CONFIG.SELECTORS_VIDEO, element => { if (!isPageAllowed) { return false; } if (isPartialElementInViewport(element)) { doVideo(element); } else { // If not in viewport, observe intersection. intersectionObserver.observe(element); } }); }); })();