From e8eea5b5deb90deb12aa4b93481ee6ae865df9cd Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:11:30 +0200 Subject: [PATCH] release: Multi-Hoster-Upload 2.1.9 --- README.md | 2 +- package-lock.json | 4 +- package.json | 2 +- renderer/app.js | 11 +++++- renderer/i18n.js | 1 + renderer/styles.css | 2 + tests/i18n.test.js | 4 ++ tests/ui-smoke.js | 90 ++++++++++++++++++++++++++++++++++++++++++++- 8 files changed, 109 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9294082..74f6485 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Multi-Hoster-Upload is a Windows desktop application for sending file batches to Download the current Setup or Portable build from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest). -The latest public release is version 2.1.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 diff --git a/package-lock.json b/package-lock.json index f4e36c0..a942312 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-hoster-uploader", - "version": "2.1.8", + "version": "2.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-hoster-uploader", - "version": "2.1.8", + "version": "2.1.9", "dependencies": { "chokidar": "^3.6.0", "undici": "^7.29.0", diff --git a/package.json b/package.json index 7660e15..9a96ceb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-hoster-uploader", - "version": "2.1.8", + "version": "2.1.9", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { diff --git a/renderer/app.js b/renderer/app.js index e685756..7503922 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1348,6 +1348,7 @@ async function addDroppedFiles(fileList) { ..._pendingFiles.map(f => f.path) ]); const newFiles = []; + let duplicateCount = 0; for (const file of files) { let filePath = ''; @@ -1361,7 +1362,11 @@ async function addDroppedFiles(fileList) { if (folderFiles && folderFiles.length > 0) { for (const fp of folderFiles) { 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 size = typeof fp === 'string' ? null : (fp.size || 0); newFiles.push({ path: p, name, size }); @@ -1377,12 +1382,16 @@ async function addDroppedFiles(fileList) { if (!existingPaths.has(filePath)) { newFiles.push({ path: filePath, name: fileName, size: file.size }); existingPaths.add(filePath); + } else { + duplicateCount++; } } if (newFiles.length > 0) { _pendingFiles.push(...newFiles); openHosterModal(); + } else if (duplicateCount > 0) { + showCopyToast('Auswahl ist bereits in den Upload-Aufträgen.'); } } finally { _addingDropped = false; diff --git a/renderer/i18n.js b/renderer/i18n.js index 4deaf7a..cceddcc 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -193,6 +193,7 @@ ['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?'], + ['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.'], ['Dauerhaftes Löschen aktivieren', 'Enable permanent deletion'], ['Hoster-Einstellungen', 'Host settings'], diff --git a/renderer/styles.css b/renderer/styles.css index da72fb3..643498a 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -3495,6 +3495,7 @@ input[type="checkbox"] { } .accounts-list { + flex: 1 1 auto; min-height: 0; padding: 12px 16px 16px; gap: 8px; @@ -3503,6 +3504,7 @@ input[type="checkbox"] { } .accounts-list-footer { + flex: 0 0 auto; margin: 0; padding: 8px 16px; border-top: 1px solid var(--border); diff --git a/tests/i18n.test.js b/tests/i18n.test.js index 2f7b186..9722eb9 100644 --- a/tests/i18n.test.js +++ b/tests/i18n.test.js @@ -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'); }); +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', () => { const cases = [ ['Einstellungen konnten vor dem Update nicht gespeichert werden', 'Settings could not be saved before the update'], diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js index cbbd572..445cb88 100644 --- a/tests/ui-smoke.js +++ b/tests/ui-smoke.js @@ -165,7 +165,8 @@ setTimeout(async () => { 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 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)); 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))]; })()'); @@ -272,6 +273,77 @@ setTimeout(async () => { const queueHidden = await wc.executeJavaScript('document.getElementById("queueShell")?.style.display'); 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'); 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 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 hoster = HOSTERS[0]; HOSTERS.forEach(name => { config.hosters[name] = []; }); @@ -1928,7 +2013,8 @@ try { // timeout or exit code - still print output if (err.stdout) console.log(err.stdout); 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')) .join('\n'); if (filtered.trim()) console.error(filtered);