+
+ 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);