Commit Graph

146 Commits

Author SHA1 Message Date
Administrator
eeec1d150c fix(queue): completed files no longer reappear in the queue after restart
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>
2026-06-19 04:35:27 +02:00
Administrator
4f2bd25426 revert(byse): drop the v3.3.78 truncated-host rejection (added cost, no benefit)
v3.3.78 rejected upload-server hosts ending in the bare ".filemoon" label so the
lookup would retry for a valid server. In practice byse is currently returning a
truncated host for its WHOLE pool, so the rejection found nothing to fall back to and
turned every byse upload into a slow "Kein Upload-Server erhalten: OK" (6x2.5s server
retry per file) instead of the fast ENOTFOUND fail it had before. That is a worse UX
with zero upside while byse is down, so revert to the v3.3.77 behavior.

The premise (byse returns a MIX of good and truncated hosts, skip the bad ones) is
unverified; the evidence is byse returning truncated hosts for everything. And the
correct upload domain is NOT safely determinable from outside: a swarm of filemoon-brand
TLDs resolve (filemoon.eu/.me/.xyz/.art/.nl, kerapoxy.cc, moonmov.pro, ...) but the ones
probed are parked/squatted (parklogic landers, shared catch-all certs), and byse's real
response is only visible with a valid key. Guessing a TLD would point uploads at a parking
page or a third party, so we do not.

lib/hosters.js is now byte-identical to v3.3.77. The v3.3.77 fixes (5xx/ECONNRESET treated
as transient -> no failover cascade/blacklist; the /api/file/list path fix) are kept.
ENOTFOUND remains transient, so byse's outage fails clean (retry same account, no cascade)
with the actual unresolvable host shown in the message. Suite 311/311.

When byse restores its server pool (returns complete hostnames), uploads resume
automatically with no client change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 02:31:57 +02:00
Administrator
46888c0220 fix(byse): reject truncated upload-server hostnames so the lookup retries for a valid one
In v3.3.77 the byse uploads started failing with "getaddrinfo ENOTFOUND
s1065.filemoon" / "s1070.filemoon" / "s1075.filemoon". The hostname has no TLD,
so DNS cannot resolve it. Root cause: byse's GET /upload/server is INTERMITTENTLY
handing back a truncated upload-server host — "sNNNN.filemoon" with the TLD dropped
(consistent with byse's ongoing filemoon migration; their own docs already expose an
old_domain/new_domain embed switch). Some uploads still work because byse returns the
complete host on those; the failing ones got truncated. byse did not change the API
contract — this is malformed data from their server pool.

The correct upload domain is NOT externally verifiable: every TLD that resolves for
these server IDs is a parked/squatter domain (filemoon.art -> ParkLogic "lander" PTR;
filemoon.nl -> a shared catch-all cert for kaobei.cc/babesex.xyz/...). Appending a TLD
would point uploads at a parking page (or a third party) — so we do NOT guess one.

Instead, treat an obviously-truncated host as "no valid server": normalizeAbsoluteUrl
now returns null for any host ending in the bare ".filemoon" label (never a real TLD).
extractUploadServerUrl then yields nothing for that response, so getUploadServer falls
through to its existing machinery — it retries the lookup (SERVER_RETRY_ATTEMPTS) to get
a different server and, crucially, returns the last-known-good server from
LAST_UPLOAD_SERVERS once one has been cached. So after any single complete response, the
truncated ones transparently reuse the good server instead of uploading into a DNS void.
If byse's whole pool is truncating (cold cache), the lookup fails CLEAN as hosterTransient
(no cascade, no blacklist — the v3.3.77 behavior), and the next batch tries again.

This composes with v3.3.77: ENOTFOUND was already transient (retry same account, no
failover); this stops the unresolvable host from ever reaching the POST in the first place
when a working server is available.

Tests: extractUploadServerUrl rejects https://sNNNN.filemoon / bare sNNNN.filemoon /
https://filemoon and keeps a complete host (https://s1065.filemoon.sx/...) untouched.
Suite 313/313.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 02:19:59 +02:00
Administrator
8b2a1d7c1f fix(byse): treat 5xx/gateway upload failures as transient infra, not account faults
A single byse.sx gateway hiccup (HTTP 502 "<!doctype html>..." or a mid-upload
ECONNRESET) was cascading through the whole failover chain and blacklisting every
account for the batch. Root cause: the upload-POST throw sites threw PLAIN errors
with no classification, so a 502 was treated as a GENERIC error -> mark-failed ->
emit('account-failed') -> failover to the next account (which hits the same
gateway) -> repeat until the chain is exhausted, then poison sibling files in the
batch via the blacklist. The screenshots showed exactly this: "Primär ... Fallback
#3" all failing with the same 502. byse did NOT change their API (the upload
contract still matches their docs and was live-probed: GET /upload/server -> POST
{key}+file -> files[0].filecode); these are transient infrastructure failures.

A 5xx gateway error is not an account fault: every account hits the same gateway,
so failing over is pointless and blacklisting is harmful. Fail open instead — retry
the SAME account and, if byse stays down, fail the file cleanly without touching the
account or the rotation cursor.

- upload-manager: _isTransientNetworkError() now honors an explicit err.transientNetwork
  flag (checked before the empty-message guard) and, as a defensive fallback, matches
  /HTTP 5\d\d/, Bad Gateway, Service Unavailable, Gateway Time-out. The flag is made
  authoritative in _isFileRejectedError and _shouldSkipRetryOnAccountError (both return
  early when it is set) so a 5xx whose HTML body happens to contain a rejection/auth
  keyword can never be mis-binned as file/account. The post-rotation retry loop also
  breaks on a transient error (parity with the primary loop) to avoid burning the
  retry budget re-uploading on a fallback.
- hosters: the upload POST throw sites tag err.transientNetwork when statusCode >= 500
  (non-JSON body and non-2xx-with-JSON) and when the 2xx status-envelope carries
  status:500; 401/403/429 stay PLAIN so they remain account errors. The server-lookup
  path (apiGet/getUploadServer) is hardened symmetrically: a 5xx there tags
  transientNetwork and getUploadServer preserves the flag onto its wrapped error, so
  the heuristic shouldRetryServerLookup() can no longer be defeated by a 5xx body that
  contains an auth keyword.
- hosters: fixed a separate real bug surfaced during investigation — _fetchByseFileList
  built the wrong URL https://api.byse.sx/api/file/list (the /api/ prefix is correct
  only for doodstream's doodapi.co host; byse already carries the api. subdomain).
  Live-probed: /api/file/list 302-redirects to the docs page, /file/list returns 200
  JSON. The wrong path made the byse async-recovery poll silently dead (always []),
  removing the safety net that reclaims a large file that registered despite a 502.
  Now https://api.byse.sx/file/list.

ECONNRESET was already transient and is unchanged. doodstream's 2xx empty-form stays
hosterTransient (one attempt, no re-upload). vidmoly/clouddrop use their own uploaders
and are unaffected; doodstream/voe apiKey uploads share uploadFile and correctly
benefit from the same 5xx-is-infra logic.

Deferred follow-up: poll-first-on-5xx dedup (route a 5xx through the now-working
recovery poll before retrying, to reclaim a registered-but-502 file instead of
re-uploading). It does not add new dupe risk vs the prior cascade and a pure 502
means the backend was never reached, so retry-same-account is dupe-safe for it.

Investigated and reviewed by two multi-agent workflows (4-lens investigation with a
synthesized fix spec; 4-lens adversarial review with per-finding verification): no
API drift, 0 confirmed defects. Tests: classifier units (flag-above-message-guard,
5xx-transient, 4xx-stay-account), an end-to-end 502/503/500-envelope tag through
uploadFile, and a 502-retries-same-account-no-cascade integration case. Suite 311/311.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 01:56:11 +02:00
Administrator
7a04060e9b feat(rotation): per-hoster toggle to disable the "Bekanntes Größen-Limit" pre-skip
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>
2026-06-17 13:41:38 +02:00
Administrator
644ed712e0 fix(rotation): persist the round-robin cursor so drip-fed uploads keep rotating
v3.3.74 created a fresh account picker inside every buildUploadTasks /
buildUploadTasksFromJobs call, so its in-memory rotation index reset to 0
on each call. That is correct for a one-shot batch (drag-drop N files at
once distributes fine), but it silently no-ops in exactly the pattern the
byse 80-uploads/30-days quota cares about: the folder monitor feeds new
files into a *running* batch one detection at a time via add-jobs-to-batch
(renderer -> add-jobs-to-batch IPC), and per-detection autoStart likewise
fires start-upload per file. Each of those calls rebuilt the picker from
index 0, so account 1 won every single time and the secondary accounts
never received anything.

The rotation cursor now lives outside the picker and survives across calls
and across app restarts (the quota spans a rolling 30-day window, so a
session-only counter would still starve secondaries for users who restart
between small uploads):

- account-rotation.js: createAccountPicker() accepts a seed { indices } map,
  resumes the per-hoster cursor from it, and exposes indices() (the advanced
  cursors) + dirty() (whether any rotation actually happened this call). The
  cursor is a monotonic counter taken mod the current enabled-account count,
  so it keeps wrapping correctly even if an account is later enabled/disabled.
- config-store.js: new top-level rotationCursors map (added to DEFAULTS, read
  back in load() which otherwise reconstructs the result and would drop
  unknown keys) plus a saveRotationCursors() method. The existing
  read-modify-write save() preserves it across unrelated settings/credential
  saves; secret-store never touches it.
- main.js: a module-level _rotationCursors is the authoritative source of
  truth (seeded once from disk on first use, updated synchronously per batch),
  with config as restart-survival backing. makeAccountPicker() seeds the
  picker from it; persistRotation() folds the advance back and flushes to
  config only when dirty. Authoritative in-memory state also closes the
  disk-read race two rapid batches would otherwise hit. Both task builders
  now receive the picker instead of constructing their own.

Composition with failover is unchanged and verified: the picker only chooses
each file's *initial* account and is never called on the failover path. The
pre-job-swap reroutes a task only when that task's own account is in
_failedAccounts, so a dead account's jobs reroute while healthy accounts keep
their rotation share. No double-advance of the cursor.

Tests: account-rotation gains seeded-resume, drip-feed-across-pickers (the
regression), dirty()-semantics, carry-forward, and count-shrink-wrap cases;
config-store gains rotationCursors default, save round-trip, no-clobber, and
credentials-undisturbed cases. Full suite 303/303.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:33:20 +02:00
Administrator
3204629fef feat(rotation): opt-in per-hoster account rotation to keep all accounts active
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>
2026-06-17 03:20:50 +02:00
Administrator
87f976faf0 fix(rotation): soften the byse suspect size-memo so one blip cannot poison a batch
The per-batch size memo previously armed on a SINGLE confirmed suspect rejection
and blocked every same-or-larger file on that account (fileSize >= memoSize).
A single spurious byse "Not video file format" at e.g. 1.3GB therefore pre-failed
the whole series of ~1.3GB files in that batch with "Bekanntes Größen-Limit auf
diesem Account", even when byse could actually take that size.

Now the memo:
- arms only after the 2nd confirmed rejection on the same account (count >= 2),
  so a lone byse aussetzer no longer short-circuits anything; and
- blocks only STRICTLY LARGER files (fileSize > memoSize), so a same-size file
  always still gets one real attempt.

_suspectSizeMemo now stores { size: smallest-rejected, count } instead of a bare
number. Still per-batch (cleared at batch start, never re-primed across batches).
Updated the two memo tests to the new semantics (added a size-aware statSync mock
so a strictly-larger file can be exercised). Full suite 284 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:44:03 +02:00
Administrator
497317dc32 fix(rotation): suspect byse rejections now reach every account instead of dying on the first
Root cause of "4 accounts configured, rotation only ever uses 1-3": byse
returns HTTP 200 + per-file status "Not video file format" for valid MKVs
above an account's size tier (proven from the live rotation log: account
4ha0 accepted 1158 files up to 2.62 GB and deterministically rejected all
6 files >= 2.81 GB with that status, each costing a full ~20 min upload).
The parser tagged this fileRejected, which (a) skipped the 30s recovery
poll that was originally BUILT for this misleading status (guard regression
from "byse skips 30s recovery poll on explicit reject") and (b) made the
upload manager skip rotation entirely - so the remaining accounts were
never tried and the files failed forever. Account selection is
primary-first failover, so a later account only ever runs when every
earlier one is hard-failed or disabled, which matches the reported
"account 4 only gets used when I disable the others".

The fix, layered:
- parseByseResult flags "Not video file format" as err.suspectReject
  (alongside fileRejected). Genuine rejections (Duplicate, too small,
  account-level disk-full) behave exactly as before.
- uploadFile runs the byse recovery poll again for suspect rejections
  (the file often registers asynchronously despite the status), but skips
  it when the caller's file probe says the upload is genuinely not a video
  (new opts.probeIsVideoLike, threaded from the upload manager's probe).
  Poll helpers now swallow an abort during their sleep and return null so
  a cancelled poll surfaces the original error instead of a bare
  AbortError.
- upload-manager: on a suspect rejection of a probe-verified video, the
  file gets ONE attempt on each remaining pool account (new accountPools
  from main.js, refreshed on save-config) - WITHOUT blacklisting the
  rejecting account, which keeps working for smaller files. A per-batch
  size memo (hoster:account -> smallest rejected size) plus a known-good
  account preference prevent re-uploading every following oversized file
  through the whole chain: the second file skips known-limited accounts
  outright and goes straight to the account that took the first one.
  Alternates that fail with a genuine account error (quota/ban/full) are
  marked failed in-batch so no later file burns a multi-GB upload on them;
  deliberately no account-failed emit there, as repointing the hoster-wide
  override would reroute normal-sized files away from a healthy primary.
- Rotation hardening from the adversarial review: the rotated-to retry
  loop now fast-breaks on file-rejected/hoster-transient errors instead of
  burning maxAttempts full uploads, and a file-class error on a rotated
  account no longer blacklists that account (it fails only the file).
  Cancelling mid-alternates or mid-rotation now records the job as
  aborted instead of error (this also stops the batch webhook from firing
  on fully user-cancelled batches). New upload-failure rot-logs are
  guarded against logging user aborts as failures; rotation retries are
  now visible in the rotation log at all (previously failures on a
  rotated-to account were never logged, which is why account 2's death
  was invisible in the support log).
- file-probe: the mpeg-ts signature required only a single leading 0x47
  byte, so GIFs and "G"-prefixed text classified as video and would have
  qualified junk for pool-wide alternate uploads; it now requires TS
  sync-byte periodicity (0x47 at offsets 0/188/376) and the probe head
  read grew from 64 to 512 bytes to make that check possible. GIF gets
  its own non-video signature.

Tests: 9 new/reworked across suspect-reject-alternates.test.js (alternate
walk, no blacklist, memo short-circuit, account-error marking, abort
labeling, probe gating) and byse-reject-recovery.test.js (suspect polls +
recovers, empty poll throws suspectReject, Duplicate still fast-fails,
probe-confirmed non-video skips the poll) plus a TS/GIF probe regression
test. Full suite 276 green.
2026-06-15 01:11:37 +02:00
Administrator
05ad08433c feat(history): configurable retention + render cap so the Verlauf tab loads fast
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>
2026-06-15 00:26:20 +02:00
Administrator
6df00a0948 revert(network): remove network-outage auto-pause entirely — false positives froze batches 2026-06-10 16:52:37 +02:00
Administrator
0e5eaa89e6 fix(webhook): retry+429+status handling, await before shutdown, error-path notify, abort/auto-retry suppress, Discord limits 2026-06-10 00:08:02 +02:00
Administrator
e1d04d0838 feat(webhook): optional Discord ping (user-id / role / @here / @everyone) so batch-done actually notifies 2026-06-09 23:40:38 +02:00
Administrator
1d116ac4bf fix(ui+byse): live newest-on-top in files panel; byse skips 30s recovery poll on explicit reject 2026-06-09 21:55:34 +02:00
Administrator
34aaa36571 feat(unattended): network outage auto-pause/resume, post-batch auto-retry rounds, webhook notifications 2026-06-09 20:39:59 +02:00
Administrator
6cd7498f70 fix(critical): safeSend infinite recursion + queueMicrotask, plus 6 audit findings 2026-06-08 22:03:19 +02:00
Administrator
0f57aef7c7 fix(stability): wrap hot timers/callbacks in try/catch, safeSend, updater waits for batch 2026-06-08 21:28:12 +02:00
Administrator
9b10a4356f feat(diagnostics): full crash instrumentation — never silently die again 2026-06-08 21:18:54 +02:00
Administrator
d9199f8aaf fix(perf): chunked startBatch + async rotLog — kill remaining 30s freeze on 5k+ jobs 2026-06-08 01:29:31 +02:00
Administrator
d59c5c1df8 perf: per-batch baseline cache, async folder walk, history-table fast path, progress IPC batching 2026-06-07 21:11:04 +02:00
Administrator
125e5f55ea fix(perf): kill per-progress renderer-to-main IPC + drop redundant queued emit + cache fileSize 2026-06-07 20:59:07 +02:00
Administrator
d280765feb fix(perf): freeze on Start with 2000+ jobs — gate probe + rot-log behind semaphore 2026-06-07 20:40:55 +02:00
Administrator
cf35f4401d feat(ui): per-hoster success rate, session-paused badge, post-batch retry, link export formats 2026-06-07 20:32:35 +02:00
Administrator
5fb313273d feat(diagnostics): file-format probe + structured upload-start/failure rot-log 2026-06-07 18:49:54 +02:00
Administrator
f42c55c521 feat(diagnostics): log levels, support bundle export, verbose toggle, log paths panel 2026-06-07 16:34:51 +02:00
Administrator
ca35c2a6a4 fix(log): persist BARE log path (no compounded daily/session stamps)
User report: in session mode the upload-log lines split across two files — the
first few before the auto-persist fallback fired, the rest after into a path
with the session-stamp DOUBLED.

Root cause in main.js _persistFallbackLogPath:
1. The strip was gated on the legacy `sessionLog` boolean, which 3.3.35 retired
   in favour of `logMode`. So in session/daily mode the gate was false and the
   resolved path got persisted with its stamp intact.
2. Even when the gate triggered, its regex matched only the daily YYYY-MM-DD
   suffix, not the session "session-YYYY-MM-DD_HH-MM-SS-pid" suffix.

The next getLogFilePath() call read that saved path as the "base", treated the
already-stamped filename as the base name, and re-applied another stamp on top.
First flush hit the original session file; everything after hit a doubly-
stamped one — exactly the symptom (top file: 2 lines, bottom file: the rest).

- lib/log-mode.js: new pure stripModeStampFromFileName helper that removes both
  the daily and the session suffix patterns. Anchored to $, no nested
  quantifiers (linear).
- main.js: gate on logMode (not sessionLog) and call the helper for daily AND
  session, so logFilePath always persists as a bare base.
- Tests: 4 new — strip behaviour + an idempotence regression that locks in
  "resolve → strip → resolve = same path" so this can't silently come back. 204/200.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 22:08:15 +02:00
Administrator
d720ba295a feat(log): add per-session log mode (one file per app launch)
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>
2026-05-30 14:41:06 +02:00
Administrator
61853e7d4d fix(doodstream): force newest-first in file-list recovery (verified on 90k-file account)
Live diagnosis against the real account: it holds 90,548 files. The recovery
fetched only page 1 of /api/file/list without forcing order, so a just-uploaded
file could be missed if the default sort isn't newest-first. Add
sort=created&order=desc — verified to return the account's newest uploads first —
so the codeless-result recovery reliably finds the file regardless of account
size.

Diagnosis also confirmed the fix's premise: the API key is valid, /api/upload/server
returns a working node even on a lapsed-premium account, and uploads DO land
server-side (many Burn Notice episodes uploaded in the failure window are present).
So the API path returns the filecode directly and sidesteps the intermittent
web-form empty-result; entering the API key makes uploads succeed. 183/183.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:38:43 +02:00
Administrator
9ae5d312e1 fix(doodstream): web upload submits the live form's fields (not stale hardcoded)
Direct improvement to the login/web path (no API key needed): we were POSTing a
stale field set — sess_id + utype=reg + file — but doodstream's CURRENT upload
form dropped `utype` and added file_title / fakefilepc / submit_btn. Submitting
an incomplete/stale field set can make the CDN node accept the bytes but skip
the registration step (→ the empty result form with no fn). Now we parse the
live upload form (already fetched in _getUploadServer) and replicate ALL its
non-file fields faithfully — exactly what the browser submits — while keeping
sess_id (the fresh node token) and utype as a harmless compatibility extra.

- _parseUploadFormFields(html): pull every named input/button from the upload
  form, excluding the file input (streamed separately). Adapts to whatever
  fields doodstream uses now rather than hardcoding.
- upload() builds the multipart from those fields; minimal known-good fallback
  if the form wasn't parsed.
- Tests: real-form extraction (incl. file-input exclusion) + no-form safety. 183/183.

Low regression risk (superset of the previously-working fields). Whether it
resolves the large-file empty-form is for the server run; the API path
(3.3.31/32) remains the reliable route when a key is available/derivable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:28:24 +02:00
Administrator
d24fd54e83 test(doodstream): end-to-end integration test for the API upload + recovery path
Closes the gap between the unit-tested parseDoodstreamResult and the real
uploadFile orchestration. Mocks the undici transport (reassign undici.request +
refresh the hosters cache; mock.module needs an experimental flag npm test
doesn't pass) and global fetch, then drives the full doodstream API path against
the doc-verified response shapes:
- filecode returned directly in result[0].filecode → used.
- codeless 2xx → recovered by polling file/list and name-matching the title.
- codeless + file never appears → throws with err.hosterTransient=true (so the
  account is not blacklisted).

Verified live this session: doodapi.co returns {"status":400,"msg":"Invalid
key"} for a bad key, so validation/list logic keys off status correctly.

Also makes the recovery poll count/delay tunable via __test.DOODSTREAM_POLL
(same 12 × 2.5 s defaults — non-behavioral) so the exhaustion test runs in ms.
Full suite 181/181.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:20:21 +02:00
Administrator
fc48f20db5 fix(doodstream): recover codeless API uploads by polling the file list by name
Backstop for the API path: if the doodapi upload POST returns no filecode (the
same backend registration hiccup that empties the web form), poll
doodapi.co/api/file/list for a newly-appeared file whose normalized title
matches what we uploaded, and claim its code — instead of failing the upload.

This is the exact recovery byse already uses in this file for the identical
symptom (large MKV, server-side "OK" but empty immediate response, file shows up
in the account shortly after). Doodstream is the same XFileSharing family with
the same doodapi-style API, and it directly addresses the user's observation
that the same file often succeeds on a second run.

- _fetchDoodstreamFileList / _resolveDoodstreamUploadByName: list via
  /api/file/list?key=&per_page=200, baseline-diff + exact normalized-title match
  (never "take the only new one", so parallel uploads can't claim each other's
  files), 12 polls × 2.5s.
- uploadFile snapshots a doodstream baseline before upload and polls after a
  codeless result, before the hosterTransient throw.

Verified solo: doodapi.co is reachable and returns {"status":400,"msg":"Invalid
key"} for a bad key, so the validation/list path keys off status correctly.
178/178. The real large-file run on the server is the final confirmation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:11:46 +02:00
Administrator
84c48ad7d6 fix(doodstream): login path auto-derives the API key → uploads via reliable API
The user uploads with username/password (login), so 3.3.30's "use API when a
key is configured" did nothing for them — and the web-form upload keeps failing
with empty forms on large files. Fix the LOGIN path itself: after logging in,
pull the account's API key out of the logged-in session and upload via the
official doodapi API (which returns result[0].filecode directly, no empty form).
The user keeps using login and configures nothing.

How the key is derived without knowing doodstream's (cookie-gated, unseen)
settings DOM: brute-force candidate extraction + API validation.
- DoodstreamUploader.deriveApiKey(): fetch the logged-in settings page
  (?op=my_account / /settings), pull every plausible long token from form-field
  values + element contents (ranked: tokens near an "api" mention first), and
  validate each against doodapi.co/api/account/info — only the account's real
  key returns status 200. A wrong guess is therefore harmless (fails validation
  → web fallback). Logs the raw settings HTML when nothing validates, so the
  scrape can be refined from a real capture if doodstream's markup differs.
- upload-manager: doodstream login-path now resolves the key ONCE per batch
  (cached by accountId; '' = tried-none) and routes to the API when found, else
  the existing web-form upload. Keyless accounts: one extra probe-login per
  batch, then unchanged.
- Tests: candidate extraction (value/textarea/api_key shapes, api-context
  ranking), validate-then-pick, null→web-fallback, preset short-circuit. 178/178.

If derivation works the login path now uploads via the API. It does NOT change
doodstream's backend; the server run confirms. Falls back safely if no key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:05:20 +02:00
Administrator
76c56cf13b fix(doodstream): extend 3.3.29 account-poison protection to the API path
The new API upload path POSTs to the same cloudatacdn.com nodes as the web
path, so it can hit the same backend flake — a 2xx response with no filecode, or
a transient "no servers available" from /api/upload/server (now that the stale
fallback node is gone). hosters.uploadFile threw GENERIC errors for both, which
the upload-manager would treat as an account failure → mark-failed →
pre-job-swap-blocked on the next batch: the exact symptom 3.3.29 fixed for the
web path, reintroduced via the unprotected API path.

Tag both API-path analogs of the empty form as err.hosterTransient=true:
- codeless 2xx ("lieferte keine file_code-Antwort") — bytes accepted, no link.
- transient "no upload server" (shouldRetryServerLookup true: no-servers/busy/
  try-again) — but NOT genuine auth failures (invalid key/unauthorized), which
  stay classified as account errors.

The upload-manager checks _isHosterTransientError (flag-based) before the
account-error classifier, so both now fail the file without blacklisting the
account. Consumption side already covered by the 3.3.29 regression test. 173/173.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:48:13 +02:00
Administrator
a8d81cbf0d fix(doodstream): upload via the doodapi API when an API key exists
Root cause of the recurring "kein Filecode — Server gab leeren Link zurueck":
the web-session upload flow gets the filecode back inside an XFileSharing HTML
form, and on long/large uploads that form comes back empty (no fn). Verified
research: doodstream's server-side file-registration callback times out under
large-file load, so the upload "succeeds" (bytes sent, HTTP 200) but no filecode
is minted — and because registration failed, the file is NOT in the file list
either, so polling can't recover it. The web path also rides a per-page-load
sess_id token that ages over the multi-minute upload.

The official doodapi.co JSON API has no such failure mode for result retrieval:
the upload response returns result[0].filecode directly, and it authenticates
with a persistent api_key (no aging sess_id). Git history confirms the API was
doodstream's ORIGINAL upload path (initial commit); web login was added later
only "as an alternative to API key" — so preferring the key restores the
intended primary path rather than fighting a deliberate choice.

- lib/account-auth.js (new, pure, unit-tested): selectUploadAuth() prefers the
  doodstream API key over username/password; all other hosters unchanged.
- main.js buildTaskFromAccount delegates to it → a doodstream account with an
  apiKey now routes through hosters.uploadFile (doodapi API) instead of the web
  uploader; keyless accounts keep using web login.
- hosters.js: drop the stale hardcoded fallback node from the doodstream API
  config (same dead tr1128ve host removed from the web path) so a failed server
  lookup throws cleanly instead of uploading into a dead end.
- Tests: 8 routing cases (doodstream key-preference, keyless fallback, voe
  unaffected, authType=api, null-safety). Full suite 173/173.

This eliminates the empty-form failure mode for result retrieval when a key is
configured. It does NOT change doodstream's backend — whether the large-file
timeout recurs (now as a structured JSON error, not a silent empty form) is for
the server run to confirm. Requires a doodstream API key on the account.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:42:19 +02:00
Administrator
166b04c526 fix(upload): classify doodstream empty-form as hoster-transient (don't kill account)
The "kein Filecode — Server gab leeren Link zurueck" error was treated as a
generic upload failure → after retries exhausted, the manager called mark-failed
and added the account to _failedAccounts → next batch re-primed with
primedFailed=1 → pre-job-swap-blocked because no fallback override exists for a
single-account hoster. One server-side flake permanently poisoned the session.

It's not an account problem — same account + same file works on a later try.
This is a doodstream-backend processing flake (empty CDN form, no fn / no st),
the same class as a transient network error: don't blacklist, just fail this
file cleanly.

- doodstream-upload.js: tag the empty-form throw with err.hosterTransient=true
  (explicit flag, primary signal — matches the err.accountError / err.fileRejected
  pattern already used elsewhere).
- upload-manager.js: new _isHosterTransientError classifier (flag first, message
  regex as defensive fallback). In the retry loop: break on first hit (server
  flake won't clear in 3 s, re-uploading the file 4× is pure bandwidth waste).
  Post-loop: dedicated branch that emits the final error WITHOUT blacklisting
  the account — same shape as the existing transient-network branch.
- Tests: classifier unit tests (flag path, regex path, negatives) + regression
  test that proves the account is NOT added to _failedAccounts and mark-failed
  does NOT fire. Drops the hoster-transient test from ~19 s to ~1.5 ms,
  confirming the in-loop fast-break works.

We now fail fast on this error class instead of retrying — the next-batch
manual retry is the recovery path, and the account stays usable for it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:34:56 +02:00
Administrator
af51bebaf7 fix(queue): stop auto-dedup from deleting pending jobs on restart/update
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>
2026-05-25 01:08:59 +02:00
Administrator
f237d0f97a fix(doodstream): survive transient network blips around the upload
After 3.3.26 fixed the filecode parsing, the remaining intermittent failure is
a generic "fetch failed" — a transient network error on one of the requests
around the multi-minute upload. Can't tell from one log line whether it's the
server-discovery GET or the post-upload result-submit, so harden both:

- _fetch (the native-fetch chokepoint for discovery, redirects, result-submit):
  retry up to 3x with short backoff on a thrown network error, each attempt
  bounded by a 20s timeout (Node fetch has none by default). Caller aborts are
  not retried. The big file upload (undici) is retried at the upload-manager
  level, not here.
- result-submit is now best-effort: if it still fails after retries but we
  already hold the filecode from the CDN response, return that instead of
  discarding a completed upload.
- label the undici upload-POST error with phase + MB sent + node, preserving the
  original message so transient classification still matches.
- eslint: add AbortSignal to globals.
- Tests: _fetch transient-retry path (10 doodstream tests total).

"fetch failed" is already classified transient by upload-manager, so this is
additive resilience; next logs will show if anything still slips through.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 00:44:20 +02:00
Administrator
18a875a764 fix(doodstream): use current page format (form action + matching sess_id)
The 3.3.25 diagnostics captured the live upload page: doodstream moved the
upload server from a `srv_url` JS variable into the multipart form's action,
e.g. action="https://xxx.cloudatacdn.com/upload/01?SESSID", with a per-page
session token in the query that matches the page's hidden sess_id input. The
old parser found neither and fell through to the stale hardcoded node, which
returns an empty filecode.

- Parse the upload server from the form action (matched via the /upload/ path),
  un-escaping &amp; in the query string.
- Refresh this.sessId from the SAME page (only on action match) so the
  multipart sess_id field matches the node URL's token; login-time and node
  tokens otherwise diverge. Keep the existing sessId if the input is absent.
- Keep the legacy ?op=upload_server JSON and srv_url paths as fallbacks; the
  fail-fast throw from 3.3.25 stays as the last resort.
- Tests: form-action parse, sess_id refresh, &amp; un-escape (9 total).

Whether this fully resolves the uploads is for the next server logs to confirm;
both the node and sess_id fixes are individually correct.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 00:32:25 +02:00
Administrator
52751df735 fix(doodstream): fail fast instead of uploading into a dead hardcoded node
Real root cause from the 3.3.24 diagnostics: the failing upload used CDN
"tr1128ve.cloudatacdn.com/upload/01" — character-for-character the hardcoded
last-resort fallback in _getUploadServer(). The CDN form came back with only
op=upload_result and NO fn/NO st, i.e. the bytes went into a stale node that
returns an empty form. So _getUploadServer can no longer extract the current
upload server (Doodstream likely changed the upload_server response/format) and
silently fell back to a dead node — wasting ~90s/95MB per attempt.

- Remove the silent hardcoded-node fallback; throw a clear error when discovery
  fails so the upload fails instantly instead of 90s later with a cryptic msg.
- Embed the raw upload_server response (status, content-type, body) and
  upload-page URL hints in the error AND debug log, to pin the format change.
- Tests: getUploadServer JSON path, srv_url HTML fallback, and the no-silent-
  fallback throw (asserts the hardcoded node never leaks into the error).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 00:25:55 +02:00
Administrator
ce5f20b1e1 fix(doodstream): surface real upload-failure reason + fix dead prod debug log
The "upload_result Seite hat keinen filecode" error fired with no actionable
detail when Doodstream's CDN returned an empty filecode (fn). Root cause is
server-side: the page structure is unchanged, the link is just missing —
Doodstream's backend refused the file (copyright/hash match, duplicate, size,
quota). XFileSharing reports the reason in the `st` field, which we ignored.

- Surface `st`: non-OK status now throws "Doodstream lehnt Datei ab (Status: …)".
- Enrich the generic error with st, fn-state, and the CDN node for diagnosis.
- Fix debug-log path: wrote to __dirname/.. which is read-only (app.asar) in
  packaged builds, so production captured zero traces. Now uses Electron's
  writable userData dir, with repo-root fallback for tests/plain node.
- Add tests/doodstream-upload.test.js (4 tests) pinning the parse/error paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 19:00:52 +02:00
Administrator
57f8f0876e feat(log): per-hoster toggle for writing links to fileuploader.log
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.
2026-05-23 15:29:25 +02:00
Administrator
166a49dd0c test(coalesce): extract done-removal coalescer + 11 unit tests
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.
2026-04-28 11:59:32 +02:00
Administrator
b1fe0cfefb fix(log): auto-rotate the other 3 internal log files (debug, rot, doodstream)
3.3.2 fixed fileuploader.log unbounded growth, but three siblings kept
growing without limit:

- upload-debug.log     (verbose, every IPC + progress event log line)
- account-rotation.log (every rotation decision)
- doodstream-debug.log (per-hoster trace from lib/doodstream-upload.js)

A multi-month dev install or a heavy production user could fill the
log dir with multi-GB files and slow every appendFile.

Wire all three through the same lib/log-rotation.js helper:
- upload-debug.log     → 25 MB cap, 2 numbered backups (~75 MB worst)
- account-rotation.log → 10 MB cap, 2 numbered backups (~30 MB worst)
- doodstream-debug.log → 10 MB cap, 1 numbered backup  (~20 MB worst)

The rotation check runs once per flush call (each is debounced or
already a once-per-event path), so the statSync overhead is
microscopic. _flushDebugLog passes a noop logger to avoid recursing
into itself; _flushRotLog and _debugLog (doodstream) use the normal
debugLog so any rotation surprises end up in upload-debug.log.

126/126 tests still green.
2026-04-28 11:11:24 +02:00
Administrator
10ae46c44d fix(upload): re-check cancellation after _sleep in rotation while-loop
The account-rotation while-loop entered with a signal.aborted /
stopAfterActive check (line 681) but then awaited _sleep(800) on
line 690 (waiting for main.js to resolve the next fallback) without
re-checking on the way out. If the user cancelled during that 800 ms
window the loop kept going — resolved the override, set up new
credentials, fired retrying-event, started a fresh attempt loop —
before _executeUpload's own signal handling finally noticed the
abort. Cancellation latency could therefore stretch by an extra
attempt's worth of work per still-spinning hoster.

One-line fix: add the same `if (signal.aborted || this.stopAfterActive)
break` after the await. Found by deep-audit MED-5.

126/126 tests still green; the fix is a guard on an already-tested
flow, no test infrastructure exists for cancel-during-rotation
specifically (would need fake-timer + mocked override-resolution).
2026-04-28 10:39:09 +02:00
Administrator
0ba8bd3a2c fix(hosters): defensive null-payload guards in result parsers + 7 tests
When a hoster server replies with a body that JSON-parses to a
non-object (literal "null", a bare string, a number, a top-level
array), uploadFile's downstream code crashed:

  payload.msg          → TypeError on null
  payload.status       → TypeError on null
  config.parseResult() → TypeError inside parseDoodstreamResult
                         (payload.result) and parseByseResult
                         (payload.files / payload.result)

The user saw a confusing "Cannot read properties of null" instead of
a useful "server returned no JSON object". Found by deep-audit pass.

Fix in three places:

1. uploadFile (lib/hosters.js): after JSON.parse, normalise non-object
   payloads to {}. Subsequent `payload.X` accesses then return
   undefined and the existing fallback paths handle the empty case.

2. parseDoodstreamResult: defensive `payload && payload.result` so
   direct callers (tests, hypothetical future callers) get the same
   guarantee instead of relying on uploadFile to have normalised.

3. parseByseResult: same `payload || typeof payload !== 'object'`
   short-circuit at entry, plus null-checks on `f` (the first files
   entry) so a server returning [null] in files doesn't crash either.

Tests: 7 new unit tests covering null/undefined/string/number/array
payloads, malformed files entries, the fileRejected/accountError
classification (regression-pinning the 3.1.4 phrasing tweaks), and
the valid-filecode happy path. 126/126 green.
2026-04-28 10:12:32 +02:00
Administrator
a6958f1418 fix(persist): stop swallowing save errors + decouple .bak refresh from save
Two related fixes from the deep-audit pass:

HIGH-2: save-global-settings-sync used `try {} catch {}` and always
returned `event.returnValue = true`, so a disk-full / AV-lock /
permissions failure looked like success to the renderer's beforeunload
chain. The user closes the app, comes back, settings are gone —
without any indication. Now the catch sets returnValue=false and
debugLogs the error message, and the bak refresh is in its own
nested try so a transient lock there doesn't fail the whole save.

MED-4: lib/config-store.js _atomicWrite had the same TOCTOU on the
.bak refresh — fs.existsSync(...) then fs.readFileSync(...) without
guarding the read. Wrapped the read+write of the backup in its own
try/catch: a stale .bak is preferable to dropping the new write
entirely just because Windows Defender briefly locked the file mid-
operation. The rename of tmp → live still throws on real failure,
which is what the outer reject is for.

119/119 tests still green; both fixes are defensive guards on
already-tested write paths.
2026-04-28 09:40:08 +02:00
Administrator
cf34353036 test(sort): extract throttled-cache utility + 12 unit tests
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.
2026-04-28 07:12:52 +02:00
Administrator
f83fdabea3 test(queue): extract terminal-job prune into testable module + 10 tests
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.
2026-04-28 06:41:47 +02:00
Administrator
d9c3a00016 test(log): extract log-rotation into testable module + 10 unit tests
The fileuploader.log rotation introduced in 3.3.2 lived inline in
main.js — fine for the runtime path, but it required electron's `app`
to even reach the function under test. Pull the rotation logic into
lib/log-rotation.js (pure fs/path, no electron deps) and cover it
properly:

- ENOENT (file missing) → no-op
- Below cap → no-op
- Over cap → live → .1, returns true
- Existing backups shift up: .1 → .2, .2 → .3
- At maxBackups limit → oldest dropped, others shift, live becomes .1
- Idempotent: rotating twice keeps the chain consistent
- maxBackups=1: never grows past .1
- Invalid maxBytes (0/negative/NaN) → safe no-op
- Provided debug callback receives a "rotated" message
- File without extension still rotates correctly

main.js now imports `maybeRotateLogFile` and calls it directly. 97/97
tests pass.
2026-04-28 05:10:53 +02:00
Administrator
3553666d9d perf(rotation): rotate after 1 fail on generic errors, not after 5
Before: a non-transient / non-file-rejected / non-account-specific
error (e.g. "VOE Upload: <any generic message>") would burn the full
retries-per-account budget on the primary before the rotation logic
even kicked in. On retries=5 that's "Retry 2/5 Primär", "Retry 3/5
Primär", … all on the same broken account before the fallback gets
a shot.

Now:
- main.js pre-resolves the next fallback for every hoster at batch-
  start (stored in _accountOverrides via the existing session cache +
  primeOverrides path). Pre-job-swap still ignores it until the
  primary is actually marked failed, so jobs still begin on primary.
- upload-manager.js: in the retry loop's generic error branch,
  _hasPendingOverride() checks whether a usable fallback is ready.
  If yes and the error is NOT transient (transient = network glitch
  = retry same acc), break out to rotation. Marks primary failed,
  rotates to acc2, retries there.
- Result: for a 2-account hoster, worst case is 1 attempt on primary
  + retries-per-account on fallback, instead of N × 2. Transient
  network errors (ENOTFOUND / ECONNRESET / socket hang up) keep the
  old "retry same account" semantics because the network is the
  issue, not the account.
- Single-account hosters: unchanged. No pending override = classic
  retry-on-same-account until exhausted.

3 new tests pin: generic + override → rotate on attempt 1; transient
+ override → stay on same acc; no override → classic retry. 87/87
green.
2026-04-22 18:23:30 +02:00