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).

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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

})();