Follow-up hardening on the v3.3.80 queue-persistence fix after a deeper adversarial
sweep + reviewer pass over the WHOLE subsystem (not just the diff). Two real defects:
1. Basename-collision lost work (regression introduced by v3.3.80 FIX A).
restoreQueueStateFromConfig collapses jobs on the FULL path while the ts-gate keys
on basename|hoster. Two genuinely different files with the same basename queued to
the same hoster from different folders therefore share a gate key: if one was logged
after savedAt, the ts-rule dropped BOTH — silently losing the still-pending one.
Pre-FIX-A only 'done' jobs were dropped, so a pending file was never at risk.
Fix: an ambiguity guard in partitionRestoredJobsByLog — the ts-rule is suppressed
when a basename|hoster key maps to more than one distinct file path (the log records
only basenames, so it can't say which physical file completed). The done-in-log rule
is unchanged. Fails safe: worst case a visible ghost survives, never silent data loss.
2. selectedFiles re-materialization with removeFromQueueOnDone=ON (second mechanism,
independent of the stale snapshot). When that setting is on, a completed job is
stripped from queueJobs but its path stays in selectedFiles (syncSelectedFilesFromQueue
only runs at batch-done, never on a mid-upload close). On restart the ts-gate operates
on queueJobs and never sees it, then the startup updateUploadView -> buildQueuePreview
re-creates it as a preview ghost AFTER the gate ran, and it re-persists with a fresh
savedAt — sticky. Fix: completedSelectionKeys() seeds _completedUploadKeys (the set
buildQueuePreview already consults) from the log at startup, keyed on full path, with
the same ambiguity guard. Log-based so it survives a hard kill, consistent with FIX A.
Also extracts the orphan-tmp sweep decision into lib/orphan-tmp.js (was untested inline
code in main.js; behavior-preserving) and adds executable coverage for the paths that
were previously only argued from logic: orphan-tmp sweep, config-store pendingQueue+savedAt
round-trip, an end-to-end scenario in the exact user-reported shape (300 queued / ~200
finished mid-session), and a 3000+500-iteration property fuzz of the gate invariant
including the lost-work guarantee. 359/359 green, ESLint clean, smoke-boot unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closing the app (especially during an active upload or on a hard kill) and
reopening sometimes left already-uploaded files sitting in the queue as if
still pending. Root cause is three layers stacked:
1. Persist-starvation: persistQueueStateSoon() reset a 10s debounce on every
progress event, so during an upload the on-disk queue snapshot was never
rewritten and stayed frozen at the pre-upload state (all jobs "preview").
2. The beforeunload sync flush only covers a clean close; a hard kill / crash
leaves that stale snapshot on disk.
3. Startup auto-dedup only dropped jobs with status "done". The completed
files were stored as "preview" in the stale snapshot, so they survived and
reappeared.
Fix (mechanism-independent — holds whether the stale snapshot came from
starvation, a mid-upload close race, or a hard kill):
FIX A (core, durable): the upload log is the source of truth. Each persisted
snapshot is now stamped with savedAt; on restart any restored job whose newest
matching log entry is timestamped at/after floor(savedAt) is dropped regardless
of status — it provably completed after the snapshot, so a "preview" row for it
is a ghost. lib/queue-dedup.js gains an additive 3rd savedAt param; without
savedAt or without log timestamps it behaves exactly as before (the 5 canary
tests stay green, so intentional re-uploads of older files still survive).
FIX B: new lib/throttle-timer.js with a max-wait. During uploads the snapshot
is now written at most ~20s into a continuous progress burst instead of never;
idle stays a pure debounce. The fallback shim honors max-wait too, so a missing
library can never silently reintroduce the starvation.
FIX C: the synchronous close-write retries renameSync on EBUSY/EPERM/EACCES and
uses a pid-unique tmp (de-conflicts it from config-store._atomicWrite's fixed
.tmp). A startup sweep reclaims orphaned <config>.<pid>.tmp files left by a hard
kill between write and rename.
lib/upload-log.js extracts formatUploadLogLine + parseUploadLogLine from main.js
so the real writer -> reader -> gate seam is unit-tested (a future log-format or
epoch-basis change can no longer pass green while breaking the fix).
Verified: 334/334 tests green (incl. throttle fake-clock starvation/maxWait,
ts-gate multi-hoster partial-completion, and the real-format seam tests), ESLint
clean, smoke-boot identical to baseline, and an adversarial multi-agent review
(15 findings, 14 refuted, 1 low — the tmp orphan, now swept) on the diff.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The byse suspect size-memo (softened in v3.3.73 to arm only after two
confirmed suspect rejections on the same account and only for strictly
larger files) still occasionally pre-skips a primary account with
"Bekanntes Größen-Limit auf diesem Account", which the user finds annoying
when they would rather just let every file attempt the upload for real.
Add a per-hoster opt-out: a new "Größen-Limit merken" checkbox (Accounts tab,
default ON so existing behavior is preserved for everyone) that, when
unchecked, disables the memo-based pre-skip entirely for that hoster.
- config-store.js / upload-manager.js DEFAULT_SETTINGS: sizeMemoEnabled: true.
- upload-manager.js _suspectMemoBlocks(): returns false up front when the
hoster has sizeMemoEnabled === false. That single guard covers BOTH consult
sites — the pre-job block (skips the guaranteed-fail upload) and the
alternate-account skip — since both route through _suspectMemoBlocks. The
memo is still recorded; it is simply never allowed to block. This moves the
flow toward "fail open": with the toggle off, every file always gets a real
attempt on its account.
- The genuine suspect-reject alternates walk is gated on err.suspectReject,
not the memo, so disabling the memo does not weaken real failover — a file
the account actually rejects still rolls over to the next account.
- renderer/app.js: checkbox data-hs="sizeMemoEnabled", checked when
hs.sizeMemoEnabled !== false; persisted by the existing generic checkbox
path in saveHosterSettingsFromDom(). The running manager picks it up on the
next batch (fresh UploadManager) and immediately on save (updateSettings).
Tests: suspect-reject-alternates gains a disabled-memo case proving the larger
third file still gets a real primary attempt (key1 tried 3x, no
suspect-memo-skip event); config-store gains a default-true + persist-false
case. Full suite 305/305. Reviewed by a 4-lens adversarial workflow
(completeness / composition / persistence-ui / conventions): 0 findings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
byse.sx requires each account to upload >=80 videos in the last 30 days
(measured across a rolling 3-month window) or it goes dormant. With a
single primary account doing all the work, the other configured accounts
decay and eventually get suspended. This adds an opt-in "Accounts rotieren"
toggle per hoster that round-robins files across every enabled account that
has credentials: file 1 -> account 1, file 2 -> account 2, file 3 ->
account 3, then wraps. Off by default, so existing single-account behavior
is unchanged.
Mechanics:
- New lib/account-rotation.js: createAccountPicker() returns a stateful
pick(hoster) closure holding a per-hoster round-robin index. It only
rotates when rotateAccounts === true AND more than one usable account
exists; otherwise it returns the first enabled account (the old primary).
enabledAccountsFor() filters disabled + credential-less accounts while
preserving configured order, so a disabled account is simply skipped in
the cycle rather than leaving a gap.
- main.js: both buildUploadTasks() and buildUploadTasksFromJobs() now build
one picker per call and use pick(hoster) instead of getPrimaryAccount(),
which is now removed (dead code). A fresh picker per batch means each
upload session starts the cycle at account 1, matching "die erste Datei
auf Account eins".
- config-store.js: rotateAccounts: false added to HOSTER_SETTINGS_DEFAULTS.
- renderer/app.js: "Accounts rotieren" checkbox in the per-hoster upload
settings (Accounts tab). Persisted by the existing generic checkbox path
in saveHosterSettingsFromDom().
Composes with failover: rotation only chooses the *initial* account per
file. On a hard account failure the existing failover (_failedAccounts +
pre-job account swap) reroutes just that account's jobs; the healthy
accounts keep their rotation share.
Single enabled account (or rotation off) = no behavior change.
Tests: tests/account-rotation.test.js (10 cases) covers off=primary,
on=round-robin+wrap, single-account no-op, skip disabled, skip no-creds,
null when none usable, per-hoster index independence, byse-only rotation,
100-file 50/50 split, and the enabledAccountsFor filter. Full suite 294/294.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three changes, investigated and adversarially reviewed via multi-agent workflow.
1. Queue maximize bug: the virtual-scrolled upload queue derived its visible-row
window from #queueContainer.clientHeight but only recomputed it on 'scroll'.
Maximizing the window enlarges the container without a scroll event, so rows
below the old viewport stayed blank until you scrolled. Fix: a ResizeObserver
on #queueContainer reusing the existing rAF-coalesced _onQueueScroll. Strictly
stronger than a window 'resize' listener — it also covers the hidden->visible
view-switch case (container 0 -> real height). No feedback loop (container box
is flex-sized, not content-sized); early-returns for <200-row queues.
2. Einstellungen tab restructured from 4 dense stacked collapsible panels into
horizontal sub-tabs: Allgemein / Automatik / Logs & Diagnose / Fernsteuerung /
Backup. All sub-pages render into the DOM at once (only the active one is
shown), so every element id stays present and saveSettings keeps working. The
cluttered "Allgemein" block is split across Allgemein + Automatik + Logs.
Per-hoster settings already moved to Accounts in v3.3.69.
3. saveSettings hardened to be null-safe (elTxt/elChk/elInt helpers): when a
settings element is absent from the DOM it now keeps the current config value
instead of collapsing to a default. Behavior is identical when elements are
present; this is insurance against a future dropped id silently overwriting a
real setting (e.g. webhook URL, folder-monitor hoster pre-selection).
Verified: 43 sub-tab ids unique, all 26 saveSettings ids present, lint clean,
boot clean, 284 tests green, 0 confirmed defects from a 3-lens adversarial review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The settings section was nested inside the collapsible cards body, so you had to
expand a hoster group first before you could open its upload settings. Moved the
section out to the group level (sibling of the cards body), so the
"Upload-Einstellungen" toggle is always visible directly under each hoster header
— collapsed or not — for every hoster. Styled as a full-width row matching the
group header. Group-cards toggle and the settings toggle are now fully independent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Declutters the Einstellungen tab: the per-hoster panels (Retries, Max Speed,
Parallel Uploads, Restart-below, Interval, Max Size, log-to-file) are no longer
listed there. Each hoster group in the Accounts tab now has a collapsible
"Upload-Einstellungen" section with exactly those controls, so everything about
a hoster lives in one place. The Einstellungen tab shows a short pointer to where
they moved.
Per-hoster edits in Accounts save via a dedicated lightweight handler
(scheduleHosterSettingsSave → saveHosterSettings) that touches only
hosterSettings — it deliberately does NOT route through the global saveSettings,
so tweaking a hoster slider can't re-run the folder-monitor start/stop or clobber
global fields. The global saveSettings still reads .hs-input[data-hoster] wherever
they live, and global inputs (which have no data-hoster) are unaffected. The new
inputs drop the .settings-autosave class and bind via delegation on the persistent
accounts container so they survive re-renders.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a custom HTML menu bar above the tab bar, replicating the Real-Debrid
Downloader's look: click-to-open triggers, hover-switch between open menus,
click-outside / Escape to close, right-flyout submenus.
- Datei: Dateien hinzufügen, Ordner hinzufügen, Sicherung submenu
(Export/Import backup), Neustart, Beenden.
- Einstellungen: "Einstellungen öffnen" (jumps to the settings tab) plus an
inline live grid with the max-parallel-uploads spinner and a speed-limit
checkbox + MB/s spinner, both two-way-synced with the Settings tab fields
via saveGlobalSettings (parallelUploadCount / globalMaxSpeedKbs).
- Hilfe: Log-Ordner öffnen, Diagnose-Paket exportieren, Suche Aktualisierungen.
Wiring reuses existing handlers (addFiles/addFolder buttons, doBackupExport/
Import, openLogFolder, createSupportBundle, checkForUpdate) and two new tiny
IPC handlers app:restart (app.relaunch+quit) / app:quit. initMenuBar() is
wrapped in try/catch in setupListeners so a menu fault can never break core
app init. CSS maps the downloader's menu styles onto this app's theme vars.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a bottom-right button under the accounts list that expands every hoster
group at once (and collapses them again — the label flips based on current
state). Reuses the existing _hosterGroupOpenMemory so the per-group open/closed
state stays consistent with manual header clicks. The footer is hidden when no
accounts exist. Pure DOM toggle, no re-render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Upload-Verlauf tab took 30s+ to open on large, never-cleared histories.
Measured on the live config: 18.9 MB config / 41 batches / 53,403 rendered rows.
Two independent problems, fixed together:
1. Render side (the 30s killer): renderHistoryTable built ALL rows into one
innerHTML. Now the build is capped to the newest 2,000 rows (HISTORY_RENDER_CAP)
after slicing the chronological tail, then sorted for display. A notice shows
"Zeige neueste 2000 von N" so nothing looks deleted; full history stays on disk
and "Verlauf exportieren" still emits everything (export reads loadHistory()).
2. Storage side: history grew unbounded because appendHistory only ever pushed.
New globalSettings.historyRetention ('all' default, non-destructive) with
policies: 7d / 30d / 90d (time-based) and 1000 / 100 (newest-uploads-based).
appendHistory now prunes after each batch; a new prune-history IPC re-applies
the policy immediately when the user changes it, gated behind a confirm() that
shows the exact removal count (dry-run first).
Prune model keeps whole batches (atomic): count policies accumulate rendered rows
from the newest batch backward and always keep >= the newest batch even if it
alone exceeds the target; time policies keep batches whose timestamp is missing
or unparseable (old entries have none) so ambiguous data is never silently dropped.
New retention dropdown lives in the Verlauf header. 9 unit tests cover the prune
edge cases incl. a realistic 41-batch fixture. Full suite: 272 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User reported two coupled issues in the accounts panel:
- "VOE Upload: CSRF-Token nicht gefunden. Bist du eingeloggt?" fires
intermittently across multiple VOE accounts when "Accounts prüfen" runs.
Each retry "fixes" one and breaks another — classic anti-bot burst response.
- The flat badge strip becomes unreadable with many accounts; user wants
collapsible per-hoster groups with "N/M" headers and green/red indicators,
click to expand to per-account detail.
DISCRIMINATOR CHECK (cheap before serializing): grep'd lib/voe-upload.js for
module-level state — none. Each new VoeUploader() carries its own cookie Map.
Burst-throttle on VOE's side is the only plausible root cause.
CONCURRENCY FIX in main.js runHosterHealthCheck:
- Group checks by hoster, run each hoster's group SEQUENTIALLY, groups in
parallel (Promise.all of sequential runners). Cross-hoster parallelism
preserved; intra-hoster bursts eliminated.
- Result array preserves input order via a result-index map.
- Hardening per review: dedup duplicate {hoster, accountId} entries before
grouping (no wasted API calls if a caller ever sends duplicates), and entries
missing accountId now return a clean "Account-ID fehlt" error instead of
silently calling per-hoster checker with null config.
- Validate-credentials and checkSingleAccount paths unchanged (single-check
payloads run the same way regardless).
- Latency trade-off acknowledged: 5 VOE accounts ~5x faster path → up to 25s
for that hoster's column. That's the cost for reliability; the user's
alternative was 0/5 working on burst-failed runs.
UI FIX in renderer:
- New _buildAccountHosterGroupHtml emits a collapsible per-hoster group
reusing the existing .hoster-panel-header / .panel-arrow CSS pattern.
- Header shows "VOE 4/5" (ok-count / total-accounts), a green/red/amber/gray
status dot, plus pills for "N deaktiviert" and "N Fehler".
- Default: auto-expand any hoster with errors, checking, or unchecked
accounts; collapse all-green.
- Open-state memory tracks user clicks. Per review: also tracks errorsAtClose
snapshot so a NEW failure since the user's close forces re-expand once.
Prevents the "I closed it once and now silent failures hide forever" risk.
- Single-card updates also refresh the parent group's header counter via
_refreshHosterGroupHeader.
- Flat badge strip in renderHealthCheckResults is now a no-op stub — the
per-hoster headers carry the same info, less duplication.
Three-lens review (workflow wch4p9ee9): concurrency PASS_WITH_NOTES, ui-state
PASS_WITH_NOTES, comment-policy PASS (zero new // or /* */ comments).
Latent concerns from review applied as hardenings.
210/210 tests green, lint clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User reported three coupled bugs in account add/edit:
(1) Invalid logins still create the account
(2) Doodstream gets created multiple times when "Prüfen & Anlegen" is
double-clicked or repeatedly OTP-retried
(3) Add/Delete in the accounts panel feel laggy
Plus a UX/feature request: account label + two-step "Prüfen → Anlegen" flow.
Map (workflow wf44zpud4, 3 parallel subagents + adversarial verify) confirmed:
- saveAccount() persisted to disk BEFORE the health check (lines 3407-3409)
- saveBtn.disabled was set AFTER two awaited IPC roundtrips → 5-100ms race window
- OTP-retry path generated a new accountId on every click (editingAccountId
stayed null in ADD mode) → DETERMINISTIC duplication on every OTP attempt
- runHealthCheck IPC required the account to be already persisted → that's
why the old code wrote-first-check-second
Fix architecture (advisor: Option A — make the invariant real, not cleanup-based):
- main.js + preload.js: NEW `validate-credentials` IPC. Accepts ephemeral
{hoster, authType, username, password, apiKey, otp} payload, builds an
ephemeral hosterConfig, runs the same per-hoster checker via a shared
_dispatchHealthCheck helper. Nothing touches config.hosters.
- renderer: two-step modal state machine.
- "Prüfen" click → validateCredentials (ephemeral) → green flips button to
"Anlegen"/"Speichern" AND caches a snapshot of the validated creds.
- "Anlegen"/"Speichern" click → only fires if cached snapshot matches the
currently-typed credential-identity (username+password or apiKey;
label and OTP are not part of the snapshot key).
- Input listeners on the identity fields drop the snapshot the moment any
cred is edited post-green → user can't sneak unverified creds through.
- _accountModalBusy is set SYNCHRONOUSLY at the top of the click handler,
before any await, so a double-click is a no-op.
- _accountModalSession token bumps on every modal reset → a stale late
response from a closed-and-reopened modal can't stomp the new session's
busy flag or UI (lens-2 review fix).
- Edit mode flows through the same path → bad edits never reach disk
before being validated (fixes the silent good-creds clobber).
- closeAccountModal cancels the auto-close timer + clears modal state so
a stale 600 ms timer can't close a freshly-reopened modal.
- Label field (new): persisted on the account, shown in the card subtitle as
"Label: XYZ • API: ABC… — API Key gültig" so identical-looking API accounts
are disambiguable. Excluded from snapshot key on purpose — label is metadata.
- Perf: drop the redundant `await getConfig()` round-trip in commit+delete
(in-memory state was already the source of truth and the old reload was the
main lag source). deleteAccount fires-and-forgets the saveConfig and closes
the modal synchronously. Commit path uses updateAccountCard for the
single-card edit case instead of a 4-panel cascade.
Multi-lens review (workflow wyoc3iq4k, 3 reviewers): OTP-correctness SHIP,
race-guard SHIP-WITH-FIXES (session-id token + busy-inside-try applied),
edit-mode+label SHIP. No blockers.
Tests: 6 new regression tests (tests/validate-credentials.test.js) covering
the three reported bugs as executable spec:
(a) failed validation persists nothing to config.hosters
(b) second click with guard set persists exactly one entry
(c) OTP-required persists nothing; OTP retry re-validates ephemerally
plus snapshot-key identity, post-validation edit invalidation, and the
ephemeral hosterConfig shape contract. 210/210 green, lint clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Saved column widths were applied as fixed pixels, so switching from fullscreen
to windowed mode left the column sum wider than the viewport and the user had to
manually drag the window wider just to see the rightmost columns.
Now: a separate _idealColumnWidths map holds the user's preferred widths
(persisted), and _applyFittedColumnWidths reshapes the displayed widths to fit
the current container width. When sum(ideals) > container.clientWidth, every
column is scaled by the same factor so the row exactly fits (and a hidden
column becomes visible again).
- Two-tier widths: ideals are only updated by an explicit drag, not by a
resize-driven refit. So dragging while the window is narrow no longer
permanently shrinks every other column.
- saveDraggedColumnWidth(col, w) saves a single column's new ideal.
- Window-resize listener refits with a 60ms debounce.
Lint clean, full suite 200/200.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a third choice next to the existing single-file and per-day modes: a new
log file is created at every app start (process boot) and used until the app is
closed. A close → reopen of the app starts a new session, hence a new file.
File pattern: fileuploader-session-YYYY-MM-DD_HH-MM-SS-<pid>.log.
The boolean sessionLog field — misnamed: it actually toggled daily mode — is
replaced by a logMode enum: "single" | "daily" | "session". The misnomer made
the migration the trap to watch: existing users with sessionLog:true must land
on "daily", NOT "session". normalizeLogMode handles this and is unit-tested.
- lib/log-mode.js (new, pure, dual CJS/window export): normalizeLogMode +
resolveLogFileName + format helpers. No fs, no Date.now() at call time.
- config-store.js: normalize at the single load() boundary so downstream
readers consume logMode only. logMode is deliberately NOT seeded in DEFAULTS
(would beat the legacy migration after merge).
- main.js: stamp SESSION_ID once at process start (with pid hedge against
same-second restart collisions); getLogFilePath and buildFallbackLogName
switch on mode via the lib. _resolveUploadLogTarget cache key is now just
the primary path, which already encodes mode/date/session — self-invalidates.
- renderer: <select> with three German labels replaces the old checkbox;
saveSettings writes logMode; index.html loads the lib so window.LogMode is
available in renderSettings.
- Tests: 14 log-mode tests (incl. legacy-migration regression), 3 config-store
tests (defaults, legacy migration, round-trip all three values). 200/200.
End-to-end simulated locally: two launches → two distinct session files; PID
hedge produces distinct names even within the same second.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reproduced from a real saved config: pendingQueue held 4 'preview' jobs (one
file across 4 hosters); the queue saved + restored correctly. But
_autoDeduplicateFromLog (runs at init after restore) removed jobs whose
fileName|hoster appeared ANYWHERE in the lifetime fileuploader.log, regardless
of status — so all 4 pending previews were deleted and the queue showed the
empty "Dateien hierhin ziehen" state. Looked update-specific only because the
server restarts on update; a plain restart did the same.
- New lib/queue-dedup.js (pure, dual CJS/window export like queue-prune.js):
partitionRestoredJobsByLog drops ONLY 'done' jobs that match the log. Pending
(preview/queued) and failed (error/aborted) jobs always survive — they're
intentional queued work (often a deliberate re-upload of a previously
uploaded file). Manual importUploadLog stays separate/explicit.
- renderer wires it in; index.html loads the module before app.js.
- Tests: 5 cases incl. the exact reproduced scenario (4 previews all in log ->
0 removed). Full suite 162/162.
Verified against the user's real electron-config.json + fileuploader.log: old
logic removed 4/4 (empty queue), new logic removes 0/4 (queue preserved).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Deep bug-hunt of the per-hoster logToFile feature found the feature
itself clean (7 data flows traced: secret-store leaves hosterSettings
alone, save round-trip preserves the key for account-less hosters,
backup import/export round-trips, updateSettings full-replaces with
default-true fallback, checkbox branch precedes numeric coercion,
boolean survives IPC→JSON→parse intact).
The one real interaction effect: _autoDeduplicateFromLog reads
fileuploader.log on startup to drop already-uploaded files from the
restored queue. With logToFile off for a hoster, its entries are
absent, so the same file could be re-uploaded after a restart. The
dedup↔log coupling predates this feature; the toggle just makes it
observable.
Make it transparent in the checkbox hint rather than silently
shipping the surprise. Full decoupling (a separate always-written
dedup index independent of the user-facing log) is a larger,
separate change with its own risk surface — deferred unless wanted.
147/147 tests still green.
New per-hoster setting "Links in Log schreiben" (logToFile, default
on). When unchecked for a hoster, that hoster's successful upload
links are no longer written to fileuploader.log — other hosters keep
logging independently.
- lib/config-store.js: logToFile: true added to HOSTER_SETTINGS_DEFAULTS;
merge-on-load gives every hoster the key (old configs included).
- renderer/app.js: checkbox per hoster panel + collection loop now
handles type=checkbox (boolean) alongside the numeric fields. The
autosave bind already special-cased checkboxes (change event).
- lib/log-policy.js (new): hosterLogToFileEnabled() — pure, opt-out
semantics. Only an explicit logToFile===false disables; missing/
malformed/non-true values all default ON so links are never
silently dropped.
- main.js: shouldLogHosterToFile() reads the LIVE uploadManager
.hosterSettings (so a mid-batch toggle takes effect at once), falls
back to persisted config, then to enabled. Guards appendUploadLog
in the done handler; skipped writes get a debugLog line.
Tests: 8 log-policy (defaults, opt-out, per-hoster independence,
malformed input) + 2 config-store (default true, persisted false
survives reload). 147/147 green, eslint clean.
Generic "Username / E-Mail" label on every login-type account form
sent users down a confusing path on VOE: VOE only accepts an email
address (the web form is type=email, name=email), but the app's
label suggested either was fine. Logging in with a username
silently failed → upload-page fetch returned a login redirect → the
"VOE Upload: CSRF-Token nicht gefunden. Bist du eingeloggt?" error,
which doesn't point at the actual cause.
Add a tiny per-hoster override table. Currently only voe.sx is in
it: label "E-Mail", placeholder "E-Mail-Adresse", input type="email"
(so the browser's email-format hint kicks in too). All three
getCredsFieldsHtml call sites pass the hoster name — edit-mode,
add-mode initial render, and the hoster-select change handler.
Other hosters keep the existing "Username / E-Mail" wording.
137/137 tests still green.
The microtask-coalesce path from 3.3.1 (queueMicrotask + Set so 500
finishing jobs become one queueJobs.filter pass instead of 500) lived
inline in renderer/app.js. Pulled out into lib/coalesced-set.js with
an injectable scheduler so a Node test can drive timing without
async waits.
API: makeCoalescedSet({ apply, scheduler? }) returns
add(id) — queue an id for the next batch
drainSync() — flush synchronously (used by beforeunload)
pendingSize() — diagnostics
isScheduled() — diagnostics
Renderer rewires the previous _pendingDoneRemovalIds + manual
queueMicrotask plumbing to the new helper. Optional-chained: if the
script fails to load, a slower per-event filter runs as fallback.
Coverage:
- multiple adds same tick → 1 apply, all ids deduped
- duplicate ids deduped
- batches between flushes stay independent
- add after flush re-schedules
- drainSync flushes synchronously, queued microtask becomes a no-op
- empty drainSync is a no-op
- throwing apply doesn't lock out subsequent batches
- default scheduler (queueMicrotask) runs eventually
- 5000-id burst still coalesces to 1 apply
137/137 green.
The dynamic-key sort throttle (3.3.0) used an inline ad-hoc cache
object with a Date.now() comparison. Pull it out into a clean
generic-purpose makeThrottledCache helper that takes the TTL and an
optional clock function so tests can drive time without sleeping.
Same dual-environment loader (CommonJS for tests, window global for
the renderer via index.html script tag) as queue-prune.
API: get(sig, input) / set(sig, input, value) / clear() / peek().
sig + input identity must both match for a hit. Inputs are compared
by reference (===), exactly what sortQueueJobs needs to invalidate
on a fresh queueJobs array (e.g. backup import).
Coverage:
- empty cache → undefined
- within TTL → cached value
- past TTL → miss (boundary at refreshMs)
- different signature → miss
- different input identity → miss (even with same content)
- overwrite refreshes timestamp
- clear empties everything
- peek reports age + signature for diagnostics
- invalid TTL throws (negative, NaN, non-number)
- TTL=0 means every call misses (immediate expiry)
- default clock works (Date.now)
- large arrays tracked by identity, not value
Renderer rewires _dynamicSortCache to the new helper with a fallback
no-op shim if window.ThrottledCache failed to load. 119/119 green.
handleBatchDone's terminal-job auto-cap (introduced in 3.3.0) lived
inline as a manual two-pass loop over queueJobs. Pull the algorithm
into lib/queue-prune.js as pure pruneOldestTerminalJobs(jobs, limit)
that returns { kept, dropped } so the caller can clean up its index/
selection in one go. Same single implementation backs runtime and
tests via dual-environment loader (CommonJS module.exports for Node
tests, window.QueuePrune global for the renderer via index.html
script tag).
Coverage:
- Empty / null / non-array input → no-op
- All-non-terminal → no-op (regardless of limit)
- Terminal count ≤ limit → no-op
- Terminal count > limit → drops oldest by insertion order
- Mixed queue: non-terminals always kept, only terminals dropped
- limit=0 → drops every terminal
- Negative / NaN / Infinity limits → safe no-op
- Malformed entries (null, missing status) handled without throwing
- Large-queue stress (5000 done jobs) keeps newest 500
- TERMINAL_STATUSES set covers exactly done/skipped/error/aborted
Renderer uses window.QueuePrune?. so a failed script load just
disables the prune rather than crashing every batch-done. 107/107
tests green.
_sessionTrackedJobs and _sessionDoneJobs accumulated jobIds across
the whole session — IDs of jobs already removed from queueJobs (by
removeFromQueueOnDone or the auto-cap that lands in handleBatchDone)
stayed in those sets forever. ~50 bytes/entry × hundreds of batches
× many jobs/batch = small but real growth over a multi-day session.
At batch-done, walk the sets and drop any ID that's no longer present
in queueJobs. _completedUploadKeys is intentionally kept — it's the
dedup against re-queueing the same file across batches and would
break that contract if pruned.
The prune is a single pass per batch-done (rare event) and only
happens when the sets aren't already empty. 97/97 tests still green.
applyQueueSelectionClasses + applyRecentSelectionClasses ran
tbody.querySelectorAll('.queue-row') / ('.recent-file-row') on every
click. querySelectorAll always walks the tree and returns a fresh
static NodeList. With 200 visible queue rows + frequent click/drag
selections that's a measurable per-click cost.
Switch to getElementsByClassName: returns a live HTMLCollection that
the engine memoizes and updates incrementally as nodes are
inserted/removed. First call still walks once; subsequent calls are
near-free reads. Iteration uses a plain index loop because
HTMLCollection is array-like, not iterable in older runtimes (it is
in modern Chromium, but the index loop is also marginally faster).
No behaviour change. 87/87 still green.
handleProgress on a 'done' event with removeFromQueueOnDone=true was
calling queueJobs.filter() once per event. With 500 parallel jobs all
finishing at roughly the same time, that's 500 × O(N) = O(N²) work
synchronously on the IPC handler thread — visible as a brief UI freeze
when a big batch completes.
Coalesce into one microtask: removeJobFromIndex + selection cleanup
stay synchronous (so subsequent lookups see the right state), but the
array rewrite is deferred to a single filter against a Set of all
ids that came in this tick. JS microtask runs after the sync IPC
batch, so within one batch-of-events we get one filter pass instead
of N.
beforeunload drains the pending set synchronously before persisting
so removeFromQueueOnDone=true users don't see jobs reappear after
restart that they expected to be gone.
Bundles four findings from a stability audit plus the missing-log bug
the user reported.
1. main.js _flushUploadLog: ENOENT after the log file's directory got
deleted mid-session was swallowed; the buffer was cleared before
appendFile so entries were silently lost and the cached target kept
pointing at the dead path. Now: mkdirSync(recursive) before every
flush idempotently recreates a missing dir; on any append error we
invalidate the cache, prepend the chunk back to the buffer and
schedule a retry. Survives "user dragged the log folder into the
trash and didn't notice".
2. renderer/app.js queueJobs auto-prune: with the default
removeFromQueueOnDone=false the queue grew forever. Past ~5000
entries every render became O(N) on a perpetually-growing N and
the user saw progressive scroll/tab lag. Cap the in-queue
terminal jobs (done/skipped/error/aborted) at 500 most-recent on
each batch-done; oldest get pruned with their index entries.
3. sortQueueJobs dynamic-key throttle: status/speed/progress/size
sorts ran a full O(N log N) sort on every progress tick. Added a
200ms-window cache for the dynamic-key path so the sort is reused
within the same UI_UPDATE_INTERVAL — invisibly small reorder lag,
massive cost savings at 5000+ jobs.
4. renderHistoryTable delegated listeners: every Verlauf-tab switch
was binding one click listener per row (5000 listeners on a
long-history user) and rebuilding the entire <tbody> innerHTML.
Single delegated tbody listener covers both row-click (copy link)
and th-click (sort), bound once per container via dataset flag.
5. sessionFilesData (recent-files panel) cap at 2000 entries with
matching _sessionFileKeys cleanup using the existing separator.
Stops the lower-panel innerHTML write from inflating to multiple
MB on long sessions.
87/87 tests still green.
After importing a backup or restoring the queue at startup, queueJobs
is reassigned to a fresh array. The sort-cache keyed its hit on
"key|direction|length" — identical across a replacement with the same
job count. Result: renderQueueTable kept getting the cached sorted
array, which held references to the DISCARDED job objects (frozen at
status='preview'). Uploads ran perfectly in the background, the
status bar updated from stats events, but every row stayed "Bereit"
with "..." as size. The user had to poke `_queueSortCache={sig:'',…}`
in DevTools to unstick it.
Include the jobs array identity (jobsRef) in the cache check. A
replacement of queueJobs → different reference → cache miss → fresh
sort with the real current objects. O(1) identity check, no CPU cost
on the common case (same array, mutated jobs).
buildPersistedQueueState persisted every non-aborted job's status
as-is. At restart no upload manager exists, so a serialized 'queued'
or 'uploading' job showed up as "Wartet" / "Upload" even though
nothing was actually running — next to a "Bereit" job for the same
file on a different hoster (screenshot the user sent).
Collapse every non-terminal state (queued, getting-server, uploading,
retrying, aborted) to 'preview' during persistence. Terminal states
(done, error, skipped) survive as-is so the user keeps their history
/ error messages. Also clears error/result when collapsing so the
restored preview row doesn't carry stale failure text.
Two related visibility improvements.
1. Status cell now shows which account the job is running on:
"Upload · Primär", "Retry 2/3 · Fallback #1: <error>", etc.
- _emitProgress passes task.accountId in every progress event
- renderer maps accountId → position in config.hosters[hoster] and
renders "Primär" for index 0 and "Fallback #N" for the rest
- Applies to uploading/getting-server/retrying (static states like
done/error already tell their own story)
2. Right-click on a job → "Log anzeigen" opens a modal with the full
per-job trail: every rot-log entry tagged with that job's jobId
plus every non-uploading progress transition. Replaces the need to
grep account-rotation.log for a single filename.
- UploadManager: all 13 job-scoped _rotLog calls now carry jobId
- main.js: _jobLogCollector Map<jobId, Array<entry>> with 200-entry
ring buffer per job; cleared on each new start-upload (fresh
batch = fresh log). addJobs mid-batch keeps history.
- New IPC 'get-job-log' returns the array; preload.js exposes
window.api.getJobLog(jobId)
- renderer: modal card + context-menu item "Log anzeigen";
entries formatted as "[HH:MM:SS.mmm] [event] k=v k=v"; copy-to-
clipboard button