Compare commits

...

7 Commits

Author SHA1 Message Date
Administrator
c58d9203bc docs(tasks): record v3.3.108 session-log 6-digit suffix release
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 20:14:23 +02:00
Administrator
f0006f8003 feat(logs): append a 6-digit random suffix to session log filenames (v3.3.108)
Session-mode log files now end in a generated 6-digit number, e.g.
26-06-2026-mdu-session-06-02-847581.log. Dropping seconds/pid in v3.3.107
reintroduced same-minute collision risk on fast close/reopen; the random
suffix restores per-launch uniqueness without leaking the process id.

- formatSessionStamp(date, rand) appends -<rand> when supplied
- main.js stamps SESSION_ID with a 6-digit Math.random value
- stripModeStampFromFileName tolerates the optional -NNNNNN suffix
- tests cover stamp-with-rand and strip-with-suffix; 411 pass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 19:52:13 +02:00
Administrator
66ae240794 feat(logs): session log filename → DD-MM-YYYY-mdu-session-HH-MM (v3.3.107)
Per user request, the per-session log file is renamed from
fileuploader-session-YYYY-MM-DD_HH-MM-SS-<pid>.log to
DD-MM-YYYY-mdu-session-HH-MM.log (hour and minute only, no seconds or pid).

lib/log-mode.js:
- formatSessionStamp(date) returns `${DD}-${MM}-${YYYY}-mdu-session-${HH}-${MM}`
  (the pid argument is dropped; main.js still passes process.pid, harmlessly
  ignored). Same-minute restarts now share a session file, which is the intended
  human-readable trade.
- the session branch of resolveLogFileName returns `${sessionId}${ext}` — the stamp
  is the full app-defined stem and baseName is intentionally ignored for session
  mode (single/daily still use the 'fileuploader' base).
- stripModeStampFromFileName recognizes the new format and resets to the default
  'fileuploader' base (the new stem embeds no base, so the configured base is not
  recoverable from it); the existing daily and old-session strip regexes are kept
  for backward-compat with any persisted old paths, and the persist/re-resolve
  round-trip stays idempotent (no compounding stamps).

Tests updated for the new format (formatSessionStamp, session resolveLogFileName,
the new-format strip, and the idempotency regression). 410 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:56:43 +02:00
Administrator
8fed8669f3 fix(startup): disable HW acceleration on RDP/VM to kill the intermittent white screen + export filename (v3.3.106)
(A) Intermittent pure-white startup window. On a Windows VM viewed over
RemoteDesktop the app sometimes opened to a blank white window — no error banner,
not even the static menu bar. A multi-agent investigation + adversarial review
pinned it by one airtight deduction: the BrowserWindow is created with
backgroundColor '#16181c' (dark), so "white" can never be an un-painted, loading,
or failed state — all of those show dark. Pure white, with no banner and no static
menu bar, and silent in the logs, eliminates every DOM/init/CSS/load failure mode
(each would leave the dark styled shell, unstyled black-on-white menu text, or the
red init().catch banner) and leaves exactly one: a GPU/compositor surface failure
on the RDP virtual display adapter. The code ran hardware acceleration full-default
with no fallback (no disableHardwareAcceleration, no GPU switch anywhere), and the
GPU child-process-gone handler is log-only — matching the silent symptom.

Fix (the reviewed safe subset):
- app.disableHardwareAcceleration() at module top (before app.whenReady), gated on
  an RDP session (process.env.SESSIONNAME matches /^RDP/) OR a persisted
  gpu-disabled.flag in userData. The renderer has no WebGL/canvas/video, so software
  compositing costs effectively nothing here and does not undo the recent renderer
  perf work; local/console users keep hardware acceleration.
- Auto-heal: when a GPU child-process-gone fires, write gpu-disabled.flag so the next
  launch disables acceleration even if the RDP gate didn't match (covers a VM reached
  via console or a flaky virtual GPU). Self-heals after at most one white screen.
- The existing child-process-gone / render-process-gone / did-fail-load logging is
  kept so the affected server's next white-start log can confirm the cause
  (CHILD PROCESS GONE type=GPU). A webContents reload would not disrupt uploads
  (uploadManager lives in the main process and is torn down only on quit), but no
  watchdog is added because in this GPU mode init completes — an init-complete signal
  would not detect the blank surface.

(B) Backup export default filename changed from multi-hoster-backup-YYYY-MM-DD.mhu to
DD-MM-YYYY-multihoster-backup.mhu.

409 tests pass; clean boot (the guard is inert off-RDP).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:05:07 +02:00
Administrator
7a40afbe7e fix(config): fsync config writes + guard against account-wipe on a corrupt read (v3.3.105)
A user's server crashed hard during an upload and lost all configured accounts. It
was NOT the v3.3.104 update (a second server updated fine and kept its accounts) —
it was a data-durability hole exposed by the crash:

- Config writes were atomic (tmp + rename) but never fsync'd, so a hard crash could
  leave electron-config.json truncated/unflushed on disk.
- On restart, load() reads the truncated file, falls back to .bak, and if that is
  also bad returns empty DEFAULTS. The next settings/queue save then persists EMPTY
  hosters — permanently wiping the accounts. Worse, the async _atomicWrite blindly
  copied the (now truncated) live file over .bak, so an empty live could clobber a
  good backup.

Hardening (lib/config-store.js + main.js; no behavior change in the happy path):
- fsync before rename in both write paths — _atomicWrite (openSync/writeSync/
  fsyncSync/closeSync) and the synchronous save-global-settings-sync on window close.
  A hard crash can no longer leave a truncated config.
- _atomicWrite only refreshes .bak when the current live file is non-trivial
  (trim length > 2), so an empty/truncated live can never overwrite a good backup
  (the sync-save path already did this).
- Wipe-guard (_guardHosters): save(), saveRotationCursors() and the sync close-save
  never intend to change hosters; if after a load() the hosters are all empty and
  the write did not explicitly provide hosters, recover them from disk
  (_recoverHostersFromDisk: live -> .bak -> .pre-history-split.bak) instead of
  persisting the wipe. An explicit save({hosters: {}}) (user deleted all accounts)
  is still allowed. Restored hosters are already-encrypted on disk and
  encryptCredentials skips already-encrypted fields, so re-serializing is safe.
- load() gained a third fallback tier — the permanent pre-history-split.bak snapshot
  (which still holds the accounts) — so load() itself recovers after corruption.

Recovery for the already-affected server: copy
%APPDATA%/multi-hoster-uploader/electron-config.json.pre-history-split.bak (or .bak)
over electron-config.json with the app closed.

2 new regression tests (post-wipe valid-empty live + .bak → guard restores accounts;
an explicit empty-hosters save is not blocked). 409 tests pass; clean boot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 03:53:01 +02:00
Administrator
335f365497 perf(recent): virtualize the Recent-uploads panel — the last non-virtual table (v3.3.104)
The v3.3.103 log (real 2464-job, 4-hoster batch ramping to 95 concurrent) confirmed
the statSync fix held (batch-start main spike 336→231ms with 10× more jobs) and the
whole 90s ramp to 95 active was pristine (event-loop mean ~11ms, fps=32,
longtasks=0). The one residual: rapidly clicking tabs DURING the 95-active upload
produced 210-221ms renderer long-tasks (proc=0ms → layout/paint, not JS).

Cause: the Recent-uploads panel rendered every sessionFilesData row (up to 2000)
into the DOM non-virtualized — the exact analog of the History table before it was
virtualized in v3.3.102. Switching to that view laid out ~2000 rows (~210ms on the
RDP VM).

Fix — virtualize renderRecentUploadsPanel, mirroring the queue/History pattern:
- The tbody gets only the visible rows plus top/bottom spacer <tr> sized from
  VIRTUAL_ROW_HEIGHT. A rAF-coalesced scroll handler and a ResizeObserver on
  .recent-files-table-wrap re-render the visible window (the ResizeObserver also
  serves as the show-trigger when the hidden panel gains size). _recentWorking holds
  the sorted set. The insertAdjacentHTML append-only fast path is dropped — a
  ~40-row window re-render is cheap, so every render just re-renders the window; on
  prepend (date desc) the scroll position is preserved (scrollTop=0 at top, else
  += added*ROW_HEIGHT).
- Selection stays correct: _buildRecentRowHtml already stamps the selected class
  from selectedRecentIds.has(row.order) per row, so an off-screen-selected row
  renders selected when scrolled into view; selectedRecentIds remains the source of
  truth and shift-select already reads the sort cache, not the DOM.
- styles.css gives .recent-file-row a fixed 28px height so the virtualization math
  is exact (the table already had table-layout:fixed, so no column-jump fix needed).
- _renderRecentVirtualRows returns early when there are no rows, so it never wipes
  the empty-state message.

Verified with Playwright at 2000 rows (bounded container): view-show layout drops
from ~118ms to ~2.4ms, the DOM stays at 29-39 rows, scrolling maps to the correct
rows, the scrollbar height is exact, an off-screen-selected row renders with the
selected class, and the row height is exactly 28. Every large table (Queue,
History, Recent) is now virtualized.

407 tests pass; clean Electron boot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 03:15:44 +02:00
Administrator
7f636258d4 perf(uploads): make the batch-start file-stat non-blocking — kill the 336ms main stall (v3.3.103)
The v3.3.102 log (real 224-job batch) confirmed History virtualization works and
steady-state uploads are pristine (fps=32, event-loop mean 11.8ms). The remaining
residual was a ~6s spin-up burst at batch start: the main event loop blocked for
336ms with cpu=0%core (i.e. blocked on I/O, not computing) and the renderer janked
196-391ms, then everything settled clean.

A multi-agent investigation plus an adversarial review corrected the obvious-looking
hypothesis. The renderer's uncapped progress-batch drain is NOT the cause:
handleProgress only mutates plain JS state and schedules already-coalesced renders
(one per frame), and main coalesces progress to ~50 latest-per-job entries per
100ms. Chunking that drain would fix nothing — and the reviewer showed it would
REGRESS correctness: requestAnimationFrame throttles to ~0 when the window is
minimized (the common state for a background uploader), so a rAF-chunked drain would
grow an unbounded backlog and defer persistQueueStateSoon for every buffered item,
losing terminal 'done' events on close (the queue-persistence ghost-fix class). So
that path is deliberately not taken.

The real cause (cpu=0%core = blocked on I/O) is a synchronous fs.statSync storm in
UploadManager.startBatch: the dedup loop ran up to DEDUP_CHUNK=200 synchronous
fs.statSync calls in a single tick before yielding (200 x ~1.68ms on the user's VM
= the exact 336ms), on a disk already saturated by the 1MB read-ahead.

Fix — make the batch-start stats non-blocking:
- The dedup loop now dedupes synchronously (cheap Map work) and then stats the
  unique files in parallel via await Promise.all(fs.promises.stat ...) per chunk, so
  the stat I/O runs on the libuv threadpool and the main thread never blocks. The
  results-Map shape ({name,size,results:[]}) and dedup semantics (size 0 on failure)
  are unchanged.
- The per-job statSync fallback is converted to await fs.promises.stat for
  consistency (it sits in an async function before the first real await; the cached
  size from dedup already lets nearly every job skip it).

Tests: the upload-manager mocks override fs.statSync; they now also override
fs.promises.stat with the same fake sizes (upload-manager.test.js x2,
suspect-reject-alternates.test.js). 407 tests pass; clean Electron boot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:53:48 +02:00
12 changed files with 365 additions and 91 deletions

View File

@ -357,8 +357,10 @@ class ConfigStore {
try { data = this._readAndParse(this.filePath); } catch {}
// Fallback to backup if main is empty/corrupt
if (!data) {
const backupPath = this.filePath + '.bak';
try { data = this._readAndParse(backupPath); } catch {}
try { data = this._readAndParse(this.filePath + '.bak'); } catch {}
}
if (!data) {
try { data = this._readAndParse(this.filePath + '.pre-history-split.bak'); } catch {}
}
if (!data) {
const fresh = JSON.parse(JSON.stringify(DEFAULTS));
@ -481,12 +483,41 @@ class ConfigStore {
return this._writeQueue;
}
_anyHosters(cfg) {
const h = cfg && cfg.hosters;
return !!h && typeof h === 'object' && Object.values(h).some(a => Array.isArray(a) && a.length > 0);
}
_recoverHostersFromDisk() {
for (const p of [this.filePath, this.filePath + '.bak', this.filePath + '.pre-history-split.bak']) {
try {
const raw = fs.readFileSync(p, 'utf-8');
if (!raw || raw.trim().length < 2) continue;
const data = JSON.parse(raw);
if (this._anyHosters(data)) return data.hosters;
} catch {}
}
return null;
}
_guardHosters(current, hostersIntentional) {
if (!hostersIntentional && !this._anyHosters(current)) {
const recovered = this._recoverHostersFromDisk();
if (recovered) {
current.hosters = recovered;
if (this._perfLog) this._perfLog('config-guard: prevented account wipe — restored hosters from on-disk backup after a corrupt/empty read');
}
}
return current;
}
save(config) {
return this._enqueueWrite(() => {
const current = this.load();
if (config.hosters) current.hosters = config.hosters;
if (config.hosterSettings) current.hosterSettings = config.hosterSettings;
if (config.globalSettings) current.globalSettings = config.globalSettings;
this._guardHosters(current, !!config.hosters);
return this._commit(current);
});
}
@ -503,18 +534,22 @@ class ConfigStore {
return new Promise((resolve, reject) => {
const tmpPath = this.filePath + '.tmp';
const backupPath = this.filePath + '.bak';
fs.writeFile(tmpPath, data, 'utf-8', (err) => {
if (err) return reject(err);
let fd;
try {
fd = fs.openSync(tmpPath, 'w');
fs.writeSync(fd, data);
fs.fsyncSync(fd);
} catch (e) {
try { if (fd !== undefined) fs.closeSync(fd); } catch {}
return reject(e);
}
try { fs.closeSync(fd); } catch {}
Promise.resolve().then(() => {
try {
// Refresh .bak from the previous live file with a raw byte copy —
// no read+JSON.parse+write. The live file was itself written through
// this atomic path, so re-validating it by parsing the whole (growing)
// config on every write was pure waste. Wrapped in try/catch so an
// AV/indexer briefly locking the file doesn't fail the save — the
// rename to the live path is the part that matters.
try {
if (fs.existsSync(this.filePath)) {
fs.copyFileSync(this.filePath, backupPath);
const cur = fs.readFileSync(this.filePath, 'utf-8');
if (cur && cur.trim().length > 2) fs.writeFileSync(backupPath, cur, 'utf-8');
}
} catch {}
fs.renameSync(tmpPath, this.filePath);
@ -604,6 +639,7 @@ class ConfigStore {
return this._enqueueWrite(() => {
const config = this.load();
config.rotationCursors = (cursors && typeof cursors === 'object' && !Array.isArray(cursors)) ? cursors : {};
this._guardHosters(config, false);
return this._commit(config);
});
}

View File

@ -1,7 +1,7 @@
// Log-file mode resolution for fileuploader.log:
// - "single" → one file: fileuploader.log
// - "daily" → per-day: fileuploader-YYYY-MM-DD.log
// - "session" → per-launch: fileuploader-session-YYYY-MM-DD_HH-MM-SS-<pid>.log
// - "session" → per-launch: DD-MM-YYYY-mdu-session-HH-MM-NNNNNN.log
//
// Pure functions only — no fs, no Date.now() at call time — so they unit-test
// cleanly and the main.js call sites pass in `new Date()` + the session stamp.
@ -38,13 +38,11 @@
return `${date.getFullYear()}-${_two(date.getMonth() + 1)}-${_two(date.getDate())}`;
}
function formatSessionStamp(date, pid) {
const d = `${date.getFullYear()}-${_two(date.getMonth() + 1)}-${_two(date.getDate())}`;
const t = `${_two(date.getHours())}-${_two(date.getMinutes())}-${_two(date.getSeconds())}`;
// PID disambiguates a same-second close→reopen — a human can't but two
// automated runs might. Cheap belt to a suspenders-not-required problem.
const pidStr = pid !== undefined && pid !== null ? `-${pid}` : '';
return `${d}_${t}${pidStr}`;
function formatSessionStamp(date, rand) {
const d = `${_two(date.getDate())}-${_two(date.getMonth() + 1)}-${date.getFullYear()}`;
const t = `${_two(date.getHours())}-${_two(date.getMinutes())}`;
const r = (rand !== undefined && rand !== null && String(rand).trim()) ? `-${String(rand).trim()}` : '';
return `${d}-mdu-session-${t}${r}`;
}
/**
@ -67,9 +65,10 @@
const date = a.date instanceof Date ? a.date : new Date();
return `${base}-${formatDateStamp(date)}${ext}`;
}
// session
// session — the stamp is the full app-defined stem (DD-MM-YYYY-mdu-session-HH-MM),
// independent of baseName.
const sid = a.sessionId && String(a.sessionId).trim();
if (sid) return `${base}-session-${sid}${ext}`;
if (sid) return `${sid}${ext}`;
// Defensive: if a session-id wasn't passed, fall back to single rather
// than emit a malformed name. main.js always supplies one.
return `${base}${ext}`;
@ -85,6 +84,9 @@
*/
function stripModeStampFromFileName(fileName) {
if (!fileName || typeof fileName !== 'string') return fileName;
const newSessionRe = /^\d{2}-\d{2}-\d{4}-mdu-session-\d{2}-\d{2}(?:-\d+)?(\.[^.]+)?$/;
const mNew = fileName.match(newSessionRe);
if (mNew) return `fileuploader${mNew[1] || ''}`;
// Order matters: session first (longer, more specific) before daily.
// Both regexes are anchored to $ with no nested/ambiguous quantifiers, so
// matching is linear — the eslint security warning is precautionary.

View File

@ -364,16 +364,17 @@ class UploadManager extends EventEmitter {
for (let i = 0; i < tasks.length; i += DEDUP_CHUNK) {
if (signal.aborted) break;
const end = Math.min(i + DEDUP_CHUNK, tasks.length);
const toStat = [];
for (let j = i; j < end; j++) {
const task = tasks[j];
if (!results.has(task.file)) {
const fileName = path.basename(task.file);
let size = 0;
try { size = fs.statSync(task.file).size; } catch {}
results.set(task.file, { name: fileName, size, results: [] });
results.set(task.file, { name: path.basename(task.file), size: 0, results: [] });
toStat.push(task.file);
}
}
if (end < tasks.length) await new Promise(setImmediate);
await Promise.all(toStat.map(async (f) => {
try { const st = await fs.promises.stat(f); const e = results.get(f); if (e) e.size = st.size; } catch {}
}));
}
this._startStatsTimer();
@ -425,7 +426,7 @@ class UploadManager extends EventEmitter {
if (cachedResult && typeof cachedResult.size === 'number' && cachedResult.size > 0) {
fileSize = cachedResult.size;
} else {
try { fileSize = fs.statSync(task.file).size; } catch { fileNotFound = true; }
try { fileSize = (await fs.promises.stat(task.file)).size; } catch { fileNotFound = true; }
}
const maxAttempts = Math.max(1, (settings.retries || 0) + 1);

27
main.js
View File

@ -27,6 +27,16 @@ const stats = require('./lib/stats');
const { createCollectors } = require('./lib/diagnostics-collectors');
const { createAgent } = require('./lib/diagnostics-agent');
function _gpuDisableFlagPath() {
try { return path.join(app.getPath('userData'), 'gpu-disabled.flag'); } catch { return null; }
}
(function maybeDisableHardwareAcceleration() {
let disable = false;
try { if (/^RDP/i.test(process.env.SESSIONNAME || '')) disable = true; } catch {}
if (!disable) { try { const f = _gpuDisableFlagPath(); if (f && fs.existsSync(f)) disable = true; } catch {} }
if (disable) { try { app.disableHardwareAcceleration(); } catch {} }
})();
const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
_eventLoopDelay.enable();
let _eldLastLog = 0;
@ -553,10 +563,10 @@ function getBaseLogFilePath() {
// Log-mode bookkeeping. Three modes (see lib/log-mode.js): single, daily, session.
// The session-id is stamped ONCE at main-process startup so every write of a
// given session lands in the same file. A close→reopen of the app starts a new
// main process, so a new SESSION_ID, so a new session file. PID is appended as
// a cheap hedge against same-second restart collisions.
// main process, so a new SESSION_ID, so a new session file. A 6-digit random is
// appended as a cheap hedge against same-minute restart collisions.
const { resolveLogFileName, formatSessionStamp, formatDateStamp, stripModeStampFromFileName } = require('./lib/log-mode');
const SESSION_ID = formatSessionStamp(new Date(), process.pid);
const SESSION_ID = formatSessionStamp(new Date(), String(Math.floor(100000 + Math.random() * 900000)));
let _activeLogKey = null; // remembers (mode + date-or-session) so cache rolls correctly
let _activeLogPath = null;
@ -1278,6 +1288,9 @@ function createWindow() {
app.on('child-process-gone', (_event, details) => {
_writeCrashLog('CHILD PROCESS GONE', new Error(details.reason || 'unknown'), details);
debugLog(`CHILD PROCESS GONE: type=${details.type} reason=${details.reason} exitCode=${details.exitCode}`);
if (details && details.type === 'GPU') {
try { const f = _gpuDisableFlagPath(); if (f) fs.writeFileSync(f, new Date().toISOString(), 'utf-8'); } catch {}
}
});
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
@ -2203,9 +2216,11 @@ ipcMain.handle('clear-history', async () => {
// --- Backup export / import ---
ipcMain.handle('export-backup', async () => {
const _bd = new Date();
const _bdate = `${String(_bd.getDate()).padStart(2, '0')}-${String(_bd.getMonth() + 1).padStart(2, '0')}-${_bd.getFullYear()}`;
const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, {
title: 'Backup exportieren',
defaultPath: `multi-hoster-backup-${new Date().toISOString().slice(0, 10)}.mhu`,
defaultPath: `${_bdate}-multihoster-backup.mhu`,
filters: [
{ name: 'Multi-Hoster Backup (verschlüsselt)', extensions: ['mhu'] },
{ name: 'Multi-Hoster Backup (Klartext JSON)', extensions: ['json'] }
@ -2476,10 +2491,12 @@ ipcMain.on('save-global-settings-sync', (event, globalSettings) => {
const _diskDiag = current.globalSettings && current.globalSettings.diagnostics;
current.globalSettings = globalSettings;
if (_diskDiag) current.globalSettings.diagnostics = _diskDiag;
try { configStore._guardHosters(current, false); } catch {}
_invalidateLogSettings();
const data = configStore._serializeForDisk(current);
const backupPath = configStore.filePath + '.bak';
fs.writeFileSync(tmpPath, data, 'utf-8');
const _fd = fs.openSync(tmpPath, 'w');
try { fs.writeSync(_fd, data); fs.fsyncSync(_fd); } finally { fs.closeSync(_fd); }
if (fs.existsSync(configStore.filePath)) {
// Use try/catch around the read so an AV/lock race doesn't fail the
// whole save just because we couldn't refresh the .bak — the write to

View File

@ -1,6 +1,6 @@
{
"name": "multi-hoster-uploader",
"version": "3.3.102",
"version": "3.3.108",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js",
"scripts": {

View File

@ -4738,54 +4738,67 @@ function _buildRecentRowHtml(row) {
let _recentLastRenderedSig = '';
let _recentLastRenderedLen = 0;
let _recentPendingAppends = 0;
let _recentWorking = [];
let _recentLastRange = { start: -1, end: -1 };
let _recentScrollQueued = false;
function _onRecentScroll() {
if (_recentScrollQueued) return;
_recentScrollQueued = true;
requestAnimationFrame(() => { _recentScrollQueued = false; _renderRecentVirtualRows(); });
}
function _renderRecentVirtualRows() {
const wrap = document.querySelector('.recent-files-table-wrap');
const tbody = document.getElementById('recentFilesBody');
if (!wrap || !tbody) return;
const total = _recentWorking.length;
if (!total) return;
const scrollTop = wrap.scrollTop;
const viewportHeight = Math.max(wrap.clientHeight, 400);
const startIdx = Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN);
const endIdx = Math.min(total, Math.ceil((scrollTop + viewportHeight) / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN);
if (startIdx === _recentLastRange.start && endIdx === _recentLastRange.end) return;
_recentLastRange = { start: startIdx, end: endIdx };
const topPad = startIdx * VIRTUAL_ROW_HEIGHT;
const bottomPad = Math.max(0, (total - endIdx) * VIRTUAL_ROW_HEIGHT);
const parts = [];
if (topPad > 0) parts.push(`<tr class="virtual-spacer" style="height:${topPad}px"><td colspan="4"></td></tr>`);
for (let i = startIdx; i < endIdx; i++) parts.push(_buildRecentRowHtml(_recentWorking[i]));
if (bottomPad > 0) parts.push(`<tr class="virtual-spacer" style="height:${bottomPad}px"><td colspan="4"></td></tr>`);
tbody.innerHTML = parts.join('');
}
function renderRecentUploadsPanel(appendOnly = false) {
const tbody = document.getElementById('recentFilesBody');
if (!tbody) return;
const pendingAppends = _recentPendingAppends;
_recentPendingAppends = 0;
const wrap = tbody.closest('.recent-files-table-wrap');
if (!sessionFilesData.length) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">Noch keine Uploads in dieser Session.</td></tr>';
_recentLastRenderedSig = '';
_recentLastRenderedLen = 0;
return;
}
const rows = sortRecentFiles(sessionFilesData);
const sig = `${recentSortState.key}|${recentSortState.direction}`;
const dateDescAppendOnly = appendOnly
&& pendingAppends > 0
&& sig === 'date|desc'
&& _recentLastRenderedSig === sig
&& tbody.querySelectorAll('.recent-file-row').length === _recentLastRenderedLen;
const wrap = tbody.closest('.recent-files-table-wrap');
const wasAtTop = !wrap || wrap.scrollTop <= 48;
let wasAppendOnly = false;
if (dateDescAppendOnly) {
const added = Math.min(pendingAppends, rows.length);
let html = '';
for (let i = 0; i < added; i++) html += _buildRecentRowHtml(rows[i]);
tbody.insertAdjacentHTML('afterbegin', html);
let evict = (_recentLastRenderedLen + added) - rows.length;
while (evict > 0) {
const last = tbody.lastElementChild;
if (!last) break;
last.remove();
evict--;
}
wasAppendOnly = true;
_recentWorking = [];
_recentLastRange = { start: -1, end: -1 };
} else {
tbody.innerHTML = rows.map(_buildRecentRowHtml).join('');
const prevLen = _recentWorking.length;
_recentWorking = sortRecentFiles(sessionFilesData);
_recentLastRange = { start: -1, end: -1 };
const sig = `${recentSortState.key}|${recentSortState.direction}`;
if (wrap) {
const added = _recentWorking.length - prevLen;
if (sig === 'date|desc' && wrap.scrollTop <= 48) wrap.scrollTop = 0;
else if (sig === 'date|desc' && added > 0) wrap.scrollTop += added * VIRTUAL_ROW_HEIGHT;
}
_renderRecentVirtualRows();
}
if (wrap && sig === 'date|desc' && wasAtTop) wrap.scrollTop = 0;
_recentLastRenderedSig = sig;
_recentLastRenderedLen = rows.length;
// Event delegation bind once, not per-row
if (!_recentListenersBound) {
_recentListenersBound = true;
if (wrap) {
wrap.addEventListener('scroll', _onRecentScroll, { passive: true });
if (typeof window.ResizeObserver !== 'undefined') new window.ResizeObserver(_onRecentScroll).observe(wrap);
}
tbody.addEventListener('click', (e) => {
const tr = e.target.closest('.recent-file-row');
if (!tr) return;
@ -4824,8 +4837,7 @@ function renderRecentUploadsPanel(appendOnly = false) {
});
}
// Sort headers only change when the sort state changes — skip on appends.
if (!wasAppendOnly) updateRecentSortHeaders();
updateRecentSortHeaders();
}
const HISTORY_RENDER_CAP = 2000;

View File

@ -657,6 +657,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
.recent-file-row {
cursor: pointer;
transition: background 0.15s;
height: 28px;
}
.recent-file-row:hover {
background: rgba(255, 255, 255, 0.03);

View File

@ -1,3 +1,164 @@
# v3.3.108 — session log filename: 6-digit uniqueness suffix
User: append a generated 6-digit number to the session log filename, e.g.
26-06-2026-mdu-session-06-02-847581.log. Dropping seconds/pid in v3.3.107 reintroduced same-minute
collision risk on a fast close/reopen.
- formatSessionStamp(date, rand) appends `-${rand}` when rand is supplied (number or string), else unchanged.
- main.js stamps SESSION_ID = formatSessionStamp(new Date(), String(Math.floor(100000 + Math.random()*900000))).
- stripModeStampFromFileName newSessionRe gains an optional `(?:-\d+)?` so the suffix strips back to the base.
- Tests: stamp-with-rand (string + number), strip-with-suffix. 411 pass.
Shipped: gitea v3.3.108 (updater latest.yml verified) + GitHub mirror (lib/log-mode.js, main.js, package.json,
tests/log-mode.test.js only; tag repointed to the sanitized mirror commit, NOT the gitea history commit).
---
# v3.3.107 — session log filename template → DD-MM-YYYY-mdu-session-HH-MM
User: change the session log filename from fileuploader-session-YYYY-MM-DD_HH-MM-SS-<pid>.log to
DD-MM-YYYY-mdu-session-HH-MM.log (hour-minute, no seconds/pid). lib/log-mode.js:
- formatSessionStamp(date) now returns `${DD}-${MM}-${YYYY}-mdu-session-${HH}-${MM}` (pid arg dropped; main.js
still passes process.pid, harmlessly ignored).
- resolveLogFileName session branch returns `${sid}${ext}` (the stamp is the full app-defined stem, baseName
ignored — single/daily still use baseName 'fileuploader').
- stripModeStampFromFileName recognizes the new format (^DD-MM-YYYY-mdu-session-HH-MM(.ext)$) and resets to the
default 'fileuploader' base (the new format embeds no base); the old daily + old-session strip regexes stay
for backward-compat with any persisted old paths. The compounding round-trip stays idempotent.
Tests updated (formatSessionStamp, resolveLogFileName session, strip new-format, idempotency). 410 pass.
---
# v3.3.106 — intermittent white-screen on startup (RDP/VM GPU) + export filename
Two asks. (A) White screen: user sometimes gets a PURE-WHITE window on start (no error banner, NOT even the
static menu bar) on a 12-vCPU Windows VM over RemoteDesktop. Workflow wn2k04x5t (2 agents + adversarial verify)
nailed it by one airtight deduction: the BrowserWindow backgroundColor is DARK (#16181c, main.js:1228), so
"white" can NEVER be an un-painted/loading/failed state — those all show DARK. Pure-white + no menu bar + no
banner + SILENT eliminates every DOM/init/CSS/load mode (each leaves the dark styled shell, unstyled-black-on-
white menu text, or the red init().catch banner) → the ONLY match is a GPU/compositor surface failure on the
RDP virtual display adapter. Confirmed: NO disableHardwareAcceleration / disable-gpu / appendSwitch ANYWHERE,
and child-process-gone (GPU) is log-only (matches the silent symptom) while render-process-gone pops a dialog.
Renderer has ZERO WebGL/canvas/video (audited) → software compositing costs ~nothing and doesn't undo the perf
work; a webContents.reload() doesn't disrupt uploads (uploadManager lives in main, torn down only on quit).
Watchdog REJECTED: the GPU mode lets init complete, so an init-complete signal wouldn't detect the white screen.
SHIPPED v3.3.106 (main.js — adversary's safe subset):
- app.disableHardwareAcceleration() at module top (before app.whenReady) GATED on RDP (process.env.SESSIONNAME
matches /^RDP/) OR a persisted gpu-disabled.flag (in userData). Zero-regression for local/console users.
- Auto-heal: on a GPU child-process-gone, write gpu-disabled.flag → next launch disables HW accel even if the
RDP gate missed (covers VM-via-console / bad virtual GPU). Self-healing after at most one white screen.
- Kept the child-process-gone/render-process-gone/did-fail-load instrumentation to CONFIRM on the server log
(caveat: root cause is the standard RDP-GPU bet, not yet confirmed on the affected machine — next white-start
log shows CHILD PROCESS GONE type=GPU = confirmed).
- (B) export-backup defaultPath: multi-hoster-backup-YYYY-MM-DD.mhu → DD-MM-YYYY-multihoster-backup.mhu.
409 tests pass, clean boot (guard inert on non-RDP dev machine).
DEFERRED (perf polish, workflow w5o2rpffx design ready): history JSONL + batch-start residual.
---
# v3.3.105 — URGENT data-safety: config write fsync + account-wipe guard
User report: after a server CRASHED during upload (NOT the v3.3.104 update — the other server updated fine and
kept its accounts), the accounts/credentials were gone. Root-cause chain (in code): config writes were atomic
(tmp+rename) but had NO fsync — a hard crash can leave electron-config.json truncated/unflushed → on restart
load() reads the corrupt/empty file, falls to .bak, and if that's also bad returns empty DEFAULTS → the next
settings/queue save persists EMPTY hosters → accounts permanently wiped (and the async _atomicWrite blindly
copyFileSync'd the live → .bak, so an empty live could clobber a good .bak).
SHIPPED v3.3.105 (lib/config-store.js + main.js — data-safety, no behavior change):
- fsync before rename in BOTH write paths: _atomicWrite (openSync+writeSync+fsyncSync+closeSync, then
guarded-.bak + rename) and main.js save-global-settings-sync (openSync+writeSync+fsyncSync+closeSync). A hard
crash can no longer leave a truncated config.
- _atomicWrite .bak is now GUARDED: read the live file and only refresh .bak if it's non-trivial (trim>2) —
an empty/truncated live can never clobber a good .bak (matches what the sync-save already did).
- WIPE-GUARD (_guardHosters): in save()/saveRotationCursors()/the sync-save, when the write does NOT
intentionally set hosters (config.hosters absent) AND the resulting hosters are all-empty, recover the
hosters from disk (_recoverHostersFromDisk tries live → .bak → .pre-history-split.bak) instead of persisting
the wipe. An EXPLICIT save({hosters:{}}) (user deleted all) is still allowed (hostersIntentional=true).
- load() gained a 3rd fallback tier: .pre-history-split.bak (the permanent v3.3.99 snapshot with accounts) so
load() itself recovers after corruption.
2 new tests (post-wipe valid-empty live + .bak → guard restores; explicit empty NOT blocked). 409 tests pass.
RECOVERY for the affected server: %APPDATA%\multi-hoster-uploader\electron-config.json.pre-history-split.bak
(or .bak) → copy over electron-config.json with the app closed.
DEFERRED (perf polish, workflow w5o2rpffx designs ready): history JSONL (append-only, kills per-batch 185MB
rewrite + first-open parse via loadHistoryRecent tail-read + meta sidecar) and the batch-start one-time
render/231ms residual. Do these AFTER the data-safety fix is confirmed stable.
---
# v3.3.104 — virtualize the Recent-uploads panel (the last non-virtual table)
v3.3.103 log (real 2464-job, 4-hoster batch → 95 concurrent): the statSync fix HELD (batch-start main spike
336→231ms with 10× more jobs), and the whole 90s ramp to 95 active was PRISTINE (mean ~11ms, fps=32,
longtasks=0). Residual: rapidly clicking tabs DURING the 95-active upload → renderer-longtask 210-221ms
(proc=0ms = layout). Cause: the Recent-uploads panel (renderRecentUploadsPanel) rendered ALL sessionFilesData
rows (≤2000) into the DOM non-virtualized — the exact analog of the History table pre-v3.3.102. Switching to
that view laid out ~2000 rows.
Workflow w23318hm0 hit transient 529 overload (no cached results); did the fix directly using the proven
History-virtualization template + Playwright empirical verification (stronger than agent review for layout).
SHIPPED v3.3.104 (renderer/app.js + styles.css):
- Virtualized renderRecentUploadsPanel mirroring History/_renderVirtualRows: tbody#recentFilesBody gets only
~visible rows + top/bottom spacer <tr> (VIRTUAL_ROW_HEIGHT=28, OVERSCAN=10). Scroll handler (_onRecentScroll
rAF-coalesced) + ResizeObserver on .recent-files-table-wrap (doubles as show-trigger). _recentWorking holds
the sorted set. DROPPED the insertAdjacentHTML append-only fast path (a ~40-row window re-render is cheap);
every render re-renders the visible window. Scroll-position preserved on prepend (date|desc: scrollTop=0 at
top, else += added*ROW_HEIGHT).
- SELECTION SAFE: _buildRecentRowHtml already stamps `selected` from selectedRecentIds.has(row.order) per row,
so off-screen-selected rows render selected when scrolled in; selectedRecentIds stays the source of truth;
shift-select already uses _recentSortCache (not the DOM). applyRecentSelectionClasses toggling only visible
rows is correct.
- styles.css: .recent-file-row { height: 28px } so the virtualization math is exact (table already had
table-layout:fixed, so no column-jump fix needed unlike History).
- Empty-state guard: _renderRecentVirtualRows returns early when total=0 so it never wipes the "Noch keine
Uploads" message.
- PLAYWRIGHT-VERIFIED @2000 rows (bounded container): show-cost 118ms→2.4ms, DOM stays 29-39 rows, scroll maps
correctly (row1500→window@1486), scrollHeight exact (56014≈56000), off-screen-selected renders with class,
row height exactly 28. 407 tests pass, clean boot.
Every large table is now virtualized (Queue, History, Recent). DEFERRED still (one-time/minor): batch-start
~500ms first-render + residual 231ms main spike for 2464 jobs (one-time per batch); first-get-history-after-
batch parse-cache/JSONL.
---
# v3.3.103 — kill the batch-start 336ms main stall (synchronous statSync storm)
v3.3.102 log (real 224-job batch): History virtualization CONFIRMED (no get-history on tab switch),
steady-state pristine (fps=32, ELD 11.8ms). Residual = a ~6s BATCH-START spin-up burst: main ELD
max=336ms @cpu=0%core + renderer-longtasks 196-391ms; settles to clean by +6s. Workflow w3bzkumo8
(4 agents + adversarial verify) CORRECTED my hypothesis:
- My "uncapped renderer progress-drain" theory was WRONG: handleProgress only mutates JS + SCHEDULES
coalesced renders (rAF/200ms); render is already one-per-frame; main coalesces to ~50 latest-per-job/100ms.
Chunking the drain fixes nothing. ADVERSARY FOUND IT WOULD REGRESS: rAF throttles to ~0 when the window is
minimized (the common background-uploader state) → unbounded _pBuf backlog AND deferred persistQueueStateSoon
(last line of _handleProgressImpl) → terminal 'done' lost on close = the queue-persistence-ghost-fix class.
DEFERRED/REJECTED as sketched.
- REAL cause (cpu=0%core = blocked on I/O): synchronous fs.statSync storm in UploadManager.startBatch dedup
loop (lib/upload-manager.js:363-377): up to DEDUP_CHUNK=200 fs.statSync in ONE tick before yielding. 200 ×
~1.68ms (measured on the VM) = the exact 336ms. Plus a per-job statSync (428). On a disk already saturated
by the 1MB read-ahead.
SHIPPED v3.3.103 (lib/upload-manager.js — adversary's zero-risk headline fix, but the more thorough async form):
- Dedup loop: dedup synchronously (cheap Map ops), then stat the unique files in PARALLEL via
`await Promise.all(toStat.map(f => fs.promises.stat(f)))` per chunk → stats run on the libuv threadpool,
main thread NEVER blocks. Preserves the exact results-Map shape {name,size,results:[]} + dedup semantics
(size=0 on failure). The 336ms sync block → 0 main-thread block.
- Per-job statSync (428) → `await fs.promises.stat` (in an async fn before the first real await; cachedResult
fast-path already skips it for ~all jobs — consistency only).
- Tests: updated the fs.statSync mocks in upload-manager.test.js (2 sites) + suspect-reject-alternates.test.js
to also mock fs.promises.stat (returns the same fake sizes). 407 tests pass, clean boot.
The renderer-longtasks (391/280/299ms) during the burst were (medium-confidence) the user's OWN tab clicks
landing while the main thread was stalled — fixing the main stall frees IPC so those clicks stay responsive.
DEFERRED still (only if needed): first-get-history-after-batch parse-cache/JSONL; the renderer chunked drain
ONLY if ever needed for interaction-responsiveness AND gated on a high-water-mark sync drain + a
document.hidden setTimeout fallback (never rAF-only).
---
# v3.3.102 — virtualize the History table (the last tab-switch layout cost)
v3.3.101's gate killed the get-history PARSE on tab switch, but the v3.3.101 log showed a RESIDUAL: tab

View File

@ -293,6 +293,27 @@ describe('ConfigStore', () => {
const config = store.load();
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'from-backup');
});
it('wipe-guard: a settings-only save recovers accounts from .bak when the live config validly has none', async () => {
// Post-wipe state: live config parses fine but has empty hosters; a backup still holds the accounts.
fs.writeFileSync(store.filePath, JSON.stringify({ hosters: {}, hosterSettings: {}, globalSettings: {}, history: [] }), 'utf-8');
fs.writeFileSync(store.filePath + '.bak', JSON.stringify({
hosters: { 'voe.sx': [{ id: 'v1', authType: 'api', apiKey: 'survive-key' }] },
hosterSettings: {}, globalSettings: {}, history: []
}), 'utf-8');
await store.save({ globalSettings: { alwaysOnTop: true } });
const cfg = store.load();
assert.ok(cfg.hosters['voe.sx'] && cfg.hosters['voe.sx'].length === 1, 'guard must restore accounts from .bak, not persist the wipe');
assert.equal(cfg.hosters['voe.sx'][0].apiKey, 'survive-key');
assert.equal(cfg.globalSettings.alwaysOnTop, true);
});
it('wipe-guard: an explicit save({hosters:{}}) (user deleted all) is NOT blocked', async () => {
await store.save({ hosters: { 'doodstream.com': [{ id: 'd1', authType: 'api', apiKey: 'k' }] } });
await store.save({ hosters: {} });
const cfg = store.load();
assert.equal((cfg.hosters['doodstream.com'] || []).length, 0, 'an intentional hosters write must be allowed to empty them');
});
});
describe('ConfigStore history split (electron-history.json)', () => {

View File

@ -57,13 +57,23 @@ test('resolveLogFileName: daily mode → fileuploader-YYYY-MM-DD.log', () => {
);
});
test('resolveLogFileName: session mode → fileuploader-session-<id>.log', () => {
test('resolveLogFileName: session mode → <sessionId>.log (baseName ignored)', () => {
assert.equal(
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session', sessionId: '2026-05-28_22-44-52-12345' }),
'fileuploader-session-2026-05-28_22-44-52-12345.log'
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session', sessionId: '26-05-2026-mdu-session-22-44' }),
'26-05-2026-mdu-session-22-44.log'
);
});
test('formatSessionStamp: DD-MM-YYYY-mdu-session-HH-MM', () => {
const { formatSessionStamp } = require('../lib/log-mode');
assert.equal(formatSessionStamp(new Date(2026, 5, 26, 6, 2, 36)), '26-06-2026-mdu-session-06-02');
});
test('formatSessionStamp: appends a 6-digit suffix when a rand is supplied', () => {
assert.equal(formatSessionStamp(new Date(2026, 5, 26, 6, 2, 36), '847581'), '26-06-2026-mdu-session-06-02-847581');
assert.equal(formatSessionStamp(new Date(2026, 5, 26, 6, 2, 36), 847581), '26-06-2026-mdu-session-06-02-847581');
});
test('resolveLogFileName: session mode with missing sessionId falls back to single (never emits malformed name)', () => {
assert.equal(
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session' }),
@ -102,12 +112,17 @@ test('stripModeStampFromFileName: strips a session-stamp suffix (with and withou
);
});
test('stripModeStampFromFileName: new DD-MM-YYYY-mdu-session-HH-MM resets to the default base', () => {
assert.equal(stripModeStampFromFileName('26-06-2026-mdu-session-06-02.log'), 'fileuploader.log');
assert.equal(stripModeStampFromFileName('26-06-2026-mdu-session-06-02-847581.log'), 'fileuploader.log');
});
test('regression: resolveLogFileName(stripModeStampFromFileName(...)) is idempotent — persisting then re-resolving never compounds stamps', () => {
// This is the exact bug shape: persist the resolved path, then on next call
// re-resolve from the saved base — must produce the same file, not a doubled
// session-stamped one. The fix is the strip; this test guards against
// regressing _persistFallbackLogPath into the 3.3.35 bug.
const sessionId = '2026-06-03_18-16-20-8132';
const sessionId = '03-06-2026-mdu-session-18-16';
const dailyDate = new Date(2026, 5, 3);
for (const mode of ['daily', 'session']) {
const date = mode === 'daily' ? dailyDate : new Date();
@ -129,12 +144,7 @@ test('formatDateStamp: zero-pads month and day', () => {
assert.equal(formatDateStamp(new Date(2026, 11, 31)), '2026-12-31');
});
test('formatSessionStamp: produces YYYY-MM-DD_HH-MM-SS-pid', () => {
const d = new Date(2026, 4, 28, 7, 9, 5);
assert.equal(formatSessionStamp(d, 12345), '2026-05-28_07-09-05-12345');
});
test('formatSessionStamp: omits the pid suffix when none provided', () => {
const d = new Date(2026, 4, 28, 22, 44, 52);
assert.equal(formatSessionStamp(d), '2026-05-28_22-44-52');
test('formatSessionStamp: DD-MM-YYYY-mdu-session-HH-MM (no seconds/pid)', () => {
assert.equal(formatSessionStamp(new Date(2026, 4, 28, 7, 9, 5)), '28-05-2026-mdu-session-07-09');
assert.equal(formatSessionStamp(new Date(2026, 4, 28, 22, 44, 52)), '28-05-2026-mdu-session-22-44');
});

View File

@ -26,14 +26,20 @@ describe('suspect-reject alternate accounts', () => {
fileProbe.probeFileHead = (...a) => mockProbe(...a);
const fs = require('fs');
const fakeSize = (p) => {
const m = /-(\d+)gb/i.exec(p);
return { size: (m ? parseInt(m[1], 10) : 3) * 1024 * 1024 * 1024 };
};
const origStatSync = fs.statSync;
fs.statSync = function (p) {
if (typeof p === 'string' && p.startsWith('/test/')) {
const m = /-(\d+)gb/i.exec(p);
return { size: (m ? parseInt(m[1], 10) : 3) * 1024 * 1024 * 1024 };
}
if (typeof p === 'string' && p.startsWith('/test/')) return fakeSize(p);
return origStatSync.call(this, p);
};
const origStat = fs.promises.stat;
fs.promises.stat = async function (p) {
if (typeof p === 'string' && p.startsWith('/test/')) return fakeSize(p);
return origStat.call(this, p);
};
UploadManager = require('../lib/upload-manager');
});

View File

@ -33,7 +33,7 @@ describe('UploadManager', () => {
hosters.uploadFile = mockUploadFile;
hosters.prefetchBaseline = async () => null;
// Mock fs.statSync for test file paths
// Mock fs.statSync + fs.promises.stat for test file paths
const fs = require('fs');
const origStatSync = fs.statSync;
fs.statSync = function(p) {
@ -42,6 +42,13 @@ describe('UploadManager', () => {
}
return origStatSync.call(this, p);
};
const origStat = fs.promises.stat;
fs.promises.stat = async function(p) {
if (typeof p === 'string' && p.startsWith('/test/')) {
return { size: fakeFileSize };
}
return origStat.call(this, p);
};
UploadManager = require('../lib/upload-manager');
});
@ -331,10 +338,10 @@ describe('UploadManager', () => {
});
it('file not found produces descriptive error', async () => {
// Override fs.statSync to throw ENOENT for a specific path
// Override fs.promises.stat to throw ENOENT for a specific path
const fs = require('fs');
const origStat = fs.statSync;
fs.statSync = function(p) {
const origStat = fs.promises.stat;
fs.promises.stat = async function(p) {
if (p === '/test/deleted.mp4') throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
return origStat.call(this, p);
};
@ -347,7 +354,7 @@ describe('UploadManager', () => {
{ file: '/test/deleted.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
]);
fs.statSync = origStat;
fs.promises.stat = origStat;
assert.ok(errors.some(e => e.includes('nicht gefunden')), `expected "nicht gefunden" error, got: ${errors.join(', ')}`);
});