LANraragi Library Checker for ExHentai

Check if ExHentai/E-Hentai galleries exist in your LANraragi library. Shows visual indicators on gallery thumbnails.

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         LANraragi Library Checker for ExHentai
// @namespace    https://github.com/troyt-666/exhentai-utilities
// @version      0.1.1
// @description  Check if ExHentai/E-Hentai galleries exist in your LANraragi library. Shows visual indicators on gallery thumbnails.
// @author       Troy T
// @homepageURL  https://github.com/troyt-666/exhentai-utilities
// @supportURL   https://github.com/troyt-666/exhentai-utilities/issues
// @match        https://exhentai.org/
// @match        https://exhentai.org/?*
// @match        https://exhentai.org/tag/*
// @match        https://exhentai.org/favorites.php*
// @match        https://e-hentai.org/
// @match        https://e-hentai.org/?*
// @match        https://e-hentai.org/tag/*
// @match        https://e-hentai.org/favorites.php*
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @grant        GM_listValues
// @grant        GM_deleteValue
// @connect      localhost
// @connect      *
// @license      MIT
// @icon         https://exhentai.org/favicon.ico
// ==/UserScript==

/*
 * LANraragi Library Checker for ExHentai
 * 
 * This userscript integrates ExHentai/E-Hentai with your LANraragi instance:
 * - Checks if galleries are already in your local library
 * - Shows visual indicators on gallery thumbnails
 * - Supports batch checking for better performance
 * - Caches results to reduce API calls
 * 
 * Part of the ExHentai Utilities toolkit:
 * https://github.com/troyt-666/exhentai-utilities
 * 
 * Configuration:
 * 1. Set your LANraragi server URL and API key below
 * 2. Install the script via Tampermonkey
 * 3. Browse ExHentai normally - indicators will appear automatically
 * 
 * Indicators:
 * - Green border: Gallery exists in your library
 * - Red border: Gallery not in library
 * - Yellow border: Similar gallery found (fuzzy match)
 * Disclaimer: 
 * - This script is not affiliated with LANraragi or ExHentai.
 * - It is a personal project and is not guaranteed to work with all LANraragi instances.
 * - The search is based on the Japanese title of the gallery, so false positives are possible if there are multiple galleries with the same titles.
 */

(function() {
    'use strict';

    console.log('=== LANraragi Checker Userscript Starting ===');
    console.log('Script URL:', GM_info.script.name);
    console.log('Script Version:', GM_info.script.version);

    // Configuration - Update these values
    const CONFIG = {
        lanraragiUrl: GM_getValue('lanraragi_url', 'http://localhost:3000'),
        apiKey: GM_getValue('lanraragi_api_key', ''),
        checkInterval: 1000, // Milliseconds between batch checks
        batchSize: 10, // Number of galleries to check at once
        cacheExpiry: 3600000, // 1 hour in milliseconds
        enableIndicators: true,
        enableTooltips: true,
        highlightNotInLibrary: GM_getValue('highlight_not_in_library', false), // Toggle for red highlighting
        // debugMode: true // Enable debug logging
        debugMode: false // Disable debug logging
    };

    console.log('LANraragi Checker: Script loaded with config:', CONFIG);

    // CSS styles for indicators - apply to thumbnail container
    GM_addStyle(`
        .gl3t.lanraragi-in-library {
            border: 3px solid #4CAF50 !important;
            box-shadow: 0 0 5px #4CAF50;
            box-sizing: border-box !important;
        }
        .gl3t.lanraragi-not-in-library {
            border: 3px solid #F44336 !important;
            box-shadow: 0 0 5px #F44336;
            box-sizing: border-box !important;
        }
        .gl3t.lanraragi-similar-exists {
            border: 3px solid #FF9800 !important;
            box-shadow: 0 0 5px #FF9800;
            box-sizing: border-box !important;
        }
        .gl3t.lanraragi-checking {
            opacity: 0.7;
            border: 3px dashed #2196F3 !important;
            box-sizing: border-box !important;
        }
        .lanraragi-tooltip {
            position: absolute;
            background: rgba(0,0,0,0.9);
            color: white;
            padding: 5px 10px;
            border-radius: 4px;
            font-size: 12px;
            z-index: 10000;
            pointer-events: none;
        }
        .lanraragi-config-panel {
            position: fixed;
            top: 10px;
            left: 10px;
            background: #333;
            color: white;
            padding: 15px;
            border-radius: 5px;
            z-index: 9999;
            display: none;
            box-shadow: 0 2px 10px rgba(0,0,0,0.5);
        }
        .lanraragi-config-panel input[type="text"],
        .lanraragi-config-panel input[type="password"] {
            width: 100%;
            margin: 5px 0;
            padding: 5px;
            background: #444;
            color: white;
            border: 1px solid #555;
            border-radius: 3px;
            box-sizing: border-box;
        }
        .lanraragi-config-panel label {
            display: block;
            margin: 10px 0 5px 0;
        }
        .lanraragi-config-panel label.checkbox-label {
            display: flex;
            align-items: center;
            margin: 10px 0;
        }
        .lanraragi-config-panel input[type="checkbox"] {
            width: auto;
            margin: 0 8px 0 0;
        }
        .lanraragi-config-toggle {
            position: fixed;
            bottom: 10px;
            left: 10px;
            background: #2196F3;
            color: white;
            padding: 10px;
            border-radius: 50%;
            cursor: pointer;
            z-index: 9998;
            font-size: 16px;
            box-shadow: 0 2px 5px rgba(0,0,0,0.3);
        }
    `);

    // Cache management
    const cache = {
        get: function(key) {
            const cached = GM_getValue(`cache_${key}`, null);
            if (cached && Date.now() - cached.timestamp < CONFIG.cacheExpiry) {
                console.log(`Cache hit for key: ${key}`, cached.data);
                return cached.data;
            }
            console.log(`Cache miss for key: ${key}`);
            return null;
        },
        set: function(key, data) {
            console.log(`Setting cache for key: ${key}`, data);
            GM_setValue(`cache_${key}`, {
                data: data,
                timestamp: Date.now()
            });
        },
        clear: function() {
            // Clear all cache entries created by this script
            if (typeof GM_listValues === 'function' && typeof GM_deleteValue === 'function') {
                const keys = GM_listValues();
                keys.forEach(key => {
                    if (key.startsWith('cache_')) {
                        GM_deleteValue(key);
                    }
                });
                console.log('Cache cleared via GM_deleteValue');
            } else {
                // Fallback for environments where GM_listValues / GM_deleteValue are not available
                console.warn('GM_listValues / GM_deleteValue not available, falling back to manual deletion');
                // Iterate over localStorage keys used by Tampermonkey ("<script id>_<key>")
                for (let i = 0; i < localStorage.length; i++) {
                    const key = localStorage.key(i);
                    if (key && key.includes('cache_')) {
                        localStorage.removeItem(key);
                    }
                }
            }
        }
    };

    // API functions
    const api = {
        searchByTitle: async function(title, galleryId = null) {
            console.log(`Searching for title: ${title}, gallery ID: ${galleryId}`);
            const cacheKey = galleryId ? `title_${title}_${galleryId}` : `title_${title}`;
            const cached = cache.get(cacheKey);
            if (cached !== null) {
                console.log(`Using cached result for: ${title}`);
                return cached;
            }

            try {
                // Build headers - only add Authorization if API key exists
                const headers = {};
                if (CONFIG.apiKey) {
                    headers['Authorization'] = `Bearer ${CONFIG.apiKey}`;
                }

                const searchUrl = `${CONFIG.lanraragiUrl}/api/search?filter=${encodeURIComponent(title)}`;
                console.log(`Making API request to: ${searchUrl}`);
                console.log(`Request headers:`, headers);
                
                const response = await gmFetch({
                    method: 'GET',
                    url: searchUrl,
                    headers: headers
                });
                
                console.log(`API response status: ${response.status}`);
                console.log(`API response text:`, response.responseText);

                const data = JSON.parse(response.responseText);
                console.log(`Parsed API response:`, data);
                console.log(`Archives found: ${data.data ? data.data.length : 0}`);
                
                const result = {
                    exists: false,
                    similar: false,
                    exactMatch: false,
                    archives: data.data || []
                };

                // Check for matches
                if (data.data && data.data.length > 0) {
                    // If we have a gallery ID, check for exact ID matches first
                    if (galleryId) {
                        const exactIdMatch = data.data.find(archive => {
                            const archiveId = extractGalleryIdFromFilename(archive.filename);
                            return archiveId === galleryId;
                        });
                        
                        if (exactIdMatch) {
                            console.log(`Found exact gallery ID match: ${galleryId}`);
                            result.exists = true;
                            result.exactMatch = true;
                        } else {
                            console.log(`Found title match but no gallery ID match for: ${galleryId}`);
                            result.similar = true;
                        }
                    } else {
                        // No gallery ID available, treat as exact match
                        result.exists = true;
                        result.exactMatch = true;
                    }
                } else {
                    // Check for similar titles if no match found
                    if (title.length > 10) {
                        const simplified = simplifyTitle(title);
                        const similarResponse = await gmFetch({
                            method: 'GET',
                            url: `${CONFIG.lanraragiUrl}/api/search?filter=${encodeURIComponent(simplified)}`,
                            headers: headers
                        });
                        const similarData = JSON.parse(similarResponse.responseText);
                        if (similarData.data && similarData.data.length > 0) {
                            result.similar = true;
                            result.archives = similarData.data;
                        }
                    }
                }

                cache.set(cacheKey, result);
                console.log(`Search result for "${title}":`, result);
                return result;
            } catch (error) {
                console.error(`LANraragi API error for title "${title}":`, error);
                console.error('Error details:', error.message, error.stack);
                return { exists: false, similar: false, exactMatch: false, error: true };
            }
        }
    };

    // Utility functions
    function gmFetch(options) {
        console.log('Making GM_xmlhttpRequest:', options.url);
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                ...options,
                onload: (response) => {
                    console.log('GM_xmlhttpRequest response received:', response.status);
                    resolve(response);
                },
                onerror: (error) => {
                    console.error('GM_xmlhttpRequest error:', error);
                    reject(error);
                }
            });
        });
    }

    function simplifyTitle(title) {
        // Remove common variations to find similar titles
        return title
            .replace(/\[.*?\]/g, '') // Remove brackets
            .replace(/\(.*?\)/g, '') // Remove parentheses
            .replace(/【.*?】/g, '') // Remove Japanese brackets
            .replace(/\s+/g, ' ') // Normalize spaces
            .trim();
    }

    function extractGalleryInfo(element) {
        // Extract title and other info from gallery element
        console.log('Extracting gallery info from element:', element);
        console.log('Element classes:', element.className);
        console.log('Element HTML:', element.innerHTML.substring(0, 200) + '...');
        
        // Look for the title element (which contains the text)
        const titleElement = element.querySelector('.glink, .gl3t a, .gl4t a');
        if (!titleElement) {
            console.log('No title element found with selectors: .glink, .gl3t a, .gl4t a');
            console.log('Available links in element:', element.querySelectorAll('a'));
            return null;
        }

        // Get the link - if titleElement is a div.glink, find its parent <a>
        let linkElement;
        if (titleElement.tagName === 'DIV' && titleElement.classList.contains('glink')) {
            linkElement = titleElement.closest('a');
        } else {
            linkElement = titleElement;
        }

        if (!linkElement || !linkElement.href) {
            console.log('No valid link element found');
            return null;
        }

        const info = {
            title: titleElement.textContent.trim(),
            element: element,
            link: linkElement.href,
            galleryId: extractGalleryId(linkElement.href)
        };
        console.log('Extracted gallery info:', info);
        return info;
    }

    function extractGalleryId(url) {
        // Extract gallery ID from ExHentai URL like https://exhentai.org/g/1560600/21011d7fbf/
        if (!url) return null;
        const match = url.match(/\/g\/(\d+)\//);
        return match ? match[1] : null;
    }

    function extractGalleryIdFromFilename(filename) {
        // Extract gallery ID from H@H archive filename like [author] title [1560600]
        // Earlier versions expected 7-digit IDs, but ExHentai gallery IDs can be fewer
        // (e.g. 6-digit 590775). Relax the requirement to 5 or more digits to avoid
        // missing valid matches while still ignoring small numbers such as chapter
        // counts or years (usually 1-4 digits).
        if (!filename) return null;
        const match = filename.match(/\[(\d{5,})\]/);
        return match ? match[1] : null;
    }

    function applyIndicator(element, status, archives = []) {
        console.log(`Applying indicator "${status}" to element:`, element);
        
        // Find the thumbnail container within the gallery element
        // Look for .gl3t, which specifically holds the thumbnail image.
        let thumbnailContainer = element.querySelector('.gl3t');
        
        // If we can't find a thumbnail container, apply to the element itself
        if (!thumbnailContainer) {
            thumbnailContainer = element;
        }
        
        // Remove existing indicators from BOTH the main element and thumbnail container
        // This prevents conflicting classes from previous checks
        const classesToRemove = ['lanraragi-in-library', 'lanraragi-not-in-library', 
                                'lanraragi-similar-exists', 'lanraragi-checking'];
        
        // Clear from main gallery element
        element.classList.remove(...classesToRemove);
        
        // Clear from thumbnail container
        thumbnailContainer.classList.remove(...classesToRemove);
        
        // Also clear from any child elements that might have these classes
        element.querySelectorAll('.lanraragi-in-library, .lanraragi-not-in-library, .lanraragi-similar-exists, .lanraragi-checking')
            .forEach(el => el.classList.remove(...classesToRemove));

        // Apply new indicator to the thumbnail container only
        switch (status) {
            case 'exists':
                thumbnailContainer.classList.add('lanraragi-in-library');
                if (CONFIG.enableTooltips) {
                    thumbnailContainer.title = 'Already in LANraragi library';
                }
                break;
            case 'similar':
                thumbnailContainer.classList.add('lanraragi-similar-exists');
                if (CONFIG.enableTooltips) {
                    thumbnailContainer.title = 'Similar title exists in library (gallery ID mismatch)';
                    const titleElement = element.querySelector('.glink');
                    if (titleElement && archives.length > 0) {
                        const archiveTitles = archives.map(archive => archive.title).join('\n');
                        titleElement.title = `Found similar:\n${archiveTitles}`;
                    }
                }
                break;
            case 'not-found':
                if (CONFIG.highlightNotInLibrary) {
                    thumbnailContainer.classList.add('lanraragi-not-in-library');
                    if (CONFIG.enableTooltips) {
                        thumbnailContainer.title = 'Not in LANraragi library';
                    }
                }
                break;
            case 'checking':
                thumbnailContainer.classList.add('lanraragi-checking');
                break;
        }
    }

    let isChecking = false;
    async function checkGalleries() {
        if (isChecking) return;
        isChecking = true;
        try {
            console.log('Starting gallery check...');
            console.log('Page body exists:', !!document.body);
            console.log('Document title:', document.title);
            
            // Debug: Check what elements exist on the page
            console.log('Debug - Looking for gallery containers...');
            console.log('Elements with class "gl1t":', document.querySelectorAll('.gl1t').length);
            console.log('Elements with class "gl3t":', document.querySelectorAll('.gl3t').length); 
            console.log('Elements with class "gl4t":', document.querySelectorAll('.gl4t').length);
            console.log('Elements with class "id1":', document.querySelectorAll('.id1').length);
            console.log('Elements with class "itg":', document.querySelectorAll('.itg').length);
            console.log('Elements with class "gl1e":', document.querySelectorAll('.gl1e').length);
            
            // Find all gallery items on the page
            const galleries = document.querySelectorAll('.gl1t, .id1');
            console.log(`Found ${galleries.length} gallery elements on page`);
            const uncheckedGalleries = [];

            galleries.forEach((gallery, index) => {
                console.log(`Processing gallery ${index + 1}/${galleries.length}`);
                if (!gallery.dataset.lanraragiChecked) {
                    const info = extractGalleryInfo(gallery);
                    if (info) {
                        uncheckedGalleries.push(info);
                        applyIndicator(gallery, 'checking');
                    }
                }
            });

            console.log(`Found ${uncheckedGalleries.length} unchecked galleries to process`);
            
            // Process in batches
            for (let i = 0; i < uncheckedGalleries.length; i += CONFIG.batchSize) {
                const batch = uncheckedGalleries.slice(i, i + CONFIG.batchSize);
                console.log(`Processing batch ${Math.floor(i/CONFIG.batchSize) + 1}, galleries ${i + 1}-${Math.min(i + CONFIG.batchSize, uncheckedGalleries.length)}`);
                
                await Promise.all(batch.map(async (galleryInfo) => {
                    console.log(`Checking gallery: "${galleryInfo.title}" with ID: ${galleryInfo.galleryId}`);
                    const result = await api.searchByTitle(galleryInfo.title, galleryInfo.galleryId);
                    
                    if (result.error) {
                        console.error(`Error checking gallery "${galleryInfo.title}"`);
                        // API error - remove indicator
                        galleryInfo.element.classList.remove('lanraragi-checking');
                    } else if (result.exists && result.exactMatch) {
                        applyIndicator(galleryInfo.element, 'exists');
                    } else if (result.similar) {
                        applyIndicator(galleryInfo.element, 'similar', result.archives);
                    } else {
                        applyIndicator(galleryInfo.element, 'not-found');
                    }
                    
                    galleryInfo.element.dataset.lanraragiChecked = 'true';
                }));

                // Wait before next batch to avoid overloading
                if (i + CONFIG.batchSize < uncheckedGalleries.length) {
                    await new Promise(resolve => setTimeout(resolve, CONFIG.checkInterval));
                }
            }
        } finally {
            isChecking = false;
        }
    }

    // Configuration panel
    function createConfigPanel() {
        const panel = document.createElement('div');
        panel.className = 'lanraragi-config-panel';
        panel.innerHTML = `
            <h3>LANraragi Configuration</h3>
            <label>Server URL:</label>
            <input type="text" id="lanraragi-url" value="${CONFIG.lanraragiUrl}" />
            <label>API Key (optional):</label>
            <input type="password" id="lanraragi-api-key" value="${CONFIG.apiKey}" placeholder="Leave blank if not required" />
            <label class="checkbox-label">
                <input type="checkbox" id="lanraragi-highlight-toggle" ${CONFIG.highlightNotInLibrary ? 'checked' : ''}>
                Highlight galleries not in library with red border
            </label>
            <button id="lanraragi-save">Save</button>
            <button id="lanraragi-test">Test Connection</button>
            <button id="lanraragi-clear-cache">Clear Cache</button>
            <div id="lanraragi-status"></div>
        `;

        document.body.appendChild(panel);

        // Toggle button
        const toggle = document.createElement('div');
        toggle.className = 'lanraragi-config-toggle';
        toggle.innerHTML = '🔧';
        toggle.title = 'LANraragi Settings';
        toggle.addEventListener('click', () => {
            panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
        });
        document.body.appendChild(toggle);

        // Event handlers
        document.getElementById('lanraragi-save').addEventListener('click', () => {
            CONFIG.lanraragiUrl = document.getElementById('lanraragi-url').value;
            CONFIG.apiKey = document.getElementById('lanraragi-api-key').value;
            CONFIG.highlightNotInLibrary = document.getElementById('lanraragi-highlight-toggle').checked;
            GM_setValue('lanraragi_url', CONFIG.lanraragiUrl);
            GM_setValue('lanraragi_api_key', CONFIG.apiKey);
            GM_setValue('highlight_not_in_library', CONFIG.highlightNotInLibrary);
            document.getElementById('lanraragi-status').textContent = 'Settings saved!';
            cache.clear();
            setTimeout(() => location.reload(), 1000);
        });

        document.getElementById('lanraragi-test').addEventListener('click', async () => {
            const status = document.getElementById('lanraragi-status');
            status.textContent = 'Testing connection...';
            
            try {
                // Build headers - only add Authorization if API key exists
                const headers = {};
                if (CONFIG.apiKey) {
                    headers['Authorization'] = `Bearer ${CONFIG.apiKey}`;
                }

                const response = await gmFetch({
                    method: 'GET',
                    url: `${CONFIG.lanraragiUrl}/api/info`,
                    headers: headers
                });
                
                if (response.status === 200) {
                    const authStatus = CONFIG.apiKey ? ' (with API key)' : ' (no API key)';
                    status.textContent = '✓ Connection successful' + authStatus + '!';
                    status.style.color = '#4CAF50';
                } else {
                    status.textContent = '✗ Connection failed!';
                    status.style.color = '#F44336';
                }
            } catch (error) {
                status.textContent = '✗ Connection error!';
                status.style.color = '#F44336';
            }
        });

        document.getElementById('lanraragi-clear-cache').addEventListener('click', () => {
            cache.clear();
            document.getElementById('lanraragi-status').textContent = 'Cache cleared!';
            setTimeout(() => location.reload(), 1000);
        });
    }

    // Main initialization
    function init() {
        console.log('=== LANraragi Checker Initializing ===');
        console.log('Current URL:', window.location.href);
        console.log('Config:', CONFIG);
        
        if (!CONFIG.apiKey) {
            console.log('LANraragi Checker: Running without API key. Some LANraragi instances may require authentication.');
        } else {
            console.log('LANraragi Checker: API key is configured');
        }

        console.log('Creating config panel...');
        createConfigPanel();

        // Initial check - works with or without API key
        if (CONFIG.enableIndicators) {
            console.log('Indicators enabled, starting initial gallery check...');
            checkGalleries();
        } else {
            console.log('Indicators disabled, skipping gallery check');
        }

        // Monitor for dynamically loaded content
        let timeout;
        const observer = new MutationObserver(() => {
            clearTimeout(timeout);
            timeout = setTimeout(checkGalleries, 400);  // run at most once every 400 ms
        });

        const container = document.querySelector('.itg');
        if (container) observer.observe(container, {childList: true});

        // Debug mode
        if (CONFIG.debugMode) {
            console.log('LANraragi Checker initialized', CONFIG);
        }
    }

    // Wait for page to load
    console.log('Document ready state:', document.readyState);
    if (document.readyState === 'loading') {
        console.log('Waiting for DOMContentLoaded...');
        document.addEventListener('DOMContentLoaded', init);
    } else {
        console.log('DOM already loaded, initializing immediately...');
        init();
    }
})();