Pornolab Preloaded Preview

Preloads and dynamically displays preview images below links. Implements fallback to the next image if the first fails to load. Includes debug mode.

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 or Violentmonkey 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 Preloaded Preview
// @version      1.7.0
// @description  Preloads and dynamically displays preview images below links. Implements fallback to the next image if the first fails to load. Includes debug mode.
// @author       Ace
// @license      MIT
// @match        *://pornolab.net/forum/tracker*
// @match        *://pornolab.net/forum/viewforum*
// @match        *://pornolab.net/forum/search*
// @icon         https://static.pornolab.net/favicon.ico
// @run-at       document-end
// @grant        none
// @namespace https://greasyfork.org/users/1418199
// ==/UserScript==

(function () {
    "use strict";

    /* ================= CONFIG ================= */
    const DEBUG_MODE = false; // Set to true to see detailed logs in console (F12)
    const MIN_HEIGHT = 51;
    const MAX_CONCURRENT_FETCHES = 4;
    const PREVIEW_HEIGHT = "21rem";
    const LAZY_LOAD_MARGIN = "500px";

    /* ================= LOGGER ================= */
    function log(...args) {
        if (DEBUG_MODE) {
            console.log("[Pornolab Preview]", ...args);
        }
    }

    /* ================= STYLES ================= */
    const style = document.createElement("style");
    style.textContent = `
    .preview-container {
      position: relative;
      width: 100%;
      max-width: 100%;
      height: ${PREVIEW_HEIGHT};
      display: flex; /* Reverted to flex for perfect centering */
      justify-content: center;
      align-items: center;
      overflow: hidden;
      margin-top: 0.5rem;
      background: #111;
      border-radius: 6px;
      font-size: 0.9rem;
      color: #888;
      text-decoration: none !important;
      cursor: pointer;
      isolation: isolate;
      box-sizing: border-box;
      transition: all 0.2s ease;
    }

    .preview-container:hover {
      box-shadow: 0 0 12px rgba(255, 255, 255, 0.15);
    }

    /* --- OWNED TOPIC STYLES --- */
    .preview-container.owned-topic {
        border: 3px solid #4CAF50;
        box-shadow: 0 0 15px rgba(76, 175, 80, 0.25);
    }
    .preview-container.owned-topic:hover {
        box-shadow: 0 0 20px rgba(76, 175, 80, 0.5);
    }

    .owned-badge {
        position: absolute;
        top: 8px;
        right: 8px;
        background: #4CAF50;
        color: #fff;
        padding: 4px 10px;
        border-radius: 4px;
        font-size: 0.8rem;
        font-weight: bold;
        z-index: 10;
        box-shadow: 0 2px 6px rgba(0,0,0,0.6);
        pointer-events: none;
        text-transform: uppercase;
        letter-spacing: 0.5px;
    }

    /* --- CAROUSEL NAVIGATION --- */
    .nav-arrow {
        position: absolute;
        top: 50%;
        transform: translateY(-50%);
        background: rgba(0, 0, 0, 0.6);
        color: white;
        border: none;
        font-size: 36px;
        cursor: pointer;
        padding: 20px 15px;
        z-index: 10;
        opacity: 0;
        transition: opacity 0.2s, background 0.2s;
        border-radius: 4px;
        user-select: none;
    }
    .preview-container:hover .nav-arrow { opacity: 1; }
    .nav-arrow:hover { background: rgba(0, 0, 0, 0.9); }
    .nav-prev { left: 10px; }
    .nav-next { right: 10px; }

    .img-counter {
        position: absolute;
        bottom: 8px;
        right: 8px;
        background: rgba(0, 0, 0, 0.7);
        color: #fff;
        padding: 3px 8px;
        border-radius: 4px;
        font-size: 0.75rem;
        z-index: 10;
        opacity: 0;
        transition: opacity 0.2s;
        pointer-events: none;
    }
    .preview-container:hover .img-counter { opacity: 1; }

    /* TEXT PLACEHOLDER FOR NO IMAGES */
    .preview-container .no-img-text {
        position: relative;
    }

    /* THE BLURRED BACKGROUND */
    .preview-container img.bg-blur {
      position: absolute;
      top: -10%; left: -10%;
      width: 120%; height: 120%;
      object-fit: cover;
      filter: blur(15px);
      opacity: 0.4;
      z-index: -1;
      pointer-events: none;
    }

    /* THE MAIN FOREGROUND IMAGE */
    .preview-container img.main-img {
      position: relative;
      max-width: 100%;
      max-height: 100%;
      object-fit: contain;
      z-index: 1;
      border-radius: 4px;
      box-shadow: 0 4px 10px rgba(0,0,0,0.5);
    }
  `;
    document.head.appendChild(style);

    /* ================= INDEXED DB LOGIC ================= */
    let dbCache = null;

    async function getDb() {
        if (dbCache) return dbCache;
        return new Promise((resolve, reject) => {
            const request = indexedDB.open("pornolab_torrents");
            request.onsuccess = (event) => {
                dbCache = event.target.result;
                resolve(dbCache);
            };
            request.onerror = (event) => reject(event.target.error);
        });
    }

    async function checkTopicInDb(topicId) {
        if (!topicId || isNaN(topicId)) return false;
        try {
            const db = await getDb();
            if (!db.objectStoreNames.contains("torrent_ids")) return false;
            return new Promise((resolve) => {
                const transaction = db.transaction(["torrent_ids"], "readonly");
                const store = transaction.objectStore("torrent_ids");
                const reqNum = store.get(topicId);
                reqNum.onsuccess = (event) => {
                    if (event.target.result !== undefined) {
                        resolve(true);
                    } else {
                        const reqStr = store.get(topicId.toString());
                        reqStr.onsuccess = (e) => resolve(e.target.result !== undefined);
                        reqStr.onerror = () => resolve(false);
                    }
                };
                reqNum.onerror = () => resolve(false);
            });
        } catch (e) {
            return false;
        }
    }

    function extractTopicId(url) {
        try {
            const urlObj = new URL(url);
            const tParam = urlObj.searchParams.get("t");
            return tParam ? parseInt(tParam, 10) : null;
        } catch (e) {
            return null;
        }
    }

    /* ================= CACHE & NETWORK ================= */
    const previewCache = new Map();

    function buildUrl(link) {
        return new URL(link.getAttribute("href"), location.href).href;
    }

    function fetchPreviewUrls(url) {
        if (!previewCache.has(url)) {
            log(`[Network] Fetching HTML for: ${url}`);
            const fetchPromise = (async () => {
                const res = await fetch(url);
                if (!res.ok) throw new Error(`HTTP ${res.status}`);
                const html = await res.text();

                // REGEX PARSING
                const urls = [];
                const tagRegex = /<[^>]+class=(["'])[^>]*\bpostImg\b[^>]*\1[^>]*>/gi;
                let match;

                while ((match = tagRegex.exec(html)) !== null) {
                    const titleMatch = match[0].match(/title=(["'])(.*?)\1/i);
                    if (titleMatch && titleMatch[2]) {
                        const extractedUrl = titleMatch[2].replace(/&amp;/g, '&');

                        // Prevent pushing JS regex templates like "$1" found in <script> blocks
                        if (!extractedUrl.startsWith("$")) {
                            urls.push(extractedUrl);
                        }
                    }
                }

                log(`[Network] Successfully parsed ${urls.length} image URLs for ${url}`);
                return urls;
            })().catch(err => {
                log(`[Network] Fetch failed for ${url}`, err);
                return [];
            });
            previewCache.set(url, fetchPromise);
        }
        return previewCache.get(url);
    }

    /* ================= DOM INJECTION & CAROUSEL ================= */
    function insertPreview(link, urls, isOwned, topicId) {
        const container = document.createElement("a");
        container.className = "preview-container";
        container.href = link.href;
        container.target = "_blank";
        container.rel = "noopener noreferrer";

        if (isOwned) {
            container.classList.add("owned-topic");
            const badge = document.createElement("div");
            badge.className = "owned-badge";
            badge.textContent = "OWNED";
            container.appendChild(badge);
        }

        if (!urls || urls.length === 0) {
            const span = document.createElement("span");
            span.className = "no-img-text";
            span.textContent = "No Eligible preview found";
            container.appendChild(span);
            link.after(container);
            return;
        }

        let validUrls = [...urls];
        let currentIndex = 0;

        const bgImg = document.createElement("img");
        bgImg.className = "bg-blur";
        //bgImg.loading = "lazy";

        const mainImg = document.createElement("img");
        mainImg.className = "main-img";
        //mainImg.loading = "lazy";

        const counter = document.createElement("div");
        counter.className = "img-counter";

        const updateView = (index) => {
            if (validUrls.length === 0) return;
            currentIndex = (index + validUrls.length) % validUrls.length;

            bgImg.src = validUrls[currentIndex];
            mainImg.src = validUrls[currentIndex];

            if (validUrls.length > 1) {
                counter.textContent = `${currentIndex + 1} / ${validUrls.length}`;
                counter.style.display = "block";
            } else {
                counter.style.display = "none";
            }
        };

        const prevBtn = document.createElement("button");
        prevBtn.className = "nav-arrow nav-prev";
        prevBtn.innerHTML = "&#10094;";

        const nextBtn = document.createElement("button");
        nextBtn.className = "nav-arrow nav-next";
        nextBtn.innerHTML = "&#10095;";

        const handleNavClick = (e, direction) => {
            e.preventDefault();
            e.stopPropagation();
            updateView(currentIndex + direction);
        };

        prevBtn.onclick = (e) => handleNavClick(e, -1);
        nextBtn.onclick = (e) => handleNavClick(e, 1);

        const handleImageError = () => {
            validUrls.splice(currentIndex, 1);

            if (validUrls.length > 0) {
                updateView(currentIndex >= validUrls.length ? 0 : currentIndex);
            } else {
                const span = document.createElement("span");
                span.className = "no-img-text";
                span.textContent = "No Eligible preview found";
                mainImg.replaceWith(span);
                bgImg.remove();
                prevBtn.remove();
                nextBtn.remove();
                counter.remove();
            }

            if (validUrls.length <= 1) {
                prevBtn.style.display = 'none';
                nextBtn.style.display = 'none';
            }
        };

        const handleImageLoad = () => {
            if (mainImg.naturalHeight < MIN_HEIGHT) handleImageError();
        };

        mainImg.addEventListener("error", handleImageError);
        mainImg.addEventListener("load", handleImageLoad);

        container.appendChild(bgImg);
        container.appendChild(mainImg);
        container.appendChild(counter);

        if (urls.length > 1) {
            container.appendChild(prevBtn);
            container.appendChild(nextBtn);
        }

        updateView(0);
        link.after(container);
    }

    /* ================= LAZY QUEUE PROCESSING ================= */
    const queue = [];
    let activeFetches = 0;

    async function processQueue() {
        if (activeFetches >= MAX_CONCURRENT_FETCHES || queue.length === 0) return;
        const link = queue.shift();
        activeFetches++;

        const url = buildUrl(link);
        const topicId = extractTopicId(url);

        try {
            const [urls, isOwned] = await Promise.all([
                fetchPreviewUrls(url),
                checkTopicInDb(topicId)
            ]);
            insertPreview(link, urls, isOwned, topicId);
        } catch (err) {
            const isOwned = await checkTopicInDb(topicId);
            insertPreview(link, [], isOwned, topicId);
        } finally {
            activeFetches--;
            processQueue();
        }
    }

    /* ================= INTERSECTION OBSERVER ================= */
    const observer = new IntersectionObserver((entries, obs) => {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                const link = entry.target;
                obs.unobserve(link);
                queue.push(link);
                processQueue();
            }
        });
    }, { rootMargin: LAZY_LOAD_MARGIN });

    const links = document.querySelectorAll(".tLink, .tt-text");
    links.forEach(link => observer.observe(link));

})();