Last active
June 19, 2023 13:25
-
-
Save lbmaian/05853120fb6c1887fd23c6d1132a1c6c to your computer and use it in GitHub Desktop.
YouTube - Remove Duplicate Recommendations
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name YouTube - Remove Duplicate Recommendations | |
// @namespace https://gist.github.com/lbmaian/05853120fb6c1887fd23c6d1132a1c6c | |
// @downloadURL https://gist.github.com/lbmaian/05853120fb6c1887fd23c6d1132a1c6c/raw/youtube-remove-duplicate-recommendations.user.js | |
// @updateURL https://gist.github.com/lbmaian/05853120fb6c1887fd23c6d1132a1c6c/raw/youtube-remove-duplicate-recommendations.user.js | |
// @version 0.5 | |
// @description Remove duplicate recommendations (YouTube bug workaround), optionally filtering: already watched, in current playlist, mixes/playlists, movies | |
// @author lbmaian | |
// @match https://www.youtube.com/* | |
// @exclude https://www.youtube.com/embed/* | |
// @icon https://www.youtube.com/favicon.ico | |
// @run-at document-start | |
// @grant GM_getValue | |
// @grant GM_setValue | |
// @grant GM_registerMenuCommand | |
// @grant GM_unregisterMenuCommand | |
// ==/UserScript== | |
(function() { | |
'use strict'; | |
const DEBUG = false; | |
const logContext = '[YouTube - Remove Duplicate Recommendations]'; | |
var debug; | |
if (DEBUG) { | |
debug = function(...args) { | |
console.debug(logContext, ...args); | |
}; | |
} else { | |
debug = function() {}; | |
} | |
function log(...args) { | |
console.log(logContext, ...args); | |
} | |
function info(...args) { | |
console.info(logContext, ...args); | |
} | |
function warn(...args) { | |
console.warn(logContext, ...args); | |
} | |
function error(...args) { | |
console.error(logContext, ...args); | |
} | |
class MenuOpt { | |
static registry = []; | |
static registerAll() { | |
for (const opt of this.registry) { | |
opt.register(); | |
} | |
} | |
constructor({name, key, defaultValue, prereqs=null, requiresRefresh=false}) { | |
this.name = name; | |
this.key = key; | |
this.value = GM_getValue(key, defaultValue); | |
this.prereqs = prereqs ?? []; | |
this.requiresRefresh = requiresRefresh; | |
MenuOpt.registry.push(this); | |
this.register(); | |
} | |
register() { | |
if (this.handle) { | |
GM_unregisterMenuCommand(this.handle); | |
} | |
for (const prereq of this.prereqs) { | |
if (prereq.opt.value !== prereq.value) { | |
return; | |
} | |
} | |
let menuName = `${this.name}${this.requiresRefresh ? ' (requires refresh to apply)' : ''}`; | |
if (typeof(this.value) === 'boolean') { | |
menuName = `(${this.value ? 'Enabled' : 'Disabled'}) ${menuName}`; | |
this.handle = GM_registerMenuCommand(menuName, () => { | |
this.value = !this.value; | |
GM_setValue(this.key, this.value); | |
MenuOpt.registerAll(); | |
}); | |
} else { | |
menuName = `(${this.value}) ${menuName}`; | |
this.handle = GM_registerMenuCommand(menuName, () => { | |
let value = prompt(menuName); | |
if (value === null) { | |
return; | |
} | |
if (typeof(this.value) === 'number') { | |
value = Number.parseFloat(value); | |
if (Number.isNaN(value)) { | |
return; | |
} | |
} | |
this.value = value; | |
GM_setValue(this.key, this.value); | |
MenuOpt.registerAll(); | |
}); | |
} | |
debug('registered menu', this, 'with name:', menuName); | |
} | |
} | |
const removeInPlaylistOpt = new MenuOpt({ | |
name: 'remove recommended videos in current playlist', | |
key: 'removeInPlaylist', | |
defaultValue: true, | |
}); | |
const removeAlreadyWatchedOpt = new MenuOpt({ | |
name: 'remove already watched recommendations', | |
key: 'removeAlreadyWatched', | |
defaultValue: false, | |
}); | |
const removeMixPlaylistOpt = new MenuOpt({ | |
name: 'remove recommended mixes/playlists', | |
key: 'removeMixPlaylist', | |
defaultValue: false, | |
}); | |
const removeMovieOpt = new MenuOpt({ | |
name: 'remove recommended movies', | |
key: 'removeMovie', | |
defaultValue: false, | |
}); | |
const filterEndScreenOpt = new MenuOpt({ | |
name: 'also filter end screen cards', | |
key: 'filterEndScreen', | |
defaultValue: true, | |
}); | |
// Updates page data response to filter initial recommendations and end screen cards. | |
let previousPageDataResponse = null; | |
function updatePageDataResponse(response, logContext) { | |
if (previousPageDataResponse === response) { | |
debug(logContext, 'same as previous page data response - already updated'); | |
return; | |
} | |
previousPageDataResponse = response; | |
const twoColumnWatchNextResults = response?.contents?.twoColumnWatchNextResults; | |
if (DEBUG) { | |
debug(logContext, 'contents.twoColumnWatchNextResults (snapshot)', window.structuredClone(twoColumnWatchNextResults)); | |
} | |
const watchNextItems = getWatchNextItems({ | |
watchNextSecondaryResults: twoColumnWatchNextResults?.secondaryResults?.secondaryResults?.results, | |
sourceDesc: 'contents.twoColumnWatchNextResults.secondaryResults.secondaryResults.results' | |
}); | |
if (DEBUG) { | |
getRendererEntryMap(watchNextItems, logContext); | |
} | |
if (!watchNextItems.items) { | |
return; | |
} | |
const playlistRendererEntryMap = removeInPlaylistOpt.value && getRendererEntryMap({ | |
items: twoColumnWatchNextResults?.playlist?.contents, | |
sourceDesc: 'contents.twoColumnWatchNextResults.playlist.contents' | |
}); | |
const removedRecommendations = filterRecommendations( | |
watchNextItems.items, | |
null, // assume all initial recommendations are unique, so don't for duplicates within them | |
playlistRendererEntryMap, | |
logContext); | |
if (filterEndScreenOpt.value) { | |
const playerOverlayRenderer = response?.playerOverlays?.playerOverlayRenderer; | |
const endScreenItems = playerOverlayRenderer?.endScreen?.watchNextEndScreenRenderer?.results; | |
if (DEBUG) { | |
debug(logContext, 'playerOverlays.playerOverlayRenderer (snapshot)', window.structuredClone(playerOverlayRenderer)); | |
getRendererEntryMap({ | |
items: endScreenItems, | |
sourceDesc: 'playerOverlays.playerOverlayRenderer.endScreen.watchNextEndScreenRenderer.results' | |
}, logContext); | |
} | |
if (!endScreenItems) { | |
return; | |
} | |
// End screen cards should be a subset of initial recommendations. | |
// This is fortunate since there's no way to determine already watched and movie (maybe &pp=sAQB??) for end screen cards. | |
// Also, page update is the only way to update end screen cards, | |
// since this end screen data is only used once by YT to generate the end screen cards. | |
const removed = new Map(); | |
const newItems = []; | |
for (const item of endScreenItems) { | |
const entry = getRendererEntry(item); | |
if (entry) { | |
const removalReason = removedRecommendations.get(entry.id); | |
if (removalReason) { | |
removed.set(entry.id, removalReason); | |
} else { | |
newItems.push(item); | |
} | |
} else { | |
newItems.push(item); | |
} | |
} | |
if (removed.size) { | |
endScreenItems.splice(0, endScreenItems.length, ...newItems); | |
} | |
log(logContext, 'removed end screen cards', removed); | |
} | |
} | |
// Updates append continuations to filter newly fetched recommendations. | |
function updateAppendContinuationItemsAction(detailArgs, logContext) { | |
const continuationItems = detailArgs?.[0]?.appendContinuationItemsAction?.continuationItems; | |
if (continuationItems?.[0] && getRendererEntry(continuationItems[0])) { // appendContinuationItemsAction is also used for comments | |
if (DEBUG) { | |
debug(logContext, 'continuationItems (snapshot)', window.structuredClone(continuationItems)); | |
} | |
const watchNextSecondaryResults = document.getElementsByTagName('ytd-watch-next-secondary-results-renderer')[0]?.data?.results; | |
const watchNextRendererEntryMap = getRendererEntryMap(getWatchNextItems({ | |
watchNextSecondaryResults, | |
sourceDesc: 'ytd-watch-next-secondary-results-renderer.data.results' | |
}, logContext)); | |
if (!watchNextRendererEntryMap) { | |
return; | |
} | |
const playlistRendererEntryMap = removeInPlaylistOpt.value && getRendererEntryMap({ | |
items: document.getElementsByTagName('yt-playlist-manager')[0]?.playlistComponent?.data?.contents, | |
sourceDesc: 'yt-playlist-manager.playlistComponent.data.contents' | |
}, logContext); | |
filterRecommendations( | |
continuationItems, | |
watchNextRendererEntryMap, | |
playlistRendererEntryMap, | |
logContext); | |
} | |
} | |
function filterRecommendations(items, watchNextRendererIds, playlistRendererIds, logContext) { | |
const removed = new Map(); | |
const newItems = []; | |
for (const item of items) { | |
const entry = getRendererEntry(item); | |
if (entry) { | |
const id = entry.id; | |
if (watchNextRendererIds?.has(id)) { | |
removed.set(id, 'duplicate'); | |
} else if (playlistRendererIds?.has(id)) { | |
removed.set(id, 'in playlist'); | |
} else if (isAlreadyWatched(entry)) { | |
removed.set(id, 'already watched'); | |
} else if (removeMixPlaylistOpt.value && entry.type === 'playlist') { | |
removed.set(id, 'mix/playlist'); | |
} else if (removeMovieOpt.value && entry.type === 'movie') { | |
removed.set(id, 'movie'); | |
} else { | |
newItems.push(item); | |
} | |
} else { | |
newItems.push(item); | |
} | |
} | |
if (removed.size) { | |
items.splice(0, items.length, ...newItems); | |
} | |
log(logContext, 'removed recommendations', removed); | |
return removed; | |
} | |
function isAlreadyWatched(rendererEntry) { | |
if (!removeAlreadyWatchedOpt.value) { | |
return false; | |
} | |
const thumbnailOverlays = rendererEntry.renderer.thumbnailOverlays | |
if (thumbnailOverlays) { | |
for (const thumbnailOverlay of thumbnailOverlays) { | |
if (thumbnailOverlay.thumbnailOverlayResumePlaybackRenderer) { | |
return true; | |
} | |
} | |
} | |
return false; | |
} | |
// watchNextSecondaryResults is either a list of renderer items, | |
// or a list of sections, one of which is the list of renderer items. | |
// Return that list of renderer items. | |
function getWatchNextItems({watchNextSecondaryResults, sourceDesc}) { | |
if (!watchNextSecondaryResults) { | |
return {sourceDesc}; | |
} | |
for (let i = 0; i < watchNextSecondaryResults.length; i++) { | |
const result = watchNextSecondaryResults[i]; | |
if (getRendererEntry(result)) { | |
return {items: watchNextSecondaryResults, sourceDesc}; | |
} | |
const items = result.itemSectionRenderer?.contents; | |
if (items) { | |
return {items, sourceDesc: `${sourceDesc}[${i}].itemSectionRenderer.contents`}; | |
} | |
} | |
return {sourceDesc: `any video/playlist renderers in ${sourceDesc}`}; | |
} | |
// Returns map of video/playlist id to renderer entry for given list of renderer items. | |
// See getRendererEntry. | |
function getRendererEntryMap({items, sourceDesc}, logContext) { | |
if (!items) { | |
return debug(logContext, 'could not find', sourceDesc); | |
} | |
const rendererMap = new Map(); | |
for (const item of items) { | |
const entry = getRendererEntry(item); | |
if (entry) { | |
rendererMap.set(entry.id, entry); | |
} | |
} | |
debug(logContext, sourceDesc, 'video/playlist renderers', rendererMap); | |
if (rendererMap.size) { | |
return rendererMap; | |
} | |
} | |
// Returns {id (video/playlist id), type ('video', 'playlist', or 'movie'), renderer} for given renderer item, | |
// which is an object with a single (renderer key, renderer) entry, which in turn may contain | |
// a videoId (e.g. compactVideoRenderer, playlistPanelVideoRenderer, endScreenVideoRenderer) | |
// or playlistId (e.g. compactPlaylistRenderer, compactRadioRenderer, endScreenPlaylistRenderer). | |
// Assumes that video ids and playlist ids altogether are unique. | |
function getRendererEntry(rendererItem) { | |
for (const rendererKey in rendererItem) { | |
const renderer = rendererItem[rendererKey]; | |
let id = renderer.videoId; | |
if (id) { | |
return {id, type: rendererKey.endsWith('MovieRenderer') ? 'movie' : 'video', renderer}; | |
} | |
id = renderer.playlistId; | |
if (id) { | |
return {id, type: 'playlist', renderer}; | |
} | |
} | |
} | |
// Note: This fires too late - the action has already been handled. | |
// Workaround is to proxy ytd-app.onYtAction_, which is done in setupYtdApp below. | |
// document.addEventListener('yt-action', evt => { | |
// if (evt.detail.actionName !== 'yt-append-continuation-items-action') { | |
// return; | |
// } | |
// debug('yt-action', evt); | |
// }); | |
const symSetup = Symbol(logContext + ' setup'); | |
// yt-page-data-fetched event fires on both new page load and channel tab change. | |
// Need to hook into ytd-app's ytd-app's own yt-page-data-fetched event listener (onYtPageDataFetched), | |
// so that we can modify the data before that event listener fires. | |
function setupYtdApp(ytdApp, logContext, errorFunc=error) { | |
// ytd-app's prototype is initialized after the element is created, | |
// so need to check that the onYtPageDataFetched method exists. | |
if (!ytdApp || !ytdApp.onYtPageDataFetched) { | |
return errorFunc('unexpectedly could not find ytd-app.onYtPageDataFetched'); | |
} | |
if (ytdApp[symSetup]) { | |
return; | |
} | |
debug(logContext, 'found yt-app', ytdApp); | |
const origOnYtPageDataFetched = ytdApp.onYtPageDataFetched; | |
ytdApp.onYtPageDataFetched = function(evt, detail) { | |
debug('ytd-app.onYtPageDataFetched', evt, detail); | |
updatePageDataResponse(detail.pageData.response, 'yt-page-data-fetched pageData.response:'); | |
return origOnYtPageDataFetched.call(this, evt, detail); | |
}; | |
// Note: Following doesn't work because handleServiceRequest_ is registered in ytd-app.actionRouter_ | |
// and called directly there. | |
// const origHandleServiceRequest = ytdApp.handleServiceRequest_; | |
// ytdApp.handleServiceRequest_ = function() { | |
// debug('ytd-app.handleServiceRequest_', arguments); | |
// return origHandleServiceRequest.apply(this, arguments); | |
// }; | |
// Note: Following doesn't work because onYtAction_ is stored in ytd-app.onYtActionBoundListener_, | |
// which is then used directly as an event listener. | |
// const origOnYtAction = ytdApp.onYtAction_; | |
// ytdApp.onYtAction_ = function() { | |
// debug('ytd-app.onYtAction_', arguments); | |
// return origOnYtAction.apply(this, arguments); | |
// }; | |
const origOnYtAction = ytdApp.onYtActionBoundListener_; | |
const onYtAction = function(evt) { | |
if (evt.detail.actionName === 'yt-append-continuation-items-action') { | |
debug('ytd-app.onYtAction yt-append-continuation-items-action', evt); | |
updateAppendContinuationItemsAction(evt.detail.args, `yt-action(${evt.detail.actionName}):`); | |
} | |
return origOnYtAction.call(this, evt); | |
}; | |
ytdApp.onYtActionBoundListener_ = onYtAction.bind(ytdApp); | |
debug('ytd-app hooks set up'); | |
ytdApp[symSetup] = true; | |
} | |
document.addEventListener('attached', evt => { | |
const ytdApp = document.getElementsByTagName('ytd-app')[0]; | |
debug('ytd-app.attached', evt); | |
setupYtdApp(ytdApp, 'ytd-app.attached:'); | |
}); | |
// In case, ytd-app somehow already exists at this point. | |
const ytdApp = document.getElementsByTagName('ytd-app')[0]; | |
setupYtdApp(ytdApp, 'document-start:', () => {}); | |
// Note: updating ytInitialData may not be necessary, since yt-page-data-fetched also fires for new page load, | |
// and in that case, the event's detail.pageData.response is the same object as ytInitialData, | |
// but DOMContentLoaded sometimes fires before ytd-app's onYtPageDataFetched fires (or rather, before we can hook into it), | |
// so this is done just in case. | |
document.addEventListener('DOMContentLoaded', evt => { | |
const ytInitialData = window.eval('ytInitialData'); // eslint-disable-line no-eval | |
debug('DOMContentLoaded ytInitialData', ytInitialData); | |
updatePageDataResponse(ytInitialData, 'DOMContentLoaded ytInitialData:'); | |
}); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment