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