E-Hentai Batch Downloader

Download all galleries from a search results page or single galleries on E-Hentai/ExHentai sequentially with optimized ZIP creation

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 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         E-Hentai Batch Downloader
// @version      1.3.3  
// @description  Download all galleries from a search results page or single galleries on E-Hentai/ExHentai sequentially with optimized ZIP creation
// @author       luka_m
// @license      MIT 
// @match        https://e-hentai.org/*
// @match        https://exhentai.org/*
// @match        https://g.e-hentai.org/*
// @match        https://r.e-hentai.org/*
// @match        http://e-hentai.org/*
// @match        http://exhentai.org/*
// @namespace    http://ext.ccloli.com
// @supportURL   https://github.com/lukamahariel/E-Hentai-Batch-Downloader
// @connect      e-hentai.org
// @connect      exhentai.org
// @connect      exhentai55ld2wyap5juskbm67czulomrouspdacjamjeloj7ugjbsad.onion
// @connect      hath.network
// @connect      api.e-hentai.org
// @connect      *
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @grant        GM_info
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.xmlHttpRequest
// @grant        GM.info
// @require      https://unpkg.com/[email protected]/umd/index.js
// ==/UserScript==

'use strict';
console.log('[EHD Batch] E-Hentai Batch Downloader v1.3.3 running on Violentmonkey.');

// Violentmonkey compatibility
var GM = window.GM || {};
var loadSetting = function(key, init) {
  if (GM.getValue) {
    return GM.getValue(key, init);
  } else {
    return Promise.resolve(GM_getValue(key, init));
  }
};
var GM_setValue = GM.setValue || GM_setValue;
var GM_xmlhttpRequest = GM.xmlHttpRequest || GM_xmlhttpRequest;
var GM_info = GM.info || GM_info ||
  { scriptHandler: 'Violentmonkey', version: 'unknown' };

// Browser check
if (navigator.userAgent.includes('Trident')) {
  alert('Internet Explorer is not supported. Please use a modern browser.');
  throw new Error('[EHD] IE not supported.');
}

// Define ehDownloadArrow
var ehDownloadArrow = '<img src="data:image/gif;base64,R0lGODlhBQAHALMAAK6vr7OztK+urra2tkJCQsDAwEZGRrKyskdHR0FBQUhISP///wAAAAAAAAAAAAAAACH5BAEAAAsALAAAAAAFAAcAAAQUUI1FlREVpbOUSkTgbZ0CUEhBLREAOw==">';

// Detect page type
var isGalleryPage = location.pathname.startsWith('/g/');
var host = location.hostname;
if (host === 'exhentai.org') host = 'e-hentai.org';

// Global variables
var isDownloading = false, isPausing = false;
var galleries = [], currentGalleryIndex = 0;
var setting = { 'ignore-torrent': true, 'cache-enabled': true };
var imageList = [], imageData = [], retryCount = [], pageURLsList = [], imageURLsList = [];
var failedCount = 0, downloadedCount = 0, fetchCount = 0, totalCount = 0;
var galleryTitle, subTitle, category, uploader;
var statusContainer, progressBar, batchProgressBar;

// Initialize settings with cache support
function initSetting() {
  loadSetting('ehD-setting', '{}').then(data => {
    try {
      if (data && typeof data === 'string' && data.trim().startsWith('{')) {
        setting = JSON.parse(data);
      } else {
        setting = { 'ignore-torrent': true, 'cache-enabled': true };
        GM_setValue('ehD-setting', JSON.stringify(setting));
      }
      console.log('[EHD Batch] Settings loaded:', setting);
    } catch (e) {
      console.error('[EHD Batch] Error parsing settings:', e, 'Data:', data);
      setting = { 'ignore-torrent': true, 'cache-enabled': true };
      GM_setValue('ehD-setting', JSON.stringify(setting));
    }
  });
}

// Batch download for search pages
if (!isGalleryPage) {
  console.log('[EHD Batch] Detected search page:', location.href);
  var ehBatchDownloadBox = document.createElement('fieldset');
  ehBatchDownloadBox.className = 'ehD-box';
  var style = document.createElement('style');
  style.textContent = `
    .ehD-box { border: 1px solid #5C0D12; padding: 15px; margin: 15px; background: #f8f8f8; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
    .ehD-box legend { font-weight: bold; color: #5C0D12; font-size: 1.2em; padding: 0 10px; }
    .ehD-box .g2 { margin: 10px 0; display: flex; align-items: center; }
    .ehD-box .g2 a { cursor: pointer; color: #5C0D12; text-decoration: none; font-weight: bold; margin-left: 5px; }
    .ehD-box .g2 a:hover { text-decoration: underline; }
    .ehD-status { margin-top: 10px; }
    .ehD-status p { margin: 5px 0; font-size: 1em; color: #333; }
    .ehD-status button { margin: 5px 5px 5px 0; padding: 8px 15px; cursor: pointer; border: none; border-radius: 4px; color: white; font-weight: bold; transition: background 0.3s; }
    .ehD-status button:disabled { cursor: not-allowed; opacity: 0.5; }
    .ehD-status #ehD-pause { background: #e67e22; }
    .ehD-status #ehD-pause:hover { background: #d35400; }
    .ehD-status #ehD-resume { background: #27ae60; }
    .ehD-status #ehD-resume:hover { background: #219a52; }
    .ehD-status #ehD-cancel { background: #c0392b; }
    .ehD-status #ehD-cancel:hover { background: #a93226; }
    .ehD-status #ehD-skip { background: #2980b9; }
    .ehD-status #ehD-skip:hover { background: #2473a6; }
    .ehD-progress { margin: 10px 0; }
    .ehD-progress label { display: block; margin-bottom: 5px; font-size: 0.9em; color: #555; }
    .ehD-progress progress { width: 100%; height: 10px; border-radius: 5px; }
  `;
  ehBatchDownloadBox.appendChild(style);
  var ehBatchDownloadBoxTitle = document.createElement('legend');
  ehBatchDownloadBoxTitle.innerHTML = 'E-Hentai Batch Downloader';
  ehBatchDownloadBox.appendChild(ehBatchDownloadBoxTitle);

  var ehBatchDownloadAction = document.createElement('div');
  ehBatchDownloadAction.className = 'g2';
  ehBatchDownloadAction.innerHTML = ehDownloadArrow + ' <a>Download All Galleries in This Page</a>';
  ehBatchDownloadAction.addEventListener('click', function(event) {
    event.preventDefault();
    console.log('[EHD Batch] Batch download button clicked.');
    startBatchDownload();
  });
  ehBatchDownloadBox.appendChild(ehBatchDownloadAction);

  statusContainer = document.createElement('div');
  statusContainer.className = 'ehD-status';
  statusContainer.innerHTML = '<p id="ehD-status-text">Status: Idle</p>';

  batchProgressBar = document.createElement('div');
  batchProgressBar.className = 'ehD-progress';
  batchProgressBar.innerHTML = '<label>Batch Progress:</label><progress id="ehD-batch-progress" value="0" max="100"></progress>';
  statusContainer.appendChild(batchProgressBar);

  progressBar = document.createElement('div');
  progressBar.className = 'ehD-progress';
  progressBar.innerHTML = '<label>Current Gallery Progress:</label><progress id="ehD-progress" value="0" max="100"></progress>';
  statusContainer.appendChild(progressBar);

  ehBatchDownloadBox.appendChild(statusContainer);
  var insertPoint = document.querySelector('#searchbox, .searchnav, .itg') || document.body;
  insertPoint.parentNode.insertBefore(ehBatchDownloadBox, insertPoint);
  console.log('[EHD Batch] Batch download box inserted before:', insertPoint);

  function startBatchDownload() {
    if (isDownloading && !confirm('A download is in progress. Stop and start batch?')) return;
    isDownloading = false;
    isPausing = false;
    galleries = [];
    currentGalleryIndex = 0;

    const galleryMap = new Map();
    const selectors = [
      '.glname a',
      '.itg a',
      '.id1 a',
      '.gld a',
      'a[href*="/g/"]'
    ];
    Array.from(document.querySelectorAll(selectors.join(','))).forEach(a => {
      var match = a.href.match(/\/g\/(\d+)\/([a-f0-9]+)\//i);
      if (match) {
        const gid = parseInt(match[1]);
        if (!galleryMap.has(gid)) {
          galleryMap.set(gid, {
            gid,
            token: match[2],
            title: a.textContent.trim() || 'Untitled_' + gid
          });
        }
      }
    });
    galleries = Array.from(galleryMap.values());

    console.log('[EHD Batch] Found', galleries.length, 'unique galleries:', galleries);
    if (galleries.length === 0) {
      alert('No galleries found. Try switching to Extended or Thumbnail view mode. Check console for details.');
      console.error('[EHD Batch] No galleries found. Tried selectors:', selectors);
      updateStatus('Error: No galleries found. Try switching to Extended or Thumbnail view.');
      return;
    }

    updateStatus(`Starting sequential download for ${galleries.length} galleries`);
    updateBatchProgress();
    isDownloading = true;
    fetchGalleryMetadata(currentGalleryIndex);
  }

  function fetchGalleryMetadata(index) {
    console.log('[EHD Batch] Entering fetchGalleryMetadata for index:', index);
    if (index >= galleries.length || !isDownloading) {
      if (isDownloading) {
        console.log('[EHD Batch] All galleries processed.');
        alert('Batch download complete.');
        updateStatus('Batch download complete.');
        isDownloading = false;
      }
      return;
    }

    // Check cache first
    const cacheKey = `ehD-cache-${galleries[index].gid}`;
    loadSetting(cacheKey, null).then(cached => {
      if (cached && setting['cache-enabled']) {
        try {
          const metadata = JSON.parse(cached);
          // Validate it's actual metadata
          if (metadata.gid && metadata.gid === galleries[index].gid) {
            galleries[index].metadata = metadata;
            console.log('[EHD Batch] Loaded valid cached metadata for gallery', galleries[index].gid);
            updateStatus(`Loaded cached metadata for "${galleries[index].title}"`);
            downloadNextGallery();
            return;
          } else {
            console.warn('[EHD Batch] Cache mismatch for', galleries[index].gid, 'Expected gid:', galleries[index].gid, 'Got:', metadata.gid);
          }
        } catch (e) {
          console.warn('[EHD Batch] Invalid cache for', galleries[index].gid, ':', e);
        }
      }

      // Batch API request
      const batch = galleries.slice(index, index + API_BATCH_SIZE).map(gal => [gal.gid, gal.token]);
      console.log('[EHD Batch] Fetching metadata for', batch.length, 'galleries starting at index', index, 'Batch:', batch);
      updateStatus(`Fetching metadata for ${batch.length} galleries starting with "${galleries[index].title}"`);

      GM_xmlhttpRequest({
        method: 'POST',
        url: 'https://api.e-hentai.org/api.php',
        data: JSON.stringify({
          method: 'gdata',
          gidlist: batch,
          namespace: 1
        }),
        headers: { 
          'Content-Type': 'application/json',
          'Cookie': document.cookie  // Add session cookies for potential auth
        },
        timeout: 30000,  // 30 seconds timeout
        onprogress: function(res) {
          if (res.lengthComputable) {
            console.log('[EHD Batch] API upload progress:', res.loaded, '/', res.total);
          }
        },
        onload: function(res) {
          console.log('[EHD Batch] API response received, status:', res.status, 'Length:', res.responseText.length);
          if (!isDownloading) return;
          if (res.status !== 200) {
            console.error('[EHD Batch] API error for batch starting at', galleries[index].gid, ':', res);
            alert(`API request failed for batch starting at ${galleries[index].title}.`);
            updateStatus(`Error: API request failed for batch. Skipping.`);
            currentGalleryIndex += batch.length;
            setTimeout(() => fetchGalleryMetadata(currentGalleryIndex), 1000);
            return;
          }
          let data;
          try {
            data = JSON.parse(res.responseText);
          } catch (e) {
            console.error('[EHD Batch] Parse error for batch:', e, 'Response:', res.responseText);
            alert(`API response parsing failed for batch.`);
            updateStatus(`Error: Failed to parse API response for batch. Skipping.`);
            currentGalleryIndex += batch.length;
            setTimeout(() => fetchGalleryMetadata(currentGalleryIndex), 1000);
            return;
          }
          if (data.error) {
            const retryDelay = Math.min(120000, 1000 * Math.pow(2, retryCount[index] || 1)); // Exponential backoff
            if (data.error.includes('too many gidlist requests')) {
              console.log('[EHD Batch] Rate limit hit. Retrying after', retryDelay, 'ms.');
              updateStatus(`Rate limit hit. Retrying in ${retryDelay / 1000} seconds.`);
              setTimeout(() => fetchGalleryMetadata(index), retryDelay);
              return;
            }
            console.error('[EHD Batch] API error:', data.error);
            alert(`API error: ${data.error}`);
            updateStatus(`API error: ${data.error}. Skipping.`);
            currentGalleryIndex += batch.length;
            setTimeout(() => fetchGalleryMetadata(currentGalleryIndex), 1000);
            return;
          }
          data.gmetadata.forEach((md, i) => {
            galleries[index + i].metadata = md;
            if (setting['cache-enabled']) {
              GM_setValue(`ehD-cache-${galleries[index + i].gid}`, JSON.stringify(md));
            }
          });
          console.log('[EHD Batch] Metadata fetched for', data.gmetadata.length, 'galleries');
          updateStatus(`Metadata fetched for ${data.gmetadata.length} galleries`);
          downloadNextGallery();
        },
        ontimeout: function() {
          console.error('[EHD Batch] API request timed out for batch starting at', galleries[index].gid);
          updateStatus(`Error: API request timed out for batch starting with "${galleries[index].title}". Skipping.`);
          currentGalleryIndex += batch.length;
          setTimeout(() => fetchGalleryMetadata(currentGalleryIndex), 2000);
        },
        onerror: function(err) {
          console.error('[EHD Batch] API request error details:', err);
          if (!isDownloading) return;
          alert(`API request failed for batch.`);
          updateStatus(`Error: API request failed for batch. Skipping.`);
          currentGalleryIndex += batch.length;
          setTimeout(() => fetchGalleryMetadata(currentGalleryIndex), 1000);
        }
      });
    });
  }
}

// Gallery page logic
if (isGalleryPage) {
  console.log('[EHD Batch] Detected gallery page:', location.href);
  var ehDownloadBox = document.createElement('fieldset');
  ehDownloadBox.className = 'ehD-box';
  ehDownloadBox.appendChild(style.cloneNode(true)); // Reuse style
  var ehDownloadBoxTitle = document.createElement('legend');
  ehDownloadBoxTitle.innerHTML = 'E-Hentai Downloader';
  ehDownloadBox.appendChild(ehDownloadBoxTitle);
  var ehDownloadAction = document.createElement('div');
  ehDownloadAction.className = 'g2';
  ehDownloadAction.innerHTML = ehDownloadArrow + ' <a>Download Gallery</a>';
  ehDownloadAction.addEventListener('click', function(event) {
    event.preventDefault();
    console.log('[EHD Batch] Single gallery download initiated.');
    const match = location.href.match(/\/g\/(\d+)\/([a-f0-9]+)\//i);
    if (!match) {
      alert('Could not parse gallery ID or token.');
      return;
    }
    galleries = [{
      gid: parseInt(match[1]),
      token: match[2],
      title: document.querySelector('h1')?.textContent.trim() || 'Untitled_' + match[1]
    }];
    currentGalleryIndex = 0;
    updateStatus('Fetching metadata for single gallery');
    isDownloading = true;
    fetchGalleryMetadata(currentGalleryIndex);
  });
  ehDownloadBox.appendChild(ehDownloadAction);

  statusContainer = document.createElement('div');
  statusContainer.className = 'ehD-status';
  statusContainer.innerHTML = '<p id="ehD-status-text">Status: Idle</p>';

  progressBar = document.createElement('div');
  progressBar.className = 'ehD-progress';
  progressBar.innerHTML = '<label>Gallery Progress:</label><progress id="ehD-progress" value="0" max="100"></progress>';
  statusContainer.appendChild(progressBar);

  ehDownloadBox.appendChild(statusContainer);
  const insertPoint = document.getElementById('gd5') || document.querySelector('.gm')?.nextElementSibling || document.body.firstChild;
  document.body.insertBefore(ehDownloadBox, insertPoint);
}

function downloadNextGallery() {
  console.log('[EHD Batch] Entering downloadNextGallery, currentGalleryIndex:', currentGalleryIndex);
  if (currentGalleryIndex >= galleries.length || !isDownloading) {
    if (isDownloading) {
      console.log('[EHD Batch] All galleries downloaded.');
      alert('Batch download complete.');
      updateStatus('Batch download complete.');
      isDownloading = false;
    }
    return;
  }

  var gal = galleries[currentGalleryIndex];
  var md = gal.metadata;
  if (!md) {
    console.error('[EHD Batch] No metadata for gallery', gal.gid);
    updateStatus(`Error: No metadata for gallery ${gal.gid}. Skipping.`);
    currentGalleryIndex++;
    setTimeout(() => fetchGalleryMetadata(currentGalleryIndex), 1000);
    return;
  }

  galleryTitle = md.title;
  subTitle = md.title_jpn || '';
  category = md.category;
  uploader = md.uploader;

  if (md.filelist && Array.isArray(md.filelist)) {
    console.log('[EHD Batch] Using filelist from API for gallery', gal.gid);
    pageURLsList = md.filelist.map((f, idx) => `https://${host}/s/${f.token}/${gal.gid}-${idx + 1}`);
    totalCount = pageURLsList.length;
    updateStatus(`Using API filelist for gallery "${galleryTitle}" (${totalCount} images)`);
    continueDownloadSetup();
  } else {
    console.warn('[EHD Batch] filelist not found in metadata. Scraping gallery pages for gallery', gal.gid, 'Title:', galleryTitle || 'Unknown');
    updateStatus(`Scraping gallery pages for "${galleryTitle || 'Unknown'}"`);
    const baseGalleryUrl = `https://${host}/g/${gal.gid}/${gal.token}/`;
    pageURLsList = [];
    totalCount = 0;
    scrapeGalleryPage(0, baseGalleryUrl);
  }
}

function scrapeGalleryPage(pageNum, baseGalleryUrl) {
  if (!isDownloading) return;
  const galleryUrl = pageNum === 0 ? baseGalleryUrl : `${baseGalleryUrl}?p=${pageNum}`;
  console.log('[EHD Batch] Scraping URL:', galleryUrl);
  updateStatus(`Scraping page ${pageNum + 1} for "${galleryTitle || 'Unknown'}"`);
  GM_xmlhttpRequest({
    method: 'GET',
    url: galleryUrl,
    headers: { 'Cookie': document.cookie },
    timeout: 30000,
    onload: function(res) {
      if (!isDownloading) return;
      if (res.status !== 200) {
        console.error('[EHD Batch] Failed to fetch gallery page:', galleryUrl, res.status);
        alert(`Failed to fetch gallery page ${pageNum + 1} for ${galleryTitle}. Skipping.`);
        updateStatus(`Error: Failed to fetch page ${pageNum + 1} for "${galleryTitle}". Skipping.`);
        currentGalleryIndex++;
        setTimeout(() => fetchGalleryMetadata(currentGalleryIndex), 1000);
        return;
      }

      try {
        const parser = new DOMParser();
        const doc = parser.parseFromString(res.responseText, 'text/html');
        const pageLinks = Array.from(doc.querySelectorAll('a[href*="/s/"]'));
        const addedCount = pageLinks.length;
        pageURLsList.push(...pageLinks.map(a => a.href));
        totalCount = pageURLsList.length;

        if (totalCount === 0 && doc.getElementById('img')) {
          console.log('[EHD Batch] Single-page gallery detected.');
          pageURLsList = [galleryUrl];
          totalCount = 1;
          updateStatus(`Single-page gallery detected for "${galleryTitle}"`);
        }

        if (addedCount === 0) {
          console.warn('[EHD Batch] No more images found on page ${pageNum + 1}, stopping scrape.');
          finalizeScrape();
          return;
        }

        const nextPageLink = doc.querySelector('a[href*="?p="], a[onclick*="return nl("]');
        const fileCount = parseInt(galleries[currentGalleryIndex].metadata.filecount);
        if ((nextPageLink || totalCount < fileCount) && pageNum < 100) {
          console.log('[EHD Batch] Continuing to scrape page', pageNum + 2);
          setTimeout(() => scrapeGalleryPage(pageNum + 1, baseGalleryUrl), 1000);
        } else {
          finalizeScrape();
        }
      } catch (e) {
        console.error('[EHD Batch] Error parsing gallery page:', e);
        alert(`Failed to parse gallery page ${pageNum + 1} for ${galleryTitle}.`);
        updateStatus(`Error: Failed to parse page ${pageNum + 1} for "${galleryTitle}".`);
        currentGalleryIndex++;
        setTimeout(() => fetchGalleryMetadata(currentGalleryIndex), 1000);
      }
    },
    onerror: function(err) {
      if (!isDownloading) return;
      console.error('[EHD Batch] Scrape request error for', galleryUrl, ':', err);
      alert(`Failed to request gallery page ${pageNum + 1} for ${galleryTitle}.`);
      updateStatus(`Error: Failed to request page ${pageNum + 1} for "${galleryTitle}".`);
      currentGalleryIndex++;
      setTimeout(() => fetchGalleryMetadata(currentGalleryIndex), 1000);
    }
  });
}

function finalizeScrape() {
  const fileCount = parseInt(galleries[currentGalleryIndex].metadata.filecount);
  if (totalCount === 0) {
    console.error('[EHD Batch] No page links found after scraping.');
    updateStatus(`Error: No images found for "${galleryTitle}". Skipping.`);
    currentGalleryIndex++;
    setTimeout(() => fetchGalleryMetadata(currentGalleryIndex), 1000);
    return;
  }
  console.log(`[EHD Batch] Scraped ${totalCount} page URLs for ${galleryTitle}`);
  updateStatus(`Scraped ${totalCount} page URLs for "${galleryTitle}"`);
  if (totalCount < fileCount) {
    console.warn(`[EHD Batch] Scraped ${totalCount} links, expected ${fileCount}.`);
    updateStatus(`Warning: Scraped ${totalCount} links, expected ${fileCount}.`);
  }
  continueDownloadSetup();
}

function continueDownloadSetup() {
  imageList = new Array(totalCount).fill(null);
  imageData = new Array(totalCount).fill(null);
  retryCount = new Array(totalCount).fill(0);
  imageURLsList = new Array(totalCount).fill(null);
  failedCount = 0;
  downloadedCount = 0;
  fetchCount = 0;
  isPausing = false;

  if (totalCount > 50) {
    console.warn('[EHD Batch] Large gallery detected:', totalCount, 'images.');
    updateStatus(`Warning: Large gallery "${galleryTitle}" (${totalCount} images). Proceed with caution.`);
  }

  statusContainer.innerHTML = `
    <p id="ehD-status-text">Status: Starting download for "${galleryTitle}" (${currentGalleryIndex + 1}/${galleries.length}, ${totalCount} images)</p>
    <button id="ehD-pause">Pause</button>
    <button id="ehD-resume" disabled>Resume</button>
    <button id="ehD-cancel">Cancel</button>
    <button id="ehD-skip">Skip Gallery</button>
  `;
  statusContainer.appendChild(progressBar); // Ensure progress bar is present
  if (batchProgressBar) statusContainer.appendChild(batchProgressBar);

  const pauseBtn = statusContainer.querySelector('#ehD-pause');
  const resumeBtn = statusContainer.querySelector('#ehD-resume');
  const cancelBtn = statusContainer.querySelector('#ehD-cancel');
  const skipBtn = statusContainer.querySelector('#ehD-skip');

  pauseBtn.addEventListener('click', () => {
    isPausing = true;
    pauseBtn.disabled = true;
    resumeBtn.disabled = false;
    console.log('[EHD Batch] Paused download for', galleryTitle);
    updateUI(`Paused download for "${galleryTitle}" (${downloadedCount + failedCount}/${totalCount})`);
  });
  resumeBtn.addEventListener('click', () => {
    isPausing = false;
    pauseBtn.disabled = false;
    resumeBtn.disabled = true;
    console.log('[EHD Batch] Resumed download for', galleryTitle);
    updateUI(`Resumed download for "${galleryTitle}" (${downloadedCount + failedCount}/${totalCount})`);
    startFetching(downloadedCount + failedCount + fetchCount);
  });
  cancelBtn.addEventListener('click', () => {
    isDownloading = false;
    isPausing = false;
    console.log('[EHD Batch] Cancelled batch download');
    updateUI(`Cancelled batch download`);
    resetGlobals();
    resetUI();
  });
  skipBtn.addEventListener('click', () => {
    isPausing = false;
    console.log('[EHD Batch] Skipped gallery', galleryTitle);
    updateUI(`Skipped gallery "${galleryTitle}"`);
    currentGalleryIndex++;
    resetGlobals();
    resetUI();
    setTimeout(() => fetchGalleryMetadata(currentGalleryIndex), 1000);
  });
  console.log('[EHD Batch] Starting gallery:', galleryTitle, 'with', totalCount, 'images');

  if (!isPausing) startFetching(0);
}

function resetGlobals() {
  imageList = [];
  imageData = [];
  retryCount = [];
  pageURLsList = [];
  imageURLsList = [];
  failedCount = 0;
  downloadedCount = 0;
  fetchCount = 0;
  totalCount = 0;
  updateProgress();
  updateBatchProgress();
}

function resetUI() {
  updateUI('Idle');
  updateProgress();
  updateBatchProgress();
}

function startFetching(startIndex) {
  if (!isDownloading || isPausing) return;
  let started = 0;
  for (let i = startIndex; i < totalCount && fetchCount < MAX_CONCURRENT_FETCHES; i++) {
    if (imageData[i] === null) {
      imageData[i] = 'Fetching';
      fetchCount++;
      started++;
      getPageData(i);
    }
  }
  if (started > 0) {
    updateUI(`Fetching ${started} images starting from ${startIndex + 1} for "${galleryTitle}" (${downloadedCount + failedCount + fetchCount}/${totalCount})`);
  }
}

function getPageData(index) {
  if (!isDownloading) return;
  GM_xmlhttpRequest({
    method: 'GET',
    url: pageURLsList[index],
    headers: { 'Cookie': document.cookie },
    onload: function(res) {
      if (!isDownloading) return;
      const responseText = res.responseText;
      const patterns = [
        /<a href="(\S+?\/fullimg(?:\.php\?|\/)\S+?)"/i, // Full image link
        /<img id="img" src="(\S+?\.(?:jpg|png|gif|bmp)(?:\?\S*)?)"/i, // Main image
        /<img[^>]+src="(\S+?\.(?:jpg|png|gif|bmp)(?:\?\S*)?)"/i // Fallback
      ];
      let imageUrl;
      for (const pattern of patterns) {
        const match = responseText.match(pattern);
        if (match) {
          imageUrl = match[1];
          if (!imageUrl.includes('509.gif') && !imageUrl.includes('error')) {
            break;
          }
        }
      }
      if (imageUrl) {
        console.log('[EHD Batch] Found image URL for index', index, ':', imageUrl);
        validateImageUrl(index, imageUrl);
      } else {
        console.error('[EHD Batch] No valid image URL found for index', index, 'Page content:', responseText.substring(0, 500));
        handleFetchError(index);
      }
    },
    onerror: function(err) {
      if (!isDownloading) return;
      console.error('[EHD Batch] Failed to fetch page:', pageURLsList[index], err);
      handleFetchError(index);
    }
  });
}

function validateImageUrl(index, url) {
  if (!isDownloading) return;
  GM_xmlhttpRequest({
    method: 'HEAD',
    url: url,
    headers: {
      'Accept': 'image/jpeg,image/png,image/gif,image/bmp',
      'Cookie': document.cookie
    },
    onload: function(res) {
      if (!isDownloading) return;
      const contentType = res.responseHeaders.match(/content-type:.*?image\/(\w+)/i)?.[1] || '';
      const contentLength = parseInt(res.responseHeaders.match(/content-length:.*?(\d+)/i)?.[1] || '0', 10);
      if (res.status === 200 && contentType.match(/jpeg|jpg|png|gif|bmp/i) && contentLength > 10000) {
        console.log('[EHD Batch] Validated image URL for index', index, ':', url, 'Size:', contentLength, 'Type:', contentType);
        downloadImage(index, url);
      } else {
        console.error('[EHD Batch] Invalid image URL for index', index, ':', url, 'Status:', res.status, 'Type:', contentType, 'Size:', contentLength);
        handleFetchError(index);
      }
    },
    onerror: function(err) {
      if (!isDownloading) return;
      console.error('[EHD Batch] HEAD request failed for', url, err);
      handleFetchError(index);
    }
  });
}

function downloadImage(index, url) {
  if (!isDownloading) return;
  GM_xmlhttpRequest({
    method: 'GET',
    url: url,
    responseType: 'blob',
    headers: {
      'Accept': 'image/jpeg,image/png,image/gif,image/bmp',
      'Referer': `https://${host}/s/${galleries[currentGalleryIndex].gid}-${index + 1}`,
      'User-Agent': navigator.userAgent,
      'Cookie': document.cookie // Pass session cookies
    },
    onload: function(res) {
      if (!isDownloading) return;
      const blob = res.response;
      if (blob instanceof Blob && blob.size > 10000 && blob.type.startsWith('image/')) {
        console.log('[EHD Batch] Downloaded image for index', index, ':', url, 'Size:', blob.size);
        imageList[index] = blob;
        imageURLsList[index] = url;
        imageData[index] = 'Done'; // Set to prevent re-fetch
        downloadedCount++;
        fetchCount--;
        updateUI(`Downloaded image ${index + 1} of ${totalCount} for "${galleryTitle}" (${downloadedCount}/${totalCount})`);
        if (!isPausing) startFetching(0);
        checkCompletion();
      } else {
        console.error('[EHD Batch] Invalid or small blob for image', index + 1, 'URL:', url, 'Size:', blob?.size || 0, 'Type:', blob?.type || 'unknown');
        handleFetchError(index);
      }
    },
    onerror: function(err) {
      if (!isDownloading) return;
      console.error('[EHD Batch] Error downloading image', index + 1, 'URL:', url, err);
      if (err.status === 0 && retryCount[index] < 3) {
        retryCount[index]++;
        const delay = Math.min(60000, 1000 * Math.pow(2, retryCount[index]));
        console.log('[EHD Batch] Retrying image', index + 1, 'after', delay, 'ms due to status: 0');
        setTimeout(() => {
          if (!isDownloading) return;
          imageData[index] = null;
          fetchCount--;
          startFetching(index);
        }, delay);
      } else {
        handleFetchError(index);
      }
    }
  });
}

function handleFetchError(index) {
  if (!isDownloading) return;
  retryCount[index]++;
  if (retryCount[index] < 3) {
    imageData[index] = null;
    fetchCount--;
    updateUI(`Retrying image ${index + 1} of ${totalCount} in "${galleryTitle}" (Attempt ${retryCount[index]}/3)`);
    if (!isPausing) startFetching(index);
  } else {
    imageData[index] = 'Failed'; // Set to prevent re-fetch
    failedCount++;
    fetchCount--;
    updateUI(`Failed to download image ${index + 1} of ${totalCount} in "${galleryTitle}" after 3 attempts (${downloadedCount}/${totalCount})`);
    if (!isPausing) startFetching(0);
    checkCompletion();
  }
}

function checkCompletion() {
  if (downloadedCount + failedCount === totalCount) {
    saveDownloaded();
  }
}

async function saveDownloaded() {
  if (!isDownloading) return;
  console.log('[EHD Batch] Entering saveDownloaded for', galleryTitle, 'Downloaded:', downloadedCount, 'Failed:', failedCount);

  if (!window.fflate) {
    console.log('[EHD Batch] fflate not loaded via @require, fetching manually.');
    const urls = [
      'https://unpkg.com/[email protected]/dist/fflate.min.js',
      'https://cdn.jsdelivr.net/npm/[email protected]/lib/index.min.js',
      'https://cdnjs.cloudflare.com/ajax/libs/fflate/0.8.3/fflate.min.js'
    ];
    for (const url of urls) {
      try {
        const res = await new Promise((resolve, reject) => {
          GM_xmlhttpRequest({
            method: 'GET',
            url: url,
            onload: resolve,
            onerror: reject
          });
        });
        if (res.status === 200) {
          (function() { eval(res.responseText); }).call(window);
          console.log('[EHD Batch] fflate loaded manually successfully from', url);
          break;
        } else {
          throw new Error(`Failed to fetch fflate from ${url}: ${res.status}`);
        }
      } catch (err) {
        console.error('[EHD Batch] Failed to load fflate from', url, ':', err);
        if (url === urls[urls.length - 1]) {
          alert(`Failed to load compression library for ZIP creation. Check console.`);
          updateStatus(`Error: Failed to load compression library for "${galleryTitle}"`);
          currentGalleryIndex++;
          setTimeout(() => fetchGalleryMetadata(currentGalleryIndex), 1000);
          return;
        }
      }
    }
  }

  if (!window.fflate || !window.fflate.zipSync) {
    console.error('[EHD Batch] fflate or zipSync unavailable after load attempts.');
    alert(`Compression library unavailable for ${galleryTitle}. Check console.`);
    updateStatus(`Error: Compression library unavailable for "${galleryTitle}"`);
    currentGalleryIndex++;
    setTimeout(() => fetchGalleryMetadata(currentGalleryIndex), 1000);
    return;
  }

  if (failedCount > 0) {
    alert(`Some images (${failedCount}) failed for ${galleryTitle}. Proceeding with available images.`);
    updateStatus(`Some images (${failedCount}) failed for "${galleryTitle}". Creating ZIP with available images.`);
  }

  if (downloadedCount === 0) {
    console.error('[EHD Batch] No images downloaded for', galleryTitle, '. Skipping.');
    alert(`No images downloaded successfully for ${galleryTitle}. Skipping.`);
    updateStatus(`Error: No images downloaded for "${galleryTitle}". Skipping.`);
    currentGalleryIndex++;
    resetGlobals();
    setTimeout(() => fetchGalleryMetadata(currentGalleryIndex), 1000);
    return;
  }

  updateStatus(`Preparing ZIP for "${galleryTitle}" (${downloadedCount} images)`);
  console.log('[EHD Batch] Initializing ZIP with', downloadedCount, 'images for', galleryTitle);

  try {
    const archive = {};
    let processedCount = 0;
    const sanitizedTitle = galleryTitle.replace(/[/\\?%*:|"<>]/g, '-').replace(/\s+/g, '_').substring(0, 97);

    for (let i = 0; i < imageList.length; i++) {
      if (imageList[i] && imageList[i] instanceof Blob && imageList[i].size > 0) {
        const url = imageURLsList[i] || '';
        const ext = (url.match(/\.(\w+)(?:$|\?)/)?.[1] || 'jpg').toLowerCase();
        const filename = `image_${String(i + 1).padStart(3, '0')}.${ext}`;
        console.log('[EHD Batch] Adding to ZIP:', filename, 'Size:', imageList[i].size);
        const arrayBuffer = await imageList[i].arrayBuffer();
        archive[filename] = [new Uint8Array(arrayBuffer), { level: 0 }];
        imageList[i] = null;
        imageURLsList[i] = null;
        processedCount++;
      } else {
        console.warn('[EHD Batch] Skipping invalid or empty blob at index', i);
      }
    }

    console.log('[EHD Batch] Generating ZIP for', galleryTitle, 'with', processedCount, 'images');
    const zipped = window.fflate.zipSync(archive);
    const zipFileBlob = new Blob([zipped], { type: 'application/zip' });

    console.log('[EHD Batch] ZIP generated successfully for', galleryTitle);
    const link = document.createElement('a');
    link.href = URL.createObjectURL(zipFileBlob);
    link.download = `${sanitizedTitle}_${galleries[currentGalleryIndex].gid}.zip`;
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
    setTimeout(() => URL.revokeObjectURL(link.href), 1000);
    console.log('[EHD Batch] ZIP download triggered for', galleryTitle);
    updateStatus(`Downloaded ZIP for "${galleryTitle}"`);
    currentGalleryIndex++;
    resetGlobals();
    updateBatchProgress();
    setTimeout(() => fetchGalleryMetadata(currentGalleryIndex), 1000);
  } catch (err) {
    console.error('[EHD Batch] ZIP creation error for', galleryTitle, ':', err);
    alert(`Failed to create ZIP for ${galleryTitle}: ${err.message}. Check console.`);
    updateStatus(`Error: Failed to create ZIP for "${galleryTitle}": ${err.message}`);
    currentGalleryIndex++;
    resetGlobals();
    setTimeout(() => fetchGalleryMetadata(currentGalleryIndex), 1000);
  }
}

function updateStatus(message) {
  const statusP = document.getElementById('ehD-status-text');
  if (statusP) statusP.textContent = `Status: ${message}`;
  console.log('[EHD Batch] Status:', message);
}

function updateUI(message) {
  updateStatus(message);
  updateProgress();
}

function updateProgress() {
  const progress = document.getElementById('ehD-progress');
  if (progress && totalCount > 0) {
    progress.value = ((downloadedCount + failedCount) / totalCount) * 100;
  } else if (progress) {
    progress.value = 0;
  }
}

function updateBatchProgress() {
  const batchProgress = document.getElementById('ehD-batch-progress');
  if (batchProgress && galleries.length > 0) {
    batchProgress.value = (currentGalleryIndex / galleries.length) * 100;
  } else if (batchProgress) {
    batchProgress.value = 0;
  }
}

const MAX_CONCURRENT_FETCHES = 3; // Reduced to avoid rate-limiting
const API_BATCH_SIZE = 10; // Reduced to avoid potential limits

initSetting();