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 `
- ${escapeHtml(item.hoster || '')} + ${escapeHtml(item.hoster ? getHosterLabel(item.hoster) : '')} [${status.toUpperCase()}] ${escapeHtml(item.message || '')}
`; @@ -999,22 +1253,42 @@ async function executeHealthCheck(hosters, mode) { renderHealthCheckResults([]); const result = await window.api.runHealthCheck({ hosters }); const rows = result && Array.isArray(result.results) ? result.results : []; + rows.forEach((row) => { + if (!row || !row.hoster) return; + accountStatuses[row.hoster] = { + status: row.status || 'unchecked', + message: row.message || '' + }; + }); renderHealthCheckResults(rows); + renderAccounts(); + renderHosterModal(); return rows; } -async function runHealthCheck() { - if (healthCheckRunning || uploading) return; - const hosters = getSelectedHosters().filter(n => n === 'doodstream.com' || n === 'vidmoly.me' || n === 'voe.sx' || n === 'byse.sx'); +async function runHealthCheck(mode = 'manual', requestedHosters = null) { + if (healthCheckRunning || (uploading && mode === 'manual')) return []; + const hosters = Array.isArray(requestedHosters) && requestedHosters.length > 0 + ? requestedHosters + : HOSTERS.filter((name) => hosterHasCredentials(name, config.hosters[name] || {})); if (hosters.length === 0) { - const allHosters = ['doodstream.com', 'vidmoly.me', 'voe.sx', 'byse.sx'].filter(n => hosterHasCredentials(n, config.hosters[n] || {})); - if (allHosters.length === 0) { alert('Keine Hoster mit Zugangsdaten fuer Health-Check.'); return; } - hosters.push(...allHosters); + if (mode === 'manual') alert('Keine Hoster mit Zugangsdaten für einen Check.'); + return []; } healthCheckRunning = true; - try { await executeHealthCheck(hosters, 'manual'); } - catch (err) { renderHealthCheckResults([{ hoster: 'system', status: 'error', message: err.message }]); } - finally { healthCheckRunning = false; } + hosters.forEach((hoster) => { + accountStatuses[hoster] = { status: 'checking', message: '' }; + }); + renderAccounts(); + try { + return await executeHealthCheck(hosters, mode); + } catch (err) { + renderHealthCheckResults([{ hoster: 'System', status: 'error', message: err.message }]); + return []; + } finally { + healthCheckRunning = false; + renderAccounts(); + } } // --- Settings --- @@ -1023,6 +1297,7 @@ function renderSettings() { container.innerHTML = ''; const globalSettings = config.globalSettings || {}; + const configuredAccounts = getAccountsWithCreds(); const generalPanel = document.createElement('div'); generalPanel.className = 'hoster-settings-panel'; generalPanel.innerHTML = ` @@ -1034,24 +1309,44 @@ function renderSettings() {
- - + +
- - + + +
+
+ + Stellt die Queue beim Start wieder her, startet aber pausiert. +
+
+ + + 0 = nur pro Hoster +
+
+ +
- +
`; container.appendChild(generalPanel); - for (const name of HOSTERS) { - const hoster = config.hosters[name] || {}; + if (configuredAccounts.length === 0) { + const empty = document.createElement('div'); + empty.className = 'settings-empty'; + empty.innerHTML = '

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 = `
- ${name} - ${hosterHasCredentials(name, hoster) ? 'Aktiv' : 'Inaktiv'} + ${escapeHtml(getAccountDisplayName(name, hoster))} + Aktiv
@@ -1109,6 +1404,10 @@ function renderSettings() { } document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath); + container.querySelectorAll('.settings-autosave').forEach((input) => { + const eventName = input.type === 'checkbox' ? 'change' : 'input'; + input.addEventListener(eventName, scheduleSettingsSave); + }); } async function chooseLogFilePath() { @@ -1116,22 +1415,38 @@ async function chooseLogFilePath() { if (!folders || !folders[0]) return; const normalized = folders[0].replace(/[\\\/]+$/, ''); document.getElementById('logFilePathInput').value = `${normalized}\\fileuploader.log`; + scheduleSettingsSave(); } -async function saveSettings() { - const newHosterSettings = {}; +function scheduleSettingsSave() { + const feedback = document.getElementById('saveFeedback'); + if (feedback) feedback.textContent = 'Speichert...'; + clearTimeout(settingsSaveTimer); + settingsSaveTimer = setTimeout(() => { + saveSettings({ feedbackText: 'Automatisch gespeichert' }).catch((err) => { + if (feedback) feedback.textContent = `Speichern fehlgeschlagen: ${err.message}`; + }); + }, 350); +} + +async function saveSettings(options = {}) { + const { feedbackText = 'Gespeichert!' } = options; + const newHosterSettings = { ...(config.hosterSettings || {}) }; const globalSettings = { ...(config.globalSettings || {}), logFilePath: (document.getElementById('logFilePathInput')?.value || '').trim(), resumeQueueOnLaunch: !!document.getElementById('resumeQueueOnLaunchInput')?.checked, + parallelUploadCount: Math.max(0, Math.min(100, parseInt(document.getElementById('parallelUploadCountInput')?.value || '0', 10) || 0)), + scaleParallelUploads: !!document.getElementById('scaleParallelUploadsInput')?.checked, removeFromQueueOnDone: !!document.getElementById('removeFromQueueOnDoneInput')?.checked }; for (const name of HOSTERS) { - const hs = {}; + const hs = { ...(hosterSettings[name] || {}) }; document.querySelectorAll(`.hs-input[data-hoster="${name}"]`).forEach(input => { const field = input.dataset.hs; - hs[field] = parseInt(input.value) || 0; + if (field === 'maxSpeedMbs') hs.maxSpeedKbs = Math.max(0, Math.round((parseFloat(input.value) || 0) * 1024)); + else hs[field] = parseInt(input.value, 10) || 0; }); newHosterSettings[name] = hs; } @@ -1140,11 +1455,15 @@ async function saveSettings() { await window.api.saveGlobalSettings(globalSettings); config = await window.api.getConfig(); hosterSettings = config.hosterSettings || {}; - renderSettings(); + clearTimeout(settingsSaveTimer); const feedback = document.getElementById('saveFeedback'); - feedback.textContent = 'Gespeichert!'; - setTimeout(() => { feedback.textContent = ''; }, 2000); + feedback.textContent = feedbackText; + setTimeout(() => { + if (feedback.textContent === feedbackText) { + feedback.textContent = 'Änderungen werden automatisch gespeichert.'; + } + }, 1800); } // --- Accounts --- @@ -1159,47 +1478,54 @@ function getHostersWithoutCreds() { } function getCredentialLabel(name, hoster) { - if (name === 'vidmoly.me') return hoster.username || 'Login'; - if (name === 'voe.sx') return hoster.username && hoster.password ? (hoster.username || 'Login') : 'API-Key'; - if (name === 'doodstream.com') return hoster.username && hoster.password ? (hoster.username || 'Login') : 'API-Key'; - return 'API-Key'; + if (name === 'vidmoly.me') return `Login: ${hoster.username || 'nicht gesetzt'}`; + if (name === 'voe.sx' || name === 'doodstream.com') { + const parts = []; + if (hoster.username && hoster.password) parts.push(`Login: ${hoster.username}`); + if (hoster.apiKey) parts.push(`API: ${maskCredential(hoster.apiKey)}`); + return parts.join(' • ') || 'Keine Zugangsdaten'; + } + return `API: ${maskCredential(hoster.apiKey) || 'nicht gesetzt'}`; } function renderAccounts() { const container = document.getElementById('accountsList'); if (!container) return; + ensureAccountStatusEntries(); const accounts = getAccountsWithCreds(); + const runCheckBtn = document.getElementById('accountsRunHealthCheckBtn'); + if (runCheckBtn) runCheckBtn.disabled = healthCheckRunning; if (accounts.length === 0) { container.innerHTML = `

Keine Accounts vorhanden

- Klicke auf "Account hinzufuegen" um einen Hoster einzurichten. + Klicke auf "Account hinzufügen", um einen Hoster einzurichten.
`; return; } container.innerHTML = accounts.map(({ name, hoster }) => { const st = accountStatuses[name] || { status: 'unchecked', message: '' }; - const statusLabels = { ok: 'Bereit', checking: 'Pruefe...', error: 'Fehler', unchecked: 'Nicht geprueft' }; - const statusLabel = statusLabels[st.status] || 'Nicht geprueft'; + const statusLabels = { ok: 'Bereit', warn: 'Warnung', checking: 'Prüfe...', error: 'Fehler', unchecked: 'Nicht geprüft' }; + const statusLabel = statusLabels[st.status] || 'Nicht geprüft'; const credLabel = getCredentialLabel(name, hoster); return `
- - + +
${statusLabel}
- + - +
`; }).join(''); @@ -1217,18 +1543,18 @@ function renderAccounts() { } async function checkSingleAccount(hosterName) { + if (!hosterName || healthCheckRunning) return; + healthCheckRunning = true; accountStatuses[hosterName] = { status: 'checking', message: '' }; renderAccounts(); try { - const rows = await executeHealthCheck([hosterName], 'auto'); + const rows = await executeHealthCheck([hosterName], 'manual'); const row = rows.find(r => r.hoster === hosterName); - if (row && row.status === 'ok') { - accountStatuses[hosterName] = { status: 'ok', message: row.message || '' }; - } else { - accountStatuses[hosterName] = { status: 'error', message: (row && row.message) || 'Pruefung fehlgeschlagen' }; - } + if (row) accountStatuses[hosterName] = { status: row.status || 'error', message: row.message || '' }; } catch (err) { - accountStatuses[hosterName] = { status: 'error', message: err.message || 'Pruefung fehlgeschlagen' }; + accountStatuses[hosterName] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' }; + } finally { + healthCheckRunning = false; } renderAccounts(); } @@ -1251,11 +1577,11 @@ function getCredsFieldsHtml(name, hoster) { return `
- +
- +
@@ -1291,23 +1617,23 @@ function openAccountModal(editHoster) { if (editingAccountHoster) { // Edit mode title.textContent = 'Account bearbeiten'; - subtitle.textContent = `Zugangsdaten fuer ${editingAccountHoster} bearbeiten.`; + subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(editingAccountHoster, config.hosters[editingAccountHoster] || {})} bearbeiten.`; hosterRow.style.display = 'none'; - saveBtn.textContent = 'Speichern & Pruefen'; + saveBtn.textContent = 'Speichern & prüfen'; const hoster = config.hosters[editingAccountHoster] || {}; credsContainer.innerHTML = getCredsFieldsHtml(editingAccountHoster, hoster); } else { // Add mode - title.textContent = 'Account hinzufuegen'; - subtitle.textContent = 'Waehle einen Hoster und gib deine Zugangsdaten ein.'; + title.textContent = 'Account hinzufügen'; + subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein.'; hosterRow.style.display = 'flex'; - saveBtn.textContent = 'Anlegen & Pruefen'; + saveBtn.textContent = 'Anlegen & prüfen'; const available = getHostersWithoutCreds(); if (available.length === 0) { hosterSelect.innerHTML = ''; credsContainer.innerHTML = ''; } else { - hosterSelect.innerHTML = available.map(name => ``).join(''); + hosterSelect.innerHTML = available.map(name => ``).join(''); credsContainer.innerHTML = getCredsFieldsHtml(available[0], {}); } } @@ -1331,7 +1657,7 @@ function closeAccountModal() { function openDeleteAccountModal(hosterName) { const modal = document.getElementById('deleteAccountModal'); const msg = document.getElementById('deleteAccountMessage'); - msg.textContent = `Account fuer "${hosterName}" wirklich loeschen? Alle Zugangsdaten werden entfernt.`; + msg.textContent = `Account für "${getHosterLabel(hosterName)}" wirklich löschen? Alle Zugangsdaten werden entfernt.`; modal.dataset.hoster = hosterName; modal.style.display = 'flex'; } @@ -1353,7 +1679,9 @@ async function deleteAccount(hosterName) { delete accountStatuses[hosterName]; await window.api.saveConfig({ hosters }); config = await window.api.getConfig(); + ensureAccountStatusEntries(); syncSelectedUploadHosters(); + if (getAccountsWithCreds().length === 0) renderHealthCheckResults([]); renderAccounts(); renderHosterSummary(); renderHosterModal(); @@ -1398,7 +1726,7 @@ async function saveAccount() { // Show checking status const statusEl = document.getElementById('accountModalStatus'); const saveBtn = document.getElementById('saveAccountBtn'); - statusEl.textContent = 'Pruefe Login...'; + statusEl.textContent = 'Prüfe Login...'; statusEl.className = 'account-modal-status checking'; saveBtn.disabled = true; @@ -1413,9 +1741,9 @@ async function saveAccount() { try { const rows = await executeHealthCheck([hosterName], 'auto'); const row = rows.find(r => r.hoster === hosterName); - if (row && row.status === 'ok') { - accountStatuses[hosterName] = { status: 'ok', message: row.message || '' }; - statusEl.textContent = 'Login erfolgreich!'; + if (row && (row.status === 'ok' || row.status === 'warn')) { + accountStatuses[hosterName] = { status: row.status || 'ok', message: row.message || '' }; + statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich!'; statusEl.className = 'account-modal-status ok'; setTimeout(() => closeAccountModal(), 1200); } else { @@ -1425,12 +1753,16 @@ async function saveAccount() { statusEl.className = 'account-modal-status error'; } } catch (err) { - accountStatuses[hosterName] = { status: 'error', message: err.message || 'Pruefung fehlgeschlagen' }; - statusEl.textContent = err.message || 'Pruefung fehlgeschlagen'; + accountStatuses[hosterName] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' }; + statusEl.textContent = err.message || 'Prüfung fehlgeschlagen'; statusEl.className = 'account-modal-status error'; } finally { saveBtn.disabled = false; + ensureAccountStatusEntries(); renderAccounts(); + renderHosterSummary(); + renderHosterModal(); + renderSettings(); } } @@ -1453,11 +1785,12 @@ async function loadHistory() { const dt = formatDateTime(batch.timestamp || new Date()); for (const file of (batch.files || [])) { for (const result of (file.results || [])) { + const isErrorLike = result.status === 'error' || result.status === 'aborted'; historyRowsData.push({ date: dt.text, dateTs: dt.ts, filename: file.name || '', host: result.hoster || '', - link: result.status === 'error' ? `[Fehler] ${result.error || ''}` : (result.download_url || result.embed_url || ''), - isError: result.status === 'error', order: order++ + link: isErrorLike ? `[${result.status === 'aborted' ? 'Abgebrochen' : 'Fehler'}] ${result.error || ''}` : (result.download_url || result.embed_url || ''), + isError: isErrorLike, order: order++ }); } } @@ -1571,8 +1904,15 @@ function setupListeners() { document.getElementById('addFilesBtn').addEventListener('click', pickFiles); document.getElementById('chooseHostersBtn').addEventListener('click', openHosterModal); document.getElementById('startUploadBtn').addEventListener('click', startUpload); - document.getElementById('cancelUploadBtn').addEventListener('click', cancelUpload); - document.getElementById('runHealthCheckBtn').addEventListener('click', runHealthCheck); + document.getElementById('reuploadSelectedBtn').addEventListener('click', retrySelectedJobs); + document.getElementById('abortSelectedBtn').addEventListener('click', abortSelectedJobs); + document.getElementById('finishStopBtn').addEventListener('click', finishUploadsInProgress); + document.getElementById('abortAllBtn').addEventListener('click', abortAllUploads); + document.getElementById('moveTopBtn').addEventListener('click', () => moveSelectedJobs('top')); + document.getElementById('moveUpBtn').addEventListener('click', () => moveSelectedJobs('up')); + document.getElementById('moveDownBtn').addEventListener('click', () => moveSelectedJobs('down')); + document.getElementById('moveBottomBtn').addEventListener('click', () => moveSelectedJobs('bottom')); + document.getElementById('accountsRunHealthCheckBtn').addEventListener('click', () => runHealthCheck('manual')); document.getElementById('copyAllLinksBtn').addEventListener('click', copyAllLinks); document.getElementById('retryFailedBtn').addEventListener('click', () => { queueJobs.forEach(j => { if (j.status === 'error') selectedJobIds.add(j.id); }); @@ -1595,7 +1935,7 @@ function setupListeners() { }); document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings); document.getElementById('clearHistoryBtn').addEventListener('click', async () => { - if (!confirm('Verlauf wirklich loeschen?')) return; + if (!confirm('Verlauf wirklich löschen?')) return; await window.api.clearHistory(); loadHistory(); }); @@ -1683,7 +2023,7 @@ function showUpdateBanner(info) { const banner = document.getElementById('updateBanner'); const msg = document.getElementById('updateMessage'); if (!banner || !msg) return; - msg.textContent = `Update v${info.remoteVersion} verfuegbar`; + msg.textContent = `Update v${info.remoteVersion} verfügbar`; banner.style.display = 'flex'; document.getElementById('installUpdateBtn').onclick = async () => { msg.textContent = 'Update wird heruntergeladen...'; diff --git a/renderer/index.html b/renderer/index.html index 80aac40..9e30893 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -21,59 +21,54 @@
-
-
- - Keine Upload-Ziele ausgewaehlt + + Keine Upload-Ziele ausgewählt
-
- - -
- -
- -
-
-
📁

Dateien hierher ziehen oder klicken

-