Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save lbmaian/05853120fb6c1887fd23c6d1132a1c6c to your computer and use it in GitHub Desktop.
Save lbmaian/05853120fb6c1887fd23c6d1132a1c6c to your computer and use it in GitHub Desktop.
YouTube - Remove Duplicate Recommendations
// ==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