// ==UserScript==
// @name Tampermonkey Video Filter v4
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Filters posts with videos using dynamic content detection and improved performance.
// @author harryangstrom, xdegeneratex, remuru
// @match https://*.coomer.party/*/user/*
// @match https://*.kemono.party/*/user/*
// @match https://*.coomer.su/*/user/*
// @match https://*.kemono.su/*/user/*
// @match https://*.coomer.party/posts*
// @match https://*.kemono.party/posts*
// @match https://*.coomer.su/posts*
// @match https://*.kemono.su/posts*
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'mov'];
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif'];
const POSTS_PER_PAGE = 50;
const API_DELAY = 2000; // Delay between API requests
const SUBSTRING_TITLE_LENGTH = 100; // Max length for title from substring
const LS_COLLAPSE_KEY = 'videoFilterPanelCollapsed_v1';
let currentDomain = window.location.hostname;
let allFoundVideoUrls = [];
let videoIntersectionObserver = null;
let isPanelCollapsed = false; // Panel is initially expanded
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'; // Store for restore
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 = '»'; // « (Collapse)
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'; // Above panelMainContent
collapseButton.onmouseenter = () => { if (collapseButton.style.backgroundColor !== disabledButtonBg) collapseButton.style.backgroundColor = hoverButtonBg; };
collapseButton.onmouseleave = () => { if (collapseButton.style.backgroundColor !== disabledButtonBg) collapseButton.style.backgroundColor = '#4a4a4c'; };
const panelMainContent = document.createElement('div');
panelMainContent.id = 'video-filter-main-content';
panelMainContent.style.marginLeft = '30px'; // Space for collapse button
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 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';
// Styles buttons based on disabled state
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';
}
// Apply initial styles and hover effects
[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; };
});
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';
panelMainContent.appendChild(document.createTextNode('Pages: '));
panelMainContent.appendChild(pageRangeInput);
panelMainContent.appendChild(filterButton);
panelMainContent.appendChild(copyUrlsButton);
panelMainContent.appendChild(statusMessage);
uiContainer.appendChild(collapseButton);
uiContainer.appendChild(panelMainContent);
document.body.appendChild(uiContainer);
// Logic for collapsing and expanding the panel
function togglePanelCollapse() {
isPanelCollapsed = !isPanelCollapsed;
if (isPanelCollapsed) {
panelMainContent.style.display = 'none';
collapseButton.innerHTML = '«'; // » (Expand)
uiContainer.style.width = '41px';
uiContainer.style.height = '80px';
uiContainer.style.padding = '0';
} else {
panelMainContent.style.display = 'block';
collapseButton.innerHTML = '»'; // « (Collapse)
uiContainer.style.width = '';
uiContainer.style.height = '';
uiContainer.style.padding = initialUiContainerPadding;
}
localStorage.setItem(LS_COLLAPSE_KEY, isPanelCollapsed.toString());
}
collapseButton.addEventListener('click', togglePanelCollapse);
// Load and apply saved collapsed state on script load
const initiallyCollapsed = localStorage.getItem(LS_COLLAPSE_KEY) === 'true';
if (initiallyCollapsed) {
// isPanelCollapsed is currently false. togglePanelCollapse will invert it to true and apply styles.
togglePanelCollapse();
}
// Set up Intersection Observer for lazy loading videos
function setupVideoIntersectionObserver() {
if (videoIntersectionObserver) {
videoIntersectionObserver.disconnect();
}
const options = {
root: null, // relative to document viewport
rootMargin: '200px 0px', // start loading when video is 200px away from viewport
threshold: 0.01 // minimal visibility to trigger
};
videoIntersectionObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const videoElement = entry.target; // This is the <video> element
const sourceElement = videoElement.querySelector('source[data-src]');
if (sourceElement) {
const videoUrl = sourceElement.getAttribute('data-src');
// console.log('Lazy loading video:', videoUrl); // Debugging line, keep commented
sourceElement.setAttribute('src', videoUrl);
videoElement.load(); // Tell the video element to load the new source
sourceElement.removeAttribute('data-src'); // Remove data-src to prevent re-processing
observer.unobserve(videoElement); // Stop observing this video once loaded
}
}
});
}, options);
}
// Display status messages in the UI
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;
}
// Clear success message after a delay
if (type === 'success' && message.includes("Copied")) {
setTimeout(() => {
if (statusMessage.textContent === message) {
statusMessage.textContent = '';
statusMessage.style.color = '#cccccc';
}
}, 3000);
}
}
// Parses the page range input string (e.g., "1, 3-5, 7") into an array of numbers
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);
}
// Determines the current page context (user profile or global search)
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') return { type: 'global_search', query: query || null };
showStatus('Error: Could not determine page context.', 'error');
console.error('Unknown page structure:', pathname, window.location.search);
return null;
}
// Builds the API URL based on the determined context and offset
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}`;
case 'user_search':
queryParams += `&q=${encodeURIComponent(context.query)}`;
return `${baseApiUrl}/${context.service}/user/${context.userId}/posts-legacy?${queryParams}`;
case 'global_search':
if (context.query) queryParams += `&q=${encodeURIComponent(context.query)}`;
return `${baseApiUrl}/posts?${queryParams}`;
default: return null;
}
}
// Fetches data from the given API URL using GM_xmlhttpRequest
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 from ${apiUrl}: ${e.message}`); }
} else { reject(`API request failed for ${apiUrl}: ${resp.status} ${resp.statusText}`); }
},
onerror: resp => reject(`API request error for ${apiUrl}: ${resp.statusText || 'Network error'}`)
});
});
}
// Checks if a filename or path has a video extension
function isVideoFile(filenameOrPath) {
if (!filenameOrPath) return false;
const lowerName = filenameOrPath.toLowerCase();
return VIDEO_EXTENSIONS.some(ext => lowerName.endsWith('.' + ext));
}
// Checks if a filename or path has an image extension
function isImageFile(filenameOrPath) {
if (!filenameOrPath) return false;
const lowerName = filenameOrPath.toLowerCase();
return IMAGE_EXTENSIONS.some(ext => lowerName.endsWith('.' + ext));
}
// Gets the URL for the post preview image
function getPostPreviewUrl(post, apiPreviewsEntry) {
// Check API previews first
if (apiPreviewsEntry && apiPreviewsEntry.length > 0 && apiPreviewsEntry[0]) {
const previewData = apiPreviewsEntry[0];
if (previewData.server && previewData.path) return `${previewData.server}${previewData.path}`;
}
// Check post file if it's an image
if (post.file && post.file.path && isImageFile(post.file.path)) return `https://${currentDomain}/data${post.file.path}`;
if (post.file && post.file.name && isImageFile(post.file.name) && post.file.name.startsWith('/')) return `https://${currentDomain}/data${post.file.name}`;
// Check attachments for images
if (post.attachments) {
for (const attachment of post.attachments) {
if (attachment.path && isImageFile(attachment.path)) return `https://${currentDomain}/data${attachment.path}`;
if (attachment.name && isImageFile(attachment.name) && attachment.name.startsWith('/')) return `https://${currentDomain}/data${attachment.name}`;
}
}
return null;
}
// Finds the first video URL associated with a post
function getFirstVideoUrlFromPost(post) {
const domain = `https://${currentDomain}/data`;
// Check main post file
if (post.file && (isVideoFile(post.file.name) || isVideoFile(post.file.path))) {
if (post.file.path) return domain + post.file.path;
if (post.file.name && post.file.name.startsWith('/')) return domain + post.file.name;
}
// Check attachments
if (post.attachments) {
for (const att of post.attachments) {
if (isVideoFile(att.name) || isVideoFile(att.path)) {
if (att.path) return domain + att.path;
if (att.name && att.name.startsWith('/')) return domain + att.name;
}
}
}
return null;
}
// Creates the HTML structure for a post card preview
function createPostCardHtml(post, previewUrl) {
const postDate = new Date(post.published || post.added);
const formattedDate = postDate.toLocaleString();
const dateTimeAttr = postDate.toISOString();
const attachmentCount = post.attachments ? post.attachments.length : 0;
const attachmentText = attachmentCount === 1 ? "1 Attachment" : `${attachmentCount} Attachments`;
let displayTitle = (post.title && post.title.trim()) ? post.title.trim() : '';
let potentialContent = post.content || post.substring || '';
// Use substring or content if no title is available
if (!displayTitle && potentialContent) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = potentialContent;
let contentForTitle = (tempDiv.textContent || tempDiv.innerText || "").trim();
if (contentForTitle) {
displayTitle = contentForTitle.substring(0, SUBSTRING_TITLE_LENGTH) + (contentForTitle.length > SUBSTRING_TITLE_LENGTH ? '...' : '');
}
}
displayTitle = displayTitle || 'No Title';
let mediaHtml = '';
const firstVideoUrl = getFirstVideoUrlFromPost(post);
// Generate media HTML based on available content (video preferred)
if (firstVideoUrl) {
const posterAttribute = previewUrl ? `poster="${previewUrl}"` : '';
mediaHtml = `
<div class="post-card__image-container" style="text-align: center; margin-bottom: 5px; background-color: #000;">
<video class="lazy-load-video" controls preload="none" width="100%" style="max-height: 300px; display: block;" ${posterAttribute}>
<source data-src="${firstVideoUrl}" type="video/mp4">
Your browser does not support the video tag.
</video>
</div>`;
} else if (previewUrl) {
mediaHtml = `
<div class="post-card__image-container" style="text-align: center; margin-bottom: 5px;">
<img class="post-card__image" src="${previewUrl}" alt="Preview for post ${post.id}" style="max-width: 100%; height: auto; max-height: 200px; object-fit: contain; border: 1px solid #444;">
</div>`;
} else {
mediaHtml = `
<div class="post-card__image-container" style="text-align: center; margin-bottom: 5px; height: 100px; display: flex; align-items: center; justify-content: center; background-color: #333; color: #aaa; font-size:0.9em; border: 1px solid #444;">
No Preview Available
</div>`;
}
const postLink = `/${post.service}/user/${post.user}/post/${post.id}`;
return `
<article class="post-card post-card--preview" data-id="${post.id}" data-service="${post.service}" data-user="${post.user}">
<a class="fancy-link fancy-link--kemono" 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 class="timestamp" datetime="${dateTimeAttr}">${formattedDate}</time>
<div>${attachmentCount > 0 ? attachmentText : 'No Attachments'}</div>
</div>
</div>
</footer>
</a>
</article>`;
}
// Main function to handle the filtering process
async function handleFilter() {
showStatus('');
filterButton.textContent = 'Filter Videos';
styleButton(filterButton, true); filterButton.disabled = true;
styleButton(copyUrlsButton, true); copyUrlsButton.disabled = true;
allFoundVideoUrls = [];
setupVideoIntersectionObserver();
const pageRangeStr = pageRangeInput.value;
const pagesToFetch = parsePageRange(pageRangeStr);
if (!pagesToFetch) {
styleButton(filterButton, false); filterButton.disabled = false;
return;
}
const context = determinePageContext();
if (!context) {
styleButton(filterButton, false); filterButton.disabled = false;
return;
}
const postListContainer = document.querySelector('.card-list__items');
if (!postListContainer) {
showStatus('Error: Post container (.card-list__items) not found.', 'error');
styleButton(filterButton, false); filterButton.disabled = false;
return;
}
// Adjust container layout for new card size
postListContainer.style.setProperty('--card-size', '350px');
// Clear existing posts
postListContainer.innerHTML = '';
// Hide original paginator elements
document.querySelectorAll('.paginator menu, .content > menu.Paginator').forEach(menu => menu.style.display = 'none');
const paginatorInfo = document.querySelector('.paginator > small, .content > div > small.subtle-text');
if (paginatorInfo) paginatorInfo.textContent = `Filtering posts...`;
let totalVideoPostsFound = 0;
// Iterate through selected pages and fetch data
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 = `Filtering Page ${i + 1} / ${pagesToFetch.length}...`;
showStatus(`Fetching page ${pageNum} (offset ${offset})...`, 'info');
try {
const apiResponse = await fetchData(apiUrl);
let posts = [], resultPreviews = null;
// Parse response format (can vary slightly)
if (Array.isArray(apiResponse)) {
posts = apiResponse;
} else if (apiResponse.results && Array.isArray(apiResponse.results)) {
posts = apiResponse.results;
resultPreviews = apiResponse.result_previews;
} else if (apiResponse.posts && Array.isArray(apiResponse.posts)) {
posts = apiResponse.posts;
resultPreviews = apiResponse.result_previews;
} else {
showStatus(`Warning: Unexpected API response for page ${pageNum}.`, 'error');
console.warn("Unexpected API response:", apiResponse);
continue; // Skip to the next page
}
// Process each post in the response
for (let postIndex = 0; postIndex < posts.length; postIndex++) {
const post = posts[postIndex];
let isVideo = false, postVideoUrls = [];
// Check main post file for video
if (post.file && (isVideoFile(post.file.name) || isVideoFile(post.file.path))) {
isVideo = true;
if (post.file.path) postVideoUrls.push(`https://${currentDomain}/data${post.file.path}`);
else if (post.file.name && post.file.name.startsWith('/')) postVideoUrls.push(`https://${currentDomain}/data${post.file.name}`);
}
// Check attachments for videos
if (post.attachments) {
post.attachments.forEach(att => {
if (isVideoFile(att.name) || isVideoFile(att.path)) {
isVideo = true;
if (att.path) postVideoUrls.push(`https://${currentDomain}/data${att.path}`);
else if (att.name && att.name.startsWith('/')) postVideoUrls.push(`https://${currentDomain}/data${att.name}`);
}
});
}
// If video found, add to list and display card
if (isVideo) {
totalVideoPostsFound++;
allFoundVideoUrls.push(...postVideoUrls);
const apiPreviewEntry = resultPreviews ? resultPreviews[postIndex] : null;
const previewUrl = getPostPreviewUrl(post, apiPreviewEntry);
const cardHtml = createPostCardHtml(post, previewUrl);
postListContainer.insertAdjacentHTML('beforeend', cardHtml);
}
}
// Update progress message
if (paginatorInfo) paginatorInfo.textContent = `Showing ${totalVideoPostsFound} video posts from selected pages.`;
} catch (error) {
showStatus(`Error fetching page ${pageNum}: ${error}`, 'error');
console.error("Filter error:", error);
}
// Wait before fetching the next page to avoid hitting rate limits
if (i < pagesToFetch.length - 1) await new Promise(resolve => setTimeout(resolve, API_DELAY));
}
// Observe newly added video elements for lazy loading
const videoElementsInContainer = postListContainer.querySelectorAll('video.lazy-load-video');
videoElementsInContainer.forEach(videoEl => {
// Only observe if it still has the data-src attribute
if (videoEl.querySelector('source[data-src]') && videoIntersectionObserver) {
videoIntersectionObserver.observe(videoEl);
}
});
filterButton.textContent = 'Filter Videos';
styleButton(filterButton, false); filterButton.disabled = false;
// Final status update based on results
if (totalVideoPostsFound > 0) {
showStatus(`Filter complete. Found ${totalVideoPostsFound} video posts.`, 'success');
styleButton(copyUrlsButton, false); copyUrlsButton.disabled = false;
} else {
showStatus('Filter complete. No video posts found on selected pages.', 'info');
styleButton(copyUrlsButton, true); copyUrlsButton.disabled = true;
}
}
// Handles copying the found video URLs to the clipboard
function handleCopyUrls() {
if (allFoundVideoUrls.length === 0) {
showStatus("No video URLs to copy.", 'error');
return;
}
const uniqueUrls = [...new Set(allFoundVideoUrls)]; // Get unique URLs
GM_setClipboard(uniqueUrls.join('\n')); // Copy to clipboard, one URL per line
const originalText = copyUrlsButton.textContent;
copyUrlsButton.textContent = `Copied ${uniqueUrls.length} URLs!`;
showStatus(`Copied ${uniqueUrls.length} unique video URLs!`, 'success');
// Restore button text after a delay
setTimeout(() => { copyUrlsButton.textContent = originalText; }, 3000);
}
// Attach event listeners to buttons
filterButton.addEventListener('click', handleFilter);
copyUrlsButton.addEventListener('click', handleCopyUrls);
// Initial status message based on page context
if (determinePageContext()) {
showStatus("Video filter ready. Enter page range and click 'Filter Videos'.", 'info');
} else {
showStatus("Page not recognized. Filter may not work as expected.", 'error');
}
})();