release: Multi-Hoster-Upload 2.1.9

This commit is contained in:
Sucukdeluxe
2026-08-11 17:11:30 +02:00
parent 32d4fc41d8
commit 69273b05d1
8 changed files with 109 additions and 7 deletions
+1 -1
View File
@@ -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). 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.8. Use the release page for the executables and the full English changelog. The latest public release is version 2.1.9. Use the release page for the executables and the full English changelog.
## Features ## Features
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.1.8", "version": "2.1.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.1.8", "version": "2.1.9",
"dependencies": { "dependencies": {
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"undici": "^7.29.0", "undici": "^7.29.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.1.8", "version": "2.1.9",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
+10 -1
View File
@@ -1348,6 +1348,7 @@ async function addDroppedFiles(fileList) {
..._pendingFiles.map(f => f.path) ..._pendingFiles.map(f => f.path)
]); ]);
const newFiles = []; const newFiles = [];
let duplicateCount = 0;
for (const file of files) { for (const file of files) {
let filePath = ''; let filePath = '';
@@ -1361,7 +1362,11 @@ async function addDroppedFiles(fileList) {
if (folderFiles && folderFiles.length > 0) { if (folderFiles && folderFiles.length > 0) {
for (const fp of folderFiles) { for (const fp of folderFiles) {
const p = typeof fp === 'string' ? fp : (fp && fp.path); const p = typeof fp === 'string' ? fp : (fp && fp.path);
if (!p || existingPaths.has(p)) continue; if (!p) continue;
if (existingPaths.has(p)) {
duplicateCount++;
continue;
}
const name = typeof fp === 'string' ? p.split('\\').pop().split('/').pop() : (fp.name || p.split('\\').pop().split('/').pop()); const name = typeof fp === 'string' ? p.split('\\').pop().split('/').pop() : (fp.name || p.split('\\').pop().split('/').pop());
const size = typeof fp === 'string' ? null : (fp.size || 0); const size = typeof fp === 'string' ? null : (fp.size || 0);
newFiles.push({ path: p, name, size }); newFiles.push({ path: p, name, size });
@@ -1377,12 +1382,16 @@ async function addDroppedFiles(fileList) {
if (!existingPaths.has(filePath)) { if (!existingPaths.has(filePath)) {
newFiles.push({ path: filePath, name: fileName, size: file.size }); newFiles.push({ path: filePath, name: fileName, size: file.size });
existingPaths.add(filePath); existingPaths.add(filePath);
} else {
duplicateCount++;
} }
} }
if (newFiles.length > 0) { if (newFiles.length > 0) {
_pendingFiles.push(...newFiles); _pendingFiles.push(...newFiles);
openHosterModal(); openHosterModal();
} else if (duplicateCount > 0) {
showCopyToast('Auswahl ist bereits in den Upload-Aufträgen.');
} }
} finally { } finally {
_addingDropped = false; _addingDropped = false;
+1
View File
@@ -193,6 +193,7 @@
['Quelldatei nach vollständigem Upload dauerhaft löschen', 'Permanently delete source file after complete upload'], ['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.'], ['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?'], ['Quelldateien dauerhaft löschen?', 'Permanently delete source files?'],
['Auswahl ist bereits in den Upload-Aufträgen.', 'The selection is already in the upload jobs.'],
['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.'], ['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'], ['Dauerhaftes Löschen aktivieren', 'Enable permanent deletion'],
['Hoster-Einstellungen', 'Host settings'], ['Hoster-Einstellungen', 'Host settings'],
+2
View File
@@ -3495,6 +3495,7 @@ input[type="checkbox"] {
} }
.accounts-list { .accounts-list {
flex: 1 1 auto;
min-height: 0; min-height: 0;
padding: 12px 16px 16px; padding: 12px 16px 16px;
gap: 8px; gap: 8px;
@@ -3503,6 +3504,7 @@ input[type="checkbox"] {
} }
.accounts-list-footer { .accounts-list-footer {
flex: 0 0 auto;
margin: 0; margin: 0;
padding: 8px 16px; padding: 8px 16px;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
+4
View File
@@ -70,6 +70,10 @@ test('English copy uses complete actions and correct singular plurals', () => {
assert.equal(translateText('Aktiv auf Port 9100 — 2 Clients verbunden', 'en'), 'Active on port 9100 — 2 clients connected'); assert.equal(translateText('Aktiv auf Port 9100 — 2 Clients verbunden', 'en'), 'Active on port 9100 — 2 clients connected');
}); });
test('translates duplicate desktop drop feedback to English', () => {
assert.equal(translateText('Auswahl ist bereits in den Upload-Aufträgen.', 'en'), 'The selection is already in the upload jobs.');
});
test('rare account, backup, update, and confirmation states translate in both directions', () => { test('rare account, backup, update, and confirmation states translate in both directions', () => {
const cases = [ const cases = [
['Einstellungen konnten vor dem Update nicht gespeichert werden', 'Settings could not be saved before the update'], ['Einstellungen konnten vor dem Update nicht gespeichert werden', 'Settings could not be saved before the update'],
+88 -2
View File
@@ -165,7 +165,8 @@ setTimeout(async () => {
await wc.executeJavaScript('document.getElementById("upload-tab").click()'); await wc.executeJavaScript('document.getElementById("upload-tab").click()');
const unchangedValues = await wc.executeJavaScript('(() => { setUiLanguage("de"); const nodes = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); let node = walker.nextNode(); while (node) { if (node.nodeValue.trim()) nodes.push({ node, source: node.nodeValue.trim() }); node = walker.nextNode(); } const attributes = [...document.querySelectorAll("[title],[aria-label],[placeholder],[data-tooltip]")].flatMap(element => ["title", "aria-label", "placeholder", "data-tooltip"].filter(name => element.hasAttribute(name)).map(name => ({ element, name, source: element.getAttribute(name).trim() }))); setUiLanguage("en"); const unchanged = nodes.filter(entry => entry.source === entry.node.nodeValue.trim()).map(entry => entry.source); unchanged.push(...attributes.filter(entry => entry.source === entry.element.getAttribute(entry.name).trim()).map(entry => entry.source)); return [...new Set(unchanged.filter(value => /[A-Za-zÄÖÜäöüß]{2}/.test(value)))].sort(); })()'); const unchangedValues = await wc.executeJavaScript('(() => { setUiLanguage("de"); const nodes = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); let node = walker.nextNode(); while (node) { if (node.nodeValue.trim()) nodes.push({ node, source: node.nodeValue.trim() }); node = walker.nextNode(); } const attributes = [...document.querySelectorAll("[title],[aria-label],[placeholder],[data-tooltip]")].flatMap(element => ["title", "aria-label", "placeholder", "data-tooltip"].filter(name => element.hasAttribute(name)).map(name => ({ element, name, source: element.getAttribute(name).trim() }))); setUiLanguage("en"); const unchanged = nodes.filter(entry => entry.source === entry.node.nodeValue.trim()).map(entry => entry.source); unchanged.push(...attributes.filter(entry => entry.source === entry.element.getAttribute(entry.name).trim()).map(entry => entry.source)); return [...new Set(unchanged.filter(value => /[A-Za-zÄÖÜäöüß]{2}/.test(value)))].sort(); })()');
const neutralUiValues = new Set(['0 kB/s', 'Accounts', 'BBCode', 'CSV', 'Changelog', 'ETA', 'ETA --:--', 'FileUploader Log', 'HTML', 'JSON', 'Label (optional)', 'Link', 'Log', 'Logs & Support', 'MB/s', 'MHU2-…', 'MULTI HOSTER UPLOADER', 'Markdown', 'Multi Hoster Uploader', 'OK', 'Plaintext', 'Port', 'Server', 'Status', 'Update', 'Upload', 'Uploads', 'Verbose Logging', 'Webhook', 'account-rotation.log', 'debug.log', 'doodstream-debug.log', 'fileuploader.log', 'upload-debug.log', 'mp4,mkv,avi']); const neutralUiValues = new Set(['0 kB/s', 'Accounts', 'BBCode', 'CSV', 'Changelog', 'ETA', 'ETA --:--', 'FileUploader Log', 'HTML', 'JSON', 'Label (optional)', 'Link', 'Log', 'Logs & Support', 'MB/s', 'MHU2-…', 'MULTI HOSTER UPLOADER', 'Markdown', 'Multi Hoster Uploader', 'OK', 'Plaintext', 'Port', 'Server', 'Status', 'Update', 'Upload', 'Uploads', 'Verbose Logging', 'Webhook', 'account-rotation.log', 'debug.log', 'doodstream-debug.log', 'fileuploader.log', 'upload-debug.log', 'mp4,mkv,avi']);
const unexpectedUnchangedValues = unchangedValues.filter(value => !neutralUiValues.has(value) && !value.includes('Multi-Hoster-Uploader')); const neutralUiPathBasenames = new Set(['account-rotation.log', 'doodstream-debug.log', 'fileuploader.log', 'upload-debug.log']);
const unexpectedUnchangedValues = unchangedValues.filter(value => !neutralUiValues.has(value) && !neutralUiPathBasenames.has(path.basename(value)) && !value.includes('Multi-Hoster-Uploader'));
if (process.env.AUDIT_I18N_UNCHANGED === '1' || unexpectedUnchangedValues.length) console.log('Unchanged i18n values: ' + JSON.stringify(unchangedValues, null, 2)); if (process.env.AUDIT_I18N_UNCHANGED === '1' || unexpectedUnchangedValues.length) console.log('Unchanged i18n values: ' + JSON.stringify(unchangedValues, null, 2));
check('Every mounted human-facing value is translated or explicitly language-neutral', unexpectedUnchangedValues.length === 0); check('Every mounted human-facing value is translated or explicitly language-neutral', unexpectedUnchangedValues.length === 0);
const englishValues = await wc.executeJavaScript('(() => { const values = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); let node = walker.nextNode(); while (node) { const value = node.nodeValue.trim(); if (value) values.push(value); node = walker.nextNode(); } values.push(...[...document.querySelectorAll("[title],[aria-label],[placeholder]")].flatMap(element => [element.title, element.getAttribute("aria-label"), element.getAttribute("placeholder")])); return [...new Set(values.filter(Boolean))]; })()'); const englishValues = await wc.executeJavaScript('(() => { const values = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); let node = walker.nextNode(); while (node) { const value = node.nodeValue.trim(); if (value) values.push(value); node = walker.nextNode(); } values.push(...[...document.querySelectorAll("[title],[aria-label],[placeholder]")].flatMap(element => [element.title, element.getAttribute("aria-label"), element.getAttribute("placeholder")])); return [...new Set(values.filter(Boolean))]; })()');
@@ -272,6 +273,77 @@ setTimeout(async () => {
const queueHidden = await wc.executeJavaScript('document.getElementById("queueShell")?.style.display'); const queueHidden = await wc.executeJavaScript('document.getElementById("queueShell")?.style.display');
check('Queue hidden (no files)', queueHidden === 'none'); check('Queue hidden (no files)', queueHidden === 'none');
const desktopDropFixture = path.join(app.getPath('temp'), 'mhu-native-drop-' + process.pid + '.mkv');
fs.writeFileSync(desktopDropFixture, Buffer.from('desktop drop fixture'));
const desktopDropPoint = await wc.executeJavaScript('(() => { const rect = document.querySelector(".upload-workspace")?.getBoundingClientRect(); return rect ? { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + Math.min(rect.height / 2, 180)) } : null; })()');
let desktopDropState = null;
try {
wc.debugger.attach('1.3');
const dragData = {
items: [{ mimeType: 'text/uri-list', data: 'file:///' + desktopDropFixture.replace(/\\\\/g, '/') }],
files: [desktopDropFixture],
dragOperationsMask: 1
};
await wc.debugger.sendCommand('Input.dispatchDragEvent', { type: 'dragEnter', ...desktopDropPoint, data: dragData });
await wc.debugger.sendCommand('Input.dispatchDragEvent', { type: 'dragOver', ...desktopDropPoint, data: dragData });
await wc.debugger.sendCommand('Input.dispatchDragEvent', { type: 'drop', ...desktopDropPoint, data: dragData });
await waitUntil(() => wc.executeJavaScript('document.getElementById("hosterModal")?.style.display === "flex"'));
desktopDropState = await wc.executeJavaScript('(() => ({ modal: document.getElementById("hosterModal")?.style.display, paths: _pendingFiles.map(file => file.path) }))()');
await wc.executeJavaScript('cancelHosterModal()');
} finally {
if (wc.debugger.isAttached()) wc.debugger.detach();
try { fs.unlinkSync(desktopDropFixture); } catch {}
}
check('Desktop file drop reaches the upload selection with its native path', desktopDropState?.modal === 'flex' && desktopDropState.paths.length === 1 && desktopDropState.paths[0] === desktopDropFixture);
const populatedDropFixture = path.join(app.getPath('temp'), 'mhu-populated-drop-' + process.pid + '.mkv');
fs.writeFileSync(populatedDropFixture, Buffer.from('populated queue drop fixture'));
await wc.executeJavaScript('(() => { selectedFiles = [{ path: "C:/ui/existing.bin", name: "existing.bin", size: 16 }]; queueJobs = [{ id: "ui-existing-drop-row", file: "C:/ui/existing.bin", fileName: "existing.bin", hoster: "doodstream.com", status: "preview", bytesUploaded: 0, bytesTotal: 16, speedKbs: 0, elapsed: 0, remaining: 0, progress: 0 }]; rebuildJobIndex(); updateUploadView(); renderQueueTable(); })()');
const populatedDropPoint = await wc.executeJavaScript('(() => { const rect = document.getElementById("queueShell")?.getBoundingClientRect(); return rect ? { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + Math.min(140, rect.height / 3)) } : null; })()');
let populatedDropState = null;
try {
wc.debugger.attach('1.3');
const dragData = {
items: [{ mimeType: 'text/uri-list', data: 'file:///' + populatedDropFixture.replace(/\\\\/g, '/') }],
files: [populatedDropFixture],
dragOperationsMask: 1
};
await wc.debugger.sendCommand('Input.dispatchDragEvent', { type: 'dragEnter', ...populatedDropPoint, data: dragData });
await wc.debugger.sendCommand('Input.dispatchDragEvent', { type: 'dragOver', ...populatedDropPoint, data: dragData });
await wc.debugger.sendCommand('Input.dispatchDragEvent', { type: 'drop', ...populatedDropPoint, data: dragData });
await waitUntil(() => wc.executeJavaScript('document.getElementById("hosterModal")?.style.display === "flex"'));
populatedDropState = await wc.executeJavaScript('(() => ({ modal: document.getElementById("hosterModal")?.style.display, paths: _pendingFiles.map(file => file.path) }))()');
await wc.executeJavaScript('cancelHosterModal(); selectedFiles = []; queueJobs = []; rebuildJobIndex(); updateUploadView(); renderQueueTable();');
} finally {
if (wc.debugger.isAttached()) wc.debugger.detach();
try { fs.unlinkSync(populatedDropFixture); } catch {}
}
check('Desktop file drop still reaches upload selection while the queue is populated', populatedDropState?.modal === 'flex' && populatedDropState.paths.length === 1 && populatedDropState.paths[0] === populatedDropFixture);
const duplicateDropFixture = path.join(app.getPath('temp'), 'mhu-duplicate-drop-' + process.pid + '.mkv');
fs.writeFileSync(duplicateDropFixture, Buffer.from('duplicate queue drop fixture'));
await wc.executeJavaScript('(() => { selectedFiles = [{ path: ' + JSON.stringify(duplicateDropFixture) + ', name: "mhu-duplicate-drop.mkv", size: 28 }]; queueJobs = [{ id: "ui-duplicate-drop-row", file: ' + JSON.stringify(duplicateDropFixture) + ', fileName: "mhu-duplicate-drop.mkv", hoster: "doodstream.com", status: "done", bytesUploaded: 28, bytesTotal: 28, speedKbs: 0, elapsed: 1, remaining: 0, progress: 100 }]; rebuildJobIndex(); updateUploadView(); renderQueueTable(); const toast = document.getElementById("copyToast"); toast.textContent = ""; toast.classList.remove("show"); })()');
const duplicateDropPoint = await wc.executeJavaScript('(() => { const rect = document.getElementById("queueShell")?.getBoundingClientRect(); return rect ? { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + Math.min(140, rect.height / 3)) } : null; })()');
let duplicateDropState = null;
try {
wc.debugger.attach('1.3');
const dragData = {
items: [{ mimeType: 'text/uri-list', data: 'file:///' + duplicateDropFixture.replace(/\\\\/g, '/') }],
files: [duplicateDropFixture],
dragOperationsMask: 1
};
await wc.debugger.sendCommand('Input.dispatchDragEvent', { type: 'dragEnter', ...duplicateDropPoint, data: dragData });
await wc.debugger.sendCommand('Input.dispatchDragEvent', { type: 'dragOver', ...duplicateDropPoint, data: dragData });
await wc.debugger.sendCommand('Input.dispatchDragEvent', { type: 'drop', ...duplicateDropPoint, data: dragData });
await new Promise(resolve => setTimeout(resolve, 100));
duplicateDropState = await wc.executeJavaScript('(() => ({ modal: document.getElementById("hosterModal")?.style.display, pending: _pendingFiles.length, toast: document.getElementById("copyToast")?.textContent, shown: document.getElementById("copyToast")?.classList.contains("show") }))()');
await wc.executeJavaScript('selectedFiles = []; queueJobs = []; rebuildJobIndex(); updateUploadView(); renderQueueTable();');
} finally {
if (wc.debugger.isAttached()) wc.debugger.detach();
try { fs.unlinkSync(duplicateDropFixture); } catch {}
}
check('Dropping a file already in the upload jobs explains the duplicate instead of doing nothing', duplicateDropState?.modal === 'none' && duplicateDropState.pending === 0 && duplicateDropState.shown === true && duplicateDropState.toast === 'Auswahl ist bereits in den Upload-Aufträgen.');
const startDisabled = await wc.executeJavaScript('document.getElementById("startUploadBtn")?.disabled'); const startDisabled = await wc.executeJavaScript('document.getElementById("startUploadBtn")?.disabled');
check('Start button disabled initially', startDisabled === true); check('Start button disabled initially', startDisabled === true);
@@ -637,6 +709,19 @@ setTimeout(async () => {
check('Hoster groups visibly animate while opening and closing', hasSmoothAccountCollapse(accountCollapseMotion.hosterMotion)); check('Hoster groups visibly animate while opening and closing', hasSmoothAccountCollapse(accountCollapseMotion.hosterMotion));
check('Hoster upload settings visibly animate while opening and closing', hasSmoothAccountCollapse(accountCollapseMotion.settingsMotion)); check('Hoster upload settings visibly animate while opening and closing', hasSmoothAccountCollapse(accountCollapseMotion.settingsMotion));
const accountsFooterGeometry = await wc.executeJavaScript(\`(() => {
const main = document.querySelector('#accounts-view .accounts-main')?.getBoundingClientRect();
const list = document.getElementById('accountsList')?.getBoundingClientRect();
const footer = document.getElementById('accountsListFooter')?.getBoundingClientRect();
return {
visible: Boolean(footer && footer.height > 0),
anchored: Boolean(main && footer && Math.abs(main.bottom - footer.bottom) <= 1),
listEndsAtFooter: Boolean(list && footer && Math.abs(list.bottom - footer.top) <= 1)
};
})()\`);
check('Accounts collapse-all footer stays anchored to the bottom below short hoster content', accountsFooterGeometry.visible && accountsFooterGeometry.anchored && accountsFooterGeometry.listEndsAtFooter);
await captureVisual('02-accounts-footer-short.png');
const tallAccountGroupGeometry = await wc.executeJavaScript(\`(() => { const tallAccountGroupGeometry = await wc.executeJavaScript(\`(() => {
const hoster = HOSTERS[0]; const hoster = HOSTERS[0];
HOSTERS.forEach(name => { config.hosters[name] = []; }); HOSTERS.forEach(name => { config.hosters[name] = []; });
@@ -1928,7 +2013,8 @@ try {
// timeout or exit code - still print output // timeout or exit code - still print output
if (err.stdout) console.log(err.stdout); if (err.stdout) console.log(err.stdout);
if (err.stderr) { if (err.stderr) {
const filtered = err.stderr.split('\n') const stderr = Buffer.isBuffer(err.stderr) ? err.stderr.toString('utf-8') : String(err.stderr);
const filtered = stderr.split('\n')
.filter(l => !l.includes('cache_util') && !l.includes('disk_cache') && !l.includes('gpu_disk_cache')) .filter(l => !l.includes('cache_util') && !l.includes('disk_cache') && !l.includes('gpu_disk_cache'))
.join('\n'); .join('\n');
if (filtered.trim()) console.error(filtered); if (filtered.trim()) console.error(filtered);