From a299fc204bebcd87271a3a19433213f2291f0c97 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:04:47 +0200 Subject: [PATCH] release: v2.1.16 Add queue search and filters, safe upload diagnostics, session report exports, account check visibility, and explicit interrupted-upload recovery.\n\nSanitize diagnostic response snippets in persisted results and rotation logs, protect CSV exports against formula injection, and add regression coverage for diagnostics and reports. --- README.md | 6 +- lib/config-store.js | 16 ++++++ lib/session-report.js | 73 ++++++++++++++++++++++++ lib/upload-diagnostics.js | 33 +++++++++++ lib/upload-manager.js | 16 +++++- main.js | 29 ++++++++++ package-lock.json | 4 +- package.json | 2 +- preload.js | 1 + renderer/app.js | 95 +++++++++++++++++++++++++++---- renderer/i18n.js | 12 ++++ renderer/index.html | 9 +++ scripts/verify-public-release.mjs | 4 ++ tests/session-report.test.js | 31 ++++++++++ tests/upload-diagnostics.test.js | 27 +++++++++ tests/upload-manager.test.js | 20 +++++++ 16 files changed, 361 insertions(+), 17 deletions(-) create mode 100644 lib/session-report.js create mode 100644 lib/upload-diagnostics.js create mode 100644 tests/session-report.test.js create mode 100644 tests/upload-diagnostics.test.js diff --git a/README.md b/README.md index 0861c63..e125156 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Multi-Hoster-Upload is a Windows desktop application for sending file batches to Download the current Setup or Portable build from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest). -The latest public release is version 2.1.15. Use the release page for the executables and the full English changelog. +The latest public release is version 2.1.16. Use the release page for the executables and the full English changelog. ## Features @@ -18,6 +18,8 @@ The latest public release is version 2.1.15. Use the release page for the execut - Build one job per selected file and destination. - Upload to several supported hosts from the same queue. - Filter the workspace by all, active, queued, completed, or failed jobs. +- Search and filter queue entries by file name, host, and status. +- Open per-upload diagnostics with the selected account, retry count, and safe error details. - Track status, progress, transferred size, speed, and the selected host account. - Read total, remaining, running, completed, and failed upload activity from the persistent sidebar telemetry. - Follow current upload speed in the sidebar and the synchronized header graph. @@ -42,6 +44,8 @@ The latest public release is version 2.1.15. Use the release page for the execut - Keep upload history entries and filter the archive by all, successful, or failed views. - Retain all history, a time window, or the latest 100 or 1,000 uploads. - Export history as CSV or JSON. +- Export a per-session CSV or JSON report with host success rates, duration, bytes, attempts, and errors. +- Clearly mark interrupted uploads after a restart so they can be resumed deliberately. - Use the complete interface in English or German and switch at runtime. - Export settings locally or transfer them with an encrypted online backup key. - Send a batch summary to a Discord webhook or another HTTP webhook. diff --git a/lib/config-store.js b/lib/config-store.js index fbd4b2d..8831d4a 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -81,6 +81,7 @@ const DEFAULTS = { showDropTarget: false, globalMaxSpeedKbs: 0, // 0 = unlimited global speed pendingQueue: null, + uploadRecovery: null, scramble: { active: false, prefix: '', @@ -584,6 +585,19 @@ class ConfigStore { }, options); } + saveUploadRecovery(uploadRecovery, options = {}) { + const snapshot = uploadRecovery === null || uploadRecovery === undefined ? null : this._clone(uploadRecovery); + return this._enqueueWrite(() => { + const current = this.load(); + current.globalSettings = { + ...(current.globalSettings || {}), + uploadRecovery: snapshot + }; + this._guardHosters(current, false); + return this._commit(current); + }, options); + } + saveLastBrowseDirectory(directory) { const snapshot = String(directory || '').trim(); return this._enqueueWrite(() => { @@ -607,6 +621,7 @@ class ConfigStore { current.globalSettings = { ...snapshot, pendingQueue: currentGlobalSettings.pendingQueue ?? null, + uploadRecovery: currentGlobalSettings.uploadRecovery ?? null, lastBrowseDirectory: currentGlobalSettings.lastBrowseDirectory || '', diagnostics: this._clone(currentGlobalSettings.diagnostics || {}), historyRetention: currentGlobalSettings.historyRetention || 'all', @@ -640,6 +655,7 @@ class ConfigStore { const current = this.load(); const globalSettings = this._clone(config.globalSettings); globalSettings.pendingQueue = current.globalSettings.pendingQueue ?? null; + globalSettings.uploadRecovery = current.globalSettings.uploadRecovery ?? null; return this._commit({ hosters: this._clone(config.hosters), hosterSettings: this._clone(config.hosterSettings), diff --git a/lib/session-report.js b/lib/session-report.js new file mode 100644 index 0000000..343d41c --- /dev/null +++ b/lib/session-report.js @@ -0,0 +1,73 @@ +function normalNumber(value) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? number : 0; +} + +const { normalizeFailureDetails } = require('./upload-diagnostics'); + +function safeError(value) { + const text = normalizeFailureDetails({ payloadSnippet: value })?.responseSnippet || ''; + return text || 'Unbekannter Fehler'; +} + +function createHosterSummary() { + return { total: 0, succeeded: 0, failed: 0, skipped: 0, aborted: 0, bytes: 0, durationSec: 0, attempts: 0, errors: {} }; +} + +function buildSessionReport(summary) { + const hosters = {}; + const totals = createHosterSummary(); + for (const file of Array.isArray(summary && summary.files) ? summary.files : []) { + const size = normalNumber(file && file.size); + for (const result of Array.isArray(file && file.results) ? file.results : []) { + const hoster = String(result && result.hoster || 'Unbekannt'); + const bucket = hosters[hoster] || (hosters[hoster] = createHosterSummary()); + const status = String(result && result.status || 'error'); + for (const target of [bucket, totals]) { + target.total++; + target.bytes += size; + target.durationSec += normalNumber(result && result.durationSec); + target.attempts += normalNumber(result && result.attempt); + if (status === 'done') target.succeeded++; + else if (status === 'skipped') target.skipped++; + else if (status === 'aborted') target.aborted++; + else target.failed++; + if (status !== 'done' && result && result.error) { + const error = safeError(result.error); + target.errors[error] = (target.errors[error] || 0) + 1; + } + } + } + } + for (const target of [totals, ...Object.values(hosters)]) { + target.successRate = target.total ? target.succeeded / target.total : 0; + } + return { + generatedAt: new Date().toISOString(), + batchId: summary && summary.id ? String(summary.id) : '', + batchTimestamp: summary && summary.timestamp ? String(summary.timestamp) : '', + totals, + hosters + }; +} + +function csvCell(value) { + let text = value === null || value === undefined ? '' : String(value); + if (/^[=+\-@]/.test(text)) text = `'${text}`; + return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +function buildSessionReportCsv(report) { + const header = ['Hoster', 'Gesamt', 'Erfolgreich', 'Fehler', 'Übersprungen', 'Abgebrochen', 'Erfolgsrate', 'Bytes', 'Dauer Sekunden', 'Versuche', 'Fehlerdetails']; + const lines = [header.join(',')]; + for (const [hoster, row] of Object.entries(report && report.hosters || {})) { + lines.push([ + hoster, row.total, row.succeeded, row.failed, row.skipped, row.aborted, + `${Math.round((row.successRate || 0) * 100)}%`, row.bytes, row.durationSec, row.attempts, + Object.entries(row.errors || {}).map(([error, count]) => `${count}× ${error}`).join(' | ') + ].map(csvCell).join(',')); + } + return `${lines.join('\n')}\n`; +} + +module.exports = { buildSessionReport, buildSessionReportCsv }; diff --git a/lib/upload-diagnostics.js b/lib/upload-diagnostics.js new file mode 100644 index 0000000..019565f --- /dev/null +++ b/lib/upload-diagnostics.js @@ -0,0 +1,33 @@ +function cleanText(value, limit = 320) { + let text = String(value || '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim(); + if (!text) return ''; + text = text.replace(/https?:\/\/[^\s"'<>]+/gi, (raw) => { + try { + const url = new URL(raw); + return `${url.host}${url.pathname}`; + } catch { + return '[URL]'; + } + }); + text = text.replace(/(["']?(?:api[_-]?key|token|password|cookie|authorization|session)["']?\s*[:=]\s*)[^,;\s}"']+/gi, '$1[redacted]'); + if (/^(?:bearer|basic)\s+/i.test(text)) { + text = '[redacted authorization]'; + } + return text.slice(0, limit); +} + +function normalizeFailureDetails(diagnostic) { + if (!diagnostic || typeof diagnostic !== 'object') return null; + const httpStatus = Number.isInteger(Number(diagnostic.http)) && Number(diagnostic.http) >= 100 && Number(diagnostic.http) <= 599 + ? Number(diagnostic.http) + : null; + const contentType = cleanText(diagnostic.contentType, 120); + const responseSnippet = cleanText(diagnostic.payloadSnippet, 320); + const details = {}; + if (httpStatus !== null) details.httpStatus = httpStatus; + if (contentType) details.contentType = contentType; + if (responseSnippet) details.responseSnippet = responseSnippet; + return Object.keys(details).length > 0 ? details : null; +} + +module.exports = { normalizeFailureDetails }; diff --git a/lib/upload-manager.js b/lib/upload-manager.js index 76165b5..0ae0d11 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -11,6 +11,7 @@ const ClouddropUploader = require('./clouddrop-upload'); const Semaphore = require('./semaphore'); const Throttle = require('./throttle'); const { probeFileHead } = require('./file-probe'); +const { normalizeFailureDetails } = require('./upload-diagnostics'); const DEFAULT_SETTINGS = { retries: 3, @@ -433,6 +434,7 @@ class UploadManager extends EventEmitter { const uploadId = crypto.randomBytes(8).toString('hex'); const jobId = task.jobId || uploadId; const fileName = path.basename(task.file); + const jobStartedAt = Date.now(); let fileSize = 0; let fileNotFound = false; const cachedResult = results && results.get(task.file); @@ -452,6 +454,8 @@ class UploadManager extends EventEmitter { let finalResultRecorded = false; let finalStatus = 'error'; let lastError = null; + let lastFailureDetails = null; + let finalAttempt = 0; const recordFinalResult = (status, payload = {}) => { if (finalResultRecorded) return; @@ -462,6 +466,11 @@ class UploadManager extends EventEmitter { hoster: task.hoster, status, error: payload.error || null, + accountId: task.accountId || null, + attempt: payload.attempt || finalAttempt, + maxAttempts, + durationSec: Number.isFinite(payload.elapsed) ? payload.elapsed : Math.round((Date.now() - jobStartedAt) / 1000), + failureDetails: payload.failureDetails || lastFailureDetails, 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 @@ -482,6 +491,7 @@ class UploadManager extends EventEmitter { elapsed: payload.elapsed || 0, remaining: 0, error: payload.error || null, + failureDetails: payload.failureDetails || lastFailureDetails, result: payload.result || null, attempt: payload.attempt || maxAttempts, maxAttempts @@ -582,6 +592,7 @@ class UploadManager extends EventEmitter { const attemptsAllowed = memoSuspect ? 0 : maxAttempts; for (let attempt = 1; attempt <= attemptsAllowed; attempt++) { + finalAttempt = attempt; if (signal.aborted || this.stopAfterActive) break; if (attempt > 1) { @@ -721,6 +732,7 @@ class UploadManager extends EventEmitter { const isSpeedRestart = speedAbort && speedAbort.signal.aborted && !signal.aborted; if (!signal.aborted && !isSpeedRestart) { const diag = (err && typeof err === 'object' && err.diagnostic) || {}; + lastFailureDetails = normalizeFailureDetails(diag); this._rotLog('upload-failure', { jobId, hoster: task.hoster, accountId: task.accountId, fileName, attempt, @@ -733,7 +745,7 @@ class UploadManager extends EventEmitter { detectedKind: (typeof fileProbe !== 'undefined' && fileProbe && fileProbe.kind) ? fileProbe.kind : null, isVideoLike: !!(typeof fileProbe !== 'undefined' && fileProbe && fileProbe.isVideoLike), headHex: (typeof fileProbe !== 'undefined' && fileProbe && fileProbe.headHex) ? fileProbe.headHex.slice(0, 32) : null, - payloadSnippet: diag.payloadSnippet || null + payloadSnippet: lastFailureDetails ? lastFailureDetails.responseSnippet || null : null }); } if (signal.aborted) { @@ -955,6 +967,7 @@ class UploadManager extends EventEmitter { // loop iterates: marks this account failed too, asks main for the next // fallback, and so on. for (let attempt = 1; attempt <= maxAttempts; attempt++) { + finalAttempt = attempt; if (signal.aborted || this.stopAfterActive) break; if (attempt > 1) { this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, @@ -1011,6 +1024,7 @@ class UploadManager extends EventEmitter { } catch (err) { this.activeJobs.delete(uploadId); lastError = err; + lastFailureDetails = normalizeFailureDetails(err && err.diagnostic); if (!signal.aborted) { this._rotLog('upload-failure', { jobId, hoster: task.hoster, accountId: task.accountId, fileName, diff --git a/main.js b/main.js index 8d4ebe5..64d2b0b 100644 --- a/main.js +++ b/main.js @@ -34,6 +34,7 @@ const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify'); const stats = require('./lib/stats'); const { createCollectors } = require('./lib/diagnostics-collectors'); const { createAgent } = require('./lib/diagnostics-agent'); +const { buildSessionReport, buildSessionReportCsv } = require('./lib/session-report'); const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 }); _eventLoopDelay.enable(); @@ -116,6 +117,7 @@ let tray = null; const configStore = new ConfigStore(app); configStore.setPerfLog((m) => { try { logInfo(m); } catch {} }); let uploadManager = null; +let lastSessionSummary = null; let sourceDeleteJournal = null; const pendingUploadFinalizations = new Map(); @@ -1964,6 +1966,23 @@ ipcMain.handle('resolve-folder-files', async (_event, folderPath) => { return walkFolderAsync(folderPath); }); +ipcMain.handle('export-session-report', async (_event, format) => { + if (!lastSessionSummary) return { ok: false, error: 'Für diese Sitzung liegt noch kein abgeschlossener Upload vor' }; + const normalizedFormat = String(format || 'csv').toLowerCase() === 'json' ? 'json' : 'csv'; + const report = buildSessionReport(lastSessionSummary); + const datePrefix = new Date().toISOString().slice(0, 10); + const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, { + title: shellText('Sitzungsbericht exportieren', 'Export session report'), + defaultPath: `upload-session-report-${datePrefix}.${normalizedFormat}`, + filters: normalizedFormat === 'json' + ? [{ name: shellText('JSON-Datei', 'JSON file'), extensions: ['json'] }] + : [{ name: shellText('CSV-Datei', 'CSV file'), extensions: ['csv'] }] + }); + if (canceled || !filePath) return { ok: false, canceled: true }; + fs.writeFileSync(filePath, normalizedFormat === 'json' ? JSON.stringify(report, null, 2) : buildSessionReportCsv(report), 'utf-8'); + return { ok: true, path: filePath, format: normalizedFormat, totalRows: report.totals.total }; +}); + ipcMain.handle('get-file-sizes', async (_event, paths) => { if (!Array.isArray(paths)) return {}; const fsp = fs.promises; @@ -2031,6 +2050,13 @@ ipcMain.handle('start-upload', async (_event, payload) => { return { started: true, taskCount: 0, skippedJobs }; } + const recovery = { + id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, + startedAt: new Date().toISOString(), + jobIds: tasks.map(task => task.jobId).filter(Boolean) + }; + try { await configStore.saveUploadRecovery(recovery); } catch (error) { debugLog(`upload recovery state could not be saved: ${error.message}`); } + // Pre-resolve a fallback for every hoster that has one. Lets the upload // manager break out of the retry loop after a single generic failure and // try the alternate account immediately, instead of hammering a probably- @@ -2208,6 +2234,7 @@ ipcMain.handle('start-upload', async (_event, payload) => { // orphans (cancel/addJobs see null, the new batch keeps running invisibly). uploadManager.on('batch-done', async (summary) => { summary = stats.mergeSkippedIntoSummary(summary, skippedJobs); + lastSessionSummary = summary; debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`); logMarker('BATCH END', { total: summary.total, ok: summary.succeeded, fail: summary.failed }); logMemorySnapshot('batch-done'); @@ -2228,6 +2255,7 @@ ipcMain.handle('start-upload', async (_event, payload) => { _progressByJob.clear(); if (finalProgressBatch.length) safeSend('upload-progress-batch', finalProgressBatch); const queuePersisted = await requestUploadFinalization(summary); + try { await configStore.saveUploadRecovery(null); } catch (error) { debugLog(`upload recovery state could not be cleared: ${error.message}`); } if (!queuePersisted) debugLog('upload finalization blocked: renderer queue acknowledgement missing'); await sourceCleanup.finishBatch({ historyPersisted, queuePersisted }); _producerTracker.finish(); @@ -2278,6 +2306,7 @@ ipcMain.handle('start-upload', async (_event, payload) => { error: err ? err.message : 'Unbekannter Fehler' }; safeSend('upload-batch-done', errorSummary); + configStore.saveUploadRecovery(null).catch(error => debugLog(`upload recovery state could not be cleared after start failure: ${error.message}`)); _producerTracker.finish(); if (!isAutoRetry) sendBatchWebhook(errorSummary, 0); if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; } diff --git a/package-lock.json b/package-lock.json index d53bb21..11a5780 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-hoster-uploader", - "version": "2.1.15", + "version": "2.1.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-hoster-uploader", - "version": "2.1.15", + "version": "2.1.16", "dependencies": { "chokidar": "^3.6.0", "undici": "^7.29.0", diff --git a/package.json b/package.json index b7da5bf..aed8406 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-hoster-uploader", - "version": "2.1.15", + "version": "2.1.16", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { diff --git a/preload.js b/preload.js index 900f483..10b6a0c 100644 --- a/preload.js +++ b/preload.js @@ -8,6 +8,7 @@ contextBridge.exposeInMainWorld('api', { clearHistory: () => ipcRenderer.invoke('clear-history'), pruneHistory: (retention, opts) => ipcRenderer.invoke('prune-history', { retention, dryRun: !!(opts && opts.dryRun) }), exportHistory: (format) => ipcRenderer.invoke('export-history', format), + exportSessionReport: (format) => ipcRenderer.invoke('export-session-report', format), saveTextFile: (defaultName, content, filters) => ipcRenderer.invoke('save-text-file', defaultName, content, filters), // Hoster settings diff --git a/renderer/app.js b/renderer/app.js index f35f82b..872b054 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -343,6 +343,9 @@ const _doneRemovalCoalescer = window.CoalescedSet : null; const queueSortState = { key: 'filename', direction: 'asc' }; let uploadSidebarFilter = 'all'; +let queueSearchQuery = ''; +let queueHosterFilter = ''; +let queueStatusFilter = ''; let _queueFilterCache = { filter: '', source: null, result: [] }; // History state @@ -415,6 +418,8 @@ async function init() { renderRecentUploadsPanel(); updateUploadView(); updateStatusBar(); + const interruptedCount = queueJobs.filter(job => job.interrupted).length; + if (interruptedCount > 0) showCopyToast(interruptedCount === 1 ? '1 unterbrochener Upload kann fortgesetzt werden.' : `${interruptedCount} unterbrochene Uploads können fortgesetzt werden.`, 7000); // Version display try { @@ -1178,6 +1183,7 @@ function restoreQueueStateFromConfig() { .map(file => ({ path: file.path, name: file.name || file.path.split(/[\\/]/).pop(), size: file.size || 0 })) : []; + const interruptedJobIds = new Set(Array.isArray(config?.globalSettings?.uploadRecovery?.jobIds) ? config.globalSettings.uploadRecovery.jobIds : []); const rawJobs = Array.isArray(pending.queueJobs) ? pending.queueJobs .filter(job => job && job.fileName && job.hoster) @@ -1194,6 +1200,8 @@ function restoreQueueStateFromConfig() { elapsed: 0, remaining: 0, error: job.error || null, + failureDetails: job.failureDetails || null, + interrupted: interruptedJobIds.size > 0 && !['done', 'error', 'skipped'].includes(job.status), result: job.result || null, sourceCleanupToken: job.sourceCleanupToken || null, sourceCleanupRequiredHosters: Array.isArray(job.sourceCleanupRequiredHosters) ? [...job.sourceCleanupRequiredHosters] : [], @@ -1270,6 +1278,7 @@ function buildPersistedQueueState() { status: isTerminal ? job.status : 'preview', bytesTotal: job.bytesTotal || 0, error: isTerminal ? (job.error || null) : null, + failureDetails: isTerminal ? (job.failureDetails || null) : null, result: isTerminal ? (job.result || null) : null, sourceCleanupToken: job.sourceCleanupToken || null, sourceCleanupRequiredHosters: Array.isArray(job.sourceCleanupRequiredHosters) ? [...job.sourceCleanupRequiredHosters] : [], @@ -1894,10 +1903,20 @@ function _matchesUploadSidebarFilter(job, filter = uploadSidebarFilter) { return true; } +function _matchesQueueDetailFilter(job) { + if (queueSearchQuery && !String(job.fileName || '').toLocaleLowerCase(getUiLocale()).includes(queueSearchQuery)) return false; + if (queueHosterFilter && job.hoster !== queueHosterFilter) return false; + if (queueStatusFilter === 'active') return ['uploading', 'getting-server', 'retrying'].includes(job.status); + if (queueStatusFilter === 'waiting') return ['preview', 'queued'].includes(job.status); + if (queueStatusFilter === 'done') return job.status === 'done'; + return !queueStatusFilter || job.status === queueStatusFilter; +} + function _getVisibleQueueJobs() { - if (uploadSidebarFilter === 'all') return queueJobs; - const filtered = queueJobs.filter(job => _matchesUploadSidebarFilter(job)); - if (_queueFilterCache.filter === uploadSidebarFilter && _queueFilterCache.source === queueJobs && _queueFilterCache.result.length === filtered.length) { + const filterKey = `${uploadSidebarFilter}\u0001${queueSearchQuery}\u0001${queueHosterFilter}\u0001${queueStatusFilter}`; + if (filterKey === 'all\u0001\u0001\u0001') return queueJobs; + const filtered = queueJobs.filter(job => _matchesUploadSidebarFilter(job) && _matchesQueueDetailFilter(job)); + if (_queueFilterCache.filter === filterKey && _queueFilterCache.source === queueJobs && _queueFilterCache.result.length === filtered.length) { let unchanged = true; for (let index = 0; index < filtered.length; index++) { if (_queueFilterCache.result[index] !== filtered[index]) { @@ -1907,7 +1926,7 @@ function _getVisibleQueueJobs() { } if (unchanged) return _queueFilterCache.result; } - _queueFilterCache = { filter: uploadSidebarFilter, source: queueJobs, result: filtered }; + _queueFilterCache = { filter: filterKey, source: queueJobs, result: filtered }; return filtered; } @@ -1934,13 +1953,14 @@ function renderQueueTable() { const tbody = document.getElementById('queueBody'); if (!tbody) return; + syncQueueHosterFilterOptions(); const visibleJobs = _getVisibleQueueJobs(); const selectionChanged = _normalizeQueueSelectionToVisible(visibleJobs); _sortedJobsCache = sortQueueJobs(visibleJobs); if (selectionChanged) updateQueueActionButtons(); const totalRows = _sortedJobsCache.length; const queueContainer = document.getElementById('queueContainer'); - const filteredEmpty = totalRows === 0 && queueJobs.length > 0 && uploadSidebarFilter !== 'all'; + const filteredEmpty = totalRows === 0 && queueJobs.length > 0 && (uploadSidebarFilter !== 'all' || queueSearchQuery || queueHosterFilter || queueStatusFilter); queueContainer?.classList.toggle('filter-empty', filteredEmpty); if (queueContainer) queueContainer.dataset.emptyLabel = filteredEmpty ? localizeUiText('Keine Uploads entsprechen diesem Filter.') : ''; @@ -2150,7 +2170,7 @@ function getStatusText(job) { const accSuffix = acc ? ` · ${acc}` : ''; let text; switch (job.status) { - case 'preview': text = 'Bereit'; break; + case 'preview': text = job.interrupted ? 'Unterbrochen · bereit zum Fortsetzen' : 'Bereit'; break; case 'queued': text = 'Wartet'; break; case 'getting-server': text = `Server…${accSuffix}`; break; case 'uploading': text = `Upload${accSuffix}`; break; @@ -3089,6 +3109,7 @@ function _handleProgressImpl(data) { // Update job state job.status = data.status; + if (data.status !== 'preview') job.interrupted = false; job.bytesUploaded = data.bytesUploaded || 0; job.bytesTotal = data.bytesTotal || job.bytesTotal; // Track session total bytes (survives removeFromQueueOnDone) @@ -3100,6 +3121,7 @@ function _handleProgressImpl(data) { job.elapsed = data.elapsed || 0; job.remaining = data.remaining || 0; job.error = data.error || null; + job.failureDetails = data.failureDetails || job.failureDetails || null; job.result = data.result || job.result; job.attempt = data.attempt || 0; job.maxAttempts = data.maxAttempts || 0; @@ -3107,6 +3129,11 @@ function _handleProgressImpl(data) { // Track which account the backend is currently using so the status cell // can display "Primär" vs "Fallback #N" during rotation. if (data.accountId) job.accountId = data.accountId; + if (data.accountId && data.hoster) { + const status = accountStatuses[data.accountId] || { status: 'unchecked', message: '' }; + if (data.status === 'done') accountStatuses[data.accountId] = { ...status, status: 'ok', message: 'Letzter Upload erfolgreich', lastSuccessAt: new Date().toISOString() }; + else if (data.status === 'error') accountStatuses[data.accountId] = { ...status, status: 'error', message: data.error || 'Letzter Upload fehlgeschlagen', lastFailureAt: new Date().toISOString() }; + } if (data.uploadId) { job.uploadId = data.uploadId; _jobIndexByUploadId.set(data.uploadId, job); @@ -3430,7 +3457,17 @@ async function showJobLogModal() { .join(' '); return `[${t}] [${e.event}] ${rest}`; }; - bodyEl.textContent = entries.map(fmt).join('\n'); + const details = job.failureDetails && typeof job.failureDetails === 'object' + ? Object.entries(job.failureDetails).map(([key, value]) => `${key}: ${value}`).join('\n') + : ''; + const summary = [ + `Hoster: ${job.hoster || '–'}`, + `Account: ${getAccountLabel(job) || job.accountId || '–'}`, + `Versuch: ${job.attempt || '–'} / ${job.maxAttempts || '–'}`, + job.error ? `Fehler: ${job.error}` : '', + details ? `Diagnose:\n${details}` : '' + ].filter(Boolean).join('\n'); + bodyEl.textContent = `${summary}\n\n${entries.map(fmt).join('\n')}`; } function hideJobLogModal() { @@ -3652,12 +3689,14 @@ function applySummaryResults(summary) { job.error = null; job.progress = 1; job.bytesUploaded = job.bytesTotal || file.size || 0; + job.failureDetails = null; } 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'; + job.failureDetails = result.failureDetails || job.failureDetails || null; } else if (result.status === 'skipped') { job.status = 'skipped'; job.error = result.error || 'Übersprungen'; @@ -3732,6 +3771,26 @@ function setUploadSidebarFilter(value) { renderQueueTable(); } +function applyQueueDetailFilters() { + queueSearchQuery = String(document.getElementById('queueSearchInput')?.value || '').trim().toLocaleLowerCase(getUiLocale()); + queueHosterFilter = String(document.getElementById('queueHosterFilter')?.value || ''); + queueStatusFilter = String(document.getElementById('queueStatusFilter')?.value || ''); + _queueFilterCache = { filter: '', source: null, result: [] }; + _normalizeQueueSelectionToVisible(); + _lastVisibleRange = { start: -1, end: -1 }; + renderQueueTable(); +} + +function syncQueueHosterFilterOptions() { + const select = document.getElementById('queueHosterFilter'); + if (!select) return; + const names = [...new Set(queueJobs.map(job => job.hoster).filter(Boolean))].sort(_collatorSimple.compare); + const current = select.value; + select.innerHTML = `${names.map(name => ``).join('')}`; + select.value = names.includes(current) ? current : ''; + queueHosterFilter = select.value; +} + function updateUploadSidebarSummary(stats = _computeQueueStats()) { let waiting = 0; let active = 0; @@ -4088,9 +4147,10 @@ async function executeHealthCheck(hosters, _mode) { if (!row) return; const key = row.accountId || row.hoster; if (key) { - accountStatuses[key] = { - status: row.status || 'unchecked', - message: row.message || '' + accountStatuses[key] = { + status: row.status || 'unchecked', + message: row.message || '', + checkedAt: result.checkedAt || new Date().toISOString() }; } }); @@ -4121,7 +4181,7 @@ async function runHealthCheck(mode = 'manual', requestedHosters = null) { // Mark all accounts as checking for (const h of hosters) { const key = typeof h === 'string' ? h : (h.accountId || h.hoster); - accountStatuses[key] = { status: 'checking', message: '' }; + accountStatuses[key] = { status: 'checking', message: '', checkedAt: null }; } renderAccounts(); try { @@ -5180,6 +5240,7 @@ function _buildAccountCardHtml(name, account, idx) { // disambiguator for accounts that otherwise look identical (e.g. two byse // API-key accounts where you can't tell what's what from the masked key). const subtitleText = (userLabel ? `Label: ${userLabel} • ` : '') + credLabel; + const checkedText = st.checkedAt ? ` • geprüft ${new Date(st.checkedAt).toLocaleTimeString(getUiLocale(), { hour: '2-digit', minute: '2-digit' })}` : ''; const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren'; const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`; @@ -5199,7 +5260,7 @@ function _buildAccountCardHtml(name, account, idx) {