From 03136e9feff5be85269ac17abae50d9174d14f46 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:10:13 +0200 Subject: [PATCH] release: Multi-Hoster-Upload 2.1.7 --- .gitignore | 1 + README.md | 10 +- lib/config-store.js | 5 +- lib/doodstream-upload.js | 8 +- lib/hosters.js | 2 +- lib/source-cleanup-policy.js | 223 +++++++++++++ lib/source-delete-journal.js | 106 +++++++ lib/source-file-cleanup.js | 455 +++++++++++++++++++++++++++ lib/updater.js | 8 +- lib/upload-manager.js | 9 + lib/webhook-notify.js | 9 +- main.js | 144 ++++++++- package-lock.json | 4 +- package.json | 2 +- preload.js | 1 + renderer/app.js | 124 ++++++-- renderer/i18n.js | 139 ++++++++- renderer/index.html | 1 + renderer/styles.css | 176 ++++++++++- scripts/verify-public-release.mjs | 6 + tests/config-store.test.js | 13 + tests/i18n.test.js | 174 +++++++++++ tests/source-cleanup-policy.test.js | 205 ++++++++++++ tests/source-delete-journal.test.js | 85 +++++ tests/source-file-cleanup.test.js | 469 ++++++++++++++++++++++++++++ tests/ui-smoke.js | 23 +- tests/upload-manager.test.js | 30 ++ tests/webhook-notify.test.js | 16 +- 28 files changed, 2368 insertions(+), 80 deletions(-) create mode 100644 lib/source-cleanup-policy.js create mode 100644 lib/source-delete-journal.js create mode 100644 lib/source-file-cleanup.js create mode 100644 tests/source-cleanup-policy.test.js create mode 100644 tests/source-delete-journal.test.js create mode 100644 tests/source-file-cleanup.test.js diff --git a/.gitignore b/.gitignore index fe318b2..346bf1f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ .dev-runner.lock release/ .artifacts/ +.worktrees/ __pycache__/ *.pyc diff --git a/README.md b/README.md index 1906082..687fe38 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Multi-Hoster-Upload is a Windows desktop application for sending file batches to Download the current Setup or Portable build from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest). -The latest public release is version 2.1.6. Use the release page for the executables and the full English changelog. +The latest public release is version 2.1.7. Use the release page for the executables and the full English changelog. ## Features @@ -116,6 +116,12 @@ Queue state is saved while you work and again during a normal application close. Successful jobs can remain visible or be removed automatically. History is stored separately, so removing a completed row from the active queue does not remove its history entry. +### Permanent source cleanup + +**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 the upload log. Deletion bypasses the Recycle Bin and cannot be undone. + ## Folder monitoring ![Automation and folder monitoring settings](docs/screenshots/automation-settings.png) @@ -176,7 +182,7 @@ Treat the generated key like a password: anyone with it can restore the encrypte - Uploads and credential checks communicate with the host you selected. - Update checks retrieve release metadata, and the dialog loads the matching changelog from GitHub when available. -- Webhook notifications are sent only when you configure a webhook URL. Discord endpoints receive a formatted summary; other endpoints receive JSON. +- Webhook notifications are sent only when you configure a webhook URL. Discord endpoints receive a formatted summary in the selected interface language; other endpoints receive JSON. - Online backup traffic occurs only when you create or restore an online backup. ## Updates and changelog diff --git a/lib/config-store.js b/lib/config-store.js index 5881d91..509f627 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -76,6 +76,7 @@ const DEFAULTS = { scaleParallelUploads: false, lastBrowseDirectory: '', removeFromQueueOnDone: false, + deleteSourceAfterSuccessfulUpload: false, showDropTarget: false, globalMaxSpeedKbs: 0, // 0 = unlimited global speed pendingQueue: null, @@ -689,7 +690,9 @@ class ConfigStore { if (this._historyMigrated) { return this._enqueueHistoryWrite(() => { const cur = this._readHistoryFile(); - if (cur === null && fs.existsSync(this.historyPath)) return; + if (cur === null && fs.existsSync(this.historyPath)) { + throw new Error('Die Verlaufsdatei ist beschädigt und wurde nicht verändert'); + } const arr = cur || []; arr.push(entry); const gs = this.load().globalSettings; diff --git a/lib/doodstream-upload.js b/lib/doodstream-upload.js index 1c3821b..4cc45d1 100644 --- a/lib/doodstream-upload.js +++ b/lib/doodstream-upload.js @@ -270,7 +270,7 @@ class DoodstreamUploader { 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 geaendert?). ` + + `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'}` ); @@ -536,7 +536,7 @@ 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. - const emptyLinkErr = new Error(`Doodstream Upload: kein Filecode — Server gab leeren Link zurueck (st=${st || '?'}, fn=${fnInfo}, CDN=${node}). CDN-Antwort: ${(resText || '').slice(0, 200)}`); + 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; } @@ -563,10 +563,10 @@ class DoodstreamUploader { // Check if fn was in original hidden fields if (fnCode && fnCode.length >= 8) return this._buildResult(fnCode); - throw new Error(`Doodstream Upload: Redirect-Antwort ungueltig (${followText.slice(0, 150)})`); + throw new Error(`Doodstream Upload: Redirect-Antwort ungültig (${followText.slice(0, 150)})`); } - throw new Error(`Doodstream Upload: Keine gueltige Antwort (Body: ${resText.slice(0, 150)})`); + throw new Error(`Doodstream Upload: Keine gültige Antwort (Body: ${resText.slice(0, 150)})`); } /** diff --git a/lib/hosters.js b/lib/hosters.js index 5bd7a5d..2221642 100644 --- a/lib/hosters.js +++ b/lib/hosters.js @@ -403,7 +403,7 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) { if (lastTransient) e.transientNetwork = true; throw e; } - throw new Error('Kein Upload-Server erhalten. API-Key pruefen.'); + throw new Error('Kein Upload-Server erhalten. API-Key prüfen.'); } async function _fetchByseFileList(apiKey, signal) { diff --git a/lib/source-cleanup-policy.js b/lib/source-cleanup-policy.js new file mode 100644 index 0000000..b97f3fd --- /dev/null +++ b/lib/source-cleanup-policy.js @@ -0,0 +1,223 @@ +(function (root) { + 'use strict'; + + const pathApi = typeof require === 'function' ? require('path') : null; + const protectedStatuses = new Set(['done', 'error', 'aborted', 'skipped']); + + function normalizeFile(file, platform) { + const value = typeof file === 'string' ? file.trim() : ''; + if (!value) return ''; + if (platform === 'win32') { + if (pathApi) return pathApi.win32.resolve(value); + return value.replace(/\//g, '\\'); + } + if (pathApi) return pathApi.resolve(value); + return value; + } + + function normalizeHoster(hoster) { + return typeof hoster === 'string' ? hoster.trim().toLowerCase() : ''; + } + + function uniqueHosters(values) { + const hosters = []; + const seen = new Set(); + for (const value of values) { + const hoster = normalizeHoster(value); + if (!hoster || seen.has(hoster)) continue; + seen.add(hoster); + hosters.push(hoster); + } + return hosters; + } + + function cloneFingerprint(fingerprint) { + if (!fingerprint || typeof fingerprint !== 'object' || Array.isArray(fingerprint)) return null; + return { ...fingerprint }; + } + + function relatedJobs(queueJobs, job, platform) { + if (!Array.isArray(queueJobs) || !job) return []; + const file = normalizeFile(job.file, platform); + const token = typeof job.sourceCleanupToken === 'string' && job.sourceCleanupToken + ? job.sourceCleanupToken + : null; + return queueJobs.filter((candidate) => { + if (!candidate) return false; + if (file && normalizeFile(candidate.file, platform) === file) return true; + return token !== null && candidate.sourceCleanupToken === token; + }); + } + + function storedRequiredHosters(jobs) { + const values = []; + for (const job of jobs) { + if (Array.isArray(job.sourceCleanupRequiredHosters)) { + values.push(...job.sourceCleanupRequiredHosters); + } + } + return uniqueHosters(values); + } + + function completedHosters(jobs, requiredHosters) { + const values = []; + for (const job of jobs) { + if (Array.isArray(job.sourceCleanupCompletedHosters)) { + values.push(...job.sourceCleanupCompletedHosters); + } + } + for (const job of jobs) { + if (job.status === 'done') values.push(job.hoster); + } + const completed = new Set(uniqueHosters(values)); + return requiredHosters.filter((hoster) => completed.has(hoster)); + } + + function storedToken(jobs) { + for (const job of jobs) { + if (typeof job.sourceCleanupToken === 'string' && job.sourceCleanupToken) { + return job.sourceCleanupToken; + } + } + return null; + } + + function storedFingerprint(jobs) { + for (const job of jobs) { + const fingerprint = cloneFingerprint(job.sourceCleanupFingerprint); + if (fingerprint) return fingerprint; + } + return null; + } + + function assignMetadata(jobs, token, requiredHosters, completed, fingerprint, touchedJobs, touchedSet) { + for (const job of jobs) { + job.sourceCleanupToken = token; + job.sourceCleanupRequiredHosters = [...requiredHosters]; + job.sourceCleanupCompletedHosters = [...completed]; + job.sourceCleanupFingerprint = cloneFingerprint(fingerprint); + if (!touchedSet.has(job)) { + touchedSet.add(job); + touchedJobs.push(job); + } + } + } + + function prepareGroups(queueJobs, jobsToStart, createToken, platform) { + const groups = []; + const touchedJobs = []; + const touchedSet = new Set(); + const preparedFiles = new Set(); + if (!Array.isArray(queueJobs) || !Array.isArray(jobsToStart)) return { groups, touchedJobs }; + + for (const selectedJob of jobsToStart) { + const file = normalizeFile(selectedJob && selectedJob.file, platform); + if (!file || preparedFiles.has(file)) continue; + const siblings = relatedJobs(queueJobs, selectedJob, platform); + if (siblings.length === 0) continue; + preparedFiles.add(file); + + const token = storedToken(siblings) || (typeof createToken === 'function' ? createToken(file) : null); + if (typeof token !== 'string' || !token) continue; + const persistedRequired = storedRequiredHosters(siblings); + const requiredHosters = uniqueHosters([ + ...persistedRequired, + ...siblings.map((job) => job.hoster) + ]); + const completed = completedHosters(siblings, requiredHosters); + const fingerprint = storedFingerprint(siblings); + + assignMetadata( + siblings, + token, + requiredHosters, + completed, + fingerprint, + touchedJobs, + touchedSet + ); + + groups.push({ + token, + file: selectedJob.file, + requiredHosters: [...requiredHosters], + completedHosters: [...completed], + fingerprint: cloneFingerprint(fingerprint), + jobs: siblings.map((job) => ({ + jobId: job.id, + hoster: normalizeHoster(job.hoster), + status: job.status + })) + }); + } + + return { groups, touchedJobs }; + } + + function markCompleted(queueJobs, job, platform) { + const siblings = relatedJobs(queueJobs, job, platform); + if (siblings.length === 0) return []; + const requiredHosters = storedRequiredHosters(siblings); + const completed = new Set(completedHosters(siblings, requiredHosters)); + const hoster = normalizeHoster(job.hoster); + if (requiredHosters.includes(hoster)) completed.add(hoster); + const orderedCompleted = requiredHosters.filter((required) => completed.has(required)); + for (const sibling of siblings) { + sibling.sourceCleanupCompletedHosters = [...orderedCompleted]; + } + return siblings; + } + + function removeRequirement(queueJobs, job, platform) { + if (!job || protectedStatuses.has(job.status)) return []; + const siblings = relatedJobs(queueJobs, job, platform); + const removedHoster = normalizeHoster(job.hoster); + for (const sibling of siblings) { + const required = Array.isArray(sibling.sourceCleanupRequiredHosters) + ? sibling.sourceCleanupRequiredHosters + : []; + const completed = Array.isArray(sibling.sourceCleanupCompletedHosters) + ? sibling.sourceCleanupCompletedHosters + : []; + sibling.sourceCleanupRequiredHosters = uniqueHosters(required) + .filter((hoster) => hoster !== removedHoster); + sibling.sourceCleanupCompletedHosters = uniqueHosters(completed) + .filter((hoster) => hoster !== removedHoster); + } + return siblings; + } + + function fingerprintFor(fingerprints, token) { + if (fingerprints instanceof Map) return fingerprints.get(token); + if (!fingerprints || typeof fingerprints !== 'object') return undefined; + return Object.prototype.hasOwnProperty.call(fingerprints, token) + ? fingerprints[token] + : undefined; + } + + function applyFingerprints(queueJobs, fingerprints) { + if (!Array.isArray(queueJobs)) return []; + const touchedJobs = []; + for (const job of queueJobs) { + if (!job || typeof job.sourceCleanupToken !== 'string' || !job.sourceCleanupToken) continue; + const fingerprint = fingerprintFor(fingerprints, job.sourceCleanupToken); + if (fingerprint === undefined) continue; + job.sourceCleanupFingerprint = cloneFingerprint(fingerprint); + touchedJobs.push(job); + } + return touchedJobs; + } + + const api = { + prepareGroups, + markCompleted, + removeRequirement, + applyFingerprints + }; + + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; + } else if (root) { + root.SourceCleanupPolicy = api; + } +})(typeof window !== 'undefined' ? window : this); diff --git a/lib/source-delete-journal.js b/lib/source-delete-journal.js new file mode 100644 index 0000000..9157880 --- /dev/null +++ b/lib/source-delete-journal.js @@ -0,0 +1,106 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +class SourceDeleteJournal { + constructor(filePath, onEvent, options = {}) { + this.filePath = filePath; + this.onEvent = typeof onEvent === 'function' ? onEvent : () => {}; + this.fs = options.fs || fs; + this.wait = typeof options.wait === 'function' ? options.wait : (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + this.pending = Promise.resolve(); + } + + append(entry) { + const operation = this.pending.then(async () => { + await this.fs.promises.mkdir(path.dirname(this.filePath), { recursive: true }); + const handle = await this.fs.promises.open(this.filePath, 'a'); + try { + await handle.writeFile(`${JSON.stringify(entry)}\n`, 'utf-8'); + await handle.sync(); + } finally { + await handle.close(); + } + }); + this.pending = operation.catch(() => {}); + return operation; + } + + plan(entry) { + return this.append({ action: 'plan', timestamp: new Date().toISOString(), ...entry }); + } + + clear(token) { + return this.append({ action: 'clear', timestamp: new Date().toISOString(), token }); + } + + async recover() { + await this.pending; + let text; + try { + text = await this.fs.promises.readFile(this.filePath, 'utf-8'); + } catch (error) { + if (error && error.code === 'ENOENT') return []; + throw error; + } + const active = new Map(); + for (const line of text.split(/\r?\n/)) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line); + if (!entry || typeof entry.token !== 'string') continue; + if (entry.action === 'plan') active.set(entry.token, entry); + if (entry.action === 'clear') active.delete(entry.token); + } catch {} + } + const outcomes = []; + for (const entry of active.values()) { + try { + const sourceExists = await this.exists(entry.file); + const stagedExists = await this.exists(entry.stagedFile); + if (!sourceExists && stagedExists) { + try { + await this.renameWithRetries(entry.stagedFile, entry.file); + outcomes.push({ token: entry.token, outcome: 'restored', file: entry.file }); + await this.clear(entry.token); + } catch (error) { + outcomes.push({ token: entry.token, outcome: 'restore-failed', file: entry.file, error: error.message }); + } + } else if (!stagedExists) { + outcomes.push({ token: entry.token, outcome: 'cleared', file: entry.file }); + await this.clear(entry.token); + } else { + outcomes.push({ token: entry.token, outcome: 'conflict', file: entry.file, stagedFile: entry.stagedFile }); + } + } catch (error) { + outcomes.push({ token: entry.token, outcome: 'recovery-failed', file: entry.file, error: error.message }); + } + } + for (const outcome of outcomes) this.onEvent(outcome); + return outcomes; + } + + async exists(target) { + try { + await this.fs.promises.lstat(target); + return true; + } catch (error) { + if (error && error.code === 'ENOENT') return false; + throw error; + } + } + + async renameWithRetries(from, to) { + const delays = [100, 250, 500]; + for (let attempt = 0; ; attempt += 1) { + try { + await this.fs.promises.rename(from, to); + return; + } catch (error) { + if (!error || !['EBUSY', 'EPERM'].includes(error.code) || attempt >= delays.length) throw error; + await this.wait(delays[attempt]); + } + } + } +} + +module.exports = SourceDeleteJournal; diff --git a/lib/source-file-cleanup.js b/lib/source-file-cleanup.js new file mode 100644 index 0000000..e8dc7e0 --- /dev/null +++ b/lib/source-file-cleanup.js @@ -0,0 +1,455 @@ +const RETRY_DELAYS = [100, 250, 500, 1000, 2000]; +const TERMINAL_STATUSES = new Set(['done', 'error', 'aborted', 'skipped', 'failed', 'missing-account']); +const crypto = require('node:crypto'); + +function createSourceFileCleanup(options) { + if (!options || !options.fs || !options.path) { + throw new TypeError('createSourceFileCleanup requires fs and path'); + } + + const fs = options.fs; + const path = options.path; + const platform = options.platform || process.platform; + const isEnabled = typeof options.isEnabled === 'function' ? options.isEnabled : () => false; + const audit = typeof options.audit === 'function' ? options.audit : () => {}; + const journal = options.journal || null; + const wait = typeof options.wait === 'function' + ? options.wait + : (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + const groups = new Map(); + const leases = new Map(); + + function canonicalize(file) { + const resolved = path.resolve(file); + return platform === 'win32' ? resolved.toLowerCase() : resolved; + } + + function cloneFingerprint(fingerprint) { + if (!fingerprint) return null; + return { + type: 'file', + size: fingerprint.size, + mtimeMs: fingerprint.mtimeMs, + birthtimeMs: fingerprint.birthtimeMs, + dev: fingerprint.dev, + ino: fingerprint.ino + }; + } + + function isFingerprint(fingerprint) { + return Boolean( + fingerprint && + fingerprint.type === 'file' && + Number.isFinite(fingerprint.size) && + Number.isFinite(fingerprint.mtimeMs) && + Number.isFinite(fingerprint.birthtimeMs) && + Number.isFinite(fingerprint.dev) && + Number.isFinite(fingerprint.ino) + ); + } + + function fingerprintFromStat(stat) { + if (!stat.isFile()) return null; + return { + type: 'file', + size: stat.size, + mtimeMs: stat.mtimeMs, + birthtimeMs: stat.birthtimeMs, + dev: stat.dev, + ino: stat.ino + }; + } + + function fingerprintsMatch(left, right) { + return Boolean( + left && + right && + left.type === right.type && + left.size === right.size && + left.mtimeMs === right.mtimeMs && + left.birthtimeMs === right.birthtimeMs && + left.dev === right.dev && + left.ino === right.ino + ); + } + + function uniqueStrings(values) { + return [...new Set((Array.isArray(values) ? values : []).filter((value) => typeof value === 'string' && value.length > 0))]; + } + + function normalizeStatus(status) { + return TERMINAL_STATUSES.has(status) ? status : 'pending'; + } + + function createManifest(input, canonicalFile) { + const requiredHosters = uniqueStrings(input.requiredHosters); + 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 status = completedHosters.has(job.hoster) ? 'done' : normalizeStatus(job.status); + jobs.set(job.jobId, Object.freeze({ + jobId: job.jobId, + hoster: job.hoster, + 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), + completedHosters, + jobs, + suppliedFingerprint: isFingerprint(input.fingerprint) ? cloneFingerprint(input.fingerprint) : null, + fingerprint: null, + registrationOutcome: null, + registrationError: null, + audited: false, + finalizationPromise: null + }; + } + + async function inspectRegistration(manifest) { + try { + const stat = await fs.promises.lstat(manifest.file); + const currentFingerprint = fingerprintFromStat(stat); + if (!currentFingerprint) { + manifest.registrationOutcome = 'unsafe-source-type'; + return; + } + if (manifest.suppliedFingerprint && !fingerprintsMatch(manifest.suppliedFingerprint, currentFingerprint)) { + manifest.fingerprint = manifest.suppliedFingerprint; + manifest.registrationOutcome = 'source-changed'; + return; + } + manifest.fingerprint = manifest.suppliedFingerprint || currentFingerprint; + } catch (error) { + manifest.registrationOutcome = error && error.code === 'ENOENT' ? 'source-missing' : 'failed'; + manifest.registrationError = error; + } + } + + async function registerGroups(inputGroups) { + const fingerprints = {}; + for (const input of Array.isArray(inputGroups) ? inputGroups : []) { + const token = input && (input.token || input.sourceCleanupToken); + if (typeof token !== 'string' || token.length === 0 || typeof input.file !== 'string' || input.file.length === 0) { + throw new TypeError('source cleanup groups require token and file'); + } + const existing = groups.get(token); + if (existing) { + if (existing.finalizationPromise) throw new Error('source cleanup group is already finalizing'); + if (canonicalize(input.file) !== existing.canonicalFile) throw new Error('source cleanup token changed file'); + existing.requiredHosters = Object.freeze(uniqueStrings([ + ...existing.requiredHosters, + ...input.requiredHosters + ])); + for (const hoster of uniqueStrings(input.completedHosters)) { + if (existing.requiredHosters.includes(hoster)) existing.completedHosters.add(hoster); + } + 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 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 })); + } + fingerprints[token] = cloneFingerprint(existing.fingerprint); + continue; + } + + const canonicalFile = canonicalize(input.file); + const manifest = createManifest(input, canonicalFile); + const leaseToken = leases.get(canonicalFile); + if (leaseToken && leaseToken !== token) { + manifest.registrationOutcome = 'blocked'; + manifest.registrationError = new Error('active-source-lease'); + const leaseOwner = groups.get(leaseToken); + if (leaseOwner) { + leaseOwner.registrationOutcome = 'blocked'; + leaseOwner.registrationError = new Error('active-source-lease'); + } + } else { + leases.set(canonicalFile, token); + await inspectRegistration(manifest); + } + groups.set(token, manifest); + fingerprints[token] = cloneFingerprint(manifest.fingerprint); + } + return fingerprints; + } + + function settle(event) { + if (!event || !TERMINAL_STATUSES.has(event.status)) return false; + const token = event.token || event.sourceCleanupToken; + const manifest = groups.get(token); + if (!manifest || manifest.finalizationPromise) return false; + const job = manifest.jobs.get(event.jobId); + 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; + } + + function markSkipped(jobId) { + let changed = false; + for (const manifest of groups.values()) { + if (manifest.finalizationPromise) continue; + const job = manifest.jobs.get(jobId); + if (!job) continue; + manifest.jobs.set(jobId, Object.freeze({ ...job, status: 'skipped' })); + changed = true; + } + return changed; + } + + function blockingStatuses(manifest) { + const blocking = []; + for (const hoster of manifest.requiredHosters) { + 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 }); + } + return blocking; + } + + async function emitAudit(manifest, outcome, details = {}) { + if (manifest.audited) return; + manifest.audited = true; + await writeAudit(manifest, outcome, details); + } + + async function writeAudit(manifest, outcome, details = {}) { + const event = { + timestamp: new Date().toISOString(), + outcome, + file: manifest.file, + hosters: [...manifest.requiredHosters], + ...details + }; + if (event.error instanceof Error) event.error = event.error.message; + try { + return (await audit(event)) !== false; + } catch { + return false; + } + } + + async function currentSourceOutcome(manifest) { + try { + const stat = await fs.promises.lstat(manifest.file); + const currentFingerprint = fingerprintFromStat(stat); + if (!currentFingerprint) return { outcome: 'unsafe-source-type', trigger: 'unsafe-source-type' }; + if (!fingerprintsMatch(manifest.fingerprint, currentFingerprint)) { + return { outcome: 'source-changed', trigger: 'source-fingerprint-mismatch' }; + } + return null; + } catch (error) { + if (error && error.code === 'ENOENT') return { outcome: 'source-missing', trigger: 'source-missing' }; + return { outcome: 'failed', trigger: 'source-stat-failed', error }; + } + } + + async function unlinkWithRetries(file) { + let attempts = 0; + while (true) { + attempts += 1; + try { + await fs.promises.unlink(file); + return { attempts }; + } catch (error) { + const retryable = platform === 'win32' && error && (error.code === 'EBUSY' || error.code === 'EPERM'); + if (!retryable || attempts > RETRY_DELAYS.length) throw Object.assign(error, { cleanupAttempts: attempts }); + await wait(RETRY_DELAYS[attempts - 1]); + } + } + } + + async function restoreStagedFile(manifest, stagedFile) { + try { + await fs.promises.rename(stagedFile, manifest.file); + return null; + } catch (error) { + return error; + } + } + + async function clearJournal(manifest) { + if (!journal || typeof journal.clear !== 'function') return; + try { await journal.clear(manifest.token); } catch {} + } + + async function stageSource(manifest) { + const stagedFile = path.join( + path.dirname(manifest.file), + `.${path.basename(manifest.file)}.mhu-delete-${crypto.randomUUID()}` + ); + if (!journal || typeof journal.plan !== 'function') { + return { outcome: 'blocked', trigger: 'delete-journal-unavailable' }; + } + try { + await journal.plan({ token: manifest.token, file: manifest.file, stagedFile }); + } catch (error) { + return { outcome: 'blocked', trigger: 'delete-journal-write-failed', error }; + } + try { + await fs.promises.rename(manifest.file, stagedFile); + } catch (error) { + await clearJournal(manifest); + return { + outcome: error && error.code === 'ENOENT' ? 'source-missing' : 'failed', + trigger: error && error.code === 'ENOENT' ? 'source-missing' : 'source-stage-failed', + error + }; + } + try { + const stat = await fs.promises.lstat(stagedFile); + const fingerprint = fingerprintFromStat(stat); + if (fingerprintsMatch(manifest.fingerprint, fingerprint)) return { stagedFile }; + const restoreError = await restoreStagedFile(manifest, stagedFile); + if (!restoreError) await clearJournal(manifest); + return { + outcome: 'source-changed', + trigger: 'source-fingerprint-mismatch-after-stage', + error: restoreError, + stagedFile: restoreError ? stagedFile : undefined + }; + } catch (error) { + const restoreError = await restoreStagedFile(manifest, stagedFile); + if (!restoreError) await clearJournal(manifest); + return { + outcome: 'failed', + trigger: 'staged-source-stat-failed', + error, + restoreError, + stagedFile: restoreError ? stagedFile : undefined + }; + } + } + + async function finalize(manifest, barriers) { + try { + let enabled; + try { + enabled = Boolean(await isEnabled()); + } catch (error) { + await emitAudit(manifest, 'failed', { trigger: 'setting-check-failed', error }); + return 'failed'; + } + if (!enabled) { + await emitAudit(manifest, 'setting-disabled', { trigger: 'setting-disabled' }); + return 'setting-disabled'; + } + if (!barriers.historyPersisted || !barriers.queuePersisted) { + await emitAudit(manifest, 'blocked', { + trigger: 'persistence-barrier-incomplete', + historyPersisted: Boolean(barriers.historyPersisted), + queuePersisted: Boolean(barriers.queuePersisted) + }); + return 'blocked'; + } + if (manifest.registrationOutcome) { + await emitAudit(manifest, manifest.registrationOutcome, { + trigger: manifest.registrationOutcome === 'blocked' ? 'active-source-lease' : manifest.registrationOutcome, + error: manifest.registrationError + }); + return manifest.registrationOutcome; + } + const blocking = blockingStatuses(manifest); + if (blocking.length > 0) { + await emitAudit(manifest, 'blocked', { + trigger: 'required-hoster-not-done', + blockingStatuses: blocking + }); + return 'blocked'; + } + const sourceOutcome = await currentSourceOutcome(manifest); + if (sourceOutcome) { + await emitAudit(manifest, sourceOutcome.outcome, sourceOutcome); + return sourceOutcome.outcome; + } + const auditPersisted = await writeAudit(manifest, 'delete-approved', { + trigger: 'all-selected-hosters-succeeded' + }); + if (!auditPersisted) { + await emitAudit(manifest, 'blocked', { trigger: 'audit-write-failed' }); + return 'blocked'; + } + const staged = await stageSource(manifest); + if (!staged.stagedFile || staged.outcome) { + await emitAudit(manifest, staged.outcome || 'failed', staged); + return staged.outcome || 'failed'; + } + const commitPersisted = await writeAudit(manifest, 'source-staged', { + trigger: 'verified-source-staged' + }); + if (!commitPersisted) { + const restoreError = await restoreStagedFile(manifest, staged.stagedFile); + if (!restoreError) await clearJournal(manifest); + await emitAudit(manifest, 'blocked', { + trigger: 'audit-commit-write-failed', + restoreError, + stagedFile: restoreError ? staged.stagedFile : undefined + }); + return 'blocked'; + } + try { + const result = await unlinkWithRetries(staged.stagedFile); + await clearJournal(manifest); + await emitAudit(manifest, 'deleted', { + trigger: 'all-selected-hosters-succeeded', + attempts: result.attempts + }); + return 'deleted'; + } catch (error) { + const restoreError = await restoreStagedFile(manifest, staged.stagedFile); + if (!restoreError) await clearJournal(manifest); + await emitAudit(manifest, 'failed', { + trigger: 'unlink-failed', + attempts: error.cleanupAttempts || 1, + error, + restoreError, + stagedFile: restoreError ? staged.stagedFile : undefined + }); + return 'failed'; + } + } catch (error) { + await emitAudit(manifest, 'failed', { trigger: 'cleanup-failed', error }); + return 'failed'; + } finally { + if (leases.get(manifest.canonicalFile) === manifest.token) leases.delete(manifest.canonicalFile); + } + } + + async function finishBatch(barriers = {}) { + const pending = []; + for (const manifest of groups.values()) { + if (!manifest.finalizationPromise) { + manifest.finalizationPromise = finalize(manifest, { + historyPersisted: barriers.historyPersisted === true, + queuePersisted: barriers.queuePersisted === true + }); + } + pending.push(manifest.finalizationPromise); + } + return Promise.all(pending); + } + + return Object.freeze({ registerGroups, settle, markSkipped, finishBatch }); +} + +module.exports = { createSourceFileCleanup }; diff --git a/lib/updater.js b/lib/updater.js index 13803b5..fa24609 100644 --- a/lib/updater.js +++ b/lib/updater.js @@ -196,10 +196,10 @@ async function prepareUpdate(onProgress, options = {}) { check = await checkForUpdate(); } if (!check || !check.available) { - throw new Error('Kein Update verfuegbar'); + throw new Error('Kein Update verfügbar'); } if (!check.assetUrl || !check.assetName) { - throw new Error('Update-Asset unvollstaendig (URL oder Name fehlt)'); + throw new Error('Update-Asset unvollständig (URL oder Name fehlt)'); } // Stage: downloading @@ -260,7 +260,7 @@ async function prepareUpdate(onProgress, options = {}) { if (onProgress) onProgress({ stage: 'verifying', percent: 0 }); if (!verifyExeHeader(fileBuffer)) { - throw new Error('Heruntergeladene Datei ist keine gueltige EXE'); + throw new Error('Heruntergeladene Datei ist keine gültige EXE'); } // Optional SHA-512 verification from latest.yml @@ -271,7 +271,7 @@ async function prepareUpdate(onProgress, options = {}) { // Try hex comparison const actualHex = crypto.createHash('sha512').update(fileBuffer).digest('hex'); if (actualHex !== expectedSha.toLowerCase()) { - throw new Error('SHA-512 Pruefung fehlgeschlagen'); + throw new Error('SHA-512 Prüfung fehlgeschlagen'); } } } diff --git a/lib/upload-manager.js b/lib/upload-manager.js index 3518164..bc751bb 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -447,11 +447,13 @@ class UploadManager extends EventEmitter { let hosterSlotAcquired = false; let globalSlotAcquired = false; let finalResultRecorded = false; + let finalStatus = 'error'; let lastError = null; const recordFinalResult = (status, payload = {}) => { if (finalResultRecorded) return; finalResultRecorded = true; + finalStatus = status; const result = { hoster: task.hoster, @@ -1053,6 +1055,13 @@ class UploadManager extends EventEmitter { // 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, + file: task.file, + hoster: task.hoster, + status: finalStatus + }); } } diff --git a/lib/webhook-notify.js b/lib/webhook-notify.js index b679df7..ffe6bc6 100644 --- a/lib/webhook-notify.js +++ b/lib/webhook-notify.js @@ -57,6 +57,7 @@ function resolveDiscordMention(raw) { function buildWebhookRequest(url, summary, meta) { const m = meta || {}; + const language = String(m.language || 'en').toLowerCase() === 'de' ? 'de' : 'en'; const total = Number(summary && summary.total) || 0; const succeeded = Number(summary && summary.succeeded) || 0; const failed = Number(summary && summary.failed) || 0; @@ -65,7 +66,9 @@ function buildWebhookRequest(url, summary, meta) { let body; if (isDiscordWebhook(url)) { - const headline = m.aborted ? 'Batch abgebrochen' : 'Batch fertig'; + const headline = language === 'de' + ? (m.aborted ? 'Batch abgebrochen' : 'Batch fertig') + : (m.aborted ? 'Batch canceled' : 'Batch completed'); const hosterEntries = Object.entries(perHoster); const MAX_HOSTER_LINES = 12; let hosterLines = hosterEntries.slice(0, MAX_HOSTER_LINES) @@ -74,7 +77,9 @@ function buildWebhookRequest(url, summary, meta) { if (hosterEntries.length > MAX_HOSTER_LINES) hosterLines += ` · …+${hosterEntries.length - MAX_HOSTER_LINES}`; const lines = [ `**Multi-Hoster-Upload — ${headline}**${m.machineName ? ` (${m.machineName})` : ''}`, - `✅ ${succeeded} ok · ❌ ${failed} Fehler · 📦 ${total} gesamt · ⏱ ${duration}` + language === 'de' + ? `✅ ${succeeded} ok · ❌ ${failed} Fehler · 📦 ${total} gesamt · ⏱ ${duration}` + : `✅ ${succeeded} succeeded · ❌ ${failed} failed · 📦 ${total} total · ⏱ ${duration}` ]; if (hosterLines) lines.push(hosterLines); const mention = resolveDiscordMention(m.mention); diff --git a/main.js b/main.js index b830100..3fca2aa 100644 --- a/main.js +++ b/main.js @@ -8,6 +8,8 @@ const path = require('path'); const fs = require('fs'); const ConfigStore = require('./lib/config-store'); const UploadManager = require('./lib/upload-manager'); +const { createSourceFileCleanup } = require('./lib/source-file-cleanup'); +const SourceDeleteJournal = require('./lib/source-delete-journal'); const { HOSTER_CONFIGS } = require('./lib/hosters'); const VidmolyUploader = require('./lib/vidmoly-upload'); const VoeUploader = require('./lib/voe-upload'); @@ -113,6 +115,26 @@ let tray = null; const configStore = new ConfigStore(app); configStore.setPerfLog((m) => { try { logInfo(m); } catch {} }); let uploadManager = null; +let sourceDeleteJournal = null; +const pendingUploadFinalizations = new Map(); + +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); + } + }); + safeSend('upload-batch-done', { summary, finalizationId }); + }); +} const activeUploadProducerTrackers = new Set(); const settingsImportGate = createSettingsImportGate(() => !!(uploadManager && uploadManager.running)); let diagnosticAgent = null; @@ -549,6 +571,7 @@ async function sendBatchWebhook(summary, durationSec, extra) { appVersion: app.getVersion(), machineName: require('os').hostname() || 'unknown-host', mention: gs.webhookMention || '', + language: gs.language || 'en', aborted: !!(extra && extra.aborted), timestamp: new Date().toISOString() }); @@ -890,6 +913,27 @@ function appendUploadLog(hoster, link, fileName) { } } +async function appendSourceCleanupAudit(event) { + debugLog(`source-cleanup: ${event.outcome} ${event.file} trigger=${event.trigger || '-'}`); + const line = `# SOURCE-CLEANUP ${JSON.stringify(event)}\r\n`; + const delays = [0, 100, 250]; + for (const delay of delays) { + if (delay) await new Promise((resolve) => setTimeout(resolve, delay)); + const target = _resolveUploadLogTarget(); + if (!target) continue; + try { + fs.mkdirSync(path.dirname(target.path), { recursive: true }); + maybeRotateLogFile(target.path, UPLOAD_LOG_MAX_BYTES, UPLOAD_LOG_MAX_BACKUPS, debugLog); + await fs.promises.appendFile(target.path, line, 'utf-8'); + return true; + } catch (error) { + _invalidateUploadLogTargetCache(); + debugLog(`source-cleanup audit append failed: ${error.message}`); + } + } + return false; +} + function flattenHistoryForExport(history) { const rows = []; const list = Array.isArray(history) ? history : []; @@ -1066,7 +1110,11 @@ function buildUploadTasksFromJobs(config, jobs, pick) { if (!job || !job.file || !job.hoster) continue; const account = pick(job.hoster); if (!account) { debugLog(` skip ${job.hoster}: no enabled account`); continue; } - tasks.push(buildTaskFromAccount(job.hoster, account, { file: job.file, jobId: job.id || job.jobId || null })); + tasks.push(buildTaskFromAccount(job.hoster, account, { + file: job.file, + jobId: job.id || job.jobId || null, + sourceCleanupToken: job.sourceCleanupToken || null + })); } return tasks; } @@ -1325,7 +1373,7 @@ async function runHosterHealthCheck(config, requestedChecks) { return { hoster, accountId, status: 'error', message: 'Account-ID fehlt im Check-Payload' }; } if (!allowed.includes(hoster)) { - return { hoster, accountId, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' }; + return { hoster, accountId, status: 'skipped', message: 'Kein Health-Check für diesen Hoster' }; } const accounts = config.hosters[hoster]; const hosterConfig = Array.isArray(accounts) ? accounts.find(a => a.id === accountId) : null; @@ -1475,7 +1523,7 @@ function updateTrayTooltip(text) { if (tray && !tray.isDestroyed()) tray.setToolTip(text); } -app.whenReady().then(() => { +app.whenReady().then(async () => { if (!_hasSingleInstanceLock) return; try { const _bootCfg = configStore.load(); @@ -1491,6 +1539,24 @@ app.whenReady().then(() => { pid: process.pid }); _sweepOrphanConfigTmps(); + sourceDeleteJournal = new SourceDeleteJournal( + path.join(app.getPath('userData'), 'source-delete-journal.jsonl') + ); + try { + const outcomes = await sourceDeleteJournal.recover(); + for (const outcome of outcomes) { + debugLog(`source-delete recovery: ${outcome.outcome} ${outcome.file}`); + await appendSourceCleanupAudit({ + timestamp: new Date().toISOString(), + outcome: `recovery-${outcome.outcome}`, + file: outcome.file, + hosters: [], + trigger: 'startup-delete-journal' + }); + } + } catch (error) { + debugLog(`source-delete recovery failed: ${error.message}`); + } createWindow(); createTray(); @@ -1790,7 +1856,7 @@ async function _dispatchHealthCheck(hoster, hosterConfig, otp) { if (hoster === 'clouddrop.cc') { return withTimeout(checkClouddropHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Clouddrop-Check'); } - return { status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' }; + return { status: 'skipped', message: 'Kein Health-Check für diesen Hoster' }; } function getUploadBrowseDirectory() { @@ -1933,14 +1999,16 @@ ipcMain.handle('get-file-sizes', async (_event, paths) => { return out; }); -ipcMain.handle('start-upload', (_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) return { error: 'Ein Upload wird bereits ausgeführt oder abgeschlossen' }; const config = configStore.load(); 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 : []; const isAutoRetry = !!(payload && payload.isAutoRetry); + const sourceCleanupGroups = payload && Array.isArray(payload.sourceCleanupGroups) ? payload.sourceCleanupGroups : []; // At 500+ jobs JSON.stringify blew up the debug log with MB-sized lines // per start-upload and added noticeable delay — log counts only. @@ -1995,6 +2063,24 @@ ipcMain.handle('start-upload', (_event, payload) => { uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config)); globalThis._mhuUploadManagerRef = uploadManager; const _thisManager = uploadManager; + 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) { + uploadManager = null; + globalThis._mhuUploadManagerRef = null; + return { error: `Quelldatei-Schutz konnte nicht vorbereitet werden: ${error.message}` }; + } + for (const skipped of skippedJobs) sourceCleanup.markSkipped(skipped.jobId); + _thisManager.sourceFileCleanup = sourceCleanup; const _producerTracker = trackUploadProducer(_thisManager); const _progressByJob = new Map(); @@ -2064,6 +2150,10 @@ ipcMain.handle('start-upload', (_event, payload) => { } catch (e) { debugLog(`stats listener error: ${e && e.message}`); } }); + uploadManager.on('job-settled', (event) => { + sourceCleanup.settle(event); + }); + uploadManager.on('account-failed', ({ hoster, accountId }) => { // Persist to session cache so a subsequent batch (after batch-done) // gets primed and won't burn retries on this account again. @@ -2126,7 +2216,9 @@ ipcMain.handle('start-upload', (_event, payload) => { 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) { @@ -2137,7 +2229,9 @@ ipcMain.handle('start-upload', (_event, payload) => { for (const value of _progressByJob.values()) finalProgressBatch.push(value); _progressByJob.clear(); if (finalProgressBatch.length) safeSend('upload-progress-batch', finalProgressBatch); - safeSend('upload-batch-done', summary); + const queuePersisted = await requestUploadFinalization(summary); + if (!queuePersisted) debugLog('upload finalization blocked: renderer queue acknowledgement missing'); + await sourceCleanup.finishBatch({ historyPersisted, queuePersisted }); _producerTracker.finish(); const fullyAborted = isAllAborted(summary); @@ -2158,9 +2252,9 @@ ipcMain.handle('start-upload', (_event, payload) => { // 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. - process.nextTick(() => { + setImmediate(() => { if (uploadManager !== _thisManager) { - debugLog('nextTick: uploadManager was replaced before startBatch'); + debugLog('setImmediate: uploadManager was replaced before startBatch'); _producerTracker.finish(); return; } @@ -2170,7 +2264,7 @@ ipcMain.handle('start-upload', (_event, payload) => { _producerTracker.finish(); return; } - debugLog(`nextTick: calling startBatch now (priming ${_sessionFailedAccounts.size} failed accounts, ${_sessionAccountOverrides.size} overrides from session)`); + debugLog(`setImmediate: calling startBatch now (priming ${_sessionFailedAccounts.size} failed accounts, ${_sessionAccountOverrides.size} overrides from session)`); _thisManager.startBatch(tasks, { primeFailedAccounts: Array.from(_sessionFailedAccounts.keys()), primeOverrides: Array.from(_sessionAccountOverrides.entries()) @@ -2194,7 +2288,7 @@ ipcMain.handle('start-upload', (_event, payload) => { logMemorySnapshot('batch-start'); debugLog(`start-upload returning started=true (startBatch deferred to nextTick)`); - return { started: true, taskCount: tasks.length, skippedJobs }; + return { started: true, taskCount: tasks.length, skippedJobs, sourceCleanupFingerprints }; }); // Logged at batch boundaries so we can spot memory growth between batches @@ -2222,24 +2316,31 @@ ipcMain.handle('cancel-selected-jobs', (_event, jobIds) => { return true; }); -ipcMain.handle('add-jobs-to-batch', (_event, payload) => { +ipcMain.handle('add-jobs-to-batch', async (_event, payload) => { if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' }; if (!uploadManager || !uploadManager.running) { return { error: 'Kein Upload aktiv' }; } 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' })); + .map(j => ({ jobId: j.id, hoster: j.hoster, reason: 'Kein gültiger Account für diesen Hoster' })); + const sourceCleanupFingerprints = uploadManager.sourceFileCleanup + ? await uploadManager.sourceFileCleanup.registerGroups(sourceCleanupGroups) + : {}; + if (uploadManager.sourceFileCleanup) { + for (const skipped of skippedJobs) uploadManager.sourceFileCleanup.markSkipped(skipped.jobId); + } if (tasks.length === 0) { debugLog(`add-jobs-to-batch: 0 tasks built (${skippedJobs.length} skipped: no account)`); - return { added: 0, skippedJobs, alreadyInBatchJobIds: [] }; + return { added: 0, skippedJobs, alreadyInBatchJobIds: [], sourceCleanupFingerprints }; } const addResult = uploadManager.addJobs(tasks); @@ -2251,7 +2352,7 @@ ipcMain.handle('add-jobs-to-batch', (_event, payload) => { debugLog( `add-jobs-to-batch: ${added} of ${tasks.length} tasks added (${alreadyInBatchJobIds.length} already in batch, ${skippedJobs.length} skipped)` ); - return { added, skippedJobs, alreadyInBatchJobIds }; + return { added, skippedJobs, alreadyInBatchJobIds, sourceCleanupFingerprints }; }); ipcMain.handle('finish-after-active', () => { @@ -2811,6 +2912,21 @@ ipcMain.handle('save-pending-queue', async (_event, pendingQueue) => { return true; }); +ipcMain.handle('complete-upload-finalization', async (_event, 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) => { assertConfigWriteAllowed(); await configStore.saveRendererGlobalSettings(globalSettings); diff --git a/package-lock.json b/package-lock.json index 7708c6a..bb8bef5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-hoster-uploader", - "version": "2.1.6", + "version": "2.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-hoster-uploader", - "version": "2.1.6", + "version": "2.1.7", "dependencies": { "chokidar": "^3.6.0", "undici": "^7.29.0", diff --git a/package.json b/package.json index 8e97b5c..c8ee503 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-hoster-uploader", - "version": "2.1.6", + "version": "2.1.7", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { diff --git a/preload.js b/preload.js index 39c2349..5261de9 100644 --- a/preload.js +++ b/preload.js @@ -48,6 +48,7 @@ contextBridge.exposeInMainWorld('api', { cancelUpload: () => ipcRenderer.invoke('cancel-upload'), cancelSelectedJobs: (jobIds) => ipcRenderer.invoke('cancel-selected-jobs', jobIds), addJobsToBatch: (payload) => ipcRenderer.invoke('add-jobs-to-batch', payload), + completeUploadFinalization: (payload) => ipcRenderer.invoke('complete-upload-finalization', payload), finishAfterActive: () => ipcRenderer.invoke('finish-after-active'), runHealthCheck: (payload) => ipcRenderer.invoke('run-health-check', payload), validateCredentials: (payload) => ipcRenderer.invoke('validate-credentials', payload), diff --git a/renderer/app.js b/renderer/app.js index 66305b1..b7690f0 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -425,8 +425,19 @@ async function init() { for (let i = 0; i < batch.length; i++) handleProgress(batch[i]); }); } - window.api.onUploadBatchDone((data) => { - handleBatchDone(data); + window.api.onUploadBatchDone(async (data) => { + const summary = data && data.summary ? data.summary : data; + 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) => { handleStats(data); @@ -491,11 +502,16 @@ async function init() { // Inject new preview jobs into the running batch const newJobs = queueJobs.filter(j => j.status === 'preview' && newPaths.has(j.file)); if (newJobs.length > 0) { + const cleanupPreparation = prepareSourceCleanup(newJobs); newJobs.forEach(j => { j.status = 'queued'; }); renderQueueTable(); window.api.addJobsToBatch({ - jobs: newJobs.map(j => ({ id: j.id, file: j.file, fileName: j.fileName, hoster: j.hoster })) - }).then(result => { _markSkippedJobs(result); }).catch(() => {}); + 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); } } @@ -574,7 +590,6 @@ function _isHistoryTabActive() { if (nextView) nextView.classList.add('active'); activeTab = tab; syncTabIndicator(tab); - syncUploadSpeedSparklineVisibility(tab.dataset.view); const activeSidebarButton = nextView?.querySelector('.view-sidebar-navigation > .view-sidebar-item.active, .settings-navigation > .settings-nav-button.active'); _syncSidebarIndicator(activeSidebarButton, true); if (tab.dataset.view === 'history' && (_historyDirty || !_historyEverLoaded)) { @@ -1095,11 +1110,16 @@ 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) { + const cleanupPreparation = prepareSourceCleanup(newJobs); newJobs.forEach(j => { j.status = 'queued'; }); renderQueueTable(); window.api.addJobsToBatch({ - jobs: newJobs.map(j => ({ id: j.id, file: j.file, fileName: j.fileName, hoster: j.hoster })) - }).then(result => { _markSkippedJobs(result); }).catch(() => {}); + 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); } } @@ -1167,6 +1187,10 @@ function restoreQueueStateFromConfig() { remaining: 0, error: job.error || null, 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: '', @@ -1239,6 +1263,10 @@ function buildPersistedQueueState() { bytesTotal: job.bytesTotal || 0, error: isTerminal ? (job.error || null) : null, result: isTerminal ? (job.result || null) : null, + sourceCleanupToken: job.sourceCleanupToken || null, + sourceCleanupRequiredHosters: Array.isArray(job.sourceCleanupRequiredHosters) ? [...job.sourceCleanupRequiredHosters] : [], + sourceCleanupCompletedHosters: Array.isArray(job.sourceCleanupCompletedHosters) ? [...job.sourceCleanupCompletedHosters] : [], + sourceCleanupFingerprint: job.sourceCleanupFingerprint || null, maxAttempts: job.maxAttempts || 0 }; }) @@ -2691,6 +2719,24 @@ function getSelectedJobLinks() { } // --- Upload --- +function prepareSourceCleanup(jobs) { + if (!config.globalSettings?.deleteSourceAfterSuccessfulUpload || !window.SourceCleanupPolicy) return { groups: [] }; + return window.SourceCleanupPolicy.prepareGroups(queueJobs, jobs, () => window.crypto.randomUUID(), 'win32'); +} + +function serializeUploadJob(job) { + return { + id: job.id, + file: job.file, + fileName: job.fileName, + hoster: job.hoster, + sourceCleanupToken: job.sourceCleanupToken || null, + sourceCleanupRequiredHosters: job.sourceCleanupRequiredHosters || [], + sourceCleanupCompletedHosters: job.sourceCleanupCompletedHosters || [], + sourceCleanupFingerprint: job.sourceCleanupFingerprint || null + }; +} + async function startUpload(opts) { if (uploading) return; if (!(opts && opts._autoRetry)) _cancelAutoRetry(true); @@ -2714,6 +2760,7 @@ async function startUpload(opts) { if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; } try { + const cleanupPreparation = prepareSourceCleanup(jobsToStart); jobsToStart.forEach(j => { j.status = 'queued'; j.error = null; @@ -2732,14 +2779,13 @@ async function startUpload(opts) { const uploadPayload = { hosters, isAutoRetry: !!(opts && opts._autoRetry), - jobs: jobsToStart.map((job) => ({ - id: job.id, - file: job.file, - fileName: job.fileName, - hoster: job.hoster - })) + 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); persistQueueStateSoon(); @@ -2779,6 +2825,7 @@ async function startSelectedUpload(explicitJobs) { return; } { + const cleanupPreparation = prepareSourceCleanup(addable); addable.forEach(j => { j.status = 'queued'; j.error = null; j.result = null; j.bytesUploaded = 0; j.speedKbs = 0; j.progress = 0; j.uploadId = null; @@ -2787,10 +2834,11 @@ async function startSelectedUpload(explicitJobs) { let result = null; try { result = await window.api.addJobsToBatch({ - jobs: addable.map(j => ({ id: j.id, file: j.file, fileName: j.fileName, hoster: j.hoster })) + jobs: addable.map(serializeUploadJob), + sourceCleanupGroups: cleanupPreparation.groups }); } catch (err) { - showCopyToast(`Jobs konnten nicht hinzugefuegt werden: ${err.message}`); + showCopyToast(`Jobs konnten nicht hinzugefügt werden: ${err.message}`); return; } @@ -2803,6 +2851,9 @@ async function startSelectedUpload(explicitJobs) { return; } _markSkippedJobs(result); + if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) { + window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); + } persistQueueStateSoon(); const added = Number(result && result.added) || 0; // Use ASCII-only toast text here to avoid encoding artifacts on some systems. @@ -2815,7 +2866,7 @@ async function startSelectedUpload(explicitJobs) { if (alreadyInBatch > 0) toastParts.push(`${alreadyInBatch} bereits im Batch`); if (skipped > 0) toastParts.push(`${skipped} ohne gueltigen Account`); if (result && result.error) { - showCopyToast(`Jobs konnten nicht hinzugefuegt werden: ${result.error}`); + showCopyToast(`Jobs konnten nicht hinzugefügt werden: ${result.error}`); } else if (toastParts.length > 0) { showCopyToast(`Jobs: ${toastParts.join(', ')}`); } else { @@ -2832,6 +2883,7 @@ async function startSelectedUpload(explicitJobs) { if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; } try { + const cleanupPreparation = prepareSourceCleanup(jobsToStart); jobsToStart.forEach(j => { j.status = 'queued'; j.error = null; @@ -2847,14 +2899,13 @@ async function startSelectedUpload(explicitJobs) { const uploadPayload = { hosters, - jobs: jobsToStart.map((job) => ({ - id: job.id, - file: job.file, - fileName: job.fileName, - hoster: job.hoster - })) + 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); persistQueueStateSoon(); @@ -2972,6 +3023,7 @@ function _handleProgressImpl(data) { // Track completed uploads so they don't get re-queued after removal if (job.status === 'done') { + if (window.SourceCleanupPolicy) window.SourceCleanupPolicy.markCompleted(queueJobs, job, 'win32'); _completedUploadKeys.add(`${job.file}|${job.hoster}`); } @@ -3796,13 +3848,6 @@ function updateUploadSpeedDisplays() { _setUploadTelemetryText('uploadSpeedValue', text); } -function syncUploadSpeedSparklineVisibility(view) { - const widget = document.getElementById('uploadSpeedSparkline'); - if (!widget) return; - const activeView = view || document.querySelector('.tab.active')?.dataset.view; - widget.classList.toggle('is-hidden', activeView !== 'upload'); -} - function drawUploadSpeedSparkline() { const canvas = document.getElementById('uploadSpeedCanvas'); if (!canvas) return; @@ -3860,7 +3905,6 @@ function updateUploadSpeedSparkline() { function initUploadSpeedSparkline() { if (uploadSpeedTimer !== null) return; - syncUploadSpeedSparklineVisibility(); updateUploadSpeedSparkline(); uploadSpeedTimer = window.setInterval(updateUploadSpeedSparkline, 250); window.addEventListener('resize', drawUploadSpeedSparkline); @@ -4186,6 +4230,14 @@ function renderSettings() { +
Quelldateien
+
+
+ + Löscht die Originaldatei endgültig, sobald alle dafür ausgewählten Hoster erfolgreich abgeschlossen sind. Der Papierkorb wird nicht verwendet. +
+ +
Einstellungen einzelner Hoster wie Wiederholungen, Geschwindigkeit, Parallelität, Dateigröße und Logging findest du im Accounts-Tab direkt beim jeweiligen Hoster.
`; @@ -4715,11 +4767,20 @@ function renderSettings() { _syncHeaderUpdateState(); container.querySelectorAll('.settings-autosave').forEach((input) => { const eventName = input.type === 'checkbox' || input.tagName === 'SELECT' ? 'change' : 'input'; - input.addEventListener(eventName, () => { + input.addEventListener(eventName, async () => { if (input.id === 'languageInput') { setUiLanguage(input.value); syncLanguagePicker(input.value); } + if (input.id === 'deleteSourceAfterSuccessfulUploadInput' && input.checked) { + const confirmed = await showAppConfirm({ + title: 'Quelldateien dauerhaft löschen?', + message: 'Nach einem vollständigen Upload zu allen ausgewählten Hostern wird die Originaldatei ohne Papierkorb endgültig von diesem PC gelöscht.', + confirmText: 'Dauerhaftes Löschen aktivieren', + danger: true + }); + if (!confirmed) input.checked = false; + } markSettingsDirty(); }); }); @@ -4847,6 +4908,7 @@ async function performSaveSettings(options = {}) { parallelUploadCount: elInt('parallelUploadCountInput', cur.parallelUploadCount ?? 0, 0, 0, 100), scaleParallelUploads: elChk('scaleParallelUploadsInput', !!cur.scaleParallelUploads), removeFromQueueOnDone: elChk('removeFromQueueOnDoneInput', !!cur.removeFromQueueOnDone), + deleteSourceAfterSuccessfulUpload: elChk('deleteSourceAfterSuccessfulUploadInput', !!cur.deleteSourceAfterSuccessfulUpload), showDropTarget: elChk('showDropTargetInput', !!cur.showDropTarget), globalMaxSpeedKbs: (() => { const el = document.getElementById('globalMaxSpeedMbsInput'); diff --git a/renderer/i18n.js b/renderer/i18n.js index 7ba7a9c..4deaf7a 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -189,6 +189,12 @@ ['Erfolgreich hochgeladene Dateien verschwinden automatisch aus der Upload-Liste.', 'Successfully uploaded files are automatically removed from the upload list.'], ['Warteschlange beim Start wiederherstellen', 'Restore queue on startup'], ['Noch nicht abgeschlossene Uploads werden beim nächsten Programmstart erneut angezeigt.', 'Incomplete uploads are shown again the next time the application starts.'], + ['Quelldateien', 'Source files'], + ['Quelldatei nach vollständigem Upload dauerhaft löschen', 'Permanently delete source file after complete upload'], + ['Löscht die Originaldatei endgültig, sobald alle dafür ausgewählten Hoster erfolgreich abgeschlossen sind. Der Papierkorb wird nicht verwendet.', 'Permanently deletes the original file after all selected hosts have completed successfully. The Recycle Bin is not used.'], + ['Quelldateien dauerhaft löschen?', 'Permanently delete source files?'], + ['Nach einem vollständigen Upload zu allen ausgewählten Hostern wird die Originaldatei ohne Papierkorb endgültig von diesem PC gelöscht.', 'After a complete upload to all selected hosts, the original file is permanently deleted from this PC without using the Recycle Bin.'], + ['Dauerhaftes Löschen aktivieren', 'Enable permanent deletion'], ['Hoster-Einstellungen', 'Host settings'], ['Upload-Einstellungen', 'Upload settings'], ['Erfolgreiche Links in fileuploader.log.', 'Successful links in fileuploader.log.'], @@ -412,6 +418,109 @@ ['Prüfen…', 'Checking…'], ['Prüfung fehlgeschlagen', 'Check failed'], ['Prüfung oder Speichern fehlgeschlagen', 'Check or save failed'], + ['Einstellungen konnten vor dem Update nicht gespeichert werden', 'Settings could not be saved before the update'], + ['Das Update wurde nicht gestartet, weil die Einstellungen vor dem Beenden nicht gespeichert werden konnten', 'The update was not started because the settings could not be saved before quitting'], + ['Login ok, Upload-Seite bereit', 'Login successful, upload page ready'], + ['Login oder API Key fehlt', 'Login or API key is missing'], + ['Account-Check lieferte kein gültiges JSON', 'Account check did not return valid JSON'], + ['Account-Check fehlgeschlagen', 'Account check failed'], + ['Upload-Server-Check lieferte kein gültiges JSON', 'Upload server check did not return valid JSON'], + ['API Key gültig, Upload-Server verfügbar', 'API key is valid, upload server available'], + ['API Key gültig, aktuell kein Server von API (Uploader nutzt Fallback)', 'API key is valid, but the API currently provides no server (the uploader uses a fallback)'], + ['API Key gültig, Upload-Server aktuell nicht geliefert', 'API key is valid, but the API currently provides no upload server'], + ['Username oder Passwort fehlt', 'Username or password is missing'], + ['Upload-URL wurde nicht erkannt', 'Upload URL was not recognized'], + ['API Key gültig, aktuell kein Server verfügbar', 'API key is valid, but no server is currently available'], + ['API Key ungültig oder Server nicht erreichbar', 'API key is invalid or the server is unreachable'], + ['Login ok, aber Upload-Seite liefert kein CSRF-Token', 'Login successful, but the upload page did not provide a CSRF token'], + ['API Key fehlt', 'API key is missing'], + ['API lieferte kein gültiges JSON', 'API did not return valid JSON'], + ['API Key gültig', 'API key is valid'], + ['Clouddrop Auth fehlgeschlagen', 'Clouddrop authentication failed'], + ['Account-ID fehlt im Check-Payload', 'Account ID is missing from the check payload'], + ['Kein Health-Check für diesen Hoster', 'No health check is available for this host'], + ['Health-Check fehlgeschlagen', 'Health check failed'], + ['Hoster fehlt', 'Host is missing'], + ['Validierung fehlgeschlagen', 'Validation failed'], + ['Ein Upload wird bereits ausgeführt oder abgeschlossen', 'An upload is already running or completed'], + ['Kein gültiger Account für diesen Hoster', 'No valid account is available for this host'], + ['Keine gültigen Zugangsdaten für die gewählten Hoster.', 'No valid credentials are available for the selected hosts.'], + ['Unbekannter Fehler', 'Unknown error'], + ['Kein Log-Pfad gefunden', 'No log path was found'], + ['Ungültige URL (muss mit http(s):// beginnen)', 'Invalid URL (must start with http(s)://)'], + ['Backup-Datei ist zu groß oder ungültig', 'The backup file is too large or invalid'], + ['Ungültiges Backup-Format', 'Invalid backup format'], + ['Keine gültige .mhu Backup-Datei', 'Not a valid .mhu backup file'], + ['Falsches Passwort oder beschädigte Datei', 'Incorrect password or damaged file'], + ['Dieses Backup wurde mit einem Passwort verschlüsselt', 'This backup was encrypted with a password'], + ['Die Verlaufsdatei ist beschädigt und wurde nicht verändert', 'The history file is damaged and was not changed'], + ['Verlauf und Aufbewahrung konnten nicht konsistent gespeichert werden', 'History and retention could not be saved consistently'], + ['Kein Ordnerpfad angegeben', 'No folder path was provided'], + ['Clouddrop: Datei ist leer', 'Clouddrop: The file is empty'], + ['Clouddrop: Keine fileId in Upload-Antwort', 'Clouddrop: No fileId in the upload response'], + ['Clouddrop: Keine sessionId von /upload/init', 'Clouddrop: No sessionId from /upload/init'], + ['Doodstream: sess_id nicht gefunden nach Login', 'Doodstream: sess_id was not found after login'], + ['Kein Upload-Server erhalten. API-Key prüfen.', 'No upload server was returned. Check the API key.'], + ['Online-Sicherung enthält keine gültigen Einstellungen', 'The online backup does not contain valid settings'], + ['Online-Sicherungen benötigen eine sichere HTTPS-Verbindung', 'Online backups require a secure HTTPS connection'], + ['Online-Sicherungsdienst antwortet nicht', 'The online backup service is not responding'], + ['Online-Sicherungsdienst ist nicht erreichbar', 'The online backup service is unavailable'], + ['Antwort des Online-Sicherungsdienstes ist zu groß', 'The response from the online backup service is too large'], + ['Online-Sicherungsschlüssel ist beschädigt', 'The online backup key is damaged'], + ['Einstellungen sind für eine Online-Sicherung zu groß', 'The settings are too large for an online backup'], + ['Online-Sicherung ist beschädigt', 'The online backup is damaged'], + ['Online-Sicherung konnte nicht entschlüsselt werden oder ist beschädigt', 'The online backup could not be decrypted or is damaged'], + ['Online-Sicherung konnte nicht gespeichert werden', 'Online backup could not be saved'], + ['Online-Sicherung wurde nicht gefunden', 'Online backup was not found'], + ['Online-Sicherung konnte nicht geladen werden', 'Online backup could not be loaded'], + ['Online-Sicherungsdienst hat ungültige Daten geliefert', 'The online backup service returned invalid data'], + ['Online-Sicherung konnte nicht gelöscht werden', 'Online backup could not be deleted'], + ['Backup hat eine ungültige Struktur', 'The backup has an invalid structure'], + ['Einstellungen werden bereits importiert', 'Settings are already being imported'], + ['Während laufender Uploads können keine Einstellungen importiert werden', 'Settings cannot be imported while uploads are running'], + ['Online-Sicherungsschlüssel ist ungültig', 'The online backup key is invalid'], + ['Ein Update wird bereits vorbereitet', 'An update is already being prepared'], + ['Die Anwendung ist noch nicht bereit, das Update sicher zu installieren', 'The application is not yet ready to install the update safely'], + ['Update abgebrochen', 'Update canceled'], + ['Update-Asset unvollständig (URL oder Name fehlt)', 'The update asset is incomplete (URL or name is missing)'], + ['Heruntergeladene Datei ist keine gültige EXE', 'The downloaded file is not a valid EXE'], + ['SHA-512 Prüfung fehlgeschlagen', 'SHA-512 verification failed'], + ['Vorbereitetes Update ist unvollständig', 'The prepared update is incomplete'], + ['Kein Setup-Asset im Release gefunden', 'No setup asset was found in the release'], + ['Download hängt — seit 45 s keine Daten (Netzwerk/Server überlastet). Bitte laufende Uploads stoppen und erneut versuchen.', 'The download stalled because no data was received for 45 seconds. Stop active uploads and try again.'], + ['Datei nicht gefunden', 'File not found'], + ['Netzwerkfehler', 'Network error'], + ['Bekanntes Größen-Limit auf diesem Account (frühere verdächtige Ablehnung)', 'Known size limit on this account (previous suspicious rejection)'], + ['Ablehnung verdächtig - Versuch auf anderem Account', 'Suspicious rejection - trying another account'], + ['Vidmoly: /api/upload/config lieferte kein JSON — evtl. nicht eingeloggt?', 'Vidmoly: /api/upload/config did not return JSON — you may not be signed in'], + ['Vidmoly: /api/upload/config unvollständig (sess_id/upload_url fehlt)', 'Vidmoly: /api/upload/config is incomplete (sess_id/upload_url is missing)'], + ['Vidmoly VM API lieferte kein JSON', 'The Vidmoly VM API did not return JSON'], + ['VOE Login: CSRF-Token nicht gefunden', 'VOE login: CSRF token not found'], + ['VOE Upload: CSRF-Token nicht gefunden. Bist du eingeloggt?', 'VOE upload: CSRF token not found. Are you signed in?'], + ['VOE: Kein Upload-Server erhalten von delivery-node', 'VOE: No upload server was returned by delivery-node'], + ['VOE Upload: Kein file_code in der Antwort gefunden', 'VOE upload: No file_code was found in the response'], + ['Nicht gespeicherte laufende Aktionen werden beendet.', 'Unsaved running actions will be stopped.'], + ['Ausgewählte Einträge entfernen?', 'Remove selected entries?'], + ['Export fehlgeschlagen', 'Export failed'], + ['Backup exportiert', 'Backup exported'], + ['Online-Backup importiert', 'Online backup imported'], + ['Passwort', 'Password'], + ['Backup importiert', 'Backup imported'], + ['Import fehlgeschlagen', 'Import failed'], + ['Upload-Start fehlgeschlagen', 'Failed to start upload'], + ['erneut versuchbar', 'retryable'], + ['manuell', 'manual'], + ['Abgebrochen.', 'Canceled.'], + ['API-Token neu erzeugen?', 'Generate a new API token?'], + ['Der bisherige Token wird sofort ungültig. Verbundene Clients müssen den neuen Token verwenden.', 'The current token will become invalid immediately. Connected clients must use the new token.'], + ['Neu erzeugen', 'Generate new'], + ['Verbindungs-Code neu erzeugen?', 'Generate a new connection code?'], + ['Der bisherige Diagnose-Code wird sofort ungültig.', 'The current diagnostics code will become invalid immediately.'], + ['Aktivieren', 'Enable'], + ['Bitte den OTP-Code eingeben.', 'Enter the OTP code.'], + ['Login fehlgeschlagen', 'Login failed'], + ['Aufbewahrung ändern?', 'Change retention?'], + ['Update fehlgeschlagen', 'Update failed'], ['Speichere aktuelle Einstellungen…', 'Saving current settings…'], ['Speichern vor dem Beenden hat zu lange gedauert', 'Saving before quitting took too long'], ['Stoppt nach aktiven Uploads...', 'Stopping after active uploads...'], @@ -482,6 +591,34 @@ const patterns = target === 'en' ? [ [/^Update v(.+) verfügbar$/, 'Update v$1 available'], + [/^Login ok, Upload-Form bereit \(Dateifeld: (.+)\)$/, 'Login successful, upload form ready (file field: $1)'], + [/^Klartext-Backup ist kein gültiges JSON: (.+)$/, 'Plain JSON backup is not valid JSON: $1'], + [/^Export fehlgeschlagen: (.+)$/, 'Export failed: $1'], + [/^Import fehlgeschlagen: (.+)$/, 'Import failed: $1'], + [/^Initialisierung fehlgeschlagen: (.+)$/, 'Initialization failed: $1'], + [/^Quelldatei-Schutz konnte nicht vorbereitet werden: (.+)$/, 'Source file protection could not be prepared: $1'], + [/^Clouddrop: API-Antwort war kein JSON (.+)$/, 'Clouddrop: API response was not JSON $1'], + [/^Clouddrop: Datei nicht lesbar: (.+)$/, 'Clouddrop: File cannot be read: $1'], + [/^Doodstream: konnte Upload-Server nicht ermitteln \(Endpoint geändert\?\)\. (.+)$/, 'Doodstream: Could not determine the upload server (endpoint changed?). $1'], + [/^Doodstream Upload fehlgeschlagen: (.+)$/, 'Doodstream upload failed: $1'], + [/^Doodstream Upload: kein Filecode — Server gab leeren Link zurück (.+)$/, 'Doodstream upload: No file code — the server returned an empty link $1'], + [/^Doodstream lehnt Datei ab \(Server-Status: (.+)\)\. CDN=(.+)$/, 'Doodstream rejected the file (server status: $1). CDN=$2'], + [/^Doodstream Upload: Redirect-Antwort ungültig \((.+)\)$/, 'Doodstream upload: Invalid redirect response ($1)'], + [/^Doodstream Upload: Keine gültige Antwort \(Body: (.+)\)$/, 'Doodstream upload: No valid response (body: $1)'], + [/^Upload zu (.+) wurde vom Server abgelehnt\.$/, 'The upload to $1 was rejected by the server.'], + [/^Upload zu (.+) lieferte keine file_code-Antwort \(Payload: (.+)\)$/, 'The upload to $1 returned no file_code response (payload: $2)'], + [/^Byse lehnte Datei ab: (.+)$/, 'Byse rejected the file: $1'], + [/^API-Antwort war kein JSON (.+)$/, 'The API response was not JSON $1'], + [/^Kein Upload-Server erhalten: (.+)$/, 'No upload server was returned: $1'], + [/^Upload-Antwort von (.+) war kein JSON (.+)$/, 'The upload response from $1 was not JSON $2'], + [/^Vidmoly Login fehlgeschlagen: (.+)$/, 'Vidmoly login failed: $1'], + [/^Vidmoly Upload abgelehnt: (.+)$/, 'Vidmoly upload rejected: $1'], + [/^Vidmoly Upload-Ergebnis: (.+)$/, 'Vidmoly upload result: $1'], + [/^VOE Login fehlgeschlagen: (.+)$/, 'VOE login failed: $1'], + [/^VOE Upload-Fehler: (.+)$/, 'VOE upload error: $1'], + [/^VOE: Upload-Server Antwort war kein JSON: (.+)$/, 'VOE: The upload server response was not JSON: $1'], + [/^Download fehlgeschlagen: (.+)$/, 'Download failed: $1'], + [/^Update-Server Antwort war kein JSON (.+)$/, 'Update server response was not JSON $1'], [/^(\d+) Links kopiert$/, '$1 links copied'], [/^(\d+) Link kopiert$/, '$1 link copied'], [/^Wirklich alle (\d+) Links aus diesem Panel entfernen\?$/, 'Remove all $1 links from this panel?'], @@ -536,7 +673,7 @@ [/^Fertig:$/, 'Completed:'], [/^Gespeichert: (.+) \((.+) KB\)$/, 'Saved: $1 ($2 KB)'], [/^Import übernommen\. Warteschlange konnte nicht vollständig gespeichert werden: (.+)$/, 'Import applied. The queue could not be saved completely: $1'], - [/^Jobs konnten nicht hinzugefuegt werden: (.+)$/, 'Jobs could not be added: $1'], + [/^Jobs konnten nicht hinzugefügt werden: (.+)$/, 'Jobs could not be added: $1'], [/^Links kopieren \((\d+)\)$/, 'Copy links ($1)'], [/^Log-Pfad nicht beschreibbar — schreibe nach: (.+)$/, 'Log path is not writable — writing to: $1'], [/^Login: (.+)$/, 'Login: $1'], diff --git a/renderer/index.html b/renderer/index.html index 72c4dc6..edb28b0 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -653,6 +653,7 @@ + diff --git a/renderer/styles.css b/renderer/styles.css index b854878..da72fb3 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -1887,12 +1887,6 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } transition: opacity .16s ease; } -.upload-speed-sparkline.is-hidden { - visibility: hidden; - opacity: 0; - pointer-events: none; -} - .upload-speed-sparkline canvas { width: 100%; height: 22px; @@ -3272,6 +3266,15 @@ input[type="checkbox"] { min-height: 58px; } +.settings-option.source-delete-option { + border-color: color-mix(in srgb, var(--danger) 72%, var(--border)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--danger) 10%, transparent); +} + +.settings-option.source-delete-option:focus-within { + border-color: var(--danger); +} + .settings-option-description, .hint { color: var(--text-dim); @@ -3292,6 +3295,165 @@ input[type="checkbox"] { grid-template-columns: minmax(140px, auto) minmax(180px, 1fr) auto; } +#settings-view { + user-select: none; +} + +#settings-view :is(input, textarea) { + user-select: text; +} + +.settings-header h2 { + font-size: 18px; +} + +.settings-header .settings-hint { + font-size: 13px; +} + +.settings-search-wrap > label, +.settings-section-label { + font-size: 12px; +} + +.settings-search-control, +#settingsSearchInput { + height: 44px; + min-height: 44px; +} + +#settingsSearchInput { + font-size: 13px; +} + +.settings-nav-button { + min-height: 42px; + padding: 9px 11px; + font-size: 14px; +} + +.settings-sidebar-status .save-feedback { + font-size: 13px; +} + +.settings-content { + padding-bottom: 42px; +} + +.settings-subpage { + max-width: 820px; +} + +.settings-page-header { + margin-bottom: 26px; +} + +.settings-page-header h3 { + font-size: 22px; +} + +.settings-page-header p { + font-size: 14px; + line-height: 1.55; +} + +.settings-section-label { + margin-top: 28px; + padding-bottom: 10px; +} + +.settings-subpage .settings-row { + min-height: 64px; + gap: 10px; + padding: 12px 14px; +} + +.settings-subpage .settings-row > label { + min-width: 210px; + font-size: 14px; +} + +.settings-subpage .settings-row > .hint { + padding-left: 220px; +} + +.settings-subpage .settings-row-wide > .hint { + margin-left: 0; +} + +.settings-subpage :is(.key-input, .hs-input:not([type="checkbox"])) { + padding: 8px 11px; + font-size: 14px; +} + +.settings-subpage .automation-retry-row { + grid-template-columns: 210px minmax(0, 1fr); +} + +.settings-subpage .automation-retry-row > label { + padding-top: 10px; +} + +.settings-option { + min-height: 70px; + padding: 14px; +} + +.settings-option-copy { + gap: 6px; +} + +.settings-option-copy label, +.program-update-title, +.online-backup-key-row label { + font-size: 14px; +} + +.settings-option-description, +.program-update-description, +.hint { + font-size: 12px; + line-height: 1.5; +} + +.settings-option > input[type="checkbox"], +.settings-subpage .settings-grid-mini .checkbox-row input[type="checkbox"] { + width: 20px; + height: 20px; +} + +.settings-subpage .settings-grid-mini .settings-row.checkbox-row { + min-height: 62px; +} + +.program-update-row { + min-height: 90px !important; +} + +.language-option { + min-height: 44px; + font-size: 14px; +} + +.settings-hoster-pointer, +.online-backup-status, +.settings-empty { + font-size: 13px; +} + +.online-backup-panel h3 { + font-size: 18px; +} + +.online-backup-panel p { + font-size: 14px; +} + +#settings-view .btn { + min-height: 40px; + font-size: 14px; +} + .accounts-main, .history-main { padding: 0; @@ -3708,7 +3870,7 @@ input[type="checkbox"] { .settings-nav-button { padding: 7px 8px; - font-size: 11px; + font-size: 13px; } .settings-content { diff --git a/scripts/verify-public-release.mjs b/scripts/verify-public-release.mjs index 3d6f18b..5759450 100644 --- a/scripts/verify-public-release.mjs +++ b/scripts/verify-public-release.mjs @@ -41,6 +41,9 @@ const sourceFiles = [ 'lib/serialized-runner.js', 'lib/settings-backup.js', 'lib/settings-import-gate.js', + 'lib/source-cleanup-policy.js', + 'lib/source-delete-journal.js', + 'lib/source-file-cleanup.js', 'lib/speed-history.js', 'lib/startup-renderer.js', 'lib/stats.js', @@ -112,6 +115,9 @@ const sourceFiles = [ 'tests/settings-backup.test.js', 'tests/settings-import-gate.test.js', 'tests/speed-history.test.js', + 'tests/source-cleanup-policy.test.js', + 'tests/source-delete-journal.test.js', + 'tests/source-file-cleanup.test.js', 'tests/startup-renderer.test.js', 'tests/stats.test.js', 'tests/support-bundle.test.js', diff --git a/tests/config-store.test.js b/tests/config-store.test.js index 021c5ce..14664bd 100644 --- a/tests/config-store.test.js +++ b/tests/config-store.test.js @@ -83,6 +83,11 @@ describe('ConfigStore', () => { assert.deepEqual(config.history, []); }); + it('keeps permanent source deletion disabled by default', () => { + const config = store.load(); + assert.equal(config.globalSettings.deleteSourceAfterSuccessfulUpload, false); + }); + it('save then load round-trips', async () => { await store.save({ hosters: { 'doodstream.com': [{ id: 'test-1', enabled: true, authType: 'api', apiKey: 'test-key-123' }] } }); const config = store.load(); @@ -664,6 +669,14 @@ describe('ConfigStore history split (electron-history.json)', () => { assert.equal(JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings.historyRetention, 'all'); }); + it('appendHistory rejects when the migrated history file is corrupted', async () => { + writeConfigWithHistory(12); + s._migrateHistory(); + fs.writeFileSync(s.historyPath, '{broken-history', 'utf-8'); + + await assert.rejects(s.appendHistory({ id: 'unsafe-delete-barrier', files: [] }), /Verlaufsdatei ist beschädigt/); + }); + it('pruneHistory trims history.json and persists the retention setting', async () => { writeConfigWithHistory(12); s._migrateHistory(); diff --git a/tests/i18n.test.js b/tests/i18n.test.js index ac49a7a..2f7b186 100644 --- a/tests/i18n.test.js +++ b/tests/i18n.test.js @@ -1,8 +1,16 @@ const test = require('node:test'); const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const espree = require('espree'); const { normalizeLanguage, translateText } = require('../renderer/i18n'); +test('translates permanent source deletion controls to English', () => { + assert.equal(translateText('Quelldatei nach vollständigem Upload dauerhaft löschen', 'en'), 'Permanently delete source file after complete upload'); + assert.equal(translateText('Dauerhaftes Löschen aktivieren', 'en'), 'Enable permanent deletion'); +}); + test('English is the fallback language and German remains selectable', () => { assert.equal(normalizeLanguage(), 'en'); assert.equal(normalizeLanguage('fr'), 'en'); @@ -61,3 +69,169 @@ test('English copy uses complete actions and correct singular plurals', () => { assert.equal(translateText('Aktiv auf Port 9100 — 1 Client verbunden', 'en'), 'Active on port 9100 — 1 client connected'); assert.equal(translateText('Aktiv auf Port 9100 — 2 Clients verbunden', 'en'), 'Active on port 9100 — 2 clients connected'); }); + +test('rare account, backup, update, and confirmation states translate in both directions', () => { + const cases = [ + ['Einstellungen konnten vor dem Update nicht gespeichert werden', 'Settings could not be saved before the update'], + ['Das Update wurde nicht gestartet, weil die Einstellungen vor dem Beenden nicht gespeichert werden konnten', 'The update was not started because the settings could not be saved before quitting'], + ['Login ok, Upload-Seite bereit', 'Login successful, upload page ready'], + ['Login oder API Key fehlt', 'Login or API key is missing'], + ['Account-Check lieferte kein gültiges JSON', 'Account check did not return valid JSON'], + ['Account-Check fehlgeschlagen', 'Account check failed'], + ['Upload-Server-Check lieferte kein gültiges JSON', 'Upload server check did not return valid JSON'], + ['API Key gültig, Upload-Server verfügbar', 'API key is valid, upload server available'], + ['API Key gültig, aktuell kein Server von API (Uploader nutzt Fallback)', 'API key is valid, but the API currently provides no server (the uploader uses a fallback)'], + ['API Key gültig, Upload-Server aktuell nicht geliefert', 'API key is valid, but the API currently provides no upload server'], + ['Username oder Passwort fehlt', 'Username or password is missing'], + ['Upload-URL wurde nicht erkannt', 'Upload URL was not recognized'], + ['API Key gültig, aktuell kein Server verfügbar', 'API key is valid, but no server is currently available'], + ['API Key ungültig oder Server nicht erreichbar', 'API key is invalid or the server is unreachable'], + ['Login ok, aber Upload-Seite liefert kein CSRF-Token', 'Login successful, but the upload page did not provide a CSRF token'], + ['API Key fehlt', 'API key is missing'], + ['API lieferte kein gültiges JSON', 'API did not return valid JSON'], + ['API Key gültig', 'API key is valid'], + ['Clouddrop Auth fehlgeschlagen', 'Clouddrop authentication failed'], + ['Account-ID fehlt im Check-Payload', 'Account ID is missing from the check payload'], + ['Kein Health-Check für diesen Hoster', 'No health check is available for this host'], + ['Health-Check fehlgeschlagen', 'Health check failed'], + ['Hoster fehlt', 'Host is missing'], + ['Validierung fehlgeschlagen', 'Validation failed'], + ['Ein Upload wird bereits ausgeführt oder abgeschlossen', 'An upload is already running or completed'], + ['Kein gültiger Account für diesen Hoster', 'No valid account is available for this host'], + ['Keine gültigen Zugangsdaten für die gewählten Hoster.', 'No valid credentials are available for the selected hosts.'], + ['Unbekannter Fehler', 'Unknown error'], + ['Kein Log-Pfad gefunden', 'No log path was found'], + ['Ungültige URL (muss mit http(s):// beginnen)', 'Invalid URL (must start with http(s)://)'], + ['Backup-Datei ist zu groß oder ungültig', 'The backup file is too large or invalid'], + ['Online-Sicherungsschlüssel ist ungültig', 'The online backup key is invalid'], + ['Ein Update wird bereits vorbereitet', 'An update is already being prepared'], + ['Die Anwendung ist noch nicht bereit, das Update sicher zu installieren', 'The application is not yet ready to install the update safely'], + ['Update abgebrochen', 'Update canceled'], + ['Ausgewählte Einträge entfernen?', 'Remove selected entries?'], + ['Export fehlgeschlagen', 'Export failed'], + ['Backup exportiert', 'Backup exported'], + ['Online-Backup importiert', 'Online backup imported'], + ['Passwort', 'Password'], + ['Backup importiert', 'Backup imported'], + ['Import fehlgeschlagen', 'Import failed'], + ['Upload-Start fehlgeschlagen', 'Failed to start upload'], + ['erneut versuchbar', 'retryable'], + ['manuell', 'manual'], + ['Abgebrochen.', 'Canceled.'], + ['API-Token neu erzeugen?', 'Generate a new API token?'], + ['Der bisherige Token wird sofort ungültig. Verbundene Clients müssen den neuen Token verwenden.', 'The current token will become invalid immediately. Connected clients must use the new token.'], + ['Neu erzeugen', 'Generate new'], + ['Verbindungs-Code neu erzeugen?', 'Generate a new connection code?'], + ['Der bisherige Diagnose-Code wird sofort ungültig.', 'The current diagnostics code will become invalid immediately.'], + ['Aktivieren', 'Enable'], + ['Bitte den OTP-Code eingeben.', 'Enter the OTP code.'], + ['Login fehlgeschlagen', 'Login failed'], + ['Aufbewahrung ändern?', 'Change retention?'], + ['Update fehlgeschlagen', 'Update failed'] + ]; + + for (const [german, english] of cases) { + assert.equal(translateText(german, 'en'), english, german); + assert.equal(translateText(english, 'de'), german, english); + } +}); + +test('interpolated rare errors translate without leaking German copy', () => { + const cases = [ + ['Login ok, Upload-Form bereit (Dateifeld: file)', 'Login successful, upload form ready (file field: file)'], + ['Klartext-Backup ist kein gültiges JSON: Unexpected token', 'Plain JSON backup is not valid JSON: Unexpected token'], + ['Export fehlgeschlagen: Zugriff verweigert', 'Export failed: Zugriff verweigert'], + ['Import fehlgeschlagen: Datei beschädigt', 'Import failed: Datei beschädigt'], + ['Initialisierung fehlgeschlagen: Konfiguration fehlt', 'Initialization failed: Konfiguration fehlt'] + ]; + + for (const [german, english] of cases) assert.equal(translateText(german, 'en'), english, german); +}); + +test('main-process user-facing copy contains no mojibake', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8'); + assert.doesNotMatch(source, /Ã|Â|â€/u); +}); + +test('library and updater errors exposed in the UI translate to English', () => { + const cases = [ + ['Ungültiges Backup-Format', 'Invalid backup format'], + ['Keine gültige .mhu Backup-Datei', 'Not a valid .mhu backup file'], + ['Falsches Passwort oder beschädigte Datei', 'Incorrect password or damaged file'], + ['Online-Sicherungsdienst ist nicht erreichbar', 'The online backup service is unavailable'], + ['Online-Sicherung wurde nicht gefunden', 'Online backup was not found'], + ['Online-Sicherung konnte nicht gelöscht werden', 'Online backup could not be deleted'], + ['Kein Ordnerpfad angegeben', 'No folder path was provided'], + ['Kein Update verfügbar', 'No update available'], + ['Update-Asset unvollständig (URL oder Name fehlt)', 'The update asset is incomplete (URL or name is missing)'], + ['Heruntergeladene Datei ist keine gültige EXE', 'The downloaded file is not a valid EXE'], + ['SHA-512 Prüfung fehlgeschlagen', 'SHA-512 verification failed'], + ['Datei nicht gefunden', 'File not found'], + ['Netzwerkfehler', 'Network error'] + ]; + + for (const [german, english] of cases) { + assert.equal(translateText(german, 'en'), english, german); + assert.equal(translateText(english, 'de'), german, english); + } +}); + +test('interpolated host and updater errors translate their user-facing copy', () => { + const cases = [ + ['Clouddrop: Datei nicht lesbar: video.mkv', 'Clouddrop: File cannot be read: video.mkv'], + ['Doodstream: konnte Upload-Server nicht ermitteln (Endpoint geändert?). Details', 'Doodstream: Could not determine the upload server (endpoint changed?). Details'], + ['Doodstream Upload fehlgeschlagen: rejected', 'Doodstream upload failed: rejected'], + ['Upload zu VOE wurde vom Server abgelehnt.', 'The upload to VOE was rejected by the server.'], + ['Vidmoly Upload abgelehnt: rejected', 'Vidmoly upload rejected: rejected'], + ['VOE Upload-Fehler: rejected', 'VOE upload error: rejected'], + ['Download fehlgeschlagen: HTTP 503', 'Download failed: HTTP 503'] + ]; + + for (const [german, english] of cases) assert.equal(translateText(german, 'en'), english, german); +}); + +test('German runtime error copy in the application source has an English translation', () => { + const files = [ + path.join(__dirname, '..', 'main.js'), + path.join(__dirname, '..', 'renderer', 'app.js'), + ...fs.readdirSync(path.join(__dirname, '..', 'lib')) + .filter(file => file.endsWith('.js')) + .map(file => path.join(__dirname, '..', 'lib', file)) + ]; + const germanCopy = /[äöüß]|\b(?:der|die|das|ein|eine|kein|keine|nicht|fehlgeschlagen|ungültig|beschädigt|wurde|werden|konnte|können|verfügbar|gefunden|erhalten|abgelehnt|Antwort|Datei|Einstellungen|Upload-Server|Prüfung|Passwort|Session|Verlauf|Sicherung|Fehler)\b/i; + const candidates = []; + + const addCandidate = (file, node) => { + if (node?.type === 'Literal' && typeof node.value === 'string') candidates.push([file, node.loc.start.line, node.value]); + if (node?.type === 'TemplateLiteral') { + if (node.expressions.some(expression => expression.type === 'ConditionalExpression')) return; + let sample = ''; + node.quasis.forEach((part, index) => { + sample += part.value.cooked; + if (index < node.expressions.length) sample += 'X'; + }); + candidates.push([file, node.loc.start.line, sample]); + } + }; + + const walk = (file, node) => { + if (!node || typeof node !== 'object') return; + if (node.type === 'NewExpression' && ['Error', 'AggregateError'].includes(node.callee?.name)) addCandidate(file, node.arguments?.[0]); + if (node.type === 'Property' && ['error', 'message', 'reason'].includes(node.key?.name || node.key?.value)) addCandidate(file, node.value); + for (const value of Object.values(node)) { + if (Array.isArray(value)) value.forEach(child => walk(file, child)); + else if (value && typeof value === 'object' && value.type) walk(file, value); + } + }; + + for (const file of files) { + const source = fs.readFileSync(file, 'utf8'); + const tree = espree.parse(source, { ecmaVersion: 'latest', sourceType: 'script', loc: true }); + walk(path.relative(path.join(__dirname, '..'), file), tree); + } + + const untranslated = candidates + .filter(([, , value]) => germanCopy.test(value) && translateText(value, 'en') === value) + .map(([file, line, value]) => `${file}:${line} ${value}`); + assert.deepEqual(untranslated, []); +}); diff --git a/tests/source-cleanup-policy.test.js b/tests/source-cleanup-policy.test.js new file mode 100644 index 0000000..8c97a91 --- /dev/null +++ b/tests/source-cleanup-policy.test.js @@ -0,0 +1,205 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const policy = require('../lib/source-cleanup-policy'); + +function queueFixture() { + return [ + { + id: 'job-doodstream', + file: 'C:\\Uploads\\Movie.MKV', + hoster: 'doodstream.com', + status: 'done' + }, + { + id: 'job-voe', + file: 'C:\\Uploads\\Movie.MKV', + hoster: 'voe.sx', + status: 'done' + }, + { + id: 'job-vidmoly', + file: 'C:\\Uploads\\Movie.MKV', + hoster: 'vidmoly.me', + status: 'error' + }, + { + id: 'job-byse', + file: 'C:\\Uploads\\Movie.MKV', + hoster: 'byse.sx', + status: 'preview' + } + ]; +} + +test('prepareGroups creates one Windows manifest with a stable token and immutable hosters', () => { + const queueJobs = queueFixture(); + let tokenCalls = 0; + + const prepared = policy.prepareGroups(queueJobs, [queueJobs[2]], () => { + tokenCalls += 1; + return 'cleanup-1'; + }, 'win32'); + + assert.equal(tokenCalls, 1); + assert.equal(prepared.groups.length, 1); + assert.equal(prepared.touchedJobs.length, 4); + assert.deepEqual(prepared.groups[0], { + token: 'cleanup-1', + file: 'C:\\Uploads\\Movie.MKV', + requiredHosters: ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx'], + completedHosters: ['doodstream.com', 'voe.sx'], + fingerprint: null, + jobs: [ + { jobId: 'job-doodstream', hoster: 'doodstream.com', status: 'done' }, + { jobId: 'job-voe', hoster: 'voe.sx', status: 'done' }, + { jobId: 'job-vidmoly', hoster: 'vidmoly.me', status: 'error' }, + { jobId: 'job-byse', hoster: 'byse.sx', status: 'preview' } + ] + }); + assert.deepEqual(queueJobs.map((job) => job.sourceCleanupToken), [ + 'cleanup-1', + 'cleanup-1', + 'cleanup-1', + 'cleanup-1' + ]); + for (const job of queueJobs) { + assert.deepEqual(job.sourceCleanupRequiredHosters, [ + 'doodstream.com', + 'voe.sx', + 'vidmoly.me', + 'byse.sx' + ]); + } + assert.doesNotThrow(() => JSON.stringify(prepared.groups)); +}); + +test('prepareGroups reuses persisted metadata across a partial retry', () => { + const queueJobs = queueFixture(); + policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32'); + queueJobs[0].sourceCleanupCompletedHosters = ['doodstream.com', 'voe.sx']; + queueJobs[1].sourceCleanupCompletedHosters = ['doodstream.com', 'voe.sx']; + queueJobs[0].status = 'preview'; + queueJobs[1].status = 'preview'; + + const prepared = policy.prepareGroups(queueJobs, [queueJobs[2]], () => { + throw new Error('token must stay stable'); + }, 'win32'); + + assert.equal(prepared.groups[0].token, 'cleanup-1'); + assert.deepEqual(prepared.groups[0].requiredHosters, [ + 'doodstream.com', + 'voe.sx', + 'vidmoly.me', + 'byse.sx' + ]); + assert.deepEqual(prepared.groups[0].completedHosters, ['doodstream.com', 'voe.sx']); + assert.equal(prepared.groups[0].jobs.find((job) => job.hoster === 'vidmoly.me').status, 'error'); +}); + +test('prepareGroups extends an active manifest when a new hoster is added', () => { + const queueJobs = queueFixture().slice(0, 2); + policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32'); + queueJobs.push({ id: 'job-new', file: 'C:\\Uploads\\Movie.MKV', hoster: 'byse.sx', status: 'preview' }); + + const prepared = policy.prepareGroups(queueJobs, [queueJobs[2]], () => 'cleanup-2', 'win32'); + + assert.equal(prepared.groups[0].token, 'cleanup-1'); + assert.deepEqual(prepared.groups[0].requiredHosters, ['doodstream.com', 'voe.sx', 'byse.sx']); +}); + +test('keeps case-distinct Windows paths in separate groups', () => { + const queueJobs = [ + { id: 'upper', file: 'C:\\CaseSensitive\\Movie.mkv', hoster: 'voe.sx', status: 'preview' }, + { id: 'lower', file: 'C:\\CaseSensitive\\movie.mkv', hoster: 'byse.sx', status: 'preview' } + ]; + + const prepared = policy.prepareGroups(queueJobs, queueJobs, (file) => `token-${file}`, 'win32'); + + assert.equal(prepared.groups.length, 2); + assert.deepEqual(prepared.groups.map((group) => group.requiredHosters), [['voe.sx'], ['byse.sx']]); +}); + +test('removing a failed job never relaxes the stored requirements', () => { + const queueJobs = queueFixture(); + policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32'); + const failedJob = queueJobs[2]; + const remainingJobs = queueJobs.filter((job) => job !== failedJob); + + policy.removeRequirement(remainingJobs, failedJob, 'win32'); + const prepared = policy.prepareGroups(remainingJobs, [remainingJobs[2]], () => { + throw new Error('token must stay stable'); + }, 'win32'); + + assert.deepEqual(prepared.groups[0].requiredHosters, [ + 'doodstream.com', + 'voe.sx', + 'vidmoly.me', + 'byse.sx' + ]); + for (const job of remainingJobs) { + assert.deepEqual(job.sourceCleanupRequiredHosters, [ + 'doodstream.com', + 'voe.sx', + 'vidmoly.me', + 'byse.sx' + ]); + } +}); + +test('removeRequirement drops only an explicitly discarded unstarted hoster', () => { + const queueJobs = queueFixture(); + policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32'); + + const touchedJobs = policy.removeRequirement(queueJobs, queueJobs[3], 'win32'); + + assert.equal(touchedJobs.length, 4); + for (const job of queueJobs) { + assert.deepEqual(job.sourceCleanupRequiredHosters, [ + 'doodstream.com', + 'voe.sx', + 'vidmoly.me' + ]); + } +}); + +test('markCompleted preserves successful hosters for later retries', () => { + const queueJobs = queueFixture(); + policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32'); + + policy.markCompleted(queueJobs, queueJobs[2], 'win32'); + policy.markCompleted(queueJobs, queueJobs[2], 'win32'); + + for (const job of queueJobs) { + assert.deepEqual(job.sourceCleanupCompletedHosters, [ + 'doodstream.com', + 'voe.sx', + 'vidmoly.me' + ]); + } +}); + +test('applyFingerprints attaches Main fingerprints by token and includes them in payloads', () => { + const queueJobs = queueFixture(); + policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32'); + const fingerprint = { + type: 'file', + size: 1048576, + mtimeMs: 1786467600000, + birthtimeMs: 1786467500000, + dev: 2114, + ino: 9001 + }; + + const touchedJobs = policy.applyFingerprints(queueJobs, { 'cleanup-1': fingerprint }); + const prepared = policy.prepareGroups(queueJobs, [queueJobs[3]], () => { + throw new Error('token must stay stable'); + }, 'win32'); + + assert.equal(touchedJobs.length, 4); + assert.deepEqual(prepared.groups[0].fingerprint, fingerprint); + for (const job of queueJobs) { + assert.deepEqual(job.sourceCleanupFingerprint, fingerprint); + assert.notEqual(job.sourceCleanupFingerprint, fingerprint); + } +}); diff --git a/tests/source-delete-journal.test.js b/tests/source-delete-journal.test.js new file mode 100644 index 0000000..7d2cb27 --- /dev/null +++ b/tests/source-delete-journal.test.js @@ -0,0 +1,85 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const SourceDeleteJournal = require('../lib/source-delete-journal'); + +test('restores a staged source after an interrupted deletion', async (t) => { + const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mhu-delete-journal-')); + t.after(() => fs.promises.rm(directory, { recursive: true, force: true })); + const file = path.join(directory, 'source.bin'); + const stagedFile = path.join(directory, '.source.bin.mhu-delete-test'); + const journal = new SourceDeleteJournal(path.join(directory, 'journal.jsonl')); + await fs.promises.writeFile(file, 'source'); + await journal.plan({ token: 'cleanup-1', file, stagedFile }); + await fs.promises.rename(file, stagedFile); + + const outcomes = await journal.recover(); + + assert.equal(await fs.promises.readFile(file, 'utf-8'), 'source'); + assert.equal(outcomes[0].outcome, 'restored'); + assert.deepEqual(await journal.recover(), []); +}); + +test('clears a completed deletion record when neither source nor stage remains', async (t) => { + const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mhu-delete-journal-')); + t.after(() => fs.promises.rm(directory, { recursive: true, force: true })); + const journal = new SourceDeleteJournal(path.join(directory, 'journal.jsonl')); + await journal.plan({ token: 'cleanup-2', file: path.join(directory, 'source.bin'), stagedFile: path.join(directory, '.staged') }); + + const outcomes = await journal.recover(); + + assert.equal(outcomes[0].outcome, 'cleared'); + assert.deepEqual(await journal.recover(), []); +}); + +test('continues recovery after one malformed active entry', async (t) => { + const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mhu-delete-journal-')); + t.after(() => fs.promises.rm(directory, { recursive: true, force: true })); + const journal = new SourceDeleteJournal(path.join(directory, 'journal.jsonl')); + const file = path.join(directory, 'source.bin'); + const stagedFile = path.join(directory, '.source.bin.mhu-delete-test'); + await journal.plan({ token: 'broken', file: null, stagedFile: null }); + await fs.promises.writeFile(stagedFile, 'source'); + await journal.plan({ token: 'valid', file, stagedFile }); + + const outcomes = await journal.recover(); + + assert.equal(outcomes.find((entry) => entry.token === 'broken').outcome, 'recovery-failed'); + assert.equal(outcomes.find((entry) => entry.token === 'valid').outcome, 'restored'); + assert.equal(await fs.promises.readFile(file, 'utf-8'), 'source'); +}); + +test('retries transient Windows rename locks during recovery', async (t) => { + const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mhu-delete-journal-')); + t.after(() => fs.promises.rm(directory, { recursive: true, force: true })); + const file = path.join(directory, 'source.bin'); + const stagedFile = path.join(directory, '.source.bin.mhu-delete-test'); + const journalPath = path.join(directory, 'journal.jsonl'); + let renameCalls = 0; + const waits = []; + const injectedFs = { + promises: { + mkdir: (...args) => fs.promises.mkdir(...args), + open: (...args) => fs.promises.open(...args), + readFile: (...args) => fs.promises.readFile(...args), + lstat: (...args) => fs.promises.lstat(...args), + rename: async (...args) => { + renameCalls += 1; + if (renameCalls < 3) throw Object.assign(new Error('locked'), { code: 'EBUSY' }); + return fs.promises.rename(...args); + } + } + }; + const writer = new SourceDeleteJournal(journalPath); + await fs.promises.writeFile(stagedFile, 'source'); + await writer.plan({ token: 'cleanup-retry', file, stagedFile }); + const journal = new SourceDeleteJournal(journalPath, null, { fs: injectedFs, wait: async (delay) => waits.push(delay) }); + + const outcomes = await journal.recover(); + + assert.equal(outcomes[0].outcome, 'restored'); + assert.equal(renameCalls, 3); + assert.deepEqual(waits, [100, 250]); +}); diff --git a/tests/source-file-cleanup.test.js b/tests/source-file-cleanup.test.js new file mode 100644 index 0000000..8d30f59 --- /dev/null +++ b/tests/source-file-cleanup.test.js @@ -0,0 +1,469 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { createSourceFileCleanup } = require('../lib/source-file-cleanup'); + +async function makeSource(t, name = 'source.bin') { + const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mhu-source-cleanup-')); + const file = path.join(directory, name); + await fs.promises.writeFile(file, Buffer.from('original source data')); + t.after(() => fs.promises.rm(directory, { recursive: true, force: true })); + return { directory, file }; +} + +function group(file, overrides = {}) { + return { + token: 'cleanup-1', + file, + requiredHosters: ['voe.sx', 'byse.sx'], + completedHosters: [], + jobs: [ + { jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending' }, + { jobId: 'job-byse', file, hoster: 'byse.sx', status: 'pending' } + ], + ...overrides + }; +} + +function makeCleanup(overrides = {}) { + const audits = []; + const waits = []; + const cleanup = createSourceFileCleanup({ + fs, + path, + platform: process.platform, + isEnabled: () => true, + audit: (event) => audits.push(event), + journal: { plan: async () => {}, clear: async () => {} }, + wait: async (milliseconds) => waits.push(milliseconds), + ...overrides + }); + return { cleanup, audits, waits }; +} + +async function settleDone(cleanup, manifest) { + for (const job of manifest.jobs) { + await cleanup.settle({ + token: manifest.token, + jobId: job.jobId, + file: manifest.file, + hoster: job.hoster, + status: 'done' + }); + } +} + +async function exists(file) { + try { + await fs.promises.lstat(file); + return true; + } catch (error) { + if (error.code === 'ENOENT') return false; + throw error; + } +} + +test('fingerprints a regular file and keeps the registered manifest immutable', async (t) => { + const { file } = await makeSource(t); + const { cleanup, audits } = makeCleanup(); + const manifest = group(file); + + const fingerprints = await cleanup.registerGroups([manifest]); + + assert.equal(fingerprints['cleanup-1'].type, 'file'); + assert.equal(fingerprints['cleanup-1'].size, Buffer.byteLength('original source data')); + assert.equal(typeof fingerprints['cleanup-1'].mtimeMs, 'number'); + assert.equal(typeof fingerprints['cleanup-1'].birthtimeMs, 'number'); + assert.equal(typeof fingerprints['cleanup-1'].dev, 'number'); + assert.equal(typeof fingerprints['cleanup-1'].ino, 'number'); + + manifest.requiredHosters.splice(1, 1); + manifest.completedHosters.push('byse.sx'); + manifest.jobs[1].hoster = 'voe.sx'; + await cleanup.settle({ + token: 'cleanup-1', + jobId: 'job-voe', + file, + hoster: 'voe.sx', + status: 'done' + }); + await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }); + + assert.equal(await exists(file), true); + assert.equal(audits.length, 1); + assert.equal(audits[0].outcome, 'blocked'); + assert.deepEqual(audits[0].blockingStatuses, [{ hoster: 'byse.sx', status: 'pending' }]); +}); + +test('extends a registered group before finalization', async (t) => { + const { file } = await makeSource(t); + const { cleanup, audits } = makeCleanup(); + await cleanup.registerGroups([group(file, { + requiredHosters: ['voe.sx'], + jobs: [{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending' }] + })]); + await cleanup.registerGroups([group(file)]); + await cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' }); + + assert.deepEqual(await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']); + assert.equal(await exists(file), true); + assert.equal(audits[0].blockingStatuses[0].hoster, 'byse.sx'); +}); + +test('deletes only when enabled, history persisted, queue persisted, and every required hoster is done', async (t) => { + const cases = [ + { name: 'setting disabled', enabled: false, historyPersisted: true, queuePersisted: true, outcome: 'setting-disabled' }, + { name: 'history missing', enabled: true, historyPersisted: false, queuePersisted: true, outcome: 'blocked' }, + { name: 'queue missing', enabled: true, historyPersisted: true, queuePersisted: false, outcome: 'blocked' } + ]; + + for (const entry of cases) { + await t.test(entry.name, async (subtest) => { + const { file } = await makeSource(subtest, `${entry.name}.bin`); + const { cleanup, audits } = makeCleanup({ isEnabled: () => entry.enabled }); + const manifest = group(file); + await cleanup.registerGroups([manifest]); + await settleDone(cleanup, manifest); + + await cleanup.finishBatch({ + historyPersisted: entry.historyPersisted, + queuePersisted: entry.queuePersisted + }); + + assert.equal(await exists(file), true); + assert.equal(audits.length, 1); + assert.equal(audits[0].outcome, entry.outcome); + }); + } + + const { file } = await makeSource(t, 'all-barriers.bin'); + const { cleanup, audits } = makeCleanup(); + const manifest = group(file); + await cleanup.registerGroups([manifest]); + await settleDone(cleanup, manifest); + + await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }); + + assert.equal(await exists(file), false); + assert.deepEqual(audits.map((event) => event.outcome), ['delete-approved', 'source-staged', 'deleted']); +}); + +test('collects terminal states and blocks error, aborted, skipped, and pending jobs', async (t) => { + const cases = [ + { status: 'error', apply: (cleanup, file) => cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'error' }) }, + { status: 'aborted', apply: (cleanup, file) => cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'aborted' }) }, + { status: 'skipped', apply: (cleanup) => cleanup.markSkipped('job-byse', 'missing-account') }, + { status: 'pending', apply: (cleanup, file) => cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'uploading' }) } + ]; + + for (const entry of cases) { + await t.test(entry.status, async (subtest) => { + const { file } = await makeSource(subtest, `${entry.status}.bin`); + const { cleanup, audits } = makeCleanup(); + const manifest = group(file); + await cleanup.registerGroups([manifest]); + await cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' }); + await entry.apply(cleanup, file); + + await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }); + + assert.equal(await exists(file), true); + assert.equal(audits.length, 1); + assert.equal(audits[0].outcome, 'blocked'); + assert.deepEqual(audits[0].blockingStatuses, [{ hoster: 'byse.sx', status: entry.status }]); + }); + } +}); + +test('combines previous successes with a successful retry without relaxing other requirements', async (t) => { + await t.test('last retry completes the immutable group', async (subtest) => { + const { file } = await makeSource(subtest, 'retry-completes.bin'); + const { cleanup } = makeCleanup(); + const manifest = group(file, { completedHosters: ['voe.sx'] }); + await cleanup.registerGroups([manifest]); + await cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'done' }); + + await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }); + + assert.equal(await exists(file), false); + }); + + await t.test('an unselected failed requirement still blocks deletion', async (subtest) => { + const { file } = await makeSource(subtest, 'retry-partial.bin'); + const { cleanup, audits } = makeCleanup(); + const manifest = group(file, { + requiredHosters: ['voe.sx', 'byse.sx', 'vidmoly.me'], + completedHosters: ['voe.sx'], + jobs: [ + { jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' }, + { jobId: 'job-byse', file, hoster: 'byse.sx', status: 'pending' }, + { jobId: 'job-vidmoly', file, hoster: 'vidmoly.me', status: 'error' } + ] + }); + await cleanup.registerGroups([manifest]); + await cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'done' }); + + await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }); + + assert.equal(await exists(file), true); + assert.deepEqual(audits[0].blockingStatuses, [{ hoster: 'vidmoly.me', status: 'error' }]); + }); +}); + +test('audits a changed or missing source without deleting a replacement', async (t) => { + await t.test('source changed', async (subtest) => { + const { file } = await makeSource(subtest, 'changed.bin'); + const { cleanup, audits } = makeCleanup(); + const manifest = group(file); + await cleanup.registerGroups([manifest]); + await settleDone(cleanup, manifest); + await fs.promises.writeFile(file, Buffer.from('replacement source data with a different size')); + + await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }); + + assert.equal(await exists(file), true); + assert.equal(audits.length, 1); + assert.equal(audits[0].outcome, 'source-changed'); + }); + + await t.test('source missing', async (subtest) => { + const { file } = await makeSource(subtest, 'missing.bin'); + const { cleanup, audits } = makeCleanup(); + const manifest = group(file); + await cleanup.registerGroups([manifest]); + await settleDone(cleanup, manifest); + await fs.promises.unlink(file); + + await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }); + + assert.equal(audits.length, 1); + assert.equal(audits[0].outcome, 'source-missing'); + }); +}); + +test('rejects non-regular sources and audits the unsafe type', async (t) => { + const { directory } = await makeSource(t); + const unsafePath = path.join(directory, 'folder-source'); + await fs.promises.mkdir(unsafePath); + const { cleanup, audits } = makeCleanup(); + const manifest = group(unsafePath); + + const fingerprints = await cleanup.registerGroups([manifest]); + await settleDone(cleanup, manifest); + await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }); + + assert.equal(fingerprints['cleanup-1'], null); + assert.equal((await fs.promises.lstat(unsafePath)).isDirectory(), true); + assert.equal(audits.length, 1); + assert.equal(audits[0].outcome, 'unsafe-source-type'); +}); + +test('runs unlink exactly once across repeated and concurrent finalization', async (t) => { + const { file } = await makeSource(t); + let unlinkCalls = 0; + const injectedFs = { + promises: { + lstat: (...args) => fs.promises.lstat(...args), + rename: (...args) => fs.promises.rename(...args), + unlink: async (...args) => { + unlinkCalls += 1; + await fs.promises.unlink(...args); + } + } + }; + const { cleanup, audits } = makeCleanup({ fs: injectedFs }); + const manifest = group(file); + await cleanup.registerGroups([manifest]); + await settleDone(cleanup, manifest); + + await Promise.all([ + cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), + cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }) + ]); + await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }); + + assert.equal(unlinkCalls, 1); + assert.deepEqual(audits.map((event) => event.outcome), ['delete-approved', 'source-staged', 'deleted']); +}); + +test('retries EBUSY and EPERM with the bounded Windows delay schedule', async (t) => { + const { file } = await makeSource(t); + const failures = ['EBUSY', 'EPERM', 'EBUSY', 'EPERM', 'EBUSY']; + let unlinkCalls = 0; + const injectedFs = { + promises: { + lstat: (...args) => fs.promises.lstat(...args), + rename: (...args) => fs.promises.rename(...args), + unlink: async (...args) => { + const code = failures[unlinkCalls]; + unlinkCalls += 1; + if (code) throw Object.assign(new Error(code), { code }); + await fs.promises.unlink(...args); + } + } + }; + const { cleanup, audits, waits } = makeCleanup({ fs: injectedFs }); + const manifest = group(file); + await cleanup.registerGroups([manifest]); + await settleDone(cleanup, manifest); + + await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }); + + assert.equal(unlinkCalls, 6); + assert.deepEqual(waits, [100, 250, 500, 1000, 2000]); + assert.equal(await exists(file), false); + assert.deepEqual(audits.map((event) => event.outcome), ['delete-approved', 'source-staged', 'deleted']); + assert.equal(audits[2].attempts, 6); +}); + +test('swallows cleanup failures, preserves the source, and audits one final failure', async (t) => { + await t.test('retryable failure exhausts all delays', async (subtest) => { + const { file } = await makeSource(subtest, 'locked.bin'); + let unlinkCalls = 0; + const injectedFs = { + promises: { + lstat: (...args) => fs.promises.lstat(...args), + rename: (...args) => fs.promises.rename(...args), + unlink: async () => { + unlinkCalls += 1; + throw Object.assign(new Error('locked'), { code: 'EBUSY' }); + } + } + }; + const { cleanup, audits, waits } = makeCleanup({ fs: injectedFs }); + const manifest = group(file); + await cleanup.registerGroups([manifest]); + await settleDone(cleanup, manifest); + + await assert.doesNotReject(cleanup.finishBatch({ historyPersisted: true, queuePersisted: true })); + + assert.equal(unlinkCalls, 6); + assert.deepEqual(waits, [100, 250, 500, 1000, 2000]); + assert.equal(await exists(file), true); + assert.deepEqual(audits.map((event) => event.outcome), ['delete-approved', 'source-staged', 'failed']); + assert.equal(audits[2].attempts, 6); + }); + + await t.test('non-retryable failure stops immediately', async (subtest) => { + const { file } = await makeSource(subtest, 'denied.bin'); + let unlinkCalls = 0; + const injectedFs = { + promises: { + lstat: (...args) => fs.promises.lstat(...args), + rename: (...args) => fs.promises.rename(...args), + unlink: async () => { + unlinkCalls += 1; + throw Object.assign(new Error('denied'), { code: 'EACCES' }); + } + } + }; + const { cleanup, audits, waits } = makeCleanup({ fs: injectedFs }); + const manifest = group(file); + await cleanup.registerGroups([manifest]); + await settleDone(cleanup, manifest); + + await assert.doesNotReject(cleanup.finishBatch({ historyPersisted: true, queuePersisted: true })); + + assert.equal(unlinkCalls, 1); + assert.deepEqual(waits, []); + assert.equal(await exists(file), true); + assert.deepEqual(audits.map((event) => event.outcome), ['delete-approved', 'source-staged', 'failed']); + assert.equal(audits[2].attempts, 1); + }); +}); + +test('preserves the original Windows path casing for filesystem operations', async (t) => { + const { file } = await makeSource(t, 'SourceCase.BIN'); + const observed = []; + const injectedFs = { + promises: { + lstat: async (target) => { + observed.push(target); + return fs.promises.lstat(target); + }, + rename: (...args) => fs.promises.rename(...args), + unlink: (...args) => fs.promises.unlink(...args) + } + }; + const { cleanup } = makeCleanup({ fs: injectedFs, platform: 'win32' }); + const manifest = group(file); + await cleanup.registerGroups([manifest]); + await settleDone(cleanup, manifest); + await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }); + + assert.equal(observed[0], file); + assert.equal(await exists(file), false); +}); + +test('blocks both case-colliding Windows source groups', async (t) => { + const { directory, file } = await makeSource(t, 'Movie.mkv'); + const secondFile = path.join(directory, 'movie.mkv'); + await fs.promises.writeFile(secondFile, Buffer.from('other source data')); + const { cleanup } = makeCleanup({ platform: 'win32' }); + const first = group(file, { token: 'cleanup-upper' }); + const second = group(secondFile, { token: 'cleanup-lower' }); + await cleanup.registerGroups([first, second]); + await settleDone(cleanup, first); + await settleDone(cleanup, second); + + assert.deepEqual(await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked', 'blocked']); + assert.equal(await exists(file), true); + assert.equal(await exists(secondFile), true); +}); + +test('blocks deletion when the audit approval cannot be persisted', async (t) => { + const { file } = await makeSource(t, 'audit-failure.bin'); + const { cleanup } = makeCleanup({ audit: async () => false }); + const manifest = group(file); + await cleanup.registerGroups([manifest]); + await settleDone(cleanup, manifest); + + assert.deepEqual(await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']); + assert.equal(await exists(file), true); +}); + +test('restores the staged source when the deletion commit audit fails', async (t) => { + const { file } = await makeSource(t, 'audit-commit-failure.bin'); + let auditCalls = 0; + const { cleanup } = makeCleanup({ audit: async () => ++auditCalls !== 2 }); + const manifest = group(file); + await cleanup.registerGroups([manifest]); + await settleDone(cleanup, manifest); + + assert.deepEqual(await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']); + assert.equal(await exists(file), true); +}); + +test('rechecks the staged file and restores a replacement without deleting it', async (t) => { + const { file } = await makeSource(t, 'stage-race.bin'); + let unlinkCalls = 0; + let firstRename = true; + const injectedFs = { + promises: { + lstat: (...args) => fs.promises.lstat(...args), + rename: async (from, to) => { + await fs.promises.rename(from, to); + if (firstRename) { + firstRename = false; + await fs.promises.writeFile(to, Buffer.from('replacement data')); + } + }, + unlink: async (...args) => { + unlinkCalls += 1; + return fs.promises.unlink(...args); + } + } + }; + const { cleanup, audits } = makeCleanup({ fs: injectedFs }); + const manifest = group(file); + await cleanup.registerGroups([manifest]); + await settleDone(cleanup, manifest); + await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }); + + assert.equal(unlinkCalls, 0); + assert.equal((await fs.promises.readFile(file, 'utf-8')), 'replacement data'); + assert.equal(audits.at(-1).outcome, 'source-changed'); +}); diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js index 9128db4..52f5a1d 100644 --- a/tests/ui-smoke.js +++ b/tests/ui-smoke.js @@ -180,9 +180,8 @@ setTimeout(async () => { check('English upload telemetry is fully localized', englishTelemetryLabels === 'Total|Connections|Remaining|Running|Completed|Failed|Speed|ETA'); const englishLayoutFits = await wc.executeJavaScript('(() => { const states = [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const view = document.querySelector(".view.active"); return view && view.scrollWidth <= view.clientWidth + 1; }); document.querySelector(".tab[data-view=upload]")?.click(); return states.every(Boolean) && document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1; })()'); check('English labels fit every main view without horizontal overflow', englishLayoutFits === true); - await wc.executeJavaScript('document.querySelector(".tab[data-view=accounts]")?.click()'); - const hiddenAccountSparkline = await wc.executeJavaScript('document.getElementById("uploadSpeedSparkline")?.classList.contains("is-hidden")'); - check('Upload speed sparkline stays reserved but hidden outside Uploads', hiddenAccountSparkline === true); + const speedSparklineAcrossTabs = await wc.executeJavaScript('(() => [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const widget = document.getElementById("uploadSpeedSparkline"); const rect = widget?.getBoundingClientRect(); const style = widget && getComputedStyle(widget); return Boolean(widget && !widget.classList.contains("is-hidden") && style.visibility === "visible" && style.opacity === "1" && rect.width > 0 && rect.height > 0); }))()'); + check('Upload speed sparkline stays visible across every main tab', speedSparklineAcrossTabs.length === 4 && speedSparklineAcrossTabs.every(Boolean)); const englishEmptyAccountHosterLabel = await wc.executeJavaScript('(() => { const container = document.getElementById("accountsSidebarHosters"); return [container?.getAttribute("data-empty-label"), getComputedStyle(container, "::after").content].join("|"); })()'); check('Empty account hoster sidebar renders its localized English label', englishEmptyAccountHosterLabel === 'No hosts yet|"No hosts yet"'); await wc.executeJavaScript('document.querySelector(".tab[data-view=upload]")?.click()'); @@ -868,7 +867,7 @@ setTimeout(async () => { const automationInputAlignment = await wc.executeJavaScript('(() => { const first = document.getElementById("autoRetryRoundsInput")?.getBoundingClientRect(); const second = document.getElementById("autoRetryDelayMinInput")?.getBoundingClientRect(); const firstHintEl = document.getElementById("autoRetryRoundsInput")?.closest(".automation-retry-row")?.querySelector(".hint"); const secondHintEl = document.getElementById("autoRetryDelayMinInput")?.closest(".automation-retry-row")?.querySelector(".hint"); const firstHint = firstHintEl?.getBoundingClientRect(); const secondHint = secondHintEl?.getBoundingClientRect(); if (!first || !second || !firstHint || !secondHint || !firstHintEl || !secondHintEl) return "missing"; const firstTextLeft = firstHint.left + parseFloat(getComputedStyle(firstHintEl).paddingLeft); const secondTextLeft = secondHint.left + parseFloat(getComputedStyle(secondHintEl).paddingLeft); return [Math.round(Math.abs(first.left - second.left)), Math.round(first.width), Math.round(second.width), firstHint.top >= first.bottom + 6, secondHint.top >= second.bottom + 6, Math.round(Math.abs(firstTextLeft - first.left)) <= 1, Math.round(Math.abs(secondTextLeft - second.left)) <= 1].join("|"); })()'); check('Automation retry hints start directly below their aligned inputs', automationInputAlignment === '0|100|100|true|true|true|true'); await wc.executeJavaScript('document.querySelector("[data-settings-page=allgemein]")?.click()'); - const updateActionAlignment = await wc.executeJavaScript('(() => { const row = document.querySelector(".program-update-row")?.getBoundingClientRect(); const button = document.getElementById("manualUpdateCheckBtn")?.getBoundingClientRect(); return row && button ? [Math.abs(row.right - button.right) <= 12, button.bottom <= row.bottom, button.left > row.left + row.width / 2].join("|") : "missing"; })()'); + const updateActionAlignment = await wc.executeJavaScript('(() => { const row = document.querySelector(".program-update-row")?.getBoundingClientRect(); const button = document.getElementById("manualUpdateCheckBtn")?.getBoundingClientRect(); return row && button ? [Math.abs(row.right - button.right) <= 16, button.bottom <= row.bottom, button.left > row.left + row.width / 2].join("|") : "missing"; })()'); check('Program update action sits at the lower right of its card', updateActionAlignment === 'true|true|true'); const updateCardContract = await wc.executeJavaScript('(() => { const card = document.querySelector(".program-update-card"); const title = card?.querySelector(".program-update-title"); const description = card?.querySelector(".program-update-description"); const button = document.getElementById("manualUpdateCheckBtn"); if (!card || !title || !description || !button) return "missing"; const cardRect = card.getBoundingClientRect(); const titleRect = title.getBoundingClientRect(); const descriptionRect = description.getBoundingClientRect(); const buttonRect = button.getBoundingClientRect(); const center = rect => rect.top + rect.height / 2; return [title.textContent.trim(), description.textContent.trim(), titleRect.top < descriptionRect.top, Math.abs(center(buttonRect) - center(cardRect)) <= 2, buttonRect.right <= cardRect.right - 10, titleRect.left >= cardRect.left + 10].join("|"); })()'); check('Program update card uses a clear title, description and vertically centered action', updateCardContract === 'Nach neuer Version suchen|Verfügbare Updates werden zusammen mit dem Changelog angezeigt.|true|true|true|true'); @@ -883,7 +882,7 @@ setTimeout(async () => { const settingsSearchIconAlignment = await wc.executeJavaScript('(() => { const icon = document.querySelector(".settings-search-icon"); const style = getComputedStyle(icon); return [style.display, style.alignItems, Boolean(icon?.querySelector("svg"))].join("|"); })()'); check('Settings search icon aligns to the input text line', settingsSearchIconAlignment === 'flex|center|true'); const settingsSearchControlGeometry = await wc.executeJavaScript('(() => { const control = document.querySelector(".settings-search-control"); const input = document.getElementById("settingsSearchInput"); const icon = document.querySelector(".settings-search-icon"); const svg = icon?.querySelector("svg"); if (!control || !input || !icon || !svg) return "missing"; const controlRect = control.getBoundingClientRect(); const inputRect = input.getBoundingClientRect(); const iconRect = icon.getBoundingClientRect(); const inputStyle = getComputedStyle(input); const iconStyle = getComputedStyle(icon); return [Math.round(controlRect.height), Math.round(inputRect.height), Math.round(Math.abs((inputRect.top + inputRect.height / 2) - (iconRect.top + iconRect.height / 2))), svg.getAttribute("viewBox"), inputStyle.lineHeight, inputStyle.paddingTop, inputStyle.paddingBottom, iconStyle.display, iconStyle.alignItems].join("|"); })()'); - check('Settings search control keeps icon and text on one shared center line', settingsSearchControlGeometry === '40|40|0|0 0 24 24|18px|0px|0px|flex|center'); + check('Settings search control keeps icon and text on one shared center line', settingsSearchControlGeometry === '44|44|0|0 0 24 24|18px|0px|0px|flex|center'); await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'logs\\']")?.click()'); await new Promise(resolve => setTimeout(resolve, 100)); @@ -905,9 +904,21 @@ setTimeout(async () => { await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'uploads\\']")?.click()'); const uploadSettingsState = await wc.executeJavaScript('(() => { const activePage = document.querySelector(".settings-subpage.active"); return [activePage?.dataset.subpage, activePage?.querySelector("h3")?.textContent.trim(), document.querySelector("label[for=removeFromQueueOnDoneInput]")?.textContent.trim(), document.getElementById("removeFromQueueOnDoneInput")?.closest(".settings-option")?.querySelector(".settings-option-description")?.textContent.trim()].join("|"); })()'); check('Upload completion behavior is immediately findable', uploadSettingsState === 'uploads|Upload-Verhalten|Nach Abschluss aus der Liste entfernen|Erfolgreich hochgeladene Dateien verschwinden automatisch aus der Upload-Liste.'); + const settingsTypography = await wc.executeJavaScript('(() => { const size = selector => parseFloat(getComputedStyle(document.querySelector(selector)).fontSize); return { heading: size(".settings-subpage.active .settings-page-header h3"), intro: size(".settings-subpage.active .settings-page-header p"), section: size(".settings-subpage.active .settings-section-label"), rowLabel: size(".settings-subpage.active .settings-row > label"), hint: size(".settings-subpage.active .hint"), optionLabel: size(".settings-subpage.active .settings-option-copy label"), optionDescription: size(".settings-subpage.active .settings-option-description"), navigation: size(".settings-nav-button"), search: size("#settingsSearchInput") }; })()'); + check('Settings use the enlarged readable typography scale', settingsTypography.heading >= 22 && settingsTypography.intro >= 14 && settingsTypography.section >= 12 && settingsTypography.rowLabel >= 14 && settingsTypography.hint >= 12 && settingsTypography.optionLabel >= 14 && settingsTypography.optionDescription >= 12 && settingsTypography.navigation >= 13 && settingsTypography.search >= 13); + const settingsSelection = await wc.executeJavaScript('(() => ({ heading: getComputedStyle(document.querySelector(".settings-subpage.active .settings-page-header h3")).userSelect, hint: getComputedStyle(document.querySelector(".settings-subpage.active .hint")).userSelect, input: getComputedStyle(document.getElementById("globalMaxSpeedMbsInput")).userSelect }))()'); + check('Settings interface copy cannot be selected while input values remain selectable', settingsSelection.heading === 'none' && settingsSelection.hint === 'none' && settingsSelection.input === 'text'); + const enlargedSettingsFit = await wc.executeJavaScript('(() => { const results = [...document.querySelectorAll(".settings-nav-button")].map(button => { button.click(); const page = document.querySelector(".settings-subpage.active"); return Boolean(page && page.scrollWidth <= page.clientWidth + 1); }); document.querySelector("[data-settings-page=uploads]")?.click(); return results.every(Boolean); })()'); + check('Enlarged settings pages stay horizontally contained', enlargedSettingsFit === true); + const sourceDeleteWarning = await wc.executeJavaScript('(() => { const option = document.querySelector(".source-delete-option"); const style = option && getComputedStyle(option); if (!style) return { visible: false }; const channels = style.borderTopColor.match(/[0-9.]+/g)?.map(Number) || []; return { visible: style.borderTopStyle === "solid" && parseFloat(style.borderTopWidth) >= 1, red: channels.length >= 3 && channels[0] > channels[1] * 1.25 && channels[0] > channels[2] * 1.15 }; })()'); + check('Permanent source deletion is visibly framed as a danger setting', sourceDeleteWarning.visible && sourceDeleteWarning.red); + const sourceDeleteSafety = await wc.executeJavaScript('(async () => { const input = document.getElementById("deleteSourceAfterSuccessfulUploadInput"); const initial = input.checked; input.click(); await new Promise(resolve => setTimeout(resolve, 25)); const dialog = document.getElementById("appAlertModal"); const visible = dialog.style.display === "flex"; const danger = document.getElementById("appAlertConfirmBtn").classList.contains("btn-danger"); document.getElementById("appAlertCancelBtn").click(); await new Promise(resolve => setTimeout(resolve, 25)); return { initial, visible, danger, cancelled: input.checked === false }; })()'); + check('Permanent source deletion defaults off and requires a danger confirmation', sourceDeleteSafety.initial === false && sourceDeleteSafety.visible && sourceDeleteSafety.danger && sourceDeleteSafety.cancelled); + const sourceDeletePersistence = await wc.executeJavaScript('(async () => { const input = document.getElementById("deleteSourceAfterSuccessfulUploadInput"); input.click(); await new Promise(resolve => setTimeout(resolve, 25)); document.getElementById("appAlertConfirmBtn").click(); await new Promise(resolve => setTimeout(resolve, 25)); const dirty = document.getElementById("saveSettingsBtn").disabled === false; await saveSettings({ feedbackText: "Gespeichert" }); const enabled = (await window.api.getGlobalSettings()).deleteSourceAfterSuccessfulUpload === true; input.checked = false; input.dispatchEvent(new Event("change", { bubbles: true })); await saveSettings({ feedbackText: "Gespeichert" }); const restored = (await window.api.getGlobalSettings()).deleteSourceAfterSuccessfulUpload === false; return { dirty, enabled, restored }; })()'); + check('Confirmed source deletion persists and can be safely disabled again', sourceDeletePersistence.dirty && sourceDeletePersistence.enabled && sourceDeletePersistence.restored); const settingsReadingWidth = await wc.executeJavaScript('(() => { const activePage = document.querySelector(".settings-subpage.active"); if (!activePage) return 0; return activePage.getBoundingClientRect().width; })()'); - check('Active settings page keeps a readable content width', settingsReadingWidth > 0 && settingsReadingWidth <= 760); + check('Active settings page keeps a readable content width', settingsReadingWidth > 0 && settingsReadingWidth <= 820); const settingsFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("settings-view")?.getBoundingClientRect(); return Boolean(view && view.bottom <= window.innerHeight + 1); })()'); check('Settings view fits inside the viewport', settingsFrameFit === true); diff --git a/tests/upload-manager.test.js b/tests/upload-manager.test.js index bec2510..b7f3ef3 100644 --- a/tests/upload-manager.test.js +++ b/tests/upload-manager.test.js @@ -67,6 +67,36 @@ describe('UploadManager', () => { assert.ok(events.length > 0, 'should emit at least one progress event'); }); + it('emits job-settled after releasing job resources', async () => { + const mgr = new UploadManager({}); + let settled; + mgr.on('job-settled', (event) => { + settled = { + ...event, + activeJobs: mgr.activeJobs.size, + abortControllers: mgr.jobAbortControllers.size + }; + }); + + await mgr.startBatch([{ + file: '/test/settled.mp4', + hoster: 'doodstream.com', + apiKey: 'key1', + jobId: 'job-settled-1', + sourceCleanupToken: 'cleanup-1' + }]); + + assert.deepEqual(settled, { + jobId: 'job-settled-1', + sourceCleanupToken: 'cleanup-1', + file: '/test/settled.mp4', + hoster: 'doodstream.com', + status: 'done', + activeJobs: 0, + abortControllers: 0 + }); + }); + it('replaces account pools and clears cached account state after an import', () => { const mgr = new UploadManager({}, {}, { 'byse.sx': [{ id: 'old', apiKey: 'old-key' }] diff --git a/tests/webhook-notify.test.js b/tests/webhook-notify.test.js index 3ae4ccb..7d17087 100644 --- a/tests/webhook-notify.test.js +++ b/tests/webhook-notify.test.js @@ -55,7 +55,7 @@ test('buildWebhookRequest: many hosters does not exceed Discord limit', () => { }); test('buildWebhookRequest: aborted meta changes the headline', () => { - const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', { total: 5, succeeded: 0, failed: 5, files: [] }, { aborted: true }); + const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', { total: 5, succeeded: 0, failed: 5, files: [] }, { aborted: true, language: 'de' }); const body = JSON.parse(req.body); assert.match(body.content, /Batch abgebrochen/); }); @@ -94,14 +94,22 @@ test('buildWebhookRequest produces Discord content body for discord URLs', () => assert.strictEqual(req.headers['Content-Type'], 'application/json'); const body = JSON.parse(req.body); assert.ok(typeof body.content === 'string'); - assert.match(body.content, /Batch fertig/); + assert.match(body.content, /Batch completed/); assert.match(body.content, /srv-1/); - assert.match(body.content, /8 ok/); - assert.match(body.content, /2 Fehler/); + assert.match(body.content, /8 succeeded/); + assert.match(body.content, /2 failed/); assert.match(body.content, /1h 1m/); assert.match(body.content, /voe\.sx: 2\/2/); }); +test('buildWebhookRequest localizes Discord content from the selected language', () => { + const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', SAMPLE_SUMMARY, { language: 'de' }); + const body = JSON.parse(req.body); + assert.match(body.content, /Batch fertig/); + assert.match(body.content, /2 Fehler/); + assert.match(body.content, /10 gesamt/); +}); + test('buildWebhookRequest produces raw JSON payload for generic URLs', () => { const req = buildWebhookRequest('https://example.com/hook', SAMPLE_SUMMARY, { durationSec: 60, appVersion: '3.3.59', timestamp: '2026-06-09T00:00:00Z' }); const body = JSON.parse(req.body);