diff --git a/lib/config-store.js b/lib/config-store.js index 2ef0557..31a4d59 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -4,7 +4,7 @@ const path = require('path'); const HOSTER_SETTINGS_DEFAULTS = { retries: 3, maxSpeedKbs: 0, // 0 = unlimited - parallelCount: 2, // 1-10 + parallelCount: 2, // 1-100 restartBelowKbs: 0, // 0 = off timeIntervalSec: 0, // delay between jobs maxSizeMb: 0 // 0 = unlimited @@ -28,6 +28,8 @@ const DEFAULTS = { shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart logFilePath: '', resumeQueueOnLaunch: true, + parallelUploadCount: 0, // 0 = use per-hoster limits only + scaleParallelUploads: false, removeFromQueueOnDone: false, pendingQueue: null, scramble: { diff --git a/lib/upload-manager.js b/lib/upload-manager.js index 304a616..98469d8 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -19,42 +19,75 @@ const DEFAULT_SETTINGS = { }; class UploadManager extends EventEmitter { - constructor(hosterSettings) { + constructor(hosterSettings, globalSettings) { super(); this.hosterSettings = hosterSettings || {}; + this.globalSettings = globalSettings || {}; this.semaphores = {}; + this.globalSemaphore = null; this.abortController = new AbortController(); this.running = false; + this.stopAfterActive = false; this.statsInterval = null; this.startTime = 0; - this.activeJobs = new Map(); // uploadId -> { speedKbs, bytesUploaded } + this.activeJobs = new Map(); // uploadId -> { jobId, speedKbs, bytesUploaded } + this.jobAbortControllers = new Map(); // jobId -> AbortController + this.cancelledJobIds = new Set(); this.sessionBytes = 0; } _getSettings(hoster) { - return { ...DEFAULT_SETTINGS, ...(this.hosterSettings[hoster] || {}) }; + const settings = { ...DEFAULT_SETTINGS, ...(this.hosterSettings[hoster] || {}) }; + const globalLimit = this._getGlobalParallelLimit(); + if (this.globalSettings.scaleParallelUploads && globalLimit > 0) { + settings.parallelCount = Math.max(settings.parallelCount || 1, globalLimit); + } + return settings; + } + + _getGlobalParallelLimit() { + const raw = Number(this.globalSettings.parallelUploadCount || 0); + if (!Number.isFinite(raw) || raw <= 0) return 0; + return Math.max(1, Math.min(100, Math.round(raw))); + } + + _getGlobalSemaphore() { + const limit = this._getGlobalParallelLimit(); + if (limit <= 0) return null; + if (!this.globalSemaphore) { + this.globalSemaphore = new Semaphore(limit); + } else { + this.globalSemaphore.updateLimit(limit); + } + return this.globalSemaphore; } _getSemaphore(hoster) { if (!this.semaphores[hoster]) { const settings = this._getSettings(hoster); this.semaphores[hoster] = new Semaphore(settings.parallelCount); + } else { + this.semaphores[hoster].updateLimit(this._getSettings(hoster).parallelCount); } return this.semaphores[hoster]; } async startBatch(tasks) { this.running = true; + this.stopAfterActive = false; this.abortController = new AbortController(); this.startTime = Date.now(); this.sessionBytes = 0; this.activeJobs.clear(); - const { signal } = this.abortController; + this.jobAbortControllers.clear(); + this.cancelledJobIds.clear(); + this.semaphores = {}; + this.globalSemaphore = null; + const { signal } = this.abortController; const batchId = `batch-${Date.now()}`; const results = new Map(); // filePath -> { name, size, results: [] } - // Initialize result map per file for (const task of tasks) { const fileName = path.basename(task.file); if (!results.has(task.file)) { @@ -64,34 +97,17 @@ class UploadManager extends EventEmitter { } } - // Start global stats emitter this._startStatsTimer(); - // Group tasks by file to process files top-to-bottom - // Within each file, all hosters run in parallel - const fileOrder = []; - const tasksByFile = new Map(); - for (const task of tasks) { - if (!tasksByFile.has(task.file)) { - tasksByFile.set(task.file, []); - fileOrder.push(task.file); - } - tasksByFile.get(task.file).push(task); - } - - for (const file of fileOrder) { - if (signal.aborted) break; - const fileTasks = tasksByFile.get(file); - const promises = fileTasks.map((task) => this._runJob(task, results, signal)); - await Promise.allSettled(promises); - } + const promises = tasks.map((task) => this._runJob(task, results, signal)); + await Promise.allSettled(promises); this._stopStatsTimer(); this.running = false; const files = Array.from(results.values()); const total = tasks.length; - const succeeded = files.reduce((n, f) => n + f.results.filter(r => r.status === 'done').length, 0); + const succeeded = files.reduce((count, file) => count + file.results.filter((result) => result.status === 'done').length, 0); const summary = { id: batchId, @@ -105,250 +121,277 @@ class UploadManager extends EventEmitter { this.emit('batch-done', summary); } - async _runJob(task, results, signal) { + async _runJob(task, results, batchSignal) { const settings = this._getSettings(task.hoster); - const semaphore = this._getSemaphore(task.hoster); + const hosterSemaphore = this._getSemaphore(task.hoster); + const globalSemaphore = this._getGlobalSemaphore(); const uploadId = crypto.randomBytes(8).toString('hex'); + const jobId = task.jobId || uploadId; const fileName = path.basename(task.file); let fileSize = 0; try { fileSize = fs.statSync(task.file).size; } catch {} const maxAttempts = Math.max(1, (settings.retries || 0) + 1); + const jobAbortController = new AbortController(); + const { signal, cleanup: cleanupSignals } = this._combineSignals(batchSignal, jobAbortController.signal); + this.jobAbortControllers.set(jobId, jobAbortController); - // File size filter - if (settings.maxSizeMb > 0 && fileSize > settings.maxSizeMb * 1024 * 1024) { - const errMsg = `Datei zu gross (Max: ${settings.maxSizeMb} MB)`; - this._emitProgress(uploadId, fileName, task.hoster, { - status: 'skipped', progress: 0, - bytesUploaded: 0, bytesTotal: fileSize, - speedKbs: 0, elapsed: 0, remaining: 0, - error: errMsg, result: null, attempt: 0, maxAttempts - }); - results.get(task.file).results.push({ - hoster: task.hoster, status: 'error', error: errMsg, - download_url: null, embed_url: null, file_code: null - }); - return; - } - - // Emit queued status - this._emitProgress(uploadId, fileName, task.hoster, { - status: 'queued', progress: 0, - bytesUploaded: 0, bytesTotal: fileSize, - speedKbs: 0, elapsed: 0, remaining: 0, - error: null, result: null, attempt: 0, maxAttempts - }); - - // Wait for semaphore slot (abortable) - try { - await semaphore.acquire(signal); - } catch { - // Aborted while waiting in queue — no slot was granted, no release needed - return; - } - if (signal.aborted) { - semaphore.release(); - return; - } - - // Time interval delay between jobs - if (settings.timeIntervalSec > 0) { - await this._sleep(settings.timeIntervalSec * 1000, signal).catch(() => {}); - if (signal.aborted) { - semaphore.release(); - return; - } - } - + let hosterSlotAcquired = false; + let globalSlotAcquired = false; + let finalResultRecorded = false; let lastError = null; - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - if (signal.aborted) break; + const recordFinalResult = (status, payload = {}) => { + if (finalResultRecorded) return; + finalResultRecorded = true; - // Retry delay - if (attempt > 1) { - this._emitProgress(uploadId, fileName, task.hoster, { - status: 'retrying', progress: 0, - bytesUploaded: 0, bytesTotal: fileSize, - speedKbs: 0, elapsed: 0, remaining: 0, - error: lastError ? lastError.message : null, - result: null, attempt, maxAttempts - }); - await this._sleep(2500, signal).catch(() => {}); - if (signal.aborted) break; + const result = { + hoster: task.hoster, + status, + error: payload.error || null, + download_url: payload.result ? payload.result.download_url || null : null, + embed_url: payload.result ? payload.result.embed_url || null : null, + file_code: payload.result ? payload.result.file_code || null : null + }; + + results.get(task.file).results.push(result); + }; + + const emitFinalStatus = (status, payload = {}) => { + this._emitProgress(uploadId, fileName, task.hoster, { + jobId, + status, + progress: status === 'done' ? 1 : 0, + bytesUploaded: status === 'done' ? fileSize : 0, + bytesTotal: fileSize, + speedKbs: payload.speedKbs || 0, + elapsed: payload.elapsed || 0, + remaining: 0, + error: payload.error || null, + result: payload.result || null, + attempt: payload.attempt || maxAttempts, + maxAttempts + }); + }; + + try { + if (settings.maxSizeMb > 0 && fileSize > settings.maxSizeMb * 1024 * 1024) { + const error = `Datei zu groß (Max: ${settings.maxSizeMb} MB)`; + emitFinalStatus('skipped', { error, attempt: 0 }); + recordFinalResult('error', { error }); + return; } - const jobStart = Date.now(); - let lastBytes = 0; - let lastSpeedTime = jobStart; - let currentSpeedKbs = 0; - let lowSpeedSince = 0; - let speedAbort = null; + this._emitProgress(uploadId, fileName, task.hoster, { + jobId, + status: 'queued', + progress: 0, + bytesUploaded: 0, + bytesTotal: fileSize, + speedKbs: 0, + elapsed: 0, + remaining: 0, + error: null, + result: null, + attempt: 0, + maxAttempts + }); - // Register active job for global stats - this.activeJobs.set(uploadId, { speedKbs: 0, bytesUploaded: 0 }); + if (globalSemaphore) { + await globalSemaphore.acquire(signal); + globalSlotAcquired = true; + } - // Speed monitor and signal cleanup (declared outside try for cleanup in catch) - let speedMonitor = null; - let signalCleanup = null; + await hosterSemaphore.acquire(signal); + hosterSlotAcquired = true; - try { - // Getting server - this._emitProgress(uploadId, fileName, task.hoster, { - status: 'getting-server', progress: 0, - bytesUploaded: 0, bytesTotal: fileSize, - speedKbs: 0, elapsed: 0, remaining: 0, - error: null, result: null, attempt, maxAttempts - }); + if (settings.timeIntervalSec > 0) { + await this._sleep(settings.timeIntervalSec * 1000, signal); + } - // Create per-job throttle - const throttle = settings.maxSpeedKbs > 0 - ? new Throttle(settings.maxSpeedKbs * 1024) - : null; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (signal.aborted || this.stopAfterActive) break; - // Speed monitor: abort if too slow - if (settings.restartBelowKbs > 0) { - speedAbort = new AbortController(); + if (attempt > 1) { + this._emitProgress(uploadId, fileName, task.hoster, { + jobId, + status: 'retrying', + progress: 0, + bytesUploaded: 0, + bytesTotal: fileSize, + speedKbs: 0, + elapsed: 0, + remaining: 0, + error: lastError ? lastError.message : null, + result: null, + attempt, + maxAttempts + }); + await this._sleep(2500, signal); } - // Combined signal - let jobSignal = signal; - if (speedAbort) { - const combined = this._combineSignals(signal, speedAbort.signal); - jobSignal = combined.signal; - signalCleanup = combined.cleanup; - } - if (settings.restartBelowKbs > 0) { - speedMonitor = setInterval(() => { - if (currentSpeedKbs > 0 && currentSpeedKbs < settings.restartBelowKbs) { - if (!lowSpeedSince) lowSpeedSince = Date.now(); - if (Date.now() - lowSpeedSince > 6000) { - if (speedAbort) speedAbort.abort(); - clearInterval(speedMonitor); + const jobStart = Date.now(); + let lastBytes = 0; + let lastSpeedTime = jobStart; + let currentSpeedKbs = 0; + let lowSpeedSince = 0; + let speedAbort = null; + let speedMonitor = null; + let uploadSignalBundle = { signal, cleanup() {} }; + + try { + this._emitProgress(uploadId, fileName, task.hoster, { + jobId, + status: 'getting-server', + progress: 0, + bytesUploaded: 0, + bytesTotal: fileSize, + speedKbs: 0, + elapsed: 0, + remaining: 0, + error: null, + result: null, + attempt, + maxAttempts + }); + + const throttle = settings.maxSpeedKbs > 0 + ? new Throttle(settings.maxSpeedKbs * 1024) + : null; + + if (settings.restartBelowKbs > 0) { + speedAbort = new AbortController(); + uploadSignalBundle = this._combineManySignals([signal, speedAbort.signal]); + speedMonitor = setInterval(() => { + if (currentSpeedKbs > 0 && currentSpeedKbs < settings.restartBelowKbs) { + if (!lowSpeedSince) lowSpeedSince = Date.now(); + if (Date.now() - lowSpeedSince > 6000) { + speedAbort.abort(); + } + } else { + lowSpeedSince = 0; } - } else { - lowSpeedSince = 0; - } - }, 2000); - } - - // Progress callback with speed tracking - const progressCb = (bytesUploaded, bytesTotal) => { - const now = Date.now(); - const elapsed = Math.round((now - jobStart) / 1000); - - // Speed calculation (update every ~1s) - const timeDelta = (now - lastSpeedTime) / 1000; - if (timeDelta >= 1) { - const bytesDelta = bytesUploaded - lastBytes; - currentSpeedKbs = Math.round(bytesDelta / timeDelta / 1024); - lastBytes = bytesUploaded; - lastSpeedTime = now; + }, 2000); } - const remaining = currentSpeedKbs > 0 - ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024)) - : 0; + this.activeJobs.set(uploadId, { jobId, speedKbs: 0, bytesUploaded: 0 }); - // Update active job stats for global aggregation - this.activeJobs.set(uploadId, { speedKbs: currentSpeedKbs, bytesUploaded }); + const progressCb = (bytesUploaded, bytesTotal) => { + const now = Date.now(); + const elapsed = Math.round((now - jobStart) / 1000); + const timeDelta = (now - lastSpeedTime) / 1000; + if (timeDelta >= 1) { + const bytesDelta = bytesUploaded - lastBytes; + currentSpeedKbs = Math.round(bytesDelta / timeDelta / 1024); + lastBytes = bytesUploaded; + lastSpeedTime = now; + } - this._emitProgress(uploadId, fileName, task.hoster, { - status: 'uploading', progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0, - bytesUploaded, bytesTotal: bytesTotal, - speedKbs: currentSpeedKbs, elapsed, remaining, - error: null, result: null, attempt, maxAttempts + const remaining = currentSpeedKbs > 0 + ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024)) + : 0; + + this.activeJobs.set(uploadId, { jobId, speedKbs: currentSpeedKbs, bytesUploaded }); + + this._emitProgress(uploadId, fileName, task.hoster, { + jobId, + status: 'uploading', + progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0, + bytesUploaded, + bytesTotal, + speedKbs: currentSpeedKbs, + elapsed, + remaining, + error: null, + result: null, + attempt, + maxAttempts + }); + }; + + let result; + if (task.hoster === 'vidmoly.me' && task.username) { + const vidmoly = new VidmolyUploader(); + await vidmoly.login(task.username, task.password); + result = await vidmoly.upload(task.file, progressCb, uploadSignalBundle.signal, throttle); + } else if (task.hoster === 'voe.sx' && task.username) { + const voe = new VoeUploader(); + await voe.login(task.username, task.password); + result = await voe.upload(task.file, progressCb, uploadSignalBundle.signal, throttle); + } else if (task.hoster === 'doodstream.com' && task.username) { + const dood = new DoodstreamUploader(); + await dood.login(task.username, task.password); + result = await dood.upload(task.file, progressCb, uploadSignalBundle.signal, throttle); + } else { + result = await uploadFile(task.hoster, task.file, task.apiKey, progressCb, uploadSignalBundle.signal, throttle); + } + + const elapsed = Math.round((Date.now() - jobStart) / 1000); + this.sessionBytes += fileSize; + this.activeJobs.delete(uploadId); + + emitFinalStatus('done', { + result, + speedKbs: currentSpeedKbs, + elapsed, + attempt }); - }; + recordFinalResult('done', { result }); + return; + } catch (err) { + this.activeJobs.delete(uploadId); - let result; - if (task.hoster === 'vidmoly.me' && task.username) { - const vidmoly = new VidmolyUploader(); - await vidmoly.login(task.username, task.password); - result = await vidmoly.upload(task.file, progressCb, jobSignal, throttle); - } else if (task.hoster === 'voe.sx' && task.username) { - const voe = new VoeUploader(); - await voe.login(task.username, task.password); - result = await voe.upload(task.file, progressCb, jobSignal, throttle); - } else if (task.hoster === 'doodstream.com' && task.username) { - const dood = new DoodstreamUploader(); - await dood.login(task.username, task.password); - result = await dood.upload(task.file, progressCb, jobSignal, throttle); - } else { - result = await uploadFile(task.hoster, task.file, task.apiKey, progressCb, jobSignal, throttle); - } + const isSpeedRestart = speedAbort && speedAbort.signal.aborted && !signal.aborted; + if (signal.aborted) { + lastError = new Error('Abgebrochen'); + break; + } - // Clear speed monitor and signal listeners - if (speedMonitor) clearInterval(speedMonitor); - if (signalCleanup) signalCleanup(); + if (this.stopAfterActive) { + lastError = new Error('Angehalten'); + break; + } - // Track session bytes - this.sessionBytes += fileSize; - this.activeJobs.delete(uploadId); + if (isSpeedRestart && attempt < maxAttempts) { + lastError = new Error('Geschwindigkeit zu niedrig - Neustart'); + continue; + } - // Success - const elapsed = Math.round((Date.now() - jobStart) / 1000); - this._emitProgress(uploadId, fileName, task.hoster, { - status: 'done', progress: 1, - bytesUploaded: fileSize, bytesTotal: fileSize, - speedKbs: currentSpeedKbs, elapsed, remaining: 0, - error: null, result, attempt, maxAttempts - }); - - results.get(task.file).results.push({ - hoster: task.hoster, status: 'done', ...result - }); - - semaphore.release(); - return; // Success — exit retry loop - - } catch (err) { - // Clear speed monitor interval and signal listeners on error - if (speedMonitor) { clearInterval(speedMonitor); speedMonitor = null; } - if (signalCleanup) { signalCleanup(); signalCleanup = null; } - if (speedAbort) { - // Check if this was a speed restart - try { speedAbort.abort(); } catch {} - } - this.activeJobs.delete(uploadId); - - if (signal.aborted) { lastError = err; - break; + if (attempt >= maxAttempts) break; + } finally { + if (speedMonitor) clearInterval(speedMonitor); + uploadSignalBundle.cleanup(); } - - // Check if speed restart (not user abort) - const isSpeedRestart = speedAbort && speedAbort.signal.aborted && !signal.aborted; - if (isSpeedRestart && attempt < maxAttempts) { - lastError = new Error('Geschwindigkeit zu niedrig - Neustart'); - continue; - } - - lastError = err; - if (attempt >= maxAttempts) break; } + + const wasStopped = this.stopAfterActive && !signal.aborted; + const wasAborted = signal.aborted || this.cancelledJobIds.has(jobId); + if (wasStopped || wasAborted) { + const error = wasStopped ? 'Warteschlange angehalten' : 'Abgebrochen'; + emitFinalStatus('aborted', { error }); + recordFinalResult('aborted', { error }); + return; + } + + const error = lastError && lastError.message ? lastError.message : 'Unbekannter Fehler'; + emitFinalStatus('error', { error }); + recordFinalResult('error', { error }); + } catch (err) { + const wasStopped = this.stopAfterActive && !signal.aborted; + const error = wasStopped + ? 'Warteschlange angehalten' + : (signal.aborted || this.cancelledJobIds.has(jobId) ? 'Abgebrochen' : (err && err.message ? err.message : 'Unbekannter Fehler')); + const status = signal.aborted || this.cancelledJobIds.has(jobId) || wasStopped ? 'aborted' : 'error'; + emitFinalStatus(status, { error }); + recordFinalResult(status === 'error' ? 'error' : 'aborted', { error }); + } finally { + this.activeJobs.delete(uploadId); + this.jobAbortControllers.delete(jobId); + cleanupSignals(); + if (hosterSlotAcquired) hosterSemaphore.release(); + if (globalSlotAcquired && globalSemaphore) globalSemaphore.release(); } - - // All attempts exhausted - this.activeJobs.delete(uploadId); - const errorMsg = signal.aborted - ? 'Abgebrochen' - : (lastError ? lastError.message : 'Unbekannter Fehler'); - - this._emitProgress(uploadId, fileName, task.hoster, { - status: 'error', progress: 0, - bytesUploaded: 0, bytesTotal: fileSize, - speedKbs: 0, elapsed: 0, remaining: 0, - error: errorMsg, result: null, - attempt: maxAttempts, maxAttempts - }); - - results.get(task.file).results.push({ - hoster: task.hoster, status: 'error', error: errorMsg, - download_url: null, embed_url: null, file_code: null - }); - - semaphore.release(); } _emitProgress(uploadId, fileName, hoster, data) { @@ -365,20 +408,18 @@ class UploadManager extends EventEmitter { } const elapsed = Math.round((Date.now() - this.startTime) / 1000); - - // Sum in-progress bytes for live total let inProgressBytes = 0; for (const job of this.activeJobs.values()) { inProgressBytes += job.bytesUploaded || 0; } this.emit('stats', { - state: this.running ? 'uploading' : 'idle', + state: this.running ? (this.stopAfterActive ? 'stopping' : 'uploading') : 'idle', globalSpeedKbs, totalBytes: this.sessionBytes + inProgressBytes, elapsed, activeJobs: activeCount, - pendingJobs: Object.values(this.semaphores).reduce((sum, s) => sum + s.pending, 0) + pendingJobs: Object.values(this.semaphores).reduce((sum, semaphore) => sum + semaphore.pending, 0) }); }, 1000); } @@ -392,39 +433,101 @@ class UploadManager extends EventEmitter { _combineSignals(signal1, signal2) { const controller = new AbortController(); - if (signal1.aborted || signal2.aborted) { controller.abort(); return { signal: controller.signal, cleanup() {} }; } + if (signal1.aborted || signal2.aborted) { + controller.abort(); + return { signal: controller.signal, cleanup() {} }; + } + + const onAbort = () => { + controller.abort(); + cleanup(); + }; + const cleanup = () => { signal1.removeEventListener('abort', onAbort); signal2.removeEventListener('abort', onAbort); }; - const onAbort = () => { controller.abort(); cleanup(); }; + signal1.addEventListener('abort', onAbort, { once: true }); signal2.addEventListener('abort', onAbort, { once: true }); return { signal: controller.signal, cleanup }; } + _combineManySignals(signals) { + const liveSignals = signals.filter(Boolean); + const controller = new AbortController(); + + if (liveSignals.some((signal) => signal.aborted)) { + controller.abort(); + return { signal: controller.signal, cleanup() {} }; + } + + const listeners = liveSignals.map((signal) => { + const handler = () => { + controller.abort(); + cleanup(); + }; + signal.addEventListener('abort', handler, { once: true }); + return { signal, handler }; + }); + + const cleanup = () => { + for (const entry of listeners) { + entry.signal.removeEventListener('abort', entry.handler); + } + }; + + return { signal: controller.signal, cleanup }; + } + _sleep(ms, signal) { return new Promise((resolve, reject) => { - const onAbort = () => { clearTimeout(timer); reject(new Error('Aborted')); }; + const onAbort = () => { + clearTimeout(timer); + reject(new Error('Aborted')); + }; + const timer = setTimeout(() => { if (signal) signal.removeEventListener('abort', onAbort); resolve(); }, ms); + if (signal) { - if (signal.aborted) { clearTimeout(timer); reject(new Error('Aborted')); return; } + if (signal.aborted) { + clearTimeout(timer); + reject(new Error('Aborted')); + return; + } signal.addEventListener('abort', onAbort, { once: true }); } }); } - cancel() { - if (this.running) { - this.abortController.abort(); - this.running = false; - this._stopStatsTimer(); - this.activeJobs.clear(); + cancelJobs(jobIds) { + for (const jobId of jobIds || []) { + if (!jobId) continue; + this.cancelledJobIds.add(jobId); + const controller = this.jobAbortControllers.get(jobId); + if (controller && !controller.signal.aborted) { + controller.abort(); + } } } + + finishAfterActive() { + this.stopAfterActive = true; + } + + cancel() { + if (!this.running) return; + this.abortController.abort(); + this.stopAfterActive = false; + this.running = false; + for (const controller of this.jobAbortControllers.values()) { + if (!controller.signal.aborted) controller.abort(); + } + this._stopStatsTimer(); + } } module.exports = UploadManager; diff --git a/main.js b/main.js index 0436738..540f8db 100644 --- a/main.js +++ b/main.js @@ -126,6 +126,46 @@ function buildUploadTasks(config, files, hosters) { return tasks; } +function buildUploadTasksFromJobs(config, jobs) { + if (!Array.isArray(jobs)) return []; + + return jobs.flatMap((job) => { + if (!job || !job.file || !job.hoster) return []; + const hosterConfig = config.hosters[job.hoster]; + if (!hosterConfig) { + debugLog(` skip ${job.hoster}: no config for queued job`); + return []; + } + + const baseTask = { + jobId: job.id || job.jobId || null, + file: job.file, + hoster: job.hoster + }; + + if (job.hoster === 'vidmoly.me') { + if (!hosterConfig.username || !hosterConfig.password) { + debugLog(` skip ${job.hoster}: missing username/password`); + return []; + } + return [{ ...baseTask, username: hosterConfig.username, password: hosterConfig.password }]; + } + + if ((job.hoster === 'voe.sx' || job.hoster === 'doodstream.com') && hosterConfig.username && hosterConfig.password) { + debugLog(` task: ${job.hoster} queued login=${hosterConfig.username.slice(0, 6)}...`); + return [{ ...baseTask, username: hosterConfig.username, password: hosterConfig.password, apiKey: hosterConfig.apiKey || '' }]; + } + + if (!hosterConfig.apiKey) { + debugLog(` skip ${job.hoster}: missing apiKey`); + return []; + } + + debugLog(` task: ${job.hoster} queued key=${hosterConfig.apiKey.slice(0, 6)}...`); + return [{ ...baseTask, apiKey: hosterConfig.apiKey }]; + }); +} + async function checkDoodstreamHealth(hosterConfig) { const username = hosterConfig && hosterConfig.username ? String(hosterConfig.username).trim() @@ -158,7 +198,7 @@ async function checkDoodstreamHealth(hosterConfig) { }); const accountPayload = await accountRes.json().catch(() => null); if (!accountPayload || typeof accountPayload !== 'object') { - return { status: 'error', message: 'Account-Check lieferte kein gueltiges JSON' }; + return { status: 'error', message: 'Account-Check lieferte kein gültiges JSON' }; } if (Number(accountPayload.status || 0) !== 200) { @@ -174,25 +214,25 @@ async function checkDoodstreamHealth(hosterConfig) { }); const serverPayload = await serverRes.json().catch(() => null); if (!serverPayload || typeof serverPayload !== 'object') { - return { status: 'warn', message: 'Upload-Server-Check lieferte kein gueltiges JSON' }; + return { status: 'warn', message: 'Upload-Server-Check lieferte kein gültiges JSON' }; } const serverResult = serverPayload.result; if (typeof serverResult === 'string' && /^https?:\/\//i.test(serverResult.trim())) { - return { status: 'ok', message: 'API Key gueltig, Upload-Server verfuegbar' }; + return { status: 'ok', message: 'API Key gültig, Upload-Server verfügbar' }; } const serverMsg = String(serverPayload.msg || serverPayload.message || '').trim(); if (/no servers available/i.test(serverMsg)) { return { status: 'warn', - message: 'API Key gueltig, aktuell kein Server von API (Uploader nutzt Fallback)' + message: 'API Key gültig, aktuell kein Server von API (Uploader nutzt Fallback)' }; } return { status: 'warn', - message: serverMsg || 'API Key gueltig, Upload-Server aktuell nicht geliefert' + message: serverMsg || 'API Key gültig, Upload-Server aktuell nicht geliefert' }; } @@ -242,13 +282,13 @@ async function checkVoeHealth(hosterConfig) { const res = await fetch(`https://voe.sx/api/upload/server?key=${encodeURIComponent(apiKey)}`, { method: 'GET' }); const data = await res.json().catch(() => null); if (data && data.result && typeof data.result === 'string' && /^https?:\/\//i.test(data.result.trim())) { - return { status: 'ok', message: 'API Key gueltig, Upload-Server verfuegbar' }; + return { status: 'ok', message: 'API Key gültig, Upload-Server verfügbar' }; } const msg = data && (data.msg || data.message) ? String(data.msg || data.message).trim() : ''; if (/no servers/i.test(msg)) { - return { status: 'warn', message: 'API Key gueltig, aktuell kein Server verfuegbar' }; + return { status: 'warn', message: 'API Key gültig, aktuell kein Server verfügbar' }; } - return { status: 'error', message: msg || 'API Key ungueltig oder Server nicht erreichbar' }; + return { status: 'error', message: msg || 'API Key ungültig oder Server nicht erreichbar' }; } const uploader = new VoeUploader(); @@ -283,12 +323,12 @@ async function checkByseHealth(hosterConfig) { const serverPayload = await serverRes.json().catch(() => null); if (!serverPayload || typeof serverPayload !== 'object') { - return { status: 'error', message: 'API lieferte kein gueltiges JSON' }; + return { status: 'error', message: 'API lieferte kein gültiges JSON' }; } const serverResult = serverPayload.result; if (typeof serverResult === 'string' && /^https?:\/\//i.test(serverResult.trim())) { - return { status: 'ok', message: 'API Key gueltig, Upload-Server verfuegbar' }; + return { status: 'ok', message: 'API Key gültig, Upload-Server verfügbar' }; } const msg = String(serverPayload.msg || serverPayload.message || '').trim(); @@ -296,7 +336,7 @@ async function checkByseHealth(hosterConfig) { return { status: 'error', message: msg }; } - return { status: 'error', message: 'API Key ungueltig oder Server nicht erreichbar' }; + return { status: 'error', message: 'API Key ungültig oder Server nicht erreichbar' }; } async function runHosterHealthCheck(config, requestedHosters) { @@ -478,18 +518,22 @@ ipcMain.handle('select-folder', async () => { ipcMain.handle('start-upload', (_event, payload) => { const config = configStore.load(); - const { files, hosters } = payload; + const files = payload && Array.isArray(payload.files) ? payload.files : []; + const hosters = payload && Array.isArray(payload.hosters) ? payload.hosters : []; + const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : []; - debugLog(`start-upload: files=${JSON.stringify(files)}, hosters=${JSON.stringify(hosters)}`); + debugLog(`start-upload: files=${JSON.stringify(files)}, hosters=${JSON.stringify(hosters)}, jobs=${jobs.length}`); - const tasks = buildUploadTasks(config, files, hosters); + const tasks = jobs.length > 0 + ? buildUploadTasksFromJobs(config, jobs) + : buildUploadTasks(config, files, hosters); debugLog(` tasks built: ${tasks.length}`); - if (tasks.length === 0) return { error: 'Keine gueltigen Zugangsdaten fuer die gewaehlten Hoster.' }; + if (tasks.length === 0) return { error: 'Keine gültigen Zugangsdaten für die gewählten Hoster.' }; // Pass hoster settings to the upload manager - uploadManager = new UploadManager(config.hosterSettings || {}); + uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}); uploadManager.on('progress', (data) => { // Only log state changes, not continuous progress updates @@ -528,6 +572,7 @@ ipcMain.handle('start-upload', (_event, payload) => { // Shutdown after finish handleShutdownAfterFinish(); + uploadManager = null; }); // Defer startBatch to next tick so the IPC response is sent first. @@ -559,7 +604,20 @@ ipcMain.handle('start-upload', (_event, payload) => { ipcMain.handle('cancel-upload', () => { if (uploadManager) { uploadManager.cancel(); - uploadManager = null; + } + return true; +}); + +ipcMain.handle('cancel-selected-jobs', (_event, jobIds) => { + if (uploadManager) { + uploadManager.cancelJobs(Array.isArray(jobIds) ? jobIds : []); + } + return true; +}); + +ipcMain.handle('finish-after-active', () => { + if (uploadManager) { + uploadManager.finishAfterActive(); } return true; }); diff --git a/package.json b/package.json index 423f35c..b98cad2 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "main.js", "scripts": { "start": "electron .", - "test": "node --test tests/", + "test": "node --test tests/*.test.js tests/ui-smoke.js", "dist": "electron-builder --win", "release:win": "electron-builder --publish never --win nsis portable", "release:gitea": "node scripts/release_gitea.mjs" diff --git a/preload.js b/preload.js index e7b10de..9e18cfa 100644 --- a/preload.js +++ b/preload.js @@ -31,6 +31,8 @@ contextBridge.exposeInMainWorld('api', { // Upload control startUpload: (payload) => ipcRenderer.invoke('start-upload', payload), cancelUpload: () => ipcRenderer.invoke('cancel-upload'), + cancelSelectedJobs: (jobIds) => ipcRenderer.invoke('cancel-selected-jobs', jobIds), + finishAfterActive: () => ipcRenderer.invoke('finish-after-active'), runHealthCheck: (payload) => ipcRenderer.invoke('run-health-check', payload), // Clipboard diff --git a/renderer/app.js b/renderer/app.js index 0428c40..ffd79b1 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -7,10 +7,12 @@ let config = { hosters: {}, hosterSettings: {}, globalSettings: {} }; let hosterSettings = {}; let uploading = false; let healthCheckRunning = false; -let accountStatuses = {}; // { 'voe.sx': { status: 'ok'|'error'|'checking'|'unchecked', message: '' } } +let accountStatuses = {}; // { 'voe.sx': { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } } let editingAccountHoster = null; // null = adding, string = editing let autoHealthCheckEnabled = true; let queuePersistTimer = null; +let settingsSaveTimer = null; +let lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: 0, elapsed: 0, activeJobs: 0 }; const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload'; // Queue state @@ -32,6 +34,7 @@ async function init() { config = await window.api.getConfig(); hosterSettings = config.hosterSettings || {}; autoHealthCheckEnabled = loadAutoCheckPreference(); + ensureAccountStatusEntries(); syncSelectedUploadHosters(); restoreQueueStateFromConfig(); renderHosterSummary(); @@ -43,12 +46,7 @@ async function init() { loadHistory(); renderRecentUploadsPanel(); updateUploadView(); - - if (shouldAutoResumeQueue()) { - setTimeout(() => { - if (!uploading) startUpload(); - }, 350); - } + updateStatusBar(); // Version display try { @@ -83,6 +81,8 @@ async function init() { const onTop = await window.api.getAlwaysOnTop(); alwaysOnTopState = !!onTop; } catch {} + + scheduleStartupAccountCheck(); } // --- Tab switching --- @@ -127,16 +127,70 @@ function getSelectedHosters() { return selectedUploadHosters.slice(); } +function getHosterLabel(name) { + const labels = { + 'doodstream.com': 'Doodstream', + 'voe.sx': 'VOE', + 'vidmoly.me': 'Vidmoly', + 'byse.sx': 'Byse' + }; + return labels[name] || name; +} + +function getAccountModeParts(name, hoster) { + if (!hoster) return []; + const hasLogin = !!(hoster.username && hoster.password); + const hasApi = !!hoster.apiKey; + + if (name === 'vidmoly.me') return hasLogin ? ['Login Web'] : []; + if (name === 'byse.sx') return hasApi ? ['API'] : []; + + const parts = []; + if (hasLogin) parts.push('Login Web'); + if (hasApi) parts.push('API'); + return parts; +} + +function getAccountDisplayName(name, hoster) { + const parts = getAccountModeParts(name, hoster); + return parts.length > 0 + ? `${getHosterLabel(name)} (${parts.join(' + ')})` + : getHosterLabel(name); +} + +function maskCredential(value, keep = 4) { + const text = String(value || '').trim(); + if (!text) return ''; + if (text.length <= keep) return text; + return `${text.slice(0, keep)}…${text.slice(-2)}`; +} + +function ensureAccountStatusEntries() { + const nextStatuses = {}; + for (const { name } of getAccountsWithCreds()) { + nextStatuses[name] = accountStatuses[name] || { status: 'unchecked', message: '' }; + } + accountStatuses = nextStatuses; +} + +function scheduleStartupAccountCheck() { + const accounts = getAccountsWithCreds(); + if (!accounts.length) return; + setTimeout(() => { + runHealthCheck('startup').catch(() => {}); + }, 500); +} + function renderHosterSummary() { const summary = document.getElementById('hosterSummary'); if (!summary) return; const hosters = getSelectedHosters(); if (hosters.length === 0) { - summary.textContent = 'Keine Upload-Ziele ausgewaehlt'; + summary.textContent = 'Keine Upload-Ziele ausgewählt'; } else if (hosters.length === 1) { - summary.textContent = `Aktives Ziel: ${hosters[0]}`; + summary.textContent = `Aktives Ziel: ${getAccountDisplayName(hosters[0], config.hosters[hosters[0]] || {})}`; } else { - summary.textContent = `${hosters.length} Ziele aktiv: ${hosters.join(', ')}`; + summary.textContent = `${hosters.length} Ziele aktiv: ${hosters.map((name) => getHosterLabel(name)).join(', ')}`; } } @@ -148,7 +202,7 @@ function renderHosterModal() { const available = getAvailableHosters(); if (available.length === 0) { list.innerHTML = ''; - hint.textContent = 'Keine Hoster mit Zugangsdaten vorhanden. Bitte zuerst in den Einstellungen API-Key oder Login eintragen.'; + hint.textContent = 'Keine Hoster mit Zugangsdaten vorhanden. Bitte zuerst in den Accounts einen Login oder API-Key hinterlegen.'; return; } @@ -157,20 +211,21 @@ function renderHosterModal() { const h = config.hosters[item.name] || {}; const st = accountStatuses[item.name]; const subtitle = st && st.status === 'ok' ? 'Bereit' + : st && st.status === 'warn' ? 'Prüfung mit Warnung' : st && st.status === 'error' ? 'Login-Fehler' - : getCredentialLabel(item.name, h) + ' hinterlegt'; + : `${getCredentialLabel(item.name, h)} hinterlegt`; return ` `; }).join(''); - hint.textContent = 'Die Auswahl wird fuer neue Queue-Eintraege verwendet.'; + hint.textContent = 'Die Auswahl wird für neue Queue-Einträge verwendet.'; list.querySelectorAll('input[data-hoster-modal]').forEach(input => { input.addEventListener('change', () => { @@ -210,11 +265,12 @@ function cancelHosterModal() { } function normalizeRestoredJobStatus(status) { - if (status === 'done' || status === 'error' || status === 'skipped' || status === 'preview') return status; + if (status === 'done' || status === 'error' || status === 'skipped' || status === 'preview' || status === 'aborted') return status; return 'queued'; } function restoreQueueStateFromConfig() { + if (config?.globalSettings?.resumeQueueOnLaunch === false) return; const pending = config?.globalSettings?.pendingQueue; if (!pending || typeof pending !== 'object') return; @@ -254,7 +310,7 @@ function restoreQueueStateFromConfig() { } function buildPersistedQueueState() { - const unfinishedJobs = queueJobs.filter(job => job.status !== 'done' && job.status !== 'skipped'); + const unfinishedJobs = queueJobs.filter(job => !['done', 'skipped', 'aborted'].includes(job.status)); const selectedFileMap = new Map(selectedFiles.map(file => [file.path, file])); for (const job of unfinishedJobs) { @@ -267,7 +323,7 @@ function buildPersistedQueueState() { } } - if (selectedFileMap.size === 0 && queueJobs.every(job => job.status === 'done' || job.status === 'skipped')) { + if (selectedFileMap.size === 0 && queueJobs.every(job => ['done', 'skipped', 'aborted'].includes(job.status))) { return null; } @@ -322,11 +378,6 @@ function clearPersistedQueueStateSoon() { }, 0); } -function shouldAutoResumeQueue() { - if (!config?.globalSettings?.resumeQueueOnLaunch) return false; - return queueJobs.some(job => !['done', 'skipped'].includes(job.status)); -} - // --- File selection --- function setupDragDrop() { const dropZone = document.getElementById('dropZone'); @@ -408,16 +459,43 @@ function updateUploadView() { buildQueuePreview(); } } - updateStartButton(); + updateQueueActionButtons(); } function updateStartButton() { const btn = document.getElementById('startUploadBtn'); const hosters = getSelectedHosters(); - const hasFiles = selectedFiles.length > 0 || queueJobs.some(j => j.status === 'queued' || j.status === 'error' || j.status === 'preview'); + const hasFiles = queueJobs.some(j => j.status === 'queued' || j.status === 'preview'); btn.disabled = uploading || hosters.length === 0 || !hasFiles; } +function updateQueueActionButtons() { + updateStartButton(); + + const hasSelection = selectedJobIds.size > 0; + const hasUploadSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && ['done', 'error', 'aborted', 'skipped'].includes(job.status)); + const hasAbortSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && ['preview', 'queued', 'getting-server', 'uploading', 'retrying'].includes(job.status)); + const hasMovableSelection = hasSelection && !uploading; + + const reuploadBtn = document.getElementById('reuploadSelectedBtn'); + const abortSelectedBtn = document.getElementById('abortSelectedBtn'); + const finishStopBtn = document.getElementById('finishStopBtn'); + const abortAllBtn = document.getElementById('abortAllBtn'); + const moveTopBtn = document.getElementById('moveTopBtn'); + const moveUpBtn = document.getElementById('moveUpBtn'); + const moveDownBtn = document.getElementById('moveDownBtn'); + const moveBottomBtn = document.getElementById('moveBottomBtn'); + + if (reuploadBtn) reuploadBtn.disabled = !hasUploadSelection; + if (abortSelectedBtn) abortSelectedBtn.disabled = !hasAbortSelection; + if (finishStopBtn) finishStopBtn.disabled = !uploading; + if (abortAllBtn) abortAllBtn.disabled = !uploading; + if (moveTopBtn) moveTopBtn.disabled = !hasMovableSelection; + if (moveUpBtn) moveUpBtn.disabled = !hasMovableSelection; + if (moveDownBtn) moveDownBtn.disabled = !hasMovableSelection; + if (moveBottomBtn) moveBottomBtn.disabled = !hasMovableSelection; +} + // Build preview jobs from selected files x selected hosters (before upload starts) function buildQueuePreview() { const hosters = getSelectedHosters(); @@ -557,6 +635,7 @@ function renderQueueTable() { // Update retry button visibility const hasFailedJobs = queueJobs.some(j => j.status === 'error'); document.getElementById('retryFailedBtn').style.display = hasFailedJobs ? 'inline-block' : 'none'; + updateQueueActionButtons(); } function _renderVirtualRows(tbody) { @@ -611,20 +690,21 @@ function sortQueueJobs(jobs) { } function getStatusOrder(status) { - const order = { uploading: 0, 'getting-server': 1, retrying: 2, queued: 3, preview: 4, done: 5, error: 6, skipped: 7 }; + const order = { uploading: 0, 'getting-server': 1, retrying: 2, queued: 3, preview: 4, done: 5, aborted: 6, error: 7, skipped: 8 }; return order[status] ?? 4; } function getStatusText(job) { switch (job.status) { - case 'preview': return 'Ready'; - case 'queued': return 'Queued'; + case 'preview': return 'Bereit'; + case 'queued': return 'Wartet'; case 'getting-server': return 'Server...'; - case 'uploading': return 'Process'; + case 'uploading': return 'Upload'; case 'retrying': return `Retry ${job.attempt}/${job.maxAttempts}`; - case 'done': return 'Done'; - case 'error': return 'Failed'; - case 'skipped': return 'Skipped'; + case 'done': return 'Fertig'; + case 'aborted': return 'Abgebrochen'; + case 'error': return 'Fehlgeschlagen'; + case 'skipped': return 'Übersprungen'; default: return job.status; } } @@ -724,8 +804,10 @@ document.addEventListener('keydown', (e) => { return true; }); selectedJobIds.clear(); + syncSelectedFilesFromQueue(); renderQueueTable(); if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } + updateStatusBar(); persistQueueStateSoon(); } } @@ -747,10 +829,18 @@ async function handleContextAction(action) { } else if (action === 'retry-selected') { retrySelectedJobs(); } else if (action === 'delete-selected') { - queueJobs = queueJobs.filter(j => !selectedJobIds.has(j.id)); + queueJobs = queueJobs.filter(j => { + if (selectedJobIds.has(j.id)) { + removeJobFromIndex(j); + return false; + } + return true; + }); selectedJobIds.clear(); + syncSelectedFilesFromQueue(); renderQueueTable(); if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } + updateStatusBar(); persistQueueStateSoon(); } else if (action === 'copy-all-links') { copyAllLinks(); @@ -775,21 +865,11 @@ async function startUpload() { if (healthCheckRunning || uploading) return; const hosters = getSelectedHosters(); - if (hosters.length === 0) { alert('Bitte mindestens einen Hoster auswaehlen.'); return; } + if (hosters.length === 0) { alert('Bitte mindestens einen Hoster auswählen.'); return; } + if (queueJobs.length === 0 && selectedFiles.length > 0) buildQueuePreview(); - // Include files from preview/queued jobs that may not be in selectedFiles (e.g. retries) - const previewFiles = queueJobs - .filter(j => j.status === 'preview' || j.status === 'queued') - .map(j => j.file) - .filter(Boolean); - for (const fp of previewFiles) { - if (!selectedFiles.find(f => f.path === fp)) { - const job = queueJobs.find(j => j.file === fp); - selectedFiles.push({ path: fp, name: job ? job.fileName : fp.split(/[\\/]/).pop(), size: job ? job.bytesTotal : null }); - } - } - - if (selectedFiles.length === 0 && previewFiles.length === 0) return; + const jobsToStart = queueJobs.filter((job) => job.status === 'preview' || job.status === 'queued'); + if (jobsToStart.length === 0) return; // Auto health check if (autoHealthCheckEnabled) { @@ -813,43 +893,45 @@ async function startUpload() { } uploading = true; - // Convert preview jobs to queued - queueJobs.forEach(j => { if (j.status === 'preview') j.status = 'queued'; }); + queueJobs.forEach(j => { + if (j.status === 'preview') j.status = 'queued'; + }); + updateQueueActionButtons(); renderQueueTable(); - - document.getElementById('startUploadBtn').style.display = 'none'; - document.getElementById('cancelUploadBtn').style.display = 'inline-block'; + updateStatusBar(); const uploadPayload = { - files: selectedFiles.map(f => f.path), - hosters + hosters, + jobs: jobsToStart.map((job) => ({ + id: job.id, + file: job.file, + fileName: job.fileName, + hoster: job.hoster + })) }; - console.log('[startUpload] sending payload:', uploadPayload); const result = await window.api.startUpload(uploadPayload); - console.log('[startUpload] response:', result); persistQueueStateSoon(); if (result && result.error) { alert(result.error); uploading = false; - document.getElementById('startUploadBtn').style.display = 'inline-block'; - document.getElementById('cancelUploadBtn').style.display = 'none'; + updateQueueActionButtons(); + updateStatusBar(); } } async function cancelUpload() { await window.api.cancelUpload(); uploading = false; - document.getElementById('startUploadBtn').style.display = 'inline-block'; - document.getElementById('cancelUploadBtn').style.display = 'none'; - updateStartButton(); + updateQueueActionButtons(); + updateStatusBar(); persistQueueStateSoon(); } // --- Progress handling --- function handleProgress(data) { - // Find matching job: O(1) by uploadId, fallback to linear search - let job = data.uploadId ? _jobIndexByUploadId.get(data.uploadId) : null; + let job = data.jobId ? _jobIndexById.get(data.jobId) : null; + if (!job && data.uploadId) job = _jobIndexByUploadId.get(data.uploadId); if (!job) { job = queueJobs.find(j => j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'queued' @@ -863,7 +945,7 @@ function handleProgress(data) { } if (!job) { job = { - id: data.uploadId || `job-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + id: data.jobId || data.uploadId || `job-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, uploadId: data.uploadId, file: '', fileName: data.fileName, hoster: data.hoster, status: data.status, bytesUploaded: 0, bytesTotal: data.bytesTotal || 0, @@ -886,45 +968,28 @@ function handleProgress(data) { job.attempt = data.attempt || 0; job.maxAttempts = data.maxAttempts || 0; job.progress = data.progress || 0; + if (data.uploadId) { + job.uploadId = data.uploadId; + _jobIndexByUploadId.set(data.uploadId, job); + } + + maybeAddSessionFile(job); scheduleQueueRender(); + updateQueueActionButtons(); + updateStatusBar(); persistQueueStateSoon(); } function handleBatchDone(summary) { - console.log('[batch-done]', summary); uploading = false; - selectedFiles = []; // Clear selected files after batch - document.getElementById('startUploadBtn').style.display = 'inline-block'; - document.getElementById('cancelUploadBtn').style.display = 'none'; - updateStartButton(); + applySummaryResults(summary); + syncSelectedFilesFromQueue(); + updateQueueActionButtons(); renderQueueTable(); - - // Add completed jobs to session files panel - const dt = formatDateTime(new Date()); - for (const job of queueJobs) { - if (job.status === 'done' && job.result) { - const link = job.result.download_url || job.result.embed_url || ''; - if (link && !sessionFilesData.some(s => s.link === link && s.filename === job.fileName && s.host === job.hoster)) { - sessionFilesData.push({ - date: dt.text, dateTs: dt.ts, - filename: job.fileName || '', host: job.hoster || '', - link, isError: false, order: sessionFilesData.length - }); - } - } else if (job.status === 'error') { - sessionFilesData.push({ - date: dt.text, dateTs: dt.ts, - filename: job.fileName || '', host: job.hoster || '', - link: `[Fehler] ${job.error || ''}`, isError: true, order: sessionFilesData.length - }); - } - } renderRecentUploadsPanel(); - loadHistory(); - // Auto-remove completed jobs from queue if enabled const removeOnDone = config.globalSettings && config.globalSettings.removeFromQueueOnDone; if (removeOnDone) { const doneJobs = queueJobs.filter(j => j.status === 'done'); @@ -936,34 +1001,37 @@ function handleBatchDone(summary) { renderQueueTable(); } - clearPersistedQueueStateSoon(); + if (queueJobs.some((job) => !['done', 'skipped', 'aborted'].includes(job.status))) persistQueueStateSoon(true); + else clearPersistedQueueStateSoon(); - // Final stats update - document.getElementById('sbState').textContent = 'Fertig'; + lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: lastUploadStats.totalBytes, elapsed: lastUploadStats.elapsed, activeJobs: 0 }; + updateStatusBar(); } function handleStats(data) { - // stats logging removed for perf - document.getElementById('sbState').textContent = data.state === 'uploading' ? 'Upload laeuft...' : 'Bereit'; - document.getElementById('sbSpeed').textContent = formatSpeed(data.globalSpeedKbs || 0); - document.getElementById('sbTotal').textContent = formatSize(data.totalBytes || 0); - document.getElementById('sbElapsed').textContent = formatTime(data.elapsed || 0); + lastUploadStats = { + state: data.state || 'idle', + globalSpeedKbs: data.globalSpeedKbs || 0, + totalBytes: data.totalBytes || 0, + elapsed: data.elapsed || 0, + activeJobs: data.activeJobs || 0 + }; + updateStatusBar(); } // --- Retry --- function retrySelectedJobs() { - // For now just mark failed jobs back to preview so user can restart queueJobs.forEach(j => { - if (selectedJobIds.has(j.id) && j.status === 'error') { + if (selectedJobIds.has(j.id) && ['error', 'done', 'aborted', 'skipped'].includes(j.status)) { j.status = 'preview'; j.error = null; + j.result = null; j.bytesUploaded = 0; j.speedKbs = 0; j.elapsed = 0; j.remaining = 0; j.progress = 0; j.uploadId = null; - // Re-add to selectedFiles if not present if (!selectedFiles.find(f => f.path === j.file || f.name === j.fileName)) { selectedFiles.push({ path: j.file, name: j.fileName, size: j.bytesTotal }); } @@ -971,10 +1039,196 @@ function retrySelectedJobs() { }); selectedJobIds.clear(); renderQueueTable(); - updateStartButton(); + updateQueueActionButtons(); + updateStatusBar(); persistQueueStateSoon(); } +async function abortSelectedJobs() { + const activeJobIds = []; + + queueJobs.forEach((job) => { + if (!selectedJobIds.has(job.id)) return; + + if (['preview', 'queued'].includes(job.status)) { + job.status = 'aborted'; + job.error = 'Abgebrochen'; + job.progress = 0; + job.uploadId = null; + } else if (['getting-server', 'uploading', 'retrying'].includes(job.status)) { + activeJobIds.push(job.id); + } + }); + + if (activeJobIds.length > 0) { + await window.api.cancelSelectedJobs(activeJobIds); + } + + selectedJobIds.clear(); + syncSelectedFilesFromQueue(); + renderQueueTable(); + updateQueueActionButtons(); + updateStatusBar(); + persistQueueStateSoon(true); +} + +async function finishUploadsInProgress() { + if (!uploading) return; + await window.api.finishAfterActive(); + lastUploadStats.state = 'stopping'; + updateStatusBar(); +} + +async function abortAllUploads() { + await cancelUpload(); +} + +function moveSelectedJobs(direction) { + if (uploading || selectedJobIds.size === 0) return; + + const jobs = queueJobs.slice(); + + if (direction === 'top') { + queueJobs = jobs.filter((job) => selectedJobIds.has(job.id)).concat(jobs.filter((job) => !selectedJobIds.has(job.id))); + } else if (direction === 'bottom') { + queueJobs = jobs.filter((job) => !selectedJobIds.has(job.id)).concat(jobs.filter((job) => selectedJobIds.has(job.id))); + } else if (direction === 'up') { + for (let i = 1; i < jobs.length; i++) { + if (selectedJobIds.has(jobs[i].id) && !selectedJobIds.has(jobs[i - 1].id)) { + [jobs[i - 1], jobs[i]] = [jobs[i], jobs[i - 1]]; + } + } + queueJobs = jobs; + } else if (direction === 'down') { + for (let i = jobs.length - 2; i >= 0; i--) { + if (selectedJobIds.has(jobs[i].id) && !selectedJobIds.has(jobs[i + 1].id)) { + [jobs[i], jobs[i + 1]] = [jobs[i + 1], jobs[i]]; + } + } + queueJobs = jobs; + } + + rebuildJobIndex(); + renderQueueTable(); + updateStatusBar(); + persistQueueStateSoon(true); +} + +function syncSelectedFilesFromQueue() { + const fileMap = new Map(); + queueJobs + .filter((job) => !['done', 'skipped', 'aborted'].includes(job.status)) + .forEach((job) => { + if (!job.file || fileMap.has(job.file)) return; + fileMap.set(job.file, { + path: job.file, + name: job.fileName, + size: job.bytesTotal || 0 + }); + }); + selectedFiles = Array.from(fileMap.values()); +} + +function maybeAddSessionFile(job) { + if (!job) return; + + const dt = formatDateTime(new Date()); + if (job.status === 'done' && job.result) { + const link = job.result.download_url || job.result.embed_url || ''; + if (!link) return; + if (!sessionFilesData.some((row) => row.link === link && row.filename === job.fileName && row.host === job.hoster)) { + sessionFilesData.push({ + date: dt.text, + dateTs: dt.ts, + filename: job.fileName || '', + host: job.hoster || '', + link, + isError: false, + order: sessionFilesData.length + }); + renderRecentUploadsPanel(); + } + } + + if (job.status === 'error') { + const errorText = `[Fehler] ${job.error || ''}`; + if (!sessionFilesData.some((row) => row.isError && row.filename === job.fileName && row.host === job.hoster && row.link === errorText)) { + sessionFilesData.push({ + date: dt.text, + dateTs: dt.ts, + filename: job.fileName || '', + host: job.hoster || '', + link: errorText, + isError: true, + order: sessionFilesData.length + }); + renderRecentUploadsPanel(); + } + } +} + +function applySummaryResults(summary) { + const files = Array.isArray(summary?.files) ? summary.files : []; + for (const file of files) { + for (const result of file.results || []) { + const job = queueJobs.find((entry) => entry.fileName === file.name && entry.hoster === result.hoster); + if (!job) continue; + if (result.status === 'done') { + job.status = 'done'; + job.result = { + download_url: result.download_url || null, + embed_url: result.embed_url || null, + file_code: result.file_code || null + }; + job.error = null; + job.progress = 1; + job.bytesUploaded = job.bytesTotal || file.size || 0; + } else if (result.status === 'aborted') { + job.status = 'aborted'; + job.error = result.error || 'Abgebrochen'; + } else if (result.status === 'error') { + job.status = 'error'; + job.error = result.error || 'Fehlgeschlagen'; + } + maybeAddSessionFile(job); + } + } +} + +function updateStatusBar() { + const counts = { + total: queueJobs.length, + remaining: queueJobs.filter((job) => ['preview', 'queued', 'getting-server', 'uploading', 'retrying'].includes(job.status)).length, + inProgress: queueJobs.filter((job) => ['getting-server', 'uploading', 'retrying'].includes(job.status)).length, + error: queueJobs.filter((job) => job.status === 'error').length + }; + + const bytesRemaining = queueJobs + .filter((job) => ['getting-server', 'uploading', 'retrying', 'queued', 'preview'].includes(job.status)) + .reduce((sum, job) => sum + Math.max(0, (job.bytesTotal || 0) - (job.bytesUploaded || 0)), 0); + const etaSeconds = lastUploadStats.globalSpeedKbs > 0 + ? Math.round(bytesRemaining / (lastUploadStats.globalSpeedKbs * 1024)) + : 0; + + const stateText = lastUploadStats.state === 'uploading' + ? 'Upload läuft...' + : lastUploadStats.state === 'stopping' + ? 'Stoppt nach aktiven Uploads...' + : uploading + ? 'Upload vorbereitet...' + : 'Bereit'; + + document.getElementById('sbState').textContent = stateText; + document.getElementById('sbSpeed').textContent = formatSpeed(lastUploadStats.globalSpeedKbs || 0); + document.getElementById('sbTotal').textContent = formatSize(lastUploadStats.totalBytes || 0); + document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`; + document.getElementById('sbConnections').textContent = `Aktive Verbindungen ${lastUploadStats.activeJobs || 0}`; + document.getElementById('sbQueueCount').textContent = `Gesamt ${counts.total}`; + document.getElementById('sbRemainingCount').textContent = `Remaining ${counts.remaining}`; + document.getElementById('sbInProgressCount').textContent = `In Progress ${counts.inProgress}`; + document.getElementById('sbErrorCount').textContent = `Error ${counts.error}`; +} + // --- Health Check --- function setHealthCheckStatus(text) { // Minimal inline status @@ -988,7 +1242,7 @@ function renderHealthCheckResults(results) { container.innerHTML = results.map(item => { const status = item.status || 'skipped'; return `
Noch keine Account-Einstellungen vorhanden.
Sobald du einen Account anlegst, erscheinen hier die passenden Upload-Einstellungen.'; + container.appendChild(empty); + } + + for (const { name, hoster } of configuredAccounts) { const hs = hosterSettings[name] || {}; + const maxSpeedMbs = hs.maxSpeedKbs > 0 ? (hs.maxSpeedKbs / 1024).toFixed(2).replace(/\.00$/, '') : '0'; const panel = document.createElement('div'); panel.className = 'hoster-settings-panel'; @@ -1059,38 +1354,38 @@ function renderSettings() { panel.innerHTML = `Keine Accounts vorhanden
- Klicke auf "Account hinzufuegen" um einen Hoster einzurichten. + Klicke auf "Account hinzufügen", um einen Hoster einzurichten.Dateien hierher ziehen oder klicken