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);
  }

})();