YouTube Sub Feed Filter 2

Filters your YouTube subscriptions feed.

As of 31/07/2022. See the latest version.

// ==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();