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.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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));

})();