// ==UserScript==
// @name YouTube Sub Feed Filter 2
// @version 0.2
// @description Filters your YouTube subscriptions feed.
// @author Callum Latham
// @namespace https://greasyfork.org/users/696211-ctl2
// @license MIT
// @match *://www.youtube.com/*
// @match *://youtube.com/*
// @require https://greasyfork.org/scripts/446506-tree-frame-2/code/Tree%20Frame%202.js?version=1076104
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.deleteValue
// ==/UserScript==
// User config
const LONG_PRESS_TIME = 400;
const REGEXP_FLAGS = 'i';
// Dev config
const VIDEO_TYPE_IDS = {
'GROUPS': {
'ALL': 'All',
'STREAMS': 'Streams',
'PREMIERS': 'Premiers',
'NONE': 'None'
},
'INDIVIDUALS': {
'STREAMS_SCHEDULED': 'Scheduled Streams',
'STREAMS_LIVE': 'Live Streams',
'STREAMS_FINISHED': 'Finished Streams',
'PREMIERS_SCHEDULED': 'Scheduled Premiers',
'PREMIERS_LIVE': 'Live Premiers',
'SHORTS': 'Shorts',
'NORMAL': 'Basic Videos'
}
};
const FRAME_STYLE = {
'OUTER': {'zIndex': 10000},
'INNER': {
'headBase': '#ff0000',
'headButtonExit': '#000000',
'borderHead': '#ffffff',
'nodeBase': ['#222222', '#111111'],
'borderTooltip': '#570000'
}
};
const TITLE = 'YouTube Sub Feed Filter';
const KEY_TREE = 'YTSFF_TREE';
const KEY_IS_ACTIVE = 'YTSFF_IS_ACTIVE';
function getVideoTypes(children) {
const registry = new Set();
const register = (value) => {
if (registry.has(value)) {
throw new Error(`Overlap found at '${value}'.`);
}
registry.add(value);
};
for (const {value} of children) {
switch (value) {
case VIDEO_TYPE_IDS.GROUPS.ALL:
Object.values(VIDEO_TYPE_IDS.INDIVIDUALS).forEach(register);
break;
case VIDEO_TYPE_IDS.GROUPS.STREAMS:
register(VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_SCHEDULED);
register(VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_LIVE);
register(VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_FINISHED);
break;
case VIDEO_TYPE_IDS.GROUPS.PREMIERS:
register(VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERS_SCHEDULED);
register(VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERS_LIVE);
break;
default:
register(value);
}
}
return registry;
}
const CUTOFF_VALUES = [
'Minimum',
'Maximum'
];
const BADGE_VALUES = [
'Exclude',
'Include',
'Require'
];
const DEFAULT_TREE = (() => {
const regexPredicate = (value) => {
try {
RegExp(value);
} catch (e) {
return 'Value must be a valid regular expression.';
}
return true;
};
return {
'children': [
{
'label': 'Filters',
'children': [],
'seed': {
'label': 'Filter Name',
'value': '',
'children': [
{
'label': 'Channel Regex',
'children': [],
'seed': {
'value': '^',
'predicate': regexPredicate
}
},
{
'label': 'Video Regex',
'children': [],
'seed': {
'value': '^',
'predicate': regexPredicate
}
},
{
'label': 'Video Types',
'children': [{
'value': VIDEO_TYPE_IDS.GROUPS.ALL,
'predicate': Object.values(VIDEO_TYPE_IDS.GROUPS).concat(Object.values(VIDEO_TYPE_IDS.INDIVIDUALS))
}],
'seed': {
'value': VIDEO_TYPE_IDS.GROUPS.NONE,
'predicate': Object.values(VIDEO_TYPE_IDS.GROUPS).concat(Object.values(VIDEO_TYPE_IDS.INDIVIDUALS))
},
'childPredicate': (children) => {
try {
getVideoTypes(children);
} catch ({message}) {
return message;
}
return true;
}
}
]
}
},
{
'label': 'Cutoffs',
'children': [
{
'label': 'Watched (%)',
'children': [],
'seed': {
'childPredicate': ([{'value': boundary}, {value}]) => {
if (boundary === CUTOFF_VALUES[0]) {
return value < 100 ? true : 'Minimum must be less than 100%';
}
return value > 0 ? true : 'Maximum must be greater than 0%';
},
'children': [
{
'value': CUTOFF_VALUES[1],
'predicate': CUTOFF_VALUES
},
{
'value': 100
}
]
}
},
{
'label': 'View Count',
'children': [],
'seed': {
'childPredicate': ([{'value': boundary}, {value}]) => {
if (boundary === CUTOFF_VALUES[1]) {
return value > 0 ? true : 'Maximum must be greater than 0';
}
return true;
},
'children': [
{
'value': CUTOFF_VALUES[0],
'predicate': CUTOFF_VALUES
},
{
'value': 0,
'predicate': (value) => Math.floor(value) === value ? true : 'Value must be an integer'
}
]
}
},
{
'label': 'Duration (Minutes)',
'children': [],
'seed': {
'childPredicate': ([{'value': boundary}, {value}]) => {
if (boundary === CUTOFF_VALUES[1]) {
return value > 0 ? true : 'Maximum must be greater than 0';
}
return true;
},
'children': [
{
'value': CUTOFF_VALUES[0],
'predicate': CUTOFF_VALUES
},
{
'value': 0
}
]
}
}
]
},
{
'label': 'Badges',
'children': [
{
'label': 'Verified',
'value': BADGE_VALUES[1],
'predicate': BADGE_VALUES
},
{
'label': 'Official Artist',
'value': BADGE_VALUES[1],
'predicate': BADGE_VALUES
}
]
}
]
};
})();
function getConfig([filters, cutoffs, badges]) {
return {
'filters': (() => {
const getRegex = ({children}) => new RegExp(children.length === 0 ? '' :
children.map(({value}) => `(${value})`).join('|'), REGEXP_FLAGS);
return filters.children.map(({'children': [channel, video, type]}) => ({
'channels': getRegex(channel),
'videos': getRegex(video),
'types': type.children.length === 0 ? Object.values(VIDEO_TYPE_IDS.INDIVIDUALS) : getVideoTypes(type.children)
}));
})(),
'cutoffs': cutoffs.children.map(({children}) => {
const boundaries = [Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY];
for (const {'children': [{'value': boundary}, {value}]} of children) {
boundaries[boundary === CUTOFF_VALUES[0] ? 0 : 1] = value;
}
return boundaries;
}),
'badges': badges.children.map(({value}) => BADGE_VALUES.indexOf(value))
};
}
// Video element helpers
function getAllSections() {
return [...document
.querySelector('.ytd-page-manager[page-subtype="subscriptions"]')
.querySelectorAll('ytd-item-section-renderer')
];
}
function getAllVideos(section) {
return [...section.querySelectorAll('ytd-grid-video-renderer')];
}
function firstWordEquals(element, word) {
return element.innerText.split(' ')[0] === word;
}
function getVideoBadges(video) {
const container = video.querySelector('#video-badges');
return container ? [...container.querySelectorAll('.badge')] : [];
}
function getChannelBadges(video) {
const container = video.querySelector('ytd-badge-supported-renderer.ytd-channel-name');
return container ? [...container.querySelectorAll('.badge')] : [];
}
function getMetadataLine(video) {
return video.querySelector('#metadata-line');
}
function isScheduled(video) {
return VIDEO_PREDICATES[VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_SCHEDULED](video) ||
VIDEO_PREDICATES[VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERS_SCHEDULED](video);
}
function isLive(video) {
return VIDEO_PREDICATES[VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_LIVE](video) ||
VIDEO_PREDICATES[VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERS_LIVE](video);
}
// Config testers
const VIDEO_PREDICATES = {
[VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_SCHEDULED]: (video) => {
const [schedule] = getMetadataLine(video).children;
return firstWordEquals(schedule, 'Scheduled');
},
[VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_LIVE]: (video) => {
for (const badge of getVideoBadges(video)) {
if (firstWordEquals(badge.querySelector('span.ytd-badge-supported-renderer'), 'LIVE')) {
return true;
}
}
return false;
},
[VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_FINISHED]: (video) => {
const {children} = getMetadataLine(video);
return children.length > 1 && firstWordEquals(children[1], 'Streamed');
},
[VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERS_SCHEDULED]: (video) => {
const [schedule] = getMetadataLine(video).children;
return firstWordEquals(schedule, 'Premieres');
},
[VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERS_LIVE]: (video) => {
for (const badge of getVideoBadges(video)) {
const text = badge.querySelector('span.ytd-badge-supported-renderer');
if (firstWordEquals(text, 'PREMIERING') || firstWordEquals(text, 'PREMIERE')) {
return true;
}
}
return false;
},
[VIDEO_TYPE_IDS.INDIVIDUALS.SHORTS]: (video) => {
let icon = video.querySelector('[overlay-style]');
return icon && icon.getAttribute('overlay-style') === 'SHORTS';
},
[VIDEO_TYPE_IDS.INDIVIDUALS.NORMAL]: (video) => {
const [, {innerText}] = getMetadataLine(video).children;
return new RegExp('^\\d+ .+ ago$').test(innerText);
}
};
const CUTOFF_GETTERS = [
// Watched %
(video) => {
const progressBar = video.querySelector('#progress');
if (!progressBar) {
return 0;
}
return Number.parseInt(progressBar.style.width.slice(0, -1));
},
// View count
(video) => {
if (isScheduled(video)) {
return 0;
}
const [{innerText}] = getMetadataLine(video).children;
const [valueString] = innerText.split(' ');
const lastChar = valueString.slice(-1);
if (/\d/.test(lastChar)) {
return Number.parseInt(valueString);
}
const valueNumber = Number.parseFloat(valueString.slice(0, -1));
switch (lastChar) {
case 'B':
return valueNumber * 1000000000;
case 'M':
return valueNumber * 1000000;
case 'K':
return valueNumber * 1000;
}
return valueNumber;
},
// Duration (minutes)
(video) => {
const timeElement = video.querySelector('ytd-thumbnail-overlay-time-status-renderer');
let minutes = 0;
if (timeElement) {
const timeParts = timeElement.innerText.split(':').map((_) => Number.parseInt(_));
let timeValue = 1 / 60;
for (let i = timeParts.length - 1; i >= 0; --i) {
minutes += timeParts[i] * timeValue;
timeValue *= 60;
}
}
return Number.isNaN(minutes) ? 0 : minutes;
}
];
const BADGE_PREDICATES = [
// Verified
(video) => getChannelBadges(video)
.some((badge) => badge.classList.contains('badge-style-type-verified')),
// Official Artist
(video) => getChannelBadges(video)
.some((badge) => badge.classList.contains('badge-style-type-verified-artist'))
];
// Hider functions
function hideSection(section, doHide = true) {
if (section.matches(':first-child')) {
const title = section.querySelector('#title');
if (doHide) {
section.style.height = '0';
section.style.borderBottom = 'none';
title.style.display = 'none';
} else {
section.style.removeProperty('height');
section.style.removeProperty('borderBottom');
title.style.removeProperty('display');
}
} else {
if (doHide) {
section.style.display = 'none';
} else {
section.style.removeProperty('display');
}
}
}
function hideVideo(video, doHide = true) {
if (doHide) {
video.style.display = 'none';
} else {
video.style.removeProperty('display');
}
}
function loadVideo(video) {
return new Promise((resolve) => {
const test = () => {
if (video.querySelector('#interaction.yt-icon-button')) {
resolve();
}
};
test();
new MutationObserver(test)
.observe(video, {
'childList ': true,
'subtree': true,
'attributes': true,
'attributeOldValue': true
});
});
}
function shouldHide({filters, cutoffs, badges}, video) {
for (let i = 0; i < BADGE_PREDICATES.length; ++i) {
if (badges[i] !== 1 && Boolean(badges[i]) !== BADGE_PREDICATES[i](video)) {
return true;
}
}
for (let i = 0; i < CUTOFF_GETTERS.length; ++i) {
const [lowerBound, upperBound] = cutoffs[i];
const value = CUTOFF_GETTERS[i](video);
if (value < lowerBound || value > upperBound) {
return true;
}
}
// Separate the section's videos by hideability
for (const {'channels': channelRegex, 'videos': videoRegex, types} of filters) {
if (
channelRegex.test(video.querySelector('a.yt-formatted-string').innerText) &&
videoRegex.test(video.querySelector('a#video-title').innerText)
) {
for (const type of types) {
if (VIDEO_PREDICATES[type](video)) {
return true;
}
}
}
}
return false;
}
async function hideFromSections(config, sections = getAllSections()) {
for (const section of sections) {
if (section.matches('ytd-continuation-item-renderer')) {
continue;
}
const videos = getAllVideos(section);
let hiddenCount = 0;
// Process all videos in the section in parallel
await Promise.all(videos.map((video) => new Promise(async (resolve) => {
await loadVideo(video);
if (shouldHide(config, video)) {
hideVideo(video);
hiddenCount++;
}
resolve();
})));
// Hide hideable videos
if (hiddenCount === videos.length) {
hideSection(section);
}
}
}
async function hideFromMutations(mutations) {
const sections = [];
for (const {addedNodes} of mutations) {
for (const section of addedNodes) {
sections.push(section);
}
}
hideFromSections(getConfig(await getForest(KEY_TREE, DEFAULT_TREE)), sections);
}
// Helpers
function resetConfig() {
for (const section of getAllSections()) {
hideSection(section, false);
for (const video of getAllVideos(section)) {
hideVideo(video, false);
}
}
}
function getButtonDock() {
return document
.querySelector('ytd-browse[page-subtype="subscriptions"]')
.querySelector('#title-container')
.querySelector('#top-level-buttons-computed');
}
// Button
class ClickHandler {
constructor(button, onShortClick, onLongClick) {
this.onShortClick = (function() {
onShortClick();
window.clearTimeout(this.longClickTimeout);
window.removeEventListener('mouseup', this.onShortClick);
}).bind(this);
this.onLongClick = (function() {
window.removeEventListener('mouseup', this.onShortClick);
onLongClick();
}).bind(this);
this.longClickTimeout = window.setTimeout(this.onLongClick, LONG_PRESS_TIME);
window.addEventListener('mouseup', this.onShortClick);
}
}
class Button {
constructor(pageManager) {
this.pageManager = pageManager;
this.element = this.getNewButton();
this.element.addEventListener('mousedown', this.onMouseDown.bind(this));
GM.getValue(KEY_IS_ACTIVE, true).then((isActive) => {
this.isActive = isActive;
if (isActive) {
this.setButtonActive();
this.pageManager.start();
}
});
}
addToDOM(button = this.element) {
const {parentElement} = getButtonDock();
parentElement.appendChild(button);
}
getNewButton() {
const openerTemplate = getButtonDock().children[1];
const button = openerTemplate.cloneNode(false);
this.addToDOM(button);
button.innerHTML = openerTemplate.innerHTML;
button.querySelector('button').innerHTML = openerTemplate.querySelector('button').innerHTML;
button.querySelector('a').removeAttribute('href');
// TODO Build the svg via javascript
button.querySelector('yt-icon').innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" focusable="false" viewBox="-50 -50 400 400"><g><path d="M128.25,175.6c1.7,1.8,2.7,4.1,2.7,6.6v139.7l60-51.3v-88.4c0-2.5,1-4.8,2.7-6.6L295.15,65H26.75L128.25,175.6z"/><rect x="13.95" y="0" width="294" height="45"/></g></svg>`;
return button;
}
hide() {
this.element.style.display = 'none';
}
show() {
this.element.parentElement.appendChild(this.element);
this.element.style.removeProperty('display');
}
setButtonActive() {
if (this.isActive) {
this.element.classList.add('style-blue-text');
this.element.classList.remove('style-opacity');
} else {
this.element.classList.add('style-opacity');
this.element.classList.remove('style-blue-text');
}
}
toggleActive() {
this.isActive = !this.isActive;
this.setButtonActive();
GM.setValue(KEY_IS_ACTIVE, this.isActive);
if (this.isActive) {
this.pageManager.start();
} else {
this.pageManager.stop();
}
}
onLongClick() {
editForest(KEY_TREE, DEFAULT_TREE, TITLE, FRAME_STYLE.INNER, FRAME_STYLE.OUTER)
.then((forest) => {
if (this.isActive) {
resetConfig();
// Hide filtered videos
hideFromSections(getConfig(forest));
}
})
.catch((error) => {
console.error(error);
if (window.confirm(
`[${TITLE}]` +
'\n\nYour config\'s structure is invalid.' +
'\nThis could be due to a script update or your data being corrupted.' +
'\n\nError Message:' +
`\n${error}` +
'\n\nWould you like to erase your data?'
)) {
GM.deleteValue(KEY_TREE);
}
});
}
async onMouseDown(event) {
if (event.button === 0) {
new ClickHandler(this.element, this.toggleActive.bind(this), this.onLongClick.bind(this));
}
}
}
// Page load/navigation handler
class PageManager {
constructor() {
// Don't run in frames (e.g. stream chat frame)
if (window.parent !== window) {
return;
}
this.videoObserver = new MutationObserver(hideFromMutations);
const emitter = document.getElementById('page-manager');
const event = 'yt-action';
const onEvent = ({detail}) => {
if (detail.actionName === 'ytd-update-grid-state-action') {
this.onLoad();
emitter.removeEventListener(event, onEvent);
}
};
emitter.addEventListener(event, onEvent);
}
start() {
getForest(KEY_TREE, DEFAULT_TREE).then(forest => {
// Call hide function when new videos are loaded
this.videoObserver.observe(
document.querySelector('ytd-browse[page-subtype="subscriptions"]').querySelector('div#contents'),
{childList: true}
);
try {
hideFromSections(getConfig(forest));
} catch (error) {
console.error(error);
window.alert(
`[${TITLE}]` +
'\n\nUnable to execute filter; Expected config structure may have changed.' +
'\nTry opening and closing the config editor to update your data\'s structure.'
);
}
});
}
stop() {
this.videoObserver.disconnect();
resetConfig();
}
isSubPage() {
return new RegExp('^.*youtube.com/feed/subscriptions(\\?flow=1|\\?pbjreload=\\d+)?$').test(document.URL);
}
isGridView() {
return document.querySelector('ytd-expanded-shelf-contents-renderer') === null;
}
onLoad() {
// Allow configuration
if (this.isSubPage() && this.isGridView()) {
this.button = new Button(this);
this.button.show();
}
document.body.addEventListener('yt-navigate-finish', (function({detail}) {
this.onNavigate(detail);
}).bind(this));
document.body.addEventListener('popstate', (function({state}) {
this.onNavigate(state);
}).bind(this));
}
onNavigate({endpoint}) {
if (endpoint.browseEndpoint) {
const {params, browseId} = endpoint.browseEndpoint;
if ((params === 'MAE%3D' || (!params && this.isGridView())) && browseId === 'FEsubscriptions') {
if (!this.button) {
this.button = new Button(this);
}
this.button.show();
this.start();
} else {
if (this.button) {
this.button.hide();
}
this.videoObserver.disconnect();
}
}
}
}
// Main
new PageManager();