☰

Thisvid Utility

Filter out private videos, sort videos based on ratings, copy video links (for yt-dlp), and copy video file direct link (for IDM).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name           Thisvid Utility
// @namespace      https://github.com/petrelpine
// @version        1.1
// @description    Filter out private videos, sort videos based on ratings, copy video links (for yt-dlp), and copy video file direct link (for IDM).
// @icon           data:image/gif;base64,R0lGODlhEAAQAPYAAAAAAAABAAEAAAEBAQIBAAICAgMDAwIBBgMDBQQEBAUFBQcHBQYGBggICAwMCg4ODA4ODhAPDRAODxERERISEhUTFBUVFRYWFhcXFxkYFhsZGh8fHyMjIzQ0NDc3NzsyNTo6Oj4+PkY+PEhDP0hEQUxHQ01NTVBLSFxSUF1TUWFWUmlcVmheXGNjY21lYm9lY29raG9ubHBmZHFnZXNoYnFsaXJua3pwbn1zcXpyf396dnp6en97eHx8fH+BgIF9eoZ9eP8AAIKCgoyIhY2JhoqKipGFhZaKipCQkJSUlJeXl5iYmKSkpKmpqbCwsLq6usDAwMXFxdXV1eLi4gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAlkAFQAIf8LTkVUU0NBUEUyLjADAQAAACwAAAAAEAAQAAAH/oBUVFRUVFRUVFRUVFRUVFRUVFRUVE0eCw8AETZUVFRUVFRUVBgYSkdGREQ2EztUVFRUVBM/OkBBOTg4OD8ZPlRUVD0bNTc0QUEzMy4uMBxUVFQTLSwqK0FBQSkoKCcAS1RQCR4lIyNBQUFBHyIkACBQQgAAEhoVQUFBQUEOAAgaQpAkAAAAQIIgQYIECRIEAAAMSJAAaAAAQIIgQYIECQIAQAIMSKJQAAAAAIAgQYIEQQAgAQATUahQSAAgQYIgQYIAAJAgwYQmVKgoeQAAQIIgQQAAAAAAwAQqVKhMgYABQIIgAAAAANAAABMqVKhQkYIBQAIAABIkANBgCRUqFVSoUKFC5UkIAAASJChChQoVKlQCAQAh+QQJLAFUACwAAAAAEAAQAIYAAAAAAQABAAABAQECAQACAgIDAwMCAQYDAwUEBAQFBQUHBwUGBgYICAgMDAoODgwODg4QDw0QDg8RERESEhIVExQVFRUWFhYXFxcZGBYbGRofHx8jIyM0NDQ3Nzc7MjU6Ojo+Pj5GPjxIQz9IREFMR0NNTU1QS0hcUlBdU1FhVlJpXFZoXlxjY2NtZWJvZWNva2hvbmxwZmRxZ2VzaGJxbGlybmt6cG59c3F6cn9/enZ6enp/e3h8fHx/gYCBfXqGfXiCgoKMiIWNiYaKioqRhYWWioqQkJCUlJSXl5eYmJikpKSpqamwsLC6urrAwMDFxcXV1dXi4uL///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH/oBUVFRUVFRUVFRUVFRUVFRUVFRUVEweCw8AETZUVFRUVFRUVBgYSUZFQ0M2EztUVFRUVBM/OkBTOTg4OD8ZPlRUVD0bNTc0U1MyMi4uMBxUVFQTLSwqK1NTUygoKCcASlRPCR4lIyNTU1NTHyIkACBPQQAAEhoVU1NTU1MOAAgaQY4kAAAAQIIpU6ZMmTIFAAAMR44kSAAAAIApU6ZMmQIAAAAMR6BMAAAAAIApU6ZMQQAgAQATUKhMSAAgQYIpU6YAAJAgwQQmVKgkeQAAQIIpUwAAAAAAwAQqVKhIkYABQIIpAAAAAOAAwBIqVKhQiYIBQAIAABAAAOBACRUqFVSoUKFCxUkIAAASJCBChQoVKlQCAQA7
// @author         PetrelPine
// @license        GNU General Public License v3.0
// @match          http*://thisvid.com/*
// ==/UserScript==

/*
 * How to batch download videos:
 * 1. At the video listing page, click "COPY ALL LINKS" button to copy video links of that category.
 * 2. Paste these links into a file named links.txt.
 * 3. Run yt-dlp -g -a "links.txt" > "links_out.txt".
 * 4. All video file direct links are stored in links_out.txt. Copy these links.
 * 5. Open IDM and select "add batch download from clipboard".
*/

(() => {
    'use strict';

    const DEBUG = true;

    /*
     * A URL is considered a video-page URL when it contains "/videos/".
     *
     * The "i" makes the match case-insensitive.
     */
    const VIDEO_URL_RE = /\/videos\//i;

    /*
     * Styling for the "copy links" button.
     */
    const BTN_CSS =
        'display:inline-block !important;' +
        'margin:10px 8px 10px 0;' +
        'padding:6px 10px;' +
        'font-family:inherit;' +
        'font-size:13px;' +
        'line-height:1.4;' +
        'cursor:pointer;' +
        'border:1px solid rgba(128,128,128,.5);' +
        'border-radius:3px;' +
        'background:transparent;' +
        'color:inherit;';

    /* ================================================================
       GENERAL HELPERS
       ================================================================ */

    // Print diagnostic information only when DEBUG is enabled.
    const log = (...args) => {
        if (DEBUG) {
            console.log('[dl]', ...args);
        }
    };

    // Promise-based delay.
    const wait = (ms) =>
        new Promise(resolve => setTimeout(resolve, ms));

    /*
     * Repeatedly call fn() until it returns a truthy value or the timeout
     * expires.
     *
     * This is useful because the website may construct its video lists
     * and player asynchronously.
     */
    const waitFor = async (
        fn,
        { timeout = 10000, interval = 100 } = {}
    ) => {
        const deadline = Date.now() + timeout;

        while (Date.now() < deadline) {
            try {
                const value = fn();

                if (value) {
                    return value;
                }
            } catch {
                // Ignore temporary DOM errors while the page is loading.
            }

            await wait(interval);
        }

        return null;
    };

    /*
     * Add ✓ or ✗ to the document title.
     *
     * If the title already starts with one of our markers, remove the old
     * marker first so they do not accumulate.
     */
    const setFlag = (mark) => {
        document.title =
            `[${mark}] ${document.title.replace(/^\[[✓✗]\]\s*/, '')}`;
    };

    /*
     * Copy text to the clipboard.
     *
     * Preferred method:
     *     navigator.clipboard.writeText()
     *
     * Fallback:
     *     document.execCommand('copy')
     *
     * The fallback is useful on pages where the modern Clipboard API
     * cannot be used.
     */
    const copyText = async (text) => {
        if (navigator.clipboard && window.isSecureContext) {
            await navigator.clipboard.writeText(text);
            return true;
        }

        const textarea = document.createElement('textarea');

        textarea.value = text;
        textarea.setAttribute('readonly', '');

        textarea.style.cssText =
            'position:fixed;' +
            'top:-1000px;' +
            'left:-1000px;' +
            'opacity:0;';

        document.body.appendChild(textarea);

        textarea.select();
        textarea.setSelectionRange(0, textarea.value.length);

        const ok = document.execCommand('copy');

        textarea.remove();

        if (!ok) {
            throw new Error('execCommand copy rejected');
        }

        return true;
    };

    /* ================================================================
       SINGLE VIDEO PAGE
       ================================================================ */

    /*
     * The video player stores its resolved video URLs in flashvars.
     *
     * Tampermonkey's unsafeWindow can sometimes provide access to the
     * page's actual JavaScript global object, so check it first.
     */
    const getFlashvars = () => {
        const scopes = [];

        if (typeof unsafeWindow !== 'undefined') {
            scopes.push(unsafeWindow);
        }

        scopes.push(window);

        for (const scope of scopes) {
            if (
                scope &&
                scope.flashvars &&
                typeof scope.flashvars === 'object'
            ) {
                return scope.flashvars;
            }
        }

        return null;
    };

    /*
     * Extract usable video URLs from flashvars.
     *
     * The main source is usually video_url.
     *
     * The website may also expose:
     *
     *     video_alt_url
     *     video_alt_url2
     *     ...
     *     video_alt_url6
     */
    const collectSources = (flashvars) => {
        const sources = [];

        const addSource = (key) => {
            const url = flashvars[key];

            if (!url || typeof url !== 'string') {
                return;
            }

            /*
             * A URL beginning with "function/<number>/" means that the
             * player has not finished resolving the URL yet.
             */
            if (/^function\/\d+\//.test(url)) {
                log('source still encoded:', key);
                return;
            }

            sources.push({
                url,
                label: flashvars[`${key}_text`] || key,
            });
        };

        addSource('video_url');
        addSource('video_alt_url');

        for (let i = 2; i <= 6; i++) {
            addSource(`video_alt_url${i}`);
        }

        return sources;
    };

    /*
     * Create the copy button for a single video page.
     */
    const createVideoPageButton = (flagContainer, source) => {
        const li = document.createElement('li');
        const a = document.createElement('a');
        const span = document.createElement('span');

        li.className = '__dl_item';
        a.className = '__dl';

        /*
         * Keep the real URL as href as well.
         *
         * The click handler below prevents normal navigation and copies
         * the URL instead.
         */
        a.href = source.url;
        a.title = `Copy ${source.label} link`;

        // 1. Update LI to serve as the relative positioning anchor and stretch its content
        li.style.setProperty('position', 'relative', 'important');
        li.style.setProperty('display', 'flex', 'important');
        li.style.setProperty('align-items', 'center', 'important');
        li.style.setProperty('justify-content', 'center', 'important');
        // li.style.setProperty('height', '55px', 'important');

        // 2. Update A to fill the <li> completely without escaping its bounds
        a.style.setProperty('display', 'flex', 'important');
        a.style.setProperty('align-items', 'center', 'important');
        a.style.setProperty('justify-content', 'center', 'important');
        a.style.setProperty('width', '100%', 'important');
        a.style.setProperty('height', '100%', 'important');
        a.style.setProperty('position', 'relative', 'important'); // Reset position back to relative
        a.style.setProperty('top', 'auto', 'important');
        a.style.setProperty('left', 'auto', 'important');
        a.style.setProperty('visibility', 'visible', 'important');
        a.style.setProperty('opacity', '1', 'important');
        a.style.setProperty('color', '#fff', 'important');
        a.style.cursor = 'pointer';

        span.style.setProperty('display', 'flex', 'important');
        span.style.setProperty('align-items', 'center', 'important');
        span.style.setProperty('justify-content', 'center', 'important');
        span.style.setProperty('width', '100%', 'important');
        span.style.setProperty('height', '100%', 'important');
        span.style.setProperty('font-size', '2em', 'important');
        span.style.setProperty('line-height', '1', 'important');
        span.style.setProperty('color', '#fff', 'important');
        span.textContent = '⧉';

        /*
         * Temporarily show ✓ or ✗ after copying.
         */
        let flashTimer = null;

        const flash = (mark) => {
            clearTimeout(flashTimer);

            span.textContent = mark;

            flashTimer = setTimeout(() => {
                span.textContent = '⧉';
            }, 3000);
        };

        a.appendChild(span);
        li.appendChild(a);
        flagContainer.appendChild(li);

        // Sync the added button's height with other list elements
        function syncHeight() {
            const siblingLi = li.previousElementSibling || li.nextElementSibling;
            if (siblingLi) {
                const height = siblingLi.getBoundingClientRect().height;
                if (height > 0) {
                    li.style.setProperty('height', `${height}px`, 'important');
                }
            }
        }
        syncHeight();
        // window.addEventListener('resize', syncHeight);

        a.addEventListener('click', async (event) => {
            event.preventDefault();
            event.stopPropagation();

            try {
                await copyText(source.url);

                flash('✓');
                setFlag('✓');

                log('copied:', source.label, source.url);
            } catch (error) {
                log('copy failed:', error);

                flash('✗');
                setFlag('✗');

                /*
                 * Last-resort fallback if clipboard access fails.
                 */
                window.prompt(
                    `Copy this ${source.label} link:`,
                    source.url
                );
            }
        });

        return li;
    };

    /*
     * Make sure the single-video copy button exists.
     *
     * This function is safe to call repeatedly.
     */
    const ensureVideoPageButton = () => {
        const flashvars = getFlashvars();

        if (!flashvars) {
            log('video page: flashvars not ready yet');
            return false;
        }

        const sources = collectSources(flashvars);

        if (!sources.length) {
            log('video page: no usable source URLs yet');
            return false;
        }

        const flagContainer =
            document.querySelector('#flagging_container');

        if (!flagContainer) {
            log('video page: #flagging_container not ready yet');
            return false;
        }
        /*
         * Only add our button if the current toolbar does not already
         * contain it.
         */
        let button = flagContainer.querySelector('.__dl_item');

        if (!button) {
            button = createVideoPageButton(
                flagContainer,
                sources[0]
            );

            log('video page: button added');
        }

        return true;
    };

    /*
     * Watch the video page for player/toolbar changes.
     *
     * Some pages build the player after the initial page load.
     * Some players also rebuild their toolbar later.
     *
     * Watching document.body means that even if the entire
     * #flagging_container is replaced, we can detect the replacement.
     */
    const watchVideoPage = () => {
        let retryTimer = null;

        /*
         * Schedule a small delayed retry.
         *
         * Multiple DOM mutations may happen in quick succession, so avoid
         * starting hundreds of simultaneous retries.
         */
        const tryAgainSoon = () => {
            if (retryTimer !== null) {
                return;
            }

            retryTimer = setTimeout(() => {
                retryTimer = null;
                ensureVideoPageButton();
            }, 100);
        };

        /*
         * Try immediately.
         */
        ensureVideoPageButton();

        /*
         * Also retry periodically during initial player construction.
         *
         * 100 attempts × 100 ms = approximately 10 seconds.
         */
        let attempts = 0;
        const maxAttempts = 100;

        const retryUntilReady = () => {
            if (
                ensureVideoPageButton() ||
                attempts >= maxAttempts
            ) {
                return;
            }

            attempts++;
            setTimeout(retryUntilReady, 100);
        };

        retryUntilReady();

        /*
         * Watch for later DOM changes.
         */
        const observer = new MutationObserver((mutations) => {
            for (const mutation of mutations) {
                if (
                    mutation.type === 'childList' &&
                    mutation.addedNodes.length
                ) {
                    tryAgainSoon();
                    break;
                }
            }
        });

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

    /* ================================================================
       COLLECT PAGE LINKS
       ================================================================ */

    /*
     * Collect all HTTP(S) links inside the container.
     */
    const collectPageLinks = (container) => {
        const seen = new Set();

        for (const link of container.querySelectorAll('a[href]')) {
            const href = link.href;

            if (!/^https?:/i.test(href)) {
                continue;
            }

            seen.add(href.split('#')[0]);
        }

        const allLinks = [...seen];

        const videoLinks =
            allLinks.filter(url => VIDEO_URL_RE.test(url));

        if (!videoLinks.length) {
            log(
                'no links matched VIDEO_URL_RE; using all links'
            );
        }

        return videoLinks.length
            ? videoLinks
            : allLinks;
    };

    /* ================================================================
       COPY PAGE LINKS BUTTONS
       ================================================================ */

    /*
     * Add a copy button for each logical category.
     *
     * If a page contains:
     *
     *     Favourite videos
     *     Public videos
     *     Private videos
     *
     * each section receives its own copy button.
     */

    const addListingButtons = () => {
        const groups = getVideoListGroups();

        log(
            `buttons: found ${groups.size} video category(s)`
        );

        for (const group of groups.values()) {
            const primary = group.primary;

            if (!primary) {
                continue;
            }

            /*
             * Only add one button for the entire logical category.
             *
             * The additional .thumbs-items containers belonging to
             * the same category will not receive their own buttons.
             */
            if (
                primary.querySelector(
                    '.__dl_bulk[data-list-category]'
                )
            ) {
                continue;
            }

            const button = document.createElement('button');

            button.type = 'button';
            button.className = '__dl_bulk';
            button.dataset.listCategory = group.key;
            button.textContent = '⧉ Copy all links';
            button.style.cssText = BTN_CSS;

            const originalLabel = button.textContent;

            let flashTimer = null;

            const flash = (text, delay = 3000) => {
                clearTimeout(flashTimer);

                button.textContent = text;

                flashTimer = setTimeout(() => {
                    button.textContent = originalLabel;
                }, delay);
            };

            button.addEventListener(
                'click',
                async (event) => {
                    event.preventDefault();
                    event.stopPropagation();

                    const links = [];

                    /*
                     * Collect videos from every .thumbs-items container
                     * belonging to this category.
                     */
                    for (const thumbs of group.containers) {
                        links.push(...collectPageLinks(thumbs));
                    }

                    if (!links.length) {
                        flash('✗ Nothing found');
                        return;
                    }

                    try {
                        await copyText(links.join('\n'));

                        flash(`✓ Copied ${links.length}`);

                        log('copied page links:', links);
                    }
                    catch (error) {
                        log('copy failed:', error);

                        flash('✗ Copy failed');

                        window.prompt(
                            'Copy these links:',
                            links.join('\n')
                        );
                    }
                }
            );

            /*
             * Put the button immediately before the primary
             * .thumbs-items container for this category.
             */
            primary.parentElement?.insertBefore(
                button,
                primary
            );

            log(`buttons: added button for ${group.key}`);
        }
    };

    /* ================================================================
       VIDEO LIST CATEGORY / GROUP HELPERS
       ================================================================ */

    /*
     * A category can appear in the DOM in two forms:
     *
     *     list_videos_public_videos_items
     *
     * or:
     *
     *     list_videos_favourite_videos
     *
     * We normalize both to:
     *
     *     list_videos_public_videos
     *     list_videos_favourite_videos
     *
     * so they can be used as the same category key.
     */
    const normalizeVideoCategoryId = (id) => {
        if (!id || !id.startsWith('list_videos_')) {
            return null;
        }

        return id.replace(/_items$/, '');
    };

    /*
     * Return all elements that can act as category markers.
     *
     * We intentionally look for every list_videos_* element rather than
     * only list_videos_*_items because, as shown in your screenshot,
     * Favourite has:
     *
     *     #list_videos_favourite_videos
     *         └── #list_videos_favourite_videos_items
     *
     * while Public/Private may use the *_items element directly.
     */
    const getVideoCategoryMarkers = () => {
        return [
            ...document.querySelectorAll(
                '[id^="list_videos_"]'
            )
        ];
    };

    /*
     * Determine which video category a particular .thumbs-items belongs to.
     */
    const getVideoCategoryForThumbs = (
        thumbs,
        categoryMarkers
    ) => {
        /*
         * ------------------------------------------------------------
         * Case 1:
         * The .thumbs-items itself has an ID.
         *
         * Example:
         *
         *   <div
         *       class="thumbs-items"
         *       id="list_videos_public_videos_items"
         *   >
         * ------------------------------------------------------------
         */
        if (
            thumbs.id &&
            thumbs.id.startsWith('list_videos_')
        ) {
            return normalizeVideoCategoryId(thumbs.id);
        }

        /*
         * ------------------------------------------------------------
         * Case 2:
         * The .thumbs-items is inside a category wrapper.
         *
         * Example from your screenshot:
         *
         *   <div id="list_videos_favourite_videos">
         *       ...
         *       <div class="thumbs-items">
         *
         * ------------------------------------------------------------
         */
        const ancestor = thumbs.closest(
            '[id^="list_videos_"]'
        );

        if (
            ancestor &&
            ancestor.id !== thumbs.id
        ) {
            return normalizeVideoCategoryId(
                ancestor.id
            );
        }

        /*
         * ------------------------------------------------------------
         * Case 3:
         * There is no category wrapper.
         *
         * Public/Private appear to have this structure:
         *
         *   #list_videos_public_videos_items
         *   .container
         *   .thumbs-items
         *   .container
         *   .thumbs-items
         *
         * The later .thumbs-items do not have IDs.
         *
         * Therefore, find the closest previous list_videos_* marker
         * in document order.
         * ------------------------------------------------------------
         */

        let latestMarker = null;

        for (const marker of categoryMarkers) {

            /*
             * marker comes before thumbs in the document.
             *
             * compareDocumentPosition() with
             * DOCUMENT_POSITION_FOLLOWING means:
             *
             *     marker -> thumbs
             *
             * in document order.
             */
            if (
                marker === thumbs ||
                (
                    marker.compareDocumentPosition(thumbs) &
                    Node.DOCUMENT_POSITION_FOLLOWING
                )
            ) {
                latestMarker = marker;
            }
        }

        if (!latestMarker) {
            return null;
        }

        return normalizeVideoCategoryId(
            latestMarker.id
        );
    };

    /*
     * Build the complete groups of .thumbs-items.
     *
     * The result looks conceptually like:
     *
     * Map {
     *     "list_videos_public_videos" => {
     *         key: "...",
     *         containers: [
     *             thumbs-items,
     *             thumbs-items,
     *             thumbs-items
     *         ],
     *         primary: thumbs-items
     *     },
     *
     *     "list_videos_private_videos" => {
     *         ...
     *     },
     *
     *     "list_videos_favourite_videos" => {
     *         ...
     *     }
     * }
     */
    const getVideoListGroups = () => {
        const groups = new Map();

        const categoryMarkers =
            getVideoCategoryMarkers();

        const thumbsItems = [
            ...document.querySelectorAll(
                '.thumbs-items'
            )
        ];

        for (const thumbs of thumbsItems) {

            const category =
                getVideoCategoryForThumbs(
                    thumbs,
                    categoryMarkers
                );

            if (!category) {
                log(
                    'group: could not determine category for',
                    thumbs
                );

                continue;
            }

            if (!groups.has(category)) {
                groups.set(category, {
                    key: category,
                    containers: [],
                    primary: null,
                });
            }

            const group = groups.get(category);

            group.containers.push(thumbs);

            /*
             * Prefer the explicitly named *_items element as the primary
             * container.
             *
             * For example:
             *
             *   #list_videos_public_videos_items
             *
             * If there is no explicitly named one, simply use the first
             * .thumbs-items we encountered.
             */
            if (thumbs.id === `${category}_items`) {
                group.primary = thumbs;
            }
        }

        /*
         * Some groups might not have an explicitly named primary container.
         */
        for (const group of groups.values()) {
            if (!group.primary) {
                group.primary = group.containers[0];
            }
        }

        return groups;
    };

    /*
     * Find the top-level video card inside a particular .thumbs-items.
     *
     * We start from an <a> and walk upward until its parent is the
     * .thumbs-items container.
     */
    const getVideoCard = (element, thumbsContainer) => {
        let current = element;

        while (
            current &&
            current.parentElement
        ) {
            if (
                current.parentElement === thumbsContainer
            ) {
                return current;
            }

            current = current.parentElement;
        }

        return null;
    };

    /* ================================================================
       FILTER PRIVATE VIDEOS
       ================================================================ */

    const filterPrivateVideos = () => {

        const groups = getVideoListGroups();

        log(`filter: found ${groups.size} video category(s)`);

        /*
         * Process every category independently.
         */
        for (const group of groups.values()) {

            let removed = 0;

            /*
             * A category may contain several .thumbs-items containers.
             *
             * Search ALL of them.
             */
            for (const thumbs of group.containers) {

                const privateIcons = [
                    ...thumbs.querySelectorAll(
                        '.icon-private'
                    )
                ];

                for (const icon of privateIcons) {

                    const videoCard =
                        getVideoCard(
                            icon,
                            thumbs
                        );

                    if (!videoCard) {
                        log(
                            'filter: could not find card for',
                            icon
                        );

                        continue;
                    }

                    /*
                     * Make sure the card really belongs directly to
                     * this .thumbs-items.
                     */
                    if (videoCard.parentElement !== thumbs) {
                        continue;
                    }

                    videoCard.remove();

                    removed++;
                }
            }

            log(`filter: ${group.key}: removed ${removed} private video(s)`);
        }
    };

    /* ================================================================
       SORT VIDEOS
       ================================================================ */

    const sortVideos = () => {

        const groups = getVideoListGroups();

        log(`sort: found ${groups.size} video category(s)`);

        /*
         * Sort each category independently.
         */
        for (const group of groups.values()) {

            const videos = [];

            /*
             * Collect videos from every .thumbs-items belonging to this category.
             */
            for (const thumbs of group.containers) {

                const percentages = [
                    ...thumbs.querySelectorAll(
                        'span.percent'
                    )
                ];

                for (const percentage of percentages) {

                    const videoCard =
                        getVideoCard(
                            percentage,
                            thumbs
                        );

                    if (!videoCard) {
                        continue;
                    }

                    /*
                     * Extract something such as "85%" into 85
                     */
                    const value = parseInt(
                        percentage.textContent.trim(),
                        10
                    );

                    videos.push({
                        element: videoCard,

                        value: Number.isNaN(value)
                            ? -Infinity
                            : value,

                        /*
                         * Preserve the original order for equal percentages.
                         */
                        originalIndex: videos.length,
                    });
                }
            }

            if (videos.length < 2) {
                continue;
            }

            /*
             * Highest percentage first.
             *
             * Equal percentages retain their original order.
             */
            videos.sort((a, b) => {

                if (b.value !== a.value) {
                    return b.value - a.value;
                }

                return (
                    a.originalIndex -
                    b.originalIndex
                );
            });

            /*
             * ------------------------------------------------------------
             * The category may contain multiple .thumbs-items elements.
             *
             * We want the sorted videos to become one continuous list,
             * rather than leaving videos scattered across multiple
             * .thumbs-items containers.
             *
             * Therefore, put all sorted video cards into the category's
             * primary .thumbs-items container.
             * ------------------------------------------------------------
             */

            const primary = group.primary;

            if (!primary) {
                continue;
            }

            /*
             * Move each video card into the primary container.
             *
             * appendChild() moves the existing element; it does not clone it.
             */
            for (const video of videos) {
                primary.appendChild(
                    video.element
                );
            }

            /*
             * The other .thumbs-items containers are now empty of video
             * cards. We leave the containers themselves in place because
             * they may be used by the site's pagination/layout system.
             */
            log(
                `sort: ${group.key}: sorted ${videos.length} video(s)`
            );
        }
    };

    /* ================================================================
       STARTUP
       ================================================================ */

    const start = () => {
        /*
         * Wait briefly because the site may still be constructing its
         * video-list containers.
         */
        setTimeout(() => {
            try {
                filterPrivateVideos();
            } catch (error) {
                log(
                    'filterPrivateVideos failed:',
                    error
                );
            }
        }, 100);

        /*
         * Run sorting after filtering.
         *
         * This is intentional: private videos should be removed before
         * the remaining videos are sorted.
         */
        setTimeout(() => {
            try {
                sortVideos();
            } catch (error) {
                log(
                    'sortVideos failed:',
                    error
                );
            }
        }, 200);

        /*
         * Only install the single-video MutationObserver on actual
         * video pages.
         */
        if (VIDEO_URL_RE.test(location.href)) {
            watchVideoPage();
        }

        /*
         * Add copy buttons to all listing containers.
         */
        addListingButtons().catch(error => {
            log(
                'addListingButtons threw:',
                error
            );
        });
    };

    /*
     * Tampermonkey can inject the script after the load event has already
     * fired, so support both situations.
     */
    if (document.readyState === 'complete') {
        start();
    } else {
        window.addEventListener(
            'load',
            start,
            { once: true }
        );
    }

})();