// ==UserScript== // @name Mangaupdates Inline Covers (Fast & Smooth) // @namespace https://github.com/AJUNNYC/mangaupdates-inline-covers // @version 2.2 // @description Display series covers inline next to links with fast fetching and smooth rendering // @author Antonio Jun // @match https://www.mangaupdates.com/releases* // @license MIT // @grant none // ==/UserScript== /** * @file Mangaupdates Inline Covers userscript * @description Automatically fetches and displays manga/series cover images inline * next to series links on MangaUpdates release pages. Features persistent caching, * parallel fetching, and smart debouncing for optimal performance. * @version 2.2 * @license MIT * @author Antonio Jun */ (function() { 'use strict'; // ============================================================================ // Constants // ============================================================================ /** @const {string} Key for storing cover cache in localStorage */ const CACHE_KEY = 'mangaupdates_coverCache'; /** @const {string} Key for storing cache version in localStorage */ const CACHE_VERSION_KEY = 'mangaupdates_cacheVersion'; /** @const {string} Current cache version - increment to invalidate old cache */ const CURRENT_VERSION = '1.0'; /** @const {number} Maximum height for cover images in pixels */ const MAX_IMAGE_HEIGHT = 200; /** @const {number} Number of concurrent fetch requests allowed */ const CONCURRENCY_LIMIT = 8; /** @const {number} Debounce delay for mutation observer in milliseconds */ const DEBOUNCE_DELAY = 100; // ============================================================================ // Cache Management // ============================================================================ // Clear cache if version changes if (localStorage.getItem(CACHE_VERSION_KEY) !== CURRENT_VERSION) { localStorage.removeItem(CACHE_KEY); localStorage.setItem(CACHE_VERSION_KEY, CURRENT_VERSION); } /** * @type {Map} * @description Persistent cache mapping series URLs to cover image URLs */ let coverCache = new Map(JSON.parse(localStorage.getItem(CACHE_KEY) || '[]')); /** * @type {Map} * @description Tracks in-flight fetch requests to prevent duplicates */ const pendingFetches = new Map(); /** * Saves the current cache to localStorage * @function saveCache * @returns {void} */ function saveCache() { try { localStorage.setItem(CACHE_KEY, JSON.stringify(Array.from(coverCache.entries()))); } catch (e) { console.warn('Failed to save cache to localStorage:', e); } } // ============================================================================ // Cover Fetching // ============================================================================ /** * Fetches the cover image URL from a series page * @async * @function fetchCover * @param {string} url - The series page URL to fetch from * @returns {Promise} The cover image URL, or null if not found */ async function fetchCover(url) { // Return existing promise if already fetching if (pendingFetches.has(url)) { return pendingFetches.get(url); } const fetchPromise = (async () => { try { const res = await fetch(url); const text = await res.text(); const parser = new DOMParser(); const doc = parser.parseFromString(text, 'text/html'); const img = doc.querySelector('img[alt="Series Image"]'); return img ? img.src : null; } catch (err) { console.error("Error fetching cover for:", url, err); return null; } finally { pendingFetches.delete(url); } })(); pendingFetches.set(url, fetchPromise); return fetchPromise; } // ============================================================================ // DOM Manipulation // ============================================================================ /** * Inserts a cover image element next to a series link * @function insertCover * @param {HTMLAnchorElement} link - The series link element * @param {string} coverUrl - The cover image URL to insert * @returns {void} */ function insertCover(link, coverUrl) { if (!link || !coverUrl) return; // Check if already processed or if image already exists if (link.dataset.coverApplied) return; // Mark immediately to prevent duplicate processing link.dataset.coverApplied = 'true'; const img = new Image(); img.src = coverUrl; // Handle both success and failure img.onload = () => { // Double-check that an image wasn't already inserted const existingImg = link.previousElementSibling; if (existingImg && existingImg.tagName === 'IMG' && existingImg.src === coverUrl) { return; // Image already exists, don't insert again } const scale = MAX_IMAGE_HEIGHT / img.height; img.width = img.width * scale; img.height = MAX_IMAGE_HEIGHT; img.style.marginRight = '10px'; img.style.verticalAlign = 'middle'; img.style.border = '1px solid #ccc'; img.style.borderRadius = '4px'; img.style.display = 'inline-block'; link.parentNode.insertBefore(img, link); }; img.onerror = () => { console.warn("Failed to load image:", coverUrl); // Remove from cache if image fails to load coverCache.delete(link.href); saveCache(); // Clear the flag so it can be retried delete link.dataset.coverApplied; }; } // ============================================================================ // Link Processing // ============================================================================ /** * Processes an array of series links, fetching and inserting covers * @async * @function processLinksFast * @param {HTMLAnchorElement[]} links - Array of series link elements to process * @param {number} [concurrencyLimit=8] - Maximum number of concurrent fetches * @returns {Promise} */ async function processLinksFast(links, concurrencyLimit = CONCURRENCY_LIMIT) { if (links.length === 0) return; // Render cached covers immediately links.forEach(link => { const cached = coverCache.get(link.href); if (cached && !link.dataset.coverApplied) { insertCover(link, cached); } }); // Filter only uncached links const uncachedLinks = links.filter(link => !coverCache.has(link.href) && !link.dataset.coverApplied ); if (uncachedLinks.length === 0) return; // Process in parallel batches let index = 0; while (index < uncachedLinks.length) { const batch = uncachedLinks.slice(index, index + concurrencyLimit); await Promise.all(batch.map(async (link) => { const cover = await fetchCover(link.href); if (cover) { coverCache.set(link.href, cover); insertCover(link, cover); } })); index += concurrencyLimit; } // Save cache once after all batches complete saveCache(); } /** * Finds and processes all series links on the current page * @function processAllLinks * @returns {void} */ function processAllLinks() { const links = Array.from(document.querySelectorAll('a[title="Click for Series Info"]')); processLinksFast(links); } // ============================================================================ // Initialization // ============================================================================ // Initial processing processAllLinks(); // Observe container for React re-renders with debouncing const container = document.querySelector('div#release_container') || document.body; let debounceTimer; const observer = new MutationObserver(() => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { processAllLinks(); }, DEBOUNCE_DELAY); }); observer.observe(container, { childList: true, subtree: true }); })();