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.

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

})();