Erome Downloader

Download individual files with persistent queue, clean naming, site-styled UI, cross-tab sync, and draggable/resizable panel, plus bulk download, reordering, auto-retry, and proper collapse, using simple naming.

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         Erome Downloader
// @namespace    http://tampermonkey.net/
// @version      1.5
// @description  Download individual files with persistent queue, clean naming, site-styled UI, cross-tab sync, and draggable/resizable panel, plus bulk download, reordering, auto-retry, and proper collapse, using simple naming.
// @license      MIT
// @author       LisaTurtlesCuck
// @match        https://www.erome.com/a/*
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addValueChangeListener
// @require      https://code.jquery.com/jquery-3.6.0.min.js
// @require      https://code.jquery.com/ui/1.12.1/jquery-ui.min.js
// ==/UserScript==

(function() {
  'use strict';

  // --- Configuration ---
  const FETCH_TIMEOUT_MS = 300000;
  const MAX_RETRIES = 3;
  const DEFAULT_PANEL_HEIGHT = '300px';

  const STORAGE_JOBS = "eromeJobs";
  const STORAGE_SESSION = "eromeSessionBytes";
  const STORAGE_LIFETIME = "eromeLifetimeBytes";
  const STORAGE_PANEL_POS = "eromePanelPos";
  const STORAGE_PANEL_SIZE = "eromePanelSize";
  const STORAGE_MINIMIZED = "eromePanelMinimized";

  let jobs = [];
  let runnerBusy = false;
  let statusPanel = null;
  let statusMinimized = GM_getValue(STORAGE_MINIMIZED, false);

  let sessionBytes = GM_getValue(STORAGE_SESSION, 0);
  let lifetimeBytes = GM_getValue(STORAGE_LIFETIME, 0);

  // -------------------------
  // Utilities
  // -------------------------
  function newId() {
    return 'j_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2,8);
  }
  function sanitizeForFilename(t) {
    return String(t || '')
      .replace(/[<>:"/\\|?*\x00-\x1F]/g, '')
      .replace(/\s+/g, '_')
      .substring(0, 100);
  }
  function niceBytes(n) {
    if (!n) return "0 B";
    if (n < 1024) return n + " B";
    if (n < 1024*1024) return (n/1024).toFixed(1) + " KB";
    if (n < 1024*1024*1024) return (n/1024/1024).toFixed(1) + " MB";
    return (n/1024/1024/1024).toFixed(1) + " GB";
  }

  // Custom function to toggle minimization state
  function toggleMinimize(minimizeState = !statusMinimized) {
    statusMinimized = minimizeState;
    statusPanel.find('.body,.footer').toggle(!statusMinimized);
    statusPanel.find('.resize-handle').toggle(!statusMinimized); // Hide resize handle when minimized
    statusPanel.find('#minimizeStatus-erome').text(statusMinimized ? '□' : '_'); // Change icon

    if (statusMinimized) {
        // Save current expanded height, then set height to auto to collapse to header size
        statusPanel.data('original-height', statusPanel.css('height')).css('height', 'auto');
        statusPanel.css('min-height', 'unset');
    } else {
        // Restore saved/default height
        statusPanel.css('height', statusPanel.data('original-height') || GM_getValue(STORAGE_PANEL_SIZE)?.height || DEFAULT_PANEL_HEIGHT);
        statusPanel.css('min-height', '200px'); // Restore minimum height for resizing
    }

    GM_setValue(STORAGE_MINIMIZED, statusMinimized);
    savePanelState();
  }

  // -------------------------
  // XHR wrapper (arraybuffer)
  // -------------------------
  const activeXhrs = new Map();

  function gmFetchArrayBuffer(jobId, url, timeoutMs = FETCH_TIMEOUT_MS) {
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        activeXhrs.delete(jobId);
        reject(new Error("Timeout"));
      }, timeoutMs);

      const xhr = GM_xmlhttpRequest({
        method: "GET",
        url,
        headers: { "Referer": window.location.href },
        responseType: "arraybuffer",
        onload: res => {
          clearTimeout(timer);
          activeXhrs.delete(jobId);
          if (res.status >= 200 && res.status < 300) resolve(res.response);
          else reject(new Error(`HTTP ${res.status}`));
        },
        onerror: () => {
          clearTimeout(timer);
          activeXhrs.delete(jobId);
          reject(new Error("Network error"));
        }
      });
      activeXhrs.set(jobId, xhr);
    });
  }

  async function fetchAndSave(jobId, url, filename) {
    try {
      const buf = await gmFetchArrayBuffer(jobId, url, FETCH_TIMEOUT_MS);

      const job = jobs.find(j => j.id === jobId);
      if (job && job.status === 'failed') {
          console.log(`Job ${jobId} finished downloading but was marked for cancellation. Skipping file save.`);
          return 0;
      }

      if (!buf) return 0;

      const blob = new Blob([buf]);
      const a = document.createElement("a");
      const obj = URL.createObjectURL(blob);
      a.href = obj;
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      setTimeout(() => URL.revokeObjectURL(obj), 60000);
      return blob.size;

    } catch (e) {
      console.error("fetchAndSave failed", e);
      return 0;
    }
  }

  // -------------------------
  // Storage helpers (merge-safe)
  // -------------------------
  function saveJobs() {
    try {
      jobs.forEach(j => { if (typeof j.retries !== 'number') j.retries = 0; });
      GM_setValue(STORAGE_JOBS, jobs);
      GM_setValue(STORAGE_SESSION, sessionBytes);
      GM_setValue(STORAGE_LIFETIME, lifetimeBytes);
    } catch (e) {
      console.warn("saveJobs failed", e);
    }
  }

  function loadJobs(initial = false) {
    try {
      const saved = GM_getValue(STORAGE_JOBS, []);
      const savedArr = Array.isArray(saved) ? saved : [];
      for (const s of savedArr) {
          if (!s.id) s.id = newId();
          if (typeof s.retries !== 'number') s.retries = 0; // Initialize retries
      }

      if (jobs.length === 0) {
        jobs = savedArr.map(s => ({ ...s }));
        if (initial) {
          for (const j of jobs) if (j.status === 'fetching') j.status = 'queued';
        }
        return;
      }

      const existingMap = new Map(jobs.map(j => [j.id, j]));
      const newList = [];
      for (const s of savedArr) {
        if (s.id && existingMap.has(s.id)) {
          const existing = existingMap.get(s.id);
          const keepFetching = existing.status === 'fetching';
          Object.assign(existing, s);
          if (keepFetching && (s.status === 'queued' || s.status === 'fetching')) {
            existing.status = 'fetching';
          }
          newList.push(existing);
        } else {
          newList.push({ ...s });
        }
      }
      const savedIds = new Set(savedArr.map(s => s.id));
      for (const ex of jobs) {
        if (!savedIds.has(ex.id)) newList.push(ex);
      }
      jobs = newList;

      if (initial) {
        for (const j of jobs) if (j.status === 'fetching') j.status = 'queued';
      }
    } catch (e) {
      console.warn("loadJobs failed", e);
      jobs = [];
    }
  }

  // -------------------------
  // UI: status panel (including draggable/resizable logic)
  // -------------------------
  function savePanelState() {
    if (!statusPanel) return;
    GM_setValue(STORAGE_PANEL_POS, {
      right: statusPanel.css('right'),
      bottom: statusPanel.css('bottom')
    });
    GM_setValue(STORAGE_PANEL_SIZE, {
      width: statusPanel.css('width'),
      height: statusPanel.css('height')
    });
  }

  function loadPanelState(panel) {
    const pos = GM_getValue(STORAGE_PANEL_POS);
    const size = GM_getValue(STORAGE_PANEL_SIZE);
    if (pos) {
      panel.css('right', pos.right).css('bottom', pos.bottom);
      panel.css({ width: '', maxHeight: '' });
    }
    if (size) {
      panel.css('width', size.width);
      panel.css('height', size.height);
    } else {
      panel.css('height', DEFAULT_PANEL_HEIGHT);
    }
  }

  function makeDraggableAndResizable(panel) {
    const header = panel.find('.hdr');
    const resizeHandle = panel.find('.resize-handle');
    let isDragging = false;
    let isResizing = false;
    let startX, startY, startRight, startBottom, startWidth, startHeight;

    header.on('mousedown', function(e) {
      // Prevent drag if minimized
      if (statusMinimized) return;
      if (e.target !== header[0] && !$(e.target).is('span') && !$(e.target).is('div')) return;
      isDragging = true;
      startX = e.clientX;
      startY = e.clientY;
      startRight = parseFloat(panel.css('right'));
      startBottom = parseFloat(panel.css('bottom'));
      panel.css('cursor', 'grabbing');
      e.preventDefault();
    });

    resizeHandle.on('mousedown', function(e) {
      // Prevent resize if minimized
      if (statusMinimized) return;
      isResizing = true;
      startX = e.clientX;
      startY = e.clientY;
      startWidth = panel.width();
      startHeight = panel.height();
      e.preventDefault();
    });

    $(document).on('mousemove', function(e) {
      if (isDragging) {
        const dx = e.clientX - startX;
        const dy = e.clientY - startY;
        panel.css('right', Math.max(0, startRight - dx) + 'px');
        panel.css('bottom', Math.max(0, startBottom - dy) + 'px');
      } else if (isResizing) {
        const dx = e.clientX - startX;
        const dy = e.clientY - startY;
        const newWidth = Math.max(300, startWidth + dx);
        const newHeight = Math.max(200, startHeight - dy);
        panel.css('width', newWidth + 'px');
        panel.css('height', newHeight + 'px');
      }
    });

    $(document).on('mouseup', function() {
      if (isDragging || isResizing) {
        isDragging = false;
        isResizing = false;
        panel.css('cursor', 'default');
        savePanelState();
      }
    });
  }

  function initializeStatusPanel() {
    if (statusPanel) return statusPanel;

    // Add jQuery UI CSS for sortable icons (e.g., cursor)
    $('head').append('<link rel="stylesheet" href="//code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css">');

    const css = `
      #erome-status-panel {
        position:fixed;right:16px;bottom:16px;width:620px;max-height:60vh;
        background:#000;color:#fff;border-radius:8px;border:1px solid #444;
        box-shadow:0 4px 12px rgba(0,0,0,0.8);z-index:10000;
        font-size:13px;font-family:"Open Sans",Arial,sans-serif;
        display:flex;flex-direction:column;overflow:hidden;
        min-width: 300px;
        resize: none;
      }
      #erome-status-panel .hdr {
        background:#222;padding:6px 8px;display:flex;
        justify-content:space-between;align-items:center;font-weight:bold;
        cursor: grab;
      }
      #erome-status-panel .hdr button { margin-left:6px; }
      #erome-status-panel .body { flex:1;overflow-y:auto;overflow-x:hidden; }
      #erome-status-panel .row {
        display:flex;align-items:center;gap:6px;padding:2px 8px;
        border-bottom:1px dashed rgba(255,255,255,0.08);white-space:nowrap;font-size:12px;
        cursor: move;
      }
      #erome-status-panel .row:last-child { border-bottom: none; }
      #erome-status-panel .col-icon   { width:3ch;text-align:center;padding-left:8px;color:#e83e8c;font-weight:bold; }
      #erome-status-panel .col-title  { width:40ch;overflow:hidden;text-overflow:ellipsis; }
      #erome-status-panel .col-pct    { width:5ch;text-align:right; }
      #erome-status-panel .col-status { width:15ch;text-align:left; }
      #erome-status-panel .col-controls { width:12ch;display:flex;gap:4px;justify-content:flex-end; }
      #erome-status-panel .footer {
        background:#222;padding:4px 8px;display:flex;flex-direction:column;gap:4px;font-size:12px;
        border-top:1px solid #444;
      }
      #erome-status-panel .footer-line { display:flex;justify-content:space-between;align-items:center; }
      #erome-status-panel .footer-line-small { margin-top: 4px; border-top: 1px dotted #333; padding-top: 4px; }
      #erome-status-panel button, #erome-status-panel .controls-btn {
        font-size:11px;line-height:1.2;padding:2px 6px;border:none;border-radius:6px;
        cursor:pointer;background:#e83e8c;color:#fff;transition:background 0.2s;
        text-decoration: none;
      }
      #erome-status-panel button:hover, #erome-status-panel .controls-btn:hover { background:#c71c6f; }
      .individual-download-btn, .bulk-download-btn {
        background:#e83e8c !important;color:#fff !important;border:none !important;
        border-radius:20px !important;padding:6px 12px !important;cursor:pointer !important;
        z-index:100 !important;font-size:12px !important;transition:background 0.2s !important;
        margin-right: 5px;
      }
      .individual-download-btn { position:absolute !important;top:10px !important;right:10px !important; }
      .bulk-download-btn { margin-top: 10px; }
      .individual-download-btn:hover, .bulk-download-btn:hover { background:#c71c6f !important; }
      #erome-status-panel .resize-handle {
        position: absolute; bottom: 0; right: 0;
        width: 15px; height: 15px;
        cursor: se-resize;z-index: 10001;
        border-bottom: 3px solid #e83e8c;
        border-right: 3px solid #e83e8c;
        border-radius: 0 0 8px 0;
      }
      .cancel-btn { background: #dc3545 !important; padding: 2px 4px !important; }
      .cancel-btn:hover { background: #c82333 !important; }
      .retry-btn { background: #ffc107 !important; color: #333 !important; padding: 2px 4px !important; }
      .retry-btn:hover { background: #e0a800 !important; }
      .remove-btn { background: #6c757d !important; padding: 2px 4px !important; }
      .remove-btn:hover { background: #5a6268 !important; }
      .ui-sortable-helper { opacity: 0.8; }
    `;
    $('head').append(`<style>${css}</style>`);

    statusPanel = $(`
      <div id="erome-status-panel">
        <div class="hdr"><span>Download Queue</span>
          <div><button id="minimizeStatus-erome" title="Minimize">${statusMinimized ? '□' : '_'}</button><button id="closeStatus-erome" title="Close">×</button></div>
        </div>
        <div class="body" id="erome-status-body"></div>
        <div class="footer">
          <div class="footer-line"><span>Session: <span id="erome-session-total">0 B</span></span> <button id="clearSession-erome" title="Clear all jobs and session total">Clear All</button></div>
          <div class="footer-line footer-line-small">
            <span id="erome-failed-count">0 Failed</span>
            <span>
              <button id="clearFailed-erome" title="Remove all failed jobs">Clear Failed</button>
              <button id="retryFailed-erome" title="Queue all failed jobs for immediate retry">Retry All</button>
            </span>
          </div>
          <div class="footer-line"><span>Lifetime: <span id="erome-lifetime-total">0 B</span></span> <button id="resetLifetime-erome" title="Reset lifetime total">Reset Life</button></div>
        </div>
        <div class="resize-handle"></div>
      </div>
    `);
    $('body').append(statusPanel);

    loadPanelState(statusPanel);
    makeDraggableAndResizable(statusPanel);
    toggleMinimize(statusMinimized); // Apply initial minimized state and proper height/min-height setting

    // Event Handlers
    statusPanel.find('#closeStatus-erome').on('click', ()=> {
      statusPanel.remove(); statusPanel = null;
      GM_setValue(STORAGE_PANEL_POS, null);
      GM_setValue(STORAGE_PANEL_SIZE, null);
    });
    statusPanel.find('#minimizeStatus-erome').on('click', ()=> {
      toggleMinimize();
    });
    statusPanel.find('#resetLifetime-erome').on('click', ()=> {
      if (confirm("Reset lifetime downloaded total?")) { lifetimeBytes = 0; saveJobs();updateTotals(); }
    });
    statusPanel.find('#clearSession-erome').on('click', ()=> {
      if (confirm("Clear all jobs (Done/Failed/Queued) and session total?")) { jobs = [];sessionBytes = 0;saveJobs();renderPanel(); }
    });
    statusPanel.find('#clearFailed-erome').on('click', ()=> {
      clearFailedJobs();
    });
    statusPanel.find('#retryFailed-erome').on('click', ()=> {
      bulkRetryFailedJobs();
    });

    // Dynamic Button Handlers
    statusPanel.find('#erome-status-body').on('click', '.cancel-btn', function() {
        cancelJob($(this).data('job-id'));
    }).on('click', '.retry-btn', function() {
        retryJob($(this).data('job-id'));
    }).on('click', '.remove-btn', function() {
        removeJob($(this).data('job-id'));
    });

    updateTotals();renderPanel();
    return statusPanel;
  }

  function renderPanel() {
    if (!statusPanel) return;
    const body = statusPanel.find('#erome-status-body');
    let html = "";
    let failedCount = 0;

    for (const j of jobs) {
      if (j.status === 'failed') failedCount++;

      const icon = j.status==='done'?'✔':(j.status==='failed'?'✖':(j.status==='fetching'?'⏳':'⏱'));
      const pct = (j.progress||0).toString().padStart(3) + "%";
      let status = "";
      let controlsHtml = "";
      const isQueuedOrFetching = j.status === 'queued' || j.status === 'fetching';

      let retryStatus = '';
      if (j.status === 'failed' && j.retries > 0) {
          retryStatus = ` (Tried ${j.retries}/${MAX_RETRIES})`;
      } else if (j.status === 'failed' && j.retries >= MAX_RETRIES) {
          retryStatus = ` (Max Retries)`;
      }

      if (j.status==='done') {
        status = `Done (${niceBytes(j.sizeBytes)})`;
        controlsHtml = `<a class="controls-btn" href="${j.albumUrl}" target="_blank" title="Go to Album">🔗</a><button class="controls-btn remove-btn" data-job-id="${j.id}" title="Remove Job">🗑</button>`;
      } else if (j.status==='failed') {
        status = "Failed" + retryStatus;
        controlsHtml = `<button class="controls-btn retry-btn" data-job-id="${j.id}" title="Retry Download">↻</button><a class="controls-btn" href="${j.albumUrl}" target="_blank" title="Go to Album">🔗</a><button class="controls-btn remove-btn" data-job-id="${j.id}" title="Remove Job">🗑</button>`;
      } else if (j.status==='fetching') {
        status = "Downloading...";
        controlsHtml = `<button class="controls-btn cancel-btn" data-job-id="${j.id}" title="Cancel Download">✖</button>`;
      } else { // queued
        status = "Queued";
        controlsHtml = `<button class="controls-btn cancel-btn" data-job-id="${j.id}" title="Cancel Download">✖</button>`;
      }

      const rowStyle = j.status==='failed' ? 'style="color:#f8d7da;background:rgba(114, 28, 36, 0.5);"' : '';

      // Only allow drag on queued items
      const draggable = isQueuedOrFetching ? '' : 'style="cursor: default;"';
      const draggableClass = isQueuedOrFetching ? ' draggable-row' : '';

      html += `<div class="row${draggableClass}" data-job-id="${j.id}" ${rowStyle} ${draggable}>
                 <div class="col-icon">${icon}</div>
                 <div class="col-title" title="${j.filename}">${j.filename}</div>
                 <div class="col-pct">${pct}</div>
                 <div class="col-status">${status}</div>
                 <div class="col-controls">${controlsHtml}</div>
               </div>`;
    }

    body.html(html);
    statusPanel.find('#erome-failed-count').text(`${failedCount} Failed`);
    updateTotals();
    initializeSortable(body);
  }

  function initializeSortable(body) {
    // Destroy previous sortable instance to prevent conflicts
    if (body.data('ui-sortable')) body.sortable('destroy');

    // Make only the rows of queued/fetching items sortable
    body.sortable({
        items: '.draggable-row',
        axis: 'y',
        cursor: 'move',
        containment: "parent",
        update: function(event, ui) {
            const newOrderIds = body.find('.row.draggable-row').map(function() {
                return $(this).data('job-id');
            }).get();

            // Reconstruct the jobs array in the new order
            const reorderedJobs = [];
            const otherJobs = jobs.filter(j => j.status !== 'queued' && j.status !== 'fetching');

            for (const id of newOrderIds) {
                const job = jobs.find(j => j.id === id);
                if (job) reorderedJobs.push(job);
            }

            // Add non-queued/fetching jobs back at the end
            jobs = [...reorderedJobs, ...otherJobs];
            saveJobs();
            renderPanel(); // Re-render to show correct row style/cursor
        }
    });
  }

  function updateTotals() {
    if (!statusPanel) return;
    statusPanel.find('#erome-session-total').text(niceBytes(sessionBytes));
    statusPanel.find('#erome-lifetime-total').text(niceBytes(lifetimeBytes));
  }
  function addBytes(n) {
    if (!n) return;
    sessionBytes += n;lifetimeBytes += n;
    saveJobs();updateTotals();
  }

  // -------------------------
  // Job management
  // -------------------------
  function removeJob(jobId) {
    jobs = jobs.filter(j => j.id !== jobId);
    saveJobs();
    renderPanel();
    runJobs();
  }

  function clearFailedJobs() {
    jobs = jobs.filter(j => j.status !== 'failed');
    saveJobs();
    renderPanel();
    runJobs();
  }

  function bulkRetryFailedJobs() {
    jobs.forEach(j => {
        if (j.status === 'failed') {
            j.status = 'queued';
            j.progress = 0;
            j.sizeBytes = 0;
        }
    });
    saveJobs();
    renderPanel();
    runJobs();
  }

  function cancelJob(jobId) {
    const job = jobs.find(j => j.id === jobId);
    if (job) {
        job.status = 'failed';
        job.progress = 0;
        saveJobs();
        renderPanel();
        runJobs();
    }
  }

  function retryJob(jobId) {
    const job = jobs.find(j => j.id === jobId);
    if (job && job.status === 'failed') {
        job.status = 'queued';
        job.progress = 0;
        job.sizeBytes = 0;
        saveJobs();
        renderPanel();
        runJobs();
    }
  }

  function addJob(info) {
    const exists = jobs.find(j => j.url === info.url && j.filename === info.filename && j.status !== 'done' && j.status !== 'failed');
    if (exists) {
      if (exists.status === 'failed') {
        exists.status = 'queued';exists.progress = 0;exists.sizeBytes = 0;exists.retries=0;
      }
      saveJobs();renderPanel();runJobs();return;
    }

    const job = {id:newId(),url:info.url,filename:info.filename,status:'queued',progress:0,sizeBytes:0, albumUrl: info.albumUrl, retries: 0};
    jobs.push(job);

    saveJobs();renderPanel();runJobs();
  }

  async function runJobs() {
    if (runnerBusy) return;
    runnerBusy = true;
    while (true) {
      const job = jobs.find(j => j.status === 'queued');
      if (!job) break;
      const canon = jobs.find(j => j.id === job.id) || job;

      renderPanel();

      await runJob(canon);
    }
    runnerBusy = false;
  }

  async function runJob(job) {
    const idx = jobs.findIndex(j => j.id === job.id);
    const jobRef = idx >= 0 ? jobs[idx] : job;

    jobRef.status = 'fetching';jobRef.progress = 0;
    renderPanel();
    saveJobs();

    try {
      const size = await fetchAndSave(jobRef.id, jobRef.url, jobRef.filename);

      if (jobRef.status === 'failed' || size === 0) {

        if (jobRef.status !== 'failed') {
            jobRef.retries++;
            if (jobRef.retries < MAX_RETRIES) {
                jobRef.status = 'queued';
            } else {
                jobRef.status = 'failed';
            }
            jobRef.progress = 0;
        }

        renderPanel();saveJobs();
        return;
      }

      // Success
      jobRef.status = 'done';jobRef.progress = 100;jobRef.sizeBytes = size;addBytes(size);
      jobRef.retries = 0;

    } catch (e) {
      // Catch XHR network errors/timeouts
      if (jobRef.status !== 'failed') {
          jobRef.retries++;
          if (jobRef.retries < MAX_RETRIES) {
              jobRef.status = 'queued';
          } else {
              jobRef.status = 'failed';
          }
          jobRef.progress = 0;
      }
    }
    renderPanel();saveJobs();
  }

  // -------------------------
  // Media discovery & bulk queueing
  // -------------------------

  async function tryUnlockVideo(mg, maxAttempts = 5, delayMs = 500) {
    const videoEl = mg.find('video').get(0);
    if (!videoEl) return null;

    for (let i = 0; i < maxAttempts; i++) {
      let currentSrc = videoEl.currentSrc || videoEl.src;
      if (currentSrc && currentSrc.startsWith('http')) return currentSrc;

      try {
        if (videoEl.paused) {
          const p = videoEl.play();
          if (p && typeof p.then === 'function') {
            await p.catch(e => { /* Ignore play error */ });
            videoEl.pause();
          }
        }
      } catch (e) { }

      currentSrc = videoEl.currentSrc || videoEl.src;
      if (currentSrc && currentSrc.startsWith('http')) return currentSrc;

      await new Promise(resolve => setTimeout(resolve, delayMs));
    }
    return null;
  }

  async function getMediaInfoFromGroup(mg, mediaType) {
    const album = sanitizeForFilename(($('h1').text()||'Album').trim());
    const albumUrl = window.location.href.split('#')[0].split('?')[0];

    // 1. Image Check
    const img = mg.find('.img[data-src]');
    if (img.length && (mediaType === 'image' || mediaType === 'all')) {
      const url = img.attr('data-src').split('?')[0];
      const ext = url.split('.').pop().split(/\#|\?/)[0];

      // *** MODIFIED: Removed _p[index] suffix ***
      return { url, filename: `${album}.${ext}`, albumUrl, type: 'image' };
    }

    // 2. Video Check
    let vs = mg.find('source[type="video/mp4"]').attr('src');
    if (!vs) {
      vs = await tryUnlockVideo(mg);
    }

    if (vs && vs.startsWith('http') && (mediaType === 'video' || mediaType === 'all')) {
      const url = vs.split('?')[0];

      // *** MODIFIED: Removed _v[index] suffix ***
      return { url, filename: `${album}.mp4`, albumUrl, type: 'video' };
    }

    return null;
  }

  async function bulkQueueMedia(mediaType) {
      const mediaGroups = $('.media-group');
      if (mediaGroups.length === 0) return;

      const buttonId = mediaType === 'image' ? '#bulk-dl-images' : (mediaType === 'video' ? '#bulk-dl-videos' : null);
      const btn = $(buttonId);
      const originalText = btn.text();
      btn.prop('disabled', true).text('Queueing...');

      const infoPromises = mediaGroups.map(function() {
          return getMediaInfoFromGroup($(this), mediaType);
      }).get();

      const results = await Promise.all(infoPromises);
      let count = 0;

      for (const info of results) {
          if (info) {
              addJob(info);
              count++;
          }
      }

      btn.prop('disabled', false).text(originalText);
      if (count > 0) {
          ensureStatusPanel();
      }
  }

  // -------------------------
  // Attach download buttons
  // -------------------------
  function ensureStatusPanel() {
    if (!statusPanel) initializeStatusPanel();
  }

  function addIndividualDownloadButtons() {
    $('.individual-download-btn').remove();

    if ($('#bulk-dl-images').length === 0) {
        const h1 = $('h1');
        const bulkButtons = $(`
            <div style="margin-top: 10px;">
                <button class="bulk-download-btn" id="bulk-dl-images" title="Queue all images on the page">DL All Images</button>
                <button class="bulk-download-btn" id="bulk-dl-videos" title="Queue all videos on the page">DL All Videos</button>
            </div>
        `);
        h1.after(bulkButtons);

        $('#bulk-dl-images').on('click', () => bulkQueueMedia('image'));
        $('#bulk-dl-videos').on('click', () => bulkQueueMedia('video'));
    }

    $('.media-group').each(function() {
      const mg = $(this);
      if (mg.find('.individual-download-btn').length) return;

      mg.css('position','relative');

      const btn = $(`<button class="individual-download-btn" title="Download this file">DL</button>`);
      mg.append(btn);

      btn.on('click', async (e) => {
        e.preventDefault();
        e.stopPropagation();

        const originalText = btn.text();
        btn.prop('disabled', true).text('...');

        const info = await getMediaInfoFromGroup(mg, 'all');

        btn.prop('disabled', false).text(originalText);

        if (info) addJob(info);
        else {
          ensureStatusPanel();
          const body = statusPanel.find('#erome-status-body');
          const timestamp = new Date().toLocaleTimeString();
          body.prepend(`<div class="row" style="color:#f8d7da;background:rgba(114, 28, 36, 0.5);"><div class="col-icon">✖</div><div class="col-title">URL fetch failed (${timestamp})</div><div class="col-pct">--</div><div class="col-status">Failed</div></div>`);
        }
      });
    });
  }

  // -------------------------
  // DOM observer
  // -------------------------
  const observer = new MutationObserver(muts => {
    let mediaAdded = false;
    for (const m of muts) {
      for (const n of m.addedNodes) {
        if (n.nodeType === 1) {
          if ($(n).is('.media-group')) { mediaAdded = true; break; }
          if ($(n).find('.media-group').length) { mediaAdded = true; break; }
        }
      }
      if (mediaAdded) break;
    }
    if (mediaAdded) {
        addIndividualDownloadButtons();
    }
  });
  observer.observe(document.body, { childList: true, subtree: true });

  // -------------------------
  // Boot & cross-tab sync
  // -------------------------
  window.addEventListener('load', () => {
    const checkJQueryUI = setInterval(() => {
        if (typeof $.ui !== 'undefined' && typeof $.ui.sortable !== 'undefined') {
            clearInterval(checkJQueryUI);

            setTimeout(() => {
              loadJobs(true);initializeStatusPanel();renderPanel();runJobs();addIndividualDownloadButtons();
              if (typeof GM_addValueChangeListener !== "undefined") {
                GM_addValueChangeListener(STORAGE_JOBS, () => { loadJobs(false);renderPanel();runJobs(); });
                GM_addValueChangeListener(STORAGE_SESSION, (n,o,v) => { sessionBytes=v||0;updateTotals(); });
                GM_addValueChangeListener(STORAGE_LIFETIME, (n,o,v) => { lifetimeBytes=v||0;updateTotals(); });
                GM_addValueChangeListener(STORAGE_PANEL_POS, () => { if(statusPanel) loadPanelState(statusPanel); });
                GM_addValueChangeListener(STORAGE_PANEL_SIZE, () => { if(statusPanel) loadPanelState(statusPanel); });
                GM_addValueChangeListener(STORAGE_MINIMIZED, (n,o,v) => { if(statusPanel && v !== statusMinimized) toggleMinimize(v); });
              }
            }, 800);
        }
    }, 100);
  });

})();