YouTube Subscriptions Filter

Filter YouTube subscriptions using simple Google-style syntax

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name         YouTube Subscriptions Filter
// @namespace    local.youtube.subscriptions.filter
// @version      0.3
// @description  Filter YouTube subscriptions using simple Google-style syntax
// @match        https://www.youtube.com/feed/subscriptions*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    "use strict";

    const STYLE_ID = "yt-sub-filter-style";

    const INLINE_BAR_ID = "yt-sub-filter-bar";
    const FLOAT_BAR_ID = "yt-sub-filter-float";

    const INLINE_INPUT_ID = "yt-sub-filter-input";
    const FLOAT_INPUT_ID = "yt-sub-filter-input-float";
    const AUTOCOMPLETE_ID = "yt-sub-filter-autocomplete";

    const FIELD_TOKENS = ["title", "channel", "text"];

    let currentQuery = "";
    let autocompleteIndex = 0;
    let applyFilterQueued = false;

    function addStyles() {
        if (document.getElementById(STYLE_ID)) return;

        const style = document.createElement("style");

        style.id = STYLE_ID;

        style.textContent = `
            #${INLINE_BAR_ID} {
                width: 100%;
                box-sizing: border-box;
                padding: 12px 24px;
                background: var(--yt-spec-base-background, #fff);
            }

            #${FLOAT_BAR_ID} {
                position: fixed;
                top: 70px;
                left: 50%;
                transform: translateX(-50%);
                z-index: 999999;
                padding: 10px;
                border-radius: 14px;
                background: rgba(20,20,20,0.92);
                backdrop-filter: blur(8px);

                opacity: 0;
                pointer-events: none;
                transition: opacity 0.15s ease;
            }

            #${FLOAT_BAR_ID}.visible {
                opacity: 1;
                pointer-events: auto;
            }

            body.yt-sub-filter-float-visible #${INLINE_BAR_ID} {
                display: none;
            }

            #${INLINE_INPUT_ID},
            #${FLOAT_INPUT_ID} {
                width: min(700px, 80vw);
                box-sizing: border-box;
                padding: 10px 14px;
                border-radius: 20px;
                border: 1px solid #666;
                font-size: 14px;
                outline: none;
            }

            #${FLOAT_INPUT_ID} {
                background: #111;
                color: white;
            }

            #${AUTOCOMPLETE_ID} {
                position: fixed;
                z-index: 1000000;
                min-width: 180px;
                max-height: 320px;
                overflow-y: auto;
                padding: 6px;
                border-radius: 10px;
                background: rgba(20,20,20,0.96);
                color: white;
                box-shadow: 0 8px 24px rgba(0,0,0,0.35);
                display: none;
            }

            #${AUTOCOMPLETE_ID}.visible {
                display: block;
            }

            #${AUTOCOMPLETE_ID} button {
                display: block;
                width: 100%;
                box-sizing: border-box;
                padding: 8px 10px;
                border: 0;
                border-radius: 8px;
                background: transparent;
                color: inherit;
                font: inherit;
                text-align: left;
                cursor: pointer;
            }

            #${AUTOCOMPLETE_ID} button.selected,
            #${AUTOCOMPLETE_ID} button:hover {
                background: rgba(255,255,255,0.14);
            }
        `;

        document.head.appendChild(style);
    }

    function createInput(id) {
        const input = document.createElement("input");

        input.id = id;

        input.placeholder =
            'Filter subscriptions, e.g. -channel:bizar title:"video essay"';

        input.autocomplete = "off";

        input.addEventListener("input", () => {
            currentQuery = input.value;
            syncInputs(id);
            updateAutocomplete(input);
        });

        input.addEventListener("keydown", event => {
            if (handleAutocompleteKeydown(event, input)) return;

            if (event.key === "Enter") {
                applyFilter();
                return;
            }

            if (
                event.key === " " &&
                !isInsideQuotes(input.value, input.selectionStart)
            ) {
                queueMicrotask(applyFilter);
            }
        });

        input.addEventListener("blur", () => {
            applyFilter();
            setTimeout(hideAutocomplete, 0);
        });
        input.addEventListener("focus", () => updateAutocomplete(input));
        input.addEventListener("click", () => updateAutocomplete(input));
        input.addEventListener("keyup", () => updateAutocomplete(input));

        return input;
    }

    function syncInputs(sourceId) {
        const inlineInput = document.getElementById(INLINE_INPUT_ID);
        const floatInput = document.getElementById(FLOAT_INPUT_ID);

        if (sourceId !== INLINE_INPUT_ID && inlineInput) {
            inlineInput.value = currentQuery;
        }

        if (sourceId !== FLOAT_INPUT_ID && floatInput) {
            floatInput.value = currentQuery;
        }
    }

    function findVideoGrid() {
        return (
            document.querySelector("ytd-rich-grid-renderer #contents") ||
            document.querySelector("ytd-section-list-renderer #contents")
        );
    }

    function addInlineBar() {
        if (document.getElementById(INLINE_BAR_ID)) return;

        const grid = findVideoGrid();

        if (!grid || !grid.parentElement) return;

        const bar = document.createElement("div");

        bar.id = INLINE_BAR_ID;

        const input = createInput(INLINE_INPUT_ID);

        bar.appendChild(input);

        grid.parentElement.insertBefore(bar, grid);
    }

    function addFloatingBar() {
        if (document.getElementById(FLOAT_BAR_ID)) return;

        const bar = document.createElement("div");

        bar.id = FLOAT_BAR_ID;

        const input = createInput(FLOAT_INPUT_ID);

        bar.appendChild(input);

        document.body.appendChild(bar);

        function setFloatingVisible(visible) {
            bar.classList.toggle("visible", visible);
            document.body.classList.toggle("yt-sub-filter-float-visible", visible);

            if (!visible && document.activeElement !== input) {
                hideAutocomplete();
            }
        }

        document.addEventListener("mousemove", event => {
            const nearTop = event.clientY < 120;

            if (nearTop) {
                setFloatingVisible(true);
            } else if (
                document.activeElement !== input
            ) {
                setFloatingVisible(false);
            }
        });

        input.addEventListener("focus", () => {
            setFloatingVisible(true);
        });

        input.addEventListener("blur", () => {
            setFloatingVisible(false);
        });
    }

    function addAutocomplete() {
        if (document.getElementById(AUTOCOMPLETE_ID)) return;

        const menu = document.createElement("div");

        menu.id = AUTOCOMPLETE_ID;

        document.body.appendChild(menu);
    }

    function getCurrentWord(input) {
        const cursorPosition = input.selectionStart ?? input.value.length;
        const textBeforeCursor = input.value.slice(0, cursorPosition);
        const tokenStart = textBeforeCursor.search(/\S+$/);

        if (tokenStart === -1) {
            return {
                start: cursorPosition,
                end: cursorPosition,
                value: ""
            };
        }

        const textAfterCursor = input.value.slice(cursorPosition);
        const nextSpace = textAfterCursor.search(/\s/);
        const tokenEnd =
            nextSpace === -1 ? input.value.length : cursorPosition + nextSpace;

        return {
            start: tokenStart,
            end: tokenEnd,
            value: input.value.slice(tokenStart, tokenEnd)
        };
    }

    function getAutocompleteOptions(input) {
        const word = getCurrentWord(input);
        const negative = word.value.startsWith("-");
        const typed = negative ? word.value.slice(1) : word.value;

        if (typed.includes(":")) {
            return getFieldValueOptions(word, negative);
        }

        return FIELD_TOKENS
            .filter(field => field.startsWith(typed.toLowerCase()))
            .map(field => ({
                label: `${negative ? "-" : ""}${field}:`,
                value: `${negative ? "-" : ""}${field}:`
            }));
    }

    function getFieldValueOptions(word, negative) {
        const token = negative ? word.value.slice(1) : word.value;
        const colonIndex = token.indexOf(":");
        const field = token.slice(0, colonIndex).toLowerCase();
        const typedValue = token.slice(colonIndex + 1).replace(/^"/, "");

        if (!["channel", "title", "text"].includes(field)) return [];

        const prefix = `${negative ? "-" : ""}${field}:`;
        const typedLower = typedValue.toLowerCase();
        const knownValues = getKnownFieldValues(field);
        const options = knownValues
            .filter(value => value.toLowerCase().startsWith(typedLower))
            .map(value => ({
                label: `${prefix}${formatAutocompleteValue(value)}`,
                value: `${prefix}${formatAutocompleteValue(value)}`
            }));

        if (typedValue && !typedValue.endsWith("*")) {
            options.push({
                label: `${prefix}${typedValue}*`,
                value: `${prefix}${typedValue}*`
            });
        }

        return options;
    }

    function getKnownFieldValues(field) {
        const values = getVideos()
            .map(video => getRawVideoData(video)[field])
            .filter(Boolean);

        return [...new Set(values)].sort((first, second) =>
            first.localeCompare(second)
        );
    }

    function formatAutocompleteValue(value) {
        return /\s/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value;
    }

    function updateAutocomplete(input) {
        const menu = document.getElementById(AUTOCOMPLETE_ID);

        if (!menu || document.activeElement !== input) return;

        const options = getAutocompleteOptions(input);

        if (!options.length) {
            hideAutocomplete();
            return;
        }

        autocompleteIndex = Math.min(autocompleteIndex, options.length - 1);

        menu.textContent = "";

        for (const [index, option] of options.entries()) {
            const button = document.createElement("button");

            button.type = "button";
            button.textContent = option.label;
            button.classList.toggle("selected", index === autocompleteIndex);

            button.addEventListener("mousedown", event => {
                event.preventDefault();
                insertAutocompleteOption(input, option.value);
            });

            menu.appendChild(button);
        }

        const rect = input.getBoundingClientRect();

        menu.style.left = `${rect.left}px`;
        menu.style.top = `${rect.bottom + 6}px`;
        menu.style.width = `${rect.width}px`;
        menu.classList.add("visible");
    }

    function hideAutocomplete() {
        const menu = document.getElementById(AUTOCOMPLETE_ID);

        if (menu) {
            menu.classList.remove("visible");
        }
    }

    function insertAutocompleteOption(input, option) {
        const word = getCurrentWord(input);
        const nextValue =
            input.value.slice(0, word.start) +
            option +
            input.value.slice(word.end);
        const nextCursorPosition = word.start + option.length;

        input.value = nextValue;
        input.setSelectionRange(nextCursorPosition, nextCursorPosition);
        currentQuery = input.value;
        syncInputs(input.id);
        hideAutocomplete();
        input.focus();
    }

    function handleAutocompleteKeydown(event, input) {
        const menu = document.getElementById(AUTOCOMPLETE_ID);

        if (!menu?.classList.contains("visible")) return false;

        const options = getAutocompleteOptions(input);

        if (!options.length) return false;

        if (event.key === "ArrowDown") {
            event.preventDefault();
            autocompleteIndex = (autocompleteIndex + 1) % options.length;
            updateAutocomplete(input);
            return true;
        }

        if (event.key === "ArrowUp") {
            event.preventDefault();
            autocompleteIndex =
                (autocompleteIndex - 1 + options.length) % options.length;
            updateAutocomplete(input);
            return true;
        }

        if (event.key === "Tab") {
            event.preventDefault();
            insertAutocompleteOption(input, options[autocompleteIndex].value);
            return true;
        }

        if (event.key === "Escape") {
            hideAutocomplete();
            return true;
        }

        return false;
    }

    function isInsideQuotes(text, cursorPosition) {
        const beforeCursor = text.slice(0, cursorPosition);

        const quoteCount = (beforeCursor.match(/"/g) || []).length;

        return quoteCount % 2 === 1;
    }

    function getVideos() {
        return [
            ...document.querySelectorAll(
                "ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-video-renderer"
            )
        ];
    }

    function getRawVideoData(video) {
        const title =
            video.querySelector(".ytLockupMetadataViewModelTitle")?.textContent?.trim() ||
            video.querySelector("#video-title")?.textContent?.trim() ||
            video.querySelector("a#video-title-link")?.textContent?.trim() ||
            video.querySelector("a#video-title")?.getAttribute("title")?.trim() ||
            video.querySelector("a#video-title-link")?.getAttribute("title")?.trim() ||
            video.querySelector("[aria-label]")?.getAttribute("aria-label")?.trim() ||
            "";

        const channel =
            video.querySelector("ytd-channel-name a")?.textContent?.trim() ||
            video.querySelector("#channel-name a")?.textContent?.trim() ||
            video.querySelector("a[href^='/@']")?.textContent?.trim() ||
            video.querySelector("a[href^='/channel/']")?.textContent?.trim() ||
            video.querySelector("ytd-channel-name")?.textContent?.trim() ||
            video.querySelector("#channel-name")?.textContent?.trim() ||
            "";

        const text = video.textContent || "";

        return {
            title,
            channel,
            text
        };
    }

    function getVideoData(video) {
        const data = getRawVideoData(video);

        return {
            title: data.title.toLowerCase(),
            channel: data.channel.toLowerCase(),
            text: data.text.toLowerCase()
        };
    }

    function parseQuery(query) {
        const tokens = [];
        const regex = /(-?)(?:(\w+):)?(?:"([^"]*)"|(\S+))/g;

        let match;

        while ((match = regex.exec(query)) !== null) {
            const negative = match[1] === "-";
            const field = match[2] ? match[2].toLowerCase() : "text";
            const value = (match[3] ?? match[4] ?? "").toLowerCase();

            if (value) {
                tokens.push({
                    negative,
                    field,
                    value
                });
            }
        }

        return tokens;
    }

    function matches(videoData, rules) {
        for (const rule of rules) {
            const fieldText =
                videoData[rule.field] ?? videoData.text;

            const found = rule.value.endsWith("*")
                ? fieldText.startsWith(rule.value.slice(0, -1))
                : fieldText.includes(rule.value);

            if (rule.negative && found) return false;

            if (!rule.negative && !found) return false;
        }

        return true;
    }

    function applyFilter() {
        const rules = parseQuery(currentQuery);

        for (const video of getVideos()) {
            const data = getVideoData(video);

            video.style.display =
                matches(data, rules) ? "" : "none";
        }
    }

    function scheduleApplyFilter() {
        if (applyFilterQueued) return;

        applyFilterQueued = true;

        queueMicrotask(() => {
            applyFilterQueued = false;
            applyFilter();
        });
    }

    function isVideoNode(node) {
        if (!(node instanceof Element)) return false;

        return (
            node.matches(
                "ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-video-renderer"
            ) ||
            node.querySelector(
                "ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-video-renderer"
            )
        );
    }

    function handleDomChanges(mutations) {
        let addedVideos = false;

        for (const mutation of mutations) {
            for (const node of mutation.addedNodes) {
                if (isVideoNode(node)) {
                    addedVideos = true;
                    break;
                }
            }

            if (addedVideos) break;
        }

        init();

        if (addedVideos && currentQuery.trim()) {
            scheduleApplyFilter();
        }
    }

    function init() {
        addStyles();
        addInlineBar();
        addFloatingBar();
        addAutocomplete();
    }

    const observer = new MutationObserver(handleDomChanges);

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });

    init();
})();