Pornolab image preview - improved

Preview torrent images on hover in tracker listing, with cached topic parsing and faster image loading

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Pornolab image preview - improved
// @description  Preview torrent images on hover in tracker listing, with cached topic parsing and faster image loading
// @namespace    https://pornolab.net/forum/index.php
// @version      0.4
// @author       tobij12 - pingu2, revised
// @match        https://pornolab.net/forum/tracker.php*
// @grant        GM_xmlhttpRequest
// @license MIT
// @connect      *
// ==/UserScript==

(function () {
    'use strict';

    /*
     * Main performance changes:
     * 1. Topic pages are fetched and parsed once per torrent URL.
     * 2. Mouse wheel navigation reuses cached image list instead of fetching topic again.
     * 3. Direct image URLs are assigned directly to <img src>, letting the browser cache/decode.
     * 4. Host-page image fetches use blob object URLs instead of base64 strings.
     * 5. Preview state is per link, not global.
     */

    const DEBUG = false;

    const PREVIEW_LEFT = '350px';
    const DEFAULT_PREVIEW_HEIGHT = 400;
    const MIN_PREVIEW_HEIGHT = 200;
    const MAX_TOPIC_CACHE_SIZE = 300;
    const PREFETCH_NEXT_IMAGE = true;

    const blockedSources = [
        'vsexshop',
        'static.pornolab.net',
        'yadro',
        'vpipi',
        '9bee784100d7c6108d51fd70e9b79a50.gif',
        'nodrink',
        'rimg',
        '9ac78b9bb3e82339391d223a64daf18f',
        '4f9a8a86a785326a0a3d1560404a6fdc',
        '73ea7145a1b7d011589c849c5391c7b6',
        '5772513e239a6a8ee48af36544313a06'
    ];

    const topicCache = new Map();       // topicUrl -> Promise<ImageItem[]>
    const resolvedImageCache = new Map(); // cacheKey -> Promise<string|null>, final image URL/object URL
    const directImagePreloadCache = new Map(); // direct image URL -> Promise<boolean>

    const stateByLink = new WeakMap();
    const knownLinks = new Set();

    const links = document.querySelectorAll('.med .tLink');

    injectStyles();

    document.addEventListener('keydown', function (e) {
        if (e.key === 'Escape') {
            closeAllPreviews();
        }
    });

    links.forEach(setupLink);

    function setupLink(el) {
        const state = getState(el);
        knownLinks.add(el);

        // Helps absolute positioning behave more predictably.
        if (!el.style.position) {
            el.style.position = 'relative';
        }

        el.addEventListener('mouseenter', async function () {
            state.opened = true;
            state.index = 0;
            state.requestToken++;

            const token = state.requestToken;
            const topicUrl = normalizeUrl(el.href, location.href);

            showSpinner(el, 'topic page');

            try {
                const images = await getTopicImagesCached(topicUrl);

                if (!isCurrent(el, token)) return;

                state.images = images;
                state.index = 0;

                if (!images.length) {
                    showMessage(el, 'No preview images found');
                    return;
                }

                await showImageAtCurrentIndex(el, token);
            } catch (err) {
                log('Topic load failed', err);
                if (isCurrent(el, token)) {
                    showMessage(el, 'Preview load failed');
                }
            }
        });

        el.addEventListener('wheel', async function (e) {
            if (!state.opened) return;

            e.preventDefault();
            e.stopPropagation();

            const token = ++state.requestToken;

            try {
                if (!state.images) {
                    const topicUrl = normalizeUrl(el.href, location.href);
                    state.images = await getTopicImagesCached(topicUrl);
                }

                if (!state.images.length) return;

                if (e.deltaY < 0) {
                    state.index = Math.max(0, state.index - 1);
                } else if (e.deltaY > 0) {
                    state.index = Math.min(state.images.length - 1, state.index + 1);
                }

                await showImageAtCurrentIndex(el, token);
            } catch (err) {
                log('Wheel image load failed', err);
            }

            return false;
        }, { passive: false });

        const row = el.closest('tr');
        if (row) {
            row.addEventListener('mouseout', function (event) {
                const related = event.relatedTarget;

                // Ignore movement inside the same row.
                if (related && row.contains(related)) return;

                closePreview(el);
            });
        }
    }

    function getState(el) {
        let state = stateByLink.get(el);

        if (!state) {
            state = {
                opened: false,
                index: 0,
                requestToken: 0,
                images: null
            };

            stateByLink.set(el, state);
        }

        return state;
    }

    function isCurrent(el, token) {
        const state = getState(el);
        return state.opened && state.requestToken === token;
    }

    async function showImageAtCurrentIndex(el, token) {
        const state = getState(el);
        const images = state.images || [];

        if (!images.length) return;

        const item = images[state.index];
        if (!item || !item.thumbUrl) return;

        const total = images.length;
        const index = state.index;

        const position = calculatePreviewPosition(el);
        const hostLabel = getHostLabel(item.thumbUrl, item.parentUrl);

        // Show thumbnail/direct source immediately while resolving a better image.
        showPreview(el, {
            src: item.thumbUrl,
            index,
            total,
            height: position.height,
            topMargin: position.topMargin,
            loadingText: `Loading from ${hostLabel}...`,
            showSpinner: true
        });

        try {
            const finalSrc = await resolveDisplayImageUrlCached(item);

            if (!isCurrent(el, token)) return;
            if (index !== getState(el).index) return;

            if (finalSrc) {
                showPreview(el, {
                    src: finalSrc,
                    index,
                    total,
                    height: position.height,
                    topMargin: position.topMargin,
                    loadingText: '',
                    showSpinner: false
                });
            } else {
                showPreview(el, {
                    src: item.thumbUrl,
                    index,
                    total,
                    height: position.height,
                    topMargin: position.topMargin,
                    loadingText: '',
                    showSpinner: false
                });
            }

            if (PREFETCH_NEXT_IMAGE) {
                prefetchNearby(images, index);
            }
        } catch (err) {
            log('Image resolve failed', err);

            if (!isCurrent(el, token)) return;

            showPreview(el, {
                src: item.thumbUrl,
                index,
                total,
                height: position.height,
                topMargin: position.topMargin,
                loadingText: 'Using thumbnail',
                showSpinner: false
            });
        }
    }

    function prefetchNearby(images, index) {
        const next = images[index + 1];
        if (!next) return;

        resolveDisplayImageUrlCached(next).catch(() => {});
    }

    function closePreview(el) {
        const state = getState(el);
        state.opened = false;
        state.index = 0;
        state.requestToken++;
        removePreviewNodes(el);
    }

    function closeAllPreviews() {
        knownLinks.forEach(closePreview);
        document.querySelectorAll('.appendedHoverImgContainer').forEach(n => n.remove());
    }

    function removePreviewNodes(el) {
        el.querySelectorAll('.appendedHoverImgContainer').forEach(n => n.remove());
    }

    function getTopicImagesCached(topicUrl) {
        if (topicCache.has(topicUrl)) {
            return topicCache.get(topicUrl);
        }

        const promise = fetchText(topicUrl)
            .then(html => parseTopicImages(html, topicUrl))
            .catch(err => {
                topicCache.delete(topicUrl);
                throw err;
            });

        topicCache.set(topicUrl, promise);
        trimMap(topicCache, MAX_TOPIC_CACHE_SIZE);

        return promise;
    }

    function parseTopicImages(html, topicUrl) {
        const doc = new DOMParser().parseFromString(html, 'text/html');

        // The attached script scopes to .post_body and reads var.postImg, which is cleaner
        // than scanning the entire parsed HTML.
        const root = doc.querySelector('.post_body') || doc;

        const candidates = Array.from(root.querySelectorAll('var.postImg, .postImg'));

        const images = [];

        for (const node of candidates) {
            const thumbUrlRaw = node.getAttribute('title') || node.title || '';
            if (!thumbUrlRaw) continue;

            const thumbUrl = normalizeUrl(thumbUrlRaw, topicUrl);
            if (!thumbUrl) continue;

            if (isBlockedSource(thumbUrl)) continue;

            const parentAnchor = node.closest('a');
            const parentUrl = parentAnchor
                ? normalizeUrl(parentAnchor.getAttribute('href') || parentAnchor.href, topicUrl)
                : null;

            // Keep your original row1 intent, but do not require it if we're already scoped
            // to .post_body. Some pages may not preserve .row1 exactly when parsed.
            const insideLikelyContent =
                node.closest('.row1') ||
                node.closest('.post_body') ||
                root.classList?.contains('post_body');

            if (!insideLikelyContent) continue;

            images.push({
                thumbUrl,
                parentUrl
            });
        }

        return dedupeImages(images);
    }

    function dedupeImages(images) {
        const seen = new Set();
        const result = [];

        for (const item of images) {
            const key = `${item.thumbUrl}|${item.parentUrl || ''}`;
            if (seen.has(key)) continue;

            seen.add(key);
            result.push(item);
        }

        return result;
    }

    function isBlockedSource(url) {
        return blockedSources.some(blocked => url.includes(blocked));
    }

    async function resolveDisplayImageUrlCached(item) {
        const cacheKey = `${item.thumbUrl}|${item.parentUrl || ''}`;

        if (resolvedImageCache.has(cacheKey)) {
            return resolvedImageCache.get(cacheKey);
        }

        const promise = resolveDisplayImageUrl(item)
            .catch(err => {
                resolvedImageCache.delete(cacheKey);
                throw err;
            });

        resolvedImageCache.set(cacheKey, promise);
        return promise;
    }

    async function resolveDisplayImageUrl(item) {
        let validSource = item.thumbUrl;
        const parentUrl = item.parentUrl;

        if (!validSource) return null;

        // GIFs and most direct URLs can be shown directly. No base64 needed.
        if (validSource.includes('.gif')) {
            return validSource;
        }

        // Fast direct URL rewrites.
        if (validSource.includes('imgbox') && validSource.includes('thumb')) {
            return validSource
                .replace('thumbs', 'images')
                .replace('_t', '_o');
        }

        if (validSource.includes('imgdrive') && validSource.includes('small')) {
            return validSource.replace('small', 'big');
        }

        if (validSource.includes('freescreens.ru') && validSource.includes('thumb')) {
            return validSource
                .replace('freescreens.', 'picforall.')
                .replace('-thumb', '');
        }

        // Hosts that usually need the image view page parsed.
        if (validSource.includes('fastpic.org') && parentUrl) {
            return fetchFastpicBigImageObjectUrlCached(parentUrl);
        }

        if (validSource.includes('imgbox') && parentUrl) {
            return fetchHostImageObjectUrlCached(parentUrl);
        }

        if (validSource.includes('imagevenue.com') && validSource.includes('thumbs') && parentUrl) {
            return fetchHostImageObjectUrlCached(parentUrl);
        }

        if (validSource.includes('turboimg.net') && validSource.includes('/t/') && parentUrl) {
            return fetchHostImageObjectUrlCached(parentUrl);
        }

        if (validSource.includes('picshick') && validSource.includes('/th/') && parentUrl) {
            return fetchHostImageObjectUrlCached(parentUrl);
        }

        if (validSource.includes('picturelol') && validSource.includes('/th/') && parentUrl) {
            return fetchHostImageObjectUrlCached(parentUrl);
        }

        // Normal direct image URL.
        // Browser handles caching/decoding better than a userscript base64 cache.
        return validSource;
    }

    function fetchFastpicBigImageObjectUrlCached(viewPageUrl) {
        const key = `fastpic:${viewPageUrl}`;

        if (resolvedImageCache.has(key)) {
            return resolvedImageCache.get(key);
        }

        const promise = fetchFastpicBigImageObjectUrl(viewPageUrl)
            .catch(err => {
                resolvedImageCache.delete(key);
                throw err;
            });

        resolvedImageCache.set(key, promise);
        return promise;
    }

    function fetchHostImageObjectUrlCached(viewPageUrl) {
        const key = `host:${viewPageUrl}`;

        if (resolvedImageCache.has(key)) {
            return resolvedImageCache.get(key);
        }

        const promise = fetchHostImageObjectUrl(viewPageUrl)
            .catch(err => {
                resolvedImageCache.delete(key);
                throw err;
            });

        resolvedImageCache.set(key, promise);
        return promise;
    }

    async function fetchFastpicBigImageObjectUrl(viewPageUrl) {
        const response = await gmRequest({
            method: 'GET',
            url: viewPageUrl,
            responseType: 'blob'
        });

        const contentType = getContentType(response);

        if (contentType.includes('image/')) {
            return URL.createObjectURL(response.response);
        }

        if (!contentType.includes('text/html')) {
            return null;
        }

        const html = await blobToText(response.response);
        const doc = new DOMParser().parseFromString(html, 'text/html');

        const images = Array.from(doc.querySelectorAll('img'));
        const validImage = images.find(img =>
            img.src.includes('md5=') && img.src.includes('expires=')
        );

        if (!validImage) return null;

        return fetchImageAsObjectUrl(validImage.src);
    }

    async function fetchHostImageObjectUrl(viewPageUrl) {
        const response = await gmRequest({
            method: 'GET',
            url: viewPageUrl,
            responseType: 'blob'
        });

        const contentType = getContentType(response);

        if (contentType.includes('image/')) {
            return URL.createObjectURL(response.response);
        }

        if (!contentType.includes('text/html')) {
            return null;
        }

        const html = await blobToText(response.response);
        const doc = new DOMParser().parseFromString(html, 'text/html');

        const images = Array.from(doc.querySelectorAll('img'));

        const validImage = images.find(img => {
            const src = img.src || '';

            return (
                (src.includes('turboimg.net') && src.includes('/sp/')) ||
                src.includes('cdn-images.imagevenue') ||
                src.includes('picshick.com/i') ||
                src.includes('picturelol.com/i') ||
                src.includes('images2.imgbox.com') ||
                src.includes('images.imgbox.com')
            );
        });

        if (!validImage) return null;

        return fetchImageAsObjectUrl(validImage.src);
    }

    async function fetchImageAsObjectUrl(url) {
        const response = await gmRequest({
            method: 'GET',
            url,
            responseType: 'blob'
        });

        const contentType = getContentType(response);

        if (!contentType.includes('image/')) {
            // Some hosts omit content-type. Still try if blob exists.
            if (!response.response) return null;
        }

        return URL.createObjectURL(response.response);
    }

    function fetchText(url) {
        return gmRequest({
            method: 'GET',
            url,
            responseType: 'text',
            headers: {
                Referer: url,
                'User-Agent': navigator.userAgent
            }
        }).then(response => response.responseText || response.response || '');
    }

    function gmRequest(options) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: options.method || 'GET',
                url: options.url,
                responseType: options.responseType,
                headers: options.headers || undefined,
                timeout: options.timeout || 30000,
                onload: response => {
                    if (response.status >= 200 && response.status < 400) {
                        resolve(response);
                    } else {
                        reject(new Error(`HTTP ${response.status} for ${options.url}`));
                    }
                },
                ontimeout: () => reject(new Error(`Timeout for ${options.url}`)),
                onerror: err => reject(err)
            });
        });
    }

    function getContentType(response) {
        const headers = (response.responseHeaders || '').toLowerCase();

        const line = headers
            .split(/\r?\n/)
            .find(header => header.startsWith('content-type:'));

        return line || '';
    }

    function blobToText(blob) {
        if (!blob) return Promise.resolve('');

        // Blob.text() is cleaner, but FileReader is safer in older userscript contexts.
        return new Promise((resolve, reject) => {
            const reader = new FileReader();
            reader.onload = () => resolve(reader.result || '');
            reader.onerror = reject;
            reader.readAsText(blob);
        });
    }

    function showPreview(el, options) {
        const container = getPreviewContainer(el);

        container.style.left = PREVIEW_LEFT;
        container.style.marginTop = `${options.topMargin}px`;
        container.style.height = `${options.height}px`;

        const img = container.querySelector('.preview-img');
        const label = container.querySelector('.imageCounterLabel');
        const spinnerRow = container.querySelector('.preview-loading');

        label.textContent = `Image ${options.index + 1}/${options.total}`;

        if (options.showSpinner) {
            spinnerRow.style.display = 'flex';
            spinnerRow.querySelector('.spinner-text').textContent = options.loadingText || '';
        } else {
            spinnerRow.style.display = 'none';
        }

        if (img.src !== options.src) {
            img.src = options.src;
        }
    }

    function getPreviewContainer(el) {
        let container = el.querySelector('.appendedHoverImgContainer');

        if (container) return container;

        container = document.createElement('div');
        container.className = 'appendedHoverImgContainer';

        container.style.cssText = `
            position: absolute;
            left: ${PREVIEW_LEFT};
            margin-top: 15px;
            height: ${DEFAULT_PREVIEW_HEIGHT}px;
            width: auto;
            z-index: 9999;
            pointer-events: none;
        `;

        const img = document.createElement('img');
        img.className = 'preview-img';
        img.decoding = 'async';
        img.loading = 'eager';
        img.style.cssText = `
            height: 100%;
            width: auto;
            display: block;
            background: rgba(0, 0, 0, 0.25);
            box-shadow: 0 4px 20px rgba(0,0,0,0.45);
        `;

        const label = document.createElement('div');
        label.className = 'imageCounterLabel';
        label.style.cssText = `
            position: absolute;
            top: 5px;
            left: 5px;
            color: #fff;
            font-weight: normal;
            font-family: verdana, sans-serif;
            background-color: rgba(0, 0, 0, 0.6);
            padding: 2px 6px;
            font-size: 12px;
            border-radius: 4px;
            pointer-events: none;
        `;

        const loading = document.createElement('div');
        loading.className = 'preview-loading';
        loading.style.cssText = `
            position: absolute;
            left: 5px;
            bottom: 5px;
            display: flex;
            align-items: center;
            gap: 8px;
            padding: 3px 6px;
            border-radius: 4px;
            background: rgba(0,0,0,0.6);
        `;

        const spinner = document.createElement('div');
        spinner.className = 'spinner';

        const spinnerText = document.createElement('span');
        spinnerText.className = 'spinner-text';

        loading.appendChild(spinner);
        loading.appendChild(spinnerText);

        container.appendChild(img);
        container.appendChild(label);
        container.appendChild(loading);

        el.appendChild(container);

        return container;
    }

    function showSpinner(el, host) {
        const position = calculatePreviewPosition(el);

        showPreview(el, {
            src: transparentPixel(),
            index: 0,
            total: 1,
            height: position.height,
            topMargin: position.topMargin,
            loadingText: host ? `Loading from ${host}...` : 'Loading...',
            showSpinner: true
        });
    }

    function showMessage(el, message) {
        const position = calculatePreviewPosition(el);
        const container = getPreviewContainer(el);

        container.style.left = PREVIEW_LEFT;
        container.style.marginTop = `${position.topMargin}px`;
        container.style.height = '32px';

        const img = container.querySelector('.preview-img');
        const label = container.querySelector('.imageCounterLabel');
        const spinnerRow = container.querySelector('.preview-loading');

        img.src = transparentPixel();
        label.textContent = message;
        spinnerRow.style.display = 'none';
    }

    function calculatePreviewPosition(el) {
        const y = el.getBoundingClientRect().top;
        const windowHeight = window.innerHeight;

        let height = DEFAULT_PREVIEW_HEIGHT;
        let topMargin = 15;

        if (windowHeight < y + 1800 - 15) {
            height = windowHeight - y - 45;

            if (height < MIN_PREVIEW_HEIGHT) {
                height = MIN_PREVIEW_HEIGHT;
                topMargin = windowHeight - y - 225;
            }
        }

        return { height, topMargin };
    }

    function normalizeUrl(url, baseUrl) {
        if (!url) return null;

        try {
            return new URL(url, baseUrl || location.href).href;
        } catch {
            return null;
        }
    }

    function getHostLabel(thumbUrl, parentUrl) {
        try {
            if (parentUrl) return new URL(parentUrl).hostname;
            return new URL(thumbUrl).hostname;
        } catch {
            return 'host';
        }
    }

    function trimMap(map, maxSize) {
        while (map.size > maxSize) {
            const firstKey = map.keys().next().value;
            map.delete(firstKey);
        }
    }

    function injectStyles() {
        const style = document.createElement('style');

        style.textContent = `
            .spinner {
                border: 5px solid rgba(255,255,255,0.18);
                border-left-color: #aaa;
                border-radius: 50%;
                width: 14px;
                height: 14px;
                animation: pornolabPreviewSpin 1s linear infinite;
                flex: 0 0 auto;
            }

            .spinner-text {
                font-size: 13px;
                color: #eee;
                font-weight: normal;
                font-family: sans-serif;
                white-space: nowrap;
            }

            @keyframes pornolabPreviewSpin {
                to { transform: rotate(360deg); }
            }
        `;

        document.head.appendChild(style);
    }

    function transparentPixel() {
        return 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
    }

    function log(...args) {
        if (DEBUG) {
            console.log('[Pornolab preview]', ...args);
        }
    }

})();