Multi-tag search engine with background tag enrichment, search history, resume-on-block, live performer browser, favorites, and auto-saved thumbnails for recu.me.
// ==UserScript==
// @name Recu.me Titan Suite
// @namespace https://github.com/recu-titan
// @version 19.0
// @description Multi-tag search engine with background tag enrichment, search history, resume-on-block, live performer browser, favorites, and auto-saved thumbnails for recu.me.
// @author Titan
// @match https://recu.me/*
// @match https://*.recu.me/*
// @grant GM_download
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @connect recu.me
// @connect highmedia-stream.b-cdn.net
// @run-at document-end
// @license MIT
// @noframes
// ==/UserScript==
(function () {
'use strict';
const CONFIG = {
defaultDepth: 5,
storagePrefix: 'recu_v18_',
cacheExpiry: 1000 * 60 * 60 * 24 * 7, // 7 days
animationSpeed: 200,
cardMinWidth: '320px',
hoverScale: 1.05,
spriteColumns: 4,
spriteRows: 4,
spriteFrames: 16,
liveFrames: [0, 200, 400, 600, 800, 1000],
liveAnimationSpeed: 1500,
// Thumbnail settings
thumbnailPath: 'thumbnail/', // Subfolder under the browser's Downloads folder (trailing slash required)
autoSaveThumbnails: true,
// Optional: absolute path to a local folder to scan for previously-saved thumbnails so broken/expired remote thumbs can be restored.
// Leave empty to disable local restore. Example: 'C:\\Users\\YourName\\Downloads\\thumbnail' or '/home/you/Downloads/thumbnail'.
// Note: this only works if your browser allows file:// access from web pages (rare). Safe to leave blank.
localThumbnailFolder: '',
maxSearchHistory: 20,
// Concurrency settings
concurrentSearches: 3, // Number of tags to search concurrently
concurrentPages: 1 // Number of pages to fetch concurrently per tag (1 = sequential, fast)
};
const NO_THUMB = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180"%3E%3Crect width="320" height="180" fill="%231e293b"/%3E%3Ctext x="160" y="90" font-size="20" fill="%23475569" text-anchor="middle"%3ENo Preview%3C/text%3E%3C/svg%3E';
const FILTER_OPTIONS = {
section: [{ v: 'videos', l: 'Videos' }, { v: 'clips', l: 'Clips' }, { v: 'live', l: 'Live' }],
type: [{ v: 'recent', l: 'Recent' }, { v: 'bookmarked', l: 'Bookmarked' }, { v: 'viewed', l: 'Viewed' }, { v: 'liked', l: 'Liked' }],
period: [{ v: 'total', l: 'All Time' }, { v: 'month', l: 'Month' }, { v: 'week', l: 'Week' }, { v: 'today', l: 'Today' }],
gender: [{ v: 'all', l: 'All Genders' }, { v: 'female', l: 'Female' }, { v: 'male', l: 'Male' }, { v: 'couple', l: 'Couple' }, { v: 'trans', l: 'Trans' }],
ethnicity: [{ v: 'all', l: 'All Ethnicities' }, { v: 'white', l: 'White' }, { v: 'black', l: 'Black' }, { v: 'asian', l: 'Asian' }, { v: 'latino', l: 'Latino' }],
age: [{ v: 'all', l: 'All Ages' }, { v: 'young', l: 'Young' }, { v: 'mature', l: 'Mature' }],
body: [{ v: 'all', l: 'All Body Types' }, { v: 'slim', l: 'Slim' }, { v: 'chubby', l: 'Chubby' }, { v: 'muscular', l: 'Muscular' }]
};
const DEFAULT_FILTERS = {
gender: 'female'
};
const getDefaultFilterValue = key => DEFAULT_FILTERS[key] || FILTER_OPTIONS[key][0].v;
const wait = ms => new Promise(r => setTimeout(r, ms));
const suggestionCache = new Map();
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, ch => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
}[ch]));
}
function isEmptyResultsPage(html) {
return html.includes('No recordings meet the criteria');
}
function isChallengePage(html) {
return /Just a moment|cdn-cgi\/challenge-platform|cf-browser-verification|cf-chl|Verify you are human/i.test(html || '');
}
// ============================================================
// 1. STATE & PERSISTENCE
// ============================================================
const State = {
tagGroups: [],
allMatches: [],
livePerformers: [],
favorites: [],
thumbCache: {},
filters: {},
expandedGroups: new Set(),
showFavoritesOnly: false,
brokenThumbs: new Set(),
// NEW: Search history
searchHistory: [],
currentHistoryIndex: -1,
// NEW: Downloaded thumbnails tracking
downloadedThumbs: new Set(),
// NEW: Video tags cache (permanent)
videoTagsCache: {},
// NEW: Tag fetch progress tracking
tagFetchProgress: {
isRunning: false,
current: 0,
total: 0,
queue: []
},
// NEW: Expanded cards (for tag expansion)
expandedCards: new Set(),
// NEW: Tag popularity index (across current results)
tagPopularity: {},
// NEW: Active search state (for resume functionality)
activeSearch: null,
isResumingSearch: false,
searchAbortReason: null,
// NEW: Verified thumbnails (to avoid re-checking)
verifiedThumbs: new Set(),
// NEW: Videos being verified (to prevent duplicate checks)
verifyingThumbs: new Set()
};
// ============================================================
// 2. VIDEO TAG MANAGER (Fetch, Cache, Index)
// ============================================================
const VideoTagManager = {
CACHE_KEY: 'video_tags_cache_v1',
PROGRESS_KEY: 'tag_fetch_progress_v1',
CONCURRENT_FETCHES: 4,
FETCH_DELAY: 200,
saveTimer: null,
init() {
try {
const cached = GM_getValue(this.CACHE_KEY);
if (cached) {
State.videoTagsCache = JSON.parse(cached);
}
} catch (e) {
State.videoTagsCache = {};
}
},
saveCache() {
GM_setValue(this.CACHE_KEY, JSON.stringify(State.videoTagsCache));
},
scheduleCacheSave() {
if (this.saveTimer) {
clearTimeout(this.saveTimer);
}
this.saveTimer = setTimeout(() => {
this.saveTimer = null;
this.saveCache();
}, 250);
},
clearCache() {
State.videoTagsCache = {};
if (this.saveTimer) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
GM_deleteValue(this.CACHE_KEY);
alert('Video tag cache cleared!');
},
getCacheKey(video) {
return `${video.user}_${video.id}`;
},
getCachedTags(video) {
return State.videoTagsCache[this.getCacheKey(video)] || null;
},
async fetchVideoTags(video) {
const cacheKey = this.getCacheKey(video);
// Return cached if exists
if (State.videoTagsCache[cacheKey]) {
return State.videoTagsCache[cacheKey];
}
try {
const url = `https://recu.me/${video.user}/video/${video.id}/play`;
const res = await fetch(url);
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const tagsDiv = doc.querySelector('.tags');
const tags = [];
if (tagsDiv) {
tagsDiv.querySelectorAll('a').forEach(a => {
const tagText = a.innerText.trim().replace('#', '');
if (tagText) tags.push(tagText);
});
}
// Cache permanently
State.videoTagsCache[cacheKey] = tags;
this.scheduleCacheSave();
return tags;
} catch (e) {
console.error(`[Titan] Failed to fetch tags for ${video.user}/${video.id}:`, e);
return [];
}
},
// Calculate tag popularity across all current results
calculatePopularity(videos) {
State.tagPopularity = {};
videos.forEach(v => {
const tags = this.getCachedTags(v) || v.foundTags || [];
tags.forEach(tag => {
State.tagPopularity[tag] = (State.tagPopularity[tag] || 0) + 1;
});
});
},
// Sort tags by popularity
sortByPopularity(tags) {
return [...tags].sort((a, b) => {
const popA = State.tagPopularity[a] || 0;
const popB = State.tagPopularity[b] || 0;
return popB - popA;
});
},
// Background fetch for all videos with progress tracking
async fetchAllInBackground(videos, onProgress, onVideoUpdate) {
// Filter out already cached
const uncached = videos.filter(v => !this.getCachedTags(v));
if (uncached.length === 0) {
this.calculatePopularity(videos);
return;
}
State.tagFetchProgress = {
isRunning: true,
current: videos.length - uncached.length,
total: videos.length,
queue: [...uncached]
};
onProgress(State.tagFetchProgress.current, State.tagFetchProgress.total);
// Process in batches of CONCURRENT_FETCHES
const processBatch = async () => {
const batch = [];
for (let i = 0; i < this.CONCURRENT_FETCHES && State.tagFetchProgress.queue.length > 0; i++) {
const video = State.tagFetchProgress.queue.shift();
batch.push(
this.fetchVideoTags(video).then(tags => {
video.allTags = tags;
State.tagFetchProgress.current++;
onProgress(State.tagFetchProgress.current, State.tagFetchProgress.total);
onVideoUpdate(video);
return video;
})
);
// Stagger the requests
if (i < this.CONCURRENT_FETCHES - 1) {
await wait(this.FETCH_DELAY);
}
}
await Promise.all(batch);
// Continue if more in queue
if (State.tagFetchProgress.queue.length > 0 && State.tagFetchProgress.isRunning) {
await processBatch();
}
};
await processBatch();
State.tagFetchProgress.isRunning = false;
this.saveCache();
this.calculatePopularity(videos);
},
stopFetching() {
State.tagFetchProgress.isRunning = false;
State.tagFetchProgress.queue = [];
}
};
// ============================================================
// 4. THUMBNAIL CACHE & AUTO-SAVE
// ============================================================
const ThumbnailManager = {
CACHE_KEY: 'thumb_download_cache_v2',
BROKEN_THUMB_KEY: 'broken_thumb_cache_v1',
init() {
// Load downloaded thumbs tracking
try {
const cached = GM_getValue(this.CACHE_KEY);
if (cached) {
State.downloadedThumbs = new Set(JSON.parse(cached));
}
const brokenCached = GM_getValue(this.BROKEN_THUMB_KEY);
if (brokenCached) {
State.brokenThumbs = new Set(JSON.parse(brokenCached));
}
} catch (e) {
State.downloadedThumbs = new Set();
State.brokenThumbs = new Set();
}
},
_saveTimer: null,
_flushSave() {
GM_setValue(this.CACHE_KEY, JSON.stringify([...State.downloadedThumbs]));
GM_setValue(this.BROKEN_THUMB_KEY, JSON.stringify([...State.brokenThumbs]));
},
saveCache({ immediate = false } = {}) {
if (immediate) {
if (this._saveTimer) { clearTimeout(this._saveTimer); this._saveTimer = null; }
this._flushSave();
return;
}
if (this._saveTimer) return;
this._saveTimer = setTimeout(() => {
this._saveTimer = null;
this._flushSave();
}, 500);
},
getCacheKey(video) {
return `${video.user}_${video.id}`;
},
getCleanTitle(video) {
const rawTitle = video && video.title ? video.title : `${video?.user || 'video'}_video`;
return rawTitle
.replace(/[^a-z0-9]/gi, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
.substring(0, 50) || `${video?.user || 'video'}_video`;
},
getPrimaryThumbnailBaseName(video) {
return `${video.user}_${video.id}_${this.getCleanTitle(video)}`;
},
getRestoreBaseNames(video) {
return [
this.getPrimaryThumbnailBaseName(video),
`${video.user}_${video.id}_${video.user}_video`,
`${video.user}_${video.id}_${video.user}`
];
},
getDownloadFilename(video, thumbUrl) {
const ext = thumbUrl && thumbUrl.includes('.png') ? 'png' : 'jpg';
return `${CONFIG.thumbnailPath}${this.getPrimaryThumbnailBaseName(video)}.${ext}`;
},
isAlreadyDownloaded(video) {
return State.downloadedThumbs.has(this.getCacheKey(video));
},
// Fetch thumbnail as blob using XHR (emulates website's blob creation)
async fetchThumbnailAsBlob(thumbUrl) {
if (!thumbUrl || thumbUrl === NO_THUMB || thumbUrl.startsWith('data:') || thumbUrl.startsWith('blob:')) {
return null;
}
// Add delay to avoid overwhelming the server
await wait(200);
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", thumbUrl);
xhr.responseType = 'blob';
xhr.onabort = function () {
resolve(null);
};
xhr.onerror = function () {
resolve(null);
};
xhr.onload = function () {
if (this.status !== 200) {
resolve(null);
return;
}
const blob = this.response;
if (blob && blob.size > 0) {
// Create blob URL
const blobURL = URL.createObjectURL(blob);
resolve(blobURL);
} else {
resolve(null);
}
};
xhr.send();
// Timeout after 10 seconds
setTimeout(() => {
if (xhr.readyState !== XMLHttpRequest.DONE) {
xhr.abort();
resolve(null);
}
}, 10000);
});
},
// Check if thumbnail URL is valid using HTTP status code
async checkThumbnailValid(thumbUrl) {
if (!thumbUrl || thumbUrl === NO_THUMB || thumbUrl.startsWith('data:')) {
return false;
}
// Blob URLs are always considered valid (they're already loaded)
if (thumbUrl.startsWith('blob:')) {
return true;
}
// Check if already marked as broken
if (State.brokenThumbs.has(thumbUrl)) {
return false;
}
// No delay for faster auto-save - validation happens in parallel
// Use image load test to detect 4xx errors (410, 404, etc.)
// This is more reliable than HEAD requests which can't read status with no-cors
return new Promise((resolve) => {
const img = new Image();
let resolved = false;
const markBroken = () => {
if (!resolved) {
resolved = true;
State.brokenThumbs.add(thumbUrl);
this.saveCache();
resolve(false);
}
};
const markValid = () => {
if (!resolved) {
resolved = true;
resolve(true);
}
};
img.onload = () => {
// Image loaded successfully - check if it has valid dimensions
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
markValid();
} else {
// Image loaded but has no dimensions - likely broken
markBroken();
}
};
img.onerror = () => {
// Image failed to load - likely 4xx (410, 404, etc.) or network error
markBroken();
};
img.src = thumbUrl;
// Timeout after 5 seconds
setTimeout(() => {
if (!resolved) {
if (img.complete && img.naturalWidth > 0 && img.naturalHeight > 0) {
markValid();
} else {
// Timeout - assume broken
markBroken();
}
}
}, 5000);
});
},
// Restore thumbnail from local Downloads folder
// Pattern: {performer_username}_{video_ID}_{performer_username}_video.jpg
// Also supports: {performer_username}_{video_ID}_{performer_username}.jpg (without _video)
async restoreLocalThumbnail(video) {
if (!video || !video.user || !video.id) return null;
if (!CONFIG.localThumbnailFolder) return null;
const thumbnailFolder = CONFIG.localThumbnailFolder.replace(/[\\/]+$/, '');
const sep = thumbnailFolder.includes('\\') ? '\\' : '/';
const extensions = ['jpg', 'jpeg', 'png'];
// Try two patterns:
// 1. username_id_username_video.ext (with _video suffix)
// 2. username_id_username.ext (without _video suffix)
const patterns = this.getRestoreBaseNames(video);
for (const baseFilename of patterns) {
for (const ext of extensions) {
const filename = `${baseFilename}.${ext}`;
const filePath = `${thumbnailFolder}${sep}${filename}`;
// Convert path to file:// URL format
const fileUrl = `file:///${filePath.replace(/\\/g, '/').replace(/ /g, '%20').replace(/^\/+/, '')}`;
// Try to load the image to verify it exists
try {
const exists = await new Promise((resolve) => {
const img = new Image();
let resolved = false;
img.onload = () => {
if (!resolved) {
resolved = true;
resolve(true);
}
};
img.onerror = () => {
if (!resolved) {
resolved = true;
resolve(false);
}
};
img.src = fileUrl;
// Timeout after 2 seconds
setTimeout(() => {
if (!resolved) {
resolved = true;
resolve(false);
}
}, 2000);
});
if (exists) {
return fileUrl;
}
} catch (e) {
// Continue to next extension/pattern
continue;
}
}
}
return null;
},
// Re-acquire thumbnail from video page
async reacquireThumbnail(video) {
if (!video || !video.url) return null;
// First try to restore from local files
const localThumb = await this.restoreLocalThumbnail(video);
if (localThumb) {
return localThumb;
}
// Add delay to avoid overwhelming the server
await wait(500);
// Try to fetch from video page
try {
// Construct video page URL if needed
let videoUrl = video.url;
if (!videoUrl.startsWith('http')) {
videoUrl = videoUrl.startsWith('/') ? `https://recu.me${videoUrl}` : `https://recu.me/${videoUrl}`;
}
const res = await fetch(videoUrl);
if (!res.ok) return null;
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
// Try multiple selectors for thumbnail
const splash = doc.querySelector('.video-splash[data-src]');
if (splash && splash.dataset.src) {
const newThumb = splash.dataset.src;
if (newThumb && newThumb !== NO_THUMB && !State.brokenThumbs.has(newThumb)) {
// Verify new thumbnail is valid
const isValid = await this.checkThumbnailValid(newThumb);
if (isValid) {
return newThumb;
}
}
}
// Try alternative selector
const img = doc.querySelector('.video-thumb img[src], .video-splash img[src]');
if (img && img.src && img.src !== NO_THUMB) {
const newThumb = img.src;
if (!State.brokenThumbs.has(newThumb)) {
const isValid = await this.checkThumbnailValid(newThumb);
if (isValid) {
return newThumb;
}
}
}
// Try to get "Playing now" thumbnail from slider-body as fallback
const sliderBody = doc.querySelector('.slider-body');
if (sliderBody) {
// Look for video with "Playing now" message
const playingNowMessages = sliderBody.querySelectorAll('.message');
for (const message of playingNowMessages) {
if (message.textContent.trim().toLowerCase().includes('playing now')) {
// Find the video-thumb container
const videoThumb = message.closest('.video-thumb');
if (videoThumb) {
const playingNowSplash = videoThumb.querySelector('.video-splash[data-src]');
if (playingNowSplash && playingNowSplash.dataset.src) {
const playingThumb = playingNowSplash.dataset.src;
if (playingThumb && playingThumb !== NO_THUMB && !State.brokenThumbs.has(playingThumb)) {
const isValid = await this.checkThumbnailValid(playingThumb);
if (isValid) {
return playingThumb;
}
}
}
}
break; // Found one, no need to continue
}
}
}
return null;
} catch (e) {
console.error(`[Titan] Failed to re-acquire thumbnail for ${video.user}/${video.id}:`, e);
return null;
}
},
async autoSaveThumb(video) {
if (!video || !video.thumb || video.thumb === NO_THUMB) return false;
if (!CONFIG.autoSaveThumbnails) return false;
const cacheKey = this.getCacheKey(video);
// Already downloaded
if (State.downloadedThumbs.has(cacheKey)) {
return true;
}
const thumbUrl = video.thumb;
// Skip validation for blob URLs and file:// URLs (already valid)
// Only validate HTTP/HTTPS URLs, and do it quickly
let isValid = true;
if (thumbUrl.startsWith('http://') || thumbUrl.startsWith('https://')) {
// Quick check - skip if already marked broken, otherwise assume valid for speed
if (State.brokenThumbs.has(thumbUrl)) {
return false;
}
// Skip validation during auto-save for speed - download will fail gracefully if broken
isValid = true;
}
const filename = this.getDownloadFilename(video, thumbUrl);
return new Promise((resolve) => {
GM_download({
url: thumbUrl,
name: filename,
saveAs: false,
onload: () => {
State.downloadedThumbs.add(cacheKey);
this.saveCache();
resolve(true);
},
onerror: (err) => {
console.error(`[Titan] Download failed: ${filename}`, err);
resolve(false);
}
});
});
},
async batchSave(videos, progressCallback) {
let saved = 0;
let skipped = 0;
const CONCURRENT_DOWNLOADS = 5; // Process multiple thumbnails concurrently
// Filter out already downloaded videos first
const videosToDownload = videos.filter(v => !this.isAlreadyDownloaded(v));
skipped = videos.length - videosToDownload.length;
// Process in concurrent batches
for (let i = 0; i < videosToDownload.length; i += CONCURRENT_DOWNLOADS) {
const batch = videosToDownload.slice(i, i + CONCURRENT_DOWNLOADS);
const batchPromises = batch.map(video => this.autoSaveThumb(video));
const results = await Promise.all(batchPromises);
saved += results.filter(r => r === true).length;
if (progressCallback) {
const current = Math.min(i + CONCURRENT_DOWNLOADS, videosToDownload.length);
progressCallback(current + skipped, videos.length, saved, skipped, 0, 0);
}
// Small delay between batches only
if (i + CONCURRENT_DOWNLOADS < videosToDownload.length) {
await wait(10);
}
}
this.saveCache({ immediate: true });
return { saved, skipped, invalid: 0, reacquired: 0 };
},
clearCache() {
if (this._saveTimer) { clearTimeout(this._saveTimer); this._saveTimer = null; }
State.downloadedThumbs.clear();
State.brokenThumbs.clear();
GM_deleteValue(this.CACHE_KEY);
GM_deleteValue(this.BROKEN_THUMB_KEY);
alert('Thumbnail cache cleared!');
}
};
// ============================================================
// 5. SEARCH HISTORY MANAGEMENT
// ============================================================
const SearchHistory = {
KEY: 'search_history_v2',
init() {
try {
const cached = GM_getValue(this.KEY);
if (cached) {
State.searchHistory = JSON.parse(cached);
}
} catch (e) {
State.searchHistory = [];
}
},
save() {
GM_setValue(this.KEY, JSON.stringify(State.searchHistory));
},
add(searchEntry) {
// searchEntry = { timestamp, tagGroups, filters, depth, results, resultsCount }
const entry = {
id: Date.now(),
timestamp: new Date().toISOString(),
tagGroups: JSON.parse(JSON.stringify(searchEntry.tagGroups)),
filters: JSON.parse(JSON.stringify(searchEntry.filters)),
depth: searchEntry.depth,
results: searchEntry.results,
resultsCount: searchEntry.results.length,
label: this.generateLabel(searchEntry.tagGroups)
};
State.searchHistory.unshift(entry);
// Limit history size
if (State.searchHistory.length > CONFIG.maxSearchHistory) {
State.searchHistory = State.searchHistory.slice(0, CONFIG.maxSearchHistory);
}
State.currentHistoryIndex = 0;
this.save();
return entry;
},
generateLabel(tagGroups) {
return tagGroups.map(g => {
if (g.tags.length > 2) {
return `${g.tags.slice(0, 2).join('/')}+${g.tags.length - 2}`;
}
return g.tags.join('/');
}).join(' & ');
},
get(index) {
return State.searchHistory[index] || null;
},
remove(index) {
State.searchHistory.splice(index, 1);
this.save();
},
clear() {
State.searchHistory = [];
State.currentHistoryIndex = -1;
this.save();
},
mergeResults(indices) {
// Merge results from multiple history entries
const mergedResults = [];
indices.forEach(idx => {
const entry = this.get(idx);
if (entry && entry.results) {
mergedResults.push(...entry.results);
}
});
return mergeVideoLists(mergedResults);
}
};
// ============================================================
// 6. BACKUP & PERSISTENCE
// ============================================================
const Backup = {
KEY: 'autosave_master',
FAV_KEY: 'favorites',
THUMB_CACHE_KEY: 'thumb_cache_v5',
saveLocal: () => {
const data = {
ts: Date.now(),
groups: State.tagGroups,
filters: getCurrentFilters()
};
GM_setValue(Backup.KEY, JSON.stringify(data));
},
saveFavorites: () => {
GM_setValue(Backup.FAV_KEY, JSON.stringify(State.favorites));
const thumbCacheToSave = {};
State.favorites.forEach(username => {
if (State.thumbCache[username]) {
thumbCacheToSave[username] = State.thumbCache[username];
}
});
GM_setValue(Backup.THUMB_CACHE_KEY, JSON.stringify(thumbCacheToSave));
},
loadFavorites: () => {
try {
const favRaw = GM_getValue(Backup.FAV_KEY);
if (favRaw) State.favorites = JSON.parse(favRaw) || [];
const thumbRaw = GM_getValue(Backup.THUMB_CACHE_KEY);
if (thumbRaw) State.thumbCache = JSON.parse(thumbRaw) || {};
} catch (e) {
State.favorites = [];
State.thumbCache = {};
}
},
restoreLocal: () => {
const raw = GM_getValue(Backup.KEY);
if (raw) {
try {
const data = JSON.parse(raw);
if (Date.now() - data.ts < CONFIG.cacheExpiry) {
if (data.groups) State.tagGroups = data.groups;
if (data.filters) applyFiltersToUI(applyStartupFilterDefaults(data.filters));
}
} catch (e) { console.error("Restore failed", e); }
}
Backup.loadFavorites();
},
exportToFile: () => {
const data = {
version: '19.0 Titan',
date: new Date().toISOString(),
filters: getCurrentFilters(),
groups: State.tagGroups,
favorites: State.favorites,
thumbCache: State.thumbCache,
searchHistory: State.searchHistory
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `titan_backup_${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
},
importFromFile: (file) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
if (data.groups) State.tagGroups = data.groups;
if (data.filters) applyFiltersToUI(data.filters);
if (data.favorites) State.favorites = data.favorites;
if (data.thumbCache) State.thumbCache = data.thumbCache;
if (data.searchHistory) State.searchHistory = data.searchHistory;
if (window.renderChips) window.renderChips();
if (window.renderHistoryPanel) window.renderHistoryPanel();
Backup.saveLocal();
Backup.saveFavorites();
SearchHistory.save();
alert(`Imported ${State.tagGroups.length} groups, ${State.favorites.length} favorites, and ${State.searchHistory.length} history entries.`);
} catch (err) { alert("Invalid Backup File"); }
};
reader.readAsText(file);
}
};
// ============================================================
// 6.5. SEARCH INDEXER & RESUME
// ============================================================
const SearchIndexer = {
KEY: 'active_search_index_v1',
_saveTimer: null,
_pendingState: null,
_flush() {
const searchState = this._pendingState;
if (!searchState) return;
this._pendingState = null;
try {
const toSave = {
...searchState,
completedTags: searchState.completedTags instanceof Set
? Array.from(searchState.completedTags)
: (Array.isArray(searchState.completedTags) ? searchState.completedTags : []),
timestamp: Date.now()
};
GM_setValue(this.KEY, JSON.stringify(toSave));
} catch (e) {
console.error('[Titan] Failed to save search index:', e);
}
},
save(searchState, { immediate = false } = {}) {
this._pendingState = searchState;
if (immediate) {
if (this._saveTimer) { clearTimeout(this._saveTimer); this._saveTimer = null; }
this._flush();
return;
}
if (this._saveTimer) return;
this._saveTimer = setTimeout(() => {
this._saveTimer = null;
this._flush();
}, 400);
},
load() {
try {
const raw = GM_getValue(this.KEY);
if (!raw) return null;
const data = JSON.parse(raw);
// Only return if search is less than 24 hours old
if (Date.now() - data.timestamp < 1000 * 60 * 60 * 24) {
return data;
}
// Clear old search
this.clear();
return null;
} catch (e) {
return null;
}
},
clear() {
if (this._saveTimer) { clearTimeout(this._saveTimer); this._saveTimer = null; }
this._pendingState = null;
GM_deleteValue(this.KEY);
State.activeSearch = null;
},
updateProgress(groupIndex, tagIndex, completedPages, totalPages) {
if (!State.activeSearch) return;
const tagKey = `${groupIndex}_${tagIndex}`;
State.activeSearch.progress[tagKey] = {
completedPages,
totalPages,
lastUpdate: Date.now()
};
this.save(State.activeSearch);
},
markTagComplete(groupIndex, tagIndex) {
if (!State.activeSearch) return;
const tagKey = `${groupIndex}_${tagIndex}`;
if (!State.activeSearch.completedTags) {
State.activeSearch.completedTags = new Set();
}
State.activeSearch.completedTags.add(tagKey);
this.save(State.activeSearch, { immediate: true });
},
isTagComplete(groupIndex, tagIndex) {
if (!State.activeSearch || !State.activeSearch.completedTags) return false;
return State.activeSearch.completedTags.has(`${groupIndex}_${tagIndex}`);
},
getTagProgress(groupIndex, tagIndex) {
if (!State.activeSearch) return null;
const tagKey = `${groupIndex}_${tagIndex}`;
return State.activeSearch.progress[tagKey] || null;
}
};
const Memory = {
save: (key, data) => {
try { localStorage.setItem(CONFIG.storagePrefix + 'cache_' + key, JSON.stringify({ ts: Date.now(), data })); }
catch (e) {
Object.keys(localStorage).forEach(storageKey => {
if (storageKey.startsWith(CONFIG.storagePrefix)) {
localStorage.removeItem(storageKey);
}
});
}
},
load: (key) => {
const raw = localStorage.getItem(CONFIG.storagePrefix + 'cache_' + key);
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return (Date.now() - parsed.ts) < CONFIG.cacheExpiry ? parsed.data : null;
} catch (e) {
localStorage.removeItem(CONFIG.storagePrefix + 'cache_' + key);
return null;
}
},
clear: () => {
Object.keys(localStorage).forEach(k => { if (k.startsWith(CONFIG.storagePrefix)) localStorage.removeItem(k); });
alert('Cache cleared.');
}
};
// ============================================================
// 7. ENGINE & HELPERS
// ============================================================
function parseDuration(str) {
if (!str) return 0;
const parts = str.split(':').map(Number);
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
if (parts.length === 2) return parts[0] * 60 + parts[1];
return 0;
}
function normalizeList(values) {
return [...new Set((values || []).filter(Boolean))];
}
function sortVideos(videos) {
return [...videos].sort((a, b) => {
if ((a.foundTimestamp || 0) !== (b.foundTimestamp || 0)) {
return (b.foundTimestamp || 0) - (a.foundTimestamp || 0);
}
return (a.foundPage || 999) - (b.foundPage || 999);
});
}
function mergeVideoRecords(existing, incoming) {
const merged = {
...existing,
...incoming,
title: existing.title || incoming.title,
url: incoming.url || existing.url,
thumb: incoming.thumb && incoming.thumb !== NO_THUMB ? incoming.thumb : existing.thumb,
duration: incoming.duration || existing.duration,
durationSec: incoming.durationSec || existing.durationSec || 0,
foundTags: normalizeList([...(existing.foundTags || []), ...(incoming.foundTags || [])]),
searchedTags: normalizeList([...(existing.searchedTags || []), ...(incoming.searchedTags || [])]),
allTags: normalizeList([...(existing.allTags || []), ...(incoming.allTags || [])])
};
merged.foundPage = Math.min(existing.foundPage || 999, incoming.foundPage || 999);
merged.foundTimestamp = Math.max(existing.foundTimestamp || 0, incoming.foundTimestamp || 0);
return merged;
}
function mergeVideoLists(...lists) {
const merged = new Map();
lists.flat().forEach(video => {
if (!video) return;
const key = video.user || video.id;
if (!key) return;
if (merged.has(key)) {
merged.set(key, mergeVideoRecords(merged.get(key), video));
return;
}
merged.set(key, {
...video,
foundTags: normalizeList(video.foundTags),
searchedTags: normalizeList(video.searchedTags),
allTags: normalizeList(video.allTags)
});
});
return sortVideos(Array.from(merged.values()));
}
function getCurrentFilters() {
const filters = {};
Object.keys(FILTER_OPTIONS).forEach(key => {
const el = document.getElementById(`v16-opt-${key}`);
filters[key] = el ? el.value : getDefaultFilterValue(key);
});
return filters;
}
function isTagSearchSectionSupported(filters) {
return filters.section === 'videos' || filters.section === 'clips';
}
function applyStartupFilterDefaults(filters) {
return {
...filters,
gender: getDefaultFilterValue('gender')
};
}
function applyFiltersToUI(filters) {
Object.keys(filters).forEach(key => {
const el = document.getElementById(`v16-opt-${key}`);
if (el) el.value = filters[key];
});
}
function isLiveStreamUrl(url) {
return url && (url.includes('highmedia-stream') || /[?&]c=/.test(url));
}
function extractTValue(url) {
const match = url.match(/[?&]t=(\d+)/);
return match ? match[1] : null;
}
function buildLiveThumbUrl(url, newCValue) {
const tValue = extractTValue(url);
const room = url.match(/room=([^&]+)/)?.[1];
if (room && tValue) {
return `https://highmedia-stream.b-cdn.net/stream?room=${room}&t=${tValue}&c=${newCValue}`;
}
if (url.includes('&c=')) {
return url.replace(/&c=\d+/, `&c=${newCValue}`);
} else if (url.includes('?c=')) {
return url.replace(/\?c=\d+/, `?c=${newCValue}`);
}
return url;
}
function getSpritePosition(frame) {
const col = frame % CONFIG.spriteColumns;
const row = Math.floor(frame / CONFIG.spriteColumns);
const xPercent = (col / (CONFIG.spriteColumns - 1)) * 100;
const yPercent = (row / (CONFIG.spriteRows - 1)) * 100;
return `${xPercent}% ${yPercent}%`;
}
async function fetchStaticThumb(username) {
try {
const res = await fetch(`/performer/${username}/popular`);
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const splash = doc.querySelector('.video-splash[data-src]');
if (splash) {
const thumbUrl = splash.dataset.src;
if (thumbUrl && !thumbUrl.startsWith('blob:')) {
return thumbUrl;
}
}
const img = doc.querySelector('.video-thumb img[src]');
if (img && img.src && !img.src.startsWith('blob:')) {
return img.src;
}
return null;
} catch (e) {
console.error(`[Titan] Failed to fetch static thumb for ${username}:`, e);
return null;
}
}
async function fetchSuggestions(query) {
const normalizedQuery = (query || '').trim().toLowerCase();
if (normalizedQuery.length < 2) return [];
if (suggestionCache.has(normalizedQuery)) return suggestionCache.get(normalizedQuery);
try {
const res = await fetch(`/performers/search-suggest/${encodeURIComponent(normalizedQuery)}`, { method: 'POST' });
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const items = [];
doc.querySelectorAll('a[href*="/tag:"]').forEach(a => {
const val = a.innerText.trim().replace('#', '');
if (!items.find(i => i.val === val)) items.push({ type: 'tag', val, label: '#' + val });
});
suggestionCache.set(normalizedQuery, items);
return items;
} catch (e) {
suggestionCache.set(normalizedQuery, []);
return [];
}
}
// ============================================================
// 8. SCANNERS
// ============================================================
async function scanLivePerformers(filters, depth, logCallback) {
const performers = new Map();
let baseUrl = `/live/gender:${filters.gender}/ethnicity:${filters.ethnicity}/age:${filters.age}/body:${filters.body}`;
for (let page = 1; page <= depth; page++) {
logCallback(`🔴 Scanning live performers (Page ${page}/${depth})...`);
const url = page > 1 ? `${baseUrl}/page/${page}` : baseUrl;
try {
const res = await fetch(url);
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
let found = 0;
doc.querySelectorAll('a[href*="/performer/"]').forEach(link => {
const href = link.getAttribute('href');
const match = href.match(/\/performer\/([^\/]+)/);
if (match) {
const username = match[1];
if (!performers.has(username)) {
const splashDiv = link.querySelector('.video-splash');
let thumb = null;
if (splashDiv) {
thumb = splashDiv.dataset.src || splashDiv.dataset.bg || null;
if (!thumb && splashDiv.style.backgroundImage) {
const bgMatch = splashDiv.style.backgroundImage.match(/url\(['"]?([^'"]+)['"]?\)/);
if (bgMatch) thumb = bgMatch[1];
}
}
if (thumb) {
const isLive = isLiveStreamUrl(thumb);
const type = isLive ? 'live' : 'sprite';
State.thumbCache[username] = { url: thumb, timestamp: Date.now(), type };
performers.set(username, {
username,
url: href,
thumb,
thumbType: type,
isLive: true,
isFavorite: State.favorites.includes(username)
});
found++;
}
}
}
});
logCallback(`🔴 Page ${page}: Found ${found} performers (Total: ${performers.size})`);
if (found === 0 && page > 1) break;
await wait(300);
} catch (e) { break; }
}
Backup.saveFavorites();
return Array.from(performers.values());
}
// Find the maximum page number that exists for a tag using binary search
async function findMaxPage(tag, baseUrl, logCallback) {
logCallback(`🔍 Detecting max pages for <b>#${tag}</b>...`);
// Helper to check if a page exists
const checkPage = async (page) => {
const url = `${baseUrl}/page/${page}`;
try {
const res = await fetch(url, { method: 'GET', redirect: 'manual' });
// 200 = page exists, 302/404 = page doesn't exist
if (res.status === 200) {
// Verify it actually has content by checking for video thumbs
const html = await res.text();
// Empty-state marker: site explicitly says no results match
if (isEmptyResultsPage(html) || isChallengePage(html)) return false;
const doc = new DOMParser().parseFromString(html, 'text/html');
// Exclude trending-performers-videos widget (appears on every page)
const realThumbs = Array.from(doc.querySelectorAll('.video-thumb[data-performer]'))
.filter(t => !t.closest('.trending-performers-videos'));
return realThumbs.length > 0;
}
// 302 redirect means page doesn't exist
return false;
} catch (e) {
return false;
}
};
// Start at page 500
let testPage = 500;
let exists = await checkPage(testPage);
await wait(100); // Small delay between checks
if (!exists) {
// Page 500 doesn't exist, search down
logCallback(`📉 Page 500 not found, searching down...`);
const firstPageExists = await checkPage(1);
await wait(100);
if (!firstPageExists) {
logCallback(`⚠️ No matching pages detected for <b>#${tag}</b>`);
return 0;
}
let low = 1;
let high = 500;
let maxFound = 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const midExists = await checkPage(mid);
await wait(100);
if (midExists) {
maxFound = mid;
low = mid + 1; // Search higher
} else {
high = mid - 1; // Search lower
}
}
logCallback(`✅ Found max page: ${maxFound}`);
return maxFound;
} else {
// Page 500 exists, search up to find the maximum
logCallback(`📈 Page 500 exists, searching up for maximum...`);
let low = 500;
let high = 5000; // Start with reasonable upper bound
let maxFound = 500;
// First, find an upper bound by doubling
while (high < 10000) {
const testHigh = high;
const highExists = await checkPage(testHigh);
await wait(100);
if (highExists) {
maxFound = testHigh;
high = testHigh * 2; // Double and try again
} else {
break; // Found upper bound
}
}
// Now binary search between maxFound and high
low = maxFound;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const midExists = await checkPage(mid);
await wait(100);
if (midExists) {
maxFound = mid;
low = mid + 1; // Search higher
} else {
high = mid - 1; // Search lower
}
}
logCallback(`✅ Found max page: ${maxFound}`);
return maxFound;
}
}
// Analyze max pages for all tags and display recommendations
async function analyzeTagPages(tagGroups, filters, updateCallback) {
const results = [];
const allTags = normalizeList(tagGroups.flatMap(g => g.tags.map(tag => tag.replace('#', '').trim()).filter(Boolean)));
const sectionPrefix = filters.section === 'clips' ? '/clips' : '';
let baseUrl = `${sectionPrefix}/type:${filters.type}`;
baseUrl += `/period:${filters.period}`;
baseUrl += `/gender:${filters.gender}/ethnicity:${filters.ethnicity}/age:${filters.age}/body:${filters.body}`;
for (let i = 0; i < allTags.length; i++) {
const tag = allTags[i];
const tagBaseUrl = `${baseUrl}/tag:${encodeURIComponent(tag)}`;
updateCallback(`Analyzing #${tag} (${i + 1}/${allTags.length})...`);
try {
const maxPage = await findMaxPage(tag, tagBaseUrl, (msg) => {
// Silent during analysis
});
results.push({ tag, maxPage: maxPage || 0 });
} catch (e) {
console.error(`[Titan] Error analyzing #${tag}:`, e);
results.push({ tag, maxPage: 0, error: true });
}
// Small delay between tags
await wait(200);
}
return results;
}
async function scanVideos(tag, depth, logCallback, resumeFromPage = null, tagMeta = null) {
const filters = getCurrentFilters();
tag = tag.replace('#', '').trim();
const cacheKey = `${tag}_${filters.section}_${filters.type}_${filters.period}_${filters.gender}_${filters.ethnicity}_${filters.age}_${filters.body}`;
const pageProgressKey = `page_${cacheKey}`;
const maxPageKey = `maxpage_${cacheKey}`;
const progressMeta = tagMeta && Number.isInteger(tagMeta.groupIndex) && Number.isInteger(tagMeta.tagIndex) ? tagMeta : null;
// Check for cached results
const cached = Memory.load(cacheKey);
const lastPage = Memory.load(pageProgressKey);
const lastPageNum = lastPage ? parseInt(lastPage, 10) : 0;
// Check for cached max page
let cachedMaxPage = Memory.load(maxPageKey);
let actualMaxPage = cachedMaxPage ? parseInt(cachedMaxPage, 10) : null;
// Determine start page:
// 1. If resumeFromPage is explicitly provided (from SearchIndexer), use it
// 2. If depth increased and we have a lastPage, continue from lastPage + 1
// 3. Otherwise start from page 1
let startPage = 1;
if (resumeFromPage !== null && resumeFromPage !== undefined) {
startPage = parseInt(resumeFromPage, 10);
} else if (depth > lastPageNum && lastPageNum > 0) {
startPage = lastPageNum + 1;
} else {
startPage = 1;
}
// Ensure startPage is at least 1
startPage = Math.max(1, startPage);
// If we have cached results and depth hasn't increased, return cached
if (cached && depth <= lastPageNum) {
logCallback(`<span style="color:#4ade80">⚡ Loaded #${tag} from memory (${cached.length} performers)</span>`);
return cached.map(v => ({
...v,
foundTags: v.foundTags ? [...new Set([...v.foundTags, tag])] : [tag]
}));
}
// Load existing performers from cache if resuming
const performers = new Map(); // Key by performer username
if (cached) {
cached.forEach(v => {
performers.set(v.user, { ...v });
});
}
const sectionPrefix = filters.section === 'clips' ? '/clips' : '';
let baseUrl = `${sectionPrefix}/type:${filters.type}`;
baseUrl += `/period:${filters.period}`;
baseUrl += `/gender:${filters.gender}/ethnicity:${filters.ethnicity}/age:${filters.age}/body:${filters.body}`;
baseUrl += `/tag:${encodeURIComponent(tag)}`;
// Detect actual max page if not cached or if depth is high
if (!actualMaxPage && depth >= 100) {
actualMaxPage = await findMaxPage(tag, baseUrl, logCallback);
if (actualMaxPage) {
Memory.save(maxPageKey, actualMaxPage);
// Adjust depth to not exceed actual max
depth = Math.min(depth, actualMaxPage);
logCallback(`📊 Using detected max page: ${actualMaxPage} (adjusted depth: ${depth})`);
}
} else if (actualMaxPage) {
// Use cached max page to limit depth
depth = Math.min(depth, actualMaxPage);
}
let lastSuccessfulPage = startPage - 1;
let emptyPageStreak = 0;
const concurrentPages = CONFIG.concurrentPages || 1;
let shouldStop = false;
let interruptedReason = null;
const pauseSearch = (reason) => {
interruptedReason = reason;
State.searchAbortReason = reason;
if (progressMeta) {
SearchIndexer.updateProgress(progressMeta.groupIndex, progressMeta.tagIndex, lastSuccessfulPage, depth);
}
if (State.activeSearch) {
State.activeSearch.pauseReason = reason;
SearchIndexer.save(State.activeSearch);
}
shouldStop = true;
};
// Helper function to fetch and process a single page
const fetchPage = async (page) => {
if (shouldStop) return null;
logCallback(`📡 Scanning <b>#${tag}</b> (Page ${page}/${depth})...`);
const url = `${baseUrl}/page/${page}`;
try {
const res = await fetch(url);
if (!res.ok) {
// Check for 403 Forbidden - refresh and resume
if (res.status === 403) {
logCallback(`🔄 403 Forbidden detected - search paused for resume.`);
pauseSearch('HTTP 403 / Cloudflare block');
return null; // Return null to stop current execution
}
logCallback(`⚠️ Page ${page} returned ${res.status} - continuing to next tag`);
shouldStop = true;
return null;
}
const html = await res.text();
if (isChallengePage(html)) {
logCallback(`🔄 Cloudflare challenge detected - search paused for resume.`);
pauseSearch('Cloudflare challenge page');
return null;
}
// Empty-state marker: stop scanning this tag immediately
if (isEmptyResultsPage(html)) {
if (page === startPage) {
logCallback(`<span style="color:#fbbf24">⚠️ <b>#${tag}</b> has no results matching current filters</span>`);
}
shouldStop = true;
return { page, results: [], found: 0, emptyState: true };
}
const doc = new DOMParser().parseFromString(html, 'text/html');
// Exclude trending-performers-videos widget (appears on every page even with no results)
const videoThumbs = Array.from(doc.querySelectorAll('.video-thumb[data-performer]'))
.filter(t => !t.closest('.trending-performers-videos'));
const pageResults = [];
videoThumbs.forEach(thumbEl => {
const performer = thumbEl.getAttribute('data-performer');
if (!performer) return;
// Only keep first video per performer
if (!performers.has(performer)) {
const link = thumbEl.querySelector('a[href*="/video/"]');
if (!link) return;
const href = link.getAttribute('href');
const match = href.match(/(?:\/([^\/]+))?\/video\/(\d+)/);
if (!match) return;
const id = match[2];
const user = match[1] || performer;
const splashEl = link.querySelector('.video-splash');
let thumb = splashEl ? (splashEl.dataset.src || NO_THUMB) : NO_THUMB;
if (thumb.includes('url(')) thumb = thumb.slice(5, -2);
// Try to get title from link title attribute, or use performer name as fallback
let title = link.getAttribute('title') || link.getAttribute('data-title') || `${performer} video`;
if (!title || title.trim() === '') {
title = `${performer} video`;
}
const timeEl = thumbEl.querySelector('.video-time');
const durationStr = timeEl ? timeEl.innerText.replace('HD', '').replace(/<[^>]*>/g, '').trim() : '00:00';
const videoObj = {
id,
url: href,
title,
thumb,
user: performer,
duration: durationStr,
durationSec: parseDuration(durationStr),
foundTags: [tag], // Track which tag found this performer
foundPage: page, // Track which page this was found on
foundTimestamp: Date.now() // Track when it was found
};
pageResults.push({ performer, videoObj });
} else {
// Performer already exists, add this tag to its foundTags
const existing = performers.get(performer);
if (!existing.foundTags.includes(tag)) {
existing.foundTags.push(tag);
}
// Update page and timestamp if found on an earlier page (newer content)
if (page < (existing.foundPage || Infinity)) {
existing.foundPage = page;
existing.foundTimestamp = Date.now();
} else if (page === existing.foundPage) {
// Same page, update timestamp to most recent
existing.foundTimestamp = Date.now();
}
}
});
return { page, results: pageResults, found: pageResults.length };
} catch (e) {
// Log error but continue to next tag instead of breaking entire search
console.error(`[Titan] Error scanning #${tag} page ${page}:`, e);
logCallback(`⚠️ Error on page ${page} for <b>#${tag}</b> - continuing to next tag`);
shouldStop = true;
return null;
}
};
// Process pages with concurrency
if (concurrentPages > 1) {
// Concurrent page fetching
for (let page = startPage; page <= depth && !shouldStop; page += concurrentPages) {
const pageBatch = [];
for (let i = 0; i < concurrentPages && (page + i) <= depth; i++) {
pageBatch.push(fetchPage(page + i));
}
const results = await Promise.all(pageBatch);
// Process results in order
for (const result of results) {
if (!result || shouldStop) {
shouldStop = true;
break;
}
// Add new performers to the map
result.results.forEach(({ performer, videoObj }) => {
if (!performers.has(performer)) {
performers.set(performer, videoObj);
}
});
// If no results found and we're past page 1, check if we've reached the max
// Only stop if we're at or past the detected max page, or if we're significantly past depth
if (result.found === 0 && result.page > 1) {
emptyPageStreak++;
const shouldStopEarly = actualMaxPage
? (result.page >= actualMaxPage)
: (emptyPageStreak >= 2);
if (shouldStopEarly) {
logCallback(`ℹ️ <b>#${tag}</b> has no more pages (stopped at page ${result.page - 1}${actualMaxPage ? `, max detected: ${actualMaxPage}` : ''}) - continuing with other tags...`);
shouldStop = true;
break;
} else {
// Empty page but not at max yet - might be a gap, continue
logCallback(`⚠️ <b>#${tag}</b> page ${result.page} is empty, but continuing (max: ${actualMaxPage || 'unknown'})...`);
}
} else {
emptyPageStreak = 0;
}
lastSuccessfulPage = Math.max(lastSuccessfulPage, result.page);
if (progressMeta) {
SearchIndexer.updateProgress(progressMeta.groupIndex, progressMeta.tagIndex, lastSuccessfulPage, depth);
}
}
// Small delay between batches to avoid overwhelming the server
if (!shouldStop && (page + concurrentPages) <= depth) {
await wait(50);
}
}
} else {
// Sequential page fetching (original behavior)
for (let page = startPage; page <= depth && !shouldStop; page++) {
const result = await fetchPage(page);
if (!result || shouldStop) {
break;
}
// Add new performers to the map
result.results.forEach(({ performer, videoObj }) => {
if (!performers.has(performer)) {
performers.set(performer, videoObj);
}
});
// If no results found and we're past page 1, check if we've reached the max
// Only stop if we're at or past the detected max page, or if we're significantly past depth
if (result.found === 0 && result.page > 1) {
emptyPageStreak++;
const shouldStopEarly = actualMaxPage
? (result.page >= actualMaxPage)
: (emptyPageStreak >= 2);
if (shouldStopEarly) {
logCallback(`ℹ️ <b>#${tag}</b> has no more pages (stopped at page ${result.page - 1}${actualMaxPage ? `, max detected: ${actualMaxPage}` : ''}) - continuing with other tags...`);
break;
} else {
// Empty page but not at max yet - might be a gap, continue
logCallback(`⚠️ <b>#${tag}</b> page ${result.page} is empty, but continuing (max: ${actualMaxPage || 'unknown'})...`);
}
} else {
emptyPageStreak = 0;
}
lastSuccessfulPage = result.page;
if (progressMeta) {
SearchIndexer.updateProgress(progressMeta.groupIndex, progressMeta.tagIndex, lastSuccessfulPage, depth);
}
// Reduced delay for faster sequential fetching
await wait(50);
}
}
// Save results and page progress
const result = Array.from(performers.values());
Memory.save(cacheKey, result);
Memory.save(pageProgressKey, lastSuccessfulPage);
if (interruptedReason) {
throw new Error(interruptedReason);
}
return result;
}
// ============================================================
// 9. UI & RENDERERS
// ============================================================
function createUI() {
const old = document.getElementById('v16-titan');
if (old) old.remove();
// Initialize managers
ThumbnailManager.init();
SearchHistory.init();
VideoTagManager.init();
const ui = document.createElement('div');
ui.id = 'v16-titan';
ui.style.cssText = `
background: #0f172a; border: 2px solid #a855f7; border-radius: 12px;
padding: 25px; margin: 20px 0; color: white;
font-family: 'Segoe UI', sans-serif; box-shadow: 0 10px 50px rgba(0,0,0,0.8);
width: 95vw; position: relative; left: 50%; transform: translateX(-50%); z-index: 1000;
`;
let filterHTML = '';
Object.keys(FILTER_OPTIONS).forEach(key => {
const defaultValue = getDefaultFilterValue(key);
const opts = FILTER_OPTIONS[key].map(o => `<option value="${o.v}"${o.v === defaultValue ? ' selected' : ''}>${o.l}</option>`).join('');
filterHTML += `
<div style="flex:1; min-width:120px;">
<label style="font-size:11px; color:#94a3b8; display:block; margin-bottom:4px; text-transform:uppercase;">${key}</label>
<select id="v16-opt-${key}" style="width:100%; background:#1e293b; border:1px solid #334155; color:white; padding:6px; border-radius:4px;">
${opts}
</select>
</div>
`;
});
ui.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h3 style="margin:0; color:#a855f7; font-size:24px; font-weight:800;">🔱 TITAN V19.0</h3>
<div style="display:flex; gap:10px; align-items:center;">
<label style="font-size:13px; color:#94a3b8;">Depth: <input id="v16-depth" type="number" value="${CONFIG.defaultDepth}" style="width:40px; background:#1e293b; border:1px solid #334155; color:white; text-align:center;"></label>
<label style="font-size:12px; color:#94a3b8; display:flex; align-items:center; gap:5px;">
<input type="checkbox" id="v16-auto-save" ${CONFIG.autoSaveThumbnails ? 'checked' : ''}>
Auto-Save
</label>
<button id="v16-toggle-settings" style="background:none; border:1px solid #334155; color:#cbd5e1; cursor:pointer; padding:2px 8px; border-radius:4px;">⚙️</button>
<button id="v16-download" style="background:#0ea5e9; border:none; color:white; cursor:pointer; padding:2px 10px; border-radius:4px; font-weight:bold;">📥 Save All</button>
</div>
</div>
<div id="v16-settings-panel" style="display:none; background:#020617; padding:15px; border-radius:8px; border:1px solid #334155; margin-bottom:20px;">
<div style="display:flex; flex-wrap:wrap; gap:10px; margin-bottom:15px;">
${filterHTML}
</div>
<div style="display:flex; justify-content:space-between; border-top:1px solid #1e293b; padding-top:10px;">
<div style="display:flex; gap:10px;">
<button id="v16-clear-cache" style="color:#f87171; background:none; border:none; cursor:pointer;">⚠️ Clear Search Cache</button>
<button id="v16-clear-thumb-cache" style="color:#f97316; background:none; border:none; cursor:pointer;">🗑️ Clear Thumb Cache</button>
<button id="v16-clear-tag-cache" style="color:#8b5cf6; background:none; border:none; cursor:pointer;">🏷️ Clear Tag Cache</button>
<button id="v16-clear-history" style="color:#ef4444; background:none; border:none; cursor:pointer;">🧹 Clear History</button>
<button id="v16-clear-all-cache" style="color:#dc2626; background:none; border:none; cursor:pointer; font-weight:bold;">🔥 Clear ALL Cache</button>
</div>
<div style="display:flex; gap:10px;">
<button id="v16-export" style="background:#334155; border:none; color:white; padding:5px 10px; cursor:pointer; border-radius:4px;">Export</button>
<input type="file" id="v16-import" style="display:none;">
<button onclick="document.getElementById('v16-import').click()" style="background:#334155; border:none; color:white; padding:5px 10px; cursor:pointer; border-radius:4px;">Import</button>
</div>
</div>
</div>
<div id="v16-tabs" style="display:flex; gap:5px; margin-bottom:20px; border-bottom:1px solid #334155; padding-bottom:10px;">
<button class="v16-tab active" data-tab="search" style="padding:10px 20px; background:#a855f7; border:none; color:white; border-radius:6px 6px 0 0; cursor:pointer; font-weight:bold;">🔍 Tag Search</button>
<button class="v16-tab" data-tab="history" style="padding:10px 20px; background:#1e293b; border:1px solid #334155; border-bottom:none; color:#94a3b8; border-radius:6px 6px 0 0; cursor:pointer;">📜 History (<span id="v16-history-count">0</span>)</button>
<button class="v16-tab" data-tab="live" style="padding:10px 20px; background:#1e293b; border:1px solid #334155; border-bottom:none; color:#94a3b8; border-radius:6px 6px 0 0; cursor:pointer;">🔴 Live</button>
</div>
<div id="v16-tab-search" class="v16-tab-content">
<div style="display:flex; gap:15px; margin-bottom:20px; position:relative;">
<div style="flex:1; position:relative;">
<input id="v16-input" type="text" placeholder="Type tags (space separated)... Enter=OR, Shift+Enter=AND" autocomplete="off"
style="width:100%; padding:15px; background:#020617; border:1px solid #334155; border-radius:8px; color:white; outline:none; font-size:16px;">
<div id="v16-suggest" style="position:absolute; top:100%; left:0; right:0; background:#1e293b; border:1px solid #a855f7; z-index:100; max-height:250px; overflow-y:auto; display:none;"></div>
</div>
<button id="v16-smart" style="padding:0 30px; background:linear-gradient(45deg, #a855f7, #d946ef); border:none; color:white; border-radius:8px; font-weight:bold; cursor:pointer;">✨ Smart</button>
<button id="v16-go" style="padding:0 35px; background:#334155; border:1px solid #a855f7; color:white; border-radius:8px; font-weight:bold; cursor:pointer;">Search</button>
</div>
<div id="v16-chips" style="display:flex; flex-wrap:wrap; gap:10px; margin-bottom:20px; min-height:40px;"></div>
<div id="v16-page-analysis" style="display:none; padding:15px; background:#1e293b; border:1px solid #475569; border-radius:8px; margin-bottom:15px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<span style="color:#94a3b8; font-size:14px; font-weight:bold;">📊 Page Analysis & Recommendations</span>
<button id="v16-analyze-pages" style="background:#0ea5e9; border:none; color:white; padding:6px 15px; border-radius:6px; font-size:12px; cursor:pointer; font-weight:bold;">🔍 Analyze Pages</button>
</div>
<div id="v16-page-recommendations" style="font-size:13px; color:#cbd5e1;"></div>
</div>
</div>
<div id="v16-tab-history" class="v16-tab-content" style="display:none;">
<div style="margin-bottom:15px; display:flex; gap:10px; align-items:center;">
<span style="color:#94a3b8; font-size:14px;">📜 Search History - Click to reload results, or merge multiple searches</span>
<button id="v16-merge-selected" style="background:#6366f1; border:none; color:white; padding:5px 15px; border-radius:4px; cursor:pointer; display:none;">🔗 Merge Selected</button>
</div>
<div id="v16-history-list" style="display:flex; flex-direction:column; gap:10px; max-height:400px; overflow-y:auto;"></div>
</div>
<div id="v16-tab-live" class="v16-tab-content" style="display:none;">
<div style="display:flex; gap:15px; margin-bottom:20px; align-items:center;">
<input id="v16-live-search" type="text" placeholder="Filter performers by name..."
style="flex:1; padding:15px; background:#020617; border:1px solid #334155; border-radius:8px; color:white; outline:none; font-size:16px;">
<button id="v16-scan-live" style="padding:15px 30px; background:linear-gradient(45deg, #ef4444, #f97316); border:none; color:white; border-radius:8px; font-weight:bold; cursor:pointer;">🔴 Scan Live</button>
<button id="v16-show-favorites" style="padding:15px 20px; background:#1e293b; border:2px solid #eab308; color:#eab308; border-radius:8px; font-weight:bold; cursor:pointer;">⭐ (<span id="v16-fav-count">0</span>)</button>
</div>
<div id="v16-live-status" style="font-size:14px; color:#cbd5e1; margin-bottom:15px;"></div>
<div id="v16-live-grid" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:15px;"></div>
</div>
<div id="v16-status" style="font-size:14px; color:#cbd5e1; margin-bottom:20px; display:none; padding:15px; background:#1e293b; border-radius:8px;"></div>
<div id="v16-resume-notification" style="display:none; padding:15px; background:linear-gradient(135deg, #f59e0b, #d97706); border:2px solid #fbbf24; border-radius:8px; margin-bottom:20px; color:#fff;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div>
<div style="font-weight:bold; font-size:16px; margin-bottom:5px;">⏸️ Interrupted Search Detected</div>
<div style="font-size:13px; opacity:0.9;" id="v16-resume-info">A previous search was interrupted. Click Resume to continue from where it left off.</div>
</div>
<div style="display:flex; gap:10px;">
<button id="v16-resume-btn" style="background:#fff; color:#d97706; border:none; padding:8px 20px; border-radius:6px; font-weight:bold; cursor:pointer;">▶️ Resume</button>
<button id="v16-dismiss-resume" style="background:rgba(255,255,255,0.2); color:#fff; border:1px solid rgba(255,255,255,0.3); padding:8px 15px; border-radius:6px; cursor:pointer;">✕ Dismiss</button>
</div>
</div>
</div>
<div id="v16-tag-fetch-progress" style="display:none; padding:10px 15px; background:#1e1b4b; border:1px solid #6366f1; border-radius:8px; margin-bottom:15px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<span style="color:#a5b4fc; font-size:13px;">🏷️ <span id="v16-tag-fetch-text">Fetching video tags...</span></span>
<span id="v16-tag-fetch-stats" style="color:#6366f1; font-size:12px;">0/0</span>
</div>
<div style="background:#312e81; border-radius:4px; height:6px; overflow:hidden;">
<div id="v16-tag-fetch-bar" style="background:linear-gradient(90deg, #6366f1, #8b5cf6); height:100%; width:0%; transition:width 0.3s;"></div>
</div>
</div>
<div id="v16-download-progress" style="display:none; padding:15px; background:#1e293b; border-radius:8px; margin-bottom:20px;">
<div style="display:flex; justify-content:space-between; margin-bottom:10px;">
<span id="v16-dl-text">Downloading...</span>
<span id="v16-dl-stats"></span>
</div>
<div style="background:#334155; border-radius:4px; height:8px; overflow:hidden;">
<div id="v16-dl-bar" style="background:linear-gradient(90deg, #a855f7, #d946ef); height:100%; width:0%; transition:width 0.3s;"></div>
</div>
</div>
<div id="v16-grid" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(${CONFIG.cardMinWidth}, 1fr)); gap:20px;"></div>
`;
const target = document.querySelector('.container .row .col-12');
if (target) target.insertBefore(ui, target.firstChild);
const els = {
input: ui.querySelector('#v16-input'),
suggest: ui.querySelector('#v16-suggest'),
chips: ui.querySelector('#v16-chips'),
grid: ui.querySelector('#v16-grid'),
settingsBtn: ui.querySelector('#v16-toggle-settings'),
settingsPanel: ui.querySelector('#v16-settings-panel'),
importInput: ui.querySelector('#v16-import'),
exportBtn: ui.querySelector('#v16-export'),
clearBtn: ui.querySelector('#v16-clear-cache'),
clearThumbCacheBtn: ui.querySelector('#v16-clear-thumb-cache'),
clearTagCacheBtn: ui.querySelector('#v16-clear-tag-cache'),
clearHistoryBtn: ui.querySelector('#v16-clear-history'),
clearAllCacheBtn: ui.querySelector('#v16-clear-all-cache'),
goBtn: ui.querySelector('#v16-go'),
smartBtn: ui.querySelector('#v16-smart'),
downloadBtn: ui.querySelector('#v16-download'),
status: ui.querySelector('#v16-status'),
resumeNotification: ui.querySelector('#v16-resume-notification'),
resumeBtn: ui.querySelector('#v16-resume-btn'),
dismissResumeBtn: ui.querySelector('#v16-dismiss-resume'),
resumeInfo: ui.querySelector('#v16-resume-info'),
depth: ui.querySelector('#v16-depth'),
autoSaveCheckbox: ui.querySelector('#v16-auto-save'),
liveSearch: ui.querySelector('#v16-live-search'),
scanLiveBtn: ui.querySelector('#v16-scan-live'),
liveGrid: ui.querySelector('#v16-live-grid'),
liveStatus: ui.querySelector('#v16-live-status'),
showFavoritesBtn: ui.querySelector('#v16-show-favorites'),
favCount: ui.querySelector('#v16-fav-count'),
tabs: ui.querySelectorAll('.v16-tab'),
historyList: ui.querySelector('#v16-history-list'),
historyCount: ui.querySelector('#v16-history-count'),
pageAnalysis: ui.querySelector('#v16-page-analysis'),
analyzePagesBtn: ui.querySelector('#v16-analyze-pages'),
pageRecommendations: ui.querySelector('#v16-page-recommendations'),
mergeBtn: ui.querySelector('#v16-merge-selected'),
dlProgress: ui.querySelector('#v16-download-progress'),
dlText: ui.querySelector('#v16-dl-text'),
dlStats: ui.querySelector('#v16-dl-stats'),
dlBar: ui.querySelector('#v16-dl-bar'),
tagFetchProgress: ui.querySelector('#v16-tag-fetch-progress'),
tagFetchText: ui.querySelector('#v16-tag-fetch-text'),
tagFetchStats: ui.querySelector('#v16-tag-fetch-stats'),
tagFetchBar: ui.querySelector('#v16-tag-fetch-bar')
};
const liveAnimationTimers = new Map();
const videoAnimationTimers = new Map();
let selectedHistoryIndices = new Set();
// Auto-save toggle
els.autoSaveCheckbox.onchange = () => {
CONFIG.autoSaveThumbnails = els.autoSaveCheckbox.checked;
};
function updateFavCount() {
els.favCount.textContent = State.favorites.length;
}
function updateHistoryCount() {
els.historyCount.textContent = State.searchHistory.length;
}
function toggleFavorite(username, currentThumb, thumbType, btnElement) {
const idx = State.favorites.indexOf(username);
if (idx === -1) {
State.favorites.push(username);
if (currentThumb) {
State.thumbCache[username] = {
url: currentThumb,
timestamp: Date.now(),
type: thumbType || (isLiveStreamUrl(currentThumb) ? 'live' : 'sprite')
};
}
} else {
State.favorites.splice(idx, 1);
}
Backup.saveFavorites();
updateFavCount();
if (els.liveGrid.offsetParent) {
if (State.showFavoritesOnly) renderLiveGrid(buildFavoritePerformers());
else {
const query = els.liveSearch.value.toLowerCase();
renderLiveGrid(State.livePerformers.filter(p => p.username.toLowerCase().includes(query)));
}
}
if (btnElement) {
const isFav = State.favorites.includes(username);
btnElement.innerHTML = isFav ? '★' : '☆';
btnElement.style.color = isFav ? '#eab308' : 'white';
}
}
function buildFavoritePerformers() {
return State.favorites.map(username => {
const existing = State.livePerformers.find(p => p.username === username);
if (existing) return existing;
const cached = State.thumbCache[username];
return {
username,
url: `/performer/${username}`,
thumb: cached ? cached.url : NO_THUMB,
thumbType: cached ? cached.type : 'sprite',
isLive: false,
isFavorite: true
};
});
}
// ============================================================
// HISTORY PANEL RENDERER
// ============================================================
function renderHistoryPanel() {
updateHistoryCount();
selectedHistoryIndices.clear();
els.mergeBtn.style.display = 'none';
if (State.searchHistory.length === 0) {
els.historyList.innerHTML = `<div style="color:#64748b; text-align:center; padding:40px;">No search history yet. Run some searches!</div>`;
return;
}
els.historyList.innerHTML = State.searchHistory.map((entry, idx) => {
const date = new Date(entry.timestamp);
const timeStr = date.toLocaleString();
const tagsPreview = escapeHtml(entry.label || 'Unknown tags');
return `
<div class="v16-history-item" data-idx="${idx}" style="background:#1e293b; border:1px solid #334155; border-radius:8px; padding:15px; display:flex; justify-content:space-between; align-items:center; cursor:pointer; transition:0.2s;">
<div style="display:flex; align-items:center; gap:15px;">
<input type="checkbox" class="v16-history-check" data-idx="${idx}" style="width:18px; height:18px; cursor:pointer;">
<div>
<div style="font-weight:bold; color:#fff; margin-bottom:5px;">${tagsPreview}</div>
<div style="font-size:12px; color:#64748b;">${escapeHtml(timeStr)} • Depth: ${entry.depth} • <span style="color:#4ade80;">${entry.resultsCount} results</span></div>
</div>
</div>
<div style="display:flex; gap:10px;">
<button class="v16-history-load" data-idx="${idx}" style="background:#a855f7; border:none; color:white; padding:8px 15px; border-radius:4px; cursor:pointer; font-size:12px;">📂 Load</button>
<button class="v16-history-rerun" data-idx="${idx}" style="background:#0ea5e9; border:none; color:white; padding:8px 15px; border-radius:4px; cursor:pointer; font-size:12px;">🔄 Re-run</button>
<button class="v16-history-delete" data-idx="${idx}" style="background:#7f1d1d; border:none; color:#fca5a5; padding:8px 15px; border-radius:4px; cursor:pointer; font-size:12px;">🗑️</button>
</div>
</div>`;
}).join('');
// Attach listeners
els.historyList.querySelectorAll('.v16-history-check').forEach(cb => {
cb.onclick = (e) => {
e.stopPropagation();
const idx = parseInt(cb.dataset.idx);
if (cb.checked) {
selectedHistoryIndices.add(idx);
} else {
selectedHistoryIndices.delete(idx);
}
els.mergeBtn.style.display = selectedHistoryIndices.size >= 2 ? 'block' : 'none';
};
});
els.historyList.querySelectorAll('.v16-history-load').forEach(btn => {
btn.onclick = async (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.idx);
const entry = SearchHistory.get(idx);
if (entry && entry.results) {
State.allMatches = entry.results;
State.currentHistoryIndex = idx;
State.expandedCards.clear();
els.status.style.display = 'block';
els.status.innerHTML = `📜 <b>Loaded from history:</b> ${entry.label}<br>👉 <b>${entry.resultsCount} results</b>`;
renderGrid(entry.results);
// Switch to search tab to show results
switchToTab('search');
// Check if we need to fetch tags for any videos
const needsFetch = entry.results.some(v => !VideoTagManager.getCachedTags(v));
if (needsFetch) {
els.tagFetchProgress.style.display = 'block';
await VideoTagManager.fetchAllInBackground(
entry.results,
(current, total) => {
const percent = Math.round((current / total) * 100);
els.tagFetchBar.style.width = `${percent}%`;
els.tagFetchStats.textContent = `${current}/${total}`;
els.tagFetchText.textContent = current === total
? 'Tag fetch complete!'
: 'Fetching video tags...';
},
(video) => {
updateVideoCard(video);
}
);
// Re-render with popularity-sorted tags
renderGrid(State.allMatches);
setTimeout(() => {
els.tagFetchProgress.style.display = 'none';
}, 2000);
}
}
};
});
els.historyList.querySelectorAll('.v16-history-rerun').forEach(btn => {
btn.onclick = async (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.idx);
const entry = SearchHistory.get(idx);
if (entry && entry.results && entry.results.length > 0) {
// Gather videos from history entry
const videos = entry.results;
// Show progress indicator
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = '⏳ Fetching...';
try {
// Calculate concurrency based on number of tags
// Count all tags across all tag groups
const totalTags = entry.tagGroups ?
entry.tagGroups.reduce((sum, group) => sum + (group.tags ? group.tags.length : 0), 0) : 0;
// Concurrency logic:
// - If tags <= 10: use number of tags (match the number of tags)
// - If tags > 10: split by half (tags / 2, rounded up)
// - Maximum is 10
let CONCURRENCY;
if (totalTags === 0) {
CONCURRENCY = 1; // Default to 1 if no tags
} else if (totalTags <= 10) {
CONCURRENCY = totalTags; // Match number of tags
} else {
CONCURRENCY = Math.min(Math.ceil(totalTags / 2), 10); // Split by half, max 10
}
const results = { success: 0, failed: 0 };
let processed = 0;
const THUMBNAIL_CONCURRENCY = 10; // Process 10 thumbnails concurrently for speed
for (let i = 0; i < videos.length; i += THUMBNAIL_CONCURRENCY) {
const batch = videos.slice(i, i + THUMBNAIL_CONCURRENCY);
const promises = batch.map(async (video) => {
const thumbUrl = await fetchStaticThumb(video.user);
if (thumbUrl) {
video.thumb = thumbUrl;
results.success++;
} else {
results.failed++;
}
processed++;
const percent = Math.round((processed / videos.length) * 100);
btn.textContent = `⏳ ${percent}% (${results.success}✓ ${results.failed}✗)`;
});
await Promise.all(promises);
// Small delay between batches only
if (i + THUMBNAIL_CONCURRENCY < videos.length) {
await wait(50);
}
}
// Re-render grid with updated thumbnails
renderGrid(videos);
btn.textContent = '✅ Done';
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
}, 2000);
} catch (error) {
console.error('[Titan] Error fetching thumbnails:', error);
btn.textContent = '❌ Error';
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
}, 2000);
}
} else {
// If no results, restore tag groups and filters, then run search
State.tagGroups = JSON.parse(JSON.stringify(entry.tagGroups));
applyFiltersToUI(entry.filters);
els.depth.value = entry.depth;
renderChips();
switchToTab('search');
// Trigger search
els.goBtn.click();
}
};
});
els.historyList.querySelectorAll('.v16-history-delete').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.idx);
if (confirm('Delete this search from history?')) {
SearchHistory.remove(idx);
renderHistoryPanel();
}
};
});
els.historyList.querySelectorAll('.v16-history-item').forEach(item => {
item.onmouseenter = () => item.style.borderColor = '#a855f7';
item.onmouseleave = () => item.style.borderColor = '#334155';
});
}
window.renderHistoryPanel = renderHistoryPanel;
function switchToTab(tabName) {
els.tabs.forEach(t => {
t.classList.remove('active');
t.style.background = '#1e293b';
t.style.color = '#94a3b8';
});
const activeTab = ui.querySelector(`.v16-tab[data-tab="${tabName}"]`);
if (activeTab) {
activeTab.classList.add('active');
activeTab.style.background = '#a855f7';
activeTab.style.color = 'white';
}
ui.querySelectorAll('.v16-tab-content').forEach(c => c.style.display = 'none');
ui.querySelector(`#v16-tab-${tabName}`).style.display = 'block';
}
// Tab switching
els.tabs.forEach(tab => {
tab.onclick = () => {
switchToTab(tab.dataset.tab);
if (tab.dataset.tab === 'history') {
renderHistoryPanel();
}
};
});
els.showFavoritesBtn.onclick = () => {
State.showFavoritesOnly = !State.showFavoritesOnly;
els.showFavoritesBtn.style.background = State.showFavoritesOnly ? '#eab308' : '#1e293b';
els.showFavoritesBtn.style.color = State.showFavoritesOnly ? '#000' : '#eab308';
if (State.showFavoritesOnly) {
els.liveStatus.innerHTML = `⭐ Showing <b>${State.favorites.length}</b> favorites`;
renderLiveGrid(buildFavoritePerformers());
} else {
els.liveStatus.innerHTML = State.livePerformers.length ? `🔴 <b>${State.livePerformers.length}</b> live performers` : '';
renderLiveGrid(State.livePerformers);
}
};
els.scanLiveBtn.onclick = async () => {
els.scanLiveBtn.disabled = true;
els.scanLiveBtn.innerText = '⏳ Scanning...';
State.showFavoritesOnly = false;
State.brokenThumbs.clear();
els.showFavoritesBtn.style.background = '#1e293b';
els.showFavoritesBtn.style.color = '#eab308';
const filters = getCurrentFilters();
const depth = parseInt(els.depth.value) || CONFIG.defaultDepth;
State.livePerformers = await scanLivePerformers(filters, depth, msg => els.liveStatus.innerHTML = msg);
State.livePerformers.forEach(p => p.isFavorite = State.favorites.includes(p.username));
els.liveStatus.innerHTML = `🔴 <b>${State.livePerformers.length}</b> live performers found`;
renderLiveGrid(State.livePerformers);
els.scanLiveBtn.disabled = false;
els.scanLiveBtn.innerText = '🔴 Scan Live';
};
els.liveSearch.oninput = () => {
if (State.showFavoritesOnly) return;
const query = els.liveSearch.value.toLowerCase();
renderLiveGrid(State.livePerformers.filter(p => p.username.toLowerCase().includes(query)));
};
function renderLiveGrid(performers) {
liveAnimationTimers.forEach(timer => clearInterval(timer));
liveAnimationTimers.clear();
els.liveGrid.innerHTML = performers.map(p => {
const isFav = State.favorites.includes(p.username);
const isLive = isLiveStreamUrl(p.thumb) || p.thumbType === 'live';
const isHighMedia = p.thumb && p.thumb.includes('highmedia-stream');
const bgSize = isHighMedia ? 'cover' : `${CONFIG.spriteColumns * 100}% ${CONFIG.spriteRows * 100}%`;
const safeUser = escapeHtml(p.username);
const safeThumb = escapeHtml(p.thumb || '');
const safeUrl = escapeHtml(p.url || '#');
return `
<div class="v16-live-card-wrapper" data-username="${safeUser}" style="position:relative;">
<a href="${safeUrl}" target="_blank" style="text-decoration:none; display:block;">
<div class="v16-live-card" style="background:#1e293b; border-radius:8px; overflow:hidden; border:1px solid ${isFav ? '#eab308' : '#334155'}; transition:0.3s; position:relative; aspect-ratio:16/9;">
<div class="v16-thumb-container" style="width:100%; height:100%; background-image:url('${safeThumb}'); background-size:${bgSize}; background-position:center; transition: background-position 0s;"></div>
<div style="position:absolute; bottom:0; left:0; right:0; background:rgba(0,0,0,0.7); padding:8px; font-size:12px; color:white; display:flex; justify-content:space-between; align-items:center;">
<span style="font-weight:bold; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${safeUser}</span>
${p.isLive ? '<span style="background:#ef4444; padding:2px 5px; border-radius:3px; font-size:10px;">LIVE</span>' : ''}
</div>
</div>
</a>
<button class="v16-fav-btn" data-user="${safeUser}" style="position:absolute; top:5px; right:5px; background:rgba(0,0,0,0.5); border:none; border-radius:50%; width:28px; height:28px; cursor:pointer; color:${isFav ? '#eab308' : 'white'}; font-size:16px; display:flex; align-items:center; justify-content:center; z-index:10;">${isFav ? '★' : '☆'}</button>
</div>`;
}).join('');
els.liveGrid.querySelectorAll('.v16-live-card-wrapper').forEach(card => {
const username = card.dataset.username;
const performer = performers.find(p => p.username === username);
const thumbContainer = card.querySelector('.v16-thumb-container');
const currentThumbUrl = performer.thumb;
const tester = new Image();
tester.src = currentThumbUrl;
tester.onerror = async () => {
if (!State.brokenThumbs.has(currentThumbUrl)) {
State.brokenThumbs.add(currentThumbUrl);
ThumbnailManager.saveCache();
const newThumb = await fetchStaticThumb(username);
if (newThumb) {
State.thumbCache[username] = { url: newThumb, type: 'sprite', timestamp: Date.now() };
performer.thumb = newThumb;
performer.thumbType = 'sprite';
thumbContainer.style.backgroundImage = `url('${newThumb}')`;
thumbContainer.style.backgroundSize = `${CONFIG.spriteColumns * 100}% ${CONFIG.spriteRows * 100}%`;
} else {
thumbContainer.style.backgroundImage = 'none';
thumbContainer.innerText = 'NO PREVIEW';
thumbContainer.style.display = 'flex'; thumbContainer.style.alignItems = 'center'; thumbContainer.style.justifyContent = 'center';
}
}
};
card.onmouseenter = () => {
let frame = 0;
const initialType = performer.thumbType || (isLiveStreamUrl(performer.thumb) ? 'live' : 'sprite');
const intervalMs = initialType === 'live' ? CONFIG.liveAnimationSpeed : CONFIG.animationSpeed;
const timer = setInterval(() => {
const currentType = performer.thumbType || (isLiveStreamUrl(performer.thumb) ? 'live' : 'sprite');
if (currentType === 'sprite') {
frame = (frame + 1) % CONFIG.spriteFrames;
thumbContainer.style.backgroundPosition = getSpritePosition(frame);
} else if (currentType === 'live') {
frame = (frame + 1) % CONFIG.liveFrames.length;
thumbContainer.style.backgroundImage = `url('${buildLiveThumbUrl(performer.thumb, CONFIG.liveFrames[frame])}')`;
}
}, intervalMs);
liveAnimationTimers.set(username, timer);
};
card.onmouseleave = () => {
clearInterval(liveAnimationTimers.get(username));
liveAnimationTimers.delete(username);
if ((performer.thumbType || 'sprite') === 'sprite') thumbContainer.style.backgroundPosition = 'center';
};
});
els.liveGrid.querySelectorAll('.v16-fav-btn').forEach(btn => {
btn.onclick = (e) => {
e.preventDefault();
const user = btn.dataset.user;
const perf = performers.find(p => p.username === user);
toggleFavorite(user, perf?.thumb, perf?.thumbType, btn);
};
});
}
let timer;
let suggestionRequestId = 0;
els.settingsBtn.onclick = () => { els.settingsPanel.style.display = els.settingsPanel.style.display === 'none' ? 'block' : 'none'; };
els.exportBtn.onclick = Backup.exportToFile;
els.importInput.onchange = (e) => Backup.importFromFile(e.target.files[0]);
els.clearBtn.onclick = Memory.clear;
els.clearThumbCacheBtn.onclick = () => ThumbnailManager.clearCache();
els.clearTagCacheBtn.onclick = () => VideoTagManager.clearCache();
els.clearHistoryBtn.onclick = () => {
if (confirm('Clear all search history?')) {
SearchHistory.clear();
renderHistoryPanel();
}
};
els.clearAllCacheBtn.onclick = () => {
if (confirm('Clear ALL cache? This will remove:\n- Search cache\n- Thumbnail cache\n- Tag cache\n- Search history\n\nThis cannot be undone!')) {
Memory.clear();
ThumbnailManager.clearCache();
VideoTagManager.clearCache();
SearchHistory.clear();
renderHistoryPanel();
alert('All cache cleared!');
}
};
els.mergeBtn.onclick = async () => {
if (selectedHistoryIndices.size < 2) return;
const indices = Array.from(selectedHistoryIndices).sort((a, b) => a - b);
State.allMatches = SearchHistory.mergeResults(indices);
State.currentHistoryIndex = -1;
State.expandedCards.clear();
els.status.style.display = 'block';
els.status.innerHTML = `🔗 <b>Merged ${indices.length} history entries</b><br>👉 <b>${State.allMatches.length} merged results</b>`;
renderGrid(State.allMatches);
switchToTab('search');
const needsFetch = State.allMatches.some(v => !VideoTagManager.getCachedTags(v));
if (needsFetch) {
els.tagFetchProgress.style.display = 'block';
await VideoTagManager.fetchAllInBackground(
State.allMatches,
(current, total) => {
const percent = Math.round((current / total) * 100);
els.tagFetchBar.style.width = `${percent}%`;
els.tagFetchStats.textContent = `${current}/${total}`;
els.tagFetchText.textContent = current === total
? 'Tag fetch complete!'
: 'Fetching video tags...';
},
(video) => {
updateVideoCard(video);
}
);
renderGrid(State.allMatches);
setTimeout(() => {
els.tagFetchProgress.style.display = 'none';
}, 2000);
}
};
// Download all with progress
els.downloadBtn.onclick = async () => {
if (State.allMatches.length === 0) return alert("No videos found to download!");
if (!confirm(`Download ${State.allMatches.length} thumbnails?\n\nBlack/empty images will be automatically skipped.`)) return;
els.dlProgress.style.display = 'block';
els.downloadBtn.disabled = true;
els.downloadBtn.style.opacity = '0.5';
const result = await ThumbnailManager.batchSave(State.allMatches, (current, total, saved, skipped, invalid, reacquired) => {
const percent = Math.round((current / total) * 100);
els.dlBar.style.width = `${percent}%`;
els.dlText.textContent = `Processing ${current}/${total}...`;
els.dlStats.innerHTML = `<span style="color:#4ade80;">✓ ${saved}</span> | <span style="color:#94a3b8;">⏭ ${skipped}</span> | <span style="color:#f87171;">❌ ${invalid}</span>${reacquired > 0 ? ` | <span style="color:#fbbf24;">🔄 ${reacquired}</span>` : ''}`;
});
els.dlText.textContent = 'Complete!';
els.dlStats.innerHTML = `<span style="color:#4ade80;">Saved: ${result.saved}</span> | <span style="color:#94a3b8;">Cached: ${result.skipped}</span> | <span style="color:#f87171;">Invalid: ${result.invalid}</span>${result.reacquired > 0 ? ` | <span style="color:#fbbf24;">Re-acquired: ${result.reacquired}</span>` : ''}`;
setTimeout(() => {
els.dlProgress.style.display = 'none';
els.downloadBtn.disabled = false;
els.downloadBtn.style.opacity = '1';
}, 3000);
};
els.input.addEventListener('input', () => {
clearTimeout(timer);
const val = els.input.value.trim();
suggestionRequestId++;
const requestId = suggestionRequestId;
if (val.length < 2) { els.suggest.innerHTML = ''; els.suggest.style.display = 'none'; return; }
timer = setTimeout(async () => {
const items = await fetchSuggestions(val);
if (requestId !== suggestionRequestId || els.input.value.trim() !== val) return;
if (items.length) {
els.suggest.innerHTML = items.map(i => `<div class="v16-item" data-val="${escapeHtml(i.val)}" style="padding:10px; cursor:pointer; border-bottom:1px solid #333;">${escapeHtml(i.label)}</div>`).join('');
els.suggest.style.display = 'block';
els.suggest.querySelectorAll('.v16-item').forEach(div => {
div.onclick = () => {
const tag = div.dataset.val;
els.input.value = tag;
els.suggest.style.display = 'none';
// Auto-add as OR group when clicked
addGroup('OR', [tag]);
};
});
} else {
els.suggest.innerHTML = '';
els.suggest.style.display = 'none';
}
}, 300);
});
const addGroup = (type, tags) => {
if (!tags.length) return;
State.tagGroups.push({ type, tags });
els.input.value = '';
renderChips();
Backup.saveLocal();
};
els.smartBtn.onclick = async () => {
const val = els.input.value.trim();
if (!val) return;
els.smartBtn.innerText = "⏳ ...";
const suggestions = await fetchSuggestions(val);
const related = new Set([val.toLowerCase()]);
suggestions.forEach(s => { if (s.val.includes(val.toLowerCase())) related.add(s.val); });
addGroup('OR', Array.from(related));
els.smartBtn.innerText = "✨ Smart";
els.suggest.style.display = 'none';
};
els.input.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const tags = els.input.value.split(/[\s,]+/).map(t => t.replace('#', '').trim()).filter(t => t);
if (tags.length === 0) return;
if (e.shiftKey) {
tags.forEach(t => addGroup('AND', [t]));
} else {
addGroup('OR', tags);
}
els.suggest.style.display = 'none';
}
};
function renderChips() {
// Show page analysis section if there are tags
if (State.tagGroups.length > 0) {
els.pageAnalysis.style.display = 'block';
} else {
els.pageAnalysis.style.display = 'none';
}
els.chips.innerHTML = State.tagGroups.map((g, i) => {
const isOr = g.type === 'OR';
const isExpanded = State.expandedGroups.has(i);
const color = isOr ? '#a855f7' : '#64748b';
if (isOr && g.tags.length > 1) {
if (isExpanded) {
return `
<div class="v16-group-container" data-idx="${i}" style="display:flex; flex-wrap:wrap; gap:5px; padding:10px; background:${color}15; border:2px dashed ${color}; border-radius:8px;">
<div style="width:100%; display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<span style="font-size:11px; color:${color}; font-weight:bold;">[OR GROUP] Click tag to remove</span>
<span class="v16-collapse" data-idx="${i}" style="cursor:pointer; color:#94a3b8; font-size:12px; padding:2px 8px; background:#1e293b; border-radius:4px;">▲ Collapse</span>
</div>
${g.tags.map((tag, ti) => `<div class="v16-tag-chip" data-group="${i}" data-tag="${ti}" style="background:${color}33; border:1px solid ${color}; padding:6px 12px; border-radius:20px; font-size:12px; color:#fff; cursor:pointer; display:flex; align-items:center; gap:6px;"><span>#${escapeHtml(tag)}</span><span style="color:#f87171; font-weight:bold;">×</span></div>`).join('')}
<div class="v16-delete-group" data-idx="${i}" style="background:#7f1d1d; border:1px solid #ef4444; padding:6px 12px; border-radius:20px; font-size:12px; color:#fca5a5; cursor:pointer;">🗑 Delete All</div>
</div>`;
} else {
const label = g.tags.length > 2 ? `${g.tags.slice(0, 2).join(' / ')} +${g.tags.length - 2}` : g.tags.join(' / ');
return `<div class="v16-group-chip" data-idx="${i}" style="background:${color}22; border:1px solid ${color}; padding:8px 16px; border-radius:25px; font-size:13px; color:#fff; display:flex; align-items:center; gap:10px; cursor:pointer;"><span style="color:${color}; font-weight:bold;">[OR]</span><span>${escapeHtml(label)}</span><span style="color:#94a3b8; font-size:11px;">▼</span><span class="del" data-idx="${i}" style="cursor:pointer; font-weight:bold; color:#f87171;">×</span></div>`;
}
} else {
return `<div style="background:${color}22; border:1px solid ${color}; padding:8px 16px; border-radius:25px; font-size:13px; color:#fff; display:flex; align-items:center; gap:10px;"><span>${escapeHtml(g.tags.join(' & '))}</span><span class="del" data-idx="${i}" style="cursor:pointer; font-weight:bold; color:#f87171;">×</span></div>`;
}
}).join('');
els.chips.querySelectorAll('.del').forEach(s => s.onclick = (e) => { e.stopPropagation(); State.tagGroups.splice(s.dataset.idx, 1); State.expandedGroups.delete(parseInt(s.dataset.idx)); renderChips(); Backup.saveLocal(); });
els.chips.querySelectorAll('.v16-group-chip').forEach(c => c.onclick = (e) => { if (e.target.classList.contains('del')) return; State.expandedGroups.add(parseInt(c.dataset.idx)); renderChips(); });
els.chips.querySelectorAll('.v16-collapse').forEach(s => s.onclick = (e) => { e.stopPropagation(); State.expandedGroups.delete(parseInt(s.dataset.idx)); renderChips(); });
els.chips.querySelectorAll('.v16-tag-chip').forEach(c => c.onclick = () => { const g = parseInt(c.dataset.group), t = parseInt(c.dataset.tag); State.tagGroups[g].tags.splice(t, 1); if (!State.tagGroups[g].tags.length) { State.tagGroups.splice(g, 1); State.expandedGroups.delete(g); } renderChips(); Backup.saveLocal(); });
els.chips.querySelectorAll('.v16-delete-group').forEach(b => b.onclick = () => { const i = parseInt(b.dataset.idx); State.tagGroups.splice(i, 1); State.expandedGroups.delete(i); renderChips(); Backup.saveLocal(); });
}
window.renderChips = renderChips;
// Page analysis button handler
els.analyzePagesBtn.onclick = async () => {
if (!State.tagGroups.length) return alert("Add tags first!");
els.pageAnalysis.style.display = 'block';
els.analyzePagesBtn.disabled = true;
els.analyzePagesBtn.textContent = '⏳ Analyzing...';
els.pageRecommendations.innerHTML = '<div style="color:#94a3b8;">Starting analysis...</div>';
const filters = getCurrentFilters();
if (!isTagSearchSectionSupported(filters)) {
els.pageRecommendations.innerHTML = '<div style="color:#fbbf24;">⚠️ Live mode is not supported in Tag Search. Use the Live tab for live performer scans.</div>';
els.analyzePagesBtn.disabled = false;
els.analyzePagesBtn.textContent = '🔍 Analyze Pages';
return;
}
const allTags = State.tagGroups.flatMap(g => g.tags);
try {
const results = await analyzeTagPages(State.tagGroups, filters, (msg) => {
els.pageRecommendations.innerHTML = `<div style="color:#0ea5e9;">${msg}</div>`;
});
// Display recommendations
let html = '<div style="margin-bottom:10px; padding:10px; background:#0f172a; border-radius:6px;">';
html += '<div style="font-weight:bold; margin-bottom:8px; color:#a5b4fc;">📊 Analysis Results:</div>';
const maxPages = results.map(r => r.maxPage).filter(p => p > 0);
const minMax = maxPages.length > 0 ? Math.min(...maxPages) : 0;
const maxMax = maxPages.length > 0 ? Math.max(...maxPages) : 0;
const avgMax = maxPages.length > 0 ? Math.round(maxPages.reduce((a, b) => a + b, 0) / maxPages.length) : 0;
results.forEach(({ tag, maxPage, error }) => {
const color = error ? '#f87171' : (maxPage > 0 ? '#4ade80' : '#fbbf24');
const icon = error ? '❌' : (maxPage > 0 ? '✓' : '⚠️');
html += `<div style="display:flex; justify-content:space-between; padding:5px 0; border-bottom:1px solid #334155;">
<span style="color:#cbd5e1;">${icon} <b>#${escapeHtml(tag)}</b></span>
<span style="color:${color}; font-weight:bold;">${error ? 'Error' : (maxPage > 0 ? `${maxPage} pages` : 'Not detected')}</span>
</div>`;
});
html += '</div>';
if (maxPages.length > 0) {
html += `<div style="padding:10px; background:#1e293b; border-radius:6px; margin-top:10px;">
<div style="color:#94a3b8; font-size:12px; margin-bottom:5px;">📈 Statistics:</div>
<div style="display:flex; gap:15px; flex-wrap:wrap;">
<span style="color:#4ade80;">Min: <strong>${minMax}</strong></span>
<span style="color:#0ea5e9;">Avg: <strong>${avgMax}</strong></span>
<span style="color:#fbbf24;">Max: <strong>${maxMax}</strong></span>
</div>
<div style="margin-top:10px; padding-top:10px; border-top:1px solid #334155;">
<div style="color:#a5b4fc; font-size:12px; margin-bottom:5px;">💡 Recommended Depth:</div>
<div style="display:flex; gap:10px; align-items:center;">
<input type="number" id="v16-recommended-depth" value="${avgMax}"
style="width:80px; padding:5px; background:#0f172a; border:1px solid #475569; color:white; border-radius:4px; text-align:center;">
<button id="v16-apply-recommended"
style="background:#0ea5e9; border:none; color:white; padding:5px 15px; border-radius:4px; cursor:pointer; font-size:12px;">Apply to Depth</button>
</div>
</div>
</div>`;
}
els.pageRecommendations.innerHTML = html;
const applyBtn = els.pageRecommendations.querySelector('#v16-apply-recommended');
if (applyBtn) {
applyBtn.onclick = () => {
const recInput = els.pageRecommendations.querySelector('#v16-recommended-depth');
if (recInput) els.depth.value = recInput.value;
applyBtn.textContent = '✓ Applied';
setTimeout(() => { applyBtn.textContent = 'Apply to Depth'; }, 2000);
};
}
els.analyzePagesBtn.disabled = false;
els.analyzePagesBtn.textContent = '🔍 Re-Analyze';
} catch (e) {
console.error('[Titan] Error analyzing pages:', e);
els.pageRecommendations.innerHTML = `<div style="color:#f87171;">❌ Error during analysis: ${e.message}</div>`;
els.analyzePagesBtn.disabled = false;
els.analyzePagesBtn.textContent = '🔍 Analyze Pages';
}
};
els.goBtn.onclick = async () => {
if (!State.tagGroups.length) return alert("Add tags first!");
els.status.style.display = 'block';
els.grid.innerHTML = '';
els.goBtn.disabled = true;
els.goBtn.style.opacity = '0.5';
State.expandedCards.clear();
State.searchAbortReason = null;
// Stop any existing tag fetch
VideoTagManager.stopFetching();
els.tagFetchProgress.style.display = 'none';
const depth = parseInt(els.depth.value) || CONFIG.defaultDepth;
const filters = getCurrentFilters();
if (!isTagSearchSectionSupported(filters)) {
els.status.innerHTML = '⚠️ Live mode is not supported in Tag Search. Use the Live tab for live performer scans.';
els.goBtn.disabled = false;
els.goBtn.style.opacity = '1';
return;
}
const logs = [];
let intersection = null;
// Initialize search state for indexing/resume
const previousActiveSearch = State.isResumingSearch ? State.activeSearch : null;
State.isResumingSearch = false;
State.activeSearch = previousActiveSearch ? {
...previousActiveSearch,
tagGroups: JSON.parse(JSON.stringify(State.tagGroups)),
filters: filters,
depth: depth,
progress: previousActiveSearch.progress || {},
completedTags: previousActiveSearch.completedTags instanceof Set
? previousActiveSearch.completedTags
: new Set(previousActiveSearch.completedTags || []),
startTime: previousActiveSearch.startTime || Date.now()
} : {
tagGroups: JSON.parse(JSON.stringify(State.tagGroups)),
filters: filters,
depth: depth,
progress: {},
completedTags: new Set(),
startTime: Date.now()
};
SearchIndexer.save(State.activeSearch);
// Collect all searched tags for reference
const allSearchedTags = State.tagGroups.flatMap(g => g.tags);
// Collect all tags that need to be searched with their metadata
const tagsToSearch = [];
for (let groupIndex = 0; groupIndex < State.tagGroups.length; groupIndex++) {
const group = State.tagGroups[groupIndex];
for (let tagIndex = 0; tagIndex < group.tags.length; tagIndex++) {
const tag = group.tags[tagIndex];
// Check if we're resuming and this tag was already completed
if (SearchIndexer.isTagComplete(groupIndex, tagIndex)) {
logs.push(`<span style="color:#94a3b8">⏭️ Skipping #${tag} (already completed)</span>`);
continue;
}
tagsToSearch.push({
tag,
groupIndex,
tagIndex,
groupType: group.type
});
}
}
// Process tags concurrently in batches
const concurrentSearches = CONFIG.concurrentSearches || 3;
const tagResults = new Map(); // Map: "groupIndex_tagIndex" -> results array
// Throttle status DOM writes — many concurrent page fetches can spam this 100s of times/sec.
// We coalesce updates to one paint per animation frame.
let pendingStatusHtml = null;
let statusRafScheduled = false;
const setStatusThrottled = (html) => {
pendingStatusHtml = html;
if (statusRafScheduled) return;
statusRafScheduled = true;
requestAnimationFrame(() => {
statusRafScheduled = false;
if (pendingStatusHtml !== null) {
els.status.innerHTML = pendingStatusHtml;
pendingStatusHtml = null;
}
});
};
// Helper function to search a single tag
const searchTag = async (tagInfo) => {
const { tag, groupIndex, tagIndex } = tagInfo;
// Get resume page if available
const tagProgress = SearchIndexer.getTagProgress(groupIndex, tagIndex);
const resumeFromPage = tagProgress ? tagProgress.completedPages + 1 : null;
try {
const list = await scanVideos(tag, depth, (msg) => {
const statusMsg = logs.join('<br>') + (logs.length ? '<br>' : '') + msg;
setStatusThrottled(statusMsg);
}, resumeFromPage, { groupIndex, tagIndex });
// Update progress - mark tag as complete
SearchIndexer.markTagComplete(groupIndex, tagIndex);
// Log completion - search continues with other tags
logs.push(`<span style="color:#4ade80">✓ Completed #${tag} (${list.length} results)</span>`);
return { groupIndex, tagIndex, results: list, success: true };
} catch (e) {
console.error(`[Titan] Error scanning tag ${tag}:`, e);
if (State.searchAbortReason) {
logs.push(`<span style="color:#fbbf24">⏸ Paused while scanning #${tag}: ${escapeHtml(State.searchAbortReason)}</span>`);
} else {
logs.push(`<span style="color:#f87171">❌ Error scanning #${tag} - continuing...</span>`);
}
return { groupIndex, tagIndex, results: [], success: false };
}
};
// Process tags in concurrent batches
for (let i = 0; i < tagsToSearch.length; i += concurrentSearches) {
if (State.searchAbortReason) break;
const batch = tagsToSearch.slice(i, i + concurrentSearches);
const batchTagNames = batch.map(t => `#${t.tag}`).join(', ');
logs.push(`<span style="color:#0ea5e9">🔄 Processing batch: ${batchTagNames} (${i + 1}-${Math.min(i + concurrentSearches, tagsToSearch.length)} of ${tagsToSearch.length})</span>`);
els.status.innerHTML = logs.join('<br>');
const batchPromises = batch.map(tagInfo => searchTag(tagInfo));
const batchResults = await Promise.all(batchPromises);
// Store results
batchResults.forEach(({ groupIndex, tagIndex, results }) => {
const key = `${groupIndex}_${tagIndex}`;
tagResults.set(key, results);
});
if (State.searchAbortReason) break;
// Reduced delay between batches for faster processing
if (i + concurrentSearches < tagsToSearch.length) {
await wait(50);
}
}
if (State.searchAbortReason) {
const reason = escapeHtml(State.searchAbortReason);
els.status.innerHTML = logs.join('<br>') + `<br><hr style="border-color:#333; margin:15px 0;">` +
`<span style="color:#fbbf24; font-weight:bold;">⏸ Search paused: ${reason}</span><br>` +
`<span style="color:#94a3b8; font-size:12px;">Solve the challenge or refresh the page, then use Resume to continue from saved progress.</span>`;
els.resumeInfo.innerHTML = `<strong>Search paused</strong><br><span style="opacity:0.85;">${reason}</span>`;
els.resumeNotification.style.display = 'block';
els.goBtn.disabled = false;
els.goBtn.style.opacity = '1';
return;
}
// Now process results by group for intersection logic
for (let groupIndex = 0; groupIndex < State.tagGroups.length; groupIndex++) {
const group = State.tagGroups[groupIndex];
const groupMap = new Map(); // Key by performer (user)
// Collect all results for this group
for (let tagIndex = 0; tagIndex < group.tags.length; tagIndex++) {
const key = `${groupIndex}_${tagIndex}`;
const results = tagResults.get(key) || [];
results.forEach(v => {
const performerKey = v.user; // Use performer as key
if (groupMap.has(performerKey)) {
// Merge foundTags
const existing = groupMap.get(performerKey);
existing.foundTags = [...new Set([...existing.foundTags, ...v.foundTags])];
} else {
groupMap.set(performerKey, { ...v });
}
});
}
const videos = Array.from(groupMap.values());
const name = group.tags.length > 1 ? `[${group.tags[0]} +${group.tags.length - 1}]` : `#${group.tags[0]}`;
logs.push(`<span style="color:${group.type === 'OR' ? '#a855f7' : '#94a3b8'}">${name}</span>: ${videos.length} performers`);
if (intersection === null) {
intersection = videos;
} else {
// Merge foundTags when intersecting (key by performer)
const currentMap = new Map(videos.map(v => [v.user, v]));
intersection = intersection.filter(v => currentMap.has(v.user)).map(v => {
const other = currentMap.get(v.user);
return {
...v,
foundTags: [...new Set([...v.foundTags, ...other.foundTags])]
};
});
}
}
// Prepare new results
const newResults = (intersection || []).map(v => ({
...v,
searchedTags: allSearchedTags,
// Ensure foundPage and foundTimestamp exist (defaults for cached results)
foundPage: v.foundPage || 999,
foundTimestamp: v.foundTimestamp || 0
}));
State.allMatches = mergeVideoLists(newResults);
renderGrid(State.allMatches);
// Save to history
const historyEntry = SearchHistory.add({
tagGroups: State.tagGroups,
filters: filters,
depth: depth,
results: State.allMatches
});
els.status.innerHTML = logs.join('<br>') + `<br><hr style="border-color:#333; margin:15px 0;">` +
`👉 <b>Total: <span style="color:#4ade80; font-size:16px;">${State.allMatches.length} Performers</span></b>` +
`<br><span style="color:#64748b; font-size:12px;">💾 Saved to history</span>`;
updateHistoryCount();
// Clear search index - search is complete
SearchIndexer.clear();
// Start background tag fetching from /play pages (polish step after search)
if (State.allMatches.length > 0) {
// Small delay before starting tag fetch to let search complete
await wait(500);
els.tagFetchProgress.style.display = 'block';
els.tagFetchText.textContent = 'Polishing: Fetching tags from /play pages...';
await VideoTagManager.fetchAllInBackground(
State.allMatches,
// Progress callback
(current, total) => {
const percent = Math.round((current / total) * 100);
els.tagFetchBar.style.width = `${percent}%`;
els.tagFetchStats.textContent = `${current}/${total}`;
els.tagFetchText.textContent = current === total
? 'Tag fetch complete!'
: 'Fetching video tags...';
},
// Video update callback
(video) => {
updateVideoCard(video);
}
);
// Update history with enriched tag data
if (State.searchHistory.length > 0) {
State.searchHistory[0].results = State.allMatches;
SearchHistory.save();
}
// Re-render all cards with popularity-sorted tags
await renderGrid(State.allMatches);
setTimeout(() => {
els.tagFetchProgress.style.display = 'none';
}, 2000);
}
// Auto-save thumbnails if enabled
if (CONFIG.autoSaveThumbnails && State.allMatches.length > 0) {
els.dlProgress.style.display = 'block';
els.dlText.textContent = 'Auto-saving thumbnails...';
await ThumbnailManager.batchSave(State.allMatches, (current, total, saved, skipped, invalid, reacquired) => {
const percent = Math.round((current / total) * 100);
els.dlBar.style.width = `${percent}%`;
els.dlStats.innerHTML = `<span style="color:#4ade80;">✓ ${saved}</span> | <span style="color:#94a3b8;">⏭ ${skipped}</span> | <span style="color:#f87171;">❌ ${invalid}</span>${reacquired > 0 ? ` | <span style="color:#fbbf24;">🔄 ${reacquired}</span>` : ''}`;
});
setTimeout(() => {
els.dlProgress.style.display = 'none';
}, 2000);
}
els.goBtn.disabled = false;
els.goBtn.style.opacity = '1';
};
function renderGrid(videos) {
videoAnimationTimers.forEach(timer => clearInterval(timer));
videoAnimationTimers.clear();
els.grid.innerHTML = videos.map(v => renderVideoCard(v)).join('');
attachCardListeners();
}
// Check and restore local thumbnail for a video
async function checkAndRestoreThumbnail(video) {
// If thumbnail is missing or broken, try to restore from local files
if (!video.thumb || video.thumb === NO_THUMB || State.brokenThumbs.has(video.thumb)) {
const localThumb = await ThumbnailManager.restoreLocalThumbnail(video);
if (localThumb) {
video.thumb = localThumb;
return true;
}
}
return false;
}
function renderVideoCard(v) {
const isFav = State.favorites.includes(v.user);
const isCached = ThumbnailManager.isAlreadyDownloaded(v);
const isExpanded = State.expandedCards.has(v.id);
// Check for local thumbnail asynchronously (won't block rendering)
if (CONFIG.localThumbnailFolder && (!v.thumb || v.thumb === NO_THUMB || State.brokenThumbs.has(v.thumb))) {
checkAndRestoreThumbnail(v).then(restored => {
if (restored) {
// Re-render this card with the restored thumbnail
const cardEl = document.querySelector(`.v16-card-wrapper[data-id="${v.id}"]`);
if (cardEl) {
const thumbContainer = cardEl.querySelector('.v16-thumb-container');
if (thumbContainer) {
thumbContainer.style.backgroundImage = `url('${v.thumb}')`;
thumbContainer.style.backgroundSize = `${CONFIG.spriteColumns * 100}% ${CONFIG.spriteRows * 100}%`;
const noThumbDiv = thumbContainer.querySelector('div[style*="No thumbnail"]');
if (noThumbDiv) noThumbDiv.remove();
}
}
}
});
}
// Simple thumbnail display - just show the URL
const safeThumb = escapeHtml(v.thumb || '');
const safeDuration = escapeHtml(v.duration || '');
const safeUser = escapeHtml(v.user || '');
const safeUrl = escapeHtml(v.url || '#');
const safeId = escapeHtml(v.id || '');
const thumbStyle = v.thumb && v.thumb !== NO_THUMB
? `background-image:url('${safeThumb}'); background-size:${CONFIG.spriteColumns * 100}% ${CONFIG.spriteRows * 100}%;`
: 'background-image:none;';
const thumbDisplay = v.thumb && v.thumb !== NO_THUMB ? '' : '<div style="display:flex; align-items:center; justify-content:center; height:100%; color:#64748b; font-size:12px;">No thumbnail</div>';
// Get all tags: from cache, or foundTags as fallback
const cachedTags = VideoTagManager.getCachedTags(v);
const allTags = cachedTags || v.allTags || [];
const foundTags = v.foundTags || [];
const searchedTags = v.searchedTags || [];
// Separate matched vs other tags (Set lookup is O(1) vs Array.includes O(n))
const matchedSet = new Set();
for (const t of searchedTags) matchedSet.add(t);
for (const t of foundTags) matchedSet.add(t);
const matchedTags = [];
const otherTags = [];
for (const t of allTags) {
if (matchedSet.has(t)) matchedTags.push(t);
else otherTags.push(t);
}
// Sort by popularity
const sortedMatched = VideoTagManager.sortByPopularity(matchedTags);
const sortedOther = VideoTagManager.sortByPopularity(otherTags);
// Combine: matched first, then others
const allSortedTags = [...sortedMatched, ...sortedOther];
// Dynamic collapse: show ~3-5 tags based on typical card width
const collapseThreshold = 4;
const visibleTags = isExpanded ? allSortedTags : allSortedTags.slice(0, collapseThreshold);
const hiddenCount = allSortedTags.length - collapseThreshold;
// Build tag HTML
const tagsHtml = visibleTags.map(tag => {
const safeTag = escapeHtml(tag);
const isMatched = matchedTags.includes(tag);
if (isMatched) {
return `<span class="v16-tag v16-tag-matched" style="background:#4ade80; color:#000; padding:2px 6px; border-radius:3px; font-size:10px; font-weight:bold;">#${safeTag}</span>`;
} else {
return `<span class="v16-tag v16-tag-other" style="background:#334155; color:#94a3b8; padding:2px 6px; border-radius:3px; font-size:10px;">#${safeTag}</span>`;
}
}).join(' ');
// Expand/collapse button
let expandBtnHtml = '';
if (!isExpanded && hiddenCount > 0) {
expandBtnHtml = `<span class="v16-tag-expand" data-id="${safeId}" style="background:#1e293b; color:#6366f1; padding:2px 6px; border-radius:3px; font-size:10px; cursor:pointer; border:1px solid #6366f1;">+${hiddenCount} more</span>`;
} else if (isExpanded && allSortedTags.length > collapseThreshold) {
expandBtnHtml = `<span class="v16-tag-collapse" data-id="${safeId}" style="background:#1e293b; color:#6366f1; padding:2px 6px; border-radius:3px; font-size:10px; cursor:pointer; border:1px solid #6366f1;">− less</span>`;
}
// Loading indicator if tags not yet fetched
const loadingHtml = (!cachedTags && allTags.length === 0 && foundTags.length > 0)
? `<span style="color:#64748b; font-size:10px; font-style:italic;">Loading tags...</span>`
: '';
return `
<div class="v16-card-wrapper" data-id="${safeId}" style="position:relative;">
<a href="${safeUrl}" target="_blank" style="text-decoration:none; display:block;">
<div class="v16-card" style="background:#1e293b; border-radius:8px; overflow:hidden; border:1px solid #334155; transition:0.3s; position:relative; z-index:1;">
<div class="v16-thumb-container" style="width:100%; aspect-ratio:16/9; overflow:hidden; background:#000; position:relative; ${thumbStyle} background-position:0% 0%;">
${thumbDisplay}
<div style="position:absolute; bottom:8px; right:8px; background:rgba(0,0,0,0.85); color:white; font-size:11px; padding:3px 6px; border-radius:4px; font-weight:bold;">${safeDuration}</div>
${isCached ? '<div style="position:absolute; top:8px; left:8px; background:#4ade80; color:#000; font-size:10px; padding:2px 5px; border-radius:3px;">💾</div>' : ''}
</div>
<div style="padding:12px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<div style="color:#a855f7; font-size:14px; font-weight:bold;">@${safeUser}</div>
<div style="color:#64748b; font-size:11px;">${safeDuration}</div>
</div>
<div class="v16-tags-container" style="display:flex; flex-wrap:wrap; gap:4px; min-height:20px;">
${tagsHtml}
${expandBtnHtml}
${loadingHtml}
</div>
</div>
</div>
</a>
<button class="v16-fav-btn" data-user="${safeUser}" data-thumb="${safeThumb}" style="position:absolute; top:5px; right:5px; background:rgba(0,0,0,0.5); border:none; border-radius:50%; width:28px; height:28px; cursor:pointer; color:${isFav ? '#eab308' : 'white'}; font-size:16px; display:flex; align-items:center; justify-content:center; z-index:10;">${isFav ? '★' : '☆'}</button>
</div>`;
}
// Coalesce per-video card updates into a single rAF flush.
// The background tag fetcher fires updateVideoCard once per video; without
// batching we'd do N querySelectors + N innerHTML parses + N listener rebinds.
const _pendingCardUpdates = new Map();
let _cardUpdateRaf = null;
function _flushCardUpdates() {
_cardUpdateRaf = null;
const videos = Array.from(_pendingCardUpdates.values());
_pendingCardUpdates.clear();
for (const video of videos) {
const card = els.grid.querySelector(`.v16-card-wrapper[data-id="${video.id}"]`);
if (!card) continue;
const newCard = document.createElement('div');
newCard.innerHTML = renderVideoCard(video);
card.replaceWith(newCard.firstElementChild);
attachCardListeners(video.id);
}
}
function updateVideoCard(video) {
if (!video || !video.id) return;
_pendingCardUpdates.set(video.id, video);
if (_cardUpdateRaf !== null) return;
_cardUpdateRaf = requestAnimationFrame(_flushCardUpdates);
}
function prependVideoCards(videos) {
const fragment = document.createDocumentFragment();
videos.forEach((v, i) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = renderVideoCard(v);
const card = wrapper.firstElementChild;
// Add fade-in animation with stagger
card.style.opacity = '0';
card.style.transform = 'translateY(-20px)';
card.style.transition = 'opacity 0.4s ease, transform 0.4s ease';
setTimeout(() => {
card.style.opacity = '1';
card.style.transform = 'translateY(0)';
}, i * 100);
fragment.appendChild(card);
});
els.grid.insertBefore(fragment, els.grid.firstChild);
attachCardListeners();
}
function attachCardListeners(specificId = null) {
const selector = specificId
? `.v16-card-wrapper[data-id="${specificId}"]`
: '.v16-card-wrapper';
els.grid.querySelectorAll(selector).forEach(card => {
const vidId = card.dataset.id;
const thumbContainer = card.querySelector('.v16-thumb-container');
// Hover animation
card.onmouseenter = () => {
let frame = 0;
const timer = setInterval(() => {
frame = (frame + 1) % CONFIG.spriteFrames;
thumbContainer.style.backgroundPosition = getSpritePosition(frame);
}, CONFIG.animationSpeed);
videoAnimationTimers.set(vidId, timer);
};
card.onmouseleave = () => {
clearInterval(videoAnimationTimers.get(vidId));
videoAnimationTimers.delete(vidId);
thumbContainer.style.backgroundPosition = '0% 0%';
};
// Favorite button
const favBtn = card.querySelector('.v16-fav-btn');
if (favBtn) {
favBtn.onclick = (e) => {
e.preventDefault();
toggleFavorite(favBtn.dataset.user, favBtn.dataset.thumb, 'sprite', favBtn);
};
}
// Tag expand/collapse
const expandBtn = card.querySelector('.v16-tag-expand');
if (expandBtn) {
expandBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
State.expandedCards.add(expandBtn.dataset.id);
const video = State.allMatches.find(v => v.id === expandBtn.dataset.id);
if (video) updateVideoCard(video);
};
}
const collapseBtn = card.querySelector('.v16-tag-collapse');
if (collapseBtn) {
collapseBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
State.expandedCards.delete(collapseBtn.dataset.id);
const video = State.allMatches.find(v => v.id === collapseBtn.dataset.id);
if (video) updateVideoCard(video);
};
}
});
}
const style = document.createElement('style');
style.textContent = `
.v16-card:hover { transform: scale(${CONFIG.hoverScale}) translateY(-10px); border-color: #a855f7 !important; z-index: 10; box-shadow: 0 15px 40px rgba(0,0,0,0.9); }
.v16-live-card:hover { transform: scale(1.03); border-color: #ef4444 !important; box-shadow: 0 10px 30px rgba(239,68,68,0.3); }
.v16-live-card-wrapper { position: relative; }
.v16-fav-btn:hover { transform: scale(1.2); }
.v16-item:hover { background: #334155; }
.v16-tag-chip:hover { background: #7f1d1d !important; border-color: #ef4444 !important; }
.v16-group-chip:hover { transform: scale(1.02); }
.v16-history-item:hover { background: #334155 !important; }
.v16-tag { transition: transform 0.2s, box-shadow 0.2s; cursor: default; }
.v16-tag:hover { transform: scale(1.1); box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
.v16-tag-matched:hover { background: #22c55e !important; }
.v16-tag-other:hover { background: #475569 !important; color: #fff !important; }
.v16-tag-expand:hover, .v16-tag-collapse:hover { background: #6366f1 !important; color: #fff !important; }
.v16-card-wrapper { animation: none; }
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
`;
document.head.appendChild(style);
// Check for interrupted search on page load
function checkForInterruptedSearch() {
const interrupted = SearchIndexer.load();
if (interrupted && interrupted.tagGroups) {
State.activeSearch = interrupted;
// Convert completedTags Set from array (JSON doesn't support Set)
if (Array.isArray(interrupted.completedTags)) {
State.activeSearch.completedTags = new Set(interrupted.completedTags);
} else {
State.activeSearch.completedTags = new Set();
}
// If this was a 403 refresh, auto-resume immediately
if (interrupted.refresh403) {
// Clear the flag
State.activeSearch.refresh403 = false;
SearchIndexer.save(State.activeSearch);
// Auto-resume after a short delay to let page load
setTimeout(() => {
resumeInterruptedSearch();
}, 500);
return;
}
// Show resume notification for manual interruptions
const elapsed = Math.floor((Date.now() - interrupted.timestamp) / 1000 / 60);
const tagCount = interrupted.tagGroups.reduce((sum, g) => sum + g.tags.length, 0);
const completedCount = State.activeSearch.completedTags.size;
const remainingCount = tagCount - completedCount;
els.resumeInfo.innerHTML = `
<strong>${tagCount} tags</strong> at depth <strong>${interrupted.depth}</strong><br>
<span style="opacity:0.8;">${completedCount} completed, ${remainingCount} remaining</span> • Interrupted ${elapsed} min ago
`;
els.resumeNotification.style.display = 'block';
}
}
// Resume interrupted search
function resumeInterruptedSearch() {
if (!State.activeSearch) return;
// Restore tag groups and filters
State.tagGroups = JSON.parse(JSON.stringify(State.activeSearch.tagGroups));
applyFiltersToUI(State.activeSearch.filters);
els.depth.value = State.activeSearch.depth;
renderChips();
// Hide resume notification
els.resumeNotification.style.display = 'none';
// Switch to search tab and trigger search
switchToTab('search');
State.isResumingSearch = true;
setTimeout(() => {
els.goBtn.click();
}, 100);
}
// Load initial data
Backup.restoreLocal();
if (State.tagGroups.length) renderChips();
updateFavCount();
updateHistoryCount();
// Check for interrupted search
checkForInterruptedSearch();
// Resume button handler
els.resumeBtn.onclick = () => {
resumeInterruptedSearch();
};
// Dismiss resume notification
els.dismissResumeBtn.onclick = () => {
SearchIndexer.clear();
els.resumeNotification.style.display = 'none';
};
// Expose global function for clearing all cache from console
window.clearAllTitanCache = function () {
Memory.clear();
ThumbnailManager.clearCache();
VideoTagManager.clearCache();
SearchHistory.clear();
SearchIndexer.clear();
alert('All Titan cache cleared!');
};
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', createUI);
} else {
createUI();
}
})();