Tampermonkey Video Filter v4 (video duration)

Filters and sorts posts by video duration, supports popular pages, and includes SPA navigation handling. Skips duration check if field is empty or timeout is reached.

Από την 10/08/2025. Δείτε την τελευταία έκδοση.

// ==UserScript==
// @name         Tampermonkey Video Filter v4 (video duration)
// @namespace    http://tampermonkey.net/
// @version      1.3.6
// @description  Filters and sorts posts by video duration, supports popular pages, and includes SPA navigation handling. Skips duration check if field is empty or timeout is reached.
// @author       harryangstrom, xdegeneratex, remuru, AI Assistant
// @match        https://*.coomer.party/*
// @match        https://*.coomer.su/*
// @match        https://*.coomer.st/*
// @match        https://*.kemono.su/*
// @match        https://*.kemono.party/*
// @match        https://*.kemono.cr/*
// @grant        GM_setClipboard
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @run-at       document-idle
// @license      MIT
// ==/UserScript==


(function() {
    'use strict';

    const CUSTOM_STYLES = `
       select option {
  color: var(--colour0-primary) !important;
}
    `;
    GM_addStyle(CUSTOM_STYLES);


    // --- CONFIGURATION ---
    const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'mov', 'webm'];
    const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif'];
    const POSTS_PER_PAGE = 50;
    const API_DELAY = 1000;
    const SUBSTRING_TITLE_LENGTH = 100;
    const LS_COLLAPSE_KEY = 'videoFilterPanelCollapsed_v2';
    const VIDEO_DURATION_CHECK_TIMEOUT = 15000;
    const MAX_CONCURRENT_METADATA_REQUESTS = 5;

    // --- GLOBAL STATE ---
    let currentDomain = window.location.hostname;
    let allFoundVideoUrls = [];
    let videoIntersectionObserver = null;
    let isPanelCollapsed = false;

    // --- UI Elements ---
    const uiContainer = document.createElement('div');
    uiContainer.id = 'video-filter-ui';
    uiContainer.style.position = 'fixed';
    uiContainer.style.bottom = '10px';
    uiContainer.style.right = '10px';
    uiContainer.style.backgroundColor = '#2c2c2e';
    uiContainer.style.color = '#e0e0e0';
    uiContainer.style.border = '1px solid #444444';
    const initialUiContainerPadding = '12px';
    uiContainer.style.padding = initialUiContainerPadding;
    uiContainer.style.zIndex = '9999';
    uiContainer.style.fontFamily = 'Arial, sans-serif';
    uiContainer.style.fontSize = '14px';
    uiContainer.style.boxShadow = '0 2px 8px rgba(0,0,0,0.5)';
    uiContainer.style.borderRadius = '4px';
    uiContainer.style.transition = 'width 0.2s ease-in-out, height 0.2s ease-in-out, padding 0.2s ease-in-out';

    const collapseButton = document.createElement('button');
    collapseButton.id = 'video-filter-collapse-button';
    collapseButton.innerHTML = '»';
    collapseButton.title = 'Collapse/Expand Panel';
    collapseButton.style.position = 'absolute';
    collapseButton.style.bottom = '8px';
    collapseButton.style.left = '8px';
    collapseButton.style.width = '25px';
    collapseButton.style.height = '60px';
    collapseButton.style.display = 'flex';
    collapseButton.style.alignItems = 'center';
    collapseButton.style.justifyContent = 'center';
    collapseButton.style.padding = '0';
    collapseButton.style.fontSize = '16px';
    collapseButton.style.backgroundColor = '#4a4a4c';
    collapseButton.style.color = '#f0f0f0';
    collapseButton.style.border = '1px solid #555555';
    collapseButton.style.borderRadius = '3px';
    collapseButton.style.cursor = 'pointer';
    collapseButton.style.zIndex = '1';

    const panelMainContent = document.createElement('div');
    panelMainContent.id = 'video-filter-main-content';
    panelMainContent.style.marginLeft = '30px';

    const pageRangeInput = document.createElement('input');
    pageRangeInput.type = 'text';
    pageRangeInput.id = 'video-filter-page-range';
    pageRangeInput.value = '1';
    pageRangeInput.placeholder = 'e.g., 1, 2-5, 7';
    pageRangeInput.style.width = '100px';
    pageRangeInput.style.marginRight = '8px';
    pageRangeInput.style.padding = '6px 8px';
    pageRangeInput.style.backgroundColor = '#1e1e1e';
    pageRangeInput.style.color = '#e0e0e0';
    pageRangeInput.style.border = '1px solid #555555';
    pageRangeInput.style.borderRadius = '3px';

    const durationLabel = document.createElement('label');
    durationLabel.htmlFor = 'video-filter-duration-range';
    durationLabel.textContent = 'Duration (s): ';
    durationLabel.style.marginLeft = '10px';

    const durationRangeInput = document.createElement('input');
    durationRangeInput.type = 'text';
    durationRangeInput.id = 'video-filter-duration-range';
    durationRangeInput.placeholder = 'e.g., 10-30, 60-, -120';
    durationRangeInput.title = 'Filter by video duration in seconds. Examples:\n"10-30": 10 to 30 seconds\n"60-": 60 seconds or more\n"-120": up to 120 seconds\nLeave empty for no duration filter.';
    durationRangeInput.style.width = '100px';
    durationRangeInput.style.marginRight = '8px';
    durationRangeInput.style.padding = '6px 8px';
    durationRangeInput.style.backgroundColor = '#1e1e1e';
    durationRangeInput.style.color = '#e0e0e0';
    durationRangeInput.style.border = '1px solid #555555';
    durationRangeInput.style.borderRadius = '3px';

    const sortLabel = document.createElement('label');
    sortLabel.htmlFor = 'video-filter-sort-by';
    sortLabel.textContent = 'Sort by: ';
    sortLabel.style.marginLeft = '10px';

    const sortBySelect = document.createElement('select');
    sortBySelect.id = 'video-filter-sort-by';
    sortBySelect.style.padding = '6px 8px';
    sortBySelect.style.backgroundColor = '#1e1e1e';
    sortBySelect.style.color = 'var(--colour0-primary, #e0e0e0)';
    sortBySelect.style.border = '1px solid #555555';
    sortBySelect.style.borderRadius = '3px';
    sortBySelect.innerHTML = `
        <option value="date_desc">Date (Newest First)</option>
        <option value="date_asc">Date (Oldest First)</option>
        <option value="duration_desc">Duration (Longest First)</option>
        <option value="duration_asc">Duration (Shortest First)</option>
    `;

    const filterButton = document.createElement('button');
    filterButton.id = 'video-filter-button';
    filterButton.textContent = 'Filter Videos';

    const copyUrlsButton = document.createElement('button');
    copyUrlsButton.id = 'video-copy-urls-button';
    copyUrlsButton.textContent = 'Copy Video URLs';
    copyUrlsButton.disabled = true;

    const baseButtonBg = '#3a3a3c';
    const hoverButtonBg = '#4a4a4c';
    const disabledButtonBg = '#303030';
    const disabledButtonColor = '#777777';

    function styleButton(button, disabled = false) {
        if (disabled) {
            button.style.backgroundColor = disabledButtonBg;
            button.style.color = disabledButtonColor;
            button.style.cursor = 'default';
        } else {
            button.style.backgroundColor = baseButtonBg;
            button.style.color = '#f0f0f0';
            button.style.cursor = 'pointer';
        }
        button.style.marginRight = '8px';
        button.style.padding = '6px 12px';
        button.style.border = '1px solid #555555';
        button.style.borderRadius = '3px';
    }

    [filterButton, copyUrlsButton].forEach(btn => {
        styleButton(btn, btn.disabled);
        btn.onmouseenter = () => { if (!btn.disabled) btn.style.backgroundColor = hoverButtonBg; };
        btn.onmouseleave = () => { if (!btn.disabled) btn.style.backgroundColor = baseButtonBg; };
    });
    collapseButton.onmouseenter = () => { if (collapseButton.style.backgroundColor !== disabledButtonBg) collapseButton.style.backgroundColor = hoverButtonBg; };
    collapseButton.onmouseleave = () => { if (collapseButton.style.backgroundColor !== disabledButtonBg) collapseButton.style.backgroundColor = '#4a4a4c'; };

    const statusMessage = document.createElement('div');
    statusMessage.id = 'video-filter-status';
    statusMessage.style.marginTop = '8px';
    statusMessage.style.fontSize = '12px';
    statusMessage.style.minHeight = '15px';
    statusMessage.style.color = '#cccccc';

    const topControlsContainer = document.createElement('div');
    topControlsContainer.style.marginBottom = '8px';
    topControlsContainer.appendChild(document.createTextNode('Pages: '));
    topControlsContainer.appendChild(pageRangeInput);
    topControlsContainer.appendChild(durationLabel);
    topControlsContainer.appendChild(durationRangeInput);



    const bottomControlsContainer = document.createElement('div');

    bottomControlsContainer.appendChild(filterButton);
    bottomControlsContainer.appendChild(copyUrlsButton);

    bottomControlsContainer.appendChild(sortLabel);
    bottomControlsContainer.appendChild(sortBySelect);

    panelMainContent.appendChild(topControlsContainer);
    panelMainContent.appendChild(bottomControlsContainer);
    panelMainContent.appendChild(statusMessage);

    uiContainer.appendChild(collapseButton);
    uiContainer.appendChild(panelMainContent);
    document.body.appendChild(uiContainer);
    // --- End of UI Elements ---

    function flushLogs(logCollector) {
        if (logCollector.length === 0) return;
        logCollector.forEach(entry => {
            switch (entry.type) {
                case 'group': console.groupCollapsed(...entry.args); break;
                case 'log': console.log(...entry.args); break;
                case 'warn': console.warn(...entry.args); break;
                case 'endgroup': console.groupEnd(); break;
            }
        });
    }

    function togglePanelCollapse() {
        isPanelCollapsed = !isPanelCollapsed;
        if (isPanelCollapsed) {
            panelMainContent.style.display = 'none';
            collapseButton.innerHTML = '«';
            uiContainer.style.width = '41px';
            uiContainer.style.height = '80px';
            uiContainer.style.padding = '0';
        } else {
            panelMainContent.style.display = 'block';
            collapseButton.innerHTML = '»';
            uiContainer.style.width = '';
            uiContainer.style.height = '';
            uiContainer.style.padding = initialUiContainerPadding;
        }
        localStorage.setItem(LS_COLLAPSE_KEY, isPanelCollapsed.toString());
    }

    function setupVideoIntersectionObserver() {
        if (videoIntersectionObserver) {
            videoIntersectionObserver.disconnect();
        }
        const options = { root: null, rootMargin: '200px 0px', threshold: 0.01 };
        videoIntersectionObserver = new IntersectionObserver((entries, observer) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    const videoElement = entry.target;
                    const sourceElement = videoElement.querySelector('source[data-src]');
                    if (sourceElement) {
                        const videoUrl = sourceElement.getAttribute('data-src');
                        sourceElement.setAttribute('src', videoUrl);
                        videoElement.load();
                        sourceElement.removeAttribute('data-src');
                        observer.unobserve(videoElement);
                    }
                }
            });
        }, options);
    }

    function showStatus(message, type = 'info') {
        statusMessage.textContent = message;
        switch (type) {
            case 'error': statusMessage.style.color = '#ff6b6b'; break;
            case 'success': statusMessage.style.color = '#76c7c0'; break;
            case 'info': default: statusMessage.style.color = '#cccccc'; break;
        }
        if (type === 'success' && message.includes("Copied")) {
            setTimeout(() => {
                if (statusMessage.textContent === message) {
                    statusMessage.textContent = '';
                    statusMessage.style.color = '#cccccc';
                }
            }, 3000);
        }
    }

    function parsePageRange(inputStr) {
        const pages = new Set();
        if (!inputStr || inputStr.trim() === '') {
            showStatus('Error: Page range cannot be empty.', 'error');
            return null;
        }
        const parts = inputStr.split(',');
        for (const part of parts) {
            if (part.includes('-')) {
                const [startStr, endStr] = part.split('-');
                const start = parseInt(startStr, 10);
                const end = parseInt(endStr, 10);
                if (isNaN(start) || isNaN(end) || start <= 0 || end < start) {
                    showStatus(`Error: Invalid range "${part}". Start must be > 0 and end >= start.`, 'error');
                    return null;
                }
                for (let i = start; i <= end; i++) pages.add(i);
            } else {
                const page = parseInt(part, 10);
                if (isNaN(page) || page <= 0) {
                    showStatus(`Error: Invalid page number "${part}". Must be > 0.`, 'error');
                    return null;
                }
                pages.add(page);
            }
        }
        if (pages.size === 0) {
            showStatus('Error: No valid pages specified.', 'error');
            return null;
        }
        return Array.from(pages).sort((a, b) => a - b);
    }

    // ИСПРАВЛЕНО: `max` теперь корректно устанавливается в `Infinity`.
    function parseDurationRange(inputStr) {
        if (!inputStr || inputStr.trim() === '') return null;
        const trimmedInput = inputStr.trim();
        let match;
        match = trimmedInput.match(/^(\d+)-(\d+)$/); if (match) return { min: parseInt(match[1], 10), max: parseInt(match[2], 10) };
        match = trimmedInput.match(/^(\d+)-$/); if (match) return { min: parseInt(match[1], 10), max: Infinity };
        match = trimmedInput.match(/^-(\d+)$/); if (match) return { min: 0, max: parseInt(match[1], 10) };
        showStatus(`Error: Invalid duration format "${trimmedInput}". Use e.g. "10-30", "60-", or "-120".`, 'error');
        return { error: true };
    }

    // --- Video Duration Logic with Timeout ---
    function _getVideoDurationInternal(videoUrl) {
        return new Promise((resolve, reject) => {
            const video = document.createElement('video');
            video.preload = 'metadata';
            video.style.display = 'none';
            document.body.appendChild(video);
            let resolved = false;
            let timeoutId = null;
            const cleanup = () => { if (timeoutId) clearTimeout(timeoutId); video.onloadedmetadata = video.onerror = video.onabort = null; try { video.src = ''; video.removeAttribute('src'); while (video.firstChild) { video.removeChild(video.firstChild); } } catch (e) { /* ignore */ } if (video.parentNode) video.parentNode.removeChild(video); };
            timeoutId = setTimeout(() => { if (resolved) return; resolved = true; const errorMsg = `Timeout loading metadata for ${videoUrl.split('/').pop()} after ${VIDEO_DURATION_CHECK_TIMEOUT / 1000}s.`; console.warn(errorMsg); reject(new Error(errorMsg)); cleanup(); }, VIDEO_DURATION_CHECK_TIMEOUT);
            video.onloadedmetadata = () => { if (resolved) return; resolved = true; const duration = video.duration; if (typeof duration === 'number' && !isNaN(duration) && isFinite(duration)) { resolve(duration); } else { reject(new Error(`Invalid or infinite duration for ${videoUrl.split('/').pop()}: ${duration}`)); } cleanup(); };
            video.onerror = () => { if (resolved) return; resolved = true; reject(new Error(`Error loading metadata for ${videoUrl.split('/').pop()}`)); cleanup(); };
            video.onabort = () => { if (resolved) return; resolved = true; reject(new Error(`Metadata loading aborted for ${videoUrl.split('/').pop()}.`)); cleanup(); };
            const sourceElement = document.createElement('source');
            sourceElement.src = videoUrl;
            video.appendChild(sourceElement);
            video.load();
        });
    }

    class DurationCheckerPool {
        constructor(maxConcurrent) {
            this.maxConcurrent = maxConcurrent;
            this.queue = [];
            this.activeCount = 0;
        }
        add(videoUrl) {
            // Этот лог остается здесь, т.к. он показывает общую очередь, а не относится к конкретному посту
            console.log(`[VFilter Pool] Queuing: ${videoUrl.split('/').pop()}`);
            return new Promise((resolve, reject) => {
                this.queue.push({ videoUrl, resolve, reject });
                this._processQueue();
            });
        }
        async _processQueue() {
            if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) {
                return;
            }
            this.activeCount++;
            const { videoUrl, resolve, reject } = this.queue.shift();
            const videoFileNameForStatus = videoUrl.split('/').pop();

            showStatus(`Dur. check (${this.activeCount}/${this.maxConcurrent}, Q:${this.queue.length}): ${videoFileNameForStatus.substring(0,15)}...`, 'info');

            _getVideoDurationInternal(videoUrl)
                .then(duration => resolve(duration))
                .catch(error => reject(error))
                .finally(() => {
                this.activeCount--;
                this._processQueue();
            });
        }
    }
    // --- End Video Duration Logic ---

    // --- Page Context & API ---
    function determinePageContext() {
        const pathname = window.location.pathname;
        const searchParams = new URLSearchParams(window.location.search);
        const query = searchParams.get('q');
        const userProfileMatch = pathname.match(/^\/([^/]+)\/user\/([^/]+)$/);

        if (userProfileMatch && !query) return { type: 'profile', service: userProfileMatch[1], userId: userProfileMatch[2] };
        if (userProfileMatch && query) return { type: 'user_search', service: userProfileMatch[1], userId: userProfileMatch[2], query };

        if (pathname === '/posts/popular') {
            let ctxDate = searchParams.get('date');
            let ctxPeriod = searchParams.get('period');
            let dateFound = !!ctxDate;
            let periodFound = !!ctxPeriod;

            if (!dateFound || !periodFound) {
                try {
                    const nextDataScript = document.getElementById('__NEXT_DATA__');
                    if (nextDataScript) {
                        const jsonData = JSON.parse(nextDataScript.textContent);
                        const pageProps = jsonData?.props?.pageProps;
                        if (pageProps) {
                            let tempDate = null, tempPeriod = null;
                            if (pageProps.data?.info?.date && pageProps.data?.base?.period) {
                                tempDate = pageProps.data.info.date.substring(0, 10);
                                tempPeriod = pageProps.data.base.period;
                            } else if (pageProps.props?.today && pageProps.props?.currentPage === "popular_posts") {
                                tempDate = pageProps.props.today;
                                tempPeriod = 'week';
                            } else if (pageProps.initialState?.feed?.feed?.info?.date && pageProps.initialState?.feed?.feed?.base?.period) {
                                tempDate = pageProps.initialState.feed.feed.info.date.substring(0, 10);
                                tempPeriod = pageProps.initialState.feed.feed.base.period;
                            } else if (pageProps.initialProps?.pageProps?.data?.info?.date && pageProps.initialProps?.pageProps?.data?.base?.period) {
                                tempDate = pageProps.initialProps.pageProps.data.info.date.substring(0, 10);
                                tempPeriod = pageProps.initialProps.pageProps.data.base.period;
                            }
                            if (!dateFound && tempDate) { ctxDate = tempDate; dateFound = true; }
                            if (!periodFound && tempPeriod) { ctxPeriod = tempPeriod; periodFound = true; }
                        }
                    }
                } catch (e) { console.warn("[VFilter]: Could not parse __NEXT_DATA__.", e); }

                if (!dateFound) {
                    const popularDateSpan = document.querySelector('main#main.main section.site-section.site-section--popular-posts header.site-section__header h1.site-section__heading span');
                    if (popularDateSpan && popularDateSpan.title) {
                        const titleDateMatch = popularDateSpan.title.match(/^(\d{4}-\d{2}-\d{2})/);
                        if (titleDateMatch && titleDateMatch[1]) {
                            ctxDate = titleDateMatch[1];
                            dateFound = true;
                            if (!periodFound) { ctxPeriod = 'day'; periodFound = true; }
                        }
                    }
                }

                if (dateFound && !periodFound) {
                    ctxPeriod = 'week'; periodFound = true;
                }

                if (searchParams.toString() === '' && !dateFound) {
                    const today = new Date();
                    ctxDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
                    dateFound = true;
                    if (!periodFound) { ctxPeriod = 'week'; periodFound = true; }
                    console.warn("[VFilter]: Used fallback to today's date for /posts/popular.");
                }
            }

            if (dateFound && periodFound) {
                return { type: 'popular_posts', date: ctxDate, period: ctxPeriod, query: null };
            } else {
                console.error('[VFilter]: Missing date/period for /posts/popular. Final Date:', ctxDate, 'Final Period:', ctxPeriod);
                return null;
            }
        }

        if (pathname === '/posts') return { type: 'global_search', query: query || null };

        console.error('[VFilter]: Unknown page structure for context.', pathname, window.location.search);
        return null;
    }

    function buildApiUrl(context, offset) {
        let baseApiUrl = `https://${currentDomain}/api/v1`;
        let queryParams = [`o=${offset}`];
        switch (context.type) {
            case 'profile': return `${baseApiUrl}/${context.service}/user/${context.userId}?${queryParams.join('&')}`;
            case 'user_search': queryParams.push(`q=${encodeURIComponent(context.query)}`); return `${baseApiUrl}/${context.service}/user/${context.userId}/posts-legacy?${queryParams.join('&')}`;
            case 'global_search': if (context.query) queryParams.push(`q=${encodeURIComponent(context.query)}`); return `${baseApiUrl}/posts?${queryParams.join('&')}`;
            case 'popular_posts': queryParams.push(`date=${encodeURIComponent(context.date)}`); queryParams.push(`period=${encodeURIComponent(context.period)}`); return `${baseApiUrl}/posts/popular?${queryParams.join('&')}`;
            default: return null;
        }
    }

    // --- Helper Functions ---
    function fetchData(apiUrl) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: apiUrl, onload: resp => { if (resp.status >= 200 && resp.status < 300) { try { resolve(JSON.parse(resp.responseText)); } catch (e) { reject(`Error parsing JSON: ${e.message}`); } } else { reject(`API request failed: ${resp.status} ${resp.statusText}`); } }, onerror: resp => reject(`Network error: ${resp.statusText || 'Unknown'}`) }); }); }
    function isVideoFile(p) { return p ? VIDEO_EXTENSIONS.some(e => p.toLowerCase().endsWith('.' + e)) : false; }
    function isImageFile(p) { return p ? IMAGE_EXTENSIONS.some(e => p.toLowerCase().endsWith('.' + e)) : false; }
    function getPostPreviewUrl(post, apiPreviewsEntry) { if (apiPreviewsEntry && apiPreviewsEntry.length > 0 && apiPreviewsEntry[0]?.server && apiPreviewsEntry[0]?.path) { return `${apiPreviewsEntry[0].server}${apiPreviewsEntry[0].path}`; } if (post.file?.path && isImageFile(post.file.path)) return `https://${currentDomain}/data${post.file.path}`; if (post.attachments) { for (const a of post.attachments) { if (a.path && isImageFile(a.path)) return `https://${currentDomain}/data${a.path}`; } } return null; }
    function getAllVideoUrlsFromPost(post) { const d = `https://${currentDomain}/data`, u = []; if (post.file?.path && isVideoFile(post.file.path)) u.push(d + post.file.path); if (post.attachments) { for (const a of post.attachments) { if (a.path && isVideoFile(a.path)) u.push(d + a.path); } } return [...new Set(u)]; }
    function getFirstVideoUrlForDisplay(post) { const v = getAllVideoUrlsFromPost(post); return v.length > 0 ? v[0] : null; }
    function createPostCardHtml(postData, previewUrl, videoDurationToDisplay = null) {
        const { post, postDate } = postData;
        const formattedDate = postDate.toLocaleString();
        const attachmentCount = post.attachments?.length || 0;
        const attachmentText = attachmentCount === 1 ? "1 Attachment" : `${attachmentCount} Attachments`;
        let displayTitle = (post.title || '').trim();
        if (!displayTitle) {
            const div = document.createElement('div');
            div.innerHTML = post.content || '';
            displayTitle = (div.textContent || "").trim().substring(0, SUBSTRING_TITLE_LENGTH);
        }
        displayTitle = displayTitle || 'No Title';
        const firstVideoUrlForCard = getFirstVideoUrlForDisplay(post);
        const durationDisplay = videoDurationToDisplay !== null ? `<div style="position:absolute;bottom:5px;right:5px;background:rgba(0,0,0,0.7);color:white;padding:2px 5px;font-size:0.8em;border-radius:3px;">${Math.round(videoDurationToDisplay)}s</div>` : '';
        let mediaHtml = '';
        if (firstVideoUrlForCard) {
            const poster = previewUrl ? `poster="${previewUrl}"` : '';
            mediaHtml = `<div style="position:relative;background:#000;"><video class="lazy-load-video" controls preload="none" width="100%" style="max-height:265px;display:block;" ${poster}><source data-src="${firstVideoUrlForCard}" type="video/mp4"></video>${durationDisplay}</div>`;
        } else if (previewUrl) {
            mediaHtml = `<div><img src="${previewUrl}" style="max-width:100%;max-height:200px;object-fit:contain;"></div>`;
        } else {
            mediaHtml = `<div style="height:100px;display:flex;align-items:center;justify-content:center;background:#333;color:#aaa;">No Preview</div>`;
        }
        const postLink = `/${post.service}/user/${post.user}/post/${post.id}`;
        const sortDate = postDate.getTime();
        const sortDuration = videoDurationToDisplay !== null ? Math.round(videoDurationToDisplay) : -1;
        return `<article class="post-card post-card--preview" data-sort-date="${sortDate}" data-sort-duration="${sortDuration}"><a class="fancy-link" href="${postLink}" target="_blank" rel="noopener noreferrer"><header class="post-card__header" title="${displayTitle.replace(/"/g, '"')}">${displayTitle}</header>${mediaHtml}<footer class="post-card__footer"><div><div><time datetime="${postDate.toISOString()}">${formattedDate}</time><div>${attachmentCount > 0 ? attachmentText : 'No Attachments'}</div></div></div></footer></a></article>`;
    }


    // ##################################################################
    // ### НАЧАЛО ИЗМЕНЕННОГО БЛОКА: Core Logic: handleFilter with Sorting ###
    // ##################################################################
    async function handleFilter() {
        console.log("[VFilter] Starting filter process...");
        showStatus('');
        filterButton.textContent = 'Filtering...';
        styleButton(filterButton, true); filterButton.disabled = true;
        styleButton(copyUrlsButton, true); copyUrlsButton.disabled = true;
        allFoundVideoUrls = [];
        setupVideoIntersectionObserver();

        const pagesToFetch = parsePageRange(pageRangeInput.value);
        if (!pagesToFetch) { styleButton(filterButton, false); filterButton.disabled = false; return; }

        const parsedDurationFilter = parseDurationRange(durationRangeInput.value);
        if (parsedDurationFilter?.error) { styleButton(filterButton, false); filterButton.disabled = false; return; }

        const context = determinePageContext();
        if (!context) { showStatus('Filter disabled, context not recognized.', 'error'); styleButton(filterButton, false); filterButton.disabled = false; return; }

        const postListContainer = document.querySelector('.card-list__items');
        if (!postListContainer) { showStatus('Error: Post container not found.', 'error'); styleButton(filterButton, false); filterButton.disabled = false; return; }

        postListContainer.style.setProperty('--card-size', '350px');
        postListContainer.innerHTML = '';
        document.querySelectorAll('.paginator menu, .content > menu.Paginator').forEach(m => m.style.display = 'none');
        const paginatorInfo = document.querySelector('.paginator > small, .content > div > small.subtle-text');
        if (paginatorInfo) paginatorInfo.textContent = `Filtering posts...`;

        const sortOption = sortBySelect.value;
        // ИСПРАВЛЕНО: `needsDurationCheck` теперь корректно преобразуется в boolean.
        const needsDurationCheck = !!parsedDurationFilter || sortOption.startsWith('duration_');
        console.log(`[VFilter] Filter settings: Duration Check=${needsDurationCheck}, Sort By=${sortOption}, Duration Range=${JSON.stringify(parsedDurationFilter)}`);

        const durationCheckerPool = new DurationCheckerPool(MAX_CONCURRENT_METADATA_REQUESTS);
        let postsProcessedCounter = 0;
        const allProcessingPromises = [];

        console.log(`[VFilter] Starting to fetch ${pagesToFetch.length} pages.`);
        for (let i = 0; i < pagesToFetch.length; i++) {
            const pageNum = pagesToFetch[i];
            const offset = (pageNum - 1) * POSTS_PER_PAGE;
            const apiUrl = buildApiUrl(context, offset);
            if (!apiUrl) { showStatus('Error: Could not build API URL.', 'error'); break; }

            filterButton.textContent = `Fetching Page ${i + 1}/${pagesToFetch.length}...`;
            try {
                const apiResponse = await fetchData(apiUrl);
                const posts = Array.isArray(apiResponse) ? apiResponse : (apiResponse.results || apiResponse.posts || []);
                const resultPreviews = apiResponse.result_previews;

                console.log(`[VFilter] Fetched data for page ${pageNum}. Found ${posts.length} posts.`);

                for (let postIndex = 0; postIndex < posts.length; postIndex++) {
                    postsProcessedCounter++;
                    // ИСПРАВЛЕНО: Передаем `post` и `postsProcessedCounter` как аргументы, чтобы избежать проблем с замыканием.
                    const currentPost = posts[postIndex];
                    const currentPostNumber = postsProcessedCounter;

                    const postProcessingPromise = (async (post, postNumber) => {
                        const logCollector = [];

                        if (!post || !post.id) {
                            logCollector.push({ type: 'warn', args: [`Post #${postNumber}: SKIPPED, invalid post data.`] });
                            flushLogs(logCollector);
                            return false;
                        }

                        const postVideoUrls = getAllVideoUrlsFromPost(post);

                        if (postVideoUrls.length === 0) {
                            // Не засоряем лог, если видео нет - это норма.
                            return false;
                        }

                        let postDuration = null;
                        let matchesDurationFilter = !parsedDurationFilter;

                        if (needsDurationCheck) {
                            const durationPromises = postVideoUrls.map(videoUrl => durationCheckerPool.add(videoUrl));
                            const results = await Promise.allSettled(durationPromises);

                            logCollector.push({ type: 'log', args: [`Post ${post.id}: All duration checks settled.`] });

                            for (const result of results) {
                                if (result.status === 'fulfilled') {
                                    const duration = result.value;
                                    logCollector.push({ type: 'log', args: [`Post ${post.id}: Duration check PASSED with ~${Math.round(duration)}s`] });
                                    if (postDuration === null) postDuration = duration;
                                    if (parsedDurationFilter && duration >= parsedDurationFilter.min && duration <= parsedDurationFilter.max) {
                                        matchesDurationFilter = true;
                                        postDuration = duration;
                                        break;
                                    }
                                } else {
                                    logCollector.push({ type: 'warn', args: [`Post ${post.id}: Duration check FAILED:`, result.reason.message] });
                                }
                            }
                            if (!parsedDurationFilter && postDuration !== null) {
                                matchesDurationFilter = true;
                            }
                        }

                        const finalDecision = matchesDurationFilter ? 'MATCHED' : 'SKIPPED';
                        logCollector.push({ type: 'log', args: [`Post ${post.id}: Filter check result: ${finalDecision}. Final duration for sorting: ${postDuration}`] });

                        if (matchesDurationFilter) {
                            const postData = { post, postDate: new Date(post.published || post.added) };
                            const sortDuration = postDuration !== null ? Math.round(postDuration) : -1;

                            logCollector.push({
                                type: 'log',
                                args: [
                                    `%cPost ${post.id}: RENDERING CARD with data-sort-date="${postData.postDate.getTime()}" data-sort-duration="${sortDuration}"`,
                                    'color: #00dd00'
                                ]
                            });

                            allFoundVideoUrls.push(...postVideoUrls);
                            const apiPreviewEntry = resultPreviews ? (resultPreviews[postIndex] || resultPreviews[post.id]) : null;
                            const previewUrl = getPostPreviewUrl(post, apiPreviewEntry);

                            const cardHtml = createPostCardHtml(postData, previewUrl, postDuration);
                            postListContainer.insertAdjacentHTML('beforeend', cardHtml);

                            const newCard = postListContainer.lastElementChild;
                            const newVideo = newCard.querySelector('video.lazy-load-video');
                            if (newVideo) videoIntersectionObserver.observe(newVideo);
                        }

                        const groupTitle = `[VFilter] Post #${postNumber} (ID: ${post.id}) - ${finalDecision}`;
                        flushLogs([{ type: 'group', args: [groupTitle] }, ...logCollector, { type: 'endgroup', args: [] }]);

                        return matchesDurationFilter;
                    })(currentPost, currentPostNumber);

                    allProcessingPromises.push(postProcessingPromise);
                }
            } catch (error) {
                console.error("[VFilter] Critical error while fetching page:", error);
                showStatus(`Error on page ${pageNum}: ${error}`, 'error');
            }
            if (i < pagesToFetch.length - 1) await new Promise(r => setTimeout(r, API_DELAY));
        }

        console.log(`[VFilter] All pages fetched. Total promises created: ${allProcessingPromises.length}. Waiting for all to complete...`);
        showStatus('Processing remaining videos...', 'info');

        const results = await Promise.all(allProcessingPromises);
        const matchedPostsCounter = results.filter(Boolean).length;

        console.log(`[VFilter] All video processing complete. Total matched posts: ${matchedPostsCounter}`);

        console.log(`[VFilter] Sorting ${matchedPostsCounter} displayed cards based on '${sortOption}'...`);
        showStatus('Sorting results...', 'info');

        const cards = Array.from(postListContainer.querySelectorAll('.post-card--preview'));

        cards.sort((a, b) => {
            const aDate = Number(a.dataset.sortDate);
            const bDate = Number(b.dataset.sortDate);
            const aDuration = Number(a.dataset.sortDuration);
            const bDuration = Number(b.dataset.sortDuration);

            switch (sortOption) {
                case 'date_asc': return aDate - bDate;
                case 'duration_desc': return bDuration - aDuration;
                case 'duration_asc': return aDuration - bDuration;
                case 'date_desc': default: return bDate - aDate;
            }
        });

        console.log("[VFilter] Clearing container to re-append cards in sorted order...");
        postListContainer.innerHTML = '';
        cards.forEach(card => postListContainer.appendChild(card));
        console.log("[VFilter] Finished re-appending sorted cards.");

        if (paginatorInfo) paginatorInfo.textContent = `Showing ${matchedPostsCounter} video posts. Processed ${postsProcessedCounter} posts.`;
        filterButton.textContent = 'Filter Videos';
        styleButton(filterButton, false); filterButton.disabled = false;

        if (matchedPostsCounter > 0) {
            showStatus(`Filter complete. Found ${matchedPostsCounter} video posts.`, 'success');
            styleButton(copyUrlsButton, false); copyUrlsButton.disabled = false;
        } else {
            showStatus(`Filter complete. No matching video posts found.`, 'info');
        }
    }
    // ################################################################
    // ### КОНЕЦ ИЗМЕНЕННОГО БЛОКА ###
    // ################################################################

    function handleCopyUrls() {
        if (allFoundVideoUrls.length === 0) { showStatus("No video URLs to copy.", 'error'); return; }
        const uniqueUrls = [...new Set(allFoundVideoUrls)];
        GM_setClipboard(uniqueUrls.join('\n'));
        copyUrlsButton.textContent = `Copied ${uniqueUrls.length} URLs!`;
        showStatus(`Copied ${uniqueUrls.length} unique video URLs!`, 'success');
        setTimeout(() => { copyUrlsButton.textContent = 'Copy Video URLs'; }, 3000);
    }

    // --- SPA Navigation Handling ---
    function handleUrlChangeAndSetStatus() {
        setTimeout(() => {
            console.log("[VFilter] URL changed, re-evaluating context.");
            const currentContext = determinePageContext();
            allFoundVideoUrls = [];
            styleButton(copyUrlsButton, true); copyUrlsButton.disabled = true;
            if (videoIntersectionObserver) videoIntersectionObserver.disconnect();
            if (currentContext) {
                showStatus("Video filter ready. Set filters and click 'Filter Videos'.", 'info');
                styleButton(filterButton, false); filterButton.disabled = false;
            } else {
                showStatus("Page context not recognized. Filter disabled on this page.", 'error');
                styleButton(filterButton, true); filterButton.disabled = true;
            }
        }, 100);
    }

    // --- Event Listeners ---
    filterButton.addEventListener('click', handleFilter);
    copyUrlsButton.addEventListener('click', handleCopyUrls);
    collapseButton.addEventListener('click', togglePanelCollapse);
    const originalPushState = history.pushState;
    history.pushState = function() { originalPushState.apply(this, arguments); window.dispatchEvent(new Event('custompushstate')); };
    const originalReplaceState = history.replaceState;
    history.replaceState = function() { originalReplaceState.apply(this, arguments); window.dispatchEvent(new Event('customreplacestate')); };
    window.addEventListener('popstate', handleUrlChangeAndSetStatus);
    window.addEventListener('custompushstate', handleUrlChangeAndSetStatus);
    window.addEventListener('customreplacestate', handleUrlChangeAndSetStatus);

    // --- Initial Setup ---
    const initiallyCollapsed = localStorage.getItem(LS_COLLAPSE_KEY) === 'true';
    if (initiallyCollapsed) togglePanelCollapse();
    handleUrlChangeAndSetStatus();

})();