Sleazy Fork is available in English.

Pornolab Preloaded Preview

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

// ==UserScript==
// @name         Pornolab Preloaded Preview
// @version      1.6.3
// @description  Preloads and dynamically displays preview images below links, avoiding duplicate previews. 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*
// @run-at       document-end
// @require      https://cdnjs.cloudflare.com/ajax/libs/core-js/3.25.1/minified.js
// @grant        none
// @namespace https://greasyfork.org/users/1418199
// ==/UserScript==

(async function () {

  // Define the size for the preview image containers
  const previewSize = {
    width: "auto", // Auto width, so the image adjusts dynamically
    height: "17rem", // Fixed height for the preview image container
  };

  // Set to true for detailed logging, false to suppress debug output
  const debug = false;

  // To track the links whose previews are currently being fetched
  const inProgressLinks = new Set();

  // To track the links that failed to load previews
  const failedLinks = new Map();

  // Maximum retry attempts for fetching image URLs
  const MAX_RETRIES = 3;

  // Throttle interval in milliseconds to limit IntersectionObserver callback frequency
  const THROTTLE_INTERVAL = 300;

  // Maximum number of concurrent fetches allowed for image URLs
  const MAX_CONCURRENT_FETCHES = 5;

  // Count the current number of ongoing fetches
  let concurrentFetchCount = 0;

  // Function to log debug or warning messages based on the 'debug' flag
  function debugLog(type, ...args) {
    if (debug) {
      const prefix = "pornolabPreloadedPreview";
      if (type === "warn") {
        console.warn(`${prefix} [WARN]:`, ...args);
      } else if (type === "log") {
        console.log(`${prefix} [DEBUG]:`, ...args);
      }
    }
  }

  try {
    // Inject custom styles for the preview image containers dynamically
    const style = document.createElement("style");
    style.textContent = `
        .preview-container {
            max-width: ${previewSize.width};
            height: ${previewSize.height};
            display: flex;
            justify-content: center;
            align-items: center;
            overflow: hidden;
        }
        .preview-container img {
            width: 100%;
            height: 100%;
            object-fit: scale-down;
        }
    `;
    document.head.appendChild(style);

    // Log success in injecting styles
    debugLog("log", "Styles injected successfully.");
  } catch (error) {
    console.error("pornolabPreloadedPreview: Failed to inject styles:", error);
  }

  // Cache for storing previously fetched preview URLs
  const previewCache = new Map();

  // Function to fetch the preview images' URLs with retry mechanism
  async function fetchWithRetry(url, retries = 0) {
    debugLog("log", `Attempting to fetch URL: ${url}. Retry count: ${retries}`);
    try {
      const response = await fetch(url);
      if (!response.ok) {
        console.error(
          `pornolabPreloadedPreview: Fetch failed for ${url} (${response.statusText}). Retry: ${retries}`
        );
        if (retries < MAX_RETRIES) {
          return fetchWithRetry(url, retries + 1); // Retry fetching if the request fails
        } else {
          failedLinks.set(url, "Failed after retries"); // Mark as failed after max retries
          debugLog("log", `Max retries reached for URL: ${url}`);
          return [];
        }
      }

      const html = await response.text(); // Get the page content as text
      const doc = new DOMParser().parseFromString(html, "text/html"); // Parse the page content as HTML

      const imgElements = doc.querySelectorAll(".postImg"); // Find all image elements
      const imgUrls = Array.from(imgElements)
        .map((img) => img.title) // Extract image URLs from the 'title' attribute
        .filter(Boolean); // Remove any empty values

      debugLog("log", `Fetched ${imgUrls.length} image URLs for ${url}.`);
      if (imgUrls.length === 0) {
        debugLog("warn", `No images found for URL ${url}`);
        failedLinks.set(url, "No images found"); // Mark as failed if no images are found
      }

      return imgUrls;
    } catch (error) {
      console.error(`pornolabPreloadedPreview: Fetch error for ${url}:`, error);
      if (retries < MAX_RETRIES) {
        return fetchWithRetry(url, retries + 1); // Retry fetching if an error occurs
      } else {
        failedLinks.set(url, "Failed after retries"); // Mark as failed after max retries
        debugLog("log", `Max retries reached for URL: ${url}`);
        return [];
      }
    }
  }

  // Function to check cache or fetch preview URLs from a link element
  async function getCachedOrFetchPreviewUrls(linkElement) {
    const url = "https://pornolab.net/forum" + linkElement.getAttribute("href").slice(1);

    debugLog("log", `Processing link: ${url}`);
    if (previewCache.has(url)) {
      debugLog("log", `Cache hit for URL: ${url}`);
      return Promise.resolve(previewCache.get(url)); // Return from cache if available
    }

    if (inProgressLinks.has(url)) {
      debugLog("warn", `Fetch already in progress for ${url}`);
      return Promise.resolve([]); // Skip fetch if already in progress
    }

    if (failedLinks.has(url)) {
      debugLog("warn", `Skipping failed link ${url}`);
      return Promise.resolve([]); // Skip fetch if already failed
    }

    inProgressLinks.add(url); // Mark this link as being processed
    debugLog("log", `Starting fetch for URL: ${url}`);
    
    const delay = () => new Promise(resolve => setTimeout(resolve, 100));

    // Wait for an available slot for fetching if we exceed the max concurrent fetches
    while (concurrentFetchCount >= MAX_CONCURRENT_FETCHES) {
      await delay(); // Wait 100ms before checking again
    }
    
    concurrentFetchCount++; // Increment concurrent fetch counter

    try {
      const result = await fetchWithRetry(url); // Fetch the preview URLs
      previewCache.set(url, result); // Cache the result for future use
      return result;
    } finally {
      concurrentFetchCount--; // Decrement counter once fetch is completed
      inProgressLinks.delete(url); // Mark this link as no longer in progress
    }
  }

  // Function to insert the preview image below the link
  function insertPreview(linkElement, imgUrls) {
    try {
      if (!imgUrls || imgUrls.length === 0) {
        debugLog("warn", "No preview URLs available for link:", linkElement);
        return;
      }

      if (linkElement.hasAttribute("data-preview-processed")) {
        debugLog("warn", "Link already processed:", linkElement);
        return; // Skip if the preview has already been processed
      }
      linkElement.setAttribute("data-preview-processed", "true");

      const existingPreview =
        linkElement.nextElementSibling?.classList.contains("preview-container");
      if (existingPreview) {
        debugLog("warn", "Preview already exists for link:", linkElement);
        return; // Skip if a preview already exists for this link
      }

      // Create preview container element
      const previewContainer = document.createElement("div");
      previewContainer.className = "preview-container";

      // Create image element for the preview
      const img = document.createElement("img");
      img.src = imgUrls[0]; // Start with the first image URL
      img.loading = "lazy"; // Lazy load the image

      // Event listener for error when the image fails to load
      img.addEventListener("error", function () {
        console.error(`pornolabPreloadedPreview: Image failed to load: ${img.src}`);
        imgUrls.shift(); // Remove the failed URL from the list
        if (imgUrls.length > 0) {
          img.src = imgUrls[0]; // Try the next image URL
        } else {
          debugLog("warn", "No more URLs to try for link:", linkElement);
          img.remove(); // Remove the image if no URLs are left
        }
      });

      // Event listener to check if the image is too small (less than 201px height)
      img.addEventListener("load", function () {
        if (img.naturalHeight < 201) {
          debugLog("warn", `Image too small: ${img.src}`);
          imgUrls.shift(); // Remove small image from the list
          if (imgUrls.length > 0) {
            img.src = imgUrls[0]; // Try the next image URL
          } else {
            debugLog("warn", "No more valid URLs for link:", linkElement);
            img.remove(); // Remove the image if no valid URLs are left
          }
        }
      });

      // Append image to preview container
      previewContainer.appendChild(img);

      // Insert preview container after the link
      linkElement.parentNode.insertBefore(previewContainer, linkElement.nextSibling);

      // Adjust styles for the link container
      linkElement.parentNode.style.cssText = `
          padding: 1rem;
          display: flex;
          justify-content: space-between;
      `;
      linkElement.style.cssText = `
          width: 30%;
      `;

      debugLog("log", `Preview inserted for link: ${linkElement}`);
    } catch (error) {
      console.error("pornolabPreloadedPreview: Error inserting preview for link:", linkElement, error);
    }
  }

  // Function to preload previews for all visible links
  const preloadPreviews = async function () {
    try {
      const links = document.querySelectorAll(".tLink, .tt-text"); // Select all relevant links
      const tasks = Array.from(links).map((link) => async () => {
        const imgUrls = await getCachedOrFetchPreviewUrls(link); // Fetch or get from cache
        insertPreview(link, imgUrls); // Insert the preview image
      });

      debugLog("log", `Starting preload for ${tasks.length} links.`);
      await preloadAll(tasks, 5); // Limit to 5 concurrent requests for performance
    } catch (error) {
      console.error("pornolabPreloadedPreview: Error in preloadPreviews:", error);
    }
  };

  // Function to execute tasks in batches with a concurrency limit
  async function preloadAll(tasks, limit = 5) {
    const results = [];
    try {
      while (tasks.length) {
        const batch = tasks.splice(0, limit).map((task) => task());
        results.push(...(await Promise.allSettled(batch)));
      }
    } catch (error) {
      console.error("pornolabPreloadedPreview: Error during batch processing:", error);
    }
    return results;
  }

  // Throttling function to limit the rate of IntersectionObserver callbacks
  function throttle(func, limit) {
    let lastFunc;
    let lastRan;
    return function (...args) {
      const context = this;
      if (!lastRan) {
        func.apply(context, args); // Execute immediately if no previous call
        lastRan = Date.now();
      } else {
        clearTimeout(lastFunc);
        lastFunc = setTimeout(() => {
          if (Date.now() - lastRan >= limit) {
            func.apply(context, args); // Execute once the limit time has passed
            lastRan = Date.now();
          }
        }, limit - (Date.now() - lastRan));
      }
    };
  }

  try {
    // Throttled IntersectionObserver callback function
    const throttledObserverCallback = throttle((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) { // If link is in view
          const link = entry.target;
          preloadPreviews()
            .then(() => observer.unobserve(link)) // Unobserve link after preload
            .catch((error) => {
              console.error("pornolabPreloadedPreview: Error observing link:", link, error);
              observer.observe(link); // Reobserve link in case of error
            });
        }
      });
    }, THROTTLE_INTERVAL); // Throttle interval set to 300ms

    // Initialize IntersectionObserver to monitor the visibility of links
    const observer = new IntersectionObserver(throttledObserverCallback);

    document.querySelectorAll(".tLink, .tt-text").forEach((link) => {
      observer.observe(link); // Observe each relevant link
    });

    // MutationObserver to handle dynamically added links
    const mutationObserver = new MutationObserver(() => {
      try {
        document.querySelectorAll(".tLink, .tt-text").forEach((link) => {
          if (!link.hasAttribute("data-preview-processed")) {
            observer.observe(link); // Observe newly added links
          }
        });
      } catch (error) {
        console.error("pornolabPreloadedPreview: Error in mutation observer:", error);
      }
    });

    mutationObserver.observe(document.body, {
      childList: true,
      subtree: true,
    });
  } catch (error) {
    console.error("pornolabPreloadedPreview: Error in observer setup:", error);
  }

})();