From d7c9f287e481179e9d1c47e884d7e79d562809df Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Mon, 17 Aug 2026 04:25:22 +0200 Subject: [PATCH] release: restore v2.1.19 baseline for v2.1.24 Restore the v2.1.19 application baseline and retain only the focused import preflight summary with duplicate, unavailable, destination, job, and size-limit visibility. --- .gitea/workflows/ci.yml | 4 - .github/workflows/ci.yml | 4 - .gitignore | 9 - README.md | 21 +- lib/batch-completion-report.js | 188 -- lib/batch-mutation-gate.js | 55 - lib/config-store.js | 179 +- lib/diagnostics-agent.js | 23 +- lib/diagnostics-collectors.js | 79 +- lib/doodstream-upload.js | 279 +-- lib/filename-filter.js | 70 - lib/hoster-transport-error.js | 91 - lib/hosters.js | 649 ++---- lib/import-preflight.js | 325 ++- lib/queue-dedup.js | 3 +- lib/remote-server.js | 19 +- lib/source-cleanup-policy.js | 148 +- lib/source-file-cleanup.js | 66 +- lib/startup-renderer.js | 533 +---- lib/stats.js | 118 +- lib/support-bundle.js | 156 +- lib/updater.js | 8 +- lib/upload-audit.js | 72 +- lib/upload-confirmation.js | 50 +- lib/upload-diagnostics.js | 24 - lib/upload-manager.js | 611 +----- lib/upload-recovery.js | 101 - lib/upload-schedule.js | 167 -- lib/upload-start-reservation.js | 28 - lib/vidmoly-upload.js | 323 +-- lib/voe-upload.js | 307 +-- lib/webhook-notify.js | 4 +- main.js | 835 +++---- package-lock.json | 4 +- package.json | 14 +- preload-drop-target.js | 5 +- preload.js | 7 - renderer/app.js | 1924 ++++++----------- renderer/drop-target.html | 16 +- renderer/i18n.js | 170 +- renderer/index.html | 145 +- renderer/styles.css | 625 +----- scripts/afterPack.cjs | 48 +- scripts/dev-runner.cjs | 258 +-- scripts/release-plan.mjs | 90 +- scripts/verify-public-release.mjs | 171 +- tests/batch-completion-main.test.js | 58 - tests/batch-completion-report.test.js | 130 -- tests/batch-mutation-gate.test.js | 113 - tests/byse-reject-recovery.test.js | 88 - tests/config-store.test.js | 267 --- tests/dev-runner.test.js | 123 +- tests/diagnostics-agent.test.js | 54 +- tests/diagnostics-collectors.test.js | 167 +- tests/diagnostics-protocol.test.js | 28 +- tests/doodstream-api-upload.test.js | 94 +- tests/doodstream-upload.test.js | 149 -- tests/filename-filter.test.js | 91 - tests/hidden-electron-window.test.js | 79 - tests/hoster-recovery-provenance.test.js | 232 -- tests/hoster-recovery-safety.test.js | 230 -- tests/hosters.test.js | 93 +- tests/i18n.test.js | 52 +- tests/import-preflight.test.js | 222 +- tests/main-remote-input.test.js | 76 - tests/package-build-files.test.js | 87 +- tests/public-release-verifier.test.js | 166 +- tests/queue-dedup.test.js | 26 - tests/release-plan.test.js | 43 - tests/remote-server.test.js | 52 - tests/source-cleanup-policy.test.js | 254 +-- tests/source-file-cleanup.test.js | 135 +- tests/startup-renderer.test.js | 1240 +---------- tests/stats.test.js | 274 --- tests/support-bundle.test.js | 173 +- tests/support/hidden-electron-window.js | 57 - tests/ui-smoke.js | 1692 +-------------- tests/updater-version.test.js | 121 +- tests/upload-audit.test.js | 233 +- tests/upload-confirmation.test.js | 65 +- tests/upload-diagnostics.test.js | 37 - tests/upload-finalization-coordinator.test.js | 402 ---- tests/upload-manager-recovery-claims.test.js | 1055 --------- tests/upload-manager.test.js | 494 +---- tests/upload-recovery.test.js | 150 -- tests/upload-schedule.test.js | 125 -- tests/upload-start-reservation.test.js | 57 - 87 files changed, 2185 insertions(+), 16125 deletions(-) delete mode 100644 lib/batch-completion-report.js delete mode 100644 lib/batch-mutation-gate.js delete mode 100644 lib/filename-filter.js delete mode 100644 lib/hoster-transport-error.js delete mode 100644 lib/upload-recovery.js delete mode 100644 lib/upload-schedule.js delete mode 100644 lib/upload-start-reservation.js delete mode 100644 tests/batch-completion-main.test.js delete mode 100644 tests/batch-completion-report.test.js delete mode 100644 tests/batch-mutation-gate.test.js delete mode 100644 tests/filename-filter.test.js delete mode 100644 tests/hidden-electron-window.test.js delete mode 100644 tests/hoster-recovery-provenance.test.js delete mode 100644 tests/hoster-recovery-safety.test.js delete mode 100644 tests/main-remote-input.test.js delete mode 100644 tests/release-plan.test.js delete mode 100644 tests/support/hidden-electron-window.js delete mode 100644 tests/upload-finalization-coordinator.test.js delete mode 100644 tests/upload-manager-recovery-claims.test.js delete mode 100644 tests/upload-recovery.test.js delete mode 100644 tests/upload-schedule.test.js delete mode 100644 tests/upload-start-reservation.test.js diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 3ffe2bd..59f24dc 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -3,7 +3,6 @@ name: CI on: push: branches: ['**'] - tags: ['v*'] pull_request: jobs: @@ -16,9 +15,6 @@ jobs: with: node-version: 24 cache: npm - - run: npm run verify:public-source - run: npm ci - run: npm run verify - env: - RUN_UI_SMOKE: '1' - run: npm run dist diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ffe2bd..59f24dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,6 @@ name: CI on: push: branches: ['**'] - tags: ['v*'] pull_request: jobs: @@ -16,9 +15,6 @@ jobs: with: node-version: 24 cache: npm - - run: npm run verify:public-source - run: npm ci - run: npm run verify - env: - RUN_UI_SMOKE: '1' - run: npm run dist diff --git a/.gitignore b/.gitignore index 608f513..346bf1f 100644 --- a/.gitignore +++ b/.gitignore @@ -20,12 +20,3 @@ account-rotation.log doodstream-debug.log upload-debug.log release-*.log -tasks/ -.cl[a]ude/ -.c[o]dex/ -AGENTS.md -C[L]AUDE.md -MEMORY.md -MEMORY_SUMMARY.md -raw_memories.md -prompts/ diff --git a/README.md b/README.md index 721adcf..85b3dc7 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,28 @@ -# Multi Hoster Uploader +# Multi-Hoster-Upload -Multi Hoster Uploader is a Windows desktop application for sending file batches to several supported video hosts from one queue. Add files or folders, choose one or more destinations, manage multiple accounts, and follow every upload from preparation to its final link. +Multi-Hoster-Upload is a Windows desktop application for sending file batches to several supported video hosts from one queue. Add files or folders, choose one or more destinations, manage multiple accounts, and follow every upload from preparation to its final link. -![Multi Hoster Uploader product overview](assets/product-overview.png) +![Multi-Hoster-Upload product overview](assets/product-overview.png) ## Download 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.23. Use the release page for the executables and the full English changelog. +The latest public release is version 2.1.24. Use the release page for the executables and the full English changelog. ## Features ### Upload workspace - Add individual files, complete folders, or files by drag and drop. -- Inspect imports before queue creation and review readable, duplicate, filtered, and host-size-limited files in one summary. -- Filter new imports by file name with reusable include or exclude conditions before upload jobs are created. -- Review how many selected files were accepted or excluded before choosing upload destinations. +- Review candidates, duplicates, unavailable files, accepted files, destinations, resulting jobs, and configured size-limit omissions before confirming an import. - 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, smoothly interpolated progress, transferred size, speed, and the selected host account. -- Read remaining, total, running, connection, completed, and failed upload activity from the persistent sidebar telemetry. +- 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. - Reorder selected jobs, start selected jobs, retry finished jobs, or stop active work. - Copy completed links individually or together. @@ -35,14 +33,12 @@ The latest public release is version 2.1.23. Use the release page for the execut - Keep multiple named accounts for each host. - Validate credentials before a new or edited account is saved. - Run health checks for one account or all configured accounts. -- Review recent host reliability, throughput, last success, and account availability in a dedicated health overview. - Complete an OTP check in the account view when a host requests it. - Enable, disable, prioritize, and reorder accounts. - Rotate files across enabled accounts or keep the first enabled account as the primary account. - Switch to an available fallback account when an account-specific upload error is detected. - Apply retries, concurrency, bandwidth, file-size, and pacing settings per host. - Monitor a folder for new files and start matching uploads automatically. -- Restrict new upload starts to configurable weekday and local-time windows while allowing active transfers to finish. ### History, transfer, and updates @@ -51,7 +47,6 @@ The latest public release is version 2.1.23. Use the release page for the execut - 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. -- Review a final post-cleanup batch report with file, job, host, transfer, and source-cleanup totals, then export the complete report as JSON or sanitized errors as CSV. - 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. @@ -131,7 +126,7 @@ Successful jobs can remain visible or be removed automatically. History is store **Settings > Uploads** can permanently delete a source file after every destination selected for that file has completed successfully. This option is disabled by default and requires an explicit warning confirmation before it can be enabled. -Cleanup remains blocked while any required destination is unfinished, failed, canceled, skipped, or missing valid confirmation. The application records the original file identity, preserves successful destinations across retries and restarts, waits for active file handles to close, and refuses deletion if the source was replaced or modified. Every cleanup decision and result is written to `upload-audit.log`, while the FileUploader and MDU session logs remain limited to successful links. Deletion bypasses the Recycle Bin and cannot be undone. +Cleanup remains blocked while any required destination is unfinished, failed, canceled, skipped, or missing valid confirmation. The application records the original file identity, preserves successful destinations across retries and restarts, waits for active file handles to close, and refuses deletion if the source was replaced or modified. Every cleanup decision and result is written to the upload log. Deletion bypasses the Recycle Bin and cannot be undone. ## Folder monitoring @@ -200,7 +195,7 @@ Treat the generated key like a password: anyone with it can restore the encrypte Open **Settings > General** or **Help** and select **Check for updates**. When a newer version is available, the application shows the installed and available versions, download progress, and release notes. The update dialog requests the changelog for the matching version from the public GitHub release and uses the release's fallback description if that changelog is unavailable. -Installation is refused unless `latest.yml` contains the matching version, installer name, file size, and a valid SHA-512 checksum. The downloaded Setup executable must match those values and contain a valid Windows executable header before it can be launched. +The downloaded Setup executable is checked as a Windows executable. When `latest.yml` contains a SHA-512 value, the downloaded file is also checked against it before installation starts. You can always install manually from the [GitHub releases page](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases). diff --git a/lib/batch-completion-report.js b/lib/batch-completion-report.js deleted file mode 100644 index abcc2c2..0000000 --- a/lib/batch-completion-report.js +++ /dev/null @@ -1,188 +0,0 @@ -const { classifyErrorCategory } = require('./stats'); -const { redactLogText } = require('./support-bundle'); - -function number(value) { - const parsed = Number(value); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; -} - -function integer(value) { - return Math.max(0, Math.trunc(number(value))); -} - -function iso(value, fallback) { - const date = new Date(value); - return Number.isNaN(date.getTime()) ? fallback : date.toISOString(); -} - -function text(value, secrets, limit = 500) { - const source = value instanceof Error ? value.message : String(value ?? ''); - return String(redactLogText(source, secrets) || '').slice(0, limit); -} - -function redactPosixPaths(value) { - let output = ''; - let index = 0; - while (index < value.length) { - const previous = value[index - 1] || ''; - if (value[index] !== '/' || (index > 0 && !/[\s=:([{]/.test(previous))) { - output += value[index++]; - continue; - } - let end = index + 1; - while (end < value.length && !/[\s"'<>|]/.test(value[end])) end++; - const candidate = value.slice(index, end); - if (candidate.slice(1).includes('/')) { - output += ''; - index = end; - continue; - } - output += value[index++]; - } - return output; -} - -function errorText(value, secrets) { - return redactPosixPaths(text(value, secrets) - .replace(/https?:\/\/[^\s"'<>]+/gi, '')) - .replace(/\b[A-Za-z0-9_-]{24,}\b/g, ''); -} - -function fileName(value, secrets) { - const name = String(value ?? '').split(/[\\/]/).pop() || ''; - return text(name, secrets, 260); -} - -function createJobTotals() { - return { total: 0, succeeded: 0, failed: 0, skipped: 0, aborted: 0 }; -} - -function createHostTotals() { - return { ...createJobTotals(), successfulBytes: 0 }; -} - -function addStatus(target, status) { - target.total++; - if (status === 'done') target.succeeded++; - else if (status === 'skipped') target.skipped++; - else if (status === 'aborted') target.aborted++; - else target.failed++; -} - -function buildCleanupTotals(outcomes) { - const totals = { requested: 0, deleted: 0, blocked: 0, failed: 0 }; - for (const value of Array.isArray(outcomes) ? outcomes : []) { - const outcome = String(value || 'failed'); - if (outcome === 'setting-disabled') continue; - totals.requested++; - if (outcome === 'deleted') totals.deleted++; - else if (outcome === 'blocked' || outcome === 'source-changed' || outcome === 'source-missing' || outcome === 'unsafe-source-type') totals.blocked++; - else totals.failed++; - } - return totals; -} - -function deepFreeze(value) { - if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value; - Object.values(value).forEach(deepFreeze); - return Object.freeze(value); -} - -function buildBatchCompletionReport(input = {}) { - const summary = input.summary && typeof input.summary === 'object' ? input.summary : {}; - const secrets = Array.isArray(input.secrets) ? input.secrets : []; - const completedAt = iso(input.completedAt, new Date().toISOString()); - const startedAt = iso(input.startedAt ?? summary.timestamp, completedAt); - const durationSec = Math.max(0, (new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 1000); - const files = { total: 0, fullySucceeded: 0, partiallySucceeded: 0, failed: 0 }; - const jobs = createJobTotals(); - const hostMap = new Map(); - const errors = []; - let successfulBytes = 0; - - for (const file of Array.isArray(summary.files) ? summary.files : []) { - const results = Array.isArray(file?.results) ? file.results : []; - if (results.length === 0) continue; - files.total++; - const size = number(file?.size); - const successful = results.filter(result => result?.status === 'done').length; - if (successful === results.length) files.fullySucceeded++; - else if (successful > 0) files.partiallySucceeded++; - else files.failed++; - const safeFileName = fileName(file?.name ?? file?.fileName, secrets); - - for (const result of results) { - const status = String(result?.status || 'error'); - const hoster = text(result?.hoster || 'unknown', secrets, 120) || 'unknown'; - if (!hostMap.has(hoster)) hostMap.set(hoster, createHostTotals()); - const host = hostMap.get(hoster); - addStatus(jobs, status); - addStatus(host, status); - if (status === 'done') { - successfulBytes += size; - host.successfulBytes += size; - } - if (status === 'error' || result?.remoteCommitUncertain === true) { - const message = errorText(result?.error || 'Unknown error', secrets); - errors.push({ - jobId: text(result?.jobId, secrets, 160), - fileName: safeFileName, - hoster, - status, - category: classifyErrorCategory(message), - attempt: integer(result?.attempt), - maxAttempts: integer(result?.maxAttempts), - remoteCommitUncertain: result?.remoteCommitUncertain === true, - message - }); - } - } - } - - const hosters = Object.fromEntries([...hostMap.entries()].sort(([left], [right]) => left.localeCompare(right))); - const batchId = text(summary.id, secrets, 160); - const report = { - reportId: text(input.reportId || `report-${batchId || completedAt}`, secrets, 200), - batchId, - startedAt, - completedAt, - generatedAt: completedAt, - durationSec, - files, - jobs, - cleanup: buildCleanupTotals(input.cleanupOutcomes), - transfer: { - successfulBytes, - averageBytesPerSecond: durationSec > 0 ? successfulBytes / durationSec : 0 - }, - hosters, - errors - }; - return deepFreeze(report); -} - -function csvCell(value) { - let output = value === null || value === undefined ? '' : String(value); - if (/^[\u0000-\u0020]*[=+\-@]/.test(output)) output = `'${output}`; - return /[",\r\n]/.test(output) ? `"${output.replace(/"/g, '""')}"` : output; -} - -function buildBatchErrorCsv(report) { - const rows = [['Job ID', 'File name', 'Host', 'Status', 'Category', 'Attempt', 'Max attempts', 'Remote commit uncertain', 'Message']]; - for (const error of Array.isArray(report?.errors) ? report.errors : []) { - rows.push([ - error.jobId, - error.fileName, - error.hoster, - error.status, - error.category, - integer(error.attempt), - integer(error.maxAttempts), - error.remoteCommitUncertain === true ? 'true' : 'false', - error.message - ]); - } - return `${rows.map(row => row.map(csvCell).join(',')).join('\n')}\n`; -} - -module.exports = { buildBatchCompletionReport, buildBatchErrorCsv }; diff --git a/lib/batch-mutation-gate.js b/lib/batch-mutation-gate.js deleted file mode 100644 index 78b3b3b..0000000 --- a/lib/batch-mutation-gate.js +++ /dev/null @@ -1,55 +0,0 @@ -function createBatchMutationGate() { - let activeLeaseCount = 0; - let sealed = false; - let activeAtSeal = false; - let drainPromise = null; - let resolveDrain = null; - - function acquire() { - if (sealed) return null; - - activeLeaseCount += 1; - let open = true; - - return Object.freeze({ - finish() { - if (!open) return false; - - open = false; - activeLeaseCount -= 1; - - if (sealed && activeLeaseCount === 0 && resolveDrain) { - const resolve = resolveDrain; - resolveDrain = null; - resolve(activeAtSeal); - } - - return true; - }, - isOpen() { - return open; - } - }); - } - - function sealAndDrain() { - if (drainPromise) return drainPromise; - - sealed = true; - activeAtSeal = activeLeaseCount > 0; - - if (!activeAtSeal) { - drainPromise = Promise.resolve(false); - return drainPromise; - } - - drainPromise = new Promise((resolve) => { - resolveDrain = resolve; - }); - return drainPromise; - } - - return Object.freeze({ acquire, sealAndDrain }); -} - -module.exports = { createBatchMutationGate }; diff --git a/lib/config-store.js b/lib/config-store.js index 32c35cd..8831d4a 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -2,7 +2,6 @@ const fs = require('fs'); const path = require('path'); const secretStore = require('./secret-store'); const { normalizeLogMode } = require('./log-mode'); -const { normalizeUploadSchedule } = require('./upload-schedule'); const HOSTER_SETTINGS_DEFAULTS = { retries: 3, @@ -79,18 +78,6 @@ const DEFAULTS = { lastBrowseDirectory: '', removeFromQueueOnDone: false, deleteSourceAfterSuccessfulUpload: false, - filenameFilter: { - enabled: false, - action: 'include', - matchMode: 'all', - conditions: [] - }, - uploadSchedule: { - enabled: false, - weekdays: [1, 2, 3, 4, 5, 6, 0], - start: '00:00', - end: '23:59' - }, showDropTarget: false, globalMaxSpeedKbs: 0, // 0 = unlimited global speed pendingQueue: null, @@ -145,20 +132,6 @@ const HISTORY_RETENTION_OPTIONS = [ { value: '100', label: 'Letzte 100 Uploads' } ]; -const DIAGNOSTIC_ERROR_MESSAGES = Object.freeze({ - DIAGNOSTIC_CONFIG_READ_FAILED: 'Die Diagnosekonfiguration konnte nicht gelesen werden', - DIAGNOSTIC_CONFIG_INVALID: 'Die Diagnosekonfiguration ist ungültig', - DIAGNOSTIC_HISTORY_NOT_FOUND: 'Die Diagnoseverlaufsdatei wurde nicht gefunden', - DIAGNOSTIC_HISTORY_READ_FAILED: 'Die Diagnoseverlaufsdatei konnte nicht gelesen werden', - DIAGNOSTIC_HISTORY_INVALID: 'Die Diagnoseverlaufsdatei ist ungültig' -}); - -function diagnosticStoreError(code) { - const error = new Error(DIAGNOSTIC_ERROR_MESSAGES[code]); - error.code = code; - return error; -} - function batchTimestampMs(batch) { const raw = batch && batch.timestamp; if (raw === null || raw === undefined || raw === '') return null; @@ -251,30 +224,6 @@ class ConfigStore { } } - _readHistoryFileStrict() { - let raw; - try { - raw = fs.readFileSync(this.historyPath, 'utf-8'); - } catch (error) { - if (error && error.code === 'ENOENT') { - throw diagnosticStoreError('DIAGNOSTIC_HISTORY_NOT_FOUND'); - } - throw diagnosticStoreError('DIAGNOSTIC_HISTORY_READ_FAILED'); - } - if (!raw || raw.trim().length < 2) { - throw diagnosticStoreError('DIAGNOSTIC_HISTORY_INVALID'); - } - let parsed; - try { - parsed = JSON.parse(raw); - } catch { - throw diagnosticStoreError('DIAGNOSTIC_HISTORY_INVALID'); - } - if (Array.isArray(parsed)) return parsed; - if (parsed && Array.isArray(parsed.history)) return parsed.history; - throw diagnosticStoreError('DIAGNOSTIC_HISTORY_INVALID'); - } - _writeHistoryFileDurable(arr) { const tmp = this.historyPath + '.tmp'; const fd = fs.openSync(tmp, 'w'); @@ -287,35 +236,15 @@ class ConfigStore { fs.renameSync(tmp, this.historyPath); } - async _writeHistoryFileAtomic(arr) { - const tmp = this.historyPath + '.tmp'; - let handle; - let operationError; - try { - handle = await fs.promises.open(tmp, 'w'); - await handle.writeFile(JSON.stringify(arr), 'utf-8'); - await handle.sync(); - } catch (error) { - operationError = error; - } - if (handle) { - try { - await handle.close(); - } catch (error) { - if (!operationError) operationError = error; - } - } - if (operationError) throw operationError; - await fs.promises.rename(tmp, this.historyPath); - let directoryHandle; - try { - directoryHandle = await fs.promises.open(path.dirname(this.historyPath), 'r'); - await directoryHandle.sync(); - } catch (error) { - if (!['EINVAL', 'EISDIR', 'EPERM', 'ENOTSUP'].includes(error.code)) throw error; - } finally { - if (directoryHandle) await directoryHandle.close(); - } + _writeHistoryFileAtomic(arr) { + return new Promise((resolve, reject) => { + const tmp = this.historyPath + '.tmp'; + fs.writeFile(tmp, JSON.stringify(arr), 'utf-8', (err) => { + if (err) return reject(err); + try { fs.renameSync(tmp, this.historyPath); } catch (e) { return reject(e); } + resolve(); + }); + }); } _quiescedWriteError() { @@ -393,31 +322,6 @@ class ConfigStore { return JSON.parse(raw); } - _readConfigFileStrict() { - let raw; - try { - raw = fs.readFileSync(this.filePath, 'utf-8'); - } catch { - throw diagnosticStoreError('DIAGNOSTIC_CONFIG_READ_FAILED'); - } - if (!raw || raw.trim().length < 2) { - throw diagnosticStoreError('DIAGNOSTIC_CONFIG_INVALID'); - } - let data; - try { - data = JSON.parse(raw); - } catch { - throw diagnosticStoreError('DIAGNOSTIC_CONFIG_INVALID'); - } - if (!data || typeof data !== 'object' || Array.isArray(data) || - !data.hosters || typeof data.hosters !== 'object' || Array.isArray(data.hosters) || - !data.globalSettings || typeof data.globalSettings !== 'object' || Array.isArray(data.globalSettings) || - (data.history !== undefined && !Array.isArray(data.history))) { - throw diagnosticStoreError('DIAGNOSTIC_CONFIG_INVALID'); - } - return data; - } - _clone(obj) { try { return structuredClone(obj); } catch { return JSON.parse(JSON.stringify(obj)); } @@ -456,11 +360,7 @@ class ConfigStore { return r; } - loadDiagnosticsConfig() { - return this._loadImpl(true); - } - - _loadImpl(strict = false) { + _loadImpl() { try { // In-memory cache keyed on the file's mtime+size. The processed config // (merged + credential-decrypted) is reparsed/re-decrypted from disk ONLY @@ -472,27 +372,21 @@ class ConfigStore { // long-running main-thread drag. load() always returns a CLONE so callers // can mutate the result without corrupting the cache. let stat = null; - if (!strict) { - try { stat = fs.statSync(this.filePath); } catch {} - } + try { stat = fs.statSync(this.filePath); } catch {} const statKey = stat ? `${stat.mtimeMs}:${stat.size}` : ''; - if (!strict && stat && this._cache && this._cacheKey === statKey) { + if (stat && this._cache && this._cacheKey === statKey) { return this._clone(this._cache); } let data = null; - if (strict) { - data = this._readConfigFileStrict(); - } else { - // Try main config - try { data = this._readAndParse(this.filePath); } catch {} - // Fallback to backup if main is empty/corrupt - if (!data) { - try { data = this._readAndParse(this.filePath + '.bak'); } catch {} - } - if (!data) { - try { data = this._readAndParse(this.filePath + '.pre-history-split.bak'); } catch {} - } + // Try main config + try { data = this._readAndParse(this.filePath); } catch {} + // Fallback to backup if main is empty/corrupt + if (!data) { + try { data = this._readAndParse(this.filePath + '.bak'); } catch {} + } + if (!data) { + try { data = this._readAndParse(this.filePath + '.pre-history-split.bak'); } catch {} } if (!data) { const fresh = JSON.parse(JSON.stringify(DEFAULTS)); @@ -566,7 +460,6 @@ class ConfigStore { // Downstream readers consume logMode only and must NOT derive from // sessionLog at call sites. globalSettings.logMode = normalizeLogMode(globalSettings); - globalSettings.uploadSchedule = normalizeUploadSchedule(globalSettings.uploadSchedule); const rotationCursors = (data.rotationCursors && typeof data.rotationCursors === 'object' && !Array.isArray(data.rotationCursors)) ? data.rotationCursors : {}; @@ -574,13 +467,13 @@ class ConfigStore { // Decrypt credentials stored with safeStorage so the rest of the app // keeps working with plaintext in memory. secretStore.decryptCredentials(result); - if (!strict && stat) { + if (stat) { this._cache = result; this._cacheKey = statKey; } return this._clone(result); } catch (error) { - if (strict || error instanceof secretStore.SecretStoreError) throw error; + if (error instanceof secretStore.SecretStoreError) throw error; const fresh = JSON.parse(JSON.stringify(DEFAULTS)); fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings); return fresh; @@ -596,7 +489,6 @@ class ConfigStore { const hosters = this._clone(config.hosters || {}); const globalSettings = this._clone(config.globalSettings || {}); delete globalSettings.allowPlaintextCredentialStorage; - globalSettings.uploadSchedule = normalizeUploadSchedule(globalSettings.uploadSchedule); secretStore.encryptCredentials({ hosters }); return JSON.stringify({ ...config, globalSettings, hosters }, null, 2); } @@ -719,19 +611,6 @@ class ConfigStore { }); } - saveFallbackLogPath(logFilePath) { - const snapshot = String(logFilePath || '').trim(); - return this._enqueueWrite(() => { - const current = this.load(); - current.globalSettings = { - ...(current.globalSettings || {}), - logFilePath: snapshot - }; - this._guardHosters(current, false); - return this._commit(current); - }); - } - saveRendererGlobalSettings(globalSettings) { const snapshot = this._clone(globalSettings || {}); return this._enqueueWrite(() => { @@ -795,20 +674,6 @@ class ConfigStore { return config.history || []; } - loadDiagnosticsHistory() { - if (this._historyMigrated) return this._readHistoryFileStrict(); - try { - return this._readHistoryFileStrict(); - } catch (error) { - if (!error || error.code !== 'DIAGNOSTIC_HISTORY_NOT_FOUND') throw error; - } - const config = this.loadDiagnosticsConfig(); - if (!Array.isArray(config.history)) { - throw diagnosticStoreError('DIAGNOSTIC_HISTORY_INVALID'); - } - return config.history; - } - _atomicWrite(data) { return new Promise((resolve, reject) => { const tmpPath = this.filePath + '.tmp'; diff --git a/lib/diagnostics-agent.js b/lib/diagnostics-agent.js index 520fc52..f5c2239 100644 --- a/lib/diagnostics-agent.js +++ b/lib/diagnostics-agent.js @@ -1,5 +1,3 @@ -const { valueScrub } = require('./support-bundle'); - function createAgent(collectors) { const OPS = { get_system_info: (a) => collectors.getSystemInfo(a), @@ -16,28 +14,15 @@ function createAgent(collectors) { get_health: () => collectors.getHealth() }; - function redactResponse(value) { - try { - const redacted = typeof collectors.redactResponse === 'function' - ? collectors.redactResponse(value) - : valueScrub(value, []); - const response = valueScrub(redacted, []); - if (!response || typeof response !== 'object' || Array.isArray(response)) throw new Error('invalid redaction result'); - return response; - } catch { - return { ok: false, error: 'diagnostic response could not be safely returned' }; - } - } - function handle(op, args) { const fn = (typeof op === 'string' && Object.prototype.hasOwnProperty.call(OPS, op)) ? OPS[op] : null; - if (typeof fn !== 'function') return redactResponse({ ok: false, error: `unknown or non-readonly op: ${op}` }); + if (typeof fn !== 'function') return { ok: false, error: `unknown or non-readonly op: ${op}` }; try { const data = fn(args || {}); - if (data && data.ok === false) return redactResponse(data); - return redactResponse({ ok: true, data }); + if (data && data.ok === false) return data; + return { ok: true, data }; } catch (e) { - return redactResponse({ ok: false, error: String((e && e.message) || e) }); + return { ok: false, error: String((e && e.message) || e) }; } } diff --git a/lib/diagnostics-collectors.js b/lib/diagnostics-collectors.js index f1bd6f2..41f890d 100644 --- a/lib/diagnostics-collectors.js +++ b/lib/diagnostics-collectors.js @@ -16,30 +16,22 @@ function createCollectors(deps) { const { loadConfig, loadHistory, getAllLogPaths, support, stats, appInfo, systemInfo, agentInfo } = deps; function _secrets() { - return support.collectSecretValues(loadConfig()); - } - - function _currentHistory() { - if (typeof loadHistory === 'function') { - const history = loadHistory(); - if (!Array.isArray(history)) throw new Error('History reader returned invalid data'); - return history; - } - const cfg = loadConfig(); - return Array.isArray(cfg && cfg.history) ? cfg.history : []; - } - - function _historyContext() { - const secrets = _secrets(); - return { history: _currentHistory(), secrets }; + try { return support.collectSecretValues(loadConfig()); } catch { return []; } } function _deepRedact(value, secrets) { - return support.valueScrub(value, secrets || _secrets()); - } - - function redactResponse(value) { - return _deepRedact(value); + const s = secrets || _secrets(); + const walk = (v) => { + if (typeof v === 'string') return support.redactLogText(v, s); + if (Array.isArray(v)) return v.map(walk); + if (v && typeof v === 'object') { + const o = {}; + for (const k of Object.keys(v)) o[k] = walk(v[k]); + return o; + } + return v; + }; + try { return walk(value); } catch { return value; } } function _resolveLogPath(name, backup) { @@ -87,10 +79,10 @@ function createCollectors(deps) { readableNames.add(path.basename(fp)); try { const st = fs.statSync(fp); - variants.push({ id: backup === 0 ? name : `${name}:${backup}`, backup, fileName: path.basename(fp), sizeBytes: st.size, mtime: st.mtime.toISOString() }); + variants.push({ backup, sizeBytes: st.size, mtime: st.mtime.toISOString() }); } catch {} } - files.push({ id: name, name, fileName: path.basename(base), readable: true, present: variants.length > 0, variants }); + files.push({ name, path: base, readable: true, present: variants.length > 0, variants }); } let siblings = []; try { @@ -103,16 +95,16 @@ function createCollectors(deps) { return { name: f, readable: false, sizeBytes: size, mtime }; }); } catch {} - return { files, otherLogs: siblings }; + return { dir, files, otherLogs: siblings }; } function readLog(args) { const a = args || {}; const name = a.name; const p = _resolveLogPath(name, a.backup); - if (!p) return { ok: false, error: 'unknown or non-readable log identifier' }; + if (!p) return { ok: false, error: `unknown or non-readable log: ${name}` }; const tailKb = Math.min(Math.max(Number(a.tailKb) || 256, 1), 1024); - const raw = support.collectFile(p, name, tailKb * 1024, { includePath: false }); + const raw = support.collectFile(p, name, tailKb * 1024); let content = support.redactLogText(raw, _secrets()); let matchedLines; if (a.grep && typeof a.grep === 'string' && a.grep.length <= 200) { @@ -128,7 +120,7 @@ function createCollectors(deps) { } let sizeBytes = null; try { sizeBytes = fs.statSync(p).size; } catch {} - return { id: name, name, fileName: path.basename(p), sizeBytes, returnedBytes: Buffer.byteLength(content), tailKb, matchedLines, content }; + return { name, path: p, sizeBytes, returnedBytes: Buffer.byteLength(content), tailKb, matchedLines, content }; } function getAppEvents(args) { @@ -145,9 +137,10 @@ function createCollectors(deps) { return { events: out.slice(-limit), truncated: out.length > limit }; } - function _historyErrors(history, opts, secrets) { + function _historyErrors(history, opts) { const o = opts || {}; const sinceMs = Number.isFinite(o.sinceMs) ? o.sinceMs : null; + const secrets = _secrets(); const errors = []; const byCategory = {}; for (const batch of (Array.isArray(history) ? history : [])) { @@ -176,19 +169,15 @@ function createCollectors(deps) { return { errors, byCategory }; } - function _listErrors(args, history, secrets) { + function listErrors(args) { const a = args || {}; - const { errors, byCategory } = _historyErrors(history, a, secrets); + const cfg = loadConfig(); + const { errors, byCategory } = _historyErrors(cfg.history, a); const limit = Math.min(Math.max(Number(a.limit) || 100, 1), 1000); const window = Number.isFinite(a.sinceMs) ? `since ${new Date(a.sinceMs).toISOString()}` : 'all history'; return { window, total: errors.length, byCategory, errors: errors.slice(-limit) }; } - function listErrors(args) { - const { history, secrets } = _historyContext(); - return _listErrors(args, history, secrets); - } - function getQueueState(args) { const a = args || {}; const cfg = loadConfig(); @@ -218,11 +207,15 @@ function createCollectors(deps) { return result; } - function _getHistory(args, history, secrets) { + function getHistory(args) { const a = args || {}; + const history = typeof loadHistory === 'function' + ? (loadHistory() || []) + : (Array.isArray(loadConfig().history) ? loadConfig().history : []); const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 200); - const perHoster = stats.summarizePerHoster([...history]); + const perHoster = stats.summarizePerHoster(history); const recent = [...history].slice(-limit).reverse(); + const secrets = _secrets(); const batches = recent.map(b => { const out = { timestamp: b.timestamp || null, fileCount: Array.isArray(b.files) ? b.files.length : 0 }; if (a.includeFiles) { @@ -241,11 +234,6 @@ function createCollectors(deps) { return { totalBatches: history.length, returned: batches.length, perHoster, batches }; } - function getHistory(args) { - const { history, secrets } = _historyContext(); - return _getHistory(args, history, secrets); - } - function getRotationState() { const cfg = loadConfig(); return { rotationCursors: _deepRedact(cfg.rotationCursors || {}) }; @@ -265,10 +253,9 @@ function createCollectors(deps) { const a = args || {}; const errorLimit = Math.min(Math.max(Number(a.errorLimit) || 20, 1), 200); const errArgs = Number.isFinite(a.errorSinceMs) ? { sinceMs: a.errorSinceMs, limit: errorLimit } : { limit: errorLimit }; - const { history: currentHistory, secrets } = _historyContext(); - const errors = _listErrors(errArgs, currentHistory, secrets); + const errors = listErrors(errArgs); const queue = getQueueState({ includeJobs: false }); - const history = _getHistory({ limit: 5 }, currentHistory, secrets); + const history = getHistory({ limit: 5 }); const warnings = []; if (queue.source === 'persisted' && queue.stale) warnings.push('queue state is from the persisted snapshot (may lag live state; UploadManager not introspected in this build).'); if (errors.total > 0) warnings.push(`${errors.total} non-success result(s) in the error window.`); @@ -287,7 +274,7 @@ function createCollectors(deps) { return { getSystemInfo, getConfigRedacted, listLogs, readLog, getAppEvents, listErrors, getQueueState, getHistory, getRotationState, getHealth, serverHealth, - redactResponse, READABLE_LOGS + READABLE_LOGS }; } diff --git a/lib/doodstream-upload.js b/lib/doodstream-upload.js index c575f72..4cc45d1 100644 --- a/lib/doodstream-upload.js +++ b/lib/doodstream-upload.js @@ -2,12 +2,6 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { request } = require('undici'); -const { - createTransportError, - safeEndpoint, - sanitizeRemoteText, - summarizeResponse -} = require('./hoster-transport-error'); const BASE_URL = 'https://doodstream.com'; const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; @@ -103,15 +97,8 @@ class DoodstreamUploader { break; } catch (err) { if (opts.signal && opts.signal.aborted) throw err; // caller abort: don't retry - if (attempt >= 3) { - throw createTransportError('Doodstream: Webanfrage fehlgeschlagen', { - phase: 'web-request', - endpoint: url, - retryable: true, - transientNetwork: true - }); - } - _debugLog(`_fetch transient (${attempt}/3) ${safeEndpoint(url) || 'unknown endpoint'}: ${err && err.name ? err.name : 'network error'}; retry`); + if (attempt >= 3) throw err; + _debugLog(`_fetch transient (${attempt}/3) ${url}: ${err && err.message}; retry`); await new Promise(r => setTimeout(r, 400 * attempt)); } } @@ -180,35 +167,16 @@ class DoodstreamUploader { // Explicit success response } else if (json && json.message && /otp/i.test(json.message)) { // OTP required — signal caller to collect OTP from user - const err = createTransportError(`Doodstream Login: ${sanitizeRemoteText(json.message)}`, { - phase: 'login', - endpoint: BASE_URL, - httpStatus: res.status, - contentType: res.headers && res.headers.get ? res.headers.get('content-type') : null, - body - }); + const err = new Error(`Doodstream Login: ${json.message}`); err.otpRequired = true; throw err; } else if (json && json.status === 'fail') { - throw createTransportError(`Doodstream Login: ${sanitizeRemoteText(json.message) || 'Login fehlgeschlagen'}`, { - phase: 'login', - endpoint: BASE_URL, - httpStatus: res.status, - contentType: res.headers && res.headers.get ? res.headers.get('content-type') : null, - body, - accountError: true - }); + throw new Error(`Doodstream Login: ${json.message || 'Login fehlgeschlagen'}`); } else if (body.includes('Dashboard')) { // Got dashboard HTML directly — login worked } else { - const msg = sanitizeRemoteText(json && json.message) || 'Login fehlgeschlagen'; - throw createTransportError(`Doodstream Login: ${msg}`, { - phase: 'login', - endpoint: BASE_URL, - httpStatus: res.status, - contentType: res.headers && res.headers.get ? res.headers.get('content-type') : null, - body - }); + const msg = (json && json.message) || 'Login fehlgeschlagen'; + throw new Error(`Doodstream Login: ${msg}`); } } @@ -252,7 +220,7 @@ class DoodstreamUploader { const res = await this._fetch(BASE_URL + '/?op=upload_server'); const text = await res.text(); const ctype = (res.headers && res.headers.get) ? (res.headers.get('content-type') || '') : ''; - _debugLog(`upload_server: status=${res.status} ctype=${ctype} response=${summarizeResponse(text, ctype)}`); + _debugLog(`upload_server: status=${res.status} ctype=${ctype} body(800)=${(text || '').slice(0, 800)}`); let json; try { json = JSON.parse(text); } catch { json = null; } @@ -286,7 +254,7 @@ class DoodstreamUploader { // Capture the form's real fields so upload() submits exactly what the // browser would (file_title, submit_btn, …) instead of stale hardcoded ones. this._uploadFormFields = this._parseUploadFormFields(html); - _debugLog(`upload_server: using form action node=${safeEndpoint(url)} sessLength=${this.sessId.length} fields=${Object.keys(this._uploadFormFields).join(',')}`); + _debugLog(`upload_server: using form action node=${url} sess=${this.sessId} fields=${Object.keys(this._uploadFormFields).join(',')}`); return url; } @@ -297,19 +265,15 @@ class DoodstreamUploader { // No upload server could be extracted. We MUST NOT silently fall back to a // hardcoded node: that node is stale and accepts the bytes but returns an // empty form (no filecode) — so the user wastes ~90s uploading 95 MB into a - // dead end and gets a cryptic "kein Filecode" 90s later. Fail fast with - // safe structured diagnostics. - _debugLog(`upload_server: no server response=${summarizeResponse(text, ctype)} page=${summarizeResponse(html, pageRes.headers && pageRes.headers.get ? pageRes.headers.get('content-type') : '')}`); - throw createTransportError('Doodstream: konnte Upload-Server nicht ermitteln', { - phase: 'upload-server', - endpoint: BASE_URL + '/?op=upload_server', - httpStatus: res.status, - contentType: ctype, - body: text, - retryable: res.status >= 500, - transientNetwork: res.status >= 500, - hosterTransient: res.status >= 500 - }); + // dead end and gets a cryptic "kein Filecode" 90s later. Fail fast and put + // the raw responses in the error so the real format change is diagnosable. + const urlHints = (html.match(/https?:\/\/[^'">\s]+/g) || []).slice(0, 4).join(' , '); + _debugLog(`upload_server: NO SERVER. upload-page html(2000)=${(html || '').slice(0, 2000)}`); + throw new Error( + `Doodstream: konnte Upload-Server nicht ermitteln (Endpoint geändert?). ` + + `op=upload_server status=${res.status} ctype=${ctype} body=${(text || '').slice(0, 300)} ` + + `| upload-page URL-Treffer: ${urlHints || 'keine'}` + ); } /** @@ -393,7 +357,7 @@ class DoodstreamUploader { let uploadRes; try { - uploadRes = await this._requestUpload(uploadUrl, { + uploadRes = await request(uploadUrl, { method: 'POST', headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, @@ -406,15 +370,15 @@ class DoodstreamUploader { bodyTimeout: UPLOAD_TIMEOUT, headersTimeout: 60000 }); - } catch { + } catch (err) { + // Label which phase failed so a future "fetch failed"/"terminated" is + // attributable to the big upload POST vs the small bookend requests. The + // original message is preserved as a substring so upload-manager's + // transient classification still matches. NOTE: undici may surface + // "terminated"/"other side closed", which are not yet in that transient + // list — revisit if logs show them. const mb = Math.round(bytesRead / 1048576); - throw createTransportError(`Doodstream Upload-POST nach ${mb} MB fehlgeschlagen`, { - phase: 'upload-request', - endpoint: uploadUrl, - retryable: true, - transientNetwork: true, - remoteCommitUncertain: true - }); + throw new Error(`Doodstream Upload-POST (${mb} MB an ${uploadUrl}): ${err && err.message ? err.message : err}`); } const statusCode = uploadRes.statusCode; @@ -430,69 +394,27 @@ class DoodstreamUploader { } } - let resText; - try { - resText = await uploadRes.body.text(); - } catch { - throw createTransportError('Doodstream Upload-Antwort konnte nicht gelesen werden', { - phase: 'upload-response-read', - endpoint: uploadUrl, - retryable: true, - transientNetwork: true, - remoteCommitUncertain: true - }); - } - const uploadContentType = uploadRes.headers && uploadRes.headers['content-type']; - _debugLog(`Upload response: ${summarizeResponse(resText, uploadContentType)}`); + const resText = await uploadRes.body.text(); + _debugLog(`Upload response body (first 500): ${resText.slice(0, 500)}`); if (statusCode >= 400) { let payload; try { payload = JSON.parse(resText); } catch {} - const msg = payload && payload.msg ? sanitizeRemoteText(payload.msg) : ''; - throw createTransportError(`Doodstream Upload fehlgeschlagen${msg ? `: ${msg}` : ''}`, { - phase: 'upload-response', - endpoint: uploadUrl, - httpStatus: statusCode, - contentType: uploadContentType, - body: resText, - retryable: statusCode === 429 || statusCode >= 500, - transientNetwork: statusCode >= 500 - }); + const msg = payload && payload.msg ? payload.msg : resText.slice(0, 200); + throw new Error(`Doodstream Upload HTTP ${statusCode}: ${msg}`); } return this._parseUploadResponse(resText); } - _requestUpload(url, options) { - return request(url, options); - } - /** * Follow a redirect URL from upload server and extract filecode */ async _handleUploadResult(url) { - _debugLog(`Following upload result URL: ${safeEndpoint(url) || 'unknown endpoint'}`); - let res; - try { - res = await this._fetch(url); - } catch (error) { - if (error && typeof error === 'object') error.remoteCommitUncertain = true; - throw error; - } - let html; - try { - html = await res.text(); - } catch { - throw createTransportError('Doodstream Ergebnis-Antwort konnte nicht gelesen werden', { - phase: 'upload-response-read', - endpoint: url, - retryable: true, - transientNetwork: true, - remoteCommitUncertain: true - }); - } - const contentType = res.headers && typeof res.headers.get === 'function' ? res.headers.get('content-type') : ''; - _debugLog(`Result page: ${summarizeResponse(html, contentType)}`); + _debugLog(`Following upload result URL: ${url}`); + const res = await this._fetch(url); + const html = await res.text(); + _debugLog(`Result page (first 500): ${html.slice(0, 500)}`); return this._parseUploadResponse(html); } @@ -536,12 +458,12 @@ class DoodstreamUploader { // 3. Parse HTML form (XFileSharing two-step upload) const hiddenFields = this._extractHiddenFields(resText); - _debugLog(`Hidden fields: ${Object.keys(hiddenFields).join(',')}`); + _debugLog(`Hidden fields: ${JSON.stringify(hiddenFields)}`); // Check if filecode is already in hidden fields const fnCode = hiddenFields.fn || hiddenFields.filecode || hiddenFields.file_code; if (fnCode && fnCode.length >= 8) { - _debugLog(`Filecode from hidden field 'fn': length ${fnCode.length}`); + _debugLog(`Filecode from hidden field 'fn': ${fnCode}`); // We still need to submit the form so doodstream registers the file // But the filecode is the 'fn' value } @@ -552,7 +474,7 @@ class DoodstreamUploader { // Ensure op=upload_result is set if (!hiddenFields.op) hiddenFields.op = 'upload_result'; - _debugLog(`Submitting upload_result fields: ${Object.keys(hiddenFields).join(',')}`); + _debugLog(`Submitting upload_result to ${BASE_URL}/ with fields: ${JSON.stringify(hiddenFields)}`); const formData = new URLSearchParams(hiddenFields); let followText = ''; try { @@ -565,23 +487,18 @@ class DoodstreamUploader { body: formData.toString() }); followText = await followRes.text(); - } catch { + } catch (err) { // The file already uploaded to the CDN; this POST only registers it on // doodstream's side. If it fails transiently (even after _fetch's own // retries) but we already hold the filecode, the upload succeeded from // the user's view — return it rather than discarding a done upload. if (fnCode && fnCode.length >= 8) { - _debugLog(`upload_result submit failed; using existing filecode length ${fnCode.length}`); + _debugLog(`upload_result submit failed (${err && err.message}); using fn ${fnCode}`); return this._buildResult(fnCode); } - throw createTransportError('Doodstream Upload: Ergebnis konnte nicht registriert werden', { - phase: 'upload-result-submit', - endpoint: BASE_URL, - retryable: true, - transientNetwork: true - }); + throw err; } - _debugLog(`upload_result response: ${summarizeResponse(followText, '')}`); + _debugLog(`upload_result response (first 500): ${followText.slice(0, 500)}`); // Try to find filecode in result page const resultCode = this._findFilecodeInHtml(followText); @@ -606,17 +523,11 @@ class DoodstreamUploader { // download link being empty while the page structure is unchanged points // at doodstream's backend, not at a parsing bug on our side. const st = hiddenFields.st || ''; - const safeStatus = sanitizeRemoteText(st, 100); - const fnInfo = fnCode ? `vorhanden(len ${fnCode.length})` : 'fehlt/leer'; - const node = safeEndpoint(this._lastUploadUrl) || 'unbekannt'; - _debugLog(`No filecode. st=${safeStatus || '?'} fn=${fnInfo} node=${node} response=${summarizeResponse(resText, 'text/html')}`); + const fnInfo = fnCode ? `"${fnCode}"(len ${fnCode.length})` : 'fehlt/leer'; + const node = this._lastUploadUrl || '?'; + _debugLog(`No filecode. st=${st} fn=${fnInfo} node=${node} CDN-body=${(resText || '').slice(0, 400)}`); if (st && st !== 'OK') { - throw createTransportError(`Doodstream lehnt Datei ab (Server-Status: ${safeStatus || 'unbekannt'})`, { - phase: 'upload-result', - endpoint: this._lastUploadUrl || BASE_URL, - contentType: 'text/html', - body: resText - }); + throw new Error(`Doodstream lehnt Datei ab (Server-Status: ${st}). CDN=${node}`); } // Empty form (no fn, no st) is a doodstream-side processing flake — same // account + same file works on a later attempt. Tag it explicitly so the @@ -625,42 +536,26 @@ class DoodstreamUploader { // session and later batches hit `pre-job-swap-blocked` for no fault of // the account). The flag is the primary signal; the message text is a // belt-and-suspenders regex fallback in the classifier. - throw createTransportError(`Doodstream Upload: kein Filecode (st=${safeStatus || '?'}, fn=${fnInfo}, CDN=${node})`, { - phase: 'upload-result', - endpoint: this._lastUploadUrl || BASE_URL, - contentType: 'text/html', - body: resText, - retryable: true, - hosterTransient: true - }); + const emptyLinkErr = new Error(`Doodstream Upload: kein Filecode — Server gab leeren Link zurück (st=${st || '?'}, fn=${fnInfo}, CDN=${node}). CDN-Antwort: ${(resText || '').slice(0, 200)}`); + emptyLinkErr.hosterTransient = true; + throw emptyLinkErr; } // 4. Fallback: follow form action as-is (for non-XFS forms) const formAction = resText.match(/]*action=['"]([^'"]+)['"]/i); if (formAction) { - _debugLog(`Fallback: following form action ${safeEndpoint(formAction[1]) || 'unknown endpoint'}`); + _debugLog(`Fallback: following form action ${formAction[1]}`); const formData = new URLSearchParams(hiddenFields); - let followText; - try { - const followRes = await this._fetch(formAction[1], { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Referer': BASE_URL + '/' - }, - body: formData.toString() - }); - followText = await followRes.text(); - } catch { - throw createTransportError('Doodstream Upload: Redirect-Antwort konnte nicht gelesen werden', { - phase: 'upload-result-submit', - endpoint: formAction[1], - retryable: true, - transientNetwork: true, - remoteCommitUncertain: true - }); - } - _debugLog(`Fallback response: ${summarizeResponse(followText, '')}`); + const followRes = await this._fetch(formAction[1], { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Referer': BASE_URL + '/' + }, + body: formData.toString() + }); + const followText = await followRes.text(); + _debugLog(`Fallback response (first 500): ${followText.slice(0, 500)}`); const fallbackCode = this._findFilecodeInHtml(followText); if (fallbackCode) return this._buildResult(fallbackCode); @@ -668,23 +563,10 @@ class DoodstreamUploader { // Check if fn was in original hidden fields if (fnCode && fnCode.length >= 8) return this._buildResult(fnCode); - throw createTransportError('Doodstream Upload: Redirect-Antwort ungültig', { - phase: 'upload-result', - endpoint: formAction[1], - body: followText, - hosterTransient: true, - retryable: true - }); + throw new Error(`Doodstream Upload: Redirect-Antwort ungültig (${followText.slice(0, 150)})`); } - throw createTransportError('Doodstream Upload: Keine gültige Antwort', { - phase: 'upload-result', - endpoint: this._lastUploadUrl || BASE_URL, - contentType: /<\s*(?:!doctype|html|body|form|input)\b/i.test(resText) ? 'text/html' : 'text/plain', - body: resText, - hosterTransient: true, - retryable: true - }); + throw new Error(`Doodstream Upload: Keine gültige Antwort (Body: ${resText.slice(0, 150)})`); } /** @@ -708,15 +590,7 @@ class DoodstreamUploader { */ _extractFromJson(payload) { if (payload.status && Number(payload.status) !== 200 && payload.msg) { - throw createTransportError(`Doodstream Upload: ${sanitizeRemoteText(payload.msg) || 'Antwort wurde abgelehnt'}`, { - phase: 'upload-result', - endpoint: this._lastUploadUrl || BASE_URL, - httpStatus: Number(payload.status), - contentType: 'application/json', - body: JSON.stringify(payload), - retryable: Number(payload.status) === 429 || Number(payload.status) >= 500, - transientNetwork: Number(payload.status) >= 500 - }); + throw new Error(`Doodstream Upload: ${payload.msg}`); } let item = null; @@ -728,28 +602,15 @@ class DoodstreamUploader { } if (!item) { - throw createTransportError('Doodstream Upload: Antwort enthielt kein Ergebnis', { - phase: 'upload-result', - endpoint: this._lastUploadUrl || BASE_URL, - contentType: 'application/json', - body: JSON.stringify(payload), - hosterTransient: true, - retryable: true - }); + throw new Error(`Doodstream Upload fehlgeschlagen: ${payload.msg || JSON.stringify(payload).slice(0, 150)}`); } - const fileCode = String(item.filecode || item.file_code || '').trim(); - if (!fileCode) { - throw createTransportError('Doodstream Upload: Antwort enthielt keinen Filecode', { - phase: 'upload-result', - endpoint: this._lastUploadUrl || BASE_URL, - contentType: 'application/json', - body: JSON.stringify(payload), - hosterTransient: true, - retryable: true - }); - } - return this._buildResult(fileCode); + const fileCode = item.filecode || item.file_code || ''; + return { + download_url: item.download_url || item.protected_dl || (fileCode ? `https://doodstream.com/d/${fileCode}` : null), + embed_url: item.protected_embed || (fileCode ? `https://doodstream.com/e/${fileCode}` : null), + file_code: fileCode + }; } _buildResult(fileCode) { @@ -835,7 +696,7 @@ class DoodstreamUploader { return key; } } - _debugLog(`api-key derive: ${candidates.length} candidate(s), none validated. response=${summarizeResponse(html, 'text/html')}`); + _debugLog(`api-key derive: ${candidates.length} candidate(s), none validated. settings html(2500)=${(html || '').slice(0, 2500)}`); return null; } } diff --git a/lib/filename-filter.js b/lib/filename-filter.js deleted file mode 100644 index 765e6c4..0000000 --- a/lib/filename-filter.js +++ /dev/null @@ -1,70 +0,0 @@ -(function initFilenameFilter(root, factory) { - const api = factory(); - if (typeof module === 'object' && module.exports) module.exports = api; - if (root) root.FilenameFilter = api; -})(typeof window !== 'undefined' ? window : globalThis, function createFilenameFilter() { - function normalizeFilenameFilter(value) { - const source = value && typeof value === 'object' ? value : {}; - const conditions = Array.isArray(source.conditions) - ? source.conditions.flatMap(condition => { - if (!condition || typeof condition !== 'object') return []; - const text = String(condition.value ?? '').trim(); - if (!text) return []; - return [{ - operator: condition.operator === 'notContains' ? 'notContains' : 'contains', - value: text - }]; - }) - : []; - return { - enabled: source.enabled === true, - action: source.action === 'exclude' ? 'exclude' : 'include', - matchMode: source.matchMode === 'any' ? 'any' : 'all', - conditions - }; - } - - function getFilename(entry) { - if (entry && typeof entry === 'object' && entry.name) return String(entry.name); - const source = entry && typeof entry === 'object' ? entry.path : entry; - return String(source ?? '').split(/[\\/]/).pop() || ''; - } - - function evaluateFilenameFilter(filename, value) { - const filter = normalizeFilenameFilter(value); - const active = filter.enabled && filter.conditions.length > 0; - if (!active) return { accepted: true, matched: false, active, filter }; - const normalizedName = String(filename ?? '').toLowerCase(); - const results = filter.conditions.map(condition => { - const contains = normalizedName.includes(condition.value.toLowerCase()); - return condition.operator === 'notContains' ? !contains : contains; - }); - const matched = filter.matchMode === 'any' ? results.some(Boolean) : results.every(Boolean); - const accepted = filter.action === 'exclude' ? !matched : matched; - return { accepted, matched, active, filter }; - } - - function applyFilenameFilter(entries, value) { - const filter = normalizeFilenameFilter(value); - const accepted = []; - const excluded = []; - for (const entry of Array.isArray(entries) ? entries : []) { - const evaluation = evaluateFilenameFilter(getFilename(entry), filter); - if (evaluation.accepted) accepted.push(entry); - else excluded.push(entry); - } - return { - total: accepted.length + excluded.length, - accepted, - excluded, - active: filter.enabled && filter.conditions.length > 0, - filter - }; - } - - return { - normalizeFilenameFilter, - evaluateFilenameFilter, - applyFilenameFilter - }; -}); diff --git a/lib/hoster-transport-error.js b/lib/hoster-transport-error.js deleted file mode 100644 index e710a94..0000000 --- a/lib/hoster-transport-error.js +++ /dev/null @@ -1,91 +0,0 @@ -function normalizeContentType(value) { - const contentType = String(value || '').trim().slice(0, 120); - const parts = contentType.split(';').map(part => part.trim()); - if (parts.length < 1 || parts.length > 2) return null; - const slashIndex = parts[0].indexOf('/'); - if (slashIndex <= 0 || slashIndex === parts[0].length - 1) return null; - const tokenCharacters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.+-_'; - const validToken = token => Array.from(token).every(char => tokenCharacters.includes(char)); - if (!validToken(parts[0].slice(0, slashIndex)) || !validToken(parts[0].slice(slashIndex + 1))) return null; - if (parts.length === 2) { - const charsetPrefix = 'charset='; - if (!parts[1].toLowerCase().startsWith(charsetPrefix)) return null; - const charset = parts[1].slice(charsetPrefix.length); - if (!charset || !validToken(charset)) return null; - } - return contentType; -} - -function safeEndpoint(value) { - try { - const url = new URL(String(value || '')); - return `${url.hostname.toLowerCase()}${url.pathname}`; - } catch { - return null; - } -} - -function safeEndpointHost(value) { - try { - return new URL(String(value || '')).hostname.toLowerCase(); - } catch { - return null; - } -} - -function responseKind(body, contentType) { - const text = String(body || '').trim(); - if (!text) return 'empty'; - const type = String(contentType || '').toLowerCase(); - if (type.includes('json') || /^[\[{]/.test(text)) return 'json'; - if (type.includes('html') || /<\s*(?:!doctype|html|body|form|input)\b/i.test(text)) return 'html'; - return 'text'; -} - -function summarizeResponse(body, contentType) { - const text = String(body || ''); - const kind = responseKind(text, contentType); - return `${kind} response (${Buffer.byteLength(text, 'utf8')} bytes)`; -} - -function sanitizeRemoteText(value, limit = 180) { - let text = String(value || '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim(); - text = text.replace(/https?:\/\/[^\s"'<>]+/gi, (raw) => safeEndpoint(raw) || '[URL]'); - text = text.replace(/\b(?:authorization|proxy-authorization|cookie|set-cookie)\s*[:=]\s*[^,]+/gi, '[redacted]'); - text = text.replace(/((?:api[_-]?key|token|password|secret|session|sess[_-]?id|csrf)["']?\s*[:=]\s*["']?)[^\s,;"'<>]+/gi, '$1[redacted]'); - text = text.replace(/(<(?:input|textarea)[^>]*(?:name|id)=["'][^"']*(?:key|token|password|secret|session|sess|csrf)[^"']*["'][^>]*(?:value=["']))[^"']*(["'])/gi, '$1[redacted]$2'); - text = text.replace(/\b[A-Za-z0-9_-]{20,}\b/g, '[redacted]'); - return text.slice(0, limit); -} - -function createTransportError(message, options = {}) { - const httpStatus = Number(options.httpStatus); - const hasHttpStatus = Number.isInteger(httpStatus) && httpStatus >= 100 && httpStatus <= 599; - const contentType = normalizeContentType(options.contentType); - const endpointHost = safeEndpointHost(options.endpoint); - const kind = responseKind(options.body, contentType); - const suffix = hasHttpStatus ? ` (HTTP ${httpStatus})` : ''; - const error = new Error(`${sanitizeRemoteText(message, 220)}${suffix}`); - error.diagnostic = { - phase: String(options.phase || 'transport').slice(0, 80), - http: hasHttpStatus ? httpStatus : null, - contentType, - safeEndpointHost: endpointHost, - responseKind: kind, - retryable: options.retryable === true, - payloadSnippet: summarizeResponse(options.body, contentType) - }; - if (options.transientNetwork === true) error.transientNetwork = true; - if (options.hosterTransient === true) error.hosterTransient = true; - if (options.accountError === true) error.accountError = true; - if (options.fileRejected === true) error.fileRejected = true; - if (options.remoteCommitUncertain === true) error.remoteCommitUncertain = true; - return error; -} - -module.exports = { - createTransportError, - safeEndpoint, - sanitizeRemoteText, - summarizeResponse -}; diff --git a/lib/hosters.js b/lib/hosters.js index 64e826f..2221642 100644 --- a/lib/hosters.js +++ b/lib/hosters.js @@ -2,7 +2,6 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { request } = require('undici'); -const { createTransportError, sanitizeRemoteText } = require('./hoster-transport-error'); const UPLOAD_TIMEOUT = 1800000; // 30 minutes const API_TIMEOUT = 45000; // 45 seconds @@ -173,11 +172,10 @@ function parseDoodstreamResult(payload) { item = result; } - const fileCode = item.filecode || item.file_code || null; return { - download_url: fileCode ? `https://doodstream.com/d/${fileCode}` : null, - embed_url: fileCode ? `https://doodstream.com/e/${fileCode}` : null, - file_code: fileCode + download_url: item.download_url || item.protected_dl || null, + embed_url: item.protected_embed || null, + file_code: item.filecode || item.file_code || null }; } @@ -236,7 +234,7 @@ function parseByseResult(payload) { // wall, so we must rotate. File-specific rejections (Duplicate, wrong // format, too small/large) ARE per-file and rotation is pointless. const accountLevel = /(not enough (disk )?(space|storage)|insufficient (disk )?space|disk (space )?full|storage (exhausted|full|voll|limit)|quota (exceeded|voll|überschritten)|account (full|voll|suspended|banned))/i.test(perFileError); - const err = new Error(`Byse lehnte Datei ab: ${sanitizeRemoteText(perFileError)}`); + const err = new Error(`Byse lehnte Datei ab: ${perFileError}`); if (accountLevel) { err.accountError = true; } else { @@ -310,64 +308,32 @@ function createUploadBody(filePath, formFields, onProgress, throttle, signal) { // --- API helper using built-in fetch (follows redirects automatically) --- -async function apiGet(url, signal, hosterName) { +async function apiGet(url, signal) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), API_TIMEOUT); const onAbort = () => controller.abort(); if (signal) signal.addEventListener('abort', onAbort); try { - let res; - try { - res = await fetch(url, { - method: 'GET', - signal: controller.signal, - redirect: 'follow' - }); - } catch (err) { - if (signal && signal.aborted) throw err; - throw createTransportError(`${hosterName}: Upload-Server-Abfrage fehlgeschlagen`, { - phase: 'upload-server', - endpoint: url, - retryable: true, - transientNetwork: true - }); - } + const res = await fetch(url, { + method: 'GET', + signal: controller.signal, + redirect: 'follow' + }); const text = await res.text(); - const contentType = res.headers && typeof res.headers.get === 'function' - ? res.headers.get('content-type') - : null; let data; try { data = JSON.parse(text); } catch { - throw createTransportError(`${hosterName}: Upload-Server-Antwort war kein JSON`, { - phase: 'upload-server', - endpoint: url, - httpStatus: res.status, - contentType, - body: text, - retryable: res.status >= 500, - transientNetwork: res.status >= 500 - }); + const err = new Error(`API-Antwort war kein JSON (HTTP ${res.status}): ${(text || '').slice(0, 200)}`); + if (res.status >= 500) err.transientNetwork = true; + throw err; } - const apiStatus = Number(data && data.status); - const effectiveStatus = res.status < 200 || res.status >= 300 - ? res.status - : (apiStatus >= 400 ? apiStatus : null); - if (effectiveStatus) { - const retryable = effectiveStatus === 429 || effectiveStatus >= 500; - throw createTransportError(`${hosterName}: Upload-Server-Abfrage wurde abgelehnt`, { - phase: 'upload-server', - endpoint: url, - httpStatus: effectiveStatus, - contentType, - body: text, - retryable, - transientNetwork: effectiveStatus >= 500, - accountError: effectiveStatus === 401 || effectiveStatus === 403 - }); + if (data.status && [401, 403, 429, 500].includes(data.status)) { + const err = new Error(data.msg || data.message || JSON.stringify(data)); + if (data.status === 500) err.transientNetwork = true; + throw err; } return data; } finally { @@ -381,14 +347,12 @@ async function apiGet(url, signal, hosterName) { async function getUploadServer(hosterName, hosterConfig, apiKey, signal) { let lastMessage = ''; let lastTransient = false; - let lastError = null; for (let attempt = 1; attempt <= SERVER_RETRY_ATTEMPTS; attempt++) { for (const endpoint of hosterConfig.serverEndpoints) { const url = `${hosterConfig.apiBase}${endpoint}?key=${encodeURIComponent(apiKey)}`; try { - const data = await apiGet(url, signal, hosterName); - lastError = null; + const data = await apiGet(url, signal); const uploadUrl = extractUploadServerUrl(data, hosterConfig.apiBase); if (uploadUrl) { LAST_UPLOAD_SERVERS.set(hosterName, uploadUrl); @@ -401,16 +365,12 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) { if (apiMessage) lastMessage = apiMessage; } catch (err) { if (err.name === 'AbortError') throw err; - lastError = err; if (err.message) lastMessage = err.message; if (err.transientNetwork === true) lastTransient = true; } } - const retryable = lastError && lastError.diagnostic - ? lastError.diagnostic.retryable === true - : shouldRetryServerLookup(lastMessage); - if (attempt < SERVER_RETRY_ATTEMPTS && retryable) { + if (attempt < SERVER_RETRY_ATTEMPTS && shouldRetryServerLookup(lastMessage)) { await sleep(SERVER_RETRY_DELAY_MS, signal); continue; } @@ -419,14 +379,11 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) { } const cachedServer = LAST_UPLOAD_SERVERS.get(hosterName); - const retryable = lastError && lastError.diagnostic - ? lastError.diagnostic.retryable === true - : shouldRetryServerLookup(lastMessage); - if (cachedServer && retryable) { + if (cachedServer && shouldRetryServerLookup(lastMessage)) { return cachedServer; } - if (retryable && Array.isArray(hosterConfig.fallbackUploadServers)) { + if (shouldRetryServerLookup(lastMessage) && Array.isArray(hosterConfig.fallbackUploadServers)) { for (const fallback of hosterConfig.fallbackUploadServers) { const normalized = normalizeAbsoluteUrl(fallback, hosterConfig.apiBase); if (normalized) { @@ -437,291 +394,50 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) { } if (lastMessage) { - const e = lastError || createTransportError(`Kein Upload-Server für ${hosterName} erhalten`, { - phase: 'upload-server', - endpoint: hosterConfig.apiBase, - retryable - }); - if (retryable) e.hosterTransient = true; + const e = new Error(`Kein Upload-Server erhalten: ${lastMessage}`); + // "no servers available" / busy / try-again is a transient hoster-side + // condition, not an account fault — tag it so the account isn't blacklisted. + // Genuine auth failures (invalid key / unauthorized / forbidden) make + // shouldRetryServerLookup return false and stay classified as account errors. + if (shouldRetryServerLookup(lastMessage)) e.hosterTransient = true; if (lastTransient) e.transientNetwork = true; throw e; } - throw createTransportError(`Kein Upload-Server für ${hosterName} erhalten`, { - phase: 'upload-server', - endpoint: hosterConfig.apiBase - }); + throw new Error('Kein Upload-Server erhalten. API-Key prüfen.'); } -async function _requestFileList(url, signal, phase, hosterName) { - let response; - try { - response = await request(url, { - method: 'GET', signal, - headers: { 'Accept': 'application/json', 'User-Agent': 'multi-hoster-uploader/1.1' }, - headersTimeout: 30_000, bodyTimeout: 30_000 - }); - } catch (err) { - if (signal && signal.aborted) throw err; - throw createTransportError(`${hosterName}: Dateiliste konnte nicht geladen werden`, { - phase, - endpoint: url, - retryable: true, - transientNetwork: true - }); - } - - const contentType = response.headers && response.headers['content-type']; - let text; - try { - text = await response.body.text(); - } catch (err) { - if (signal && signal.aborted) throw err; - throw createTransportError(`${hosterName}: Dateiliste konnte nicht gelesen werden`, { - phase, - endpoint: url, - httpStatus: response.statusCode, - contentType, - retryable: true, - transientNetwork: true - }); - } - - if (response.statusCode < 200 || response.statusCode >= 300) { - const retryable = response.statusCode === 429 || response.statusCode >= 500; - throw createTransportError(`${hosterName}: Dateiliste konnte nicht geladen werden`, { - phase, - endpoint: url, - httpStatus: response.statusCode, - contentType, - body: text, - retryable, - transientNetwork: response.statusCode >= 500 - }); - } - - let data; - try { - data = JSON.parse(text); - } catch { - throw createTransportError(`${hosterName}: Dateiliste war kein JSON`, { - phase, - endpoint: url, - httpStatus: response.statusCode, - contentType, - body: text - }); - } - - if (!data || typeof data !== 'object') { - throw createTransportError(`${hosterName}: Dateiliste hatte ein ungültiges Format`, { - phase, - endpoint: url, - httpStatus: response.statusCode, - contentType, - body: text - }); - } - - const apiStatus = Number(data && data.status); - const statusText = typeof data.status === 'string' ? data.status.trim().toLowerCase() : ''; - const semanticFailure = data.success === false - || data.ok === false - || data.status === false - || /^(?:error|failed|failure|denied|invalid|rejected)$/.test(statusText); - if (apiStatus >= 400 || semanticFailure) { - const retryable = apiStatus === 429 || apiStatus >= 500; - throw createTransportError(`${hosterName}: Dateiliste wurde abgelehnt`, { - phase, - endpoint: url, - httpStatus: apiStatus >= 100 ? apiStatus : response.statusCode, - contentType, - body: text, - retryable, - transientNetwork: apiStatus >= 500 - }); - } - - return data; -} - -function _requireFileList(data, candidates, phase, hosterName, url) { - for (const candidate of candidates) { - if (Array.isArray(candidate)) return candidate; - } - throw createTransportError(`${hosterName}: Dateiliste hatte ein ungültiges Format`, { - phase, - endpoint: url, - httpStatus: 200, - contentType: 'application/json' - }); -} - -async function _fetchByseFileList(apiKey, signal, phase = 'recovery-poll') { +async function _fetchByseFileList(apiKey, signal) { // Byse's file-list endpoint. Returns up to 100 most-recent files — enough // to match the upload we just did against what the server has. The API // shape is typical XFS: { status, msg, result: { files: [...] } } or // { status, msg, files: [...] }. const url = `https://api.byse.sx/file/list?key=${encodeURIComponent(apiKey)}&per_page=100&sort=date&order=desc`; - const data = await _requestFileList(url, signal, phase, 'Byse'); - const src = _requireFileList(data, [ - data.files, - data.result && data.result.files, - data.result - ], phase, 'Byse', url); - return src.map(f => ({ - file_code: String(f.file_code || f.filecode || '').trim(), - file_name: String(f.title || f.name || f.file_name || '').trim() - })).filter(f => f.file_code); + try { + const { body, statusCode } = await request(url, { + method: 'GET', signal, + headers: { 'Accept': 'application/json', 'User-Agent': 'multi-hoster-uploader/1.1' }, + headersTimeout: 30_000, bodyTimeout: 30_000 + }); + const text = await body.text(); + if (statusCode < 200 || statusCode >= 300) return []; + const data = JSON.parse(text); + const src = Array.isArray(data.files) ? data.files + : (data.result && Array.isArray(data.result.files) ? data.result.files + : (Array.isArray(data.result) ? data.result : [])); + return src.map(f => ({ + file_code: String(f.file_code || f.filecode || '').trim(), + file_name: String(f.title || f.name || f.file_name || '').trim() + })).filter(f => f.file_code); + } catch { + return []; + } } function _normalizeFileTitle(s) { - const normalized = String(s || '') - .normalize('NFKD') - .toLowerCase() - .replace(/\.[\p{Letter}\p{Number}]+$/u, '') - .replace(/\p{Variation_Selector}+/gu, ''); - const alphanumeric = normalized - .replace(/\p{Mark}+/gu, '') - .replace(/[^\p{Letter}\p{Number}]+/gu, ''); - if (alphanumeric) return alphanumeric; - const codePoints = Array.from(normalized, value => value.codePointAt(0).toString(16)).join('-'); - return `symbols:${codePoints}`; + return String(s || '').toLowerCase().replace(/\.[a-z0-9]+$/i, '').replace(/[^a-z0-9]+/g, ''); } -function _normalizeRecoveryHoster(value) { - return String(value || '').normalize('NFKC').trim().toLowerCase(); -} - -function _normalizeRecoveryAccount(value) { - return String(value || '').normalize('NFKC').trim(); -} - -function _createRecoveryUncertainError() { - const error = new Error('Upload-Ergebnis für diesen Titel ist wegen eines möglichen Remote-Commits unsicher'); - error.remoteCommitUncertain = true; - error.hosterTransient = true; - return error; -} - -function _markRecoveryUncertain(recoveryClaim, error) { - if (!recoveryClaim) return error; - if (typeof recoveryClaim.markUncertain === 'function') { - return recoveryClaim.markUncertain(error); - } - const uncertainError = error && typeof error === 'object' - ? error - : _createRecoveryUncertainError(); - uncertainError.remoteCommitUncertain = true; - uncertainError.hosterTransient = true; - return uncertainError; -} - -function _createAbortError() { - const error = new Error('Operation aborted'); - error.name = 'AbortError'; - return error; -} - -function _waitForRecoveryTurn(predecessor, signal) { - if (!signal) return predecessor; - if (signal.aborted) return Promise.reject(_createAbortError()); - return new Promise((resolve, reject) => { - const onAbort = () => { - signal.removeEventListener('abort', onAbort); - reject(_createAbortError()); - }; - signal.addEventListener('abort', onAbort, { once: true }); - predecessor.then( - () => { - signal.removeEventListener('abort', onAbort); - resolve(); - }, - error => { - signal.removeEventListener('abort', onAbort); - reject(error); - } - ); - }); -} - -function createRecoveryClaimRegistry() { - const accounts = new Map(); - let nextClaimId = 1; - return { - forUpload(hosterName, apiKey, fileName) { - const accountIdentity = crypto.createHash('sha256') - .update(`${_normalizeRecoveryHoster(hosterName)}\0${_normalizeRecoveryAccount(apiKey)}`) - .digest('hex'); - let account = accounts.get(accountIdentity); - if (!account) { - account = { - codes: new Map(), - titles: new Map() - }; - accounts.set(accountIdentity, account); - } - const titleIdentity = _normalizeFileTitle(fileName); - let title = account.titles.get(titleIdentity); - if (!title) { - title = { - tail: Promise.resolve(), - uncertain: false - }; - account.titles.set(titleIdentity, title); - } - const claimId = nextClaimId++; - return { - has(code) { - return account.codes.has(String(code || '').trim()); - }, - reserve(code) { - const normalized = String(code || '').trim(); - if (!normalized) return false; - if (account.codes.has(normalized)) { - return account.codes.get(normalized) === claimId; - } - account.codes.set(normalized, claimId); - return true; - }, - markUncertain(error) { - title.uncertain = true; - const uncertainError = error && typeof error === 'object' - ? error - : _createRecoveryUncertainError(); - uncertainError.remoteCommitUncertain = true; - uncertainError.hosterTransient = true; - return uncertainError; - }, - isUncertain() { - return title.uncertain; - }, - async runExclusive(operation, signal) { - const predecessor = title.tail; - let release; - const current = new Promise(resolve => { - release = resolve; - }); - title.tail = predecessor.then(() => current, () => current); - try { - await _waitForRecoveryTurn(predecessor, signal); - if (title.uncertain) throw _createRecoveryUncertainError(); - const result = await operation(); - if (title.uncertain) throw _createRecoveryUncertainError(); - return result; - } finally { - release(); - } - } - }; - }, - clear() { - accounts.clear(); - } - }; -} - -async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal, recoveryClaim) { - if (!(baselineCodes instanceof Set)) return null; +async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal) { const expected = _normalizeFileTitle(fileName); const POLL_ATTEMPTS = 15; const POLL_DELAY_MS = 2000; @@ -734,13 +450,8 @@ async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal, // poller could claim job B's newly appeared file and return the wrong // URL. At the cost of a few false-negatives when byse mangles the // filename beyond our normalizer, correctness for parallel uploads wins. - const matches = newFiles - .filter(f => _normalizeFileTitle(f.file_name) === expected) - .filter(f => !recoveryClaim || typeof recoveryClaim.has !== 'function' || !recoveryClaim.has(f.file_code)); - if (matches.length > 1) return null; - if (matches.length === 1) { - const match = matches[0]; - if (recoveryClaim && typeof recoveryClaim.reserve === 'function' && !recoveryClaim.reserve(match.file_code)) return null; + const match = newFiles.find(f => _normalizeFileTitle(f.file_name) === expected); + if (match) { return { download_url: `https://byse.sx/d/${match.file_code}`, embed_url: `https://byse.sx/e/${match.file_code}`, @@ -758,24 +469,34 @@ async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal, return null; } -async function _fetchDoodstreamFileList(apiKey, signal, phase = 'recovery-poll') { +async function _fetchDoodstreamFileList(apiKey, signal) { // doodapi.co file list: { msg, status:200, result: { files: [{ file_code, title, uploaded, ... }] } } // sort=created&order=desc forces newest-first — VERIFIED against a real 90k-file // account, where a single page without it could miss a just-uploaded file. The // recovery only needs the most recent uploads, so page 1 newest-first suffices. const url = `https://doodapi.co/api/file/list?key=${encodeURIComponent(apiKey)}&per_page=200&sort=created&order=desc`; - const data = await _requestFileList(url, signal, phase, 'Doodstream'); - const files = _requireFileList(data, [data && data.result && data.result.files], phase, 'Doodstream', url); - return files.map(f => ({ - file_code: String(f.file_code || f.filecode || '').trim(), - file_name: String(f.title || f.file_name || f.name || '').trim() - })).filter(f => f.file_code); + try { + const { body, statusCode } = await request(url, { + method: 'GET', signal, + headers: { 'Accept': 'application/json', 'User-Agent': 'multi-hoster-uploader/1.1' }, + headersTimeout: 30_000, bodyTimeout: 30_000 + }); + const text = await body.text(); + if (statusCode < 200 || statusCode >= 300) return []; + const data = JSON.parse(text); + const files = data && data.result && Array.isArray(data.result.files) ? data.result.files : []; + return files.map(f => ({ + file_code: String(f.file_code || f.filecode || '').trim(), + file_name: String(f.title || f.file_name || f.name || '').trim() + })).filter(f => f.file_code); + } catch { + return []; + } } const DOODSTREAM_POLL = { attempts: 12, delayMs: 2500 }; // test-tunable via __test -async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, signal, recoveryClaim) { - if (!(baselineCodes instanceof Set)) return null; +async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, signal) { // Same recovery byse uses: the upload POST returned no filecode, but the file // may register in the account a little later. Poll the list for a NEW file // whose normalized title matches what we uploaded. Exact-name match only @@ -788,13 +509,8 @@ async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, s if (signal && signal.aborted) return null; const list = await _fetchDoodstreamFileList(apiKey, signal); const fresh = list.filter(f => !baselineCodes.has(f.file_code)); - const matches = fresh - .filter(f => _normalizeFileTitle(f.file_name) === expected) - .filter(f => !recoveryClaim || typeof recoveryClaim.has !== 'function' || !recoveryClaim.has(f.file_code)); - if (matches.length > 1) return null; - if (matches.length === 1) { - const match = matches[0]; - if (recoveryClaim && typeof recoveryClaim.reserve === 'function' && !recoveryClaim.reserve(match.file_code)) return null; + const match = fresh.find(f => _normalizeFileTitle(f.file_name) === expected); + if (match) { return { download_url: `https://doodstream.com/d/${match.file_code}`, embed_url: `https://doodstream.com/e/${match.file_code}`, @@ -817,33 +533,21 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro if (!config) throw new Error(`Unbekannter Hoster: ${hosterName}`); let byseBaseline = null; - let byseBaselineError = null; if (hosterName === 'byse.sx') { if (opts && opts.byseBaseline instanceof Set) { byseBaseline = opts.byseBaseline; } else { - try { - const baseline = await _fetchByseFileList(apiKey, signal, 'recovery-baseline'); - byseBaseline = new Set(baseline.map(f => f.file_code)); - } catch (err) { - if (signal && signal.aborted) throw err; - byseBaselineError = err; - } + const baseline = await _fetchByseFileList(apiKey, signal); + byseBaseline = new Set(baseline.map(f => f.file_code)); } } let doodBaseline = null; - let doodBaselineError = null; if (hosterName === 'doodstream.com') { if (opts && opts.doodBaseline instanceof Set) { doodBaseline = opts.doodBaseline; } else { - try { - const baseline = await _fetchDoodstreamFileList(apiKey, signal, 'recovery-baseline'); - doodBaseline = new Set(baseline.map(f => f.file_code)); - } catch (err) { - if (signal && signal.aborted) throw err; - doodBaselineError = err; - } + const baseline = await _fetchDoodstreamFileList(apiKey, signal); + doodBaseline = new Set(baseline.map(f => f.file_code)); } } @@ -856,52 +560,31 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro const { iterable, boundary, totalSize } = createUploadBody(filePath, formFields, onProgress, throttle, signal); - let uploadResponse; - try { - uploadResponse = await request(targetUrl, { - method: 'POST', - body: iterable, - signal, - headers: { - 'Content-Type': `multipart/form-data; boundary=${boundary}`, - 'Content-Length': String(totalSize), - 'Accept': 'application/json, text/plain;q=0.9, */*;q=0.8', - 'User-Agent': 'multi-hoster-uploader/1.1' - }, - headersTimeout: UPLOAD_TIMEOUT, - bodyTimeout: UPLOAD_TIMEOUT - }); - } catch (err) { - const error = signal && signal.aborted ? err : createTransportError(`Upload zu ${hosterName} konnte nicht übertragen werden`, { - phase: 'upload-request', - endpoint: targetUrl, - retryable: true, - transientNetwork: true - }); - throw _markRecoveryUncertain(opts && opts.recoveryClaim, error); - } + const { body, statusCode, headers } = await request(targetUrl, { + method: 'POST', + body: iterable, + signal, + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': String(totalSize), + 'Accept': 'application/json, text/plain;q=0.9, */*;q=0.8', + 'User-Agent': 'multi-hoster-uploader/1.1' + }, + headersTimeout: UPLOAD_TIMEOUT, + bodyTimeout: UPLOAD_TIMEOUT + }); - const { body, statusCode, headers } = uploadResponse; - - let rawBody; - try { - rawBody = await body.text(); - } catch (err) { - throw _markRecoveryUncertain(opts && opts.recoveryClaim, err); - } + const rawBody = await body.text(); let payload = null; try { payload = rawBody ? JSON.parse(rawBody) : {}; } catch { - throw _markRecoveryUncertain(opts && opts.recoveryClaim, createTransportError(`Upload-Antwort von ${hosterName} war kein JSON`, { - phase: 'upload-response', - endpoint: targetUrl, - httpStatus: statusCode, - contentType: headers && headers['content-type'], - body: rawBody, - retryable: statusCode >= 500, - transientNetwork: statusCode >= 500 - })); + const snippet = rawBody ? rawBody.slice(0, 240).replace(/\s+/g, ' ').trim() : ''; + const err = new Error( + `Upload-Antwort von ${hosterName} war kein JSON (HTTP ${statusCode}${snippet ? `): ${snippet}` : ')'}` + ); + if (statusCode >= 500) err.transientNetwork = true; + throw err; } // Normalize valid-but-not-object JSON (JSON.parse('null') → null; // JSON.parse('"foo"') → string; JSON.parse('[1]') → array). Without this @@ -915,33 +598,19 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro } if (statusCode < 200 || statusCode >= 300) { - const error = createTransportError(`Upload zu ${hosterName} fehlgeschlagen`, { - phase: 'upload-response', - endpoint: targetUrl, - httpStatus: statusCode, - contentType: headers && headers['content-type'], - body: rawBody, - retryable: statusCode === 429 || statusCode >= 500, - transientNetwork: statusCode >= 500 - }); - throw statusCode >= 500 - ? _markRecoveryUncertain(opts && opts.recoveryClaim, error) - : error; + const err = new Error( + payload.msg + || payload.message + || `Upload fehlgeschlagen (HTTP ${statusCode}${headers?.['content-type'] ? `, ${headers['content-type']}` : ''})` + ); + if (statusCode >= 500) err.transientNetwork = true; + throw err; } if (payload.status && [401, 403, 429, 500].includes(payload.status)) { - const error = createTransportError(`Upload zu ${hosterName} wurde abgelehnt`, { - phase: 'upload-response', - endpoint: targetUrl, - httpStatus: Number(payload.status), - contentType: headers && headers['content-type'], - body: rawBody, - retryable: Number(payload.status) === 429 || Number(payload.status) >= 500, - transientNetwork: Number(payload.status) >= 500 - }); - throw Number(payload.status) >= 500 - ? _markRecoveryUncertain(opts && opts.recoveryClaim, error) - : error; + const err = new Error(payload.msg || payload.message || JSON.stringify(payload)); + if (payload.status === 500) err.transientNetwork = true; + throw err; } let result = null; @@ -950,32 +619,19 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro result = config.parseResult(payload); } catch (err) { if (err && typeof err === 'object' && !err.diagnostic) { - err.diagnostic = createTransportError(`Upload zu ${hosterName} konnte nicht ausgewertet werden`, { - phase: 'upload-result', - endpoint: targetUrl, - httpStatus: statusCode, - contentType: headers && headers['content-type'], - body: rawBody - }).diagnostic; + try { + err.diagnostic = { + hoster: hosterName, + http: statusCode, + contentType: (headers && headers['content-type']) || null, + payloadSnippet: JSON.stringify(payload).slice(0, 1000), + uploadUrl: targetUrl + }; + } catch { /* JSON cycle — skip diagnostic */ } } parseErr = err; } if (result && (result.file_code || result.download_url || result.embed_url)) { - if (result.file_code && opts && opts.recoveryClaim && typeof opts.recoveryClaim.reserve === 'function') { - if (!opts.recoveryClaim.reserve(result.file_code)) { - const error = createTransportError(`Upload zu ${hosterName} lieferte eine bereits zugeordnete file_code-Antwort`, { - phase: 'upload-result', - endpoint: targetUrl, - httpStatus: statusCode, - contentType: headers && headers['content-type'], - body: rawBody, - retryable: true, - hosterTransient: true - }); - error.remoteIdentityClaimed = true; - throw _markRecoveryUncertain(opts.recoveryClaim, error); - } - } return result; } @@ -1001,12 +657,8 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro // even after our uploader gave up. if (hosterName === 'byse.sx' && byseBaseline && !explicitlyRejected) { const fileName = path.basename(filePath); - try { - const polled = await _resolveByseUploadByName(apiKey, fileName, byseBaseline, signal, opts && opts.recoveryClaim); - if (polled) return polled; - } catch (err) { - throw _markRecoveryUncertain(opts && opts.recoveryClaim, err); - } + const polled = await _resolveByseUploadByName(apiKey, fileName, byseBaseline, signal); + if (polled) return polled; } // Doodstream: the doodapi upload POST returned no filecode (the same backend @@ -1014,47 +666,24 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro // the file did register, claim its code instead of failing the upload. if (hosterName === 'doodstream.com' && doodBaseline && !explicitlyRejected) { const fileName = path.basename(filePath); - try { - const polled = await _resolveDoodstreamUploadByName(apiKey, fileName, doodBaseline, signal, opts && opts.recoveryClaim); - if (polled) return polled; - } catch (err) { - throw _markRecoveryUncertain(opts && opts.recoveryClaim, err); - } + const polled = await _resolveDoodstreamUploadByName(apiKey, fileName, doodBaseline, signal); + if (polled) return polled; } - if (hosterName === 'byse.sx' && byseBaselineError && !explicitlyRejected) { - byseBaselineError.hosterTransient = true; - throw _markRecoveryUncertain(opts && opts.recoveryClaim, byseBaselineError); - } - - if (hosterName === 'doodstream.com' && doodBaselineError && !explicitlyRejected) { - doodBaselineError.hosterTransient = true; - throw _markRecoveryUncertain(opts && opts.recoveryClaim, doodBaselineError); - } - - if (parseErr) { - throw explicitlyRejected - ? parseErr - : _markRecoveryUncertain(opts && opts.recoveryClaim, parseErr); - } + if (parseErr) throw parseErr; if (payload.success === false) { - throw createTransportError(`Upload zu ${hosterName} wurde vom Server abgelehnt`, { - phase: 'upload-result', - endpoint: targetUrl, - httpStatus: statusCode, - contentType: headers && headers['content-type'], - body: rawBody - }); + throw new Error(payload.msg || payload.message || `Upload zu ${hosterName} wurde vom Server abgelehnt.`); } // Avoid throwing a bare "OK" / "SUCCESS" as the error message — that happens // when the server says "msg: OK" but ships no file_code anywhere we know - // about, typically an API change. Surface safe structured response metadata - // so future logs show what kind of response the server returned. + // about, typically an API change. Surface the full (trimmed) payload so + // future logs actually show what the server returned. const msg = String(payload.msg || payload.message || '').trim(); const isOkishNoPayload = /^(ok|success|done|accepted)$/i.test(msg); if (isOkishNoPayload || !msg) { + const snippet = JSON.stringify(payload).slice(0, 400); // 2xx with no filecode: the hoster accepted the upload (bytes sent, status // OK) but returned no usable link. For doodstream this is the API-path // analog of the web empty-form — the backend file-registration timing out @@ -1062,33 +691,23 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro // so tag it hosterTransient: the upload-manager then fails this file WITHOUT // blacklisting the account (same protection the web path got in 3.3.29) and // the account stays usable for the next retry/batch. - throw _markRecoveryUncertain(opts && opts.recoveryClaim, createTransportError(`Upload zu ${hosterName} lieferte keine file_code-Antwort`, { - phase: 'upload-result', - endpoint: targetUrl, - httpStatus: statusCode, - contentType: headers && headers['content-type'], - body: rawBody, - retryable: true, - hosterTransient: true - })); + const err = new Error( + `Upload zu ${hosterName} lieferte keine file_code-Antwort (Payload: ${snippet})` + ); + err.hosterTransient = true; + throw err; } - throw createTransportError(`Upload zu ${hosterName} wurde abgelehnt`, { - phase: 'upload-result', - endpoint: targetUrl, - httpStatus: statusCode, - contentType: headers && headers['content-type'], - body: rawBody - }); + throw new Error(msg); } async function prefetchBaseline(hosterName, apiKey, signal) { try { if (hosterName === 'byse.sx') { - const baseline = await _fetchByseFileList(apiKey, signal, 'recovery-baseline'); + const baseline = await _fetchByseFileList(apiKey, signal); return new Set(baseline.map(f => f.file_code)); } if (hosterName === 'doodstream.com') { - const baseline = await _fetchDoodstreamFileList(apiKey, signal, 'recovery-baseline'); + const baseline = await _fetchDoodstreamFileList(apiKey, signal); return new Set(baseline.map(f => f.file_code)); } } catch { /* leave caller to fall back to per-job fetch */ } @@ -1098,8 +717,6 @@ async function prefetchBaseline(hosterName, apiKey, signal) { module.exports = { uploadFile, prefetchBaseline, - createRecoveryClaimRegistry, - normalizeRecoveryTitle: _normalizeFileTitle, HOSTER_CONFIGS, __test: { extractUploadServerUrl, diff --git a/lib/import-preflight.js b/lib/import-preflight.js index 3efe501..e29eac7 100644 --- a/lib/import-preflight.js +++ b/lib/import-preflight.js @@ -1,185 +1,176 @@ (function initImportPreflight(root, factory) { const api = typeof module === 'object' && module.exports - ? factory(require('path'), require('./filename-filter')) + ? factory(require('path')) : factory({ normalize: value => String(value).replace(/[\\/]+/g, '/'), basename: value => String(value).split(/[\\/]/).pop() || '' - }, root.FilenameFilter); + }); if (typeof module === 'object' && module.exports) module.exports = api; if (root) root.ImportPreflight = api; -})(typeof window !== 'undefined' ? window : globalThis, function createImportPreflight(path, filenameFilter) { -const { applyFilenameFilter } = filenameFilter; - -function normalizePathValue(value) { - let text = String(value ?? '').trim(); - if (!text) return ''; - const uncNamespace = text.match(/^[\\/]{2}\?[\\/]UNC[\\/]/i); - if (uncNamespace) text = `\\\\${text.slice(uncNamespace[0].length)}`; - else { - const driveNamespace = text.match(/^[\\/]{2}\?[\\/](?=[A-Za-z]:[\\/])/); - if (driveNamespace) text = text.slice(driveNamespace[0].length); - } - return path.normalize(text); -} - -function normalizeEntry(value) { - const source = value && typeof value === 'object' ? value : {}; - const filePath = normalizePathValue(typeof value === 'string' ? value : source.path); - const sourceName = typeof value === 'string' ? '' : String(source.name ?? '').trim(); - return { - path: filePath, - name: sourceName || path.basename(filePath), - size: Number.isFinite(Number(source.size)) ? Number(source.size) : null - }; -} - -function createPathKey(value, caseInsensitive) { - const normalized = normalizePathValue(value); - return caseInsensitive ? normalized.toLocaleLowerCase('en-US') : normalized; -} - -async function mapWithConcurrency(items, concurrency, operation) { - const results = new Array(items.length); - let cursor = 0; - async function worker() { - while (cursor < items.length) { - const index = cursor++; - results[index] = await operation(items[index], index); +})(typeof window !== 'undefined' ? window : globalThis, function createImportPreflight(path) { + function normalizePathValue(value) { + let text = String(value ?? '').trim(); + if (!text) return ''; + const uncNamespace = text.match(/^[\\/]{2}\?[\\/]UNC[\\/]/i); + if (uncNamespace) text = `\\\\${text.slice(uncNamespace[0].length)}`; + else { + const driveNamespace = text.match(/^[\\/]{2}\?[\\/](?=[A-Za-z]:[\\/])/); + if (driveNamespace) text = text.slice(driveNamespace[0].length); } - } - await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker)); - return results; -} - -function unavailableReason(result) { - if (!result || result.exists === false) return 'missing'; - if (result.readable === false) return 'unreadable'; - const size = Number(result.size); - if (!Number.isFinite(size) || size <= 0) return 'empty'; - return ''; -} - -async function inspectReadableImportPath(filePath, openPath) { - let fileHandle = null; - try { - fileHandle = await openPath(filePath, 'r'); - const fileStat = await fileHandle.stat(); - if (!fileStat.isFile()) return { exists: true, readable: false, size: fileStat.size }; - return { exists: true, readable: true, size: fileStat.size }; - } catch (error) { - if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return { exists: false }; - return { exists: true, readable: false }; - } finally { - if (fileHandle) { - try { - await fileHandle.close(); - } catch {} - } - } -} - -async function inspectImportEntries(entries, options = {}) { - const input = Array.isArray(entries) ? entries : []; - const caseInsensitive = options.caseInsensitive ?? (typeof process === 'object' ? process.platform === 'win32' : true); - const existing = new Set((Array.isArray(options.existingPaths) ? options.existingPaths : []) - .map(value => createPathKey(value && typeof value === 'object' ? value.path : value, caseInsensitive)) - .filter(Boolean)); - const duplicates = []; - const unavailable = []; - const unique = []; - - for (const value of input) { - const entry = normalizeEntry(value); - if (!entry.path) { - unavailable.push({ ...entry, reason: 'missing' }); - continue; - } - const key = createPathKey(entry.path, caseInsensitive); - if (existing.has(key)) { - duplicates.push(entry); - continue; - } - existing.add(key); - unique.push(entry); + return path.normalize(text); } - const filtered = applyFilenameFilter(unique, options.filenameFilter); - const concurrency = Math.max(1, Math.min(32, Math.trunc(Number(options.concurrency)) || 8)); - const inspectPath = typeof options.inspectPath === 'function' - ? options.inspectPath - : async (_entryPath, entry) => ({ exists: true, readable: true, size: entry.size }); - const inspected = await mapWithConcurrency(filtered.accepted, concurrency, async entry => { + function normalizeEntry(value) { + const source = value && typeof value === 'object' ? value : {}; + const filePath = normalizePathValue(typeof value === 'string' ? value : source.path); + const sourceName = typeof value === 'string' ? '' : String(source.name ?? '').trim(); + return { + path: filePath, + name: sourceName || path.basename(filePath), + size: Number.isFinite(Number(source.size)) ? Number(source.size) : null + }; + } + + function createPathKey(value, caseInsensitive) { + const normalized = normalizePathValue(value); + return caseInsensitive ? normalized.toLocaleLowerCase('en-US') : normalized; + } + + async function mapWithConcurrency(items, concurrency, operation) { + const results = new Array(items.length); + let cursor = 0; + async function worker() { + while (cursor < items.length) { + const index = cursor++; + results[index] = await operation(items[index], index); + } + } + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker)); + return results; + } + + function unavailableReason(result) { + if (!result || result.exists === false) return 'missing'; + if (result.readable === false) return 'unreadable'; + const size = Number(result.size); + if (!Number.isFinite(size) || size <= 0) return 'empty'; + return ''; + } + + async function inspectReadableImportPath(filePath, openPath) { + let fileHandle = null; try { - const result = await inspectPath(entry.path, entry); - const reason = unavailableReason(result); - if (reason) return { entry: { ...entry, size: Number(result?.size) || 0 }, reason }; - return { entry: { ...entry, size: Number(result.size) }, reason: '' }; + fileHandle = await openPath(filePath, 'r'); + const fileStat = await fileHandle.stat(); + if (!fileStat.isFile()) return { exists: true, readable: false, size: fileStat.size }; + return { exists: true, readable: true, size: fileStat.size }; } catch (error) { - return { entry, reason: error && error.code === 'ENOENT' ? 'missing' : 'unreadable' }; + if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return { exists: false }; + return { exists: true, readable: false }; + } finally { + if (fileHandle) { + try { + await fileHandle.close(); + } catch {} + } } - }); - const accepted = []; - for (const result of inspected) { - if (result.reason) unavailable.push({ ...result.entry, reason: result.reason }); - else accepted.push(result.entry); + } + + async function inspectImportEntries(entries, options = {}) { + const input = Array.isArray(entries) ? entries : []; + const caseInsensitive = options.caseInsensitive ?? (typeof process === 'object' ? process.platform === 'win32' : true); + const existing = new Set((Array.isArray(options.existingPaths) ? options.existingPaths : []) + .map(value => createPathKey(value && typeof value === 'object' ? value.path : value, caseInsensitive)) + .filter(Boolean)); + const duplicates = []; + const unavailable = []; + const unique = []; + + for (const value of input) { + const entry = normalizeEntry(value); + if (!entry.path) { + unavailable.push({ ...entry, reason: 'missing' }); + continue; + } + const key = createPathKey(entry.path, caseInsensitive); + if (existing.has(key)) { + duplicates.push(entry); + continue; + } + existing.add(key); + unique.push(entry); + } + + const concurrency = Math.max(1, Math.min(32, Math.trunc(Number(options.concurrency)) || 8)); + const inspectPath = typeof options.inspectPath === 'function' + ? options.inspectPath + : async (_entryPath, entry) => ({ exists: true, readable: true, size: entry.size }); + const inspected = await mapWithConcurrency(unique, concurrency, async entry => { + try { + const result = await inspectPath(entry.path, entry); + const reason = unavailableReason(result); + if (reason) return { entry: { ...entry, size: Number(result?.size) || 0 }, reason }; + return { entry: { ...entry, size: Number(result.size) }, reason: '' }; + } catch (error) { + return { entry, reason: error && error.code === 'ENOENT' ? 'missing' : 'unreadable' }; + } + }); + const accepted = []; + for (const result of inspected) { + if (result.reason) unavailable.push({ ...result.entry, reason: result.reason }); + else accepted.push(result.entry); + } + + return { + candidateCount: input.length, + duplicateCount: duplicates.length, + unavailableCount: unavailable.length, + acceptedCount: accepted.length, + accepted, + duplicates, + unavailable + }; + } + + function normalizeSelectedHosters(values) { + return Array.from(new Set((Array.isArray(values) ? values : []) + .map(value => String(value ?? '').trim()) + .filter(Boolean))); + } + + function isImportPairEligible(file, hoster, hosterSettings = {}) { + const maxSizeMb = Number(hosterSettings?.[hoster]?.maxSizeMb); + return !(maxSizeMb > 0 && Number(file?.size) > maxSizeMb * 1024 * 1024); + } + + function getEligibleImportHosters(file, selectedHosters, hosterSettings = {}) { + return normalizeSelectedHosters(selectedHosters) + .filter(hoster => isImportPairEligible(file, hoster, hosterSettings)); + } + + function summarizeImportPlan(input = {}) { + const inspection = input.inspection && typeof input.inspection === 'object' ? input.inspection : {}; + const accepted = Array.isArray(inspection.accepted) ? inspection.accepted : []; + const selectedHosters = normalizeSelectedHosters(input.selectedHosters); + const settings = input.hosterSettings && typeof input.hosterSettings === 'object' ? input.hosterSettings : {}; + let jobCount = 0; + for (const file of accepted) jobCount += getEligibleImportHosters(file, selectedHosters, settings).length; + return { + candidateCount: Number(inspection.candidateCount) || 0, + duplicateCount: Number(inspection.duplicateCount) || 0, + unavailableCount: Number(inspection.unavailableCount) || 0, + acceptedCount: accepted.length, + targetCount: selectedHosters.length, + jobCount, + sizeLimitedJobCount: accepted.length * selectedHosters.length - jobCount + }; } return { - candidateCount: input.length, - duplicateCount: duplicates.length, - filteredCount: filtered.excluded.length, - unavailableCount: unavailable.length, - acceptedCount: accepted.length, - accepted, - duplicates, - filtered: filtered.excluded, - unavailable + getEligibleImportHosters, + inspectImportEntries, + inspectReadableImportPath, + isImportPairEligible, + summarizeImportPlan }; -} - -function normalizeSelectedHosters(values) { - return Array.from(new Set((Array.isArray(values) ? values : []) - .map(value => String(value ?? '').trim()) - .filter(Boolean))); -} - -function isImportPairEligible(file, hoster, hosterSettings = {}) { - const maxSizeMb = Number(hosterSettings?.[hoster]?.maxSizeMb); - return !(maxSizeMb > 0 && Number(file?.size) > maxSizeMb * 1024 * 1024); -} - -function getEligibleImportHosters(file, selectedHosters, hosterSettings = {}) { - return normalizeSelectedHosters(selectedHosters) - .filter(hoster => isImportPairEligible(file, hoster, hosterSettings)); -} - -function summarizeImportPlan(input = {}) { - const inspection = input.inspection && typeof input.inspection === 'object' ? input.inspection : {}; - const accepted = Array.isArray(inspection.accepted) ? inspection.accepted : []; - const selectedHosters = normalizeSelectedHosters(input.selectedHosters); - const settings = input.hosterSettings && typeof input.hosterSettings === 'object' ? input.hosterSettings : {}; - let jobCount = 0; - for (const file of accepted) { - jobCount += getEligibleImportHosters(file, selectedHosters, settings).length; - } - const sizeLimitedJobCount = accepted.length * selectedHosters.length - jobCount; - return { - candidateCount: Number(inspection.candidateCount) || 0, - duplicateCount: Number(inspection.duplicateCount) || 0, - filteredCount: Number(inspection.filteredCount) || 0, - unavailableCount: Number(inspection.unavailableCount) || 0, - acceptedCount: accepted.length, - targetCount: selectedHosters.length, - jobCount, - sizeLimitedJobCount - }; -} - -return { - getEligibleImportHosters, - inspectImportEntries, - inspectReadableImportPath, - isImportPairEligible, - summarizeImportPlan -}; }); diff --git a/lib/queue-dedup.js b/lib/queue-dedup.js index 70a2254..4b97799 100644 --- a/lib/queue-dedup.js +++ b/lib/queue-dedup.js @@ -76,8 +76,7 @@ const keyUnambiguous = k !== null && filesPerKey.get(k).size <= 1; const uploadedAfterSnapshot = savedAtFloor !== null && k !== null && keyUnambiguous && logMaxTs.has(k) && logMaxTs.get(k) >= savedAtFloor; - const hasTerminalResult = job && job.status === 'done' && job.result && typeof job.result === 'object'; - if (!hasTerminalResult && (doneInLog || uploadedAfterSnapshot)) { + if (doneInLog || uploadedAfterSnapshot) { removed.push(job); } else { kept.push(job); diff --git a/lib/remote-server.js b/lib/remote-server.js index 0cd3deb..6af9a1e 100644 --- a/lib/remote-server.js +++ b/lib/remote-server.js @@ -21,12 +21,8 @@ class RemoteServer { return new Promise((resolve, reject) => { this._config = opts; - const host = opts.host || '127.0.0.1'; - if (host !== '127.0.0.1' && host !== '::1') { - reject(new Error('Remote server requires a loopback host')); - return; - } - const wssOpts = { port: opts.port, host, maxPayload: 256 * 1024 }; + const wssOpts = { port: opts.port, maxPayload: 256 * 1024 }; + if (opts.host) wssOpts.host = opts.host; this._wss = new WebSocketServer(wssOpts, () => { resolve(); }); @@ -98,17 +94,6 @@ class RemoteServer { const client = this._clients.get(ws); if (!client) return; - if (!msg || typeof msg !== 'object' || Array.isArray(msg)) { - if (!client.authenticated) { - authReceived = true; - clearTimeout(authTimeout); - this._recordFailedAttempt(ip); - ws.close(4002, 'Invalid token'); - this._clients.delete(ws); - } - return; - } - if (!client.authenticated) { authReceived = true; clearTimeout(authTimeout); diff --git a/lib/source-cleanup-policy.js b/lib/source-cleanup-policy.js index f8ad833..b97f3fd 100644 --- a/lib/source-cleanup-policy.js +++ b/lib/source-cleanup-policy.js @@ -2,7 +2,7 @@ 'use strict'; const pathApi = typeof require === 'function' ? require('path') : null; - const metadataVersion = 2; + const protectedStatuses = new Set(['done', 'error', 'aborted', 'skipped']); function normalizeFile(file, platform) { const value = typeof file === 'string' ? file.trim() : ''; @@ -59,39 +59,18 @@ return uniqueHosters(values); } - function confirmedHosters(jobs, requiredHosters) { + function completedHosters(jobs, requiredHosters) { const values = []; for (const job of jobs) { - if (job.sourceCleanupMetadataVersion === metadataVersion && Array.isArray(job.sourceCleanupConfirmedHosters)) { - values.push(...job.sourceCleanupConfirmedHosters); + if (Array.isArray(job.sourceCleanupCompletedHosters)) { + values.push(...job.sourceCleanupCompletedHosters); } } - const confirmed = new Set(uniqueHosters(values)); - return requiredHosters.filter((hoster) => confirmed.has(hoster)); - } - - function provisionalHosters(jobs, requiredHosters) { - const values = []; for (const job of jobs) { - if (job.sourceCleanupMetadataVersion === metadataVersion && Array.isArray(job.sourceCleanupProvisionalHosters)) { - values.push(...job.sourceCleanupProvisionalHosters); - } + if (job.status === 'done') values.push(job.hoster); } - const provisional = new Set(uniqueHosters(values)); - return requiredHosters.filter((hoster) => provisional.has(hoster)); - } - - function startedHosters(jobs, requiredHosters) { - const values = []; - let legacyMetadata = false; - for (const job of jobs) { - if (job.sourceCleanupMetadataVersion !== metadataVersion) continue; - if (!Array.isArray(job.sourceCleanupStartedHosters)) legacyMetadata = true; - else values.push(...job.sourceCleanupStartedHosters); - } - if (legacyMetadata) return [...requiredHosters]; - const started = new Set(uniqueHosters(values)); - return requiredHosters.filter((hoster) => started.has(hoster)); + const completed = new Set(uniqueHosters(values)); + return requiredHosters.filter((hoster) => completed.has(hoster)); } function storedToken(jobs) { @@ -111,16 +90,12 @@ return null; } - function assignMetadata(jobs, token, requiredHosters, confirmed, provisional, started, fingerprint, touchedJobs, touchedSet) { + function assignMetadata(jobs, token, requiredHosters, completed, fingerprint, touchedJobs, touchedSet) { for (const job of jobs) { - job.sourceCleanupMetadataVersion = metadataVersion; job.sourceCleanupToken = token; job.sourceCleanupRequiredHosters = [...requiredHosters]; - job.sourceCleanupConfirmedHosters = [...confirmed]; - job.sourceCleanupProvisionalHosters = [...provisional]; - job.sourceCleanupStartedHosters = [...started]; + job.sourceCleanupCompletedHosters = [...completed]; job.sourceCleanupFingerprint = cloneFingerprint(fingerprint); - delete job.sourceCleanupCompletedHosters; if (!touchedSet.has(job)) { touchedSet.add(job); touchedJobs.push(job); @@ -133,10 +108,7 @@ const touchedJobs = []; const touchedSet = new Set(); const preparedFiles = new Set(); - const revokedHosters = []; - const revokedSet = new Set(); - if (!Array.isArray(queueJobs) || !Array.isArray(jobsToStart)) return { groups, touchedJobs, revokedHosters }; - const currentRoundJobs = new Set(jobsToStart); + if (!Array.isArray(queueJobs) || !Array.isArray(jobsToStart)) return { groups, touchedJobs }; for (const selectedJob of jobsToStart) { const file = normalizeFile(selectedJob && selectedJob.file, platform); @@ -152,28 +124,14 @@ ...persistedRequired, ...siblings.map((job) => job.hoster) ]); - const currentStartedHosters = new Set(uniqueHosters( - siblings.filter((job) => currentRoundJobs.has(job)).map((job) => job.hoster) - )); - const started = uniqueHosters([...startedHosters(siblings, requiredHosters), ...currentStartedHosters]); - const storedConfirmed = confirmedHosters(siblings, requiredHosters); - const confirmed = storedConfirmed.filter((hoster) => !currentStartedHosters.has(hoster)); - const provisional = provisionalHosters(siblings, requiredHosters) - .filter((hoster) => !currentStartedHosters.has(hoster)); - for (const hoster of storedConfirmed) { - if (!currentStartedHosters.has(hoster) || revokedSet.has(hoster)) continue; - revokedSet.add(hoster); - revokedHosters.push(hoster); - } + const completed = completedHosters(siblings, requiredHosters); const fingerprint = storedFingerprint(siblings); assignMetadata( siblings, token, requiredHosters, - confirmed, - provisional, - started, + completed, fingerprint, touchedJobs, touchedSet @@ -183,107 +141,48 @@ token, file: selectedJob.file, requiredHosters: [...requiredHosters], - confirmedHosters: [...confirmed], + completedHosters: [...completed], fingerprint: cloneFingerprint(fingerprint), jobs: siblings.map((job) => ({ jobId: job.id, hoster: normalizeHoster(job.hoster), - status: job.status, - currentRound: currentRoundJobs.has(job) + status: job.status })) }); } - return { groups, touchedJobs, revokedHosters }; + return { groups, touchedJobs }; } function markCompleted(queueJobs, job, platform) { const siblings = relatedJobs(queueJobs, job, platform); if (siblings.length === 0) return []; const requiredHosters = storedRequiredHosters(siblings); - const provisional = new Set(provisionalHosters(siblings, requiredHosters)); + const completed = new Set(completedHosters(siblings, requiredHosters)); const hoster = normalizeHoster(job.hoster); - if (requiredHosters.includes(hoster)) provisional.add(hoster); - const orderedConfirmed = confirmedHosters(siblings, requiredHosters); - const orderedProvisional = requiredHosters.filter((required) => provisional.has(required)); + if (requiredHosters.includes(hoster)) completed.add(hoster); + const orderedCompleted = requiredHosters.filter((required) => completed.has(required)); for (const sibling of siblings) { - sibling.sourceCleanupMetadataVersion = metadataVersion; - sibling.sourceCleanupConfirmedHosters = [...orderedConfirmed]; - sibling.sourceCleanupProvisionalHosters = [...orderedProvisional]; - delete sibling.sourceCleanupCompletedHosters; + sibling.sourceCleanupCompletedHosters = [...orderedCompleted]; } return siblings; } - async function persistRoundCompletions(queueJobs, options = {}) { - if (!Array.isArray(queueJobs) || typeof options.persist !== 'function') return false; - const historyPersisted = options.historyPersisted === true; - const groupsByToken = new Map(); - for (const job of queueJobs) { - if (!job || typeof job.sourceCleanupToken !== 'string' || !job.sourceCleanupToken) continue; - if (!groupsByToken.has(job.sourceCleanupToken)) groupsByToken.set(job.sourceCleanupToken, []); - groupsByToken.get(job.sourceCleanupToken).push(job); - } - const snapshots = []; - for (const jobs of groupsByToken.values()) { - const requiredHosters = storedRequiredHosters(jobs); - const confirmed = confirmedHosters(jobs, requiredHosters); - const provisional = provisionalHosters(jobs, requiredHosters); - const promoted = historyPersisted - ? uniqueHosters([...confirmed, ...provisional]) - : confirmed; - const orderedPromoted = requiredHosters.filter((hoster) => promoted.includes(hoster)); - for (const job of jobs) { - snapshots.push({ - job, - confirmedHosters: job.sourceCleanupMetadataVersion === metadataVersion - ? uniqueHosters(job.sourceCleanupConfirmedHosters) - : [] - }); - job.sourceCleanupMetadataVersion = metadataVersion; - job.sourceCleanupConfirmedHosters = [...orderedPromoted]; - job.sourceCleanupProvisionalHosters = []; - delete job.sourceCleanupCompletedHosters; - } - } - let persisted = false; - try { - persisted = (await options.persist()) === true; - } catch {} - if (!persisted) { - for (const snapshot of snapshots) { - snapshot.job.sourceCleanupMetadataVersion = metadataVersion; - snapshot.job.sourceCleanupConfirmedHosters = [...snapshot.confirmedHosters]; - snapshot.job.sourceCleanupProvisionalHosters = []; - delete snapshot.job.sourceCleanupCompletedHosters; - } - } - return persisted; - } - function removeRequirement(queueJobs, job, platform) { - if (!job || job.status !== 'preview' || job.interrupted) return []; + if (!job || protectedStatuses.has(job.status)) return []; const siblings = relatedJobs(queueJobs, job, platform); const removedHoster = normalizeHoster(job.hoster); - if (startedHosters(siblings, storedRequiredHosters(siblings)).includes(removedHoster)) return []; for (const sibling of siblings) { const required = Array.isArray(sibling.sourceCleanupRequiredHosters) ? sibling.sourceCleanupRequiredHosters : []; - const confirmed = sibling.sourceCleanupMetadataVersion === metadataVersion && Array.isArray(sibling.sourceCleanupConfirmedHosters) - ? sibling.sourceCleanupConfirmedHosters + const completed = Array.isArray(sibling.sourceCleanupCompletedHosters) + ? sibling.sourceCleanupCompletedHosters : []; - const provisional = sibling.sourceCleanupMetadataVersion === metadataVersion && Array.isArray(sibling.sourceCleanupProvisionalHosters) - ? sibling.sourceCleanupProvisionalHosters - : []; - sibling.sourceCleanupMetadataVersion = metadataVersion; sibling.sourceCleanupRequiredHosters = uniqueHosters(required) .filter((hoster) => hoster !== removedHoster); - sibling.sourceCleanupConfirmedHosters = uniqueHosters(confirmed) + sibling.sourceCleanupCompletedHosters = uniqueHosters(completed) .filter((hoster) => hoster !== removedHoster); - sibling.sourceCleanupProvisionalHosters = uniqueHosters(provisional) - .filter((hoster) => hoster !== removedHoster); - delete sibling.sourceCleanupCompletedHosters; } return siblings; } @@ -312,7 +211,6 @@ const api = { prepareGroups, markCompleted, - persistRoundCompletions, removeRequirement, applyFingerprints }; diff --git a/lib/source-file-cleanup.js b/lib/source-file-cleanup.js index fc6d0d3..e8dc7e0 100644 --- a/lib/source-file-cleanup.js +++ b/lib/source-file-cleanup.js @@ -83,29 +83,30 @@ function createSourceFileCleanup(options) { function createManifest(input, canonicalFile) { const requiredHosters = uniqueStrings(input.requiredHosters); - const confirmedHosters = new Set( - uniqueStrings(input.confirmedHosters).filter((hoster) => requiredHosters.includes(hoster)) - ); + const completedHosters = new Set(uniqueStrings(input.completedHosters)); const jobs = new Map(); for (const job of Array.isArray(input.jobs) ? input.jobs : []) { if (!job || typeof job.jobId !== 'string' || typeof job.hoster !== 'string') continue; - const currentRound = job.currentRound !== false; - const status = currentRound ? 'pending' : normalizeStatus(job.status); + const status = completedHosters.has(job.hoster) ? 'done' : normalizeStatus(job.status); jobs.set(job.jobId, Object.freeze({ jobId: job.jobId, hoster: job.hoster, - status, - currentRound + status })); } + for (const hoster of completedHosters) { + if (requiredHosters.includes(hoster)) continue; + completedHosters.delete(hoster); + } + return { token: input.token || input.sourceCleanupToken, file: path.resolve(input.file), canonicalFile, requiredHosters: Object.freeze(requiredHosters), - confirmedHosters, + completedHosters, jobs, suppliedFingerprint: isFingerprint(input.fingerprint) ? cloneFingerprint(input.fingerprint) : null, fingerprint: null, @@ -151,32 +152,16 @@ function createSourceFileCleanup(options) { ...existing.requiredHosters, ...input.requiredHosters ])); - const incomingJobs = Array.isArray(input.jobs) ? input.jobs : []; - const currentRoundHosters = new Set( - incomingJobs - .filter((job) => job && typeof job.hoster === 'string' && job.currentRound !== false) - .map((job) => job.hoster) - ); - for (const hoster of currentRoundHosters) existing.confirmedHosters.delete(hoster); - for (const hoster of uniqueStrings(input.confirmedHosters)) { - if (existing.requiredHosters.includes(hoster) && !currentRoundHosters.has(hoster)) { - existing.confirmedHosters.add(hoster); - } + for (const hoster of uniqueStrings(input.completedHosters)) { + if (existing.requiredHosters.includes(hoster)) existing.completedHosters.add(hoster); } - for (const job of incomingJobs) { + for (const job of Array.isArray(input.jobs) ? input.jobs : []) { if (!job || typeof job.jobId !== 'string' || typeof job.hoster !== 'string') continue; const previous = existing.jobs.get(job.jobId); - const incomingCurrentRound = job.currentRound !== false; - const currentRound = Boolean((previous && previous.currentRound) || incomingCurrentRound); - const status = incomingCurrentRound - ? 'pending' + const status = existing.completedHosters.has(job.hoster) + ? 'done' : normalizeStatus(previous ? previous.status : job.status); - existing.jobs.set(job.jobId, Object.freeze({ - jobId: job.jobId, - hoster: job.hoster, - status, - currentRound - })); + existing.jobs.set(job.jobId, Object.freeze({ jobId: job.jobId, hoster: job.hoster, status })); } fingerprints[token] = cloneFingerprint(existing.fingerprint); continue; @@ -209,9 +194,10 @@ function createSourceFileCleanup(options) { const manifest = groups.get(token); if (!manifest || manifest.finalizationPromise) return false; const job = manifest.jobs.get(event.jobId); - if (!job || !job.currentRound || job.hoster !== event.hoster) return false; + if (!job || job.hoster !== event.hoster) return false; if (typeof event.file === 'string' && canonicalize(event.file) !== manifest.canonicalFile) return false; manifest.jobs.set(event.jobId, Object.freeze({ ...job, status: event.status })); + if (event.status === 'done') manifest.completedHosters.add(event.hoster); return true; } @@ -220,7 +206,7 @@ function createSourceFileCleanup(options) { for (const manifest of groups.values()) { if (manifest.finalizationPromise) continue; const job = manifest.jobs.get(jobId); - if (!job || !job.currentRound) continue; + if (!job) continue; manifest.jobs.set(jobId, Object.freeze({ ...job, status: 'skipped' })); changed = true; } @@ -230,17 +216,11 @@ function createSourceFileCleanup(options) { function blockingStatuses(manifest) { const blocking = []; for (const hoster of manifest.requiredHosters) { - const jobs = [...manifest.jobs.values()].filter((job) => job.hoster === hoster); - const currentJobs = jobs.filter((job) => job.currentRound); - if (currentJobs.length > 0) { - const currentBlocker = currentJobs.find((job) => job.status !== 'done'); - if (!currentBlocker) continue; - const status = currentJobs.map((job) => job.status).find((value) => value !== 'pending') || 'pending'; - blocking.push({ hoster, status }); - continue; - } - if (manifest.confirmedHosters.has(hoster)) continue; - const statuses = jobs.map((job) => job.status).filter((status) => status !== 'done'); + if (manifest.completedHosters.has(hoster)) continue; + const statuses = [...manifest.jobs.values()] + .filter((job) => job.hoster === hoster) + .map((job) => job.status); + if (statuses.includes('done')) continue; const status = statuses.find((value) => value !== 'pending') || 'pending'; blocking.push({ hoster, status }); } diff --git a/lib/startup-renderer.js b/lib/startup-renderer.js index 43c3496..aea6694 100644 --- a/lib/startup-renderer.js +++ b/lib/startup-renderer.js @@ -1,10 +1,6 @@ -function configureStartupRenderer(app, env = process.env, platform = process.platform, argv = process.argv) { +function configureStartupRenderer(app, env = process.env, platform = process.platform) { const sessionName = String(env && env.SESSIONNAME || ''); if (platform === 'win32' && /^RDP-/i.test(sessionName)) app.disableHardwareAcceleration(); - if (Array.isArray(argv) && argv.includes('--dev')) { - app.commandLine.appendSwitch('force-prefers-no-reduced-motion'); - app.commandLine.appendSwitch('user-data-dir', app.getPath('userData')); - } return app; } @@ -12,518 +8,10 @@ function resolveStartupLanguage(config) { return config && config.globalSettings && config.globalSettings.language === 'de' ? 'de' : 'en'; } -function createStartupFailureDocument(language) { - const german = language === 'de'; - const title = german ? 'Oberfläche konnte nicht geladen werden' : 'The interface could not load'; - const detail = german - ? 'Multi Hoster Uploader konnte die Oberfläche nach einem sicheren Wiederherstellungsversuch nicht laden.' - : 'Multi Hoster Uploader could not load the interface after a safe recovery attempt.'; - const close = german ? 'Schließen' : 'Close'; - return `Multi Hoster Uploader

${title}

${detail}

`; -} - -function createStartupNavigationLoader(window, target, options = {}) { - let startupDocument = 0; - return function loadStartupDocument() { - startupDocument++; - return window.loadFile(target, { - ...options, - query: { - ...options.query, - startupDocument: String(startupDocument) - } - }); - }; -} - -function createStartupRevealGate(window, { onBlock } = {}) { - let blocked = true; - let activationPending = false; - - function hasWindow() { - return !!window && (typeof window.isDestroyed !== 'function' || !window.isDestroyed()); - } - - function showAuthorizedSurface(activate) { - if (!hasWindow()) return false; - if (activate && window.isMinimized()) window.restore(); - if (!window.isVisible()) window.show(); - if (activate) window.focus(); - activationPending = false; - return true; - } - - const block = () => { - blocked = true; - if (typeof onBlock === 'function') onBlock(); - return true; - }; - - const request = () => { - if (!hasWindow()) return false; - activationPending = true; - if (!blocked) showAuthorizedSurface(true); - return true; - }; - - const reveal = () => { - blocked = false; - return showAuthorizedSurface(activationPending); - }; - - const navigate = (operation, ...args) => { - block(); - return operation(...args); - }; - - return { block, navigate, request, reveal }; -} - -function createStartupExternalRevealBindings({ - getWindow, - getRevealGate, - sendDroppedFiles, - maxPendingDropPayloads = 32 -}) { - const pendingDropPayloads = []; - const pendingDropLimit = Number.isSafeInteger(maxPendingDropPayloads) && maxPendingDropPayloads > 0 - ? maxPendingDropPayloads - : 32; - let pendingWindow = null; - let readyWindow = null; - - function clearPendingDropPayloads(window) { - if (window && pendingWindow !== window) return false; - pendingDropPayloads.length = 0; - pendingWindow = null; - return true; - } - - function clearWindowState(window) { - clearPendingDropPayloads(window); - if (!window || readyWindow === window) readyWindow = null; - } - - function getActiveWindow() { - const window = getWindow(); - if (!window || (typeof window.isDestroyed === 'function' && window.isDestroyed())) { - clearWindowState(); - return null; - } - if (pendingWindow && pendingWindow !== window) clearPendingDropPayloads(); - if (readyWindow && readyWindow !== window) readyWindow = null; - return window; - } - - function requestReveal() { - const activeWindow = getActiveWindow(); - if (!activeWindow) return false; - const revealGate = getRevealGate(); - if (!revealGate || typeof revealGate.request !== 'function') return false; - revealGate.request(); - return true; - } - - function queueDropPayload(window, paths) { - if (pendingWindow && pendingWindow !== window) clearPendingDropPayloads(); - pendingWindow = window; - if (pendingDropPayloads.length >= pendingDropLimit) pendingDropPayloads.shift(); - pendingDropPayloads.push(paths); - } - - function handleDropTargetFiles(_event, paths) { - const window = getActiveWindow(); - if (!window) return false; - if (!window.isVisible() || window.isMinimized()) requestReveal(); - if (readyWindow === window) sendDroppedFiles(paths); - else queueDropPayload(window, paths); - return true; - } - - function rendererBlocked(window) { - const activeWindow = getActiveWindow(); - if (!activeWindow || activeWindow !== window) { - clearWindowState(window); - return false; - } - readyWindow = null; - return true; - } - - function rendererReady(window) { - const activeWindow = getActiveWindow(); - if (!activeWindow || activeWindow !== window) { - clearWindowState(window); - return false; - } - readyWindow = window; - if (pendingWindow !== window) return true; - const payloads = pendingDropPayloads.splice(0); - pendingWindow = null; - for (const paths of payloads) sendDroppedFiles(paths); - return true; - } - - function windowClosed(window) { - clearWindowState(window); - } - - return { - bindSecondInstance(app) { - app.on('second-instance', requestReveal); - }, - bindTrayClick(tray) { - tray.on('click', requestReveal); - }, - createTrayMenuItem(label) { - return { label, click: requestReveal }; - }, - bindDropTargetFiles(ipcMain) { - ipcMain.on('drop-target:files', handleDropTargetFiles); - }, - rendererBlocked, - rendererReady, - windowClosed - }; -} - -function createStartupCloseHandler({ window, shouldPrepareClose, requestClosePreparation }) { - return function handleStartupClose(event) { - if (!shouldPrepareClose() || window.webContents.isDestroyed()) return false; - event.preventDefault(); - requestClosePreparation(); - return true; - }; -} - -function createStartupRecoveryCoordinator({ - load, - reload, - reveal, - showFailure, - close, - readyTimeoutMs = 15000, - scheduleReadyDeadline = setTimeout, - cancelReadyDeadline = clearTimeout -}) { - let initialLoad; - let initialLoadPending = false; - let navigation; - let recoveryNavigations = 0; - let terminalFailure; - let recovery; - let queuedRecovery; - let readyDeadline; - let rendererGeneration = 0; - let awaitingGeneration = null; - let stopped = false; - - function clearRendererDeadline() { - if (readyDeadline !== undefined) cancelReadyDeadline(readyDeadline); - readyDeadline = undefined; - } - - function clearRendererDocument() { - awaitingGeneration = null; - clearRendererDeadline(); - } - - function acceptRendererDocument(generation) { - if (!Number.isInteger(generation) || awaitingGeneration !== generation) return false; - clearRendererDocument(); - return true; - } - - async function runNavigation(operation, args = []) { - if (navigation) return navigation; - const currentNavigation = Promise.resolve().then(() => operation(...args)); - navigation = currentNavigation; - try { - return await currentNavigation; - } finally { - if (navigation === currentNavigation) navigation = undefined; - } - } - - function endWithFailure(failure) { - if (stopped) return Promise.resolve(false); - if (!terminalFailure) { - clearRendererDocument(); - terminalFailure = Promise.resolve().then(async () => { - if (typeof showFailure !== 'function') { - await close(failure); - return; - } - try { - await showFailure(failure); - reveal(); - } catch (surfaceError) { - await close({ ...failure, surfaceError }); - } - }); - } - return terminalFailure; - } - - function trackRecovery(currentRecovery) { - recovery = currentRecovery; - currentRecovery.then( - () => { - if (recovery === currentRecovery) recovery = undefined; - }, - () => { - if (recovery === currentRecovery) recovery = undefined; - } - ); - return currentRecovery; - } - - async function performRecovery(phase, details) { - if (stopped) return false; - if (terminalFailure) return terminalFailure; - if (recoveryNavigations >= 1) { - return endWithFailure({ phase, attempt: recoveryNavigations + 1, details }); - } - recoveryNavigations++; - try { - await runNavigation(reload); - return true; - } catch (error) { - if (stopped) return false; - return endWithFailure({ phase: 'renderer-reload', attempt: recoveryNavigations, details, error }); - } - } - - function recoverRenderer(phase, details) { - if (stopped) return Promise.resolve(false); - if (terminalFailure) return terminalFailure; - clearRendererDocument(); - if (initialLoadPending) { - queuedRecovery = { phase, details, recoveryNavigations }; - if (recovery) return recovery; - const currentRecovery = Promise.resolve(initialLoad).then(() => { - const pendingRecovery = queuedRecovery; - queuedRecovery = undefined; - if (stopped) return false; - if (terminalFailure) return terminalFailure; - if (!pendingRecovery || recoveryNavigations > pendingRecovery.recoveryNavigations) return true; - return performRecovery(pendingRecovery.phase, pendingRecovery.details); - }); - return trackRecovery(currentRecovery); - } - if (recovery) return recovery; - return trackRecovery(performRecovery(phase, details)); - } - - return { - loadInitial(...args) { - if (stopped) return Promise.resolve(false); - if (terminalFailure) return terminalFailure; - if (!initialLoad) { - initialLoadPending = true; - const currentInitialLoad = (async () => { - for (let attempt = 1; attempt <= 2; attempt++) { - if (stopped) return false; - if (attempt === 2) recoveryNavigations = Math.max(recoveryNavigations, 1); - try { - return await runNavigation(load, args); - } catch (error) { - if (stopped) return false; - if (attempt === 2) { - await endWithFailure({ phase: 'initial-load', attempt, error }); - } - } - } - })(); - initialLoad = currentInitialLoad; - currentInitialLoad.then( - () => { - initialLoadPending = false; - }, - () => { - initialLoadPending = false; - } - ); - } - return initialLoad; - }, - rendererCrashed(details) { - return recoverRenderer('renderer-crash', details); - }, - rendererInitializationFailed(details) { - return recoverRenderer('renderer-initialization', details); - }, - rendererLoadStarted() { - if (stopped || terminalFailure) return false; - clearRendererDocument(); - rendererGeneration++; - awaitingGeneration = rendererGeneration; - return rendererGeneration; - }, - rendererLoaded(generation) { - if (stopped || terminalFailure || awaitingGeneration !== generation) return false; - clearRendererDeadline(); - readyDeadline = scheduleReadyDeadline(() => { - if (stopped || terminalFailure || awaitingGeneration !== generation) return false; - readyDeadline = undefined; - return recoverRenderer('renderer-ready-timeout', { timeoutMs: readyTimeoutMs }); - }, readyTimeoutMs); - if (readyDeadline && typeof readyDeadline.unref === 'function') readyDeadline.unref(); - return true; - }, - rendererReady(generation) { - if (stopped || terminalFailure || !acceptRendererDocument(generation)) return false; - recoveryNavigations = 0; - reveal(); - return true; - }, - dispose() { - if (stopped) return false; - stopped = true; - queuedRecovery = undefined; - clearRendererDocument(); - return true; - } - }; -} - -function createStartupRendererHandlers({ - window, - ipcMain, - coordinator, - onDocumentLoadStarted, - onRendererCrashed, - onReady, - onInitializationFailed -}) { - let disposed = false; - let generation = null; - let activeFrame = null; - let pendingDocumentUrl = null; - let pendingFrameAddress = null; - const webContents = window && window.webContents; - - function frameAddress(frame) { - if (!frame || frame.detached) return null; - if (typeof frame.isDestroyed === 'function' && frame.isDestroyed()) return null; - if (!Number.isInteger(frame.processId) || typeof frame.frameToken !== 'string' || !frame.frameToken) return null; - return `${frame.processId}:${frame.frameToken}`; - } - - function frameIdentity(frame) { - const address = frameAddress(frame); - if (!address) return null; - if (typeof frame.url !== 'string' || !frame.url) return null; - return `${address}:${frame.url}`; - } - - function startDocument(url, frame) { - if (disposed) return false; - generation = coordinator.rendererLoadStarted(); - activeFrame = null; - pendingDocumentUrl = typeof url === 'string' && url ? url : null; - pendingFrameAddress = frameAddress(frame); - if (generation !== false && typeof onDocumentLoadStarted === 'function') onDocumentLoadStarted(); - return generation; - } - - function finishDocument() { - if (disposed || generation === null) return false; - activeFrame = frameIdentity(webContents && webContents.mainFrame); - return coordinator.rendererLoaded(generation); - } - - function resolveEventFrame(event) { - if (disposed || !window || window.isDestroyed() || !event || event.sender !== webContents) return null; - if (!frameIdentity(event.senderFrame)) return null; - return event.senderFrame; - } - - function resolveReadyGeneration(event) { - const senderFrame = resolveEventFrame(event); - if (!senderFrame || !Number.isInteger(generation) || !activeFrame || frameIdentity(senderFrame) !== activeFrame) return null; - return generation; - } - - function resolveInitializationGeneration(event) { - const senderFrame = resolveEventFrame(event); - if (!senderFrame || !Number.isInteger(generation)) return null; - if (activeFrame) return frameIdentity(senderFrame) === activeFrame ? generation : null; - if (!pendingDocumentUrl || !pendingFrameAddress) return null; - if (senderFrame.url !== pendingDocumentUrl || frameAddress(senderFrame) !== pendingFrameAddress) return null; - return generation; - } - - function handleNavigation(details, _url, isInPlace, isMainFrame) { - const sameDocument = details && typeof details.isSameDocument === 'boolean' ? details.isSameDocument : isInPlace; - const mainFrame = details && typeof details.isMainFrame === 'boolean' ? details.isMainFrame : isMainFrame; - if (sameDocument || mainFrame === false) return false; - const url = details && typeof details.url === 'string' ? details.url : _url; - const frame = details && details.frame; - return startDocument(url, frame); - } - - function handleRendererCrash(_event, details) { - if (disposed) return false; - if (typeof onRendererCrashed === 'function') onRendererCrashed(details); - return coordinator.rendererCrashed(details); - } - - function handleRendererInitializationFailed(event, details) { - if (resolveInitializationGeneration(event) === null) return false; - if (typeof onInitializationFailed === 'function') onInitializationFailed(details); - return coordinator.rendererInitializationFailed(details); - } - - function handleRendererReady(event) { - const eventGeneration = resolveReadyGeneration(event); - if (eventGeneration === null) return false; - const ready = coordinator.rendererReady(eventGeneration); - if (ready && typeof onReady === 'function') onReady(); - return ready; - } - - if (webContents && typeof webContents.on === 'function') { - webContents.on('did-start-navigation', handleNavigation); - webContents.on('did-finish-load', finishDocument); - webContents.on('render-process-gone', handleRendererCrash); - } - if (ipcMain && typeof ipcMain.on === 'function') { - ipcMain.on('app:close-handshake-ready', handleRendererReady); - ipcMain.on('app:renderer-initialization-failed', handleRendererInitializationFailed); - } - - return { - documentLoadStarted: startDocument, - documentLoaded: finishDocument, - rendererCrashed(details) { - if (disposed) return false; - return coordinator.rendererCrashed(details); - }, - rendererInitializationFailed: handleRendererInitializationFailed, - rendererReady: handleRendererReady, - dispose() { - if (disposed) return false; - disposed = true; - if (webContents && typeof webContents.removeListener === 'function') { - webContents.removeListener('did-start-navigation', handleNavigation); - webContents.removeListener('did-finish-load', finishDocument); - webContents.removeListener('render-process-gone', handleRendererCrash); - } - if (ipcMain && typeof ipcMain.removeListener === 'function') { - ipcMain.removeListener('app:close-handshake-ready', handleRendererReady); - ipcMain.removeListener('app:renderer-initialization-failed', handleRendererInitializationFailed); - } - return coordinator.dispose(); - } - }; -} - function createStartupWindow(BrowserWindow, options) { - const window = new BrowserWindow({ - ...options, - show: false, - disableAutoHideCursor: true + const window = new BrowserWindow({ ...options, show: false }); + window.once('ready-to-show', () => { + window.show(); }); return { @@ -534,15 +22,4 @@ function createStartupWindow(BrowserWindow, options) { }; } -module.exports = { - configureStartupRenderer, - createStartupCloseHandler, - createStartupExternalRevealBindings, - createStartupFailureDocument, - createStartupNavigationLoader, - createStartupRecoveryCoordinator, - createStartupRevealGate, - createStartupRendererHandlers, - createStartupWindow, - resolveStartupLanguage -}; +module.exports = { configureStartupRenderer, createStartupWindow, resolveStartupLanguage }; diff --git a/lib/stats.js b/lib/stats.js index 11ad24f..fade21b 100644 --- a/lib/stats.js +++ b/lib/stats.js @@ -39,116 +39,6 @@ return out; } - function summarizeHosterHealth(history, options = {}) { - const out = {}; - const hosters = options.hosters && typeof options.hosters === 'object' ? options.hosters : {}; - const accountStatuses = options.accountStatuses && typeof options.accountStatuses === 'object' ? options.accountStatuses : {}; - const failedKeys = new Set(options.sessionFailedKeys instanceof Set - ? options.sessionFailedKeys - : (Array.isArray(options.sessionFailedKeys) ? options.sessionFailedKeys : [])); - const nowCandidate = options.now instanceof Date - ? options.now.getTime() - : (Number.isFinite(options.now) ? Number(options.now) : Date.parse(options.now)); - const nowMs = Number.isFinite(nowCandidate) ? nowCandidate : Date.now(); - const recentCutoff = nowMs - 7 * 24 * 60 * 60 * 1000; - - const ensure = (name) => out[name] || (out[name] = { - sampleSize: 0, - successful: 0, - failed: 0, - skipped: 0, - successRate: null, - effectiveBytes: 0, - effectiveDurationSec: 0, - effectiveBytesPerSecond: null, - lastSuccessAt: null, - failuresLast7Days: 0, - configuredAccounts: 0, - accountProblems: 0, - uncheckedAccounts: 0, - checkingAccounts: 0 - }); - - const hasCredentials = (account) => { - if (!account || typeof account !== 'object' || !account.id) return false; - if (account.authType === 'api') return Boolean(String(account.apiKey || '').trim()); - if (account.authType === 'login') return Boolean(String(account.username || '').trim() && String(account.password || '').trim()); - return Boolean(String(account.apiKey || '').trim() || (String(account.username || '').trim() && String(account.password || '').trim())); - }; - - for (const [name, accountsValue] of Object.entries(hosters)) { - const bucket = ensure(name); - const accounts = Array.isArray(accountsValue) ? accountsValue : []; - bucket.configuredAccounts = accounts.length; - for (const account of accounts) { - if (account?.enabled === false) continue; - const status = accountStatuses[account?.id]?.status || 'unchecked'; - const unavailable = !hasCredentials(account); - const problem = unavailable || failedKeys.has(`${name}:${account?.id || ''}`) || ['error', 'warn', 'otp_required'].includes(status); - if (problem) bucket.accountProblems++; - if (!unavailable && status === 'unchecked') bucket.uncheckedAccounts++; - if (!unavailable && status === 'checking') bucket.checkingAccounts++; - } - } - - const validBatches = (Array.isArray(history) ? history : []) - .map((batch, index) => { - const timestampMs = batch?.timestamp ? Date.parse(batch.timestamp) : NaN; - return { batch, index, timestampMs }; - }) - .filter(({ timestampMs }) => Number.isFinite(timestampMs) && timestampMs <= nowMs); - const batches = [...validBatches] - .sort((a, b) => b.timestampMs - a.timestampMs || b.index - a.index) - .slice(0, 50); - - for (const { batch, timestampMs } of batches) { - if (!batch || !Array.isArray(batch.files)) continue; - for (const file of batch.files) { - if (!file || !Array.isArray(file.results)) continue; - const fileSize = Number(file.size); - for (const result of file.results) { - if (!result?.hoster) continue; - const bucket = ensure(result.hoster); - bucket.sampleSize++; - if (result.status === 'done') { - bucket.successful++; - const previous = bucket.lastSuccessAt ? Date.parse(bucket.lastSuccessAt) : -Infinity; - if (timestampMs > previous) bucket.lastSuccessAt = new Date(timestampMs).toISOString(); - const durationSec = Number(result.durationSec); - if (Number.isFinite(fileSize) && fileSize > 0 && Number.isFinite(durationSec) && durationSec > 0) { - bucket.effectiveBytes += fileSize; - bucket.effectiveDurationSec += durationSec; - } - } else if (result.status === 'skipped') { - bucket.skipped++; - } else { - bucket.failed++; - } - } - } - } - - for (const { batch, timestampMs } of validBatches) { - if (timestampMs < recentCutoff || !Array.isArray(batch?.files)) continue; - for (const file of batch.files) { - if (!Array.isArray(file?.results)) continue; - for (const result of file.results) { - if (!result?.hoster || result.status === 'done' || result.status === 'skipped') continue; - ensure(result.hoster).failuresLast7Days++; - } - } - } - - for (const bucket of Object.values(out)) { - const attempted = bucket.successful + bucket.failed; - bucket.successRate = attempted > 0 ? bucket.successful / attempted : null; - bucket.effectiveBytesPerSecond = bucket.effectiveDurationSec > 0 - ? bucket.effectiveBytes / bucket.effectiveDurationSec - : null; - } - return out; - } - function classifyErrorCategory(err) { if (!err || typeof err !== 'string') return 'unknown'; const s = err.toLowerCase(); @@ -196,10 +86,8 @@ }; const existingJobIds = new Set(); const filesByName = new Map(); - const filesByKey = new Map(); for (const file of merged.files) { filesByName.set(String(file.name || file.fileName || ''), file); - if (file.fileKey) filesByKey.set(String(file.fileKey), file); for (const result of file.results) { if (result?.jobId) existingJobIds.add(result.jobId); } @@ -208,14 +96,11 @@ for (const skipped of Array.isArray(skippedJobs) ? skippedJobs : []) { if (!skipped || (skipped.jobId && existingJobIds.has(skipped.jobId))) continue; const fileName = String(skipped.fileName || skipped.file || '').split(/[\\/]/).pop() || ''; - const fileKey = String(skipped.fileKey || ''); - let file = fileKey ? filesByKey.get(fileKey) : filesByName.get(fileName); + let file = filesByName.get(fileName); if (!file) { file = { name: fileName, size: Number(skipped.size) || 0, results: [] }; - if (fileKey) file.fileKey = fileKey; merged.files.push(file); filesByName.set(fileName, file); - if (fileKey) filesByKey.set(fileKey, file); } file.results.push({ jobId: skipped.jobId || null, @@ -285,7 +170,6 @@ const api = { summarizePerHoster, - summarizeHosterHealth, classifyErrorCategory, summarizeBatchErrors, mergeSkippedIntoSummary, diff --git a/lib/support-bundle.js b/lib/support-bundle.js index 10a3394..468b7f3 100644 --- a/lib/support-bundle.js +++ b/lib/support-bundle.js @@ -26,147 +26,47 @@ function collectSecretValues(config) { if (typeof o !== 'object') return; for (const k of Object.keys(o)) { const v = o[k]; - if (CRED_KEYS.has(k) && typeof v === 'string' && v.length > 0) out.add(v); + if (CRED_KEYS.has(k) && typeof v === 'string' && v.length >= 6) out.add(v); else walk(v); } })(config); return Array.from(out); } -function redactConfiguredSecrets(text, secrets) { - if (!Array.isArray(secrets)) return text; - const values = Array.from(new Set(secrets - .filter(value => typeof value === 'string' && value.length > 0) - .flatMap(value => { - const variants = [value]; - for (let index = 0; index < 3; index++) { - const escaped = JSON.stringify(variants[variants.length - 1]).slice(1, -1); - if (escaped === variants[variants.length - 1]) break; - variants.push(escaped); - } - return variants; - }))) - .sort((a, b) => b.length - a.length); - let out = text; - for (const value of values) { - let offset = 0; - while (offset < out.length) { - const index = out.indexOf(value, offset); - if (index < 0) break; - const before = index > 0 ? out[index - 1] : ''; - const after = index + value.length < out.length ? out[index + value.length] : ''; - const continuation = character => /[A-Za-z0-9_.]/.test(character); - if (!continuation(before) && !continuation(after)) { - out = `${out.slice(0, index)}${REDACTED}${out.slice(index + value.length)}`; - offset = index + REDACTED.length; - } else { - offset = index + value.length; - } - } - } - return out; -} - -function redactHtmlCredentialFields(text) { - let out = ''; - let offset = 0; - const lower = text.toLowerCase(); - while (offset < text.length) { - const start = lower.indexOf('') { - end++; - break; - } - } - const input = text.slice(start, end); - const sensitive = /\btype\s*=\s*["']?password\b/i.test(input) - || /\b(?:name|id)\s*=\s*["']?(?:password|passwd|api[_-]?(?:key|token)|token|secret|authorization|cookie|session(?:[_-]?id)?)\b/i.test(input); - out += sensitive - ? input - .replace(/(\bvalue\s*=\s*)(["'])([\s\S]*?)\2/gi, `$1$2${REDACTED}$2`) - .replace(/(\bvalue\s*=\s*)(?!["'])([^\s>]+)/gi, `$1${REDACTED}`) - : input; - offset = end; - } - return out; -} - -function redactAbsolutePaths(text) { - const isDriveStart = (value, index) => /[A-Za-z]/.test(value[index] || '') - && !/[A-Za-z0-9]/.test(value[index - 1] || '') - && value[index + 1] === ':' - && /[\\/]/.test(value[index + 2] || ''); - const isBackslashUncStart = (value, index) => { - if (value[index] !== '\\' || value[index + 1] !== '\\' || value[index - 1] === '\\') return false; - let cursor = index + 2; - while (value[cursor] === '\\') cursor++; - if (value[cursor] === '?') return true; - const separator = value.indexOf('\\', cursor); - return separator > cursor; - }; - const isSlashUncStart = (value, index) => value[index] === '/' - && value[index + 1] === '/' - && !/[:/]/.test(value[index - 1] || '') - && !/[\/]/.test(value[index + 2] || '') - && value.indexOf('/', index + 2) > index + 2; - let out = ''; - let index = 0; - while (index < text.length) { - if (!isDriveStart(text, index) && !isBackslashUncStart(text, index) && !isSlashUncStart(text, index)) { - out += text[index]; - index++; - continue; - } - let end = index; - while (end < text.length && !/[\r\n"'<>|]/.test(text[end])) end++; - const candidate = text.slice(index, end).replace(/\s+(?:trigger|error|outcome|hoster|attempt|status|code)=.*$/i, ''); - out += ''; - index += candidate.length; - } - return out; -} - function redactLogText(text, secrets) { if (typeof text !== 'string' || !text) return text; - let out = redactConfiguredSecrets(text, secrets); - out = redactHtmlCredentialFields(out) - .replace(/("(?:file|fileName|stagedFile|sourceFile|targetFile|path|[A-Za-z0-9_]*Path)"\s*:\s*")[^"]*(")/gi, '$1$2'); - out = redactAbsolutePaths(out) + let out = text; + if (Array.isArray(secrets)) { + for (const s of secrets) { + if (typeof s === 'string' && s.length >= 6) out = out.split(s).join(REDACTED); + } + } + out = out + .replace(/("(?:file|fileName|stagedFile|sourceFile|targetFile|path|[A-Za-z0-9_]*Path)"\s*:\s*")[^"]*(")/gi, '$1$2') + .replace(/\b[A-Za-z]:(?:\\+|\/+)[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/gi, '') + .replace(/\\{2,}[A-Za-z0-9._$-]+\\+[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/g, '') .replace(/https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+/gi, 'https://discord.com/api/webhooks/' + REDACTED) .replace(/(\/\/[^\s/:@]+:)[^\s/@]+(@)/g, '$1' + REDACTED + '$2') - .replace(/(\b(?:proxy-)?authorization\s*:\s*)[^\r\n]*/gi, '$1' + REDACTED) - .replace(/(\b(?:set-cookie|cookie)\s*:\s*)[^\r\n]*/gi, '$1' + REDACTED) - .replace(/(\b(?:bearer|basic)\s+)[A-Za-z0-9._~+\-/=]+/gi, '$1' + REDACTED) + .replace(/(authorization:\s*(?:bearer|basic)\s+)\S+/gi, '$1' + REDACTED) + .replace(/\bbearer\s+[A-Za-z0-9._\-/+]{16,}/gi, 'bearer ' + REDACTED) .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}/g, REDACTED) - .replace(/([?&](?:api[_-]?key|key|token|access[_-]?token|refresh[_-]?token|auth|authorization|password|pass|cookie|session(?:[_-]?id)?)=)[^\s&#"'`]+/gi, '$1' + REDACTED) - .replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|authorization|cookie|(?:access|refresh|auth|session)[_-]?token|token|session[_-]?id|sessionid|session|sess[_-]?id|sessid|sess)"?\s*[:=]\s*)(["'])(.*?)\2/gi, `$1$2${REDACTED}$2`) - .replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|authorization|cookie|(?:access|refresh|auth|session)[_-]?token|token|session[_-]?id|sessionid|session|sess[_-]?id|sessid|sess)"?\s*[:=]\s*)(?!["'])([^\s,;}\]\r\n]+)/gi, '$1' + REDACTED); + .replace(/([?&](?:api_?key|key|token|access_token|password|pass)=)[^\s&"'`]+/gi, '$1' + REDACTED) + .replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|(?:access|refresh|auth|session)[_-]?token|token|sessionid|session)"?\s*[:=]\s*"?)[A-Za-z0-9._\-/+]{8,}/gi, '$1' + REDACTED) + .replace(/(\bset-cookie:|\bcookie:)\s*\S[^\n]*/gi, '$1 ' + REDACTED) + .replace(/(\bsess(?:_?id)?\b["'=:\s]+)[A-Za-z0-9._\-]{8,}/gi, '$1' + REDACTED); return out; } function valueScrub(value, secrets) { if (value === null || value === undefined) return value; - if (typeof value === 'string') return redactLogText(value, secrets); - if (Array.isArray(value)) return value.map(entry => valueScrub(entry, secrets)); - if (typeof value === 'object') { - const out = {}; - for (const [key, entry] of Object.entries(value)) out[redactLogText(key, secrets)] = valueScrub(entry, secrets); - return out; + const json = JSON.stringify(value); + let scrubbed = json; + if (Array.isArray(secrets)) { + for (const s of secrets) { + if (typeof s === 'string' && s.length >= 6) scrubbed = scrubbed.split(s).join(REDACTED); + } } - return value; + return JSON.parse(scrubbed); } function collectFile(filePath, label, maxBytes, options) { @@ -201,18 +101,18 @@ function collectFile(filePath, label, maxBytes, options) { function buildSupportBundleText({ header, sanitizedConfig, files, secrets }) { const parts = []; - parts.push('=== Multi Hoster Uploader Support Bundle ===\n'); + parts.push('=== Multi-Hoster-Upload Support Bundle ===\n'); if (header && typeof header === 'object') { for (const [k, v] of Object.entries(header)) parts.push(`${k}: ${v}\n`); } parts.push('\n'); parts.push('=== Config (sanitized — password/apiKey/token/cookie/sessionId redacted) ===\n'); - parts.push(JSON.stringify(sanitizedConfig, null, 2)); + parts.push(redactLogText(JSON.stringify(sanitizedConfig, null, 2), secrets)); parts.push('\n\n'); for (const f of (files || [])) { - parts.push(collectFile(f.path, f.label || 'log', f.maxBytes, { includePath: false })); + parts.push(redactLogText(collectFile(f.path, f.label || f.path, f.maxBytes, { includePath: false }), secrets)); } - return redactLogText(parts.join(''), secrets); + return parts.join(''); } module.exports = { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, buildSupportBundleText, CRED_KEYS, REDACTED }; diff --git a/lib/updater.js b/lib/updater.js index 733084c..f540b4a 100644 --- a/lib/updater.js +++ b/lib/updater.js @@ -61,10 +61,6 @@ function findLatestYml(assets) { return assets.find(a => /^latest\.yml$/i.test(a.name)) || null; } -function normalizeInstallerName(value) { - return path.basename(String(value || '')).toLowerCase().replace(/[ ._]+/g, ''); -} - async function fetchJson(url, signal) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT); @@ -102,7 +98,7 @@ async function fetchGithubReleaseNotes(remoteVersion, fallback = '', fetchImpl = headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', - 'User-Agent': 'Multi-Hoster-Uploader' + 'User-Agent': 'Multi-Hoster-Upload' } }); if (!response.ok) return String(fallback || ''); @@ -184,7 +180,7 @@ async function parseLatestYml(url, expected = {}, fetchImpl = fetch) { if (expected.version && version !== expected.version) { throw new Error('Prüfsummen-Metadaten gehören zu einer anderen Version'); } - if (expected.assetName && normalizeInstallerName(assetPath) !== normalizeInstallerName(expected.assetName)) { + if (expected.assetName && path.basename(assetPath) !== path.basename(expected.assetName)) { throw new Error('Prüfsummen-Metadaten gehören nicht zum ausgewählten Installer'); } if (expected.assetSize && size !== Number(expected.assetSize)) { diff --git a/lib/upload-audit.js b/lib/upload-audit.js index 9fa1f6c..7d96d38 100644 --- a/lib/upload-audit.js +++ b/lib/upload-audit.js @@ -1,31 +1,10 @@ const nodePath = require('path'); -const { formatUploadPlanLogLine } = require('./upload-log'); function getUploadAuditLogPath(uploadLogPath, pathApi = nodePath) { if (typeof uploadLogPath !== 'string' || !uploadLogPath.trim()) return null; return pathApi.join(pathApi.dirname(uploadLogPath), 'upload-audit.log'); } -async function appendDurably(fs, targetPath, line) { - const handle = await fs.promises.open(targetPath, 'a'); - let appendError = null; - let closeError = null; - try { - await handle.appendFile(line, 'utf-8'); - await handle.sync(); - } catch (error) { - appendError = error; - } - try { - await handle.close(); - } catch (error) { - closeError = error; - } - if (appendError && closeError) throw new AggregateError([appendError, closeError], 'Audit append and close failed'); - if (appendError) throw appendError; - if (closeError) throw closeError; -} - function createUploadAuditWriter(options) { const source = options && typeof options === 'object' ? options : {}; const fs = source.fs; @@ -33,47 +12,39 @@ function createUploadAuditWriter(options) { const resolveUploadLogTarget = source.resolveUploadLogTarget; const rotateLogFile = typeof source.rotateLogFile === 'function' ? source.rotateLogFile : () => {}; const invalidateUploadLogTarget = typeof source.invalidateUploadLogTarget === 'function' ? source.invalidateUploadLogTarget : () => {}; - const persistFallbackLogPath = typeof source.persistFallbackLogPath === 'function' ? source.persistFallbackLogPath : async () => false; + const persistFallbackLogPath = typeof source.persistFallbackLogPath === 'function' ? source.persistFallbackLogPath : async () => {}; const reportError = typeof source.reportError === 'function' ? source.reportError : () => {}; const retryDelays = Array.isArray(source.retryDelays) && source.retryDelays.length > 0 ? source.retryDelays : [0, 100, 250]; const maxBytes = Number.isFinite(source.maxBytes) ? source.maxBytes : 10 * 1024 * 1024; const maxBackups = Number.isFinite(source.maxBackups) ? source.maxBackups : 2; let activePath = null; - if (!fs || !fs.promises || typeof fs.promises.open !== 'function' || typeof resolveUploadLogTarget !== 'function') { + if (!fs || !fs.promises || typeof fs.promises.appendFile !== 'function' || typeof resolveUploadLogTarget !== 'function') { throw new TypeError('createUploadAuditWriter requires fs and resolveUploadLogTarget'); } async function append(line, label) { - const excludedPaths = new Set(); + let excludedPath = null; for (const delay of retryDelays) { if (delay) await new Promise(resolve => setTimeout(resolve, delay)); - const uploadTarget = resolveUploadLogTarget(excludedPaths); - if (!uploadTarget || excludedPaths.has(uploadTarget.path)) continue; + const uploadTarget = resolveUploadLogTarget(excludedPath); const targetPath = uploadTarget && getUploadAuditLogPath(uploadTarget.path, path); if (!targetPath) continue; try { fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + rotateLogFile(targetPath, maxBytes, maxBackups); + await fs.promises.appendFile(targetPath, line, 'utf-8'); + activePath = targetPath; if (uploadTarget.isFallback) { - let persisted = false; try { - persisted = await persistFallbackLogPath(uploadTarget.path); + await persistFallbackLogPath(uploadTarget.path); } catch (error) { reportError('audit-fallback-persist', error); } - if (persisted !== true) { - excludedPaths.add(uploadTarget.path); - invalidateUploadLogTarget(); - reportError('audit-fallback-persist', new Error('Fallback log path could not be persisted')); - continue; - } } - rotateLogFile(targetPath, maxBytes, maxBackups); - await appendDurably(fs, targetPath, line); - activePath = targetPath; return true; } catch (error) { - excludedPaths.add(uploadTarget.path); + excludedPath = uploadTarget.path; invalidateUploadLogTarget(); reportError(label, error); } @@ -84,27 +55,4 @@ function createUploadAuditWriter(options) { return { append, getActivePath: () => activePath }; } -function createUploadAuditEvents(writer, now = () => new Date()) { - if (!writer || typeof writer.append !== 'function') throw new TypeError('createUploadAuditEvents requires an audit writer'); - return { - appendSourceCleanup: event => writer.append(`# SOURCE-CLEANUP ${JSON.stringify(event)}\r\n`, 'source-cleanup'), - appendUploadPlan: (plan, mode) => writer.append(formatUploadPlanLogLine(now(), plan, mode), 'upload-plan') - }; -} - -async function runAfterDurableAudit(audit, action) { - let persisted = false; - try { - persisted = await audit(); - } catch {} - if (persisted !== true) return { ok: false }; - return { ok: true, value: await action() }; -} - -function getUploadAuditFailureMessage(language) { - return language === 'de' - ? 'Der Uploadplan konnte nicht dauerhaft protokolliert werden. Bitte prüfe den Log-Pfad und versuche es erneut.' - : 'The upload plan could not be recorded durably. Check the log path and try again.'; -} - -module.exports = { getUploadAuditLogPath, createUploadAuditWriter, createUploadAuditEvents, runAfterDurableAudit, getUploadAuditFailureMessage }; +module.exports = { getUploadAuditLogPath, createUploadAuditWriter }; diff --git a/lib/upload-confirmation.js b/lib/upload-confirmation.js index 05fd840..754a304 100644 --- a/lib/upload-confirmation.js +++ b/lib/upload-confirmation.js @@ -11,13 +11,13 @@ const HOSTER_RESULT_DOMAINS = { 'doodstream.com': ['doodstream.com', 'dood.to', 'dood.la', 'dood.so', 'dsvplay.com'] }; -function isExpectedHostUrl(value, expectedHost, allowHttp = false) { +function isExpectedHostUrl(value, expectedHost) { if (typeof value !== 'string' || value.trim() === '') return false; try { const url = new URL(value); const hostname = url.hostname.toLowerCase(); const acceptedDomains = HOSTER_RESULT_DOMAINS[expectedHost] || [expectedHost]; - return (url.protocol === 'https:' || (allowHttp && url.protocol === 'http:')) + return (url.protocol === 'http:' || url.protocol === 'https:') && acceptedDomains.some(domain => hostname === domain || hostname.endsWith(`.${domain}`)); } catch { return false; @@ -32,15 +32,25 @@ function getUrlHost(value) { } } -function selectPublicUploadUrl(result) { - for (const value of [result?.download_url, result?.embed_url]) { - if (typeof value !== 'string' || value.trim() === '') continue; - try { - const url = new URL(value.trim()); - if (url.protocol === 'https:') return url.href; - } catch {} - } - return ''; +function normalizeDoodstreamUrl(value) { + if (typeof value !== 'string' || value.trim() === '') return value; + try { + const url = new URL(value); + const hostname = url.hostname.toLowerCase(); + if (hostname !== 'doodstream.com' && HOSTER_RESULT_DOMAINS['doodstream.com'].includes(hostname)) { + url.hostname = 'doodstream.com'; + return url.toString(); + } + } catch {} + return value; +} + +function normalizeConfirmedResult(result, hoster) { + if (hoster !== 'doodstream.com') return result; + const downloadUrl = normalizeDoodstreamUrl(result.download_url); + const embedUrl = normalizeDoodstreamUrl(result.embed_url); + if (downloadUrl === result.download_url && embedUrl === result.embed_url) return result; + return { ...result, download_url: downloadUrl, embed_url: embedUrl }; } function assertUploadConfirmation(result, hoster) { @@ -51,18 +61,10 @@ function assertUploadConfirmation(result, hoster) { && value !== undefined && !(typeof value === 'string' && value.trim() === '') )); - if (SUPPORTED_HOSTERS.has(expectedHost) && FILE_CODE_PATTERN.test(fileCode)) { - if (expectedHost === 'doodstream.com' && urls.every(value => isExpectedHostUrl(value, expectedHost, true))) { - return { - ...result, - file_code: fileCode, - download_url: `https://doodstream.com/d/${fileCode}`, - embed_url: `https://doodstream.com/e/${fileCode}` - }; - } - if (urls.length > 0 && urls.every(value => isExpectedHostUrl(value, expectedHost))) { - return fileCode === result.file_code ? result : { ...result, file_code: fileCode }; - } + if (SUPPORTED_HOSTERS.has(expectedHost) + && FILE_CODE_PATTERN.test(fileCode) + && urls.every(value => isExpectedHostUrl(value, expectedHost))) { + return normalizeConfirmedResult(result, expectedHost); } const error = new Error(`Upload zu ${hoster || 'unbekanntem Hoster'} wurde nicht bestätigt`); error.diagnostic = { @@ -74,4 +76,4 @@ function assertUploadConfirmation(result, hoster) { throw error; } -module.exports = { assertUploadConfirmation, selectPublicUploadUrl }; +module.exports = { assertUploadConfirmation }; diff --git a/lib/upload-diagnostics.js b/lib/upload-diagnostics.js index df406ce..019565f 100644 --- a/lib/upload-diagnostics.js +++ b/lib/upload-diagnostics.js @@ -9,28 +9,13 @@ function cleanText(value, limit = 320) { return '[URL]'; } }); - text = text.replace(/\b(authorization|proxy-authorization|cookie|set-cookie)\s*[:=]\s*[^,]+/gi, '$1=[redacted]'); 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]'; } - text = text.replace(/\b[A-Za-z0-9_-]{20,}\b/g, '[redacted]'); return text.slice(0, limit); } -function normalizeEndpointHost(value) { - const host = String(value || '').trim().toLowerCase(); - if (!host || host.length > 253 || !/^[a-z0-9.-]+$/.test(host)) return ''; - if (host.startsWith('.') || host.endsWith('.') || host.includes('..')) return ''; - return host; -} - -function normalizePhase(value) { - const phase = String(value || '').trim(); - if (/^[a-z0-9._:-]{1,80}$/i.test(phase)) return phase; - return cleanText(phase, 80); -} - 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 @@ -38,18 +23,9 @@ function normalizeFailureDetails(diagnostic) { : null; const contentType = cleanText(diagnostic.contentType, 120); const responseSnippet = cleanText(diagnostic.payloadSnippet, 320); - const phase = normalizePhase(diagnostic.phase); - const endpointHost = normalizeEndpointHost(diagnostic.safeEndpointHost); - const responseKind = ['empty', 'json', 'html', 'text'].includes(diagnostic.responseKind) - ? diagnostic.responseKind - : ''; const details = {}; - if (phase) details.phase = phase; if (httpStatus !== null) details.httpStatus = httpStatus; if (contentType) details.contentType = contentType; - if (endpointHost) details.endpointHost = endpointHost; - if (responseKind) details.responseKind = responseKind; - if (typeof diagnostic.retryable === 'boolean') details.retryable = diagnostic.retryable; if (responseSnippet) details.responseSnippet = responseSnippet; return Object.keys(details).length > 0 ? details : null; } diff --git a/lib/upload-manager.js b/lib/upload-manager.js index e68e11d..ee2b367 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -3,7 +3,7 @@ const path = require('path'); const { assertUploadConfirmation } = require('./upload-confirmation'); const fs = require('fs'); const crypto = require('crypto'); -const { uploadFile, prefetchBaseline, createRecoveryClaimRegistry, normalizeRecoveryTitle } = require('./hosters'); +const { uploadFile, prefetchBaseline } = require('./hosters'); const VidmolyUploader = require('./vidmoly-upload'); const VoeUploader = require('./voe-upload'); const DoodstreamUploader = require('./doodstream-upload'); @@ -12,7 +12,6 @@ const Semaphore = require('./semaphore'); const Throttle = require('./throttle'); const { probeFileHead } = require('./file-probe'); const { normalizeFailureDetails } = require('./upload-diagnostics'); -const { createUploadScheduleGate } = require('./upload-schedule'); const DEFAULT_SETTINGS = { retries: 3, @@ -29,7 +28,6 @@ class UploadManager extends EventEmitter { super(); this.hosterSettings = hosterSettings || {}; this.globalSettings = globalSettings || {}; - this.uploadScheduleGate = createUploadScheduleGate(this.globalSettings.uploadSchedule); this.accountPools = accountPools || {}; this.semaphores = {}; this.globalSemaphore = null; @@ -54,11 +52,6 @@ class UploadManager extends EventEmitter { this._suspectGoodAccounts = new Map(); // hoster -> accountId that accepted a suspect-class file this._doodApiKeyCache = new Map(); // accountId/username -> derived doodstream API key ('' = tried, none) this._baselineCache = new Map(); // hoster:apiKey -> Promise> (one fetch shared across all jobs in batch) - this._recoveryClaims = createRecoveryClaimRegistry(); - this._recoveryAuthModes = new Map(); - this._suspectResolutionGates = new Map(); - this._batchJobIds = new Set(); - this._batchTotal = 0; } updateAccountPools(accountPools) { @@ -75,9 +68,6 @@ class UploadManager extends EventEmitter { this._suspectGoodAccounts.clear(); this._doodApiKeyCache.clear(); this._baselineCache.clear(); - if (!this.running) this._recoveryClaims.clear(); - if (!this.running) this._recoveryAuthModes.clear(); - if (!this.running) this._clearSuspectResolutionGates(); } switchAccount(hoster, fallbackAccount) { @@ -140,41 +130,6 @@ class UploadManager extends EventEmitter { return true; } - _swapFailedAccount(task, jobId, fileName) { - if (!task.accountId || !this._failedAccounts.has(task.hoster + ':' + task.accountId)) return false; - const override = this._accountOverrides.get(task.hoster); - if (override && !this._failedAccounts.has(task.hoster + ':' + override.id)) { - this._rotLog('pre-job-swap', { - jobId, hoster: task.hoster, fileName, fromAccountId: task.accountId, toAccountId: override.id - }); - task.accountId = override.id; - task.username = override.username; - task.password = override.password; - task.apiKey = override.apiKey; - return true; - } - this._rotLog('pre-job-swap-blocked', { - jobId, hoster: task.hoster, fileName, accountId: task.accountId, - hasOverride: !!override, - overrideAlsoFailed: override ? this._failedAccounts.has(task.hoster + ':' + override.id) : false - }); - return false; - } - - async _coordinateAccountFailure(task, err, signal, jobId) { - if (!task.accountId || (err && err.remoteCommitUncertain === true)) return; - if (!this._shouldSkipRetryOnAccountError(err)) return; - const key = task.hoster + ':' + task.accountId; - if (this._failedAccounts.has(key)) return; - this._failedAccounts.set(key, true); - this._rotLog('mark-failed', { - jobId, hoster: task.hoster, fileName: path.basename(task.file), - accountId: task.accountId, lastError: err && err.message ? err.message : String(err) - }); - this.emit('account-failed', { hoster: task.hoster, accountId: task.accountId }); - await this._sleep(800, signal); - } - _rotLog(event, data) { this.emit('rot-log', { ts: Date.now(), event, ...data }); } @@ -202,7 +157,6 @@ class UploadManager extends EventEmitter { // which takes priority in _shouldSkipRetryOnAccountError. _isFileRejectedError(err) { if (!err) return false; - if (err.remoteCommitUncertain === true) return false; if (err.transientNetwork === true) return false; if (err.accountError === true) return false; // explicit account-level wins if (err.fileRejected === true) return true; @@ -307,7 +261,6 @@ class UploadManager extends EventEmitter { updateSettings(hosterSettings, globalSettings) { this.hosterSettings = hosterSettings || this.hosterSettings; this.globalSettings = globalSettings || this.globalSettings; - this.uploadScheduleGate.update(this.globalSettings.uploadSchedule); // Live-update semaphores for running uploads for (const [hoster, sem] of Object.entries(this.semaphores)) { const settings = this._getSettings(hoster); @@ -381,8 +334,6 @@ class UploadManager extends EventEmitter { async startBatch(tasks, opts = {}) { const pendingCancelledJobIds = new Set(this.pendingCancelledJobIds); const pendingCancelAll = this.pendingCancelAll; - this._batchJobIds = new Set(tasks.map((task) => task.jobId).filter(Boolean)); - this._batchTotal = tasks.length; this.pendingCancelledJobIds.clear(); this.pendingCancelAll = false; this.running = true; @@ -397,10 +348,6 @@ class UploadManager extends EventEmitter { for (const jobId of pendingCancelledJobIds) this.cancelledJobIds.add(jobId); this._doodApiKeyCache.clear(); // re-derive doodstream keys fresh each batch this._baselineCache.clear(); // re-fetch baselines per batch (a long batch could outlast remote-side relevance) - this._recoveryClaims.clear(); - this._recoveryClaims = createRecoveryClaimRegistry(); - this._recoveryAuthModes.clear(); - this._clearSuspectResolutionGates(); this.semaphores = {}; this.globalSemaphore = null; this.globalThrottle = null; @@ -441,7 +388,7 @@ class UploadManager extends EventEmitter { for (let j = i; j < end; j++) { const task = tasks[j]; if (!results.has(task.file)) { - results.set(task.file, { name: path.basename(task.file), fileKey: task.fileKey || null, size: 0, results: [] }); + results.set(task.file, { name: path.basename(task.file), size: 0, results: [] }); toStat.push(task.file); } } @@ -472,7 +419,7 @@ class UploadManager extends EventEmitter { this._emitStats(); const files = Array.from(results.values()); - const total = this._batchTotal; + const total = tasks.length; const succeeded = files.reduce((count, file) => count + file.results.filter((result) => result.status === 'done').length, 0); const skipped = files.reduce((count, file) => count + file.results.filter((result) => result.status === 'skipped').length, 0); @@ -481,21 +428,18 @@ class UploadManager extends EventEmitter { timestamp: new Date().toISOString(), total, succeeded, - failed: Math.max(0, total - succeeded - skipped), + failed: total - succeeded - skipped, skipped, files }; - this._recoveryClaims.clear(); - this._recoveryAuthModes.clear(); - this._clearSuspectResolutionGates(); - this._doodApiKeyCache.clear(); - this._baselineCache.clear(); this.emit('batch-done', summary); } async _runJob(task, results, batchSignal) { const settings = this._getSettings(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); @@ -515,6 +459,8 @@ class UploadManager extends EventEmitter { const { signal, cleanup: cleanupSignals } = this._combineSignals(batchSignal, jobAbortController.signal); this.jobAbortControllers.set(jobId, jobAbortController); + let hosterSlotAcquired = false; + let globalSlotAcquired = false; let finalResultRecorded = false; let finalStatus = 'error'; let lastError = null; @@ -527,7 +473,6 @@ class UploadManager extends EventEmitter { finalStatus = status; const result = { - jobId, hoster: task.hoster, status, error: payload.error || null, @@ -540,16 +485,12 @@ class UploadManager extends EventEmitter { embed_url: payload.result ? payload.result.embed_url || null : null, file_code: payload.result ? payload.result.file_code || null : null }; - if (payload.remoteCommitUncertain === true || payload.error?.remoteCommitUncertain === true || lastError?.remoteCommitUncertain === true) { - result.remoteCommitUncertain = true; - } results.get(task.file).results.push(result); }; const emitFinalStatus = (status, payload = {}) => { if (status === 'aborted' && this.abortController.signal.aborted) return; - const remoteCommitUncertain = payload.remoteCommitUncertain === true || payload.error?.remoteCommitUncertain === true || lastError?.remoteCommitUncertain === true; this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, jobId, status, @@ -563,8 +504,7 @@ class UploadManager extends EventEmitter { failureDetails: payload.failureDetails || lastFailureDetails, result: payload.result || null, attempt: payload.attempt || maxAttempts, - maxAttempts, - remoteCommitUncertain + maxAttempts }); }; @@ -601,6 +541,9 @@ class UploadManager extends EventEmitter { // queueJobs array; the first event it actually needs from main is the // 'getting-server' / 'uploading' transition for the jobs that the // semaphore lets through. + await hosterSemaphore.acquire(signal); + hosterSlotAcquired = true; + let fileProbe = null; try { fileProbe = await probeFileHead(task.file, 512); @@ -615,19 +558,48 @@ class UploadManager extends EventEmitter { headHex: fileProbe && fileProbe.headHex ? fileProbe.headHex.slice(0, 32) : null }); + if (globalSemaphore) { + await globalSemaphore.acquire(signal); + globalSlotAcquired = true; + } + + if (settings.timeIntervalSec > 0) { + await this._waitForInterval(task.hoster, settings.timeIntervalSec * 1000, signal); + } + // Pre-job-swap: if this account was marked failed WHILE this task was // waiting in the semaphore queue, jump straight to the override instead // of burning a guaranteed-to-fail upload attempt. Critical at scale: // with 500 queued jobs and 1 parallel slot, without this check every // job still hits the original dead account first. - this._swapFailedAccount(task, jobId, fileName); + if (task.accountId && this._failedAccounts.has(task.hoster + ':' + task.accountId)) { + const override = this._accountOverrides.get(task.hoster); + if (override && !this._failedAccounts.has(task.hoster + ':' + override.id)) { + this._rotLog('pre-job-swap', { + jobId, hoster: task.hoster, fileName, fromAccountId: task.accountId, toAccountId: override.id + }); + task.accountId = override.id; + task.username = override.username; + task.password = override.password; + task.apiKey = override.apiKey; + } else { + this._rotLog('pre-job-swap-blocked', { + jobId, hoster: task.hoster, fileName, accountId: task.accountId, + hasOverride: !!override, + overrideAlsoFailed: override ? this._failedAccounts.has(task.hoster + ':' + override.id) : false + }); + } + } // A previous file of at least this size already got a suspect rejection // on this exact account — skip the guaranteed-to-fail multi-GB upload // and go straight to the alternate-account walk below. - let memoSuspect = this._createSuspectMemoError(task, fileProbe, fileSize); - if (memoSuspect) { - this._beginSuspectResolution(task.hoster, jobId); + let memoSuspect = null; + if (fileProbe && fileProbe.isVideoLike === true && task.accountId + && this._suspectMemoBlocks(task.hoster, task.accountId, fileSize)) { + memoSuspect = new Error('Bekanntes Größen-Limit auf diesem Account (frühere verdächtige Ablehnung)'); + memoSuspect.fileRejected = true; + memoSuspect.suspectReject = true; lastError = memoSuspect; this._rotLog('suspect-memo-skip', { jobId, hoster: task.hoster, fileName, accountId: task.accountId, fileSize @@ -636,8 +608,6 @@ class UploadManager extends EventEmitter { const attemptsAllowed = memoSuspect ? 0 : maxAttempts; for (let attempt = 1; attempt <= attemptsAllowed; attempt++) { - await this._waitForUploadSchedule(signal); - this._throwIfUploadStartBlocked(signal); finalAttempt = attempt; if (signal.aborted || this.stopAfterActive) break; @@ -757,7 +727,9 @@ class UploadManager extends EventEmitter { } catch { /* progress callbacks must never throw — swallowing is correct, the stream keeps going */ } }; - const result = await this._executeUploadWithAdmission(task, progressCb, uploadSignalBundle.signal, throttle, fileProbe, fileSize, true, jobId); + const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle, fileProbe); + + if (signal.aborted || this.cancelledJobIds.has(jobId)) throw new Error('Aborted'); const elapsed = Math.round((Date.now() - jobStart) / 1000); this.sessionBytes += fileSize; @@ -794,11 +766,6 @@ class UploadManager extends EventEmitter { payloadSnippet: lastFailureDetails ? lastFailureDetails.responseSnippet || null : null }); } - if (err && err.remoteCommitUncertain === true) { - lastError = err; - break; - } - if (signal.aborted) { lastError = new Error('Abgebrochen'); break; @@ -864,16 +831,6 @@ class UploadManager extends EventEmitter { const wasStopped = this.stopAfterActive && !signal.aborted; const wasAborted = signal.aborted || this.cancelledJobIds.has(jobId); - if (lastError && lastError.remoteCommitUncertain === true) { - const cancelledUncertain = lastError.cancelledAfterUploadStart === true; - const status = cancelledUncertain ? 'aborted' : 'error'; - const error = cancelledUncertain - ? 'Abgebrochen; Remote-Status konnte nicht bestätigt werden' - : (lastError.message || 'Remote-Upload konnte nicht eindeutig bestätigt werden'); - emitFinalStatus(status, { error, remoteCommitUncertain: true }); - recordFinalResult(status, { error, remoteCommitUncertain: true }); - return; - } if (wasStopped || wasAborted) { const error = wasStopped ? 'Warteschlange angehalten' : 'Abgebrochen'; emitFinalStatus('aborted', { error }); @@ -907,6 +864,12 @@ class UploadManager extends EventEmitter { this._noteSuspectReject(task.hoster, task.accountId, fileSize); const alt = await this._trySuspectRejectAlternates(task, { uploadId, jobId, fileName, fileSize, settings, signal, fileProbe }); if (alt) { + if (signal.aborted || this.cancelledJobIds.has(jobId)) { + const error = 'Abgebrochen'; + emitFinalStatus('aborted', { error }); + recordFinalResult('aborted', { error }); + return; + } emitFinalStatus('done', { result: alt.result, speedKbs: alt.speedKbs, elapsed: alt.elapsed, attempt: 1 }); recordFinalResult('done', { result: alt.result }); return; @@ -1028,8 +991,6 @@ 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++) { - await this._waitForUploadSchedule(signal); - this._throwIfUploadStartBlocked(signal); finalAttempt = attempt; if (signal.aborted || this.stopAfterActive) break; if (attempt > 1) { @@ -1078,7 +1039,8 @@ class UploadManager extends EventEmitter { ? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } } : hosterThrottle || globalThrottle; - const result = await this._executeUploadWithAdmission(task, progressCb, signal, throttle, fileProbe, fileSize, true, jobId); + const result = await this._executeUpload(task, progressCb, signal, throttle, fileProbe); + if (signal.aborted || this.cancelledJobIds.has(jobId)) throw new Error('Aborted'); this.activeJobs.delete(uploadId); this.sessionBytes += fileSize; emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt }); @@ -1099,7 +1061,6 @@ class UploadManager extends EventEmitter { rotationRetry: true }); } - if (err && err.remoteCommitUncertain === true) break; if (signal.aborted || this.stopAfterActive) break; if (this._isFileRejectedError(err)) break; if (this._isHosterTransientError(err)) break; @@ -1111,16 +1072,6 @@ class UploadManager extends EventEmitter { const stoppedLate = this.stopAfterActive && !signal.aborted; const abortedLate = signal.aborted || this.cancelledJobIds.has(jobId); - if (lastError && lastError.remoteCommitUncertain === true) { - const cancelledUncertain = lastError.cancelledAfterUploadStart === true; - const status = cancelledUncertain ? 'aborted' : 'error'; - const error = cancelledUncertain - ? 'Abgebrochen; Remote-Status konnte nicht bestätigt werden' - : (lastError.message || 'Remote-Upload konnte nicht eindeutig bestätigt werden'); - emitFinalStatus(status, { error, remoteCommitUncertain: true }); - recordFinalResult(status, { error, remoteCommitUncertain: true }); - return; - } if (stoppedLate || abortedLate) { const error = stoppedLate ? 'Warteschlange angehalten' : 'Abgebrochen'; emitFinalStatus('aborted', { error }); @@ -1134,25 +1085,20 @@ class UploadManager extends EventEmitter { emitFinalStatus('error', { error }); recordFinalResult('error', { error }); } catch (err) { - if (err && err.remoteCommitUncertain === true) lastError = err; const wasStopped = this.stopAfterActive && !signal.aborted; - const remoteCommitUncertain = !!(err && err.remoteCommitUncertain === true); - const cancelledUncertain = remoteCommitUncertain && err.cancelledAfterUploadStart === true; - const error = remoteCommitUncertain - ? (cancelledUncertain ? 'Abgebrochen; Remote-Status konnte nicht bestätigt werden' : (err.message || 'Remote-Upload konnte nicht eindeutig bestätigt werden')) - : wasStopped + const error = wasStopped ? 'Warteschlange angehalten' : (signal.aborted || this.cancelledJobIds.has(jobId) ? 'Abgebrochen' : (err && err.message ? err.message : 'Unbekannter Fehler')); - const status = remoteCommitUncertain - ? (cancelledUncertain ? 'aborted' : 'error') - : (signal.aborted || this.cancelledJobIds.has(jobId) || wasStopped ? 'aborted' : 'error'); - emitFinalStatus(status, { error, remoteCommitUncertain }); - recordFinalResult(status, { error, remoteCommitUncertain }); + const status = signal.aborted || this.cancelledJobIds.has(jobId) || wasStopped ? 'aborted' : 'error'; + emitFinalStatus(status, { error }); + recordFinalResult(status === 'error' ? 'error' : 'aborted', { error }); } finally { - this._endSuspectResolution(task.hoster, jobId); this.activeJobs.delete(uploadId); this.jobAbortControllers.delete(jobId); cleanupSignals(); + // Release in reverse order of acquire (global first, then hoster) + if (globalSlotAcquired && globalSemaphore) globalSemaphore.release(); + if (hosterSlotAcquired) hosterSemaphore.release(); this.emit('job-settled', { jobId, sourceCleanupToken: task.sourceCleanupToken || null, @@ -1188,8 +1134,6 @@ class UploadManager extends EventEmitter { }); continue; } - await this._waitForUploadSchedule(signal); - this._throwIfUploadStartBlocked(signal); attempted += 1; this._rotLog('suspect-reject-alt', { jobId, hoster: task.hoster, fileName, fromAccountId: task.accountId, toAccountId: account.id @@ -1238,7 +1182,8 @@ class UploadManager extends EventEmitter { ? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } } : hosterThrottle || globalThrottle; try { - const result = await this._executeUploadWithAdmission(task, progressCb, signal, throttle, fileProbe, fileSize, false, jobId); + const result = await this._executeUpload(task, progressCb, signal, throttle, fileProbe); + if (signal.aborted || this.cancelledJobIds.has(jobId)) throw new Error('Aborted'); this.activeJobs.delete(uploadId); this.sessionBytes += fileSize; this._suspectGoodAccounts.set(task.hoster, account.id); @@ -1256,7 +1201,6 @@ class UploadManager extends EventEmitter { suspectAlternate: true }); } - if (err && err.remoteCommitUncertain === true) throw err; if (signal.aborted || this.stopAfterActive) break; if (err && err.suspectReject === true) { this._noteSuspectReject(task.hoster, account.id, fileSize); @@ -1289,325 +1233,20 @@ class UploadManager extends EventEmitter { return null; } - async _executeUploadWithAdmission(task, progressCb, signal, throttle, fileProbe, fileSize, coordinateAccountFailure = true, jobId = task.jobId) { - while (true) { - await this._waitForUploadSchedule(signal); - this._throwIfUploadStartBlocked(signal); - const context = await this._createRecoveryContext(task); - this._throwIfUploadStartBlocked(signal); - let retryAdmission = false; - const operation = async () => { - this._throwIfUploadStartBlocked(signal); - const hosterSemaphore = this._getSemaphore(task.hoster); - const globalSemaphore = this._getGlobalSemaphore(); - let hosterSlotAcquired = false; - let globalSlotAcquired = false; - const releaseSlots = () => { - if (globalSlotAcquired && globalSemaphore) globalSemaphore.release(); - if (hosterSlotAcquired) hosterSemaphore.release(); - globalSlotAcquired = false; - hosterSlotAcquired = false; - }; - const acquireSlots = async () => { - await hosterSemaphore.acquire(signal); - hosterSlotAcquired = true; - this._throwIfUploadStartBlocked(signal); - if (globalSemaphore) { - await globalSemaphore.acquire(signal); - globalSlotAcquired = true; - this._throwIfUploadStartBlocked(signal); - } - }; - try { - await this._waitForSuspectResolution(task.hoster, jobId, signal); - this._throwIfUploadStartBlocked(signal); - const accountFailed = !!task.accountId && this._failedAccounts.has(task.hoster + ':' + task.accountId); - if (accountFailed) { - if (this._swapFailedAccount(task, jobId, path.basename(task.file))) { - retryAdmission = true; - return null; - } - if (!coordinateAccountFailure) { - const error = new Error('Account became unavailable before upload'); - error.accountUnavailable = true; - throw error; - } - } - const settings = this._getSettings(task.hoster); - if (settings.timeIntervalSec > 0) { - await this._waitForInterval(task.hoster, settings.timeIntervalSec * 1000, signal, acquireSlots); - } else { - await acquireSlots(); - } - this._throwIfUploadStartBlocked(signal); - if (this._hasForeignSuspectResolution(task.hoster, jobId)) { - releaseSlots(); - await this._waitForSuspectResolution(task.hoster, jobId, signal); - this._throwIfUploadStartBlocked(signal); - retryAdmission = true; - return null; - } - const lateMemoSuspect = coordinateAccountFailure - ? this._createSuspectMemoError(task, fileProbe, fileSize) - : null; - if (lateMemoSuspect) { - this._rotLog('suspect-memo-skip', { - jobId, - hoster: task.hoster, - fileName: path.basename(task.file), - accountId: task.accountId, - fileSize - }); - this._beginSuspectResolution(task.hoster, jobId); - throw lateMemoSuspect; - } - if (!this.uploadScheduleGate.evaluate().allowed) { - releaseSlots(); - await this._waitForUploadSchedule(signal); - this._throwIfUploadStartBlocked(signal); - retryAdmission = true; - return null; - } - try { - this._throwIfUploadStartBlocked(signal); - return await this._executeUpload(task, progressCb, signal, throttle, fileProbe, context); - } catch (err) { - if (coordinateAccountFailure && err && err.suspectReject === true) { - this._beginSuspectResolution(task.hoster, jobId); - } - if (err && err.remoteCommitUncertain === true) { - if (context.recoveryClaim) throw context.recoveryClaim.markUncertain(err); - throw err; - } - if (coordinateAccountFailure) await this._coordinateAccountFailure(task, err, signal, jobId); - this._throwIfUploadStartBlocked(signal); - throw err; - } - } finally { - releaseSlots(); - } - }; - const result = context.recoveryClaim - ? await context.recoveryClaim.runExclusive(operation, signal) - : await operation(); - if (retryAdmission) { - this._throwIfUploadStartBlocked(signal); - continue; - } - return result; - } + async _executeUpload(task, progressCb, signal, throttle, fileProbe) { + const result = await this._executeUploadUnchecked(task, progressCb, signal, throttle, fileProbe); + return assertUploadConfirmation(result, task.hoster); } - _throwIfUploadStartBlocked(signal) { - if (signal && signal.aborted) { - const error = new Error('Aborted'); - error.name = 'AbortError'; - throw error; - } - if (this.stopAfterActive) { - const error = new Error('Warteschlange angehalten'); - error.stopAfterActive = true; - throw error; - } - } - - _waitForUploadSchedule(signal) { - return this.uploadScheduleGate.wait(signal, () => this._throwIfUploadStartBlocked(signal)); - } - - _createSuspectMemoError(task, fileProbe, fileSize) { - if (!fileProbe || fileProbe.isVideoLike !== true || !task.accountId) return null; - if (!this._suspectMemoBlocks(task.hoster, task.accountId, fileSize)) return null; - const error = new Error('Bekanntes Größen-Limit auf diesem Account (frühere verdächtige Ablehnung)'); - error.fileRejected = true; - error.suspectReject = true; - return error; - } - - _beginSuspectResolution(hoster, jobId) { - const existing = this._suspectResolutionGates.get(hoster); - if (existing) return existing.ownerJobId === jobId; - let release; - const promise = new Promise(resolve => { - release = resolve; - }); - this._suspectResolutionGates.set(hoster, { ownerJobId: jobId, promise, release }); - return true; - } - - _hasForeignSuspectResolution(hoster, jobId) { - const gate = this._suspectResolutionGates.get(hoster); - return !!gate && gate.ownerJobId !== jobId; - } - - _waitForSuspectResolution(hoster, jobId, signal) { - const gate = this._suspectResolutionGates.get(hoster); - if (!gate || gate.ownerJobId === jobId) return Promise.resolve(); - if (!signal) return gate.promise; - if (signal.aborted) { - const error = new Error('Aborted'); - error.name = 'AbortError'; - return Promise.reject(error); - } - return new Promise((resolve, reject) => { - const onAbort = () => { - signal.removeEventListener('abort', onAbort); - const error = new Error('Aborted'); - error.name = 'AbortError'; - reject(error); - }; - signal.addEventListener('abort', onAbort, { once: true }); - gate.promise.then(() => { - signal.removeEventListener('abort', onAbort); - resolve(); - }); - }); - } - - _endSuspectResolution(hoster, jobId) { - const gate = this._suspectResolutionGates.get(hoster); - if (!gate || gate.ownerJobId !== jobId) return false; - this._suspectResolutionGates.delete(hoster); - gate.release(); - return true; - } - - _clearSuspectResolutionGates() { - for (const gate of this._suspectResolutionGates.values()) gate.release(); - this._suspectResolutionGates.clear(); - } - - async _executeUpload(task, progressCb, signal, throttle, fileProbe, context) { - const result = await this._executeUploadUnchecked(task, progressCb, signal, throttle, fileProbe, context); - let confirmed; - try { - confirmed = assertUploadConfirmation(result, task.hoster); - } catch (err) { - if (context.recoveryClaim) throw context.recoveryClaim.markUncertain(err); - throw err; - } - return confirmed; - } - - async _awaitUploadResult(resultPromise, signal) { - if (!signal) return resultPromise; - const createAbortUncertainty = () => { - const error = new Error('Abgebrochen; Remote-Status konnte nicht bestätigt werden'); - error.name = 'AbortError'; - error.remoteCommitUncertain = true; - error.cancelledAfterUploadStart = true; - return error; - }; - if (signal.aborted) { - void Promise.resolve(resultPromise).catch(() => {}); - throw createAbortUncertainty(); - } - let onAbort; - const abortPromise = new Promise((resolve, reject) => { - onAbort = () => { - reject(createAbortUncertainty()); - }; - signal.addEventListener('abort', onAbort, { once: true }); - }); - try { - return await Promise.race([resultPromise, abortPromise]); - } catch (error) { - if (!signal.aborted) throw error; - if (error && error.remoteCommitUncertain === true) { - error.cancelledAfterUploadStart = true; - throw error; - } - throw createAbortUncertainty(); - } finally { - signal.removeEventListener('abort', onAbort); - } - } - - async _createRecoveryContext(task) { - const fileName = path.basename(task.file); - if ((task.hoster === 'vidmoly.me' || task.hoster === 'voe.sx') && task.username) { - const accountIdentity = this._recoveryAccountIdentity(task); - return { - recoveryClaim: this._createRecoveryClaim(task, accountIdentity, fileName), - doodApiKey: null - }; - } - if (task.hoster === 'doodstream.com' && task.username) { - const doodApiKey = await this._resolveDoodstreamApiKey(task); - const accountIdentity = doodApiKey || this._recoveryAccountIdentity(task); - return { - recoveryClaim: this._createRecoveryClaim(task, accountIdentity, fileName), - doodApiKey - }; - } - if (task.hoster === 'byse.sx' || task.hoster === 'doodstream.com' || task.hoster === 'voe.sx') { - const accountIdentity = task.hoster === 'byse.sx' - ? task.apiKey - : (task.hoster === 'doodstream.com' ? task.apiKey : this._recoveryAccountIdentity(task)); - return { - recoveryClaim: this._createRecoveryClaim(task, accountIdentity, fileName), - doodApiKey: null - }; - } - return { recoveryClaim: null, doodApiKey: null }; - } - - _recoveryAccountIdentity(task, fallbackIdentity = null) { - for (const value of [task.accountId, task.apiKey, fallbackIdentity, task.username]) { - if (value !== null && value !== undefined && String(value).trim()) return value; - } - return ''; - } - - _createRecoveryClaim(task, accountIdentity, fileName) { - const accountClaim = this._recoveryClaims.forUpload(task.hoster, accountIdentity, fileName); - if (task.hoster !== 'doodstream.com' && task.hoster !== 'voe.sx') return accountClaim; - const hosterClaim = this._recoveryClaims.forUpload(task.hoster, 'mixed-auth-recovery-boundary', fileName); - const modeKey = `${task.hoster}\0${normalizeRecoveryTitle(fileName)}`; - let modeState = this._recoveryAuthModes.get(modeKey); - if (!modeState) { - modeState = new Set(); - this._recoveryAuthModes.set(modeKey, modeState); - } - const authMode = task.username ? 'login' : 'api'; - return { - has(code) { - return accountClaim.has(code); - }, - reserve(code) { - return accountClaim.reserve(code); - }, - markUncertain(error) { - hosterClaim.markUncertain(error); - return accountClaim.markUncertain(error); - }, - isUncertain() { - return hosterClaim.isUncertain() || accountClaim.isUncertain(); - }, - runExclusive(operation, signal) { - return hosterClaim.runExclusive( - async () => { - if (Array.from(modeState).some(mode => mode !== authMode)) { - const error = new Error('Gemischte Upload-Anmeldungen für denselben Remote-Titel wurden sicher blockiert'); - error.remoteCommitUncertain = true; - error.hosterTransient = true; - throw error; - } - const result = await accountClaim.runExclusive(operation, signal); - if (result !== null && result !== undefined) modeState.add(authMode); - return result; - }, - signal - ); - } - }; - } - - async _executeUploadUnchecked(task, progressCb, signal, throttle, fileProbe, context) { + async _executeUploadUnchecked(task, progressCb, signal, throttle, fileProbe) { if (task.hoster === 'vidmoly.me' && task.username) { - return this._executeRecoveryAwareLoginUpload(task, VidmolyUploader, progressCb, signal, throttle, context.recoveryClaim); + const vidmoly = new VidmolyUploader(); + await vidmoly.login(task.username, task.password); + return vidmoly.upload(task.file, progressCb, signal, throttle); } else if (task.hoster === 'voe.sx' && task.username) { - return this._executeRecoveryAwareLoginUpload(task, VoeUploader, progressCb, signal, throttle, context.recoveryClaim); + const voe = new VoeUploader(); + await voe.login(task.username, task.password); + return voe.upload(task.file, progressCb, signal, throttle); } else if (task.hoster === 'doodstream.com' && task.username) { // Login-path reliability fix: the web-form upload returns the filecode in // an HTML form that comes back empty for large files (doodstream backend @@ -1615,67 +1254,31 @@ class UploadManager extends EventEmitter { // session ONCE per batch and upload via the official API instead — it // returns result[0].filecode directly and has no empty-form failure mode. // Falls back to the web-form upload if no valid key can be derived. - const apiKey = context.doodApiKey; + const apiKey = await this._resolveDoodstreamApiKey(task); if (apiKey) { this._rotLog('doodstream-via-api', { accountId: task.accountId, fileName: path.basename(task.file) }); - return this._executeRecoveryAwareApiUpload('doodstream.com', task.file, apiKey, progressCb, signal, throttle, fileProbe, context.recoveryClaim); + return uploadFile('doodstream.com', task.file, apiKey, progressCb, signal, throttle, { + doodBaseline: await this._getBaseline('doodstream.com', apiKey, signal) + }); } this._rotLog('doodstream-via-web', { accountId: task.accountId, fileName: path.basename(task.file) }); const dood = new DoodstreamUploader(); await dood.login(task.username, task.password); - let result; - try { - result = await this._awaitUploadResult(dood.upload(task.file, progressCb, signal, throttle), signal); - } catch (err) { - if (context.recoveryClaim && this._isDoodstreamRemoteCommitUncertain(err)) { - throw context.recoveryClaim.markUncertain(err); - } - throw err; - } - if (result && result.file_code && !context.recoveryClaim.reserve(result.file_code)) { - const error = new Error('Doodstream Upload lieferte eine bereits zugeordnete Remote-Identität'); - error.remoteIdentityClaimed = true; - throw context.recoveryClaim.markUncertain(error); - } - return result; + return dood.upload(task.file, progressCb, signal, throttle); } else if (task.hoster === 'clouddrop.cc') { const clouddrop = new ClouddropUploader(task.apiKey); - return this._awaitUploadResult(clouddrop.upload(task.file, progressCb, signal, throttle), signal); + return clouddrop.upload(task.file, progressCb, signal, throttle); } else { - if (task.hoster === 'byse.sx' || task.hoster === 'doodstream.com' || task.hoster === 'voe.sx') { - return this._executeRecoveryAwareApiUpload(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, fileProbe, context.recoveryClaim); + const baselineOpts = {}; + if (task.hoster === 'byse.sx') { + baselineOpts.byseBaseline = await this._getBaseline('byse.sx', task.apiKey, signal); + if (fileProbe && fileProbe.ok !== false) baselineOpts.probeIsVideoLike = fileProbe.isVideoLike === true; } - return this._awaitUploadResult(uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, {}), signal); + if (task.hoster === 'doodstream.com') baselineOpts.doodBaseline = await this._getBaseline('doodstream.com', task.apiKey, signal); + return uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, baselineOpts); } } - async _executeRecoveryAwareLoginUpload(task, UploaderClass, progressCb, signal, throttle, recoveryClaim) { - const uploader = new UploaderClass(recoveryClaim); - await uploader.login(task.username, task.password); - return this._awaitUploadResult(uploader.upload(task.file, progressCb, signal, throttle), signal); - } - - async _executeRecoveryAwareApiUpload(hosterName, filePath, apiKey, progressCb, signal, throttle, fileProbe, recoveryClaim) { - const options = { recoveryClaim }; - if (hosterName === 'byse.sx') { - options.byseBaseline = await this._getBaseline(hosterName, apiKey, signal); - if (fileProbe && fileProbe.ok !== false) options.probeIsVideoLike = fileProbe.isVideoLike === true; - } else if (hosterName === 'doodstream.com') { - options.doodBaseline = await this._getBaseline(hosterName, apiKey, signal); - } - return this._awaitUploadResult(uploadFile(hosterName, filePath, apiKey, progressCb, signal, throttle, options), signal); - } - - _isDoodstreamRemoteCommitUncertain(error) { - if (!error || typeof error !== 'object') return false; - if (error.remoteCommitUncertain === true) return true; - if (error.accountError === true || error.fileRejected === true) return false; - const phase = error.diagnostic && error.diagnostic.phase; - if (phase === 'upload-request') return true; - return (phase === 'upload-response' || phase === 'upload-result-submit' || phase === 'upload-result') - && (error.hosterTransient === true || error.transientNetwork === true); - } - _getBaseline(hosterName, apiKey, signal) { if (!apiKey) return Promise.resolve(null); const key = `${hosterName}:${apiKey}`; @@ -1692,27 +1295,19 @@ class UploadManager extends EventEmitter { // so a 40-file batch logs in + derives ONCE, not per file). The empty-string // sentinel distinguishes "tried, none" from "not yet tried" (undefined). async _resolveDoodstreamApiKey(task) { - const accountId = task.accountId !== null && task.accountId !== undefined - ? String(task.accountId).normalize('NFKC').trim() - : ''; - const cacheKey = accountId - ? `account:${accountId}` - : `username:${String(task.username || '').normalize('NFKC').trim().toLowerCase()}`; + const cacheKey = task.accountId || task.username; const cached = this._doodApiKeyCache.get(cacheKey); - if (cached !== undefined) return (await cached) || null; + if (cached !== undefined) return cached || null; - const pending = (async () => { - try { - const probe = new DoodstreamUploader(); - await probe.login(task.username, task.password); - return (await probe.deriveApiKey()) || ''; - } catch { - return ''; - } - })(); - this._doodApiKeyCache.set(cacheKey, pending); - const key = await pending; - if (this._doodApiKeyCache.get(cacheKey) === pending) this._doodApiKeyCache.set(cacheKey, key); + let key = ''; + try { + const probe = new DoodstreamUploader(); + await probe.login(task.username, task.password); + key = (await probe.deriveApiKey()) || ''; + } catch { + key = ''; + } + this._doodApiKeyCache.set(cacheKey, key); return key || null; } @@ -1828,7 +1423,7 @@ class UploadManager extends EventEmitter { }); } - _waitForInterval(hoster, intervalMs, signal, acquireSlots) { + _waitForInterval(hoster, intervalMs, signal) { // Serialize interval waits per hoster so concurrent jobs queue up properly const prev = this.intervalLocks[hoster] || Promise.resolve(); const next = prev.then(async () => { @@ -1838,7 +1433,6 @@ class UploadManager extends EventEmitter { if (elapsed < intervalMs) { await this._sleep(intervalMs - elapsed, signal); } - await acquireSlots(); this.lastStartTime[hoster] = Date.now(); }); this.intervalLocks[hoster] = next.catch(() => {}); @@ -1854,18 +1448,16 @@ class UploadManager extends EventEmitter { const addResult = { added: 0, alreadyInBatchJobIds: [] }; for (const task of tasks) { // Skip if this job is already being processed (prevent duplicates) - if (task.jobId && this._batchJobIds.has(task.jobId)) { + if (task.jobId && this.jobAbortControllers.has(task.jobId)) { addResult.alreadyInBatchJobIds.push(task.jobId); continue; } - if (task.jobId) this._batchJobIds.add(task.jobId); const fileName = path.basename(task.file); if (!results.has(task.file)) { let size = 0; try { size = fs.statSync(task.file).size; } catch {} - results.set(task.file, { name: fileName, fileKey: task.fileKey || null, size, results: [] }); + results.set(task.file, { name: fileName, size, results: [] }); } - this._batchTotal++; this._additionalPromises.push(this._runJob(task, results, signal)); addResult.added++; } @@ -1886,7 +1478,6 @@ class UploadManager extends EventEmitter { finishAfterActive() { this.stopAfterActive = true; - this.uploadScheduleGate.wake(); } cancel() { diff --git a/lib/upload-recovery.js b/lib/upload-recovery.js deleted file mode 100644 index 7e568e4..0000000 --- a/lib/upload-recovery.js +++ /dev/null @@ -1,101 +0,0 @@ -(function exposeUploadRecovery(root, factory) { - const api = factory(); - if (typeof module !== 'undefined' && module.exports) module.exports = api; - if (root) root.UploadRecovery = api; -})(typeof window !== 'undefined' ? window : globalThis, () => { - const terminalStatuses = new Set(['done', 'error', 'skipped', 'aborted']); - - function buildTerminalJobSnapshots(summary) { - const snapshots = new Map(); - for (const file of Array.isArray(summary?.files) ? summary.files : []) { - for (const result of Array.isArray(file?.results) ? file.results : []) { - const jobId = typeof result?.jobId === 'string' ? result.jobId : ''; - const status = typeof result?.status === 'string' ? result.status : ''; - if (!jobId || !terminalStatuses.has(status)) continue; - const uploadResult = result.download_url || result.embed_url || result.file_code - ? { - download_url: result.download_url || null, - embed_url: result.embed_url || null, - file_code: result.file_code || null - } - : null; - snapshots.set(jobId, { - jobId, - status, - error: result.error || null, - failureDetails: result.failureDetails || null, - ...(result.remoteCommitUncertain === true ? { remoteCommitUncertain: true } : {}), - result: uploadResult - }); - } - } - return Array.from(snapshots.values()); - } - - function buildFailedUploadSummary(tasks, message, now = Date.now()) { - const files = new Map(); - for (const [index, task] of (Array.isArray(tasks) ? tasks : []).entries()) { - if (!task || typeof task !== 'object') continue; - const filePath = typeof task.file === 'string' ? task.file : ''; - const fileName = filePath.split(/[\\/]/).pop() || `upload-${index + 1}`; - const key = filePath || `${fileName}\0${index}`; - if (!files.has(key)) files.set(key, { - name: fileName, - ...(typeof task.fileKey === 'string' && task.fileKey ? { fileKey: task.fileKey } : {}), - size: 0, - results: [] - }); - files.get(key).results.push({ - jobId: typeof task.jobId === 'string' ? task.jobId : '', - hoster: typeof task.hoster === 'string' ? task.hoster : '', - status: 'error', - error: message, - failureDetails: null, - download_url: null, - embed_url: null, - file_code: null - }); - } - const grouped = Array.from(files.values()); - const failed = grouped.reduce((count, file) => count + file.results.length, 0); - return { - id: `start-error-${now}`, - timestamp: new Date(now).toISOString(), - total: failed, - succeeded: 0, - failed, - skipped: 0, - files: grouped, - error: message - }; - } - - function getRecoveryOutcome(job, recovery) { - const status = typeof job?.status === 'string' ? job.status : 'preview'; - const jobId = typeof job?.id === 'string' ? job.id : ''; - const terminal = Array.isArray(recovery?.terminalJobs) - ? recovery.terminalJobs.find(entry => entry?.jobId === jobId && terminalStatuses.has(entry.status)) - : null; - if (terminal) { - return { - status: terminal.status, - error: terminal.error || null, - failureDetails: terminal.failureDetails || null, - ...(terminal.remoteCommitUncertain === true ? { remoteCommitUncertain: true } : {}), - result: terminal.result || null, - ...(recovery?.historyPending === true ? { historyPending: true } : {}), - interrupted: false - }; - } - const interruptedIds = new Set(Array.isArray(recovery?.jobIds) ? recovery.jobIds.filter(Boolean) : []); - return { status, interrupted: interruptedIds.has(jobId) && !terminalStatuses.has(status) }; - } - - function resolveRemoteCommitUncertainty(previous, progress) { - if (progress?.remoteCommitUncertain === true) return true; - if (progress?.status === 'done') return false; - return previous === true; - } - - return { buildFailedUploadSummary, buildTerminalJobSnapshots, getRecoveryOutcome, resolveRemoteCommitUncertainty }; -}); diff --git a/lib/upload-schedule.js b/lib/upload-schedule.js deleted file mode 100644 index 08040c4..0000000 --- a/lib/upload-schedule.js +++ /dev/null @@ -1,167 +0,0 @@ -;(function initUploadSchedule(root, factory) { - const api = factory(); - if (typeof module === 'object' && module.exports) module.exports = api; - if (root) root.UploadSchedule = api; -})(typeof window !== 'undefined' ? window : globalThis, function createUploadSchedule() { -const WEEKDAY_ORDER = Object.freeze([1, 2, 3, 4, 5, 6, 0]); -const DEFAULT_UPLOAD_SCHEDULE = Object.freeze({ - enabled: false, - weekdays: Object.freeze([...WEEKDAY_ORDER]), - start: '00:00', - end: '23:59' -}); - -function normalizeTime(value) { - const text = typeof value === 'string' ? value.trim() : ''; - return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(text) ? text : ''; -} - -function timeMinutes(value) { - const normalized = normalizeTime(value); - if (!normalized) return null; - const [hours, minutes] = normalized.split(':').map(Number); - return hours * 60 + minutes; -} - -function normalizeUploadSchedule(value) { - const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; - const rawWeekdays = Array.isArray(source.weekdays) ? source.weekdays : DEFAULT_UPLOAD_SCHEDULE.weekdays; - const selected = new Set(rawWeekdays.map(Number).filter(day => Number.isInteger(day) && day >= 0 && day <= 6)); - return { - enabled: source.enabled === true, - weekdays: WEEKDAY_ORDER.filter(day => selected.has(day)), - start: normalizeTime(source.start ?? DEFAULT_UPLOAD_SCHEDULE.start), - end: normalizeTime(source.end ?? DEFAULT_UPLOAD_SCHEDULE.end) - }; -} - -function scheduleValidity(schedule) { - const startMinutes = timeMinutes(schedule.start); - const endMinutes = timeMinutes(schedule.end); - if (schedule.weekdays.length === 0) return { valid: false, reason: 'weekdays', startMinutes, endMinutes }; - if (startMinutes === null || endMinutes === null) return { valid: false, reason: 'time', startMinutes, endMinutes }; - if (startMinutes === endMinutes) return { valid: false, reason: 'equal-times', startMinutes, endMinutes }; - return { valid: true, reason: null, startMinutes, endMinutes }; -} - -function nextStartDate(schedule, now, startMinutes) { - const selected = new Set(schedule.weekdays); - for (let dayOffset = 0; dayOffset <= 7; dayOffset++) { - const candidate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOffset, 0, 0, 0, 0); - if (!selected.has(candidate.getDay())) continue; - candidate.setMinutes(startMinutes); - if (candidate.getTime() > now.getTime()) return candidate; - } - return null; -} - -function evaluateUploadSchedule(value, now = new Date()) { - const schedule = normalizeUploadSchedule(value); - const current = now instanceof Date ? new Date(now.getTime()) : new Date(now); - if (Number.isNaN(current.getTime())) throw new TypeError('Invalid schedule evaluation date'); - if (!schedule.enabled) { - return { schedule, enabled: false, valid: true, allowed: true, reason: null, nextStart: null }; - } - const validity = scheduleValidity(schedule); - if (!validity.valid) { - return { schedule, enabled: true, valid: false, allowed: false, reason: validity.reason, nextStart: null }; - } - const currentMinutes = current.getHours() * 60 + current.getMinutes(); - const currentDay = current.getDay(); - const selected = new Set(schedule.weekdays); - const { startMinutes, endMinutes } = validity; - const overnight = startMinutes > endMinutes; - const previousDay = (currentDay + 6) % 7; - const allowed = overnight - ? (selected.has(currentDay) && currentMinutes >= startMinutes) || (selected.has(previousDay) && currentMinutes < endMinutes) - : selected.has(currentDay) && currentMinutes >= startMinutes && currentMinutes < endMinutes; - return { - schedule, - enabled: true, - valid: true, - allowed, - reason: allowed ? null : 'closed', - nextStart: allowed ? null : nextStartDate(schedule, current, startMinutes) - }; -} - -function createAbortError() { - const error = new Error('Aborted'); - error.name = 'AbortError'; - return error; -} - -function createUploadScheduleGate(initial, options = {}) { - const now = typeof options.now === 'function' ? options.now : () => new Date(); - const setTimer = typeof options.setTimeout === 'function' ? options.setTimeout : setTimeout; - const clearTimer = typeof options.clearTimeout === 'function' ? options.clearTimeout : clearTimeout; - let schedule = normalizeUploadSchedule(initial); - let disposed = false; - const waiters = new Set(); - - const wake = () => { - for (const waiter of [...waiters]) waiter(); - }; - - const waitForWake = (state, signal) => new Promise((resolve, reject) => { - let settled = false; - let timer = null; - const finish = (error) => { - if (settled) return; - settled = true; - if (timer !== null) clearTimer(timer); - waiters.delete(onWake); - signal?.removeEventListener('abort', onAbort); - if (error) reject(error); - else resolve(); - }; - const onWake = () => finish(); - const onAbort = () => finish(createAbortError()); - if (disposed || signal?.aborted) { - finish(createAbortError()); - return; - } - waiters.add(onWake); - signal?.addEventListener('abort', onAbort, { once: true }); - if (state.nextStart) { - const delay = Math.max(1, Math.min(2147483647, state.nextStart.getTime() - now().getTime())); - timer = setTimer(onWake, delay); - } - }); - - return Object.freeze({ - evaluate() { - return evaluateUploadSchedule(schedule, now()); - }, - update(value) { - schedule = normalizeUploadSchedule(value); - wake(); - return this.evaluate(); - }, - async wait(signal, check) { - while (true) { - if (typeof check === 'function') check(); - if (disposed || signal?.aborted) throw createAbortError(); - const state = evaluateUploadSchedule(schedule, now()); - if (state.allowed) return state; - await waitForWake(state, signal); - } - }, - wake, - dispose() { - disposed = true; - wake(); - }, - get schedule() { - return normalizeUploadSchedule(schedule); - } - }); -} - -return { - DEFAULT_UPLOAD_SCHEDULE, - normalizeUploadSchedule, - evaluateUploadSchedule, - createUploadScheduleGate -}; -}); diff --git a/lib/upload-start-reservation.js b/lib/upload-start-reservation.js deleted file mode 100644 index 2e9c000..0000000 --- a/lib/upload-start-reservation.js +++ /dev/null @@ -1,28 +0,0 @@ -function createUploadStartReservation() { - let active = null; - - return { - acquire() { - if (active) return null; - const state = { cancelled: false, released: false }; - const lease = { - isCancelled: () => state.cancelled, - release() { - if (state.released) return; - state.released = true; - if (active && active.lease === lease) active = null; - } - }; - active = { lease, state }; - return lease; - }, - cancel() { - if (!active) return false; - active.state.cancelled = true; - return true; - }, - isActive: () => active !== null - }; -} - -module.exports = { createUploadStartReservation }; diff --git a/lib/vidmoly-upload.js b/lib/vidmoly-upload.js index fa2eec0..cd9b145 100644 --- a/lib/vidmoly-upload.js +++ b/lib/vidmoly-upload.js @@ -2,8 +2,6 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { request } = require('undici'); -const { createTransportError, sanitizeRemoteText } = require('./hoster-transport-error'); -const { normalizeRecoveryTitle } = require('./hosters'); const BASE_URL = 'https://vidmoly.me'; const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; @@ -15,9 +13,8 @@ const RESULT_POLL_DELAY_MS = 2000; * XFileSharing-based upload for Vidmoly (login + form upload) */ class VidmolyUploader { - constructor(recoveryClaim = null) { + constructor() { this.cookies = new Map(); - this.recoveryClaim = recoveryClaim; } _cookieHeader() { @@ -133,51 +130,14 @@ class VidmolyUploader { * removed. Returns an XFS-style session token + a transit-server URL. */ async getUploadParams() { - const endpoint = `${BASE_URL}/api/upload/config`; - let res; - try { - res = await this._fetch(endpoint); - } catch { - throw createTransportError('Vidmoly: Upload-Konfiguration konnte nicht geladen werden', { - phase: 'upload-config', - endpoint, - retryable: true, - transientNetwork: true - }); - } + const res = await this._fetch(`${BASE_URL}/api/upload/config`); const body = await res.text(); - const contentType = res.headers && typeof res.headers.get === 'function' - ? res.headers.get('content-type') - : null; - if (res.status < 200 || res.status >= 300) { - throw createTransportError('Vidmoly: Upload-Konfiguration konnte nicht geladen werden', { - phase: 'upload-config', - endpoint, - httpStatus: res.status, - contentType, - body, - retryable: res.status === 429 || res.status >= 500, - transientNetwork: res.status >= 500 - }); - } let payload = null; try { payload = JSON.parse(body); } catch { - throw createTransportError('Vidmoly: Upload-Konfiguration war kein JSON', { - phase: 'upload-config', - endpoint, - httpStatus: res.status, - contentType, - body - }); + throw new Error('Vidmoly: /api/upload/config lieferte kein JSON — evtl. nicht eingeloggt?'); } if (!payload || !payload.sess_id || !payload.upload_url) { - throw createTransportError('Vidmoly: Upload-Konfiguration war unvollständig', { - phase: 'upload-config', - endpoint, - httpStatus: res.status, - contentType, - body - }); + throw new Error('Vidmoly: /api/upload/config unvollständig (sess_id/upload_url fehlt)'); } return { uploadUrl: payload.upload_url, @@ -194,14 +154,7 @@ class VidmolyUploader { async upload(filePath, onProgress, signal, throttle) { const fileName = path.basename(filePath); const fileSize = fs.statSync(filePath).size; - let baselineCodes = null; - let baselineError = null; - try { - baselineCodes = await this._captureVmFileCodes(); - } catch (err) { - if (signal && signal.aborted) throw err; - baselineError = err; - } + const baselineCodes = await this._captureVmFileCodes(); const { uploadUrl, params, fileFieldName } = await this.getUploadParams(); @@ -258,34 +211,21 @@ class VidmolyUploader { const targetUrl = uploadUrl + (uploadUrl.includes('?') ? '&' : '?') + 'X-Progress-ID=' + progressId; // Browsers don't send vidmoly.me cookies across origins, so we don't either. - let uploadResponse; - try { - uploadResponse = await request(targetUrl, { - method: 'POST', - body: generate(), - signal, - headers: { - 'User-Agent': USER_AGENT, - 'Accept': '*/*', - 'Origin': BASE_URL, - 'Referer': `${BASE_URL}/`, - 'Content-Type': `multipart/form-data; boundary=${boundary}`, - 'Content-Length': String(totalSize) - }, - headersTimeout: UPLOAD_TIMEOUT, - bodyTimeout: UPLOAD_TIMEOUT - }); - } catch (err) { - const error = signal && signal.aborted ? err : createTransportError('Vidmoly Upload konnte nicht übertragen werden', { - phase: 'upload-request', - endpoint: targetUrl, - retryable: true, - transientNetwork: true - }); - throw this._markRemoteCommitUncertain(error); - } - - const { body, statusCode, headers } = uploadResponse; + const { body, statusCode, headers } = await request(targetUrl, { + method: 'POST', + body: generate(), + signal, + headers: { + 'User-Agent': USER_AGENT, + 'Accept': '*/*', + 'Origin': BASE_URL, + 'Referer': `${BASE_URL}/`, + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': String(totalSize) + }, + headersTimeout: UPLOAD_TIMEOUT, + bodyTimeout: UPLOAD_TIMEOUT + }); this._parseCookiesFromHeaders(headers || {}); @@ -296,34 +236,13 @@ class VidmolyUploader { // Always drain the original body to prevent connection leak try { await body.text(); } catch {} if (location) { - try { - const resultRes = await this._fetch(new URL(location, uploadUrl).href); - resultHtml = await resultRes.text(); - } catch (err) { - throw this._markRemoteCommitUncertain(err); - } + const resultRes = await this._fetch(new URL(location, uploadUrl).href); + resultHtml = await resultRes.text(); } else { resultHtml = ''; } } else { - try { - resultHtml = await body.text(); - } catch (err) { - throw this._markRemoteCommitUncertain(err); - } - } - - if (statusCode >= 400) { - const error = createTransportError('Vidmoly Upload fehlgeschlagen', { - phase: 'upload-response', - endpoint: targetUrl, - httpStatus: statusCode, - contentType: headers && headers['content-type'], - body: resultHtml, - retryable: statusCode === 429 || statusCode >= 500, - transientNetwork: statusCode >= 500 - }); - throw statusCode >= 500 ? this._markRemoteCommitUncertain(error) : error; + resultHtml = await body.text(); } // Try JSON first. The current transit server returns @@ -348,69 +267,43 @@ class VidmolyUploader { if (urls) return urls; } if (json.status && !/ok/i.test(json.status) && json.msg) { - throw createTransportError(`Vidmoly Upload abgelehnt: ${sanitizeRemoteText(json.msg)}`, { - phase: 'upload-result', - endpoint: targetUrl, - httpStatus: statusCode, - contentType: 'application/json', - body: resultHtml - }); + throw new Error(`Vidmoly Upload abgelehnt: ${json.msg}`); } } catch (err) { - if (err && err.diagnostic) throw err; + if (err && /Vidmoly Upload abgelehnt/.test(err.message)) throw err; } try { return this._parseUploadResult(resultHtml); } catch (primaryErr) { - if (primaryErr && primaryErr.remoteIdentityClaimed === true) throw primaryErr; - if (baselineCodes) { - try { - const fallback = await this._resolveUploadedFileFromVmApi(fileName, baselineCodes, signal); - if (fallback) return fallback; - } catch (err) { - throw this._markRemoteCommitUncertain(err); - } - } - if (baselineError) { - baselineError.hosterTransient = true; - throw this._markRemoteCommitUncertain(baselineError); - } - throw this._markRemoteCommitUncertain(primaryErr); + const fallback = await this._resolveUploadedFileFromVmApi(fileName, baselineCodes, signal); + if (fallback) return fallback; + throw primaryErr; } } _normalizeTitle(value) { - return normalizeRecoveryTitle(value); + return String(value || '') + .toLowerCase() + .normalize('NFKD') + .replace(/[^a-z0-9]+/g, ''); } - _markRemoteCommitUncertain(error) { - if (this.recoveryClaim && typeof this.recoveryClaim.markUncertain === 'function') { - return this.recoveryClaim.markUncertain(error); - } - const uncertainError = error && typeof error === 'object' - ? error - : new Error('Vidmoly Upload-Ergebnis ist unsicher'); - uncertainError.remoteCommitUncertain = true; - uncertainError.hosterTransient = true; - return uncertainError; + _scoreVmCandidate(file, expectedTitle) { + if (!file || !file.file_code) return -1; + if (!expectedTitle) return 0; + + const title = this._normalizeTitle(file.full_title || file.title_txt || ''); + if (!title) return -1; + if (title === expectedTitle) return 120; + if (title.startsWith(expectedTitle) || expectedTitle.startsWith(title)) return 90; + if (title.includes(expectedTitle) || expectedTitle.includes(title)) return 70; + return 0; } - _buildUrlsFromCode(fileCode, phase = 'upload-result') { + _buildUrlsFromCode(fileCode) { const code = String(fileCode || '').trim(); if (!code) return null; - if (this.recoveryClaim - && typeof this.recoveryClaim.reserve === 'function' - && !this.recoveryClaim.reserve(code)) { - const error = createTransportError('Vidmoly Upload-Ergebnis ist bereits einem anderen Upload zugeordnet', { - phase, - endpoint: BASE_URL, - retryable: true, - hosterTransient: true - }); - error.remoteIdentityClaimed = true; - throw this._markRemoteCommitUncertain(error); - } return { download_url: `${BASE_URL}/w/${code}`, @@ -420,15 +313,19 @@ class VidmolyUploader { } async _captureVmFileCodes() { - const files = await this._fetchVmList('recovery-baseline'); - return new Set( - files - .map((f) => String(f.file_code || '').trim()) - .filter(Boolean) - ); + try { + const files = await this._fetchVmList(); + return new Set( + files + .map((f) => String(f.file_code || '').trim()) + .filter(Boolean) + ); + } catch { + return new Set(); + } } - async _fetchVmList(phase = 'recovery-poll') { + async _fetchVmList() { const params = new URLSearchParams({ op: 'vm', api: 'list', @@ -439,46 +336,14 @@ class VidmolyUploader { fld_id: '0' }); - const endpoint = `${BASE_URL}/?${params.toString()}`; - let res; - try { - res = await this._fetch(endpoint); - } catch { - throw createTransportError('Vidmoly: Dateiliste konnte nicht geladen werden', { - phase, - endpoint, - retryable: true, - transientNetwork: true - }); - } + const res = await this._fetch(`${BASE_URL}/?${params.toString()}`); const body = await res.text(); - const contentType = res.headers && typeof res.headers.get === 'function' - ? res.headers.get('content-type') - : null; - - if (res.status < 200 || res.status >= 300) { - throw createTransportError('Vidmoly: Dateiliste konnte nicht geladen werden', { - phase, - endpoint, - httpStatus: res.status, - contentType, - body, - retryable: res.status === 429 || res.status >= 500, - transientNetwork: res.status >= 500 - }); - } let payload; try { payload = JSON.parse(body); } catch { - throw createTransportError('Vidmoly: Dateiliste war kein JSON', { - phase, - endpoint, - httpStatus: res.status, - contentType, - body - }); + throw new Error('Vidmoly VM API lieferte kein JSON'); } if (!payload || !Array.isArray(payload.files)) return []; @@ -486,10 +351,7 @@ class VidmolyUploader { } async _resolveUploadedFileFromVmApi(fileName, baselineCodes, signal) { - if (!(baselineCodes instanceof Set)) return null; const expectedTitle = this._normalizeTitle(path.parse(fileName).name); - let lastPollError = null; - let successfulPoll = false; for (let attempt = 0; attempt < RESULT_POLL_ATTEMPTS; attempt++) { if (signal && signal.aborted) { @@ -500,27 +362,46 @@ class VidmolyUploader { let files = []; try { - files = await this._fetchVmList('recovery-poll'); - successfulPoll = true; - } catch (err) { - if (err && err.name === 'AbortError') throw err; - lastPollError = err; + files = await this._fetchVmList(); + } catch { + files = []; } const withCode = files.filter((f) => f && typeof f.file_code === 'string' && f.file_code.trim()); - const newFiles = withCode.filter((f) => !baselineCodes.has(f.file_code.trim())); - const matches = newFiles - .filter((file) => { - const title = this._normalizeTitle(file.full_title || file.title_txt || ''); - return expectedTitle && title === expectedTitle; - }) - .filter((file) => !this.recoveryClaim - || typeof this.recoveryClaim.has !== 'function' - || !this.recoveryClaim.has(file.file_code.trim())); + const newFiles = withCode.filter((f) => !baselineCodes.has(f.file_code)); - if (matches.length > 1) return null; - if (matches.length === 1) { - return this._buildUrlsFromCode(matches[0].file_code, 'recovery-poll'); + if (newFiles.length > 0) { + let best = null; + let bestScore = -1; + + for (const file of newFiles) { + const score = this._scoreVmCandidate(file, expectedTitle); + if (score > bestScore) { + bestScore = score; + best = file; + } + } + + if (best && bestScore > 0) { + return this._buildUrlsFromCode(best.file_code); + } + } + + if (expectedTitle) { + let bestMatch = null; + let bestScore = -1; + + for (const file of withCode) { + const score = this._scoreVmCandidate(file, expectedTitle); + if (score > bestScore) { + bestScore = score; + bestMatch = file; + } + } + + if (bestMatch && bestScore >= 90) { + return this._buildUrlsFromCode(bestMatch.file_code); + } } if (attempt < RESULT_POLL_ATTEMPTS - 1) { @@ -528,7 +409,6 @@ class VidmolyUploader { } } - if (!successfulPoll && lastPollError) throw lastPollError; return null; } @@ -617,23 +497,18 @@ class VidmolyUploader { if (codeInPage) file_code = codeInPage[1]; } - if (file_code) { - const urls = this._buildUrlsFromCode(file_code); - if (!download_url) download_url = urls.download_url; - if (!embed_url) embed_url = urls.embed_url; + // Build URLs from file_code + if (file_code && !download_url) { + download_url = `${BASE_URL}/w/${file_code}`; + } + if (file_code && !embed_url) { + embed_url = `${BASE_URL}/embed-${file_code}.html`; } if (!download_url && !file_code) { const errMatch = html.match(/class=["']err["'][^>]*>([^<]+)/i); const errMsg = errMatch ? errMatch[1].trim() : 'Kein Download-Link gefunden'; - throw createTransportError(`Vidmoly Upload-Ergebnis: ${sanitizeRemoteText(errMsg)}`, { - phase: 'upload-result', - endpoint: BASE_URL, - contentType: 'text/html', - body: html, - hosterTransient: true, - retryable: true - }); + throw new Error(`Vidmoly Upload-Ergebnis: ${errMsg}`); } return { download_url, embed_url, file_code }; diff --git a/lib/voe-upload.js b/lib/voe-upload.js index 6cf683d..4c105c3 100644 --- a/lib/voe-upload.js +++ b/lib/voe-upload.js @@ -2,8 +2,6 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { request } = require('undici'); -const { createTransportError, sanitizeRemoteText } = require('./hoster-transport-error'); -const { normalizeRecoveryTitle } = require('./hosters'); const BASE_URL = 'https://voe.sx'; const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; @@ -16,9 +14,8 @@ const RESULT_POLL_DELAY_MS = 2000; * Fallback when API-based upload fails or is unavailable. */ class VoeUploader { - constructor(recoveryClaim = null) { + constructor() { this.cookies = new Map(); - this.recoveryClaim = recoveryClaim; } _cookieHeader() { @@ -163,58 +160,21 @@ class VoeUploader { * Returns { server: "https://cdn-xxx.edgeon-bandwidth.com/node/u/01", session_id: "..." } */ async _getDeliveryNode(csrfToken) { - const endpoint = `${BASE_URL}/engine/delivery-node`; - let res; - try { - res = await this._fetch(endpoint, { - headers: { - 'X-CSRF-TOKEN': csrfToken, - 'X-Requested-With': 'XMLHttpRequest', - 'Accept': 'application/json' - } - }); - } catch { - throw createTransportError('VOE: Upload-Server konnte nicht geladen werden', { - phase: 'upload-server', - endpoint, - retryable: true, - transientNetwork: true - }); - } + const res = await this._fetch(`${BASE_URL}/engine/delivery-node`, { + headers: { + 'X-CSRF-TOKEN': csrfToken, + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json' + } + }); const body = await res.text(); - const contentType = res.headers && typeof res.headers.get === 'function' - ? res.headers.get('content-type') - : null; - if (res.status < 200 || res.status >= 300) { - throw createTransportError('VOE: Upload-Server konnte nicht geladen werden', { - phase: 'upload-server', - endpoint, - httpStatus: res.status, - contentType, - body, - retryable: res.status === 429 || res.status >= 500, - transientNetwork: res.status >= 500 - }); - } let data; try { data = JSON.parse(body); } catch { - throw createTransportError('VOE: Upload-Server Antwort war kein JSON', { - phase: 'upload-server', - endpoint, - httpStatus: res.status, - contentType, - body - }); + throw new Error(`VOE: Upload-Server Antwort war kein JSON: ${body.slice(0, 200)}`); } if (!data || !data.success || !data.server) { - throw createTransportError('VOE: Kein Upload-Server erhalten von delivery-node', { - phase: 'upload-server', - endpoint, - httpStatus: res.status, - contentType, - body - }); + throw new Error('VOE: Kein Upload-Server erhalten von delivery-node'); } return { uploadServer: data.server, sessionId: data.session_id || '' }; @@ -223,54 +183,26 @@ class VoeUploader { /** * List current files via VOE API (for result polling fallback) */ - async _fetchFileList(phase = 'recovery-poll') { - const endpoint = `${BASE_URL}/api2/my-files?sort=date&order=dsc&page=1&per_page=50`; - let res; + async _fetchFileList() { try { - res = await this._fetch(endpoint); + const res = await this._fetch(`${BASE_URL}/api2/my-files?sort=date&order=dsc&page=1&per_page=50`); + const body = await res.text(); + const data = JSON.parse(body); + if (data && Array.isArray(data.data)) return data.data; + if (data && Array.isArray(data.files)) return data.files; + return []; } catch { - throw createTransportError('VOE: Dateiliste konnte nicht geladen werden', { - phase, - endpoint, - retryable: true, - transientNetwork: true - }); + return []; } - const body = await res.text(); - const contentType = res.headers && typeof res.headers.get === 'function' - ? res.headers.get('content-type') - : null; - if (res.status < 200 || res.status >= 300) { - throw createTransportError('VOE: Dateiliste konnte nicht geladen werden', { - phase, - endpoint, - httpStatus: res.status, - contentType, - body, - retryable: res.status === 429 || res.status >= 500, - transientNetwork: res.status >= 500 - }); - } - let data; - try { - data = JSON.parse(body); - } catch { - throw createTransportError('VOE: Dateiliste war kein JSON', { - phase, - endpoint, - httpStatus: res.status, - contentType, - body - }); - } - if (data && Array.isArray(data.data)) return data.data; - if (data && Array.isArray(data.files)) return data.files; - return []; } async _captureFileCodes() { - const files = await this._fetchFileList('recovery-baseline'); - return new Set(files.map(f => String(f.file_code || f.slug || '').trim()).filter(Boolean)); + try { + const files = await this._fetchFileList(); + return new Set(files.map(f => String(f.file_code || f.slug || '').trim()).filter(Boolean)); + } catch { + return new Set(); + } } /** @@ -280,14 +212,7 @@ class VoeUploader { async upload(filePath, onProgress, signal, throttle) { const fileName = path.basename(filePath); const fileSize = fs.statSync(filePath).size; - let baselineCodes = null; - let baselineError = null; - try { - baselineCodes = await this._captureFileCodes(); - } catch (err) { - if (signal && signal.aborted) throw err; - baselineError = err; - } + const baselineCodes = await this._captureFileCodes(); // Step 1: Get CSRF token from upload page const { csrfToken } = await this._getUploadParams(); @@ -333,57 +258,27 @@ class VoeUploader { } // Step 3: POST file to CDN upload server - let uploadResponse; - try { - uploadResponse = await request(uploadServer, { - method: 'POST', - body: generate(), - signal, - headers: { - 'User-Agent': USER_AGENT, - 'Cookie': this._cookieHeader(), - 'Content-Type': `multipart/form-data; boundary=${boundary}`, - 'Content-Length': String(totalSize), - 'X-CSRF-TOKEN': csrfToken, - 'X-Requested-With': 'XMLHttpRequest', - 'Referer': `${BASE_URL}/file-upload`, - 'Origin': BASE_URL - }, - headersTimeout: UPLOAD_TIMEOUT, - bodyTimeout: UPLOAD_TIMEOUT - }); - } catch (err) { - const error = signal && signal.aborted ? err : createTransportError('VOE Upload konnte nicht übertragen werden', { - phase: 'upload-request', - endpoint: uploadServer, - retryable: true, - transientNetwork: true - }); - throw this._markRemoteCommitUncertain(error); - } - - const { body, headers, statusCode } = uploadResponse; + const { body, headers } = await request(uploadServer, { + method: 'POST', + body: generate(), + signal, + headers: { + 'User-Agent': USER_AGENT, + 'Cookie': this._cookieHeader(), + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': String(totalSize), + 'X-CSRF-TOKEN': csrfToken, + 'X-Requested-With': 'XMLHttpRequest', + 'Referer': `${BASE_URL}/file-upload`, + 'Origin': BASE_URL + }, + headersTimeout: UPLOAD_TIMEOUT, + bodyTimeout: UPLOAD_TIMEOUT + }); this._parseCookiesFromHeaders(headers || {}); - let rawBody; - try { - rawBody = await body.text(); - } catch (err) { - throw this._markRemoteCommitUncertain(err); - } - if (statusCode < 200 || statusCode >= 300) { - const error = createTransportError('VOE Upload fehlgeschlagen', { - phase: 'upload-response', - endpoint: uploadServer, - httpStatus: statusCode, - contentType: headers && headers['content-type'], - body: rawBody, - retryable: statusCode === 429 || statusCode >= 500, - transientNetwork: statusCode >= 500 - }); - throw statusCode >= 500 ? this._markRemoteCommitUncertain(error) : error; - } + const rawBody = await body.text(); // Try JSON response try { @@ -400,48 +295,22 @@ class VoeUploader { // Check for error if (json.error || json.message) { - throw createTransportError(`VOE Upload-Fehler: ${sanitizeRemoteText(json.error || json.message)}`, { - phase: 'upload-result', - endpoint: uploadServer, - contentType: 'application/json', - body: rawBody - }); + throw new Error(`VOE Upload-Fehler: ${json.error || json.message}`); } } catch (parseErr) { - if (parseErr && parseErr.diagnostic) throw parseErr; + if (parseErr.message.startsWith('VOE Upload-Fehler')) throw parseErr; // Not JSON - might be a redirect or HTML response } // Fallback: poll the file list to find the newly uploaded file - if (baselineCodes) { - try { - const result = await this._resolveUploadedFile(fileName, baselineCodes, signal); - if (result) return result; - } catch (err) { - throw this._markRemoteCommitUncertain(err); - } - } + const result = await this._resolveUploadedFile(fileName, baselineCodes, signal); + if (result) return result; - if (baselineError) { - baselineError.hosterTransient = true; - throw this._markRemoteCommitUncertain(baselineError); - } - - throw this._markRemoteCommitUncertain(createTransportError('VOE Upload: Kein file_code in der Antwort gefunden', { - phase: 'upload-result', - endpoint: uploadServer, - contentType: headers && headers['content-type'], - body: rawBody, - hosterTransient: true, - retryable: true - })); + throw new Error('VOE Upload: Kein file_code in der Antwort gefunden'); } async _resolveUploadedFile(fileName, baselineCodes, signal) { - if (!(baselineCodes instanceof Set)) return null; const expectedTitle = this._normalizeTitle(path.parse(fileName).name); - let lastPollError = null; - let successfulPoll = false; for (let attempt = 0; attempt < RESULT_POLL_ATTEMPTS; attempt++) { if (signal && signal.aborted) { @@ -452,31 +321,29 @@ class VoeUploader { let files = []; try { - files = await this._fetchFileList('recovery-poll'); - successfulPoll = true; - } catch (err) { - if (err && err.name === 'AbortError') throw err; - lastPollError = err; - } + files = await this._fetchFileList(); + } catch { files = []; } const withCode = files.filter(f => f && (f.file_code || f.slug)); const newFiles = withCode.filter(f => !baselineCodes.has(String(f.file_code || f.slug || '').trim())); - const matches = newFiles - .filter(file => { - const title = this._normalizeTitle(file.title || file.name || ''); - return expectedTitle && title === expectedTitle; - }) - .filter(file => { - const code = String(file.file_code || file.slug || '').trim(); - return !this.recoveryClaim - || typeof this.recoveryClaim.has !== 'function' - || !this.recoveryClaim.has(code); - }); - if (matches.length > 1) return null; - if (matches.length === 1) { - const code = matches[0].file_code || matches[0].slug; - return this._buildUrls(code, 'recovery-poll'); + if (newFiles.length > 0) { + // Try to match by title + let best = null; + let bestScore = -1; + + for (const file of newFiles) { + const score = this._scoreCandidate(file, expectedTitle); + if (score > bestScore) { + bestScore = score; + best = file; + } + } + + if (best && (bestScore > 0 || newFiles.length === 1)) { + const code = best.file_code || best.slug; + return this._buildUrls(code); + } } if (attempt < RESULT_POLL_ATTEMPTS - 1) { @@ -484,41 +351,31 @@ class VoeUploader { } } - if (!successfulPoll && lastPollError) throw lastPollError; return null; } _normalizeTitle(value) { - return normalizeRecoveryTitle(value); + return String(value || '') + .toLowerCase() + .normalize('NFKD') + .replace(/[^a-z0-9]+/g, ''); } - _markRemoteCommitUncertain(error) { - if (this.recoveryClaim && typeof this.recoveryClaim.markUncertain === 'function') { - return this.recoveryClaim.markUncertain(error); - } - const uncertainError = error && typeof error === 'object' - ? error - : new Error('VOE Upload-Ergebnis ist unsicher'); - uncertainError.remoteCommitUncertain = true; - uncertainError.hosterTransient = true; - return uncertainError; + _scoreCandidate(file, expectedTitle) { + if (!file || !(file.file_code || file.slug)) return -1; + if (!expectedTitle) return 0; + + const title = this._normalizeTitle(file.title || file.name || ''); + if (!title) return -1; + if (title === expectedTitle) return 120; + if (title.startsWith(expectedTitle) || expectedTitle.startsWith(title)) return 90; + if (title.includes(expectedTitle) || expectedTitle.includes(title)) return 70; + return 0; } - _buildUrls(fileCode, phase = 'upload-result') { + _buildUrls(fileCode) { const code = String(fileCode || '').trim(); if (!code) return null; - if (this.recoveryClaim - && typeof this.recoveryClaim.reserve === 'function' - && !this.recoveryClaim.reserve(code)) { - const error = createTransportError('VOE Upload-Ergebnis ist bereits einem anderen Upload zugeordnet', { - phase, - endpoint: BASE_URL, - retryable: true, - hosterTransient: true - }); - error.remoteIdentityClaimed = true; - throw this._markRemoteCommitUncertain(error); - } return { download_url: `${BASE_URL}/${code}`, embed_url: `${BASE_URL}/e/${code}`, diff --git a/lib/webhook-notify.js b/lib/webhook-notify.js index ca28970..315b6b6 100644 --- a/lib/webhook-notify.js +++ b/lib/webhook-notify.js @@ -78,7 +78,7 @@ function buildWebhookRequest(url, summary, meta) { .join(' · '); if (hosterEntries.length > MAX_HOSTER_LINES) hosterLines += ` · …+${hosterEntries.length - MAX_HOSTER_LINES}`; const lines = [ - `**Multi Hoster Uploader — ${headline}**${m.machineName ? ` (${m.machineName})` : ''}`, + `**Multi-Hoster-Upload — ${headline}**${m.machineName ? ` (${m.machineName})` : ''}`, language === 'de' ? `✅ ${succeeded} ok · ❌ ${failed} Fehler${skipped > 0 ? ` · ⏭ ${skipped} übersprungen` : ''} · 📦 ${total} gesamt · ⏱ ${duration}` : `✅ ${succeeded} succeeded · ❌ ${failed} failed${skipped > 0 ? ` · ⏭ ${skipped} skipped` : ''} · 📦 ${total} total · ⏱ ${duration}` @@ -92,7 +92,7 @@ function buildWebhookRequest(url, summary, meta) { } else { body = JSON.stringify({ event: 'batch-done', - app: 'multi-hoster-uploader', + app: 'multi-hoster-upload', version: m.appVersion || null, machine: m.machineName || null, total, diff --git a/main.js b/main.js index 09fb57a..a1812b9 100644 --- a/main.js +++ b/main.js @@ -1,27 +1,11 @@ process.env.UV_THREADPOOL_SIZE = process.env.UV_THREADPOOL_SIZE || '8'; const { monitorEventLoopDelay, PerformanceObserver } = require('perf_hooks'); const { app, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Tray, Menu, nativeImage } = require('electron'); -const RuntimeBrowserWindow = globalThis.__mhuBrowserWindowConstructor || BrowserWindow; -const path = require('path'); -app.setPath('userData', path.join(app.getPath('appData'), 'multi-hoster-uploader')); -app.setName('Multi Hoster Uploader'); -app.setAppUserModelId('com.multihoster.uploader'); -const { - configureStartupRenderer, - createStartupCloseHandler, - createStartupExternalRevealBindings, - createStartupFailureDocument, - createStartupNavigationLoader, - createStartupRecoveryCoordinator, - createStartupRevealGate, - createStartupRendererHandlers, - createStartupWindow, - resolveStartupLanguage -} = require('./lib/startup-renderer'); +const { configureStartupRenderer, createStartupWindow, resolveStartupLanguage } = require('./lib/startup-renderer'); configureStartupRenderer(app); nativeTheme.themeSource = 'dark'; +const path = require('path'); const fs = require('fs'); -const crypto = require('crypto'); const ConfigStore = require('./lib/config-store'); const UploadManager = require('./lib/upload-manager'); const { createSourceFileCleanup } = require('./lib/source-file-cleanup'); @@ -43,8 +27,8 @@ const { walkFolderAsync } = require('./lib/file-discovery'); const RemoteServer = require('./lib/remote-server'); const { maybeRotateLogFile } = require('./lib/log-rotation'); const { hosterLogToFileEnabled } = require('./lib/log-policy'); -const { formatUploadLogLine, parseUploadLogLine, summarizeBatchPlan } = require('./lib/upload-log'); -const { getUploadAuditLogPath, createUploadAuditWriter, createUploadAuditEvents, runAfterDurableAudit, getUploadAuditFailureMessage } = require('./lib/upload-audit'); +const { formatUploadLogLine, parseUploadLogLine, summarizeBatchPlan, formatUploadPlanLogLine } = require('./lib/upload-log'); +const { getUploadAuditLogPath, createUploadAuditWriter } = require('./lib/upload-audit'); const { selectOrphanTmps } = require('./lib/orphan-tmp'); const { sanitizeConfig, buildSupportBundleText, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED } = require('./lib/support-bundle'); const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify'); @@ -52,11 +36,6 @@ 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 { buildBatchCompletionReport, buildBatchErrorCsv } = require('./lib/batch-completion-report'); -const { buildFailedUploadSummary, buildTerminalJobSnapshots } = require('./lib/upload-recovery'); -const { selectPublicUploadUrl } = require('./lib/upload-confirmation'); -const { createBatchMutationGate } = require('./lib/batch-mutation-gate'); -const { createUploadStartReservation } = require('./lib/upload-start-reservation'); const { inspectImportEntries, inspectReadableImportPath } = require('./lib/import-preflight'); const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 }); @@ -140,241 +119,29 @@ let tray = null; const configStore = new ConfigStore(app); configStore.setPerfLog((m) => { try { logInfo(m); } catch {} }); let uploadManager = null; -const uploadBatchMutationGates = new WeakMap(); -const uploadRecoveryStates = new WeakMap(); -const uploadBatchAdmissionSkips = new WeakMap(); -const batchCompletionReports = new Map(); let lastSessionSummary = null; -let lastBatchCompletionReport = null; -let startupRecoveryCoordinator = null; -let startupRevealGate = null; -let startupRendererHandlers = null; let sourceDeleteJournal = null; -const RENDERER_READY_TIMEOUT_MS = 15000; -const startupExternalRevealBindings = createStartupExternalRevealBindings({ - getWindow: () => mainWindow, - getRevealGate: () => startupRevealGate, - sendDroppedFiles: paths => safeSend('drop-target:files', paths) -}); +const pendingUploadFinalizations = new Map(); -function createUploadFinalizationCoordinator({ - send, - saveQueue, - schedule = setTimeout, - cancelSchedule = clearTimeout, - createFinalizationId = () => `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, - createDeliveryId = () => `delivery-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, - timeoutMs = 15000 -}) { - const pending = new Map(); - let rendererGeneration = 0; - let rendererIsReady = false; - - function settle(entry, value) { - if (!entry || entry.settled) return; - entry.settled = true; - if (entry.timer) cancelSchedule(entry.timer); - pending.delete(entry.finalizationId); - entry.resolve(value); - } - - function armTimeout(entry) { - if (entry.timer) cancelSchedule(entry.timer); - entry.timer = schedule(() => settle(entry, false), timeoutMs); - } - - function deliver(entry) { - if (!rendererIsReady || entry.settled || entry.completing || entry.deliveredGeneration === rendererGeneration) return false; - const deliveryId = createDeliveryId(); - const delivered = send({ - summary: entry.summary, - finalizationId: entry.finalizationId, - deliveryId, - historyPersisted: entry.historyPersisted - }); - if (delivered === false) return false; - entry.deliveryId = deliveryId; - entry.deliveredGeneration = rendererGeneration; - return true; - } - - function request(summary, historyPersisted) { - const finalizationId = createFinalizationId(); - return new Promise((resolve) => { - const entry = { - finalizationId, - summary, - historyPersisted: historyPersisted === true, - deliveryId: null, - deliveredGeneration: 0, - completing: false, - settled: false, - timer: null, - resolve - }; - pending.set(finalizationId, entry); - armTimeout(entry); - deliver(entry); - }); - } - - function rendererBlocked() { - rendererIsReady = false; - for (const entry of pending.values()) armTimeout(entry); - } - - function rendererReady() { - if (rendererIsReady) return rendererGeneration; - rendererIsReady = true; - rendererGeneration += 1; - for (const entry of pending.values()) deliver(entry); - return rendererGeneration; - } - - function hasCompleteTerminalFallback(entry, pendingQueue) { - if (entry.historyPersisted) return true; - const terminalStatuses = new Set(['done', 'error', 'skipped', 'aborted']); - const normalizeValue = (value) => { - if (Array.isArray(value)) return value.map(normalizeValue); - if (!value || typeof value !== 'object') return value ?? null; - return Object.fromEntries(Object.keys(value).sort().map(key => [key, normalizeValue(value[key])])); - }; - const valuesMatch = (left, right) => JSON.stringify(normalizeValue(left)) === JSON.stringify(normalizeValue(right)); - const normalizeResult = result => ({ - download_url: result?.download_url || null, - embed_url: result?.embed_url || null, - file_code: result?.file_code || null - }); - const expected = []; - for (const file of Array.isArray(entry.summary?.files) ? entry.summary.files : []) { - for (const result of Array.isArray(file?.results) ? file.results : []) { - if (result?.jobId && terminalStatuses.has(result.status)) expected.push(result); +function requestUploadFinalization(summary) { + const finalizationId = `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + return new Promise((resolve) => { + const timer = setTimeout(() => { + pendingUploadFinalizations.delete(finalizationId); + resolve(false); + }, 15000); + pendingUploadFinalizations.set(finalizationId, { + resolve(value) { + clearTimeout(timer); + pendingUploadFinalizations.delete(finalizationId); + resolve(value); } - } - const jobs = Array.isArray(pendingQueue?.queueJobs) ? pendingQueue.queueJobs : []; - const byId = new Map(jobs.map(job => [job?.id, job])); - return expected.every((result) => { - const job = byId.get(result.jobId); - if (!job || job.status !== result.status) return false; - if ((result.error || null) !== (job.error || null)) return false; - if (!valuesMatch(result.failureDetails || null, job.failureDetails || null)) return false; - if ((result.remoteCommitUncertain === true) !== (job.remoteCommitUncertain === true)) return false; - if (result.status !== 'done') return true; - return valuesMatch(normalizeResult(result), normalizeResult(job.result)); }); - } - - async function complete(payload) { - const entry = payload?.finalizationId && pending.get(payload.finalizationId); - if (!entry || entry.settled || entry.completing || !payload.deliveryId || payload.deliveryId !== entry.deliveryId) return false; - entry.completing = true; - if (payload.ready === false) { - settle(entry, false); - return false; - } - if (!hasCompleteTerminalFallback(entry, payload.pendingQueue)) { - settle(entry, false); - return false; - } - try { - await saveQueue(payload.pendingQueue ?? null); - settle(entry, true); - return true; - } catch { - settle(entry, false); - return false; - } - } - - return { request, rendererBlocked, rendererReady, complete }; + safeSend('upload-batch-done', { summary, finalizationId }); + }); } - -function createUploadFinalizationBarrier({ - appendHistory, - saveRecovery, - requestFinalization, - buildTerminalSnapshots, - now = () => new Date().toISOString(), - onError = () => {} -}) { - async function finalize(summary, recovery) { - let historyPersisted = true; - try { - await appendHistory(summary); - } catch (error) { - historyPersisted = false; - onError('history', error); - } - - const terminalRecovery = { - ...recovery, - settledAt: now(), - historyPending: !historyPersisted, - terminalJobs: buildTerminalSnapshots(summary) - }; - let terminalRecoveryPersisted = true; - try { - await saveRecovery(terminalRecovery); - } catch (error) { - terminalRecoveryPersisted = false; - onError('recovery', error); - } - - let queuePersisted = false; - try { - queuePersisted = (await requestFinalization(summary, historyPersisted)) === true; - } catch (error) { - onError('queue', error); - } - - let recoveryCleared = false; - if (historyPersisted && queuePersisted && terminalRecoveryPersisted) { - try { - await saveRecovery(null); - recoveryCleared = true; - } catch (error) { - onError('recovery-clear', error); - } - } - - return { - historyPersisted, - queuePersisted, - terminalRecoveryPersisted, - recoveryCleared, - terminalRecovery - }; - } - - return { finalize }; -} - -function requestUploadFinalization(summary, historyPersisted) { - return uploadFinalizationCoordinator.request(summary, historyPersisted); -} - -const uploadFinalizationCoordinator = createUploadFinalizationCoordinator({ - send: payload => safeSend('upload-batch-done', payload), - saveQueue: async pendingQueue => { - try { - await configStore.savePendingQueue(pendingQueue); - } catch (error) { - debugLog(`upload finalization queue save failed: ${error.message}`); - throw error; - } - }, - timeoutMs: RENDERER_READY_TIMEOUT_MS * 3 -}); -const uploadFinalizationBarrier = createUploadFinalizationBarrier({ - appendHistory: summary => configStore.appendHistory(summary), - saveRecovery: recovery => configStore.saveUploadRecovery(recovery), - requestFinalization: requestUploadFinalization, - buildTerminalSnapshots: buildTerminalJobSnapshots, - onError: (phase, error) => debugLog(`upload finalization ${phase} failed: ${error.message}`) -}); const activeUploadProducerTrackers = new Set(); -const uploadStartReservation = createUploadStartReservation(); -const settingsImportGate = createSettingsImportGate(() => !!uploadManager || uploadStartReservation.isActive()); +const settingsImportGate = createSettingsImportGate(() => !!(uploadManager && uploadManager.running)); let diagnosticAgent = null; let _diagHandler = null; @@ -497,7 +264,13 @@ const _hasSingleInstanceLock = app.requestSingleInstanceLock(); if (!_hasSingleInstanceLock) { app.quit(); } else { - startupExternalRevealBindings.bindSecondInstance(app); + app.on('second-instance', () => { + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + if (!mainWindow.isVisible()) mainWindow.show(); + mainWindow.focus(); + } + }); } // Rotation memory that survives batch-done → new UploadManager within the // same app session. Without this, clicking "Retry failed" after a batch @@ -1006,14 +779,13 @@ function _invalidateUploadLogTargetCache() { _cachedUploadLogKey = ''; } -function _resolveUploadLogTarget(excluded) { - const excludedPaths = excluded instanceof Set ? excluded : new Set(excluded ? [excluded] : []); +function _resolveUploadLogTarget(excludedPath) { const primary = getLogFilePath(); // The primary path already encodes the mode + date/session, so it changes // when the user toggles mode, daily rolls at midnight, or this is a new // process — cache invalidates naturally on path change. const key = primary; - if (_cachedUploadLogKey === key && _cachedUploadLogTarget && !excludedPaths.has(_cachedUploadLogTarget.path)) return _cachedUploadLogTarget; + if (_cachedUploadLogKey === key && _cachedUploadLogTarget && _cachedUploadLogTarget.path !== excludedPath) return _cachedUploadLogTarget; const commit = (t) => { _cachedUploadLogTarget = t; @@ -1022,7 +794,7 @@ function _resolveUploadLogTarget(excluded) { }; // Try primary → desktop → userData, mirror the original fallback ladder. - if (!excludedPaths.has(primary)) { + if (primary !== excludedPath) { try { fs.mkdirSync(path.dirname(primary), { recursive: true }); return commit({ path: primary, isFallback: false }); @@ -1034,7 +806,7 @@ function _resolveUploadLogTarget(excluded) { if (desktop) { try { const p = buildFallbackLogName(desktop); - if (!excludedPaths.has(p)) { + if (p !== excludedPath) { fs.mkdirSync(path.dirname(p), { recursive: true }); return commit({ path: p, isFallback: true }); } @@ -1042,7 +814,7 @@ function _resolveUploadLogTarget(excluded) { } try { const p = buildFallbackLogName(app.getPath('userData')); - if (excludedPaths.has(p)) return null; + if (p === excludedPath) return null; fs.mkdirSync(path.dirname(p), { recursive: true }); return commit({ path: p, isFallback: true }); } catch (err) { @@ -1065,7 +837,6 @@ const _uploadAuditWriter = createUploadAuditWriter({ persistFallbackLogPath: _persistFallbackLogPath, reportError: (label, error) => debugLog(`${label} audit append failed: ${error.message}`) }); -const _uploadAuditEvents = createUploadAuditEvents(_uploadAuditWriter); function _flushUploadLog() { if (_uploadLogWriting || _uploadLogBuffer.length === 0) return; @@ -1128,7 +899,10 @@ async function _persistFallbackLogPath(workingPath) { const base = path.basename(workingPath); toSave = path.join(dir, stripModeStampFromFileName(base)); } - await configStore.saveFallbackLogPath(toSave); + if (gs.logFilePath === toSave) return true; + gs.logFilePath = toSave; + cfg.globalSettings = gs; + await configStore.save({ globalSettings: gs }); _invalidateUploadLogTargetCache(); _invalidateLogSettings(); safeSend('log-path-auto-updated', { logFilePath: toSave }); @@ -1139,25 +913,6 @@ async function _persistFallbackLogPath(workingPath) { } } -function publishBatchCompletionReport(summary, options = {}) { - if (isAllAborted(summary)) return null; - let secrets = []; - try { secrets = collectSecretValues(configStore.load()); } catch {} - const report = buildBatchCompletionReport({ - reportId: `report-${summary?.id || Date.now()}-${Math.random().toString(36).slice(2, 10)}`, - summary, - startedAt: options.startedAt, - completedAt: options.completedAt, - cleanupOutcomes: options.cleanupOutcomes, - secrets - }); - batchCompletionReports.set(report.reportId, report); - while (batchCompletionReports.size > 5) batchCompletionReports.delete(batchCompletionReports.keys().next().value); - lastBatchCompletionReport = report; - safeSend('upload-batch-report', report); - return report; -} - // Whether this hoster's successful links should land in fileuploader.log. // Reads the LIVE uploadManager.hosterSettings (kept current via // updateSettings) so a mid-batch toggle takes effect immediately. Falls back @@ -1182,8 +937,19 @@ function appendUploadLog(hoster, link, fileName) { } } -const appendSourceCleanupAudit = _uploadAuditEvents.appendSourceCleanup; -const appendUploadPlanAudit = _uploadAuditEvents.appendUploadPlan; +async function appendUploadAuditLine(line, label) { + return _uploadAuditWriter.append(line, label); +} + +async function appendSourceCleanupAudit(event) { + debugLog(`source-cleanup: ${event.outcome} ${event.file} trigger=${event.trigger || '-'}`); + return appendUploadAuditLine(`# SOURCE-CLEANUP ${JSON.stringify(event)}\r\n`, 'source-cleanup'); +} + +async function appendUploadPlanAudit(plan, mode) { + debugLog(`upload-plan: mode=${mode} files=${plan.fileCount} destinations=${plan.destinationCount} uploads=${plan.plannedUploadCount}`); + return appendUploadAuditLine(formatUploadPlanLogLine(new Date(), plan, mode), 'upload-plan'); +} function flattenHistoryForExport(history) { const rows = []; @@ -1222,7 +988,8 @@ function flattenHistoryForExport(history) { for (const result of results) { // Only accept real URLs. file_code alone is just an opaque ID and // ends up looking like "nur sone Nummerierung" in the CSV. - const link = selectPublicUploadUrl(result); + const rawLink = result && (result.download_url || result.embed_url) || ''; + const link = /^https?:\/\//i.test(String(rawLink)) ? String(rawLink) : ''; rows.push({ batchId, batchTimestamp, @@ -1317,12 +1084,6 @@ function buildTaskFromAccount(hoster, account, extra) { return task; } -function buildBatchFileKey(file) { - const resolved = path.resolve(String(file || '')); - const canonical = process.platform === 'win32' ? resolved.toLowerCase() : resolved; - return crypto.createHash('sha256').update(canonical).digest('hex'); -} - let _rotationCursors = null; function rotationCursors() { if (_rotationCursors === null) { @@ -1353,7 +1114,7 @@ function buildUploadTasks(config, files, hosters, pick) { for (const hoster of hosters) { const account = pick(hoster); if (!account) { debugLog(` skip ${hoster}: no enabled account with creds`); continue; } - tasks.push(buildTaskFromAccount(hoster, account, { file, fileKey: buildBatchFileKey(file) })); + tasks.push(buildTaskFromAccount(hoster, account, { file })); } } return tasks; @@ -1368,7 +1129,6 @@ function buildUploadTasksFromJobs(config, jobs, pick) { if (!account) { debugLog(` skip ${job.hoster}: no enabled account`); continue; } tasks.push(buildTaskFromAccount(job.hoster, account, { file: job.file, - fileKey: buildBatchFileKey(job.file), jobId: job.id || job.jobId || null, sourceCleanupToken: job.sourceCleanupToken || null })); @@ -1662,7 +1422,7 @@ async function runHosterHealthCheck(config, requestedChecks) { } function createWindow() { - const startupWindow = createStartupWindow(RuntimeBrowserWindow, { + const startupWindow = createStartupWindow(BrowserWindow, { title: 'Multi Hoster Uploader', width: 1100, height: 750, @@ -1686,23 +1446,46 @@ function createWindow() { lastRestoredCloseAttempt = null; configStore.setWritesQuiesced(false); clearCloseFlushTimer(); - const currentStartupRevealGate = createStartupRevealGate(mainWindow, { - onBlock: () => { - startupExternalRevealBindings.rendererBlocked(startupWindow.window); - closeHandshakeReady = false; - restoreClosePreparation(closePreparationAttempt); - } - }); - startupRevealGate = currentStartupRevealGate; - mainWindow.on('close', createStartupCloseHandler({ - window: mainWindow, - shouldPrepareClose: () => !closeFlushApproved && closeHandshakeReady, - requestClosePreparation - })); + mainWindow.on('close', (event) => { + if (closeFlushApproved || !closeHandshakeReady || mainWindow.webContents.isDestroyed()) return; + event.preventDefault(); + requestClosePreparation(); + }); mainWindow.webContents.setBackgroundThrottling(false); + mainWindow.webContents.on('did-start-navigation', (_event, _url, isInPlace, isMainFrame) => { + if (isInPlace || !isMainFrame) return; + closeHandshakeReady = false; + restoreClosePreparation(closePreparationAttempt); + }); + + mainWindow.webContents.on('render-process-gone', (_event, details) => { + _writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details); + debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`); + if (mainWindow && !mainWindow.isDestroyed()) { + try { + const choice = dialog.showMessageBoxSync(mainWindow, { + type: 'error', + title: shellText('Renderer abgestürzt', 'Renderer crashed'), + message: shellText(`Der Renderer-Prozess ist abgestürzt (${details.reason}).`, `The renderer process crashed (${details.reason}).`), + detail: shellText('Bitte Diagnose-Paket exportieren und einsenden. Klick "Neu laden" um die UI wiederherzustellen — laufende Uploads im Main-Process bleiben aktiv.', 'Export and send a diagnostics package. Click "Reload" to restore the interface; uploads running in the main process remain active.'), + buttons: [shellText('Neu laden', 'Reload'), shellText('Beenden', 'Quit')], + defaultId: 0, + cancelId: 1 + }); + if (choice === 0) { + mainWindow.webContents.reload(); + } else { + app.exit(1); + } + } catch { + try { mainWindow.webContents.reload(); } catch {} + } + } + }); + mainWindow.webContents.on('unresponsive', () => { _writeCrashLog('RENDERER UNRESPONSIVE', new Error('webContents unresponsive')); debugLog('RENDERER UNRESPONSIVE'); @@ -1724,62 +1507,10 @@ function createWindow() { let startupLanguage = 'en'; try { startupLanguage = resolveStartupLanguage(configStore.load()); } catch {} - const rendererTarget = path.join(__dirname, 'renderer', 'index.html'); - const rendererOptions = { query: { language: startupLanguage } }; - const loadStartupDocument = createStartupNavigationLoader(mainWindow, rendererTarget, rendererOptions); - const loadRendererSurface = async () => { - try { - return await currentStartupRevealGate.navigate(loadStartupDocument); - } catch (error) { - _writeCrashLog('LOAD FILE FAILED', error); - debugLog(`LOAD FILE FAILED: ${error && error.stack ? error.stack : error}`); - throw error; - } - }; - startupRecoveryCoordinator = createStartupRecoveryCoordinator({ - load: loadRendererSurface, - reload: loadRendererSurface, - reveal: currentStartupRevealGate.reveal, - showFailure: () => currentStartupRevealGate.navigate( - () => mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(createStartupFailureDocument(startupLanguage))}`) - ), - close: () => app.exit(1), - readyTimeoutMs: RENDERER_READY_TIMEOUT_MS - }); - startupRendererHandlers = createStartupRendererHandlers({ - window: mainWindow, - ipcMain, - coordinator: startupRecoveryCoordinator, - onDocumentLoadStarted: () => { - currentStartupRevealGate.block(); - uploadFinalizationCoordinator.rendererBlocked(); - }, - onRendererCrashed: (details) => { - uploadFinalizationCoordinator.rendererBlocked(); - _writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details); - debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`); - }, - onReady: () => { - uploadFinalizationCoordinator.rendererReady(); - startupExternalRevealBindings.rendererReady(startupWindow.window); - closeHandshakeReady = true; - }, - onInitializationFailed: (details) => { - const message = details && typeof details.message === 'string' ? details.message : 'Renderer initialization failed'; - _writeCrashLog('RENDERER INITIALIZATION FAILED', new Error(message), details); - debugLog(`RENDERER INITIALIZATION FAILED: ${message}`); - } - }); - const currentStartupRendererHandlers = startupRendererHandlers; - const currentStartupRecoveryCoordinator = startupRecoveryCoordinator; - mainWindow.once('closed', () => { - startupExternalRevealBindings.windowClosed(startupWindow.window); - currentStartupRendererHandlers.dispose(); - if (startupRevealGate === currentStartupRevealGate) startupRevealGate = null; - if (startupRendererHandlers === currentStartupRendererHandlers) startupRendererHandlers = null; - if (startupRecoveryCoordinator === currentStartupRecoveryCoordinator) startupRecoveryCoordinator = null; - }); - void startupRecoveryCoordinator.loadInitial(); + startupWindow.load(path.join(__dirname, 'renderer', 'index.html'), (err) => { + _writeCrashLog('LOAD FILE FAILED', err); + debugLog(`LOAD FILE FAILED: ${err && err.stack ? err.stack : err}`); + }, { query: { language: startupLanguage } }); } function createTray() { @@ -1799,7 +1530,9 @@ function createTray() { tray = new Tray(icon || nativeImage.createEmpty()); refreshTrayLanguage(); - startupExternalRevealBindings.bindTrayClick(tray); + tray.on('click', () => { + if (mainWindow) { mainWindow.show(); mainWindow.focus(); } + }); } catch (err) { tray = null; debugLog(`createTray failed (non-fatal): ${err && err.message ? err.message : err}`); @@ -2169,7 +1902,7 @@ function refreshTrayLanguage() { if (!tray || tray.isDestroyed()) return; tray.setToolTip('Multi Hoster Uploader'); tray.setContextMenu(Menu.buildFromTemplate([ - startupExternalRevealBindings.createTrayMenuItem(shellText('Öffnen', 'Open')), + { label: shellText('Öffnen', 'Open'), click: () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } } }, { type: 'separator' }, { label: shellText('Beenden', 'Quit'), click: () => { app.quit(); } } ])); @@ -2279,33 +2012,10 @@ ipcMain.handle('get-file-sizes', async (_event, paths) => { return out; }); -ipcMain.handle('get-last-batch-completion-report', () => lastBatchCompletionReport); - -ipcMain.handle('export-batch-completion-report', async (_event, reportId, format) => { - const report = batchCompletionReports.get(String(reportId || '')); - if (!report) return { ok: false, error: shellText('Der Batch-Bericht ist nicht mehr verfügbar', 'The batch report is no longer available') }; - const normalizedFormat = String(format || '').toLowerCase(); - if (normalizedFormat !== 'json' && normalizedFormat !== 'csv') return { ok: false, error: shellText('Ungültiges Exportformat', 'Invalid export format') }; - const datePrefix = report.completedAt.slice(0, 10); - const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, { - title: shellText('Batch-Bericht exportieren', 'Export batch report'), - defaultPath: `upload-batch-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 }; - const content = normalizedFormat === 'json' ? JSON.stringify(report, null, 2) : buildBatchErrorCsv(report); - fs.writeFileSync(filePath, content, 'utf-8'); - return { ok: true, path: filePath, format: normalizedFormat, reportId: report.reportId }; -}); - ipcMain.handle('inspect-import-files', async (_event, payload) => { const input = payload && typeof payload === 'object' ? payload : {}; - const currentConfig = configStore.load(); return inspectImportEntries(input.entries, { existingPaths: input.existingPaths, - filenameFilter: currentConfig.globalSettings?.filenameFilter, concurrency: 8, inspectPath: filePath => inspectReadableImportPath(filePath, fs.promises.open) }); @@ -2314,15 +2024,8 @@ ipcMain.handle('inspect-import-files', async (_event, payload) => { ipcMain.handle('start-upload', async (_event, payload) => { if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' }; if (!settingsImportGate.canStartUpload()) return { error: 'Einstellungen werden gerade importiert' }; - if (uploadManager || uploadStartReservation.isActive()) return { error: 'Ein Upload wird bereits ausgeführt oder abgeschlossen' }; - const startLease = uploadStartReservation.acquire(); - if (!startLease) return { error: 'Ein Upload wird bereits ausgeführt oder abgeschlossen' }; - return executeReservedUploadStart(payload, startLease).finally(() => startLease.release()); -}); - -async function executeReservedUploadStart(payload, startLease) { + if (uploadManager) return { error: 'Ein Upload wird bereits ausgeführt oder abgeschlossen' }; const config = configStore.load(); - const batchStartedAt = new Date().toISOString(); 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 : []; @@ -2332,12 +2035,14 @@ async function executeReservedUploadStart(payload, startLease) { // At 500+ jobs JSON.stringify blew up the debug log with MB-sized lines // per start-upload and added noticeable delay — log counts only. - logMarker('BATCH START'); + logMarker('BATCH START', batchPlan); + debugLog(`start-upload: files=${batchPlan.fileCount}, hosters=${batchPlan.destinationCount}, jobs=${batchPlan.plannedUploadCount}`); const pick = makeAccountPicker(config); const tasks = jobs.length > 0 ? buildUploadTasksFromJobs(config, jobs, pick) : buildUploadTasks(config, files, hosters, pick); + persistRotation(pick); // Identify jobs that were skipped (no account/credentials) const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean)); @@ -2345,7 +2050,6 @@ async function executeReservedUploadStart(payload, startLease) { jobId: j.id, file: j.file, fileName: j.fileName || path.basename(j.file || ''), - fileKey: buildBatchFileKey(j.file), size: Number(j.bytesTotal) || 0, hoster: j.hoster, reason: 'Kein gültiger Account für diesen Hoster' @@ -2354,34 +2058,10 @@ async function executeReservedUploadStart(payload, startLease) { debugLog(` skipped ${skippedJobs.length} jobs: ${skippedJobs.map(s => s.hoster).join(', ')}`); } - const auditedStart = await runAfterDurableAudit( - () => appendUploadPlanAudit(batchPlan, 'start'), - () => true - ); - if (!auditedStart.ok) { - return { error: getUploadAuditFailureMessage(getConfiguredLanguage()) }; - } - if (startLease.isCancelled()) return { error: 'Upload wurde abgebrochen' }; - if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' }; - persistRotation(pick); - - const sourceCleanup = createSourceFileCleanup({ - fs, - path, - platform: process.platform, - isEnabled: () => configStore.load().globalSettings?.deleteSourceAfterSuccessfulUpload === true, - audit: appendSourceCleanupAudit, - journal: sourceDeleteJournal - }); - let sourceCleanupFingerprints; - try { - sourceCleanupFingerprints = await sourceCleanup.registerGroups(sourceCleanupGroups); - } catch (error) { - return { error: `Quelldatei-Schutz konnte nicht vorbereitet werden: ${error.message}` }; - } - for (const skipped of skippedJobs) sourceCleanup.markSkipped(skipped.jobId); + debugLog(` tasks built: ${tasks.length}`); if (tasks.length === 0) { + await appendUploadPlanAudit(batchPlan, 'start'); const skippedSummary = stats.mergeSkippedIntoSummary({ id: `skipped-${Date.now()}`, timestamp: new Date().toISOString(), @@ -2391,45 +2071,25 @@ async function executeReservedUploadStart(payload, startLease) { skipped: 0, files: [] }, skippedJobs); - lastSessionSummary = skippedSummary; - const finalization = await uploadFinalizationBarrier.finalize(skippedSummary, { - id: `skipped-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, - startedAt: new Date().toISOString(), - jobIds: skippedJobs.map(job => job.jobId).filter(Boolean) - }); - if (!finalization.queuePersisted) debugLog('upload finalization blocked: renderer queue acknowledgement missing'); - if (!finalization.terminalRecoveryPersisted) debugLog('upload finalization blocked: terminal recovery state was not persisted'); - const cleanupOutcomes = await sourceCleanup.finishBatch({ - historyPersisted: finalization.historyPersisted, - queuePersisted: finalization.queuePersisted && finalization.terminalRecoveryPersisted - }); - publishBatchCompletionReport(skippedSummary, { - startedAt: batchStartedAt, - completedAt: new Date().toISOString(), - cleanupOutcomes - }); - return { - started: true, - taskCount: 0, - skippedJobs, - finalized: true, - historyPersisted: finalization.historyPersisted - }; + try { await configStore.appendHistory(skippedSummary); } catch (error) { + debugLog(`appendHistory for skipped jobs failed: ${error.message}`); + } + setImmediate(() => safeSend('upload-batch-done', skippedSummary)); + return { started: true, taskCount: 0, skippedJobs }; } uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config)); - const batchMutationGate = createBatchMutationGate(); - uploadBatchMutationGates.set(uploadManager, batchMutationGate); globalThis._mhuUploadManagerRef = uploadManager; const _thisManager = uploadManager; - const batchAdmissionSkippedJobs = [...skippedJobs]; - uploadBatchAdmissionSkips.set(_thisManager, batchAdmissionSkippedJobs); + + await appendUploadPlanAudit(batchPlan, 'start'); const recovery = { id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, - startedAt: batchStartedAt, + 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 @@ -2456,17 +2116,25 @@ async function executeReservedUploadStart(payload, startLease) { // new upload; addJobs during a running batch keeps them). _jobLogCollector.clear(); + const sourceCleanup = createSourceFileCleanup({ + fs, + path, + platform: process.platform, + isEnabled: () => configStore.load().globalSettings?.deleteSourceAfterSuccessfulUpload === true, + audit: appendSourceCleanupAudit, + journal: sourceDeleteJournal + }); + let sourceCleanupFingerprints; try { - await configStore.saveUploadRecovery(recovery); + sourceCleanupFingerprints = await sourceCleanup.registerGroups(sourceCleanupGroups); } catch (error) { - debugLog(`upload recovery state could not be saved: ${error.message}`); if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; } - return { error: 'Upload-Wiederherstellung konnte nicht gespeichert werden' }; + return { error: `Quelldatei-Schutz konnte nicht vorbereitet werden: ${error.message}` }; } - uploadRecoveryStates.set(_thisManager, recovery); + for (const skipped of skippedJobs) sourceCleanup.markSkipped(skipped.jobId); _thisManager.sourceFileCleanup = sourceCleanup; const _producerTracker = trackUploadProducer(_thisManager); @@ -2500,7 +2168,7 @@ async function executeReservedUploadStart(payload, startLease) { }); } if (data.status === 'done' && data.result) { - const link = selectPublicUploadUrl(data.result); + const link = data.result.download_url || data.result.embed_url || data.result.file_code || ''; if (link) { if (shouldLogHosterToFile(data.hoster)) { appendUploadLog(data.hoster || '', link, data.fileName || ''); @@ -2532,7 +2200,7 @@ async function executeReservedUploadStart(payload, startLease) { updateTrayTooltip(`Upload: ${data.activeJobs} aktiv - ${speedMb} MB/s`); _maybeLogEventLoopDelay(data.activeJobs); } else { - updateTrayTooltip('Multi Hoster Uploader'); + updateTrayTooltip('Multi-Hoster-Upload'); } } catch (e) { debugLog(`stats listener error: ${e && e.message}`); } }); @@ -2597,8 +2265,7 @@ async function executeReservedUploadStart(payload, startLease) { // create a fresh manager which the trailing `uploadManager = null` then // orphans (cancel/addJobs see null, the new batch keeps running invisibly). uploadManager.on('batch-done', async (summary) => { - const hadActiveBatchMutation = await batchMutationGate.sealAndDrain(); - summary = stats.mergeSkippedIntoSummary(summary, batchAdmissionSkippedJobs); + 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 }); @@ -2606,6 +2273,11 @@ async function executeReservedUploadStart(payload, startLease) { const _batchDurationSec = _thisManager && _thisManager.startTime ? Math.round((Date.now() - _thisManager.startTime) / 1000) : 0; + let historyPersisted = true; + try { await configStore.appendHistory(summary); } catch (err) { + historyPersisted = false; + debugLog(`appendHistory failed: ${err.message}`); + } if (_progressFlushTimer) { clearTimeout(_progressFlushTimer); _progressFlushTimer = null; @@ -2614,20 +2286,10 @@ async function executeReservedUploadStart(payload, startLease) { for (const value of _progressByJob.values()) finalProgressBatch.push(value); _progressByJob.clear(); if (finalProgressBatch.length) safeSend('upload-progress-batch', finalProgressBatch); - const finalization = await uploadFinalizationBarrier.finalize(summary, recovery); - const { historyPersisted, queuePersisted, terminalRecoveryPersisted } = finalization; + 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'); - if (!terminalRecoveryPersisted) debugLog('upload finalization blocked: terminal recovery state was not persisted'); - if (hadActiveBatchMutation) debugLog('source cleanup blocked: batch mutation overlapped finalization'); - const cleanupOutcomes = await sourceCleanup.finishBatch({ - historyPersisted, - queuePersisted: queuePersisted && terminalRecoveryPersisted && !hadActiveBatchMutation - }); - publishBatchCompletionReport(summary, { - startedAt: recovery.startedAt, - completedAt: new Date().toISOString(), - cleanupOutcomes - }); + await sourceCleanup.finishBatch({ historyPersisted, queuePersisted }); _producerTracker.finish(); const fullyAborted = isAllAborted(summary); @@ -2648,7 +2310,6 @@ async function executeReservedUploadStart(payload, startLease) { // Defer startBatch to next tick so the IPC response is sent first. // This ensures webContents.send() calls from upload events // are not interleaved with the handle() response. - if (startLease.isCancelled()) _thisManager.cancel(); setImmediate(() => { if (uploadManager !== _thisManager) { debugLog('setImmediate: uploadManager was replaced before startBatch'); @@ -2665,24 +2326,19 @@ async function executeReservedUploadStart(payload, startLease) { _thisManager.startBatch(tasks, { primeFailedAccounts: Array.from(_sessionFailedAccounts.keys()), primeOverrides: Array.from(_sessionAccountOverrides.entries()) - }).catch(async (err) => { + }).catch((err) => { debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`); - const hadActiveBatchMutation = await batchMutationGate.sealAndDrain(); - const errorSummary = stats.mergeSkippedIntoSummary( - buildFailedUploadSummary(tasks, 'Upload konnte nicht gestartet werden'), - batchAdmissionSkippedJobs - ); - lastSessionSummary = errorSummary; - const finalization = await uploadFinalizationBarrier.finalize(errorSummary, recovery); - const cleanupOutcomes = await sourceCleanup.finishBatch({ - historyPersisted: finalization.historyPersisted, - queuePersisted: finalization.queuePersisted && finalization.terminalRecoveryPersisted && !hadActiveBatchMutation - }); - publishBatchCompletionReport(errorSummary, { - startedAt: recovery.startedAt, - completedAt: new Date().toISOString(), - cleanupOutcomes - }); + const errorSummary = { + id: 'error', + timestamp: new Date().toISOString(), + total: tasks.length, + succeeded: 0, + failed: tasks.length, + files: [], + 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; } @@ -2692,7 +2348,7 @@ async function executeReservedUploadStart(payload, startLease) { logMemorySnapshot('batch-start'); debugLog(`start-upload returning started=true (startBatch deferred to nextTick)`); return { started: true, taskCount: tasks.length, skippedJobs, sourceCleanupFingerprints }; -} +}); // Logged at batch boundaries so we can spot memory growth between batches // across long sessions (main process side only — the renderer's live view @@ -2706,7 +2362,6 @@ function logMemorySnapshot(label) { } ipcMain.handle('cancel-upload', () => { - uploadStartReservation.cancel(); if (uploadManager) { uploadManager.cancel(); } @@ -2726,79 +2381,43 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => { return { error: 'Kein Upload aktiv' }; } const batchManager = uploadManager; - const batchMutationGate = uploadBatchMutationGates.get(batchManager); - const batchMutationLease = batchMutationGate && batchMutationGate.acquire(); - if (!batchMutationLease) return { error: 'Kein Upload aktiv' }; - try { - const config = configStore.load(); - const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : []; - const sourceCleanupGroups = payload && Array.isArray(payload.sourceCleanupGroups) ? payload.sourceCleanupGroups : []; - const pick = makeAccountPicker(config); - const tasks = buildUploadTasksFromJobs(config, jobs, pick); - const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean)); - const skippedJobs = jobs - .filter(j => j && j.id && !taskJobIds.has(j.id)) - .map(j => ({ - jobId: j.id, - file: j.file, - fileName: j.fileName || path.basename(j.file || ''), - fileKey: buildBatchFileKey(j.file), - size: Number(j.bytesTotal) || 0, - hoster: j.hoster, - reason: 'Kein gültiger Account für diesen Hoster' - })); - if (jobs.length > 0) { - const auditedAdd = await runAfterDurableAudit( - () => appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add'), - () => true - ); - if (!auditedAdd.ok) return { error: getUploadAuditFailureMessage(getConfiguredLanguage()) }; - } - const batchRecovery = uploadRecoveryStates.get(batchManager); - const newRecoveryJobIds = tasks.map(task => task.jobId).filter(Boolean); - if (batchRecovery && newRecoveryJobIds.length > 0) { - const nextRecovery = { - ...batchRecovery, - jobIds: Array.from(new Set([...(batchRecovery.jobIds || []), ...newRecoveryJobIds])) - }; - try { - await configStore.saveUploadRecovery(nextRecovery); - } catch (error) { - debugLog(`upload recovery state could not be extended: ${error.message}`); - return { error: 'Upload-Wiederherstellung konnte nicht gespeichert werden' }; - } - Object.assign(batchRecovery, nextRecovery); - } - persistRotation(pick); - const sourceCleanupFingerprints = batchManager.sourceFileCleanup - ? await batchManager.sourceFileCleanup.registerGroups(sourceCleanupGroups) - : {}; - if (uploadManager !== batchManager || !batchManager.running) { - return { error: 'Kein Upload aktiv' }; - } - if (batchManager.sourceFileCleanup) { - for (const skipped of skippedJobs) batchManager.sourceFileCleanup.markSkipped(skipped.jobId); - } - if (skippedJobs.length > 0) uploadBatchAdmissionSkips.get(batchManager)?.push(...skippedJobs); - - if (tasks.length === 0) { - debugLog(`add-jobs-to-batch: 0 tasks built (${skippedJobs.length} skipped: no account)`); - return { added: 0, skippedJobs, alreadyInBatchJobIds: [], sourceCleanupFingerprints }; - } - - const addResult = batchManager.addJobs(tasks); - const added = typeof addResult === 'number' ? addResult : (addResult && addResult.added) || 0; - const alreadyInBatchJobIds = (addResult && Array.isArray(addResult.alreadyInBatchJobIds)) - ? addResult.alreadyInBatchJobIds - : []; - - debugLog( - `add-jobs-to-batch: ${added} of ${tasks.length} tasks added (${alreadyInBatchJobIds.length} already in batch, ${skippedJobs.length} skipped)` - ); - return { added, skippedJobs, alreadyInBatchJobIds, sourceCleanupFingerprints }; - } finally { - batchMutationLease.finish(); + const config = configStore.load(); + const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : []; + const sourceCleanupGroups = payload && Array.isArray(payload.sourceCleanupGroups) ? payload.sourceCleanupGroups : []; + const pick = makeAccountPicker(config); + const tasks = buildUploadTasksFromJobs(config, jobs, pick); + persistRotation(pick); + const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean)); + const skippedJobs = jobs + .filter(j => j && j.id && !taskJobIds.has(j.id)) + .map(j => ({ jobId: j.id, hoster: j.hoster, reason: 'Kein gültiger Account für diesen Hoster' })); + const sourceCleanupFingerprints = batchManager.sourceFileCleanup + ? await batchManager.sourceFileCleanup.registerGroups(sourceCleanupGroups) + : {}; + if (uploadManager !== batchManager || !batchManager.running) { + return { error: 'Kein Upload aktiv' }; } + if (batchManager.sourceFileCleanup) { + for (const skipped of skippedJobs) batchManager.sourceFileCleanup.markSkipped(skipped.jobId); + } + + if (tasks.length === 0) { + debugLog(`add-jobs-to-batch: 0 tasks built (${skippedJobs.length} skipped: no account)`); + if (jobs.length > 0) await appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add'); + return { added: 0, skippedJobs, alreadyInBatchJobIds: [], sourceCleanupFingerprints }; + } + + const addResult = batchManager.addJobs(tasks); + const added = typeof addResult === 'number' ? addResult : (addResult && addResult.added) || 0; + const alreadyInBatchJobIds = (addResult && Array.isArray(addResult.alreadyInBatchJobIds)) + ? addResult.alreadyInBatchJobIds + : []; + + debugLog( + `add-jobs-to-batch: ${added} of ${tasks.length} tasks added (${alreadyInBatchJobIds.length} already in batch, ${skippedJobs.length} skipped)` + ); + if (jobs.length > 0) await appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add'); + return { added, skippedJobs, alreadyInBatchJobIds, sourceCleanupFingerprints }; }); ipcMain.handle('finish-after-active', () => { @@ -3292,6 +2911,10 @@ ipcMain.handle('app:quit', () => { app.quit(); }); +ipcMain.on('app:close-handshake-ready', (event) => { + if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents) closeHandshakeReady = true; +}); + ipcMain.on('app:close-preparation-started', (event, attempt) => { if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents && isClosePreparationActive(attempt)) { armCloseFlushTimer(attempt, 3000); @@ -3362,7 +2985,18 @@ ipcMain.handle('save-pending-queue', async (_event, pendingQueue) => { }); ipcMain.handle('complete-upload-finalization', async (_event, payload) => { - return uploadFinalizationCoordinator.complete(payload); + const finalizationId = payload && payload.finalizationId; + const pending = finalizationId && pendingUploadFinalizations.get(finalizationId); + if (!pending) return false; + try { + await configStore.savePendingQueue(payload.pendingQueue ?? null); + pending.resolve(true); + return true; + } catch (error) { + debugLog(`upload finalization queue save failed: ${error.message}`); + pending.resolve(false); + return false; + } }); ipcMain.handle('save-global-settings', async (_event, globalSettings) => { @@ -3517,8 +3151,8 @@ function _diagAgentInfo() { function _buildDiagnosticHandler() { const collectors = createCollectors({ - loadConfig: () => configStore.loadDiagnosticsConfig(), - loadHistory: () => configStore.loadDiagnosticsHistory(), + loadConfig: () => configStore.load(), + loadHistory: () => configStore.loadHistory(), getAllLogPaths, support: { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED }, stats, @@ -3530,21 +3164,39 @@ function _buildDiagnosticHandler() { return (msg, _client, reply) => { let result; try { result = agent.handle(msg.op, msg.args); } - catch { result = { ok: false, error: 'diagnostic response could not be safely returned' }; } + catch (e) { result = { ok: false, error: String((e && e.message) || e) }; } reply(result); }; } -function _diagAllowlist() { - return []; +function _getSuggestedRemoteHosts() { + const os = require('os'); + const hosts = []; + try { + for (const entry of Object.values(os.networkInterfaces())) { + for (const net of (entry || [])) { + if (net && net.family === 'IPv4' && !net.internal && net.address) hosts.push(net.address); + } + } + } catch {} + return [...new Set(hosts)]; } -function _diagBindHost() { +function _diagAllowlist(diag) { + return Array.isArray(diag && diag.allowlist) ? diag.allowlist.map((x) => String(x).trim()).filter(Boolean) : []; +} + +function _diagBindHost(diag) { + const mode = (diag && diag.bindMode) || 'local'; + if (mode === 'network' && _diagAllowlist(diag).length > 0) return '0.0.0.0'; return '127.0.0.1'; } -function _diagPublicHost() { - return '127.0.0.1'; +function _diagPublicHost(diag) { + const explicit = String((diag && diag.publicHost) || '').trim(); + if (explicit) return explicit; + if (_diagBindHost(diag) === '127.0.0.1') return '127.0.0.1'; + return _getSuggestedRemoteHosts()[0] || '127.0.0.1'; } function buildDiagnosticCode(diag, fp) { @@ -3596,11 +3248,11 @@ ipcMain.handle('diagnostics:get-settings', () => { return { enabled: !!diag.enabled, port: diag.port || 9110, - bindMode: 'local', + bindMode: diag.bindMode === 'network' ? 'network' : 'local', bindAddress: _diagBindHost(diag), - publicHost: '127.0.0.1', + publicHost: diag.publicHost || '', allowlist: _diagAllowlist(diag), - suggestedHosts: [], + suggestedHosts: _getSuggestedRemoteHosts(), label: diag.label || require('os').hostname(), codeIssuedAt: diag.codeIssuedAt || 0, code: diag.token ? buildDiagnosticCode(diag) : '' @@ -3615,9 +3267,11 @@ ipcMain.handle('diagnostics:save-settings', async (_e, incoming) => { ...cur, enabled: !!(incoming && incoming.enabled), port: (incoming && Number(incoming.port)) || cur.port || 9110, - bindMode: 'local', - publicHost: '127.0.0.1', - allowlist: [], + bindMode: (incoming && incoming.bindMode === 'network') ? 'network' : 'local', + publicHost: (incoming && incoming.publicHost !== null && incoming.publicHost !== undefined) ? String(incoming.publicHost).trim() : (cur.publicHost || ''), + allowlist: (incoming && Array.isArray(incoming.allowlist)) + ? incoming.allowlist.map((x) => String(x).trim()).filter(Boolean) + : _diagAllowlist(cur), label: (incoming && incoming.label !== null && incoming.label !== undefined) ? String(incoming.label) : cur.label }; next.bindAddress = _diagBindHost(next); @@ -3644,10 +3298,10 @@ ipcMain.handle('diagnostics:status', () => { return { running: !!diagnosticAgent, port: diagnosticAgent ? diagnosticAgent.getPort() : (diag.port || 9110), - bindMode: 'local', + bindMode: diag.bindMode === 'network' ? 'network' : 'local', bindAddress: _diagBindHost(diag), publicHost: _diagPublicHost(diag), - allowlistCount: 0, + allowlistCount: _diagAllowlist(diag).length, clientCount: diagnosticAgent ? diagnosticAgent.getClientCount() : 0, lastAccess: diagnosticAgent ? diagnosticAgent.getLastAccess() : null }; @@ -3656,7 +3310,7 @@ ipcMain.handle('diagnostics:status', () => { function createCaptureWindow() { if (captureWindow && !captureWindow.isDestroyed()) return; captureWindowReady = false; - captureWindow = new RuntimeBrowserWindow({ + captureWindow = new BrowserWindow({ show: false, webPreferences: { contextIsolation: true, @@ -3715,7 +3369,6 @@ async function startRemoteServer() { try { await remoteServer.start({ port: remote.port || 9100, - host: '127.0.0.1', token, allowInput: remote.allowInput !== false, mainWindow, @@ -3759,13 +3412,11 @@ ipcMain.on('remote:capture-log', (_event, msg) => { // IPC: Input events from capture window ipcMain.on('remote:input-event', (_event, data) => { if (!mainWindow || mainWindow.isDestroyed()) return; - if (!data || typeof data !== 'object' || Array.isArray(data)) return; const config = configStore.load(); const remote = config.globalSettings && config.globalSettings.remote; if (!remote || !remote.allowInput) return; if (data.role !== 'admin') return; - if ((data.type === 'keydown' || data.type === 'keyup') && (typeof data.key !== 'string' || data.key.length === 0)) return; // Capture includes window frame (title bar) but NOT invisible DWM borders // sendInputEvent coordinates are relative to web content area @@ -3932,7 +3583,7 @@ function createDropTargetWindow() { const { screen } = require('electron'); const display = screen.getPrimaryDisplay(); const { width, height } = display.workAreaSize; - dropTargetWindow = new RuntimeBrowserWindow({ + dropTargetWindow = new BrowserWindow({ width: 120, height: 120, x: width - 140, @@ -3972,7 +3623,15 @@ ipcMain.handle('hide-drop-target', () => { return true; }); -startupExternalRevealBindings.bindDropTargetFiles(ipcMain); +ipcMain.on('drop-target:files', (_event, paths) => { + if (mainWindow && !mainWindow.isDestroyed()) { + if (!mainWindow.isVisible() || mainWindow.isMinimized()) { + mainWindow.show(); + mainWindow.focus(); + } + safeSend('drop-target:files', paths); + } +}); // --- Shutdown after finish --- let shutdownMode = 'nothing'; diff --git a/package-lock.json b/package-lock.json index fa9866c..559c70a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-hoster-uploader", - "version": "2.1.23", + "version": "2.1.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-hoster-uploader", - "version": "2.1.23", + "version": "2.1.24", "dependencies": { "chokidar": "^3.6.0", "undici": "^7.29.0", diff --git a/package.json b/package.json index 58112ad..9958b9a 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,13 @@ { "name": "multi-hoster-uploader", - "version": "2.1.23", + "version": "2.1.24", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { "start": "electron .", "dev": "node scripts/dev-runner.cjs", - "test": "npm run test:unit && npm run test:ui", - "test:unit": "node --test tests/*.test.js", - "test:ui": "node --test tests/ui-smoke.js", + "test": "node --test tests/*.test.js tests/ui-smoke.js", "test:backup-api": "npm --prefix services/backup-api test", - "verify:public-source": "node scripts/verify-public-release.mjs --source-only --tracked --package-version", "verify": "npm run lint && npm test && npm run test:backup-api && npm audit --omit=dev", "lint": "eslint .", "dist": "electron-builder --publish never --win", @@ -30,7 +27,7 @@ }, "build": { "appId": "com.multihoster.uploader", - "productName": "Multi Hoster Uploader", + "productName": "Multi-Hoster-Upload", "directories": { "buildResources": "assets", "output": "release" @@ -50,19 +47,14 @@ "portable" ], "icon": "assets/app_icon.ico", - "executableName": "Multi Hoster Uploader", "signAndEditExecutable": false }, "nsis": { - "artifactName": "Multi-Hoster-Upload Setup ${version}.${ext}", "oneClick": false, "perMachine": false, "allowToChangeInstallationDirectory": true, "createDesktopShortcut": true }, - "portable": { - "artifactName": "Multi-Hoster-Upload ${version}.${ext}" - }, "afterPack": "scripts/afterPack.cjs" } } diff --git a/preload-drop-target.js b/preload-drop-target.js index 8a40dac..3d11d02 100644 --- a/preload-drop-target.js +++ b/preload-drop-target.js @@ -1,6 +1,5 @@ -const { contextBridge, ipcRenderer, webUtils } = require('electron'); +const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('dropTargetApi', { - sendFiles: (paths) => ipcRenderer.send('drop-target:files', paths), - getPathForFile: (file) => webUtils.getPathForFile(file) + sendFiles: (paths) => ipcRenderer.send('drop-target:files', paths) }); diff --git a/preload.js b/preload.js index 074b49e..a05989b 100644 --- a/preload.js +++ b/preload.js @@ -9,8 +9,6 @@ contextBridge.exposeInMainWorld('api', { 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), - getLastBatchCompletionReport: () => ipcRenderer.invoke('get-last-batch-completion-report'), - exportBatchCompletionReport: (reportId, format) => ipcRenderer.invoke('export-batch-completion-report', reportId, format), saveTextFile: (defaultName, content, filters) => ipcRenderer.invoke('save-text-file', defaultName, content, filters), // Hoster settings @@ -29,7 +27,6 @@ contextBridge.exposeInMainWorld('api', { }); }, signalCloseHandshakeReady: () => ipcRenderer.send('app:close-handshake-ready'), - signalRendererInitializationFailed: (details) => ipcRenderer.send('app:renderer-initialization-failed', details), // Always on top setAlwaysOnTop: (value) => ipcRenderer.invoke('set-always-on-top', value), @@ -120,9 +117,6 @@ contextBridge.exposeInMainWorld('api', { onUploadBatchDone: (callback) => { ipcRenderer.on('upload-batch-done', (_event, data) => callback(data)); }, - onUploadBatchReport: (callback) => { - ipcRenderer.on('upload-batch-report', (_event, data) => callback(data)); - }, onUploadStats: (callback) => { ipcRenderer.on('upload-stats', (_event, data) => callback(data)); }, @@ -169,7 +163,6 @@ contextBridge.exposeInMainWorld('api', { removeAllListeners: () => { ipcRenderer.removeAllListeners('upload-progress'); ipcRenderer.removeAllListeners('upload-batch-done'); - ipcRenderer.removeAllListeners('upload-batch-report'); ipcRenderer.removeAllListeners('upload-stats'); ipcRenderer.removeAllListeners('app:update-available'); ipcRenderer.removeAllListeners('app:update-progress'); diff --git a/renderer/app.js b/renderer/app.js index e438149..1476091 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -16,25 +16,6 @@ function localizeUiText(value) { return window.I18n.translateText(value, uiLocalizer.getLanguage()); } -function getLocalizedErrorDetail(error) { - const raw = String(error?.message || error || '').trim(); - if (raw) { - const translated = localizeUiText(raw); - if (translated !== raw) return translated; - const oppositeLanguage = uiLocalizer.getLanguage() === 'de' ? 'en' : 'de'; - if (window.I18n.translateText(raw, oppositeLanguage) !== raw) return raw; - } - return localizeUiText('Unbekannter Fehler'); -} - -function formatLocalizedError(prefix, error) { - return `${localizeUiText(prefix)}: ${getLocalizedErrorDetail(error)}`; -} - -function getCopiedLinksMessage(count) { - return count === 1 ? '1 Link kopiert' : `${count} Links kopiert`; -} - function formatRemoteClientStatus(port, count) { const clients = count === 1 ? '1 Client' : `${count} Clients`; return localizeUiText(`Aktiv auf Port ${port} — ${clients} verbunden`); @@ -57,11 +38,9 @@ function refreshLocalizedRuntimeUi() { const historyContainer = document.getElementById('historyContainer'); if (historyContainer && historyRowsData.length) renderHistoryTable(historyContainer); updateStatusBar(); - if (document.getElementById('uploadScheduleEnabledInput')) syncUploadScheduleControls(); const activeRecentTab = document.querySelector('.recent-tab.active'); const hint = document.getElementById('recentFilesHint'); if (hint && activeRecentTab) hint.textContent = localizeUiText(activeRecentTab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links'); - if (_activeBatchCompletionReport) renderBatchCompletionReport(_activeBatchCompletionReport); } // Dropdown options for "Add Account" modal: value -> label @@ -81,8 +60,6 @@ let selectedUploadHosters = []; let config = { hosters: {}, hosterSettings: {}, globalSettings: {} }; let hosterSettings = {}; let uploading = false; -let sourceCleanupFinalizationPending = false; -let sourceCleanupRevocationPending = false; let healthCheckRunning = false; let _rLongTasks = 0, _rLongTaskMax = 0, _rFrameLast = 0, _rFrameWorst = 0, _rFrameCount = 0, _rFrameJank = 0, _rPerfLastLog = 0, _rPerfWindowStart = 0; @@ -359,6 +336,17 @@ const _sessionDoneJobs = new Set(); // Job IDs already counted for uploadedBytes const _completedUploadKeys = new Set(); // 'filepath|hoster' keys for done uploads (survives removeFromQueueOnDone) const _suppressedPreviewKeys = new Set(); const _deletedJobIds = new Set(); // IDs of jobs explicitly deleted by user (prevents re-creation from stale progress callbacks) +// Coalesce removeFromQueueOnDone removals into one filter pass per microtask +// to avoid O(N²) behaviour when a burst of jobs finish at once. Logic now +// lives in lib/coalesced-set.js so it can be unit-tested with a manual +// scheduler. Optional-chained so the renderer still works if the script +// failed to load — falls back to immediate per-event filter (legacy slow +// path), better than crashing. +const _doneRemovalCoalescer = window.CoalescedSet + ? window.CoalescedSet.makeCoalescedSet({ + apply: (drop) => { queueJobs = queueJobs.filter(j => !drop.has(j.id)); } + }) + : null; const queueSortState = { key: 'filename', direction: 'asc' }; let uploadSidebarFilter = 'all'; let queueSearchQuery = ''; @@ -376,364 +364,11 @@ let historySidebarFilter = 'all'; let _knownUpdateInfo = null; let _updateCheckBusy = false; let _updateInstallBusy = false; +let _updateDialogReturnFocus = null; +let _updateDialogInertState = []; let _startupAutoResumeController = null; let _startupAutoResumeCanceled = false; -const modalController = (() => { - const stack = []; - const baselineInert = new Map(); - const focusSelector = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; - - const resolveOverlay = value => typeof value === 'string' ? document.getElementById(value) : value; - const getDialog = overlay => overlay?.querySelector('[role="dialog"]') || overlay; - const isFocusable = element => { - if (!(element instanceof HTMLElement) || element.hidden || element.matches(':disabled')) return false; - const style = window.getComputedStyle(element); - return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0; - }; - const getFocusable = overlay => Array.from(getDialog(overlay)?.querySelectorAll(focusSelector) || []).filter(isFocusable); - const resolveFocusTarget = (entry, value) => { - const candidate = typeof value === 'function' - ? value() - : typeof value === 'string' - ? entry.overlay.querySelector(value) || document.querySelector(value) - : value; - return isFocusable(candidate) ? candidate : null; - }; - const syncIsolation = () => { - const top = stack.at(-1); - if (!top) { - baselineInert.forEach((inert, element) => { - if (element.isConnected) element.inert = inert; - }); - baselineInert.clear(); - return; - } - Array.from(document.body.children).forEach(element => { - if (!baselineInert.has(element)) baselineInert.set(element, element.inert); - element.inert = element !== top.overlay; - }); - }; - const focusEntry = entry => { - if (!entry || stack.at(-1) !== entry) return; - const dialog = getDialog(entry.overlay); - const target = resolveFocusTarget(entry, entry.options.initialFocus) || getFocusable(entry.overlay)[0] || dialog; - if (target instanceof HTMLElement && !target.matches(':disabled')) target.focus(); - }; - const open = (value, options = {}) => { - const overlay = resolveOverlay(value); - if (!overlay) return false; - let entry = stack.find(item => item.overlay === overlay); - if (entry) { - entry.options = { ...entry.options, ...options }; - stack.splice(stack.indexOf(entry), 1); - stack.push(entry); - } else { - entry = { - overlay, - options, - returnFocus: options.returnFocus ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null) - }; - stack.push(entry); - } - overlay.style.display = options.display || 'flex'; - overlay.setAttribute('aria-hidden', 'false'); - overlay.inert = false; - syncIsolation(); - if (!getDialog(overlay)?.contains(document.activeElement)) focusEntry(entry); - requestAnimationFrame(() => { - if (stack.at(-1) === entry && !getDialog(overlay)?.contains(document.activeElement)) focusEntry(entry); - }); - return true; - }; - const close = (value, options = {}) => { - const overlay = resolveOverlay(value); - const index = stack.findIndex(item => item.overlay === overlay); - if (index < 0) { - if (overlay) { - overlay.style.display = 'none'; - overlay.setAttribute('aria-hidden', 'true'); - } - return false; - } - const wasTop = index === stack.length - 1; - const [entry] = stack.splice(index, 1); - overlay.style.display = 'none'; - overlay.setAttribute('aria-hidden', 'true'); - syncIsolation(); - if (wasTop && options.restoreFocus !== false) { - const fallback = resolveFocusTarget(entry, options.fallbackFocus ?? entry.options.fallbackFocus); - const target = resolveFocusTarget(entry, entry.returnFocus) || fallback; - if (target) target.focus(); - else if (stack.length > 0) focusEntry(stack.at(-1)); - } - return true; - }; - const isOpen = value => { - const overlay = resolveOverlay(value); - return Boolean(overlay && stack.some(item => item.overlay === overlay)); - }; - const handleKeydown = event => { - const entry = stack.at(-1); - if (!entry) return; - const dialog = getDialog(entry.overlay); - if (!dialog) return; - if (event.key === 'Escape') { - event.preventDefault(); - event.stopImmediatePropagation(); - entry.options.onEscape?.(); - return; - } - if (event.key !== 'Tab') return; - const focusable = getFocusable(entry.overlay); - event.stopImmediatePropagation(); - if (focusable.length === 0) { - event.preventDefault(); - dialog.focus(); - return; - } - const first = focusable[0]; - const last = focusable.at(-1); - if (!dialog.contains(document.activeElement) || (event.shiftKey && document.activeElement === first) || (!event.shiftKey && document.activeElement === last)) { - event.preventDefault(); - (event.shiftKey ? last : first).focus(); - } - }; - const handleClick = event => { - const entry = stack.at(-1); - if (!entry || event.target !== entry.overlay || typeof entry.options.onBackdrop !== 'function') return; - event.preventDefault(); - event.stopImmediatePropagation(); - entry.options.onBackdrop(); - }; - - document.addEventListener('keydown', handleKeydown, true); - document.addEventListener('click', handleClick, true); - return { open, close, isOpen }; -})(); - -const _shownBatchCompletionReportIds = new Set(); -let _activeBatchCompletionReport = null; -let _batchCompletionReportUiReady = false; - -function batchReportNumber(value) { - const number = Number(value); - return Number.isFinite(number) && number > 0 ? number : 0; -} - -function batchReportInteger(value) { - return Math.max(0, Math.trunc(batchReportNumber(value))); -} - -function setBatchCompletionValue(id, value) { - const element = document.getElementById(id); - if (element) element.textContent = batchReportInteger(value).toLocaleString(getUiLocale()); -} - -function getBatchCompletionOutcome(report) { - const files = report?.files || {}; - const jobs = report?.jobs || {}; - const cleanup = report?.cleanup || {}; - return batchReportInteger(files.partiallySucceeded) > 0 - || batchReportInteger(files.failed) > 0 - || batchReportInteger(jobs.failed) > 0 - || batchReportInteger(jobs.skipped) > 0 - || batchReportInteger(jobs.aborted) > 0 - || batchReportInteger(cleanup.blocked) > 0 - || batchReportInteger(cleanup.failed) > 0 - || (Array.isArray(report?.errors) && report.errors.length > 0) - ? 'mixed' - : 'success'; -} - -function getBatchErrorCategoryLabel(category) { - const labels = { - network: 'Netzwerk', - 'hoster-transient': 'Temporärer Hosterfehler', - 'file-rejected': 'Datei abgelehnt', - 'account-error': 'Account-Fehler', - aborted: 'Abgebrochen', - unknown: 'Unbekannt' - }; - return localizeUiText(labels[String(category || '')] || 'Unbekannt'); -} - -function getBatchErrorStatusLabel(status) { - const labels = { - done: 'Erfolgreich', - error: 'Fehlgeschlagen', - skipped: 'Übersprungen', - aborted: 'Abgebrochen' - }; - return localizeUiText(labels[String(status || '')] || 'Fehlgeschlagen'); -} - -function renderBatchCompletionHosters(report) { - const body = document.getElementById('batchCompletionHostersBody'); - if (!body) return; - const rows = Object.entries(report?.hosters && typeof report.hosters === 'object' ? report.hosters : {}).map(([hoster, values]) => { - const row = document.createElement('tr'); - row.dataset.hoster = hoster; - const host = document.createElement('th'); - host.scope = 'row'; - host.textContent = getHosterLabel(hoster); - row.appendChild(host); - [ - batchReportInteger(values?.total).toLocaleString(getUiLocale()), - batchReportInteger(values?.succeeded).toLocaleString(getUiLocale()), - batchReportInteger(values?.failed).toLocaleString(getUiLocale()), - batchReportInteger(values?.skipped).toLocaleString(getUiLocale()), - batchReportInteger(values?.aborted).toLocaleString(getUiLocale()), - formatBytes(batchReportNumber(values?.successfulBytes)) - ].forEach(value => { - const cell = document.createElement('td'); - cell.textContent = value; - row.appendChild(cell); - }); - return row; - }); - body.replaceChildren(...rows); -} - -function renderBatchCompletionErrors(report) { - const section = document.getElementById('batchCompletionErrorsSection'); - const list = document.getElementById('batchCompletionErrorsList'); - const count = document.getElementById('batchCompletionErrorsCount'); - const more = document.getElementById('batchCompletionErrorsMore'); - if (!section || !list || !count || !more) return; - const errors = Array.isArray(report?.errors) ? report.errors : []; - section.hidden = errors.length === 0; - count.textContent = errors.length.toLocaleString(getUiLocale()); - const items = errors.slice(0, 5).map(error => { - const item = document.createElement('li'); - const head = document.createElement('div'); - head.className = 'batch-completion-error-head'; - const file = document.createElement('strong'); - file.className = 'batch-completion-error-file'; - file.textContent = String(error?.fileName || localizeUiText('Unbekannt')); - const hoster = document.createElement('span'); - hoster.textContent = getHosterLabel(String(error?.hoster || '')); - head.append(file, hoster); - const meta = document.createElement('div'); - meta.className = 'batch-completion-error-meta'; - const status = document.createElement('span'); - status.textContent = getBatchErrorStatusLabel(error?.status); - const category = document.createElement('span'); - category.textContent = getBatchErrorCategoryLabel(error?.category); - meta.append(status, category); - const attempt = batchReportInteger(error?.attempt); - const maxAttempts = batchReportInteger(error?.maxAttempts); - if (attempt > 0 || maxAttempts > 0) { - const attemptLabel = document.createElement('span'); - attemptLabel.textContent = `${localizeUiText('Versuch')} ${attempt}${maxAttempts > 0 ? `/${maxAttempts}` : ''}`; - meta.appendChild(attemptLabel); - } - if (error?.remoteCommitUncertain === true) { - const uncertain = document.createElement('span'); - uncertain.className = 'batch-completion-error-uncertain'; - uncertain.textContent = localizeUiText('Remote-Abschluss unklar'); - meta.appendChild(uncertain); - } - const message = document.createElement('p'); - message.className = 'batch-completion-error-message'; - message.textContent = String(error?.message || localizeUiText('Unbekannter Fehler')); - item.append(head, meta, message); - return item; - }); - list.replaceChildren(...items); - const remaining = Math.max(0, errors.length - items.length); - more.hidden = remaining === 0; - more.textContent = remaining === 1 ? localizeUiText('1 weiterer Fehler') : localizeUiText(`${remaining} weitere Fehler`); -} - -function renderBatchCompletionReport(report) { - const modal = document.getElementById('batchCompletionModal'); - if (!modal || !report) return false; - _activeBatchCompletionReport = report; - const outcome = getBatchCompletionOutcome(report); - modal.dataset.reportId = String(report.reportId); - modal.dataset.outcome = outcome; - const outcomeLabel = document.getElementById('batchCompletionOutcome'); - if (outcomeLabel) outcomeLabel.textContent = localizeUiText(outcome === 'success' ? 'Erfolgreich' : 'Mit Problemen'); - const fileCount = batchReportInteger(report.files?.total); - const jobCount = batchReportInteger(report.jobs?.total); - const summary = document.getElementById('batchCompletionSummary'); - if (summary) summary.textContent = `${fileCount.toLocaleString(getUiLocale())} ${localizeUiText(fileCount === 1 ? 'Datei' : 'Dateien')} · ${jobCount.toLocaleString(getUiLocale())} ${localizeUiText(jobCount === 1 ? 'Auftrag' : 'Aufträge')} · ${formatDateTime(report.completedAt).text}`; - setBatchCompletionValue('batchCompletionFilesTotal', report.files?.total); - setBatchCompletionValue('batchCompletionFilesFullySucceeded', report.files?.fullySucceeded); - setBatchCompletionValue('batchCompletionFilesPartiallySucceeded', report.files?.partiallySucceeded); - setBatchCompletionValue('batchCompletionFilesFailed', report.files?.failed); - setBatchCompletionValue('batchCompletionJobsTotal', report.jobs?.total); - setBatchCompletionValue('batchCompletionJobsSucceeded', report.jobs?.succeeded); - setBatchCompletionValue('batchCompletionJobsFailed', report.jobs?.failed); - setBatchCompletionValue('batchCompletionJobsSkipped', report.jobs?.skipped); - setBatchCompletionValue('batchCompletionJobsAborted', report.jobs?.aborted); - setBatchCompletionValue('batchCompletionCleanupRequested', report.cleanup?.requested); - setBatchCompletionValue('batchCompletionCleanupDeleted', report.cleanup?.deleted); - setBatchCompletionValue('batchCompletionCleanupBlocked', report.cleanup?.blocked); - setBatchCompletionValue('batchCompletionCleanupFailed', report.cleanup?.failed); - const duration = document.getElementById('batchCompletionDuration'); - const bytes = document.getElementById('batchCompletionSuccessfulBytes'); - const speed = document.getElementById('batchCompletionAverageSpeed'); - if (duration) duration.textContent = formatDuration(Math.round(batchReportNumber(report.durationSec))); - if (bytes) bytes.textContent = formatBytes(batchReportNumber(report.transfer?.successfulBytes)); - if (speed) speed.textContent = `${formatBytes(batchReportNumber(report.transfer?.averageBytesPerSecond))}/s`; - renderBatchCompletionHosters(report); - renderBatchCompletionErrors(report); - uiLocalizer.translate(modal); - return true; -} - -function closeBatchCompletionReport() { - modalController.close('batchCompletionModal', { fallbackFocus: '#addFilesBtn' }); -} - -function showBatchCompletionReport(report) { - const reportId = typeof report?.reportId === 'string' ? report.reportId.trim() : ''; - if (!reportId || _shownBatchCompletionReportIds.has(reportId)) return false; - _shownBatchCompletionReportIds.add(reportId); - if (!renderBatchCompletionReport({ ...report, reportId })) return false; - return modalController.open('batchCompletionModal', { - initialFocus: '#batchCompletionHeaderCloseBtn', - fallbackFocus: '#addFilesBtn', - onEscape: closeBatchCompletionReport - }); -} - -async function exportVisibleBatchCompletionReport(format, button) { - const reportId = _activeBatchCompletionReport?.reportId; - if (!reportId || !window.api?.exportBatchCompletionReport) return; - button.disabled = true; - try { - const result = await window.api.exportBatchCompletionReport(reportId, format); - if (result?.ok) showCopyToast(format === 'json' ? 'JSON-Bericht exportiert' : 'Fehler-CSV exportiert'); - else if (!result?.canceled) await showAppAlert(result?.error || 'Batch-Bericht konnte nicht exportiert werden.', 'Export fehlgeschlagen'); - } catch (error) { - await showAppAlert(getLocalizedErrorDetail(error), 'Export fehlgeschlagen'); - } finally { - button.disabled = false; - } -} - -function setupBatchCompletionReportUi() { - if (_batchCompletionReportUiReady) return; - _batchCompletionReportUiReady = true; - document.getElementById('batchCompletionHeaderCloseBtn')?.addEventListener('click', closeBatchCompletionReport); - document.getElementById('batchCompletionCloseBtn')?.addEventListener('click', closeBatchCompletionReport); - const jsonButton = document.getElementById('batchCompletionExportJsonBtn'); - const csvButton = document.getElementById('batchCompletionExportCsvBtn'); - jsonButton?.addEventListener('click', () => exportVisibleBatchCompletionReport('json', jsonButton)); - csvButton?.addEventListener('click', () => exportVisibleBatchCompletionReport('csv', csvButton)); - window.api?.onUploadBatchReport?.(showBatchCompletionReport); -} - -async function showLastBatchCompletionReport() { - if (!window.api?.getLastBatchCompletionReport) return; - try { - showBatchCompletionReport(await window.api.getLastBatchCompletionReport()); - } catch {} -} - // Session-specific files for the "Files" panel (resets each session) let sessionFilesData = []; let _recentSeqCounter = 0; @@ -765,7 +400,7 @@ async function init() { try { config = await window.api.getConfig(); } catch (error) { - await showAppAlert(getLocalizedErrorDetail(error), 'Zugangsdaten gesperrt'); + await showAppAlert(error.message || String(error), 'Zugangsdaten gesperrt'); throw error; } setUiLanguage(config.globalSettings?.language); @@ -789,7 +424,6 @@ async function init() { renderRecentUploadsPanel(); updateUploadView(); updateStatusBar(); - await showLastBatchCompletionReport(); 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); @@ -811,18 +445,16 @@ async function init() { } window.api.onUploadBatchDone(async (data) => { const summary = data && data.summary ? data.summary : data; - const requiresFinalization = Boolean(data && data.finalizationId && window.api.completeUploadFinalization); - if (!requiresFinalization) { - handleBatchDone(summary); - return; - } - sourceCleanupFinalizationPending = true; - try { - handleBatchDone(summary, { deferPersistence: true, historyPersisted: data.historyPersisted === true }); - await completeSourceCleanupFinalization(data); - } finally { - sourceCleanupFinalizationPending = false; - updateQueueActionButtons(); + handleBatchDone(summary); + if (data && data.finalizationId && window.api.completeUploadFinalization) { + if (_doneRemovalCoalescer) _doneRemovalCoalescer.drainSync(); + queuePersistThrottle.cancel(); + await window.api.completeUploadFinalization({ + finalizationId: data.finalizationId, + pendingQueue: queueJobs.some((job) => !['done', 'skipped'].includes(job.status)) + ? buildPersistedQueueState() + : null + }); } }); window.api.onUploadStats((data) => { @@ -858,7 +490,7 @@ async function init() { }); // Folder monitor: auto-queue new files - window.api.onFolderMonitorNewFiles(async (files) => { + window.api.onFolderMonitorNewFiles((files) => { window.api.debugLog('folder-monitor: received ' + files.length + ' file(s)'); const fm = config.globalSettings && config.globalSettings.folderMonitor; const fmHosters = fm && Array.isArray(fm.hosters) && fm.hosters.length > 0 ? fm.hosters : []; @@ -876,29 +508,35 @@ async function init() { const name = p.split('\\').pop().split('/').pop(); newFiles.push({ path: p, name, size: null }); } - const admitted = admitFilenameFilter(newFiles); - if (admitted.accepted.length > 0) { - const newPaths = new Set(admitted.accepted.map(f => f.path)); + if (newFiles.length > 0) { + const newPaths = new Set(newFiles.map(f => f.path)); clearDedupKeysForPaths(newPaths); - selectedFiles.push(...admitted.accepted); + selectedFiles.push(...newFiles); buildQueuePreview(); updateUploadView(); - if (admitted.active && admitted.excluded.length > 0) showFilenameFilterResult(admitted); if (fm.autoStart && !uploading && !healthCheckRunning) { startUpload(); } else if (uploading) { // Inject new preview jobs into the running batch const newJobs = queueJobs.filter(j => j.status === 'preview' && newPaths.has(j.file)); if (newJobs.length > 0) { - await startSelectedUpload(newJobs); + const cleanupPreparation = prepareSourceCleanup(newJobs); + newJobs.forEach(j => { j.status = 'queued'; }); + renderQueueTable(); + window.api.addJobsToBatch({ + jobs: newJobs.map(serializeUploadJob), + sourceCleanupGroups: cleanupPreparation.groups + }).then(result => { + _markSkippedJobs(result); + if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); + }).catch(() => {}); + persistQueueStateSoon(true); } } - } else if (admitted.active && admitted.total > 0) { - showFilenameFilterResult(admitted); } } else { // No pre-selected hosters: open modal - await addPathsToQueue(files); + addPathsToQueue(files); } }); @@ -908,8 +546,8 @@ async function init() { }); // Drop target window: files dropped on the small floating window - window.api.onDropTargetFiles((entries) => { - addDropTargetEntries(entries).catch(console.error); + window.api.onDropTargetFiles((paths) => { + addPathsToQueue(paths); }); // Remote client count updates (registered once, not per renderSettings call) @@ -1141,8 +779,8 @@ async function _handleMenuAction(action) { const res = await window.api.createSupportBundle(); if (res && res.ok) showCopyToast(`Diagnose-Paket gespeichert (${(res.bytes / 1024).toFixed(1)} KB)`); else if (res && res.canceled) showCopyToast('Abgebrochen'); - else showCopyToast(formatLocalizedError('Fehler', res?.error)); - } catch (err) { showCopyToast(formatLocalizedError('Fehler', err)); } + else showCopyToast(`Fehler: ${(res && res.error) || 'unbekannt'}`); + } catch (err) { showCopyToast(`Fehler: ${err.message || err}`); } break; } case 'check-updates': { @@ -1486,7 +1124,6 @@ function renderHosterModal() { renderImportPlanSummary(); }); }); - renderImportPlanSummary(); } @@ -1494,26 +1131,19 @@ function openHosterModal() { syncSelectedUploadHosters(); renderHosterModal(); const description = document.getElementById('hosterModalDescription'); - if (description) { - description.textContent = localizeUiText('Vorabprüfung abgeschlossen. Wähle jetzt die Hoster für den Upload.'); - } - modalController.open('hosterModal', { - initialFocus: '#cancelHosterModalBtn', - fallbackFocus: '#addFilesBtn', - onEscape: cancelHosterModal, - onBackdrop: cancelHosterModal - }); + if (description) description.textContent = 'Vorabprüfung abgeschlossen. Wähle jetzt die Hoster für den Upload.'; + document.getElementById('hosterModal').style.display = 'flex'; } function closeHosterModal() { - modalController.close('hosterModal', { fallbackFocus: '#addFilesBtn' }); + const modal = document.getElementById('hosterModal'); + if (modal) modal.style.display = 'none'; } -async function applyHosterSelection() { +function applyHosterSelection() { if (isImportConfirmationBlocked()) return false; selectedUploadHosters = Array.from(document.querySelectorAll('input[data-hoster-modal]:checked')) .map(input => input.dataset.hosterModal); - // Move pending files to selectedFiles on confirm const admittedFiles = _pendingFiles.filter(file => window.ImportPreflight .getEligibleImportHosters(file, selectedUploadHosters, hosterSettings).length > 0); const pendingPaths = new Set(admittedFiles.map(f => f.path)); @@ -1531,14 +1161,25 @@ async function applyHosterSelection() { buildQueuePreview(); // creates 'preview' jobs for new files const newJobs = queueJobs.filter(j => j.status === 'preview' && pendingPaths.has(j.file)); if (newJobs.length > 0) { - await startSelectedUpload(newJobs); + const cleanupPreparation = prepareSourceCleanup(newJobs); + newJobs.forEach(j => { j.status = 'queued'; }); + renderQueueTable(); + window.api.addJobsToBatch({ + jobs: newJobs.map(serializeUploadJob), + sourceCleanupGroups: cleanupPreparation.groups + }).then(result => { + _markSkippedJobs(result); + if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); + }).catch(() => {}); + persistQueueStateSoon(true); } } updateUploadView(); persistQueueStateSoon(true); // immediate persist after adding files - closeHosterModal(); _pendingImportInspection = null; + document.getElementById('hosterModal').style.display = 'none'; + return true; } function cancelHosterModal() { @@ -1587,53 +1228,35 @@ function restoreQueueStateFromConfig() { .map(file => ({ path: file.path, name: file.name || file.path.split(/[\\/]/).pop(), size: file.size || 0 })) : []; - const uploadRecovery = config?.globalSettings?.uploadRecovery || null; + 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) - .map(job => { - const id = job.id || `restored-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const recoveryOutcome = window.UploadRecovery.getRecoveryOutcome({ ...job, id }, uploadRecovery); - const status = normalizeRestoredJobStatus(recoveryOutcome.status); - return { - id, - uploadId: null, - file: job.file || '', - fileName: job.fileName, - hoster: job.hoster, - status, - bytesUploaded: status === 'done' ? (job.bytesTotal || 0) : 0, - bytesTotal: job.bytesTotal || 0, - speedKbs: 0, - elapsed: 0, - remaining: 0, - error: Object.hasOwn(recoveryOutcome, 'error') ? recoveryOutcome.error : (job.error || null), - failureDetails: Object.hasOwn(recoveryOutcome, 'failureDetails') ? recoveryOutcome.failureDetails : (job.failureDetails || null), - remoteCommitUncertain: Object.hasOwn(recoveryOutcome, 'remoteCommitUncertain') - ? recoveryOutcome.remoteCommitUncertain === true - : job.remoteCommitUncertain === true, - historyPending: Object.hasOwn(recoveryOutcome, 'historyPending') - ? recoveryOutcome.historyPending === true - : job.historyPending === true, - interrupted: recoveryOutcome.interrupted === true, - result: Object.hasOwn(recoveryOutcome, 'result') ? recoveryOutcome.result : (job.result || null), - sourceCleanupMetadataVersion: job.sourceCleanupMetadataVersion === 2 ? 2 : null, - sourceCleanupToken: job.sourceCleanupToken || null, - sourceCleanupRequiredHosters: Array.isArray(job.sourceCleanupRequiredHosters) ? [...job.sourceCleanupRequiredHosters] : [], - sourceCleanupConfirmedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupConfirmedHosters) - ? [...job.sourceCleanupConfirmedHosters] - : [], - sourceCleanupProvisionalHosters: [], - sourceCleanupStartedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupStartedHosters) - ? [...job.sourceCleanupStartedHosters] - : undefined, - sourceCleanupFingerprint: job.sourceCleanupFingerprint || null, - attempt: 0, - maxAttempts: job.maxAttempts || 0, - link: '', - progress: status === 'done' ? 1 : 0 - }; - }) + .map(job => ({ + id: job.id || `restored-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + uploadId: null, + file: job.file || '', + fileName: job.fileName, + hoster: job.hoster, + status: normalizeRestoredJobStatus(job.status), + bytesUploaded: job.status === 'done' ? (job.bytesTotal || 0) : 0, + bytesTotal: job.bytesTotal || 0, + speedKbs: 0, + 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] : [], + sourceCleanupCompletedHosters: Array.isArray(job.sourceCleanupCompletedHosters) ? [...job.sourceCleanupCompletedHosters] : [], + sourceCleanupFingerprint: job.sourceCleanupFingerprint || null, + attempt: 0, + maxAttempts: job.maxAttempts || 0, + link: '', + progress: job.status === 'done' ? 1 : 0 + })) : []; // Deduplicate: keep the job with the best status for each file+hoster pair @@ -1650,10 +1273,8 @@ function restoreQueueStateFromConfig() { rebuildJobIndex(); } -function buildPersistedQueueState({ historyPersisted = true } = {}) { - const persistableJobs = historyPersisted - ? queueJobs.filter(job => !['done', 'skipped'].includes(job.status) || job.historyPending === true) - : queueJobs; +function buildPersistedQueueState() { + const persistableJobs = queueJobs.filter(job => !['done', 'skipped'].includes(job.status)); const selectedFileMap = new Map(selectedFiles.map(file => [file.path, file])); for (const job of persistableJobs) { @@ -1666,7 +1287,7 @@ function buildPersistedQueueState({ historyPersisted = true } = {}) { } } - if (historyPersisted && selectedFileMap.size === 0 && queueJobs.every(job => ['done', 'skipped'].includes(job.status) && job.historyPending !== true)) { + if (selectedFileMap.size === 0 && queueJobs.every(job => ['done', 'skipped'].includes(job.status))) { return null; } @@ -1692,7 +1313,7 @@ function buildPersistedQueueState({ historyPersisted = true } = {}) { selectedFiles: Array.from(selectedFileMap.values()), completedKeys, suppressedKeys, - queueJobs: persistableJobs.map(job => { + queueJobs: queueJobs.map(job => { const isTerminal = TERMINAL.has(job.status); return { id: job.id, @@ -1703,18 +1324,10 @@ function buildPersistedQueueState({ historyPersisted = true } = {}) { bytesTotal: job.bytesTotal || 0, error: isTerminal ? (job.error || null) : null, failureDetails: isTerminal ? (job.failureDetails || null) : null, - remoteCommitUncertain: job.remoteCommitUncertain === true, - historyPending: job.historyPending === true, result: isTerminal ? (job.result || null) : null, - sourceCleanupMetadataVersion: job.sourceCleanupToken ? 2 : null, sourceCleanupToken: job.sourceCleanupToken || null, sourceCleanupRequiredHosters: Array.isArray(job.sourceCleanupRequiredHosters) ? [...job.sourceCleanupRequiredHosters] : [], - sourceCleanupConfirmedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupConfirmedHosters) - ? [...job.sourceCleanupConfirmedHosters] - : [], - sourceCleanupStartedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupStartedHosters) - ? [...job.sourceCleanupStartedHosters] - : undefined, + sourceCleanupCompletedHosters: Array.isArray(job.sourceCleanupCompletedHosters) ? [...job.sourceCleanupCompletedHosters] : [], sourceCleanupFingerprint: job.sourceCleanupFingerprint || null, maxAttempts: job.maxAttempts || 0 }; @@ -1783,43 +1396,15 @@ function setupDragDrop() { }); } -let _pendingFiles = []; // Files waiting for hoster modal confirmation +let _pendingFiles = []; let _pendingImportInspection = null; let _importCoordination = Promise.resolve(); let _importGeneration = 0; let _pendingImportInspections = 0; - let _addingDropped = false; -function admitFilenameFilter(files) { - return window.FilenameFilter.applyFilenameFilter(files, config?.globalSettings?.filenameFilter); -} - -function formatFilenameFilterResult(result) { - const accepted = Array.isArray(result.accepted) ? result.accepted.length : Number(result.accepted) || 0; - const excluded = Array.isArray(result.excluded) ? result.excluded.length : Number(result.excluded) || 0; - return localizeUiText(`${accepted} von ${result.total} Dateien werden hinzugefügt. ${excluded} durch den Dateinamenfilter ausgeschlossen.`); -} - -function showFilenameFilterResult(result) { - showCopyToast(formatFilenameFilterResult(result), 6500); -} - -function mergePendingImportInspection(result) { - const current = _pendingImportInspection || { - candidateCount: 0, - duplicateCount: 0, - filteredCount: 0, - unavailableCount: 0, - accepted: [] - }; - _pendingImportInspection = { - candidateCount: current.candidateCount + result.candidateCount, - duplicateCount: current.duplicateCount + result.duplicateCount, - filteredCount: current.filteredCount + result.filteredCount, - unavailableCount: current.unavailableCount + result.unavailableCount, - accepted: current.accepted.concat(result.accepted) - }; +function existingImportPaths() { + return [...selectedFiles.map(file => file.path), ..._pendingFiles.map(file => file.path), ...queueJobs.map(job => job.file)]; } function getImportPlanHosters() { @@ -1854,7 +1439,6 @@ function renderImportPlanSummary() { const values = { importPlanCandidates: summary.candidateCount, importPlanDuplicates: summary.duplicateCount, - importPlanFiltered: summary.filteredCount, importPlanUnavailable: summary.unavailableCount, importPlanAccepted: summary.acceptedCount, importPlanTargets: summary.targetCount, @@ -1863,7 +1447,7 @@ function renderImportPlanSummary() { }; for (const [id, value] of Object.entries(values)) { const element = document.getElementById(id); - if (element) element.textContent = String(value); + if (element) element.textContent = (Number(value) || 0).toLocaleString(getUiLocale()); } } @@ -1871,18 +1455,28 @@ function formatRejectedImportBalance(inspection) { const values = [ ['Kandidaten', inspection.candidateCount], ['Bereits vorhanden / dupliziert', inspection.duplicateCount], - ['Durch Dateinamenfilter ausgeschlossen', inspection.filteredCount], ['Fehlend / unlesbar / leer', inspection.unavailableCount], ['Akzeptierte Dateien', inspection.acceptedCount] ]; - return values.map(([label, value]) => `${localizeUiText(label)}: ${Number(value) || 0}`).join(' · '); + return values.map(([label, value]) => `${localizeUiText(label)}: ${(Number(value) || 0).toLocaleString(getUiLocale())}`).join(' · '); } -function existingImportPaths() { - return [...selectedFiles.map(file => file.path), ..._pendingFiles.map(file => file.path), ...queueJobs.map(job => job.file)]; +function mergePendingImportInspection(result) { + const current = _pendingImportInspection || { + candidateCount: 0, + duplicateCount: 0, + unavailableCount: 0, + accepted: [] + }; + _pendingImportInspection = { + candidateCount: current.candidateCount + result.candidateCount, + duplicateCount: current.duplicateCount + result.duplicateCount, + unavailableCount: current.unavailableCount + result.unavailableCount, + accepted: current.accepted.concat(result.accepted) + }; } -function coordinateImportEntries(entries, inspectEntries) { +function coordinateImportEntries(entries) { const candidates = Array.isArray(entries) ? entries : []; if (candidates.length === 0) return Promise.resolve(null); const generation = _importGeneration; @@ -1892,10 +1486,7 @@ function coordinateImportEntries(entries, inspectEntries) { if (generation !== _importGeneration) return null; let inspection; try { - const inspect = typeof inspectEntries === 'function' - ? inspectEntries - : (values, existingPaths) => window.api.inspectImportFiles(values, existingPaths); - inspection = await inspect(candidates, existingImportPaths()); + inspection = await window.api.inspectImportFiles(candidates, existingImportPaths()); } catch { if (generation !== _importGeneration) return null; showCopyToast('Vorabprüfung fehlgeschlagen.', 6500); @@ -1911,11 +1502,8 @@ function coordinateImportEntries(entries, inspectEntries) { const acceptedPaths = new Set(inspection.accepted.map(file => file.path)); clearDedupKeysForPaths(acceptedPaths); _pendingFiles.push(...inspection.accepted); - if (document.getElementById('hosterModal')?.style.display === 'flex') { - renderHosterModal(); - } else { - openHosterModal(); - } + if (document.getElementById('hosterModal')?.style.display === 'flex') renderHosterModal(); + else openHosterModal(); } else if (_pendingFiles.length > 0) { renderImportPlanSummary(); } else { @@ -1934,55 +1522,25 @@ function coordinateImportEntries(entries, inspectEntries) { return pending; } -async function addDropTargetEntries(entries) { - const files = []; - for (const entry of Array.isArray(entries) ? entries : []) { - const filePath = typeof entry === 'string' ? entry : entry?.path; - if (!filePath) continue; - if (entry && typeof entry === 'object' && entry.isDirectory) { - try { - const folderFiles = await window.api.resolveFolderFiles(filePath); - if (Array.isArray(folderFiles)) files.push(...folderFiles); - } catch {} - continue; - } - files.push(entry); - } - await addPathsToQueue(files); -} - async function addDroppedFiles(fileList) { if (_addingDropped) return; _addingDropped = true; try { - const files = Array.from(fileList); const entries = []; - - for (const file of files) { + for (const file of Array.from(fileList)) { let filePath = ''; try { filePath = window.api.getPathForFile(file); } catch { filePath = file.path || ''; } if (!filePath) continue; - - // Detect folders: directories report size 0 and empty type in Electron drag-and-drop if (file.type === '' && file.size === 0) { try { const folderFiles = await window.api.resolveFolderFiles(filePath); if (folderFiles && folderFiles.length > 0) { - for (const fp of folderFiles) { - const p = typeof fp === 'string' ? fp : (fp && fp.path); - if (!p) continue; - const name = typeof fp === 'string' ? p.split('\\').pop().split('/').pop() : (fp.name || p.split('\\').pop().split('/').pop()); - const size = typeof fp === 'string' ? null : (fp.size || 0); - entries.push({ path: p, name, size }); - } + entries.push(...folderFiles); continue; } } catch {} } - - // Regular file - const fileName = file.name || ''; - entries.push({ path: filePath, name: fileName, size: file.size }); + entries.push({ path: filePath, name: file.name || '', size: file.size }); } await addPathsToQueue(entries); } finally { @@ -1998,13 +1556,13 @@ async function pickFiles() { async function pickFolder() { const richFiles = window.api.selectFolderWithSizes ? await window.api.selectFolderWithSizes() : null; - if (richFiles && Array.isArray(richFiles)) { await addPathsToQueue(richFiles); return; } + if (richFiles && Array.isArray(richFiles)) return addPathsToQueue(richFiles); const paths = await window.api.selectFolder(); if (!paths) return; - await addPathsToQueue(paths); + return addPathsToQueue(paths); } -async function addPathsToQueue(paths) { +function addPathsToQueue(paths) { return coordinateImportEntries(paths); } @@ -2033,7 +1591,7 @@ function updateStartButton() { const hosters = getSelectedHosters(); const hasQueuedJobs = queueJobs.some(isStartableQueueJob); const canBuildQueueFromSelection = selectedFiles.length > 0 && hosters.length > 0; - btn.disabled = uploading || sourceCleanupFinalizationPending || !(hasQueuedJobs || canBuildQueueFromSelection); + btn.disabled = uploading || !(hasQueuedJobs || canBuildQueueFromSelection); } const _UPLOAD_SELECTION_STATUSES = new Set(['done', 'error', 'aborted', 'skipped']); @@ -2069,8 +1627,8 @@ function updateQueueActionButtons() { const moveDownBtn = document.getElementById('moveDownBtn'); const moveBottomBtn = document.getElementById('moveBottomBtn'); - if (startSelectedBtn) startSelectedBtn.disabled = uploading || sourceCleanupFinalizationPending || !hasStartableSelection; - if (reuploadBtn) reuploadBtn.disabled = sourceCleanupFinalizationPending || !hasUploadSelection; + if (startSelectedBtn) startSelectedBtn.disabled = uploading || !hasStartableSelection; + if (reuploadBtn) reuploadBtn.disabled = !hasUploadSelection; if (abortSelectedBtn) abortSelectedBtn.disabled = !hasAbortSelection; if (finishStopBtn) finishStopBtn.disabled = !uploading; if (abortAllBtn) abortAllBtn.disabled = !uploading; @@ -2127,8 +1685,9 @@ function buildQueuePreview() { for (const file of selectedFiles) { for (const hoster of hosters) { + if (!window.ImportPreflight.isImportPairEligible(file, hoster, hosterSettings)) continue; const key = `${file.path}|${hoster}`; - if (window.ImportPreflight.isImportPairEligible(file, hoster, hosterSettings) && !existingKeys.has(key) && !_completedUploadKeys.has(key) && !_suppressedPreviewKeys.has(key)) { + if (!existingKeys.has(key) && !_completedUploadKeys.has(key) && !_suppressedPreviewKeys.has(key)) { const job = { id: `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, file: file.path, fileName: file.name, hoster, @@ -2714,8 +2273,7 @@ function getAccountLabel(job) { } function getStatusText(job) { - const rawError = job.error ? String(job.error).replace(/\s+/g, ' ').slice(0, 100) : ''; - const shortErr = rawError ? getLocalizedErrorDetail(rawError) : ''; + const shortErr = job.error ? String(job.error).replace(/\s+/g, ' ').slice(0, 100) : ''; const acc = getAccountLabel(job); const accSuffix = acc ? ` · ${acc}` : ''; let text; @@ -2731,7 +2289,7 @@ function getStatusText(job) { } case 'done': text = 'Fertig'; break; case 'aborted': text = shortErr || 'Abgebrochen'; break; - case 'error': text = shortErr ? (/^(?:Fehlgeschlagen|Failed)(?::|$)/.test(shortErr) ? shortErr : `Fehlgeschlagen: ${shortErr}`) : 'Fehlgeschlagen'; break; + case 'error': text = shortErr ? (/^Fehlgeschlagen(?::|$)/.test(shortErr) ? shortErr : `Fehlgeschlagen: ${shortErr}`) : 'Fehlgeschlagen'; break; case 'skipped': text = shortErr ? `Übersprungen: ${shortErr}` : 'Übersprungen'; break; default: text = job.status; } @@ -2821,12 +2379,8 @@ function showContextMenu(x, y) { const n = selectedJobIds.size; const delItem = menu.querySelector('[data-action="delete-selected"]'); if (delItem) delItem.textContent = n > 1 ? `Entfernen (${n})` : 'Entfernen'; - const copyableLinkCount = getSelectedJobLinks().length; const copyItem = menu.querySelector('[data-action="copy-links"]'); - if (copyItem) { - copyItem.textContent = copyableLinkCount > 1 ? `Links kopieren (${copyableLinkCount})` : 'Link kopieren'; - copyItem.style.display = copyableLinkCount > 0 ? '' : 'none'; - } + if (copyItem) copyItem.textContent = n > 1 ? `Links kopieren (${n})` : 'Link kopieren'; menu.querySelectorAll('[data-action="retry-selected"]').forEach(el => { el.textContent = n > 1 ? `Erneut versuchen (${n})` : 'Erneut versuchen'; }); @@ -2884,12 +2438,8 @@ function showRecentContextMenu(row, x, y) { applyRecentSelectionClasses(); } const menu = document.getElementById('recentContextMenu'); - const copyableLinkCount = getSelectedRecentLinks().length; const copyItem = menu.querySelector('[data-action="recent-copy-links"]'); - if (copyItem) { - copyItem.textContent = copyableLinkCount > 1 ? `Links kopieren (${copyableLinkCount})` : 'Link kopieren'; - copyItem.style.display = copyableLinkCount > 0 ? '' : 'none'; - } + if (copyItem) copyItem.textContent = selectedRecentIds.size > 1 ? `Links kopieren (${selectedRecentIds.size})` : 'Link kopieren'; menu.style.display = 'block'; menu.style.left = Math.min(x, window.innerWidth - menu.offsetWidth - 5) + 'px'; menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 5) + 'px'; @@ -2958,20 +2508,16 @@ async function exportAllRecentFiles() { ]); if (result && result.ok) showCopyToast(`${rows.length} Einträge exportiert`); } catch (err) { - await showAppAlert(formatLocalizedError('Export fehlgeschlagen', err), 'Export fehlgeschlagen'); + await showAppAlert('Export fehlgeschlagen: ' + (err.message || err), 'Export fehlgeschlagen'); } } -function getSelectedRecentLinks() { - return sessionFilesData +function copySelectedRecentLinks() { + const links = sessionFilesData .filter(r => selectedRecentIds.has(r.order) && !r.isError) .map(r => r.link) .filter(Boolean); -} - -function copySelectedRecentLinks() { - const links = getSelectedRecentLinks(); - if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(getCopiedLinksMessage(links.length)); } + if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); } } // --- Backup export / import --- @@ -3004,7 +2550,7 @@ async function doBackupExport() { showCopyToast('Backup exportiert'); } } catch (err) { - await showAppAlert(formatLocalizedError('Export fehlgeschlagen', err), 'Export fehlgeschlagen'); + await showAppAlert('Export fehlgeschlagen: ' + (err.message || err), 'Export fehlgeschlagen'); } } @@ -3058,7 +2604,7 @@ async function persistImportedQueueState() { } function showImportQueuePersistenceError(error) { - showCopyToast(formatLocalizedError('Import übernommen. Warteschlange konnte nicht vollständig gespeichert werden', error), 8000); + showCopyToast(`Import übernommen. Warteschlange konnte nicht vollständig gespeichert werden: ${error.message || error}`, 8000); } function setOnlineBackupStatus(message, state = '') { @@ -3249,10 +2795,10 @@ async function doBackupImport(legacyPassword) { } if (queuePersistenceError) showImportQueuePersistenceError(queuePersistenceError); } else if (result.error) { - await showAppAlert(formatLocalizedError('Import fehlgeschlagen', result.error), 'Import fehlgeschlagen'); + await showAppAlert('Import fehlgeschlagen: ' + result.error, 'Import fehlgeschlagen'); } } catch (err) { - await showAppAlert(formatLocalizedError('Import fehlgeschlagen', err), 'Import fehlgeschlagen'); + await showAppAlert('Import fehlgeschlagen: ' + (err.message || err), 'Import fehlgeschlagen'); } } @@ -3260,8 +2806,21 @@ document.addEventListener('click', (e) => { if (!e.target.closest('.context-menu')) hideContextMenu(); }); document.addEventListener('keydown', (e) => { + if (_isUpdateDialogVisible()) return; + const accountModal = document.getElementById('accountModal'); + if (e.key === 'Tab' && accountModal && accountModal.style.display !== 'none') { + const focusable = Array.from(accountModal.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (first && last && ((!e.shiftKey && document.activeElement === last) || (e.shiftKey && document.activeElement === first))) { + e.preventDefault(); + (e.shiftKey ? last : first).focus(); + } + } if (e.key === 'Escape') { hideContextMenu(); + cancelHosterModal(); + if (accountModal && accountModal.style.display !== 'none') closeAccountModal(); } if (e.target instanceof window.Element && e.target.closest('input, textarea, select')) return; const activeView = document.querySelector('.view.active'); @@ -3305,18 +2864,13 @@ document.getElementById('contextMenu').addEventListener('click', (e) => { handleContextAction(action); }); -function removeSourceCleanupRequirements(jobs) { - if (!window.SourceCleanupPolicy || !Array.isArray(jobs)) return; - for (const job of jobs) window.SourceCleanupPolicy.removeRequirement(queueJobs, job, 'win32'); -} - async function handleContextAction(action) { _normalizeQueueSelectionToVisible(); if (action === 'start-selected') { startSelectedUpload(); } else if (action === 'copy-links') { const links = getSelectedJobLinks(); - if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(getCopiedLinksMessage(links.length)); } + if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); } } else if (action === 'retry-selected') { retrySelectedJobs(); } else if (action === 'show-log') { @@ -3329,8 +2883,6 @@ async function handleContextAction(action) { return j && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server'); }); if (activeIds.length > 0) await window.api.cancelSelectedJobs(activeIds); - const removedJobs = queueJobs.filter(j => selectedJobIds.has(j.id)); - removeSourceCleanupRequirements(removedJobs); const _deletedKeys = []; queueJobs = queueJobs.filter(j => { if (selectedJobIds.has(j.id)) { @@ -3358,7 +2910,6 @@ async function handleContextAction(action) { await window.api.cancelUpload(); uploading = false; } - removeSourceCleanupRequirements(queueJobs); queueJobs.forEach(j => removeJobFromIndex(j)); queueJobs = []; selectedJobIds.clear(); @@ -3381,7 +2932,6 @@ async function handleContextAction(action) { .filter(j => j.hoster === hoster && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server' || j.status === 'preview')) .map(j => j.id); if (activeIds.length > 0) await window.api.cancelSelectedJobs(activeIds); - removeSourceCleanupRequirements(queueJobs.filter(j => j.hoster === hoster)); // Remove ALL jobs for this hoster queueJobs = queueJobs.filter(j => { if (j.hoster === hoster) { removeJobFromIndex(j); return false; } @@ -3409,76 +2959,8 @@ function getSelectedJobLinks() { // --- Upload --- function prepareSourceCleanup(jobs) { - if (!window.SourceCleanupPolicy) return { groups: [] }; - const enabled = config.globalSettings?.deleteSourceAfterSuccessfulUpload === true; - const hasMetadata = Array.isArray(jobs) && jobs.some((job) => job?.sourceCleanupToken); - if (!enabled && !hasMetadata) return { groups: [] }; - const preparation = window.SourceCleanupPolicy.prepareGroups(queueJobs, jobs, () => window.crypto.randomUUID(), 'win32'); - return enabled ? preparation : { ...preparation, groups: [] }; -} - -async function persistSourceCleanupRevocations(preparation) { - if (Array.isArray(preparation?.revokedHosters) && preparation.revokedHosters.length > 0) { - sourceCleanupRevocationPending = true; - } - if (!sourceCleanupRevocationPending) return false; - await _persistQueueSnapshotBeforeUploadStart(); - return true; -} - -async function persistQueueBeforeUploadStart(preparation) { - if (await persistSourceCleanupRevocations(preparation)) return; - await _persistQueueSnapshotBeforeUploadStart(); -} - -async function _persistQueueSnapshotBeforeUploadStart() { - try { - queuePersistThrottle.cancel(); - await persistQueueStateNow(); - await flushConfigWrites(); - sourceCleanupRevocationPending = false; - } catch (cause) { - const error = cause instanceof Error ? cause : new Error(String(cause)); - error.queuePersistenceFailure = true; - throw error; - } -} - -async function completeSourceCleanupFinalization(data) { - if (!data?.finalizationId || !window.api.completeUploadFinalization) return false; - queuePersistThrottle.cancel(); - let writesReady = true; - try { - await flushConfigWrites(); - } catch { - writesReady = false; - } - const persist = async () => { - if (!writesReady) { - try { - await window.api.completeUploadFinalization({ - finalizationId: data.finalizationId, - deliveryId: data.deliveryId, - historyPersisted: data.historyPersisted === true, - ready: false - }); - } catch {} - return false; - } - return window.api.completeUploadFinalization({ - finalizationId: data.finalizationId, - deliveryId: data.deliveryId, - historyPersisted: data.historyPersisted === true, - pendingQueue: data.historyPersisted !== true || queueJobs.some((job) => !['done', 'skipped'].includes(job.status) || job.historyPending === true) - ? buildPersistedQueueState({ historyPersisted: data.historyPersisted === true }) - : null - }); - }; - if (!window.SourceCleanupPolicy?.persistRoundCompletions) return Boolean(await persist()); - return window.SourceCleanupPolicy.persistRoundCompletions(queueJobs, { - historyPersisted: data.historyPersisted === true, - persist - }); + if (!config.globalSettings?.deleteSourceAfterSuccessfulUpload || !window.SourceCleanupPolicy) return { groups: [] }; + return window.SourceCleanupPolicy.prepareGroups(queueJobs, jobs, () => window.crypto.randomUUID(), 'win32'); } function serializeUploadJob(job) { @@ -3487,19 +2969,15 @@ function serializeUploadJob(job) { file: job.file, fileName: job.fileName, hoster: job.hoster, - sourceCleanupMetadataVersion: job.sourceCleanupToken ? 2 : null, sourceCleanupToken: job.sourceCleanupToken || null, sourceCleanupRequiredHosters: job.sourceCleanupRequiredHosters || [], - sourceCleanupConfirmedHosters: job.sourceCleanupConfirmedHosters || [], - sourceCleanupStartedHosters: Array.isArray(job.sourceCleanupStartedHosters) - ? [...job.sourceCleanupStartedHosters] - : undefined, + sourceCleanupCompletedHosters: job.sourceCleanupCompletedHosters || [], sourceCleanupFingerprint: job.sourceCleanupFingerprint || null }; } async function startUpload(opts) { - if (uploading || sourceCleanupFinalizationPending) return; + if (uploading) return; if (!(opts && opts._restoredAutoStart)) cancelStartupQueueAutoStart(); if (!(opts && opts._autoRetry)) _cancelAutoRetry(true); else _cancelAutoRetry(false); @@ -3518,18 +2996,8 @@ async function startUpload(opts) { buildQueuePreview(); } - const scopedJobs = Array.isArray(opts?.jobs) ? opts.jobs : queueJobs; - const automaticStart = opts?._autoRetry === true || opts?._restoredAutoStart === true; - const startableJobs = scopedJobs.filter((job) => isStartableQueueStatus(job.status)); - const jobsToStart = automaticStart - ? startableJobs.filter((job) => job.remoteCommitUncertain !== true) - : startableJobs; + const jobsToStart = queueJobs.filter((job) => isStartableQueueStatus(job.status)); if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; } - if (!automaticStart && !await confirmRemoteCommitRetry(jobsToStart)) { - uploading = false; - updateQueueActionButtons(); - return; - } try { const cleanupPreparation = prepareSourceCleanup(jobsToStart); @@ -3547,7 +3015,6 @@ async function startUpload(opts) { updateQueueActionButtons(); renderQueueTable(); updateStatusBar(); - await persistQueueBeforeUploadStart(cleanupPreparation); const uploadPayload = { hosters, @@ -3560,10 +3027,10 @@ async function startUpload(opts) { window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); } _markSkippedJobs(result); - if (result?.finalized !== true) persistQueueStateSoon(); + persistQueueStateSoon(); if (result && result.error) { - await showAppAlert(getLocalizedErrorDetail(result.error), 'Upload-Start fehlgeschlagen'); + await showAppAlert(result.error, 'Upload-Start fehlgeschlagen'); uploading = false; updateQueueActionButtons(); updateStatusBar(); @@ -3572,8 +3039,7 @@ async function startUpload(opts) { uploading = false; updateQueueActionButtons(); updateStatusBar(); - const prefix = err?.queuePersistenceFailure ? 'Warteschlange konnte vor dem Upload-Start nicht gespeichert werden' : 'Upload-Start fehlgeschlagen'; - await showAppAlert(formatLocalizedError(prefix, err), 'Upload-Start fehlgeschlagen'); + await showAppAlert(`Upload-Start fehlgeschlagen: ${err.message}`, 'Upload-Start fehlgeschlagen'); } } @@ -3589,17 +3055,15 @@ function _markSkippedJobs(result) { renderQueueTable(); } -async function startSelectedUpload(explicitJobs, options = {}) { - if (sourceCleanupFinalizationPending) return; +async function startSelectedUpload(explicitJobs) { const scopedJobs = Array.isArray(explicitJobs) ? explicitJobs : _getVisibleSelectedQueueJobs(); if (uploading) { _hydrateMissingJobSizes(); const addable = scopedJobs.filter(j => isStartableQueueStatus(j.status)); if (addable.length === 0) { if (selectedJobIds.size > 0) showCopyToast('Keine startbaren Jobs ausgewählt (alle laufen schon oder sind fertig).'); - return false; + return; } - if (options.remoteRetryConfirmed !== true && !await confirmRemoteCommitRetry(addable)) return false; { const cleanupPreparation = prepareSourceCleanup(addable); addable.forEach(j => { @@ -3609,15 +3073,13 @@ async function startSelectedUpload(explicitJobs, options = {}) { renderQueueTable(); let result = null; try { - await persistQueueBeforeUploadStart(cleanupPreparation); result = await window.api.addJobsToBatch({ jobs: addable.map(serializeUploadJob), sourceCleanupGroups: cleanupPreparation.groups }); } catch (err) { - const prefix = err?.queuePersistenceFailure ? 'Warteschlange konnte vor dem Upload-Start nicht gespeichert werden' : 'Jobs konnten nicht hinzugefügt werden'; - showCopyToast(formatLocalizedError(prefix, err)); - return false; + showCopyToast(`Jobs konnten nicht hinzugefügt werden: ${err.message}`); + return; } // If the batch ended between UI-state and IPC call, start a fresh batch immediately @@ -3625,7 +3087,8 @@ async function startSelectedUpload(explicitJobs, options = {}) { uploading = false; updateQueueActionButtons(); updateStatusBar(); - return startSelectedUpload(addable, { remoteRetryConfirmed: true }); + await startSelectedUpload(addable); + return; } _markSkippedJobs(result); if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) { @@ -3633,22 +3096,23 @@ async function startSelectedUpload(explicitJobs, options = {}) { } persistQueueStateSoon(); const added = Number(result && result.added) || 0; + // Use ASCII-only toast text here to avoid encoding artifacts on some systems. const skipped = Array.isArray(result && result.skippedJobs) ? result.skippedJobs.length : 0; const alreadyInBatch = Array.isArray(result && result.alreadyInBatchJobIds) ? result.alreadyInBatchJobIds.length : Math.max(0, addable.length - added - skipped); const toastParts = []; - if (added > 0) toastParts.push(localizeUiText(`${added} hinzugefügt`)); - if (alreadyInBatch > 0) toastParts.push(localizeUiText(`${alreadyInBatch} bereits im Batch`)); - if (skipped > 0) toastParts.push(localizeUiText(`${skipped} ohne gültigen Account`)); + if (added > 0) toastParts.push(`${added} hinzugefuegt`); + if (alreadyInBatch > 0) toastParts.push(`${alreadyInBatch} bereits im Batch`); + if (skipped > 0) toastParts.push(`${skipped} ohne gueltigen Account`); if (result && result.error) { - showCopyToast(formatLocalizedError('Jobs konnten nicht hinzugefügt werden', result.error)); + showCopyToast(`Jobs konnten nicht hinzugefügt werden: ${result.error}`); } else if (toastParts.length > 0) { showCopyToast(`Jobs: ${toastParts.join(', ')}`); } else { - showCopyToast('Keine Jobs hinzugefügt'); + showCopyToast('Keine Jobs hinzugefuegt'); } - return !(result && result.error); + return; } } uploading = true; // set immediately to prevent double-click race @@ -3656,12 +3120,7 @@ async function startSelectedUpload(explicitJobs, options = {}) { const hosters = getSelectedHosters(); const jobsToStart = scopedJobs.filter(job => isStartableQueueStatus(job.status)); - if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return false; } - if (options.remoteRetryConfirmed !== true && !await confirmRemoteCommitRetry(jobsToStart)) { - uploading = false; - updateQueueActionButtons(); - return false; - } + if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; } try { const cleanupPreparation = prepareSourceCleanup(jobsToStart); @@ -3677,35 +3136,30 @@ async function startSelectedUpload(explicitJobs, options = {}) { updateQueueActionButtons(); renderQueueTable(); updateStatusBar(); - await persistQueueBeforeUploadStart(cleanupPreparation); const uploadPayload = { hosters, jobs: jobsToStart.map(serializeUploadJob), sourceCleanupGroups: cleanupPreparation.groups - }; + }; const result = await window.api.startUpload(uploadPayload); if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) { window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); } _markSkippedJobs(result); - if (result?.finalized !== true) persistQueueStateSoon(); + persistQueueStateSoon(); if (result && result.error) { - await showAppAlert(getLocalizedErrorDetail(result.error), 'Upload-Start fehlgeschlagen'); + await showAppAlert(result.error, 'Upload-Start fehlgeschlagen'); uploading = false; updateQueueActionButtons(); updateStatusBar(); - return false; } - return true; } catch (err) { uploading = false; updateQueueActionButtons(); updateStatusBar(); - const prefix = err?.queuePersistenceFailure ? 'Warteschlange konnte vor dem Upload-Start nicht gespeichert werden' : 'Upload-Start fehlgeschlagen'; - await showAppAlert(formatLocalizedError(prefix, err), 'Upload-Start fehlgeschlagen'); - return false; + await showAppAlert(`Upload-Start fehlgeschlagen: ${err.message}`, 'Upload-Start fehlgeschlagen'); } } @@ -3741,31 +3195,26 @@ function handleProgress(data) { } } function _handleProgressImpl(data) { - const hasJobId = data.jobId !== undefined && data.jobId !== null && data.jobId !== ''; - let job = hasJobId ? _jobIndexById.get(data.jobId) : null; - if (hasJobId && !job) return; - if (!hasJobId && data.uploadId) { - job = _jobIndexByUploadId.get(data.uploadId); - if (!job && _deletedJobIds.has(data.uploadId)) return; - } + let job = data.jobId ? _jobIndexById.get(data.jobId) : null; + if (!job && data.uploadId) job = _jobIndexByUploadId.get(data.uploadId); if (!job) { - const candidates = queueJobs.filter(candidate => - candidate.fileName === data.fileName && - candidate.hoster === data.hoster && - candidate.status !== 'done' && - candidate.status !== 'skipped' + job = queueJobs.find(j => + j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'queued' + ) || queueJobs.find(j => + j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'preview' ); - if (candidates.length > 1) return; - job = candidates.length === 1 ? candidates[0] : null; if (job && data.uploadId) { job.uploadId = data.uploadId; _jobIndexByUploadId.set(data.uploadId, job); } } if (!job) { - if (['done', 'error', 'aborted', 'skipped'].includes(data.status)) return; + // Don't re-create jobs that were explicitly deleted by the user + if ((data.jobId && _deletedJobIds.has(data.jobId)) || (data.uploadId && _deletedJobIds.has(data.uploadId))) { + return; + } 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, @@ -3777,7 +3226,7 @@ function _handleProgressImpl(data) { } // Don't regress from terminal states (stale callbacks can arrive after completion) - if (['done', 'error', 'aborted', 'skipped'].includes(job.status)) return; + if (job.status === 'done' || job.status === 'skipped') return; // Update job state job.status = data.status; @@ -3794,7 +3243,6 @@ function _handleProgressImpl(data) { job.remaining = data.remaining || 0; job.error = data.error || null; job.failureDetails = data.failureDetails || job.failureDetails || null; - job.remoteCommitUncertain = window.UploadRecovery.resolveRemoteCommitUncertainty(job.remoteCommitUncertain, data); job.result = data.result || job.result; job.attempt = data.attempt || 0; job.maxAttempts = data.maxAttempts || 0; @@ -3826,6 +3274,23 @@ function _handleProgressImpl(data) { _completedUploadKeys.add(`${job.file}|${job.hoster}`); } + // Remove finished jobs from queue if setting is enabled. Coalesce the + // actual array filter into one microtask: a burst of 500 done events + // would otherwise fire 500 individual O(N) filters = O(N²) work, visible + // as a brief UI freeze when a big batch finishes. Index/selection are + // updated synchronously so subsequent lookups see the right state — only + // the array rewrite is deferred. + if (job.status === 'done' && config.globalSettings && config.globalSettings.removeFromQueueOnDone) { + removeJobFromIndex(job, true); + selectedJobIds.delete(job.id); + if (_doneRemovalCoalescer) { + _doneRemovalCoalescer.add(job.id); + } else { + // Legacy slow path: immediate filter when the lib script didn't load. + queueJobs = queueJobs.filter(j => j !== job); + } + } + // Status changes (done/error/etc) get one coalesced update per frame so a // burst of 500 parallel jobs flipping state doesn't fire 2000 sync DOM // updates. Ongoing uploading progress is throttled at 200ms. @@ -3837,9 +3302,9 @@ function _handleProgressImpl(data) { persistQueueStateSoon(); } -function handleBatchDone(summary, options = {}) { +function handleBatchDone(summary) { uploading = false; - applySummaryResults(summary, options.historyPersisted !== false); + applySummaryResults(summary); _deletedJobIds.clear(); // Free memory — stale IDs no longer needed after batch completes // Prune session-stats sets to current queue contents. Without this, IDs // of jobs that were removed from queueJobs (via removeFromQueueOnDone @@ -3868,19 +3333,20 @@ function handleBatchDone(summary, options = {}) { syncSelectedFilesFromQueue(); updateQueueActionButtons(); + renderQueueTable(); renderRecentUploadsPanel(); // History is only visible on the Verlauf tab. Mark it dirty and refresh when // the user actually switches to it — skips an IPC + full table rebuild per // batch-done when the user is watching the upload view. _historyDirty = true; - if (options.historyPersisted !== false || _isHistoryTabActive()) loadHistory(); + if (_isHistoryTabActive()) loadHistory(); const removeOnDone = config.globalSettings && config.globalSettings.removeFromQueueOnDone; - if (options.historyPersisted !== false && removeOnDone) { + if (removeOnDone) { // Single pass: build the keep-list and clean up the index for removed jobs. const nextJobs = []; for (const job of queueJobs) { - if (job.status === 'done' && job.historyPending !== true) { + if (job.status === 'done') { removeJobFromIndex(job, true); selectedJobIds.delete(job.id); } else { @@ -3888,7 +3354,8 @@ function handleBatchDone(summary, options = {}) { } } queueJobs = nextJobs; - } else if (options.historyPersisted !== false) { + renderQueueTable(); + } else { // Auto-prune for the default (removeOnDone=false) too: cap terminal // jobs (done/skipped/error/aborted) at the most recent N so the queue // can't grow unbounded across long sessions. The algorithm lives in @@ -3899,23 +3366,19 @@ function handleBatchDone(summary, options = {}) { // Optional-chain so the renderer still works if the prune script fails // to load (e.g. file:// path issues during dev) — falls back to no-prune // rather than crashing on every batch-done. - const result = window.QueuePrune?.pruneOldestTerminalJobs(queueJobs.filter(job => job.historyPending !== true), TERMINAL_KEEP_LIMIT); + const result = window.QueuePrune?.pruneOldestTerminalJobs(queueJobs, TERMINAL_KEEP_LIMIT); if (result) { - const droppedIds = new Set(result.dropped.map(job => job.id)); for (const j of result.dropped) { removeJobFromIndex(j, true); selectedJobIds.delete(j.id); } - queueJobs = queueJobs.filter(job => !droppedIds.has(job.id)); + queueJobs = result.kept; + renderQueueTable(); } } - renderQueueTable(); - - if (!options.deferPersistence) { - if (queueJobs.some((job) => !['done', 'skipped'].includes(job.status) || job.historyPending === true)) persistQueueStateSoon(true); - else clearPersistedQueueStateSoon(); - } + if (queueJobs.some((job) => !['done', 'skipped'].includes(job.status))) persistQueueStateSoon(true); + else clearPersistedQueueStateSoon(); lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: lastUploadStats.totalBytes, elapsed: lastUploadStats.elapsed, activeJobs: 0 }; updateStatusBar(); @@ -3933,7 +3396,6 @@ function _cancelAutoRetry(resetRound) { function _collectAutoRetryableJobs() { if (!window.Stats) return []; return queueJobs.filter(j => j.status === 'error' - && j.remoteCommitUncertain !== true && window.Stats.isRetryableCategory(window.Stats.classifyErrorCategory(j.error))); } function _scheduleAutoRetryIfNeeded() { @@ -3953,11 +3415,10 @@ function _scheduleAutoRetryIfNeeded() { if (jobs.length === 0) { _autoRetryState.round = 0; return; } for (const j of jobs) { j.status = 'queued'; j.error = null; j.result = null; - j.remoteCommitUncertain = false; j.bytesUploaded = 0; j.speedKbs = 0; j.progress = 0; j.uploadId = null; } renderQueueTable(); - startUpload({ _autoRetry: true, jobs }); + startUpload({ _autoRetry: true }); }, waitMin * 60_000); } async function _refreshSessionFailedSnapshot() { @@ -4006,35 +3467,34 @@ function _maybeShowBatchSummary(summary) { const close = () => { modal.style.display = 'none'; }; closeBtn.onclick = close; - retryAllBtn.onclick = async () => { if (await _retryFailedFromBuckets(buckets, false)) close(); }; - retryTransientBtn.onclick = async () => { if (await _retryFailedFromBuckets(buckets, true)) close(); }; + retryAllBtn.onclick = () => { _retryFailedFromBuckets(buckets, false); close(); }; + retryTransientBtn.onclick = () => { _retryFailedFromBuckets(buckets, true); close(); }; modal.style.display = 'flex'; } -async function _retryFailedFromBuckets(buckets, transientOnly) { - if (sourceCleanupFinalizationPending) return; +function _retryFailedFromBuckets(buckets, transientOnly) { const cats = transientOnly ? ['hoster-transient', 'network', 'unknown'] : ['hoster-transient', 'network', 'unknown', 'file-rejected', 'account-error']; const toRetry = []; for (const cat of cats) { for (const item of (buckets[cat] || [])) toRetry.push(item); } - if (toRetry.length === 0) return false; + if (toRetry.length === 0) return; const jobsToRetry = []; for (const item of toRetry) { - const job = item.jobId - ? queueJobs.find(candidate => candidate.id === item.jobId && candidate.status === 'error') - : (() => { - const candidates = queueJobs.filter(candidate => candidate.fileName === item.fileName && candidate.hoster === item.hoster && candidate.status === 'error'); - return candidates.length === 1 ? candidates[0] : null; - })(); - if (job && !jobsToRetry.includes(job)) jobsToRetry.push(job); + const job = queueJobs.find(j => (j.fileName === item.fileName) && (j.hoster === item.hoster) && j.status === 'error'); + if (job) { + job.status = 'queued'; + job.progress = 0; + job.bytesUploaded = 0; + job.error = null; + job.result = null; + jobsToRetry.push(job); + } } - if (jobsToRetry.length === 0) { showCopyToast('Keine passenden Jobs für Retry gefunden.'); return false; } - if (!await confirmRemoteCommitRetry(jobsToRetry)) return false; - const started = await startSelectedUpload(jobsToRetry, { remoteRetryConfirmed: true }); - if (!started) return false; + if (jobsToRetry.length === 0) { showCopyToast('Keine passenden Jobs für Retry gefunden.'); return; } + renderQueueTable(); showCopyToast(jobsToRetry.length === 1 ? '1 Job zum erneuten Upload zurückgesetzt' : `${jobsToRetry.length} Jobs zum erneuten Upload zurückgesetzt`); - return true; + if (typeof startUpload === 'function') startUpload(); } function handleStats(data) { @@ -4079,9 +3539,6 @@ function _handleStatsImpl(data) { } // --- Per-job log modal --- -let _jobLogGeneration = 0; -let _jobLogJobId = null; - async function showJobLogModal() { const selectedJobs = _getVisibleSelectedQueueJobs(); if (selectedJobs.length === 0) return; @@ -4089,28 +3546,20 @@ async function showJobLogModal() { // make sense here. const job = selectedJobs[0]; const jobId = job.id; - const generation = ++_jobLogGeneration; - _jobLogJobId = jobId; const modal = document.getElementById('jobLogModal'); const titleEl = document.getElementById('jobLogTitle'); const bodyEl = document.getElementById('jobLogBody'); if (!modal || !titleEl || !bodyEl) return; titleEl.textContent = job && job.fileName ? `Log · ${job.fileName}` : 'Upload-Log'; - bodyEl.textContent = localizeUiText('Lade…'); - modalController.open(modal, { - initialFocus: '#closeJobLogBtn', - fallbackFocus: '#addFilesBtn', - onEscape: hideJobLogModal, - onBackdrop: hideJobLogModal - }); + bodyEl.textContent = 'Lade…'; + modal.style.display = 'flex'; let entries = []; try { entries = await window.api.getJobLog(jobId); } catch {} - if (generation !== _jobLogGeneration || _jobLogJobId !== jobId || !modalController.isOpen(modal) || _jobIndexById.get(jobId) !== job) return; if (!Array.isArray(entries) || entries.length === 0) { - bodyEl.textContent = localizeUiText('Keine Log-Einträge für diesen Job (entweder noch nichts passiert oder aus vorherigem Batch und schon geräumt).'); + bodyEl.textContent = 'Keine Log-Einträge für diesen Job (entweder noch nichts passiert oder aus vorherigem Batch und schon geräumt).'; return; } @@ -4134,19 +3583,18 @@ async function showJobLogModal() { ? Object.entries(job.failureDetails).map(([key, value]) => `${key}: ${value}`).join('\n') : ''; const summary = [ - `${localizeUiText('Hoster')}: ${job.hoster || '–'}`, - `${localizeUiText('Account')}: ${getAccountLabel(job) || job.accountId || '–'}`, - `${localizeUiText('Versuch')}: ${job.attempt || '–'} / ${job.maxAttempts || '–'}`, - job.error ? `${localizeUiText('Fehler')}: ${job.error}` : '', - details ? `${localizeUiText('Diagnose')}:\n${details}` : '' + `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() { - _jobLogGeneration++; - _jobLogJobId = null; - modalController.close('jobLogModal', { fallbackFocus: '#addFilesBtn' }); + const m = document.getElementById('jobLogModal'); + if (m) m.style.display = 'none'; } async function copyJobLogToClipboard() { @@ -4156,29 +3604,15 @@ async function copyJobLogToClipboard() { } // --- Retry --- -async function confirmRemoteCommitRetry(jobs) { - const count = jobs.filter(job => job.remoteCommitUncertain === true).length; - if (count === 0) return true; - return showAppConfirm({ - title: 'Uploadstatus konnte nicht bestätigt werden', - message: count === 1 - ? 'Der Upload könnte beim Hoster bereits abgeschlossen sein. Ein erneuter Upload kann ein Duplikat erzeugen.' - : `${count} Uploads könnten bei den Hostern bereits abgeschlossen sein. Erneute Uploads können Duplikate erzeugen.`, - confirmText: 'Trotzdem erneut hochladen', - danger: true - }); -} - async function retrySelectedJobs() { - if (sourceCleanupFinalizationPending) return; _normalizeQueueSelectionToVisible(); - const retryJobs = queueJobs.filter(j => selectedJobIds.has(j.id) && ['error', 'done', 'aborted', 'skipped'].includes(j.status)); - if (retryJobs.length === 0 || !await confirmRemoteCommitRetry(retryJobs)) return; + const retryJobs = []; // Build a Set for O(1) selectedFiles dedup below. const existingFilePaths = new Set(); for (const f of selectedFiles) existingFilePaths.add(f.path); - retryJobs.forEach(j => { + queueJobs.forEach(j => { + if (selectedJobIds.has(j.id) && ['error', 'done', 'aborted', 'skipped'].includes(j.status)) { // Invalidate the old uploadId: retire the index entry and mark it so // any late progress event from the previous (cancelled/completed) // upload can't overwrite the freshly-reset state. @@ -4195,11 +3629,14 @@ async function retrySelectedJobs() { j.remaining = 0; j.progress = 0; j.uploadId = null; + retryJobs.push(j); if (!existingFilePaths.has(j.file)) { selectedFiles.push({ path: j.file, name: j.fileName, size: j.bytesTotal }); existingFilePaths.add(j.file); } + } }); + if (retryJobs.length === 0) return; for (const j of retryJobs) { if (j.file && j.hoster) { _completedUploadKeys.delete(`${j.file}|${j.hoster}`); @@ -4215,7 +3652,7 @@ async function retrySelectedJobs() { retryJobs.forEach(j => selectedJobIds.add(j.id)); selectionAnchorJobId = retryJobs[0]?.id || null; persistQueueStateSoon(); - await startSelectedUpload(retryJobs, { remoteRetryConfirmed: true }); + await startSelectedUpload(retryJobs); } async function abortSelectedJobs() { @@ -4356,25 +3793,19 @@ function maybeAddSessionFile(job) { } -function applySummaryResults(summary, historyPersisted = true) { +function applySummaryResults(summary) { const files = Array.isArray(summary?.files) ? summary.files : []; - const jobById = new Map(); - const jobsByLegacyKey = new Map(); + // Build a (fileName + hoster) → job map once so the per-result lookup is O(1) + // instead of O(|queueJobs|). Big batches (hundreds of files × multiple hosters) + // otherwise become O(n²). + const jobByKey = new Map(); for (const j of queueJobs) { - jobById.set(j.id, j); - const key = `${j.fileName}\u0001${j.hoster}`; - const candidates = jobsByLegacyKey.get(key); - if (candidates) candidates.push(j); - else jobsByLegacyKey.set(key, [j]); + jobByKey.set(`${j.fileName}\u0001${j.hoster}`, j); } for (const file of files) { for (const result of file.results || []) { - const hasJobId = result.jobId !== undefined && result.jobId !== null && result.jobId !== ''; - const candidates = hasJobId ? null : jobsByLegacyKey.get(`${file.name}\u0001${result.hoster}`); - const job = hasJobId ? jobById.get(result.jobId) : (candidates?.length === 1 ? candidates[0] : null); + const job = jobByKey.get(`${file.name}\u0001${result.hoster}`); if (!job) continue; - job.remoteCommitUncertain = window.UploadRecovery.resolveRemoteCommitUncertainty(job.remoteCommitUncertain, result); - job.historyPending = historyPersisted !== true; if (result.status === 'done') { job.status = 'done'; job.result = { @@ -4642,23 +4073,16 @@ function _setRollingUploadMetric(id, value) { const direction = numericValue > previousValue ? 'up' : 'down'; const previousText = element.getAttribute('aria-label') || previousValue.toLocaleString(getUiLocale()); - element.querySelectorAll(':scope > span').forEach(span => span.getAnimations().forEach(animation => animation.cancel())); - element.dataset.numericValue = String(numericValue); - element.setAttribute('aria-label', nextText); - if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { - const settled = document.createElement('span'); - settled.textContent = nextText; - element.replaceChildren(settled); - element.dataset.direction = 'none'; - return; - } const outgoing = document.createElement('span'); const incoming = document.createElement('span'); outgoing.textContent = previousText; incoming.textContent = nextText; outgoing.className = 'upload-rolling-outgoing'; incoming.className = 'upload-rolling-incoming'; + element.querySelectorAll(':scope > span').forEach(span => span.getAnimations().forEach(animation => animation.cancel())); + element.dataset.numericValue = String(numericValue); element.dataset.direction = direction; + element.setAttribute('aria-label', nextText); element.replaceChildren(outgoing, incoming); const distance = direction === 'up' ? -1 : 1; @@ -4764,8 +4188,8 @@ function updateStatusBar() { _setRollingUploadMetric('uploadTelemetryConnections', lastUploadStats.activeJobs || 0); _setRollingUploadMetric('uploadTelemetryRemaining', stats.remaining); _setRollingUploadMetric('uploadTelemetryRunning', stats.inProgress); - _setRollingUploadMetric('uploadTelemetryCompleted', stats.done); - _setRollingUploadMetric('uploadTelemetryFailed', stats.errors); + _setRollingUploadMetric('uploadTelemetryCompleted', _sessionDoneCount); + _setRollingUploadMetric('uploadTelemetryFailed', Math.max(_sessionErrorCount, stats.errors)); updateUploadSpeedDisplays(); _setUploadTelemetryText('uploadTelemetryEta', etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'); updateUploadSidebarSummary(stats); @@ -4779,13 +4203,37 @@ function renderHealthCheckResults(_results) { } let _appAlertResolve = null; +let _appAlertReturnFocus = null; +let _appAlertInertState = []; + +function _setAppAlertBackgroundInert(active) { + const modal = document.getElementById('appAlertModal'); + if (!modal) return; + if (active) { + if (_appAlertInertState.length > 0) return; + _appAlertInertState = Array.from(document.body.children) + .filter(element => element !== modal && 'inert' in element) + .map(element => ({ element, inert: element.inert })); + _appAlertInertState.forEach(({ element }) => { element.inert = true; }); + return; + } + _appAlertInertState.forEach(({ element, inert }) => { + if (element.isConnected) element.inert = inert; + }); + _appAlertInertState = []; +} function closeAppAlert(result = false) { const modal = document.getElementById('appAlertModal'); if (!modal) return; + modal.style.display = 'none'; + modal.setAttribute('aria-hidden', 'true'); + _setAppAlertBackgroundInert(false); const resolve = _appAlertResolve; _appAlertResolve = null; - modalController.close(modal); + const returnFocus = _appAlertReturnFocus; + _appAlertReturnFocus = null; + if (returnFocus?.isConnected) returnFocus.focus(); if (resolve) resolve(result); } @@ -4798,6 +4246,7 @@ function showAppDialog({ message, title = 'Hinweis', confirmText = 'OK', cancelT const alternate = document.getElementById('appAlertAlternateBtn'); if (!modal || !titleEl || !messageEl || !confirm || !cancel || !alternate) return Promise.resolve(false); if (_appAlertResolve) closeAppAlert(false); + _appAlertReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; titleEl.textContent = localizeUiText(title); messageEl.textContent = localizeUiText(String(message || '')); confirm.textContent = localizeUiText(confirmText); @@ -4806,11 +4255,10 @@ function showAppDialog({ message, title = 'Hinweis', confirmText = 'OK', cancelT cancel.hidden = !showCancel; alternate.textContent = localizeUiText(alternateText); alternate.hidden = !alternateText; - modalController.open(modal, { - initialFocus: () => showCancel ? cancel : confirm, - onEscape: () => closeAppAlert(false), - onBackdrop: () => closeAppAlert(false) - }); + modal.style.display = 'flex'; + modal.setAttribute('aria-hidden', 'false'); + _setAppAlertBackgroundInert(true); + (showCancel ? cancel : confirm).focus(); return new Promise(resolve => { _appAlertResolve = resolve; }); } @@ -4826,23 +4274,6 @@ function showAppChoice({ message, title, confirmText, alternateText, cancelText return showAppDialog({ message, title, confirmText, alternateText, cancelText, showCancel: true }); } -let _appAlertListenersReady = false; - -function setupAppAlertListeners() { - if (_appAlertListenersReady) return; - const confirm = document.getElementById('appAlertConfirmBtn'); - const alternate = document.getElementById('appAlertAlternateBtn'); - const cancel = document.getElementById('appAlertCancelBtn'); - const close = document.getElementById('appAlertCloseBtn'); - const modal = document.getElementById('appAlertModal'); - if (!confirm || !alternate || !cancel || !close || !modal) return; - _appAlertListenersReady = true; - confirm.addEventListener('click', () => closeAppAlert(true)); - alternate.addEventListener('click', () => closeAppAlert('alternate')); - cancel.addEventListener('click', () => closeAppAlert(false)); - close.addEventListener('click', () => closeAppAlert(false)); -} - async function executeHealthCheck(hosters, _mode, generations) { renderHealthCheckResults([]); const result = await window.api.runHealthCheck({ hosters }); @@ -4935,140 +4366,11 @@ async function _renderLogPathsList(el) { }); }); } catch (err) { - el.innerHTML = `${escapeHtml(formatLocalizedError('Fehler', err))}`; + el.innerHTML = `Fehler: ${escapeHtml(err.message || String(err))}`; } } -function filenameFilterConditionRowHtml(condition = {}) { - const operator = condition.operator === 'notContains' ? 'notContains' : 'contains'; - return ` -
-
- Dateiname filtern - -
-
- Vergleich - -
- -
`; -} - -function readFilenameFilterSettings() { - const conditions = Array.from(document.querySelectorAll('[data-filename-filter-condition]')).map(row => ({ - operator: row.querySelector('[data-filename-filter-operator]')?.value || 'contains', - value: row.querySelector('[data-filename-filter-value]')?.value || '' - })); - return window.FilenameFilter.normalizeFilenameFilter({ - enabled: document.getElementById('filenameFilterEnabledInput')?.checked === true, - action: document.getElementById('filenameFilterActionInput')?.value, - matchMode: document.getElementById('filenameFilterMatchModeInput')?.value, - conditions - }); -} - -function syncFilenameFilterControls() { - const enabled = document.getElementById('filenameFilterEnabledInput')?.checked === true; - const panel = document.getElementById('filenameFilterBuilder'); - if (panel) panel.classList.toggle('disabled', !enabled); - const rows = Array.from(document.querySelectorAll('[data-filename-filter-condition]')); - document.querySelectorAll('#filenameFilterBuilder select, #filenameFilterBuilder input, #addFilenameFilterConditionBtn').forEach(control => { - control.disabled = !enabled; - }); - rows.forEach(row => { - const remove = row.querySelector('[data-filename-filter-remove]'); - if (remove) remove.disabled = !enabled || rows.length === 1; - }); -} - -function wireFilenameFilterConditionRow(row) { - if (!row || row.dataset.wired === 'true') return; - row.dataset.wired = 'true'; - row.querySelectorAll('.settings-autosave').forEach(control => { - const eventName = control.tagName === 'SELECT' ? 'change' : 'input'; - control.addEventListener(eventName, markSettingsDirty); - }); - row.querySelector('[data-filename-filter-remove]')?.addEventListener('click', () => { - row.remove(); - syncFilenameFilterControls(); - markSettingsDirty(); - }); -} - -function appendFilenameFilterCondition(condition = {}) { - const list = document.getElementById('filenameFilterConditions'); - if (!list) return; - const template = document.createElement('template'); - template.innerHTML = filenameFilterConditionRowHtml(condition).trim(); - const row = template.content.firstElementChild; - list.appendChild(row); - wireFilenameFilterConditionRow(row); - syncFilenameFilterControls(); - row.querySelector('[data-filename-filter-value]')?.focus(); - markSettingsDirty(); -} - -let uploadScheduleStatusTimer = null; - -function readUploadScheduleSettings(fallback = config.globalSettings?.uploadSchedule) { - if (!window.UploadSchedule) return fallback || { enabled: false }; - const enabledInput = document.getElementById('uploadScheduleEnabledInput'); - if (!enabledInput) return window.UploadSchedule.normalizeUploadSchedule(fallback); - return window.UploadSchedule.normalizeUploadSchedule({ - enabled: enabledInput.checked, - weekdays: Array.from(document.querySelectorAll('[data-upload-schedule-day]:checked')).map(input => Number(input.value)), - start: document.getElementById('uploadScheduleStartInput')?.value, - end: document.getElementById('uploadScheduleEndInput')?.value - }); -} - -function syncUploadScheduleControls() { - if (!window.UploadSchedule) return; - const schedule = readUploadScheduleSettings(); - const state = window.UploadSchedule.evaluateUploadSchedule(schedule, new Date()); - const enabled = schedule.enabled; - document.querySelectorAll('[data-upload-schedule-dependent]').forEach(control => { - control.disabled = !enabled; - }); - const status = document.getElementById('uploadScheduleStatus'); - const badge = document.getElementById('uploadScheduleStatusBadge'); - if (!status || !badge) return; - let text; - let badgeText; - let stateClass = ''; - if (!enabled) { - text = localizeUiText('Deaktiviert. Neue Uploads starten sofort.'); - badgeText = localizeUiText('Inaktiv'); - } else if (!state.valid) { - text = state.reason === 'weekdays' - ? localizeUiText('Ungültig. Wähle mindestens einen Wochentag aus.') - : state.reason === 'equal-times' - ? localizeUiText('Ungültig. Start und Ende müssen unterschiedlich sein.') - : localizeUiText('Ungültig. Prüfe Start- und Endzeit.'); - badgeText = localizeUiText('Ungültig'); - stateClass = ' warning'; - } else if (state.allowed) { - text = localizeUiText('Geöffnet. Neue Uploads dürfen starten.'); - badgeText = localizeUiText('Geöffnet'); - stateClass = ' active'; - } else { - const nextStart = state.nextStart ? formatDateTime(state.nextStart.toISOString()) : '—'; - text = `${localizeUiText('Geschlossen. Nächster erlaubter Start:')} ${nextStart}`; - badgeText = localizeUiText('Geschlossen'); - stateClass = ' warning'; - } - status.textContent = text; - badge.textContent = badgeText; - badge.className = `panel-status${stateClass}`; -} - function renderSettings() { - clearInterval(uploadScheduleStatusTimer); - uploadScheduleStatusTimer = null; const container = document.getElementById('settingsHosters'); container.innerHTML = ''; @@ -5076,16 +4378,11 @@ function renderSettings() { const configuredAccounts = getAvailableHosters(); const fm = globalSettings.folderMonitor || {}; const remoteSettings = globalSettings.remote || {}; - const filenameFilter = window.FilenameFilter.normalizeFilenameFilter(globalSettings.filenameFilter); - const uploadSchedule = window.UploadSchedule.normalizeUploadSchedule(globalSettings.uploadSchedule); - const filenameFilterConditions = filenameFilter.conditions.length > 0 - ? filenameFilter.conditions - : [{ operator: 'contains', value: '' }]; const pageDefinitions = [ { id: 'allgemein', label: 'Allgemein', search: 'fenster window vordergrund foreground always on top drop target oberfläche interface updates update aktualisierung version language sprache' }, - { id: 'uploads', label: 'Uploads', search: 'upload queue warteschlange waiting fertig completed completion abschluss entfernen remove parallel geschwindigkeit speed limit fortsetzen resume wiederherstellen restore hoster dateiname filename filter enthält contains ausschließen exclude' }, - { id: 'automatik', label: 'Automatik', search: 'automatisch automation automatic retry wiederholen zeitfenster schedule wochentage weekdays start ende ordner folder monitor überwachen watch dateierweiterungen extensions unterordner subfolders duplikate duplicates' }, + { id: 'uploads', label: 'Uploads', search: 'upload queue warteschlange waiting fertig completed completion abschluss entfernen remove parallel geschwindigkeit speed limit fortsetzen resume wiederherstellen restore hoster' }, + { id: 'automatik', label: 'Automatik', search: 'automatisch automation automatic retry wiederholen ordner folder monitor überwachen watch dateierweiterungen extensions unterordner subfolders duplikate duplicates' }, { id: 'benachrichtigungen', label: 'Benachrichtigungen', search: 'benachrichtigungen notifications webhook discord meldung message ping erwähnung mention batch fertig completed' }, { id: 'logs', label: 'Logs & Support', search: 'log logs protokoll logging debug verbose diagnose diagnostics support paket package datei file ordner folder' }, { id: 'remote', label: 'Fernsteuerung', search: 'remote control fernsteuerung server input port api token verbindung connection client' }, @@ -5216,41 +4513,6 @@ function renderSettings() { - -
-
-
- - Prüft neue Dateien aus Auswahl, Ordnern, Drag-and-drop und Ordnerüberwachung, bevor sie in die Upload-Liste gelangen. -
- -
-
-
- ${filenameFilterConditions.map(filenameFilterConditionRowHtml).join('')} -
- -
-
- - -
-
- - -
-
-
-
@@ -5263,7 +4525,7 @@ function renderSettings() { `; pages.automatik.innerHTML = ` - ${pageHeader('Automatik', 'Wiederholungen, Upload-Zeitfenster und überwachte Ordner für unbeaufsichtigte Uploads.')} + ${pageHeader('Automatik', 'Wiederholungen und überwachte Ordner für unbeaufsichtigte Uploads.')}
@@ -5279,31 +4541,6 @@ function renderSettings() { Minuten · jede weitere Runde wartet entsprechend länger
- -
-
-
- - Laufende Uploads dürfen fertig werden. Wartende Uploads starten automatisch bei der nächsten Öffnung. -
- -
-
- - - - - - - -
-
- - - Lokale Systemzeit · Zeitfenster über Mitternacht werden unterstützt -
-

-
@@ -5459,9 +4696,24 @@ function renderSettings() {
- 127.0.0.1 +
-
Bindet nur an 127.0.0.1. Fernzugriff nur über einen Tunnel (z.B. Tailscale/SSH).
+
+ + +
+ + +
@@ -5576,7 +4828,7 @@ function renderSettings() { if (!activeButton || activeButton.hidden || !content.querySelector('.settings-subpage.active')) { activateSettingsPage((activeButton && !activeButton.hidden ? activeButton : visibleButtons[0]).dataset.settingsPage); } else { - _syncSidebarIndicator(activeButton); + _syncSidebarIndicator(activeButton, true); } }); @@ -5597,7 +4849,7 @@ function renderSettings() { ? `Test erfolgreich gesendet (HTTP ${res.status}).` : `Test fehlgeschlagen: ${(res && (res.error || 'HTTP ' + res.status)) || 'unbekannt'}`; } catch (err) { - if (hint) hint.textContent = formatLocalizedError('Test fehlgeschlagen', err); + if (hint) hint.textContent = `Test fehlgeschlagen: ${err.message || err}`; } finally { testWebhookBtn.disabled = false; testWebhookBtn.textContent = prev; @@ -5624,10 +4876,10 @@ function renderSettings() { } else if (res && res.canceled) { if (hint) hint.textContent = 'Abgebrochen.'; } else { - if (hint) hint.textContent = formatLocalizedError('Fehler', res?.error); + if (hint) hint.textContent = `Fehler: ${(res && res.error) || 'unbekannt'}`; } } catch (err) { - if (hint) hint.textContent = formatLocalizedError('Fehler', err); + if (hint) hint.textContent = `Fehler: ${err.message || err}`; } finally { sbBtn.disabled = false; sbBtn.textContent = prevText; @@ -5687,6 +4939,13 @@ function renderSettings() { (function wireDiagnostics() { const enabledEl = document.getElementById('diagEnabledInput'); const portEl = document.getElementById('diagPortInput'); + const modeEl = document.getElementById('diagBindModeInput'); + const publicHostEl = document.getElementById('diagPublicHostInput'); + const allowlistEl = document.getElementById('diagAllowlistInput'); + const allowlistRow = document.getElementById('diagAllowlistRow'); + const suggestRow = document.getElementById('diagSuggestRow'); + const suggestChips = document.getElementById('diagSuggestChips'); + const bindHintEl = document.getElementById('diagBindHint'); const codeEl = document.getElementById('diagCodeInput'); const issuedEl = document.getElementById('diagCodeIssued'); const badgeEl = document.getElementById('diagStatusBadge'); @@ -5697,12 +4956,40 @@ function renderSettings() { if (!ts) return ''; try { return 'Code erstellt: ' + new Date(ts).toLocaleString(getUiLocale()); } catch { return ''; } }; + const parseAllowlist = () => allowlistEl.value.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + const renderModeUi = (suggestedHosts) => { + const network = modeEl.value === 'network'; + allowlistRow.style.display = network ? '' : 'none'; + bindHintEl.innerHTML = network + ? 'Bindet an 0.0.0.0. Nur IPs/CIDRs aus der Allowlist dürfen verbinden (Loopback immer) — zusätzlich zum Token. Über Tailscale: trage deinen Tailnet-Bereich ein (z.B. 100.64.0.0/10) und die Tailscale-IP/MagicDNS oben als Code-Adresse. Transport ist plaintext über den Tunnel — Tailscale/WireGuard verschlüsselt.' + : 'Bindet nur an 127.0.0.1. Fernzugriff nur über einen Tunnel (z.B. Tailscale/SSH) — die sicherste Variante.'; + const hosts = Array.isArray(suggestedHosts) ? suggestedHosts : []; + if (hosts.length) { + suggestRow.style.display = ''; + suggestChips.innerHTML = ''; + for (const h of hosts) { + const b = document.createElement('button'); + b.className = 'btn btn-xs btn-secondary'; + b.textContent = h; + b.addEventListener('click', () => { diagnosticsEditedFields.add(publicHostEl.id); publicHostEl.value = h; markSettingsDirty(); }); + suggestChips.appendChild(b); + } + } else { + suggestRow.style.display = 'none'; + } + }; + let lastSuggested = []; const applySettings = (s) => { if (!s) return; if (!diagnosticsEditedFields.has(enabledEl.id)) enabledEl.checked = !!s.enabled; if (!diagnosticsEditedFields.has(portEl.id)) portEl.value = s.port || 9110; + if (!diagnosticsEditedFields.has(modeEl.id)) modeEl.value = s.bindMode === 'network' ? 'network' : 'local'; + if (!diagnosticsEditedFields.has(publicHostEl.id)) publicHostEl.value = s.publicHost || ''; + if (!diagnosticsEditedFields.has(allowlistEl.id)) allowlistEl.value = Array.isArray(s.allowlist) ? s.allowlist.join('\n') : ''; + lastSuggested = Array.isArray(s.suggestedHosts) ? s.suggestedHosts : []; codeEl.value = s.code || ''; issuedEl.textContent = fmtIssued(s.codeIssuedAt); + renderModeUi(lastSuggested); if (badgeEl) { badgeEl.textContent = enabledEl.checked ? 'Aktiv' : 'Inaktiv'; badgeEl.className = 'panel-status' + (enabledEl.checked ? ' active' : ''); @@ -5714,7 +5001,8 @@ function renderSettings() { if (!el || !st) return; if (st.running) { const last = st.lastAccess ? new Date(st.lastAccess).toLocaleString(getUiLocale()) : '—'; - el.textContent = `Aktiv auf 127.0.0.1:${st.port} (nur lokal) — ${st.clientCount} ${st.clientCount === 1 ? 'Client' : 'Clients'} — Letzter Zugriff: ${last}`; + const scope = st.bindMode === 'network' ? `Netzwerk (Allowlist: ${st.allowlistCount})` : 'nur lokal'; + el.textContent = `Aktiv auf ${st.bindAddress}:${st.port} (${scope}) — ${st.clientCount} ${st.clientCount === 1 ? 'Client' : 'Clients'} — Letzter Zugriff: ${last}`; el.style.color = '#10b981'; } else { el.textContent = 'Nicht aktiv'; @@ -5722,8 +5010,16 @@ function renderSettings() { } }).catch(() => {}); }; + const validate = () => { + const allowlist = parseAllowlist(); + if (enabledEl.checked && modeEl.value === 'network' && allowlist.length === 0) { + if (bindHintEl) { bindHintEl.innerHTML = 'Netzwerkmodus braucht mindestens eine IP/CIDR in der Allowlist — sonst bleibt es fail-closed auf Loopback.'; } + return false; + } + return true; + }; - [enabledEl, portEl].forEach(element => { + [enabledEl, portEl, modeEl, publicHostEl, allowlistEl].forEach(element => { const markEdited = () => diagnosticsEditedFields.add(element.id); element.addEventListener('input', markEdited); element.addEventListener('change', markEdited); @@ -5731,8 +5027,11 @@ function renderSettings() { window.api.diagnosticsGetSettings().then(applySettings).catch(() => {}); refreshStatus(); - enabledEl.addEventListener('change', markSettingsDirty); - portEl.addEventListener('change', markSettingsDirty); + enabledEl.addEventListener('change', () => { if (validate()) markSettingsDirty(); }); + portEl.addEventListener('change', () => { if (validate()) markSettingsDirty(); }); + modeEl.addEventListener('change', () => { renderModeUi(lastSuggested); if (validate()) markSettingsDirty(); }); + publicHostEl.addEventListener('change', () => { if (validate()) markSettingsDirty(); }); + allowlistEl.addEventListener('change', () => { if (validate()) markSettingsDirty(); }); document.getElementById('diagCopyCodeBtn').addEventListener('click', async () => { if (!codeEl.value) return; await window.api.copyToClipboard(codeEl.value); @@ -5775,16 +5074,6 @@ function renderSettings() { document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath); document.getElementById('openLogFolderBtn')?.addEventListener('click', () => window.api.openLogFolder()); document.getElementById('manualUpdateCheckBtn')?.addEventListener('click', requestUpdateCheck); - container.querySelectorAll('[data-filename-filter-condition]').forEach(wireFilenameFilterConditionRow); - document.getElementById('addFilenameFilterConditionBtn')?.addEventListener('click', () => appendFilenameFilterCondition()); - document.getElementById('filenameFilterEnabledInput')?.addEventListener('change', syncFilenameFilterControls); - syncFilenameFilterControls(); - container.querySelectorAll('[data-upload-schedule-control]').forEach(control => { - const eventName = control.type === 'time' ? 'input' : 'change'; - control.addEventListener(eventName, syncUploadScheduleControls); - }); - syncUploadScheduleControls(); - uploadScheduleStatusTimer = setInterval(syncUploadScheduleControls, 30000); _syncHeaderUpdateState(); container.querySelectorAll('.settings-autosave').forEach((input) => { const eventName = input.type === 'checkbox' || input.tagName === 'SELECT' ? 'change' : 'input'; @@ -5792,7 +5081,6 @@ function renderSettings() { if (input.id === 'languageInput') { setUiLanguage(input.value); syncLanguagePicker(input.value); - syncUploadScheduleControls(); } if (input.id === 'deleteSourceAfterSuccessfulUploadInput' && input.checked) { const confirmed = await showAppConfirm({ @@ -5904,16 +5192,6 @@ async function performSaveSettings(options = {}) { const cur = config.globalSettings || {}; const curFm = cur.folderMonitor || {}; const curRemote = cur.remote || {}; - const uploadSchedule = readUploadScheduleSettings(cur.uploadSchedule); - const uploadScheduleState = window.UploadSchedule.evaluateUploadSchedule(uploadSchedule, new Date()); - if (uploadSchedule.enabled && !uploadScheduleState.valid) { - const message = uploadScheduleState.reason === 'weekdays' - ? 'Wähle mindestens einen Wochentag für das Upload-Zeitfenster aus.' - : uploadScheduleState.reason === 'equal-times' - ? 'Start und Ende des Upload-Zeitfensters müssen unterschiedlich sein.' - : 'Prüfe Start und Ende des Upload-Zeitfensters.'; - throw new Error(localizeUiText(message)); - } const elTxt = (id, fb) => { const el = document.getElementById(id); return el ? el.value : fb; }; const elChk = (id, fb) => { const el = document.getElementById(id); return el ? !!el.checked : fb; }; const elInt = (id, curVal, dflt, lo, hi) => { @@ -5942,7 +5220,6 @@ async function performSaveSettings(options = {}) { scaleParallelUploads: elChk('scaleParallelUploadsInput', !!cur.scaleParallelUploads), removeFromQueueOnDone: elChk('removeFromQueueOnDoneInput', !!cur.removeFromQueueOnDone), deleteSourceAfterSuccessfulUpload: elChk('deleteSourceAfterSuccessfulUploadInput', !!cur.deleteSourceAfterSuccessfulUpload), - filenameFilter: readFilenameFilterSettings(), showDropTarget: elChk('showDropTargetInput', !!cur.showDropTarget), globalMaxSpeedKbs: (() => { const el = document.getElementById('globalMaxSpeedMbsInput'); @@ -5954,7 +5231,6 @@ async function performSaveSettings(options = {}) { webhookMention: elTxt('webhookMentionInput', cur.webhookMention || '').trim(), autoRetryRounds: elInt('autoRetryRoundsInput', cur.autoRetryRounds ?? 0, 0, 0, 5), autoRetryDelayMin: elInt('autoRetryDelayMinInput', cur.autoRetryDelayMin ?? 5, 5, 1, 120), - uploadSchedule, folderMonitor: { ...curFm, enabled: elChk('fmEnabledInput', !!curFm.enabled), @@ -6016,9 +5292,9 @@ async function performSaveSettings(options = {}) { saves.push(saveDiagnosticsSettingsTracked({ enabled: diagnosticsEnabled.checked, port: Math.min(65535, Math.max(1024, parseInt(document.getElementById('diagPortInput')?.value, 10) || 9110)), - bindMode: 'local', - publicHost: '127.0.0.1', - allowlist: [] + bindMode: document.getElementById('diagBindModeInput')?.value === 'network' ? 'network' : 'local', + publicHost: document.getElementById('diagPublicHostInput')?.value.trim() || '', + allowlist: (document.getElementById('diagAllowlistInput')?.value || '').split(/\r?\n/).map(value => value.trim()).filter(Boolean) })); } await Promise.all(saves); @@ -6117,11 +5393,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 checkedLabel = uiLocalizer.getLanguage() === 'de' ? 'geprüft' : 'checked'; - const checkedText = st.checkedAt ? ` • ${checkedLabel} ${new Date(st.checkedAt).toLocaleTimeString(getUiLocale(), { hour: '2-digit', minute: '2-digit' })}` : ''; - const statusMessage = st.message && !isDisabled - ? (['error', 'warn', 'otp_required'].includes(st.status) ? getLocalizedErrorDetail(st.message) : localizeUiText(st.message)) - : ''; + 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}`; @@ -6141,7 +5413,7 @@ function _buildAccountCardHtml(name, account, idx) {
-