Commit Graph

527 Commits

Author SHA1 Message Date
Administrator
0809c75d50 fix(remote-server): cap WS maxPayload (256 KiB) + guard sendToClient — close a pre-auth parse freeze-sink and a send-throw crash
Two isolated hardenings of the opt-in remote/diagnostics WS server, surfaced by
the session-wide diagnostics audit:

1. WebSocketServer was created with no maxPayload, so ws defaults to 100 MiB per
   message. The connection handler runs JSON.parse(raw) on the FIRST message
   (the auth frame) before authentication, so any peer past the IP allowlist
   could send a huge payload and force a synchronous multi-MB JSON.parse on the
   main-process event loop — an unbounded freeze/DoS sink. Diag, auth and WebRTC
   signaling messages are all small; cap maxPayload at 256 KiB to close it.

2. sendToClient did ws.send(JSON.stringify(data)) with no readyState/try guard
   (unlike broadcast, which checks ws.readyState === 1). A send on a closing
   socket, or a stringify throw, escaped the diag-response callback as an
   uncaughtException — a potential crash. Mirror broadcast: send only when
   readyState === 1, wrapped in try/catch.

Both are isolated to the transport layer with zero redaction surface. The audit's
larger finding — server_health doing O(historySize) synchronous work per request
(6-7 full-config clones + unbounded history walks) — is a real freeze, but ONLY on
the cold opt-in diagnostics path with a large history (this user: 23 rows), and the
safe fix cuts into the credential-redaction collectors (which have leaked twice);
deferred and documented in tasks/todo.md rather than operated under risk.

397/397 tests pass, eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 03:06:08 +02:00
Administrator
6dcc98f52d release: v3.3.88 2026-06-21 02:30:40 +02:00
Administrator
15a4509ad5 docs(todo,lessons): session-wide lag audit — clean bill of health, doodstream gate shipped, config-store findings deferred
Records the 18-agent line-by-line audit of every line written this session: the
reported lag was v3.3.87 (recent-panel cliff); the audit found no second cause
affecting this user. doodstream _debugLog sync-fs gated (shipped). config-store
load()/serialize history-scaling costs are real but sub-ms at this user's scale
and the fix is risky persistence surgery — deferred, documented with measurements.

Lesson: "audit every line" = look + measure + risk-appropriate decision, NOT
fix-everything; the load() perf win and its corruption risk are the same coin
(shared batch refs), so there is no safe version — defer, don't ship.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 02:29:59 +02:00
Administrator
233952af8f perf(doodstream): gate per-upload _debugLog behind logVerbose (default off) — drop ~8-15 sync fs syscalls/upload off the main loop
doodstream-upload.js's _debugLog ran with NO verbose gate: every call did a
synchronous statSync (via maybeRotateLogFile) + appendFileSync directly on the
main-process event loop, ~8-15x per upload (server probe, response, redirect,
result page, filecode parse, hidden fields, submit/follow, plus retry branches).
DoodstreamUploader runs in the main process, so each pair of sync fs syscalls
blocked the event loop while uploading — delaying IPC, upload-progress-batch
forwarding, tray-tooltip and webhook handling for every other concurrent upload.
A constant per-upload main-thread tax (not history-scaling), surfaced by the
session-wide lag audit and confirmed as the one finding that bites in the real
upload scenario.

Fix: gate _debugLog behind the existing globalSettings.logVerbose setting
(default false), exactly mirroring main.js logDebug/_logVerbose. A module-level
_debugVerbose flag + setDebugVerbose() setter, an early-return at the top of
_debugLog, and one wire at main.js's single setLogVerbose chokepoint (covers
boot + save-config + the verbose toggle). When verbose is off the doodstream
trace simply isn't written — same contract as the main debug.log — and the
per-upload sync fs disappears. When a doodstream issue needs tracing, enabling
verbose restores the full trace.

The config-store audit findings (load() history-clone cost, per-write history
serialize) are real but scale only with history size — tens of microseconds at
this user's 8-batch config, and the safe fix is risky persistence surgery on
credential-bearing code for a latent micro-cost; deferred and documented in
tasks/todo.md rather than shipped.

397/397 tests pass, eslint clean, gate wiring verified (shared module instance,
default-off, toggles).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 02:29:59 +02:00
Administrator
c37c8e3906 release: v3.3.87 2026-06-21 01:49:23 +02:00
Administrator
9cc8fee02c docs(todo,lessons): long-run lag root cause was the recent-panel append-gate cliff (measured 80ms->7ms)
Records the verified root cause + the dismissed-with-evidence findings:
- _sessionFileKeys "separator mismatch" = FALSE POSITIVE (real U+0001 chars, verifier Read rendered them invisibly)
- queueJobs O(N) per-render scan = real but Blink-measured <0.1ms at 3000 jobs -> skipped
- standing 2000-row relayout = median 0.4ms -> no virtualization needed
- doodstream sync _debugLog = constant freeze, deferred to a separate change

Lesson: measure magnitudes at the real artifact before fixing a "scales-with-X" cause;
profile in real Blink (Playwright) not jsdom; verify multi-agent findings against primary
evidence (char-code dump caught the control-char false positive).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 01:47:59 +02:00
Administrator
0f9096be3c perf(renderer): keep recent-uploads panel append-only past the cap (kill ~80ms per-completion freeze)
The recent-uploads panel had a cheap append-only fast path, but it was gated on
`rows.length > _recentLastRenderedLen`. maybeAddSessionFile caps sessionFilesData
by push-then-slice (2000 -> 2001 -> sliced back to 2000), so once a session
produces more than SESSION_FILES_CAP rows the length is pinned at the cap and the
gate is false forever. Every subsequent completion then fell through to the full
`tbody.innerHTML = rows.map(...).join('')` rebuild of all ~2000 rows.

The cap is per (link x file x hoster), so with 4-5 selected hosters the 2000 cap
is hit at only ~400-500 distinct files — very reachable in a long folder-monitor
session. Profiled in Chromium (same Blink engine as Electron, table-layout:fixed):
the full 2000-row rebuild costs ~80ms and ran on EVERY completion past the cap — a
repeating ~80ms main-thread freeze. That is the "fine on a fresh start, gets laggy
after many uploads while CPU (~40%) and RAM (~6GB, stable) stay normal" symptom: a
render-thread stall, not CPU saturation or a memory leak.

Fix: track newly-pushed rows in _recentPendingAppends (incremented in
maybeAddSessionFile, consumed every render) and gate the fast path on
`pendingAppends > 0` instead of length growth, so it survives the cap. Prepend the
new rows, then evict the same overflow count from the DOM bottom (oldest rows,
which is where the date-desc view places the front-of-array entries the cap slices
off). DOM work is O(added) again. The fast path is gated behind an explicit
`appendOnly` flag passed only by scheduleRecentRender's rAF, so selection / delete
/ clear / sort / batch-done renders stay full rebuilds and cannot wrong-evict or
double-prepend.

Verified in Blink over a simulated 5000-completion session (3/frame, far past the
cap): per-frame render 80ms -> median 7.4ms (>10x), and the DOM stays exactly equal
to the data (cap held at 2000, newest-on-top, oldest evicted, zero duplicates).
397/397 tests pass, eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 01:47:59 +02:00
Administrator
29d1944328 perf(config): cache parsed config + lean serialize + drop load() from log flush (long-run lag)
A 13-agent hunt pinned the "wird mit der Zeit laggy" symptom (CPU/RAM normal, UI
sluggish after many uploads) to the main process re-doing config I/O that scales
with the ever-growing history, stalling the synchronous main event loop so the
renderer's IPC round-trips feel laggy. The renderer render path was already
optimized (virtualized queue, capped panels) — confirmed clean.

This commit lands the two contained fixes (T1 + T3); the queue-persist rewrite (T2)
follows separately.

config-store (T1):
- load() now has an in-memory cache keyed on the file mtime+size. The processed
  config (merged + credential-decrypted) is re-read/re-parsed/re-DPAPI-decrypted
  ONLY when the file actually changes; our writes invalidate it, external edits
  change mtime/size so the cache misses. Eliminates a full disk read + JSON.parse of
  the whole growing history + per-credential decrypt on the vast majority of the ~38
  load() call sites. load() always returns a structuredClone so callers can mutate
  freely without corrupting the cache.
- _serializeForDisk clones ONLY the hosters subtree (the only thing encryptCredentials
  touches) instead of JSON.parse(JSON.stringify(whole config)) — no more deep-cloning
  an 8 MB history on every write.
- _atomicWrite refreshes the .bak with a raw fs.copyFileSync instead of
  read + JSON.parse + write (it was re-parsing the full config a 2nd time per write).

main.js (T3):
- The log-flush paths resolved the log file via configStore.load() ~8x/second during
  uploads (re-reading + cloning the whole config just to read logMode/logFilePath).
  Cache those two strings in module scope, invalidate on the settings-save handlers.

Verified: 26 config-store tests (incl. new cache-correctness: independent clones,
external-change invalidation, save invalidation) + full 394-test suite green, lint 0
errors. Benchmark (8000-batch / 4.6 MB history): log flush no longer calls load() at
all; the remaining per-write history serialize is what T2 removes from the hot path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 00:49:55 +02:00
Administrator
939d30abfe docs(lessons): 'do it like <other project>' = find + map it exactly, fail-closed allowlist model (v3.3.86)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:43:07 +02:00
Administrator
3f8854693a release: v3.3.86 2026-06-19 19:39:15 +02:00
Administrator
b25b51840d test(diagnostics): live network-bind path — allowlisted non-loopback peer connects over a real 0.0.0.0 socket
Closes the one link the unit/wiring tests covered only by composition: binds 0.0.0.0,
allowlists a real LAN IPv4, and asserts auth-ok over a real socket (the Tailscale path).
Skips when no non-internal IPv4 interface exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:38:25 +02:00
Administrator
8dff455062 docs(todo): Tailscale network-bind + fail-closed allowlist plan + review
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:35:38 +02:00
Administrator
0c6c502aab feat(diagnostics): network bind + fail-closed IP allowlist + host-in-code (Tailscale, like rd-diagnostics-mcp)
Matches the Real-Debrid-Downloader's rd-diagnostics-mcp model so the read-only
diagnostics agent is reachable over Tailscale (or any private tunnel) the same way
the downloader is, instead of requiring an SSH local-forward.

- lib/ip-allowlist.js (NEW): fail-closed IP allowlist — normalizeIp strips
  ::ffff:, loopback is always allowed, an empty allowlist accepts loopback ONLY
  (fail-closed), exact IP + CIDR (incl. the Tailscale CGNAT range 100.64.0.0/10) +
  wildcard rules. The real socket peer IP is the authority (never a forwarded header).
- remote-server.js: rejects non-allowlisted peers at connection (close 4005). Opt-in
  via config.allowlist (the existing remote-control server, which passes none, is
  unaffected). Loopback always passes, so local + SSH-forward use keeps working.
- Two bind modes (config diagnostics.bindMode): "local" -> 127.0.0.1 (default),
  "network" -> 0.0.0.0 but ONLY when a non-empty allowlist is set (else it stays
  loopback, fail-closed). The allowlist + token gate access; the tunnel
  (Tailscale/WireGuard) is the confidentiality layer (transport is still plaintext ws://).
- The connection code now carries the host: mhu1_<base64url{v,h,p,t,n,fp?,s?}>. The
  gateway decode is tolerant of the legacy {port,token,label} keys; connect_server
  takes the host from the code (host arg is an optional override). Proven end-to-end:
  the integration harness now connects with NO host arg and resolves it from the code.
- Renderer: Sichtbarkeit selector (local/network), public-host input with
  suggested-host chips (os.networkInterfaces — the Tailscale IP shows up there),
  allowlist textarea (network mode), and network-requires-allowlist validation.
- main.js: bindMode->host, getSuggestedRemoteHosts, host-in-code, allowlist plumbed
  into startDiagnosticAgent + the diagnostics IPC (get/save/status).
- docs: rewritten for Tailscale (set the allowlist to your tailnet, put the Tailscale
  IP/MagicDNS in the code address — no SSH forward needed).

This supersedes the v3.3.85 hard loopback-lock with the downloader's allowlist model.
Tests: lib/ip-allowlist (8) + remote-server allowlist wiring/loopback (2) + gateway
decode (host short-key + legacy tolerance). 393 app tests + 9 gateway tests + e2e +
host-in-code integration + adversarial all green; lint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:32:29 +02:00
Administrator
0ee874ba99 docs(lessons): a diagnostic tool must never freeze the process it diagnoses (v3.3.85 hardening)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:46:07 +02:00
Administrator
70e7f2a9fd release: v3.3.85 2026-06-19 18:45:22 +02:00
Administrator
cfd5ca07ec chore(gateway/tooling): verify harnesses, Windows token ACL, ESM lint, honest transport docs
Repo-side hardening and tooling from the intensive test round (none of this ships
in the app installer).

- gateway: registry.json (holds bearer tokens) now gets a best-effort owner-only
  NTFS ACL on Windows via `icacls /inheritance:r /grant:r <user>:F` (the chmod
  0600 is a no-op on NTFS); verified the file ends up <user>:(F) only.
- gateway: connect_server now reads the app version from the real get_system_info
  shape (data.app.version / data.agent.version), so "connected to vX.Y.Z" works.
- gateway: read_log tool description documents grep as a case-insensitive substring
  filter with "|" alternation (not a regex), matching the agent-side change.
- gateway: standalone verification harnesses moved to gateway/verify/ (so
  `node --test` only sweeps real unit tests) and exposed via `npm run verify`:
  e2e-verify, integration-mcp (live gateway-MCP <-> agent, all 14 tools), and
  adversarial-probe (redaction fuzz + ReDoS + lockout). `npm test` runs the units.
- eslint: gateway/** now lints as ESM (sourceType module) via a dedicated block;
  global ignores fixed so `eslint .` is clean across the whole project (0 errors).
- docs/remote-diagnostics-setup.md: made the transport story honest — the agent
  speaks plaintext ws:// over enforced loopback; the SSH/WireGuard tunnel is the
  ONLY confidentiality layer (wss/TLS + cert-pin is a documented future mode, not
  active). Removed the stale "bind to a LAN/VPN IP" guidance (loopback is enforced).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:41:21 +02:00
Administrator
8d757a99dd fix(diagnostics): harden read-only agent — grep ReDoS, prototype-chain whitelist bypass, redaction gaps
Intensive end-to-end testing (a live gateway-MCP <-> agent integration harness +
an adversarial redaction/abuse probe + an independent security audit) surfaced
three real issues in the shipped read-only diagnostic agent. All run in lib/**,
which is packaged in the app.

1. grep ReDoS froze the Electron main process. read_log compiled the
   client-supplied grep into `new RegExp(grep, 'i')` and ran it synchronously over
   the log tail IN the main process. A catastrophic pattern (e.g. "(a+)+$" against
   a long line) hangs the whole app — empirically confirmed (8s timeout, killed).
   JS regex is synchronous and uncancellable, so grep is now a case-insensitive
   literal substring filter with "|" alternation ("error|timeout|502"). Provably
   linear-time; covers the real diagnostic need.

2. Prototype-chain whitelist bypass. The op table was a plain object literal, so
   handle("constructor" | "toString" | "valueOf", ...) resolved an inherited
   Object.prototype function, passed the `typeof fn === 'function'` guard and
   returned {ok:true}. Harmless functions today, but a whitelist-integrity hole.
   Now guarded with a string check + Object.prototype.hasOwnProperty.

3. Redaction defense-in-depth gaps. redactLogText now also scrubs: basic-auth URL
   passwords (scheme://user:pass@host), Authorization: Basic, JWTs (eyJ...x.y.z),
   and bare/JSON session= values. Mostly theoretical in today's readable logs
   (secret-bearing bodies go to the excluded doodstream-debug.log; other hosters
   throw static strings) but matters as the verbose-logging surface grows.

Verified: 383 app tests (incl. new regression tests for all three), the live
gateway-MCP integration harness (all 14 tools, zero leaks, error paths), the
adversarial probe (14/14+ secret shapes scrubbed, ReDoS 1ms, lockout, malformed
args), e2e gate, lint 0 errors. Only residual: a standalone high-entropy blob with
zero key/Bearer/URL context — inherent to any denylist, acknowledged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:40:57 +02:00
Administrator
602e48cb01 fix(gateway): write the token registry with owner-only permissions (0600)
registry.json holds each server's bearer token (the connection secret). It was
written with the process default umask, leaving it group/world-readable on POSIX
multi-user hosts. Write with mode 0o600 and chmod the existing file (writeFile
only applies mode on creation). No-op on Windows (NTFS uses ACLs, and the file
already sits under the user profile and is gitignored), effective on Linux/macOS
where the gateway may run.

Gateway-only change — not part of the app installer or auto-updater, so no
version bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:57:39 +02:00
Administrator
20b53f9587 docs(lessons): security-E2E must drive every collector default path, not just the aggregate hub
Captures the two redaction leaks from the remote-diagnostics build: a hoster-
returned opaque token surviving because value-scrub only covers stored config
secrets, and the get_config_redacted/get_queue_state default paths skipping
pattern-scrub entirely. The discriminator the first E2E missed, plus the rule:
exercise each collector with its DEFAULT args and seed fixtures with a NON-config
secret so you test pattern-scrub, not value-scrub by accident.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:49:14 +02:00
Administrator
b423357a2c release: v3.3.84 2026-06-19 17:48:08 +02:00
Administrator
7b5420eeaa fix(diagnostics): deep-redact queue jobs + rotation state — one collector still leaked
get_queue_state with includeJobs:true (the DEFAULT path) scrubbed the job list
with value-scrub only, so an opaque token a hoster returns inside a job error
(token=... that is NOT one of the user's stored credentials) survived in the
response. Same leak class already fixed for get_config_redacted and
server_health, still open on the queue collector's default path.

The first end-to-end gate missed it on two coincidences: server_health calls
getQueueState with includeJobs:false (no job error ever serialized), and the
fixture's queue error used a value that WAS a config secret (so value-scrub
caught it anyway). The direct get_queue_state{includeJobs:true} path with a
non-config token was never exercised.

- getQueueState job list and getRotationState now go through _deepRedact
  (per-leaf pattern + value scrub), matching the other collectors.
- e2e-verify.mjs now plants a non-config token in a queue job error and asserts
  both get_queue_state{includeJobs:true} and the default-args call leak nothing.
- Added a main-suite regression test for the default includeJobs path.

386 app tests + 9 gateway tests + e2e gate pass; lint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:44:59 +02:00
Administrator
d69e5c39bf feat(diagnostics): MCP gateway + harden redaction so no secret ever leaves the box
Adds the connect-by-code side of remote diagnostics and closes two real
secret-leak vectors that an end-to-end gateway<->agent test surfaced.

Gateway (gateway/, local stdio MCP, Claude connects once):
- 14 read-only tools (server_health hub, read_log, list_logs, list_errors,
  get_queue_state, get_history, get_config_redacted, get_system_info,
  get_rotation_state, get_app_events + connect/disconnect/list/current).
- The HOST is always supplied by the operator, never taken from the code.
- TLS fingerprint pinning is enforced in the socket 'open' handler BEFORE the
  token is sent (wss opt-in); plain ws is loopback-only.
- registry.json (holds bearer tokens) is gitignored; only an empty example ships.

Security hardening (gates every off-box payload):
- redactLogText now scrubs opaque bearer/token-family secrets that are NOT
  stored config credentials (e.g. a session token a hoster returns inside an
  error string): bare token/auth_token/refresh_token/session_token + standalone
  "Bearer <opaque>". Benign "token bucket" prose is left intact.
- get_config_redacted deep-redacts every string leaf (JSON-safe, per-leaf, so
  the cookie/sess line patterns can't gobble across a compact-JSON field) and
  drops the history subtree (served by get_history with its own per-error
  redaction). This plugs leaks via globalSettings.pendingQueue[].error etc.

Bind-address safety:
- _safeDiagBindAddress() forces the diagnostic agent to 127.0.0.1/::1; the
  0.0.0.0 UI option is removed. Direct LAN/Internet bind stays disabled until
  encrypted transport (wss) exists — remote access goes through an SSH/VPN
  tunnel to loopback. (Never plaintext ws:// on all interfaces.)

Tests: end-to-end gateway<->agent gate (connect -> server_health/read_log/
get_config_redacted, asserts zero secret leakage, rejects doodstream log, path
traversal and write ops); + redaction regression tests in the main suite.
385 app tests + 9 gateway tests pass; lint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:39:31 +02:00
Administrator
ab7313f32c feat(diagnostics): read-only remote diagnostics agent over the existing WS transport (app side)
Adds the server/app half of a remote-diagnostics system so Claude (via a local
MCP gateway, added separately) can read a server's full state to diagnose
problems: logs, errors, queue/app state, redacted config, system health. All
read-only; security review folded in as hard requirements.

Architecture (design-verified):
- A SECOND, independent RemoteServer instance (diagnosticMode) on its own port
  (default 9110, bind 127.0.0.1) with NO capture/input callbacks, so screen
  capture and sendInputEvent are structurally unreachable from the diagnostic
  path. The existing screen-share server (port 9100) is byte-for-byte unchanged.
- lib/remote-server.js: a `host` bind option, a post-auth `diag-request` branch
  that delegates to onDiagnosticRequest and replies with a reqId-correlated
  `diag-response`, a `diagnosticMode` guard (capture never spawns), a
  timing-safe token compare (length-guarded), and getLastAccess().
- lib/diagnostics-agent.js: handle(op,args) enforces a HARDCODED read-only op
  whitelist as the sole authority — no write/exec op, no run_health_check.
- lib/diagnostics-collectors.js: pure, dependency-injected collectors
  (server_health one-shot hub, read_log, list_errors, get_queue_state,
  get_history, get_config_redacted, get_system_info, list_logs, get_app_events,
  get_rotation_state, get_health) reusing support-bundle + stats.

Security (all mandatory, implemented):
- Redaction gates every off-box payload. support-bundle now adds webhookUrl +
  diagToken to CRED_KEYS, and exports redactLogText (value-scrub of live secret
  strings + pattern-scrub of Discord webhooks / Bearer / api_key= / cookies /
  sess ids) + valueScrub + collectSecretValues. read_log takes a logical NAME
  (no path traversal); doodstream-debug.log is excluded from the readable set
  (it logs live api-key-bearing HTML). grep is length-capped (ReDoS guard).
- diagnostics config subtree (enabled/port/token/label/codeIssuedAt/bindAddress)
  defaults OFF, bind 127.0.0.1; deep-merge makes it migration-free. The
  server-owned token is preserved against renderer clobber in both
  save-global-settings handlers; the diagnostics:* IPC is the only mutator.
- app.requestSingleInstanceLock() (also the approved fix #6) so a relaunch can't
  EADDRINUSE-kill the agent and two instances can't clobber the config.

main.js: buildDiagnosticCode (mhu1_ base64url, no host embedded), start/stop +
auto-start-on-launch + stop-on-quit, the four diagnostics:* IPC handlers.
renderer: a "Diagnose-Zugriff" settings subtab (toggle, port, bind-address,
copyable connection code + regenerate, status, read-only security note).

382 tests pass (incl. 11 new: collectors+redaction, agent whitelist, and a live
RemoteServer protocol test proving auth->diag-response correlation, that a
diagnostic client never triggers the capture window, and brute-force lockout).
Lint clean (0 errors). Smoke boots identically to baseline. The standalone MCP
gateway package + operator setup docs land in a follow-up commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:24:32 +02:00
Administrator
26f34b4966 docs(lessons): re-add-path exhaustiveness, per-cell delete suppression, and "open flag != consent" (v3.3.83 hunt)
Three lessons from the second adversarial bug hunt:
- A dedup/skip key has more re-add paths than "in-place vs rebuild" suggests; enumerate every .add site and every selectedFiles re-add, cross-tabulate which clears the key. retrySelectedJobs (retry-of-done) and the folder-monitor branch were the two missed paths.
- Per-cell (file|hoster) deletion needs its own persisted suppression set with the OPPOSITE lifecycle to the completed-key (set on delete, cleared on re-add); overloading the completed-key would break re-upload-after-delete.
- Re-confirming a user-facing change is "real" is not the user consenting to the behavior change; force the decision via one AskUserQuestion or park it, do not re-raise it as ambient worry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:29:16 +02:00
Administrator
7a025be645 release: v3.3.83 2026-06-19 16:22:29 +02:00
Administrator
b1ad04d9c2 fix(queue): close three more ghost / lost-work holes found by a second adversarial hunt
A 36-agent adversarial sweep over the queue-persistence context surfaced three
NEW, reachable defects (all renderer-only) that the v3.3.80-82 work missed:

#1 retrySelectedJobs (the reuploadBtn / "erneut hochladen" path) is a THIRD
   re-add path that never cleared the completed-dedup guard. _completedUploadKeys
   gains file|hoster when a job reaches done; retry of a done row reset the job to
   pending and re-added its path to selectedFiles but kept the key. On a restart
   before the re-upload re-completes, restoreQueueStateFromConfig re-seeds the key
   and buildQueuePreview then refuses to recreate the row, so the deliberate
   re-upload silently vanished (lost work). Retry now clears the exact file|hoster
   keys it re-activates (per key, NOT per path, so a sibling hosters completed
   ghost is not resurrected).

#7 The folder-monitor pre-selected-hosters branch re-adds a path and calls
   buildQueuePreview WITHOUT going through applyHosterSelection, so the v3.3.82
   key-clear never ran. With removeFromQueueOnDone ON, a re-encoded / re-dropped
   file whose row was auto-removed was suppressed forever (silent no-upload that
   even survived a restart). The branch now clears the dedup keys for the freshly
   re-added paths, matching the manual modal flow.

#4 Deleting one hosters row of a multi-hoster file was silently undone. A
   manually deleted non-completed file|hoster pair is recreated by buildQueuePreview
   the next time it runs (adding another file, or a folder-monitor drop), because
   the recreate guard only consulted _completedUploadKeys, never the per-cell
   deletion. Result: the file got uploaded to a hoster the user explicitly
   removed, burning quota and publishing an unwanted link. Introduces
   _suppressedPreviewKeys: a manual delete of a non-done job whose file stays
   selected (pinned by a sibling job) suppresses that exact file|hoster from
   re-creation; the suppression is persisted in pendingQueue.suppressedKeys and
   re-seeded on restore (mirroring completedKeys), and is cleared whenever the
   user deliberately re-adds the file (modal or folder monitor) so a real re-add
   still works.

The clear-on-re-add logic for both sets is unified into clearDedupKeysForPaths()
and reused by applyHosterSelection and the folder-monitor branch.

Held for a separate decision/round (surfaced to the user): single-instance lock,
the sync-close write-sequence guard (its ghost symptom is masked by the existing
microtask-ordered removal + restore-time ts-gate; its real residual is rotation/
history integrity), and the upload-log timezone/same-basename edges (need a log
format change). 362 tests pass, lint clean, smoke boots identically to baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:19:47 +02:00
Administrator
1b0be5f817 docs(lessons): verify every re-queue path before changing a preview-gating dedup key
Captures the v3.3.82 near-miss the advisor caught: persisting _completedUploadKeys
risked silently no-op'ing a re-upload IF any retry path rebuilt jobs via
buildQueuePreview. Both retry paths (retrySelectedJobs, _retryFailedFromBuckets)
mutate jobs in place and are immune; only fresh modal re-adds go through the
preview and clear the key. Rule: read every 'erneut/retry/reupload' handler
before touching a skip-guard, decide mutate-vs-rebuild per handler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:57:24 +02:00
Administrator
2efbc355b3 release: v3.3.82 2026-06-19 14:53:48 +02:00
Administrator
0a607adb29 fix(queue): stop finished uploads from re-appearing as pending ghosts across restart
The completed-upload dedup guard (_completedUploadKeys, "file|hoster") is the
single source of truth that keeps a finished job from being re-materialised as a
"Bereit" preview by buildQueuePreview(). Three independent paths leaked ghosts
back into the queue:

A) removeJobFromIndex() unconditionally DELETED the dedup key. The
   removeFromQueueOnDone auto-remove path (handleProgress), the batch-done sweep,
   and the terminal-job prune all call removeJobFromIndex on a *finished* job, so
   the guard handleProgress had just added was immediately wiped and
   buildQueuePreview re-created the job as a preview both mid-session and after
   restart. removeJobFromIndex now takes keepCompletedKey; the three auto-removal
   call sites pass true (keep the guard), while the manual-delete sites keep the
   old behaviour (drop the guard so the user can re-add the file).

D/E) The dedup guard lived only in memory, so every restart began with an empty
   set and buildQueuePreview rebuilt ghosts from the persisted selectedFiles.
   buildPersistedQueueState() now serialises the keys whose file is still in the
   snapshot (completedKeys) and restoreQueueStateFromConfig() re-seeds them. To
   keep deliberate re-uploads working, applyHosterSelection() clears the
   persisted key for every freshly (re-)added file: re-adding through the hoster
   modal is the explicit "upload this again" signal. Both retry paths
   (retrySelectedJobs, _retryFailedFromBuckets) mutate the existing job in place
   instead of relying on buildQueuePreview, so they are unaffected.

H) buildQueuePreview() excluded error jobs from its existingKeys set, so a
   file+hoster that already had an 'error' row got a second 'preview' row stacked
   beside it. Error jobs now count as existing.

B/C) parseUploadLogLine() took the filename from a fixed field index and trimmed
   it. A pipe inside the link shifted the field (entry lost from the log-based
   dedup) and trimming broke matching against the untrimmed OS basename used as
   the queue-job key. The filename is now the last non-empty field and is no
   longer trimmed, so log lines with pipes in the URL and leading-space filenames
   both match end-to-end.

362 tests pass, lint clean on all touched files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:53:04 +02:00
Administrator
e216c95b61 release: v3.3.81 2026-06-19 06:52:31 +02:00
Administrator
6b0c515a9b fix(queue): close two lost-work / sticky-ghost edge cases found by intensive testing
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>
2026-06-19 06:51:48 +02:00
Administrator
f7c8d308fc release: v3.3.80 2026-06-19 04:36:20 +02:00
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
96d6dfe880 release: v3.3.79 2026-06-19 02:32:33 +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
4c0adabfb1 docs(lessons): DNS+cert != legitimate domain (parking trap); discard broken hoster data, don''t guess
Capture the v3.3.78 byse lesson: a truncated upload host (sNNNN.filemoon, no TLD)
must not be "repaired" by guessing a TLD — every resolving candidate was a parked /
squatter domain (parklogic PTR, shared catch-all cert). Verify PTR + cert SAN before
trusting a domain; discard the obviously-broken value and let the existing
retry/cache machinery find a valid one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 02:21:28 +02:00
Administrator
abb1621a60 release: v3.3.78 2026-06-19 02:20:33 +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
b1c1a8d318 docs(lessons): transient infra errors (5xx/ECONNRESET) must fail open, never blacklist
Capture the v3.3.77 byse lesson: an unclassified HTTP 502/gateway error cascaded
through the failover chain and blacklisted every account; the fix is an explicit
transientNetwork flag at the throw site (authoritative over message-keyword
heuristics) + symmetric server-lookup hardening, and throw-site tagging must be
tested end-to-end, not just flag-injected in a mock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 01:58:16 +02:00
Administrator
f81f864314 release: v3.3.77 2026-06-19 01:56:55 +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
6f8c2dcb38 release: v3.3.76 2026-06-17 13:42:19 +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
abec12a0c1 docs(lessons): rotation state must persist across calls + restarts
Capture the v3.3.74→v3.3.75 lesson: a fresh-per-call round-robin picker
silently no-ops under the folder-monitor drip-feed (each add-jobs-to-batch
call rebuilt the cursor from 0 → account 1 every time). Distribution state
that must be fair across multiple call entry points has to outlive the calls,
and across sessions when the goal is a rolling quota.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:41:50 +02:00
Administrator
9a933a9feb release: v3.3.75 2026-06-17 03:34:01 +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
950f103f99 release: v3.3.74 2026-06-17 03:22:31 +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
b032dd6b3f release: v3.3.73 2026-06-16 00:44:39 +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