// ==UserScript==
// @name Tampermonkey Video Filter v4 (video duration)
// @namespace http://tampermonkey.net/
// @version 1.3.9
// @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 = 5000;
const MAX_CONCURRENT_METADATA_REQUESTS = 10;
// --- 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.cssText = 'position:fixed; bottom:10px; right:10px; background-color:#2c2c2e; color:#e0e0e0; border:1px solid #444; padding:12px; z-index:9999; font-family:Arial,sans-serif; font-size:14px; box-shadow:0 2px 8px rgba(0,0,0,0.5); border-radius:4px; transition:all 0.2s ease-in-out;';
const initialUiContainerPadding = '12px';
const collapseButton = document.createElement('button');
collapseButton.id = 'video-filter-collapse-button';
collapseButton.innerHTML = '»';
collapseButton.title = 'Collapse/Expand Panel';
collapseButton.style.cssText = 'position:absolute; bottom:8px; left:8px; width:25px; height:60px; display:flex; align-items:center; justify-content:center; padding:0; font-size:16px; background-color:#4a4a4c; color:#f0f0f0; border:1px solid #555; border-radius:3px; cursor:pointer; z-index: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.cssText = 'width:100px; margin-right:8px; padding:6px 8px; background-color:#1e1e1e; color:#e0e0e0; border:1px solid #555; border-radius: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.cssText = 'width:100px; margin-right:8px; padding:6px 8px; background-color:#1e1e1e; color:#e0e0e0; border:1px solid #555; border-radius: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.cssText = 'padding:6px 8px; background-color:#1e1e1e; color:var(--colour0-primary, #e0e0e0); border:1px solid #555; border-radius: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',
hoverButtonBg = '#4a4a4c',
disabledButtonBg = '#303030',
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.cssText = 'margin-top:8px; font-size:12px; min-height:15px; 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);
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);
}
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
};
}
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) {
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();
});
}
}
// ИЗМЕНЕНО: Возвращена полная версия функции
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("Video Filter: 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("Video Filter: 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('Video Filter: 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('Video Filter: 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}/posts?${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;
}
}
function fetchData(apiUrl) {
const headers = {
"Accept": "text/css",
"Referer": window.location.href,
"User-Agent": navigator.userAgent,
"X-Requested-With": "XMLHttpRequest"
};
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: apiUrl,
headers: headers,
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}`;
return `<article class="post-card post-card--preview"><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>`;
}
async function handleFilter() {
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;
const needsDurationCheck = !!parsedDurationFilter || sortOption.startsWith('duration_');
const durationCheckerPool = new DurationCheckerPool(MAX_CONCURRENT_METADATA_REQUESTS);
let postsToDisplay = [];
let postsProcessedCounter = 0;
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 = `Page ${i + 1}/${pagesToFetch.length}...`;
try {
const apiResponse = await fetchData(apiUrl);
let posts = Array.isArray(apiResponse) ? apiResponse : (apiResponse.results || apiResponse.posts || []);
if (!Array.isArray(posts)) {
console.error("Could not extract a valid posts array from API response:", apiResponse);
continue;
}
let resultPreviews = apiResponse.result_previews;
for (let postIndex = 0; postIndex < posts.length; postIndex++) {
postsProcessedCounter++;
filterButton.textContent = `Page ${i + 1}/${pagesToFetch.length} (Post ${postsProcessedCounter})...`;
const post = posts[postIndex];
const postVideoUrls = getAllVideoUrlsFromPost(post);
if (postVideoUrls.length === 0) continue;
let postDuration = null;
let matchesDurationFilter = !parsedDurationFilter;
if (needsDurationCheck) {
const durationPromises = postVideoUrls.map(url => durationCheckerPool.add(url));
const results = await Promise.allSettled(durationPromises);
for (const result of results) {
if (result.status === 'fulfilled') {
const duration = result.value;
if (postDuration === null) postDuration = duration;
if (parsedDurationFilter && duration >= parsedDurationFilter.min && duration <= parsedDurationFilter.max) {
matchesDurationFilter = true;
postDuration = duration;
break;
}
} else {
console.warn(`Could not get duration for a video in post ${post.id}:`, result.reason.message);
}
}
}
if (matchesDurationFilter) {
allFoundVideoUrls.push(...postVideoUrls);
const apiPreviewEntry = resultPreviews ? (resultPreviews[postIndex] || resultPreviews[post.id]) : null;
postsToDisplay.push({
post,
previewUrl: getPostPreviewUrl(post, apiPreviewEntry),
videoDuration: postDuration,
postDate: new Date(post.published || post.added)
});
}
}
} catch (error) {
showStatus(`Error on page ${pageNum}: ${error}`, 'error');
console.error("Filter error:", error);
}
if (i < pagesToFetch.length - 1) await new Promise(r => setTimeout(r, API_DELAY));
}
showStatus('Sorting results...', 'info');
postsToDisplay.sort((a, b) => {
switch (sortOption) {
case 'date_asc':
return a.postDate - b.postDate;
case 'duration_desc':
if (a.videoDuration === null) return 1;
if (b.videoDuration === null) return -1;
return b.videoDuration - a.videoDuration;
case 'duration_asc':
if (a.videoDuration === null) return 1;
if (b.videoDuration === null) return -1;
return a.videoDuration - b.videoDuration;
case 'date_desc':
default:
return b.postDate - a.postDate;
}
});
showStatus('Rendering sorted posts...', 'info');
postsToDisplay.forEach(postData => postListContainer.insertAdjacentHTML('beforeend', createPostCardHtml(postData, postData.previewUrl, postData.videoDuration)));
postListContainer.querySelectorAll('video.lazy-load-video').forEach(videoEl => {
if (videoEl.querySelector('source[data-src]') && videoIntersectionObserver) videoIntersectionObserver.observe(videoEl);
});
if (paginatorInfo) paginatorInfo.textContent = `Showing ${postsToDisplay.length} video posts. Processed ${postsProcessedCounter} posts.`;
filterButton.textContent = 'Filter Videos';
styleButton(filterButton, false);
filterButton.disabled = false;
if (postsToDisplay.length > 0) {
showStatus(`Filter complete. Found ${postsToDisplay.length} 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);
}
function handleUrlChangeAndSetStatus() {
setTimeout(() => {
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);
}
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);
const initiallyCollapsed = localStorage.getItem(LS_COLLAPSE_KEY) === 'true';
if (initiallyCollapsed) togglePanelCollapse();
handleUrlChangeAndSetStatus();
})();