YouTube Sub Feed Filter 2

Filters your YouTube subscriptions feed.

目前為 2022-06-15 提交的版本,檢視 最新版本

// ==UserScript==
// @name        YouTube Sub Feed Filter 2
// @version     0.1
// @description Filters your YouTube subscriptions feed.
// @author      Callum Latham
// @namespace   https://gf.qytechs.cn/users/696211-ctl2
// @license     MIT
// @match       *://www.youtube.com/*
// @match       *://youtube.com/*
// @require     https://gf.qytechs.cn/scripts/446506-tree-frame-2/code/Tree%20Frame%202.js?version=1061073
// @grant       GM.setValue
// @grant       GM.getValue
// @grant       GM.deleteValue
// ==/UserScript==

//TODO Include filters for badges (Verified, Official Artist Channel, ...)
// Also view counts, video lengths, ...

// 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 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': 'Options',
            //     'children': [
            //         {
            //             // <div id="progress" class="style-scope ytd-thumbnail-overlay-resume-playback-renderer" style="width: 45%;"></div>
            //             'label': 'Watched Cutoff (%)',
            //             'value': 100,
            //             'predicate': (value) => value > 0 ? true : 'Value must be greater than 0'
            //         }
            //     ]
            // }
        ]
    };
})();

// 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 getMetadataLine(video) {
    return video.querySelector('#metadata-line');
}

// Video hiding predicates

class SectionSplitter {
    static splitters = {
        [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 metaDataLine = getMetadataLine(video);

            return metaDataLine.children.length > 1 && firstWordEquals(metaDataLine.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) => {
            return new Promise(async (resolve) => {
                let icon = video.querySelector('[overlay-style]');

                // Stop searching if it gets a live badge
                const predicate = () => getVideoBadges(video).length || (icon && icon.getAttribute('overlay-style'));

                if (!predicate()) {
                    await new Promise((resolve) => {
                        const observer = new MutationObserver(() => {
                            icon = video.querySelector('[overlay-style]');

                            if (predicate()) {
                                observer.disconnect();

                                resolve();
                            }
                        });

                        observer.observe(video, {
                            'childList': true,
                            'subtree': true,
                            'attributes': true
                        });
                    });
                }

                resolve(icon && icon.getAttribute('overlay-style') === 'SHORTS');
            });
        },
        [VIDEO_TYPE_IDS.INDIVIDUALS.NORMAL]: (video) => {
            const [, {innerText}] = getMetadataLine(video).children;

            return new RegExp('^\\d+ .+ ago$').test(innerText);
        }
    };

    hideables = [];
    promises = [];

    constructor(section) {
        this.videos = getAllVideos(section);
    }

    addHideables(channelRegex, titleRegex, videoType) {
        const predicate = SectionSplitter.splitters[videoType];
        const promises = [];

        for (const video of this.videos) {
            if (
                channelRegex.test(video.querySelector('a.yt-formatted-string').innerText) &&
                titleRegex.test(video.querySelector('a#video-title').innerText)
            ) {
                promises.push(new Promise(async (resolve) => {
                    if (await predicate(video)) {
                        this.hideables.push(video);
                    }

                    resolve();
                }));
            }
        }

        this.promises.push(Promise.all(promises));
    }
}

// Hider functions

function hideSection(section, doHide = true) {
    if (section.matches(':first-child')) {
        const title = section.querySelector('#title');
        const videoContainer = section.querySelector('#contents').querySelector('#contents');

        if (doHide) {
            title.style.display = 'none';
            videoContainer.style.display = 'none';
            section.style.borderBottom = 'none';
        } else {
            title.style.removeProperty('display');
            videoContainer.style.removeProperty('display');
            section.style.removeProperty('borderBottom');
        }
    } 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 getConfig([filters, options]) {
    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)
            }))
        })(),
        'options': {
            // 'time': options.children[0].value
        }
    };
}

function hideFromSections(config, sections = getAllSections()) {
    for (const section of sections) {
        if (section.matches('ytd-continuation-item-renderer')) {
            continue;
        }

        const splitter = new SectionSplitter(section);

        // Separate the section's videos by hideability
        for (const {channels, videos, types} of config.filters) {
            for (const type of types) {
                splitter.addHideables(channels, videos, type);
            }
        }

        Promise.all(splitter.promises)
            .then(() => {
                // Hide hideable videos
                for (const video of splitter.hideables) {
                    hideVideo(video);
                }
            });
    }
}

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);
}

function resetConfig() {
    for (const section of getAllSections()) {
        hideSection(section, false);

        for (const video of getAllVideos(section)) {
            hideVideo(video, false);
        }
    }
}

// Button

function getButtonDock() {
    return document
        .querySelector('ytd-browse[page-subtype="subscriptions"]')
        .querySelector('#title-container')
        .querySelector('#top-level-buttons-computed');
}

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(
                    'An error was thrown by Tree Frame; Your data may be corrupted.\n' +
                    'Error Message: ' + error + '\n\n' +
                    'Would you like to clear your saved configs?'
                )) {
                    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 (e) {
                debugger;
            }
        });
    }

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址