feat: global speed limit, settings cleanup, abort reset, resize panel

- Global speed throttle (shared across all uploads)
- Settings grouped into sections (Uploads, Verhalten, Log)
- Abort all resets jobs to queued (restartable without reupload)
- fileuploader.log writes immediately per upload
- Staggered interval per hoster (not parallel sleep)
- Recent files panel resizable via drag handle
- History hides aborted entries
- Done jobs removed from queue immediately when setting active

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Administrator 2026-03-11 03:08:56 +01:00
parent bfe98eac0c
commit 3d858b1ffd
6 changed files with 167 additions and 39 deletions

View File

@ -31,6 +31,7 @@ const DEFAULTS = {
parallelUploadCount: 0, // 0 = use per-hoster limits only parallelUploadCount: 0, // 0 = use per-hoster limits only
scaleParallelUploads: false, scaleParallelUploads: false,
removeFromQueueOnDone: false, removeFromQueueOnDone: false,
globalMaxSpeedKbs: 0, // 0 = unlimited global speed
pendingQueue: null, pendingQueue: null,
scramble: { scramble: {
active: false, active: false,

View File

@ -34,6 +34,8 @@ class UploadManager extends EventEmitter {
this.jobAbortControllers = new Map(); // jobId -> AbortController this.jobAbortControllers = new Map(); // jobId -> AbortController
this.cancelledJobIds = new Set(); this.cancelledJobIds = new Set();
this.sessionBytes = 0; this.sessionBytes = 0;
this.lastStartTime = {}; // hoster -> timestamp of last upload start
this.globalThrottle = null;
} }
_getSettings(hoster) { _getSettings(hoster) {
@ -62,6 +64,17 @@ class UploadManager extends EventEmitter {
return this.globalSemaphore; return this.globalSemaphore;
} }
_getGlobalThrottle() {
const kbs = Number(this.globalSettings.globalMaxSpeedKbs || 0);
if (!Number.isFinite(kbs) || kbs <= 0) return null;
if (!this.globalThrottle) {
this.globalThrottle = new Throttle(kbs * 1024);
} else {
this.globalThrottle.updateRate(kbs * 1024);
}
return this.globalThrottle;
}
_getSemaphore(hoster) { _getSemaphore(hoster) {
if (!this.semaphores[hoster]) { if (!this.semaphores[hoster]) {
const settings = this._getSettings(hoster); const settings = this._getSettings(hoster);
@ -83,6 +96,8 @@ class UploadManager extends EventEmitter {
this.cancelledJobIds.clear(); this.cancelledJobIds.clear();
this.semaphores = {}; this.semaphores = {};
this.globalSemaphore = null; this.globalSemaphore = null;
this.globalThrottle = null;
this.lastStartTime = {};
const { signal } = this.abortController; const { signal } = this.abortController;
const batchId = `batch-${Date.now()}`; const batchId = `batch-${Date.now()}`;
@ -206,7 +221,7 @@ class UploadManager extends EventEmitter {
hosterSlotAcquired = true; hosterSlotAcquired = true;
if (settings.timeIntervalSec > 0) { if (settings.timeIntervalSec > 0) {
await this._sleep(settings.timeIntervalSec * 1000, signal); await this._waitForInterval(task.hoster, settings.timeIntervalSec * 1000, signal);
} }
for (let attempt = 1; attempt <= maxAttempts; attempt++) { for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@ -255,9 +270,13 @@ class UploadManager extends EventEmitter {
maxAttempts maxAttempts
}); });
const throttle = settings.maxSpeedKbs > 0 const hosterThrottle = settings.maxSpeedKbs > 0
? new Throttle(settings.maxSpeedKbs * 1024) ? new Throttle(settings.maxSpeedKbs * 1024)
: null; : null;
const globalThrottle = this._getGlobalThrottle();
const throttle = hosterThrottle && globalThrottle
? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } }
: hosterThrottle || globalThrottle;
if (settings.restartBelowKbs > 0) { if (settings.restartBelowKbs > 0) {
speedAbort = new AbortController(); speedAbort = new AbortController();
@ -503,6 +522,16 @@ class UploadManager extends EventEmitter {
}); });
} }
async _waitForInterval(hoster, intervalMs, signal) {
const now = Date.now();
const last = this.lastStartTime[hoster] || 0;
const elapsed = now - last;
if (elapsed < intervalMs) {
await this._sleep(intervalMs - elapsed, signal);
}
this.lastStartTime[hoster] = Date.now();
}
cancelJobs(jobIds) { cancelJobs(jobIds) {
for (const jobId of jobIds || []) { for (const jobId of jobIds || []) {
if (!jobId) continue; if (!jobId) continue;

19
main.js
View File

@ -540,6 +540,13 @@ ipcMain.handle('start-upload', (_event, payload) => {
if (data.status !== 'uploading') { if (data.status !== 'uploading') {
debugLog(`progress: ${data.fileName} ${data.hoster} ${data.status} ${data.error || ''}`); debugLog(`progress: ${data.fileName} ${data.hoster} ${data.status} ${data.error || ''}`);
} }
// Write to fileuploader.log immediately when a single upload finishes
if (data.status === 'done' && data.result) {
const link = data.result.download_url || data.result.embed_url || '';
if (link) {
appendUploadLog(data.hoster || '', link, data.fileName || '');
}
}
if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-progress', data); mainWindow.webContents.send('upload-progress', data);
} }
@ -554,18 +561,6 @@ ipcMain.handle('start-upload', (_event, payload) => {
uploadManager.on('batch-done', async (summary) => { uploadManager.on('batch-done', async (summary) => {
debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`); debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`);
await configStore.appendHistory(summary); await configStore.appendHistory(summary);
// Write successful uploads to fileuploader.log
for (const file of summary.files || []) {
for (const result of file.results || []) {
if (result.status === 'done' && (result.download_url || result.embed_url)) {
appendUploadLog(
result.hoster || '',
result.download_url || result.embed_url || '',
file.name || ''
);
}
}
}
if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-batch-done', summary); mainWindow.webContents.send('upload-batch-done', summary);
} }

View File

@ -923,6 +923,19 @@ async function startUpload() {
async function cancelUpload() { async function cancelUpload() {
await window.api.cancelUpload(); await window.api.cancelUpload();
uploading = false; uploading = false;
// Reset all non-finished jobs back to queued state
for (const job of queueJobs) {
if (!['done', 'error', 'skipped'].includes(job.status)) {
job.status = 'queued';
job.progress = 0;
job.bytesUploaded = 0;
job.speedKbs = 0;
job.elapsed = 0;
job.remaining = 0;
job.error = null;
}
}
renderQueueTable();
updateQueueActionButtons(); updateQueueActionButtons();
updateStatusBar(); updateStatusBar();
persistQueueStateSoon(); persistQueueStateSoon();
@ -975,6 +988,13 @@ function handleProgress(data) {
maybeAddSessionFile(job); maybeAddSessionFile(job);
// Remove finished jobs from queue immediately if setting is enabled
if (job.status === 'done' && config.globalSettings && config.globalSettings.removeFromQueueOnDone) {
removeJobFromIndex(job);
selectedJobIds.delete(job.id);
queueJobs = queueJobs.filter(j => j !== job);
}
scheduleQueueRender(); scheduleQueueRender();
updateQueueActionButtons(); updateQueueActionButtons();
updateStatusBar(); updateStatusBar();
@ -984,6 +1004,20 @@ function handleProgress(data) {
function handleBatchDone(summary) { function handleBatchDone(summary) {
uploading = false; uploading = false;
applySummaryResults(summary); applySummaryResults(summary);
// Reset aborted jobs back to queued so they can be restarted
for (const job of queueJobs) {
if (job.status === 'aborted') {
job.status = 'queued';
job.progress = 0;
job.bytesUploaded = 0;
job.speedKbs = 0;
job.elapsed = 0;
job.remaining = 0;
job.error = null;
}
}
syncSelectedFilesFromQueue(); syncSelectedFilesFromQueue();
updateQueueActionButtons(); updateQueueActionButtons();
renderQueueTable(); renderQueueTable();
@ -1307,32 +1341,40 @@ function renderSettings() {
<span class="panel-status active">System</span> <span class="panel-status active">System</span>
</div> </div>
<div class="hoster-panel-body" data-panel="global" style="display:block"> <div class="hoster-panel-body" data-panel="global" style="display:block">
<div class="settings-row"> <div class="settings-section-label">Uploads</div>
<label>FileUploader Log</label> <div class="settings-grid-mini">
<input type="text" class="key-input settings-autosave" id="logFilePathInput" value="${escapeAttr(globalSettings.logFilePath || '')}" placeholder="Standardpfad verwenden">
<button class="btn btn-xs btn-secondary" id="chooseLogFilePathBtn">Ordner wählen</button>
</div>
<div class="settings-row checkbox-row">
<label>Queue-Wiederherstellung beim Start</label>
<input type="checkbox" class="settings-autosave" id="resumeQueueOnLaunchInput" ${globalSettings.resumeQueueOnLaunch === false ? '' : 'checked'}>
</div>
<div class="settings-row">
<label></label>
<span class="hint">Stellt die Queue beim Start wieder her, startet aber pausiert.</span>
</div>
<div class="settings-row"> <div class="settings-row">
<label>Globale parallele Uploads</label> <label>Globale parallele Uploads</label>
<input type="number" class="hs-input settings-autosave" id="parallelUploadCountInput" value="${globalSettings.parallelUploadCount ?? 0}" min="0" max="100"> <input type="number" class="hs-input settings-autosave" id="parallelUploadCountInput" value="${globalSettings.parallelUploadCount ?? 0}" min="0" max="100">
<span class="hint">0 = nur pro Hoster</span> <span class="hint">0 = nur pro Hoster</span>
</div> </div>
<div class="settings-row">
<label>Globales Speed-Limit (MB/s)</label>
<input type="number" class="hs-input settings-autosave" id="globalMaxSpeedMbsInput" value="${globalSettings.globalMaxSpeedKbs > 0 ? (globalSettings.globalMaxSpeedKbs / 1024).toFixed(2).replace(/\\.00$/, '') : '0'}" min="0" step="0.1">
<span class="hint">0 = unbegrenzt</span>
</div>
</div>
<div class="settings-section-label">Verhalten</div>
<div class="settings-grid-mini">
<div class="settings-row checkbox-row"> <div class="settings-row checkbox-row">
<label>Hoster-Limits hochskalieren</label> <label>Hoster-Limits hochskalieren</label>
<input type="checkbox" class="settings-autosave" id="scaleParallelUploadsInput" ${globalSettings.scaleParallelUploads ? 'checked' : ''}> <input type="checkbox" class="settings-autosave" id="scaleParallelUploadsInput" ${globalSettings.scaleParallelUploads ? 'checked' : ''}>
</div> </div>
<div class="settings-row checkbox-row"> <div class="settings-row checkbox-row">
<label>Aus der Queue entfernen bei Abschluss</label> <label>Aus Queue entfernen bei Abschluss</label>
<input type="checkbox" class="settings-autosave" id="removeFromQueueOnDoneInput" ${globalSettings.removeFromQueueOnDone ? 'checked' : ''}> <input type="checkbox" class="settings-autosave" id="removeFromQueueOnDoneInput" ${globalSettings.removeFromQueueOnDone ? 'checked' : ''}>
</div> </div>
<div class="settings-row checkbox-row">
<label>Queue beim Start wiederherstellen</label>
<input type="checkbox" class="settings-autosave" id="resumeQueueOnLaunchInput" ${globalSettings.resumeQueueOnLaunch === false ? '' : 'checked'}>
</div>
</div>
<div class="settings-section-label">Log</div>
<div class="settings-row">
<label>FileUploader Log</label>
<input type="text" class="key-input settings-autosave" id="logFilePathInput" value="${escapeAttr(globalSettings.logFilePath || '')}" placeholder="Standardpfad verwenden">
<button class="btn btn-xs btn-secondary" id="chooseLogFilePathBtn">Ordner wählen</button>
</div>
</div> </div>
`; `;
container.appendChild(generalPanel); container.appendChild(generalPanel);
@ -1438,7 +1480,8 @@ async function saveSettings(options = {}) {
resumeQueueOnLaunch: !!document.getElementById('resumeQueueOnLaunchInput')?.checked, resumeQueueOnLaunch: !!document.getElementById('resumeQueueOnLaunchInput')?.checked,
parallelUploadCount: Math.max(0, Math.min(100, parseInt(document.getElementById('parallelUploadCountInput')?.value || '0', 10) || 0)), parallelUploadCount: Math.max(0, Math.min(100, parseInt(document.getElementById('parallelUploadCountInput')?.value || '0', 10) || 0)),
scaleParallelUploads: !!document.getElementById('scaleParallelUploadsInput')?.checked, scaleParallelUploads: !!document.getElementById('scaleParallelUploadsInput')?.checked,
removeFromQueueOnDone: !!document.getElementById('removeFromQueueOnDoneInput')?.checked removeFromQueueOnDone: !!document.getElementById('removeFromQueueOnDoneInput')?.checked,
globalMaxSpeedKbs: Math.max(0, Math.round((parseFloat(document.getElementById('globalMaxSpeedMbsInput')?.value || '0') || 0) * 1024))
}; };
for (const name of HOSTERS) { for (const name of HOSTERS) {
@ -1785,12 +1828,13 @@ async function loadHistory() {
const dt = formatDateTime(batch.timestamp || new Date()); const dt = formatDateTime(batch.timestamp || new Date());
for (const file of (batch.files || [])) { for (const file of (batch.files || [])) {
for (const result of (file.results || [])) { for (const result of (file.results || [])) {
const isErrorLike = result.status === 'error' || result.status === 'aborted'; if (result.status === 'aborted') continue;
const isError = result.status === 'error';
historyRowsData.push({ historyRowsData.push({
date: dt.text, dateTs: dt.ts, date: dt.text, dateTs: dt.ts,
filename: file.name || '', host: result.hoster || '', filename: file.name || '', host: result.hoster || '',
link: isErrorLike ? `[${result.status === 'aborted' ? 'Abgebrochen' : 'Fehler'}] ${result.error || ''}` : (result.download_url || result.embed_url || ''), link: isError ? `[Fehler] ${result.error || ''}` : (result.download_url || result.embed_url || ''),
isError: isErrorLike, order: order++ isError, order: order++
}); });
} }
} }
@ -2140,5 +2184,41 @@ function showCopyToast(msg) {
toast._timer = setTimeout(() => toast.classList.remove('show'), 1500); toast._timer = setTimeout(() => toast.classList.remove('show'), 1500);
} }
// --- Resize handle for recent-files panel ---
{
const resizer = document.getElementById('recentFilesResizer');
const panel = document.getElementById('recentFilesPanel');
if (resizer && panel) {
let startY = 0;
let startH = 0;
resizer.addEventListener('mousedown', (e) => {
e.preventDefault();
startY = e.clientY;
startH = panel.getBoundingClientRect().height;
resizer.classList.add('dragging');
document.body.style.cursor = 'ns-resize';
document.body.style.userSelect = 'none';
const onMove = (e2) => {
const delta = startY - e2.clientY;
const newH = Math.max(60, Math.min(window.innerHeight * 0.7, startH + delta));
panel.style.flex = `0 0 ${newH}px`;
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
resizer.classList.remove('dragging');
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
}
// --- Start --- // --- Start ---
init(); init();

View File

@ -93,6 +93,7 @@
<button class="btn btn-xs btn-secondary" id="retryFailedBtn" style="display:none">Fehlgeschlagene erneut</button> <button class="btn btn-xs btn-secondary" id="retryFailedBtn" style="display:none">Fehlgeschlagene erneut</button>
</div> </div>
<div class="resize-handle" id="recentFilesResizer"></div>
<div class="recent-files-panel" id="recentFilesPanel"> <div class="recent-files-panel" id="recentFilesPanel">
<div class="recent-files-header"> <div class="recent-files-header">
<h3>Files</h3> <h3>Files</h3>

View File

@ -346,6 +346,17 @@ body {
flex-shrink: 0; flex-shrink: 0;
} }
.resize-handle {
flex: 0 0 5px;
cursor: ns-resize;
background: var(--border);
position: relative;
z-index: 5;
transition: background 0.15s;
}
.resize-handle:hover, .resize-handle.dragging {
background: var(--accent);
}
.recent-files-panel { .recent-files-panel {
flex: 0 0 180px; flex: 0 0 180px;
display: flex; display: flex;
@ -616,6 +627,17 @@ body {
.key-input:focus, .hs-input:focus { border-color: var(--accent); outline: none; } .key-input:focus, .hs-input:focus { border-color: var(--accent); outline: none; }
.hs-input { max-width: 100px; } .hs-input { max-width: 100px; }
.hint { font-size: 10px; color: var(--text-dim); } .hint { font-size: 10px; color: var(--text-dim); }
.settings-section-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
margin: 10px 0 6px;
padding-bottom: 3px;
border-bottom: 1px solid var(--border);
}
.settings-section-label:first-child { margin-top: 0; }
.toggle-vis { .toggle-vis {
background: transparent; background: transparent;