YouTube Subscriptions Filter

Filter YouTube subscriptions using simple Google-style syntax

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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