Compare commits

..

381 Commits

Author SHA1 Message Date
Administrator
d159ac484a release: v3.3.51 2026-06-08 19:22:54 +02:00
Administrator
f4b5fadc5f fix(ui): first click on sort header sets default direction instead of toggling 2026-06-08 19:22:29 +02:00
Administrator
169817f707 release: v3.3.50 2026-06-08 14:20:16 +02:00
Administrator
1418c2bc17 feat(backup): plain JSON export/import + clearer error when decrypt fails 2026-06-08 14:19:47 +02:00
Administrator
8d33141294 release: v3.3.49 2026-06-08 03:04:25 +02:00
Administrator
35341b522a fix(accounts): allow health check during active uploads + toast when already running 2026-06-08 03:04:00 +02:00
Administrator
f9aa7f4168 release: v3.3.48 2026-06-08 01:30:19 +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
ba4642e09a release: v3.3.47 2026-06-07 21:11:53 +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
4bb18f7abc release: v3.3.46 2026-06-07 20:59:34 +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
79fe3037eb release: v3.3.45 2026-06-07 20:41:25 +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
b0b86e5016 release: v3.3.44 2026-06-07 20:33:07 +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
98eba0447d release: v3.3.43 2026-06-07 18:50:24 +02:00
Administrator
5fb313273d feat(diagnostics): file-format probe + structured upload-start/failure rot-log 2026-06-07 18:49:54 +02:00
Administrator
c44dde5396 release: v3.3.42 2026-06-07 16:35:19 +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
9af65ce2a9 release: v3.3.41 2026-06-07 04:49:43 +02:00
Administrator
4f41218a92 fix(ui): log mode select no longer truncates 'Pro Session' 2026-06-07 04:49:16 +02:00
Administrator
32d35fe336 release: v3.3.40 2026-06-07 04:41:18 +02:00
Administrator
ce0bbb8b7e fix(ui): no group auto-expand while checking; only on actual error 2026-06-07 04:40:52 +02:00
Administrator
89d29c7a2a chore(gitignore): exclude electron-config + logs from tracking 2026-06-07 04:27:43 +02:00
Administrator
a97fe69cff release: v3.3.39 2026-06-07 03:28:42 +02:00
Administrator
2e8e8a3819 fix(accounts): VOE CSRF burst-throttle + collapsible per-hoster groups
User reported two coupled issues in the accounts panel:
- "VOE Upload: CSRF-Token nicht gefunden. Bist du eingeloggt?" fires
  intermittently across multiple VOE accounts when "Accounts prüfen" runs.
  Each retry "fixes" one and breaks another — classic anti-bot burst response.
- The flat badge strip becomes unreadable with many accounts; user wants
  collapsible per-hoster groups with "N/M" headers and green/red indicators,
  click to expand to per-account detail.

DISCRIMINATOR CHECK (cheap before serializing): grep'd lib/voe-upload.js for
module-level state — none. Each new VoeUploader() carries its own cookie Map.
Burst-throttle on VOE's side is the only plausible root cause.

CONCURRENCY FIX in main.js runHosterHealthCheck:
- Group checks by hoster, run each hoster's group SEQUENTIALLY, groups in
  parallel (Promise.all of sequential runners). Cross-hoster parallelism
  preserved; intra-hoster bursts eliminated.
- Result array preserves input order via a result-index map.
- Hardening per review: dedup duplicate {hoster, accountId} entries before
  grouping (no wasted API calls if a caller ever sends duplicates), and entries
  missing accountId now return a clean "Account-ID fehlt" error instead of
  silently calling per-hoster checker with null config.
- Validate-credentials and checkSingleAccount paths unchanged (single-check
  payloads run the same way regardless).
- Latency trade-off acknowledged: 5 VOE accounts ~5x faster path → up to 25s
  for that hoster's column. That's the cost for reliability; the user's
  alternative was 0/5 working on burst-failed runs.

UI FIX in renderer:
- New _buildAccountHosterGroupHtml emits a collapsible per-hoster group
  reusing the existing .hoster-panel-header / .panel-arrow CSS pattern.
- Header shows "VOE 4/5" (ok-count / total-accounts), a green/red/amber/gray
  status dot, plus pills for "N deaktiviert" and "N Fehler".
- Default: auto-expand any hoster with errors, checking, or unchecked
  accounts; collapse all-green.
- Open-state memory tracks user clicks. Per review: also tracks errorsAtClose
  snapshot so a NEW failure since the user's close forces re-expand once.
  Prevents the "I closed it once and now silent failures hide forever" risk.
- Single-card updates also refresh the parent group's header counter via
  _refreshHosterGroupHeader.
- Flat badge strip in renderHealthCheckResults is now a no-op stub — the
  per-hoster headers carry the same info, less duplication.

Three-lens review (workflow wch4p9ee9): concurrency PASS_WITH_NOTES, ui-state
PASS_WITH_NOTES, comment-policy PASS (zero new // or /* */ comments).
Latent concerns from review applied as hardenings.

210/210 tests green, lint clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 03:27:57 +02:00
Administrator
d9e858febd release: v3.3.38 2026-06-07 03:11:58 +02:00
Administrator
e26b7ea8ed fix(accounts): never persist unverified creds + dedupe-proof modal + label + perf
User reported three coupled bugs in account add/edit:
  (1) Invalid logins still create the account
  (2) Doodstream gets created multiple times when "Prüfen & Anlegen" is
      double-clicked or repeatedly OTP-retried
  (3) Add/Delete in the accounts panel feel laggy
Plus a UX/feature request: account label + two-step "Prüfen → Anlegen" flow.

Map (workflow wf44zpud4, 3 parallel subagents + adversarial verify) confirmed:
- saveAccount() persisted to disk BEFORE the health check (lines 3407-3409)
- saveBtn.disabled was set AFTER two awaited IPC roundtrips → 5-100ms race window
- OTP-retry path generated a new accountId on every click (editingAccountId
  stayed null in ADD mode) → DETERMINISTIC duplication on every OTP attempt
- runHealthCheck IPC required the account to be already persisted → that's
  why the old code wrote-first-check-second

Fix architecture (advisor: Option A — make the invariant real, not cleanup-based):
- main.js + preload.js: NEW `validate-credentials` IPC. Accepts ephemeral
  {hoster, authType, username, password, apiKey, otp} payload, builds an
  ephemeral hosterConfig, runs the same per-hoster checker via a shared
  _dispatchHealthCheck helper. Nothing touches config.hosters.
- renderer: two-step modal state machine.
    - "Prüfen" click → validateCredentials (ephemeral) → green flips button to
      "Anlegen"/"Speichern" AND caches a snapshot of the validated creds.
    - "Anlegen"/"Speichern" click → only fires if cached snapshot matches the
      currently-typed credential-identity (username+password or apiKey;
      label and OTP are not part of the snapshot key).
    - Input listeners on the identity fields drop the snapshot the moment any
      cred is edited post-green → user can't sneak unverified creds through.
    - _accountModalBusy is set SYNCHRONOUSLY at the top of the click handler,
      before any await, so a double-click is a no-op.
    - _accountModalSession token bumps on every modal reset → a stale late
      response from a closed-and-reopened modal can't stomp the new session's
      busy flag or UI (lens-2 review fix).
    - Edit mode flows through the same path → bad edits never reach disk
      before being validated (fixes the silent good-creds clobber).
    - closeAccountModal cancels the auto-close timer + clears modal state so
      a stale 600 ms timer can't close a freshly-reopened modal.
- Label field (new): persisted on the account, shown in the card subtitle as
  "Label: XYZ • API: ABC… — API Key gültig" so identical-looking API accounts
  are disambiguable. Excluded from snapshot key on purpose — label is metadata.
- Perf: drop the redundant `await getConfig()` round-trip in commit+delete
  (in-memory state was already the source of truth and the old reload was the
  main lag source). deleteAccount fires-and-forgets the saveConfig and closes
  the modal synchronously. Commit path uses updateAccountCard for the
  single-card edit case instead of a 4-panel cascade.

Multi-lens review (workflow wyoc3iq4k, 3 reviewers): OTP-correctness SHIP,
race-guard SHIP-WITH-FIXES (session-id token + busy-inside-try applied),
edit-mode+label SHIP. No blockers.

Tests: 6 new regression tests (tests/validate-credentials.test.js) covering
the three reported bugs as executable spec:
  (a) failed validation persists nothing to config.hosters
  (b) second click with guard set persists exactly one entry
  (c) OTP-required persists nothing; OTP retry re-validates ephemerally
plus snapshot-key identity, post-validation edit invalidation, and the
ephemeral hosterConfig shape contract. 210/210 green, lint clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 03:11:13 +02:00
Administrator
a7ac8c85f3 release: v3.3.37 2026-06-04 22:08:55 +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
c1585ed09a release: v3.3.36 2026-06-02 05:40:01 +02:00
Administrator
b5ff9b1a0b fix(ui): queue columns fit the window on resize (fullscreen → windowed)
Saved column widths were applied as fixed pixels, so switching from fullscreen
to windowed mode left the column sum wider than the viewport and the user had to
manually drag the window wider just to see the rightmost columns.

Now: a separate _idealColumnWidths map holds the user's preferred widths
(persisted), and _applyFittedColumnWidths reshapes the displayed widths to fit
the current container width. When sum(ideals) > container.clientWidth, every
column is scaled by the same factor so the row exactly fits (and a hidden
column becomes visible again).

- Two-tier widths: ideals are only updated by an explicit drag, not by a
  resize-driven refit. So dragging while the window is narrow no longer
  permanently shrinks every other column.
- saveDraggedColumnWidth(col, w) saves a single column's new ideal.
- Window-resize listener refits with a 60ms debounce.

Lint clean, full suite 200/200.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 05:39:28 +02:00
Administrator
72d3fe1e4e release: v3.3.35 2026-05-30 14:42:03 +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
1c8514e127 docs(lessons): doodstream live-diagnosis findings (API path verified viable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:41:49 +02:00
Administrator
1f622c5cc2 release: v3.3.34 2026-05-28 22:39:10 +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
287ebde1f5 release: v3.3.33 2026-05-28 22:29:03 +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
d2f903b8ba release: v3.3.32 2026-05-28 22:12:17 +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
6286bca7c6 release: v3.3.31 2026-05-28 22:05:52 +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
329f768e2b docs(lessons): doodstream API-vs-web-scraping fix + empty-form root cause
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:50:18 +02:00
Administrator
35314ee3ed release: v3.3.30 2026-05-28 21:48:49 +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
13de55253b release: v3.3.29 2026-05-27 20:35:29 +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
f0f1564322 release: v3.3.28 2026-05-25 01:09:30 +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
3ef3e074e6 release: v3.3.27 2026-05-25 00:44:51 +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
8f500c590e release: v3.3.26 2026-05-25 00:32:54 +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
1e6bb27404 release: v3.3.25 2026-05-25 00:26:50 +02:00
Administrator
3a23d76f24 docs(lessons): packaged-Electron log paths + surface hoster status fields
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 00:26:16 +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
9794efde46 release: v3.3.24 2026-05-24 19:01:25 +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
996fc5aa17 release: v3.3.23 2026-05-23 15:46:57 +02:00
Administrator
bd42c86796 ux(log): clarify logToFile also affects restart dedup
Deep bug-hunt of the per-hoster logToFile feature found the feature
itself clean (7 data flows traced: secret-store leaves hosterSettings
alone, save round-trip preserves the key for account-less hosters,
backup import/export round-trips, updateSettings full-replaces with
default-true fallback, checkbox branch precedes numeric coercion,
boolean survives IPC→JSON→parse intact).

The one real interaction effect: _autoDeduplicateFromLog reads
fileuploader.log on startup to drop already-uploaded files from the
restored queue. With logToFile off for a hoster, its entries are
absent, so the same file could be re-uploaded after a restart. The
dedup↔log coupling predates this feature; the toggle just makes it
observable.

Make it transparent in the checkbox hint rather than silently
shipping the surprise. Full decoupling (a separate always-written
dedup index independent of the user-facing log) is a larger,
separate change with its own risk surface — deferred unless wanted.

147/147 tests still green.
2026-05-23 15:46:27 +02:00
Administrator
042f3d0ef9 release: v3.3.22 2026-05-23 15:33:59 +02:00
Administrator
ceab155a6c fix(css): render per-hoster logToFile checkbox as a checkbox, not a stretched box
The new "Links in Log schreiben" control reuses class .hs-input for
the autosave bind to pick it up — but .hs-input also carries the
text-input styling (flex:1, padding, background, border, max-width:
300px). Applied to a checkbox that produced a stretched, padded,
filled box instead of a normal tick box.

Add an .hs-input[type="checkbox"] override that resets flex/size/
padding/background/border so it renders as a plain 16×16 checkbox
beside its label, consistent with the other settings checkboxes.

Caught during the post-feature side-effect sweep (advisor flagged the
grid layout as the one thing self-checks couldn't cover). 147/147
tests still green.
2026-05-23 15:33:31 +02:00
Administrator
fb5c1caf43 release: v3.3.21 2026-05-23 15:31:48 +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
4c88c0a756 release: v3.3.20 2026-05-23 01:10:40 +02:00
Administrator
2208632154 ux(log): default fileuploader.log path is now the user's Desktop
In packaged builds path.dirname(process.execPath) resolves to
%LOCALAPPDATA%\Programs\Multi-Hoster-Upload — a hidden install
directory the user never visits and that NSIS may prune on
uninstall. Existing files written there were effectively invisible.

Change the unconfigured-default to app.getPath('desktop') instead.
If Desktop isn't available (rare), fall back to userData (Roaming),
and finally to the exe dir as a last resort. Dev mode (isPackaged
false) is unchanged — keeps the project dir for inspection.

Custom log paths set via the Settings UI override this and continue
to work as before. Existing users with old logs in the install dir
will just see a new fileuploader.log on the Desktop going forward;
the old file stays where it is (not auto-migrated).

137/137 tests still green.
2026-05-23 01:10:10 +02:00
Administrator
c741503665 release: v3.3.19 2026-05-23 01:03:43 +02:00
Administrator
950a322022 ux(accounts): hoster-specific login field labels — VOE shows "E-Mail" only
Generic "Username / E-Mail" label on every login-type account form
sent users down a confusing path on VOE: VOE only accepts an email
address (the web form is type=email, name=email), but the app's
label suggested either was fine. Logging in with a username
silently failed → upload-page fetch returned a login redirect → the
"VOE Upload: CSRF-Token nicht gefunden. Bist du eingeloggt?" error,
which doesn't point at the actual cause.

Add a tiny per-hoster override table. Currently only voe.sx is in
it: label "E-Mail", placeholder "E-Mail-Adresse", input type="email"
(so the browser's email-format hint kicks in too). All three
getCredsFieldsHtml call sites pass the hoster name — edit-mode,
add-mode initial render, and the hoster-select change handler.

Other hosters keep the existing "Username / E-Mail" wording.
137/137 tests still green.
2026-05-23 01:03:07 +02:00
Administrator
c995d090a5 release: v3.3.18 2026-04-28 12:00:05 +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
f5256c437f release: v3.3.17 2026-04-28 11:54:25 +02:00
Administrator
d650a7395a chore(deps): npm audit fix --force — closes 12 deferred vulnerabilities
User explicitly authorized the major-version bump after the loop
flagged it as deferred. Two breaking-change upgrades land:

- electron-builder: 25.1.8 → 26.8.1
- electron:         33.4.11 → 41.3.0

Plus the transitive cleanup that the audit chain (@tootallnate/once,
http-proxy-agent, make-fetch-happen, node-gyp, @electron/rebuild,
app-builder-lib, dmg-builder, electron-builder-squirrel-windows, tar,
cacache, brace-expansion, @xmldom/xmldom) required.

Vulnerability count: 12 → 0.
35 packages added, 138 removed, 39 changed.

Verified: 126/126 unit tests still green. NSIS+portable build runs
end-to-end on the new toolchain (artifacts ~100 MB each due to the
electron 41 baseline). Renderer is Chromium-based as before; no
behaviour change expected on the user side, just a more current
runtime + signed-build pipeline.
2026-04-28 11:53:53 +02:00
Administrator
bd41aff769 docs: loop status update post-3.3.16 2026-04-28 11:51:05 +02:00
Administrator
2ea26f4b64 release: v3.3.16 2026-04-28 11:11:45 +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
6e68748ca0 release: v3.3.15 2026-04-28 10:39:29 +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
7267adfd03 release: v3.3.14 2026-04-28 10:12:52 +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
74d7f8ce5a release: v3.3.13 2026-04-28 09:40:28 +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
3626978250 release: v3.3.12 2026-04-28 09:13:09 +02:00
Administrator
04e535c709 fix(main): batch-done race could orphan a freshly-spawned UploadManager
The batch-done event handler awaits configStore.appendHistory(summary)
before nulling the global uploadManager reference. If the renderer
fires start-upload while that await is pending, the start-upload IPC
creates a fresh UploadManager and assigns it to the same global. The
old handler resumes, sets uploadManager = null, and orphans the new
manager: cancel-upload, add-jobs-to-batch, save-config re-resolve etc.
all see null and become no-ops, while the new batch keeps running
invisibly in the background.

Capture the manager identity at listener registration time and only
null the global if it still points at THIS manager. If a newer one
replaced it mid-await, leave it alone and log the near-miss for
diagnostics.

Found by deep-audit subagent. Tests still 119/119 (no test for this
because it needs a coordinated IPC + async-mock harness; the fix is
small and the diagnostic log will catch regressions).
2026-04-28 09:12:48 +02:00
Administrator
794e4162e1 release: v3.3.11 2026-04-28 08:39:31 +02:00
Administrator
f1a3d7d468 chore(deps): patch-bump eslint, undici, ws
Three semver-compatible upgrades from `npm update`:
- eslint  10.1.0 → 10.2.1  (dev-only, lint rule fixes)
- undici  7.24.5 → 7.25.0  (HTTP client used by hoster uploaders)
- ws      8.19.0 → 8.20.0  (WebSocket used by remote-server)

Lock-file-only update, package.json semver ranges already covered
these. 119/119 tests still green; no behaviour changes expected.
Remaining outdated entries (chokidar, electron, electron-builder,
rcedit) are major bumps and stay deferred until the user explicitly
authorizes a breaking-change pass.
2026-04-28 08:39:11 +02:00
Administrator
f9cc5305f6 release: v3.3.10 2026-04-28 07:39:55 +02:00
Administrator
95ad35eab9 chore(deps): npm audit fix — 4 vulnerabilities closed (no breaking changes)
Ran `npm audit fix` (without --force) to apply the safe subset of
security advisories. Lock-file-only update, 39 transitive dep
versions bumped within their semver-compatible ranges. Brought the
audit down from 16 vulnerabilities (2 low, 1 moderate, 13 high) to
12 (2 low, 10 high) — closed 1 moderate + 3 high.

The remaining 12 are all in the electron-builder dev-chain
(@tootallnate/once → http-proxy-agent → make-fetch-happen → node-gyp
→ @electron/rebuild → app-builder-lib → electron-builder, plus tar
→ cacache). Closing them requires npm audit fix --force which
upgrades electron-builder to 26.x — a major bump, intentionally
deferred until the user wants a build-pipeline change.

119/119 tests still green; package.json unchanged.
2026-04-28 07:39:32 +02:00
Administrator
a212b31b08 release: v3.3.9 2026-04-28 07:13:12 +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
8965983e0c release: v3.3.8 2026-04-28 06:42:07 +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
7269504d3d release: v3.3.7 2026-04-28 06:10:14 +02:00
Administrator
2c46430492 fix(renderer): prune session-stats sets at batch-done
_sessionTrackedJobs and _sessionDoneJobs accumulated jobIds across
the whole session — IDs of jobs already removed from queueJobs (by
removeFromQueueOnDone or the auto-cap that lands in handleBatchDone)
stayed in those sets forever. ~50 bytes/entry × hundreds of batches
× many jobs/batch = small but real growth over a multi-day session.

At batch-done, walk the sets and drop any ID that's no longer present
in queueJobs. _completedUploadKeys is intentionally kept — it's the
dedup against re-queueing the same file across batches and would
break that contract if pruned.

The prune is a single pass per batch-done (rare event) and only
happens when the sets aren't already empty. 97/97 tests still green.
2026-04-28 06:09:54 +02:00
Administrator
3ece93c363 release: v3.3.6 2026-04-28 05:39:35 +02:00
Administrator
3865a0fe33 ux(css): scope queue-row background transition to :hover only
The .queue-row rule had transition: background 0.15s applied
unconditionally. Every status flip (queued → getting-server →
uploading → done) on every visible row therefore animated for 150 ms,
and with 30+ rows changing state in close succession the compositor
ran overlapping tweens that ate paint time during heavy upload bursts.

Move the transition into the :hover rule. Hover-enter and hover-leave
keep their smooth fade — that's where transitions actually help the
user. Status changes now snap to the new background colour instantly,
which is what the queue table really wants: it conveys progress, not
animation.

No JS change. 97/97 tests still green.
2026-04-28 05:39:14 +02:00
Administrator
1c03a3f2e7 release: v3.3.5 2026-04-28 05:11:14 +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
79fe41c774 release: v3.3.4 2026-04-28 04:40:20 +02:00
Administrator
678c9ce3c5 perf(renderer): use live HTMLCollection in selection-class toggles
applyQueueSelectionClasses + applyRecentSelectionClasses ran
tbody.querySelectorAll('.queue-row') / ('.recent-file-row') on every
click. querySelectorAll always walks the tree and returns a fresh
static NodeList. With 200 visible queue rows + frequent click/drag
selections that's a measurable per-click cost.

Switch to getElementsByClassName: returns a live HTMLCollection that
the engine memoizes and updates incrementally as nodes are
inserted/removed. First call still walks once; subsequent calls are
near-free reads. Iteration uses a plain index loop because
HTMLCollection is array-like, not iterable in older runtimes (it is
in modern Chromium, but the index loop is also marginally faster).

No behaviour change. 87/87 still green.
2026-04-28 04:39:57 +02:00
Administrator
0df8557f06 release: v3.3.3 2026-04-28 04:09:48 +02:00
Administrator
4575b5ac26 fix(main): cap _jobLogCollector at 1000 jobs (FIFO eviction)
The per-job log collector was only cleared at start-upload — across a
long session with many add-jobs-to-running-batch interactions (no new
start-upload), the Map grew unbounded. At ~5000 tracked jobs that's
1 MB × 5 = 5 MB+ of stale history hanging around in the main process,
bigger as ring buffers fill.

Add a cap: when a new jobId would push size past 1000, evict the
oldest entry (Map iteration order is insertion order per spec). 1000
× 200 entries/job × ~100 B/entry ≈ 20 MB worst case, properly bounded
no matter how long the session runs. Per-job ring buffer (200 entries)
unchanged; only the count of tracked jobs is now capped.

The "Log anzeigen" modal still works for any job in the most-recent
1000 — older jobs return an empty array, which the renderer already
displays as "Keine Log-Einträge".
2026-04-28 04:09:27 +02:00
Administrator
0b306221d4 release: v3.3.2 2026-04-28 03:40:27 +02:00
Administrator
d96c6afce0 feat(log): auto-rotate fileuploader.log at 50 MB
A long-running install can otherwise grow the upload log into the
gigabyte range, eating disk and slowing every appendFile. Add a
size-checked rotation right before each flush:

- statSync the resolved log target (cheap, ENOENT skips silently).
- If size exceeds 50 MB, drop the oldest backup (.3), shift .2→.3
  and .1→.2, then rename the live file to .1 and let appendFile
  create a fresh primary on the next call.
- Max 3 backups (~200 MB worst case, bounded). debugLog records
  each rotation for diagnostics.
- Pure additive: skips when file is small or doesn't exist; no
  effect on the daily-log mode (already date-rotated).
2026-04-28 03:40:06 +02:00
Administrator
3487bc8fcf release: v3.3.1 2026-04-28 03:37:34 +02:00
Administrator
38ecc6a4cb perf(queue): coalesce removeFromQueueOnDone removals into one filter pass
handleProgress on a 'done' event with removeFromQueueOnDone=true was
calling queueJobs.filter() once per event. With 500 parallel jobs all
finishing at roughly the same time, that's 500 × O(N) = O(N²) work
synchronously on the IPC handler thread — visible as a brief UI freeze
when a big batch completes.

Coalesce into one microtask: removeJobFromIndex + selection cleanup
stay synchronous (so subsequent lookups see the right state), but the
array rewrite is deferred to a single filter against a Set of all
ids that came in this tick. JS microtask runs after the sync IPC
batch, so within one batch-of-events we get one filter pass instead
of N.

beforeunload drains the pending set synchronously before persisting
so removeFromQueueOnDone=true users don't see jobs reappear after
restart that they expected to be gone.
2026-04-28 03:37:13 +02:00
Administrator
4af89d7aa3 release: v3.3.0 2026-04-28 03:31:04 +02:00
Administrator
66f8b47b6d perf+fix: long-session lag, tab-switch lag, log-recovery
Bundles four findings from a stability audit plus the missing-log bug
the user reported.

1. main.js _flushUploadLog: ENOENT after the log file's directory got
   deleted mid-session was swallowed; the buffer was cleared before
   appendFile so entries were silently lost and the cached target kept
   pointing at the dead path. Now: mkdirSync(recursive) before every
   flush idempotently recreates a missing dir; on any append error we
   invalidate the cache, prepend the chunk back to the buffer and
   schedule a retry. Survives "user dragged the log folder into the
   trash and didn't notice".

2. renderer/app.js queueJobs auto-prune: with the default
   removeFromQueueOnDone=false the queue grew forever. Past ~5000
   entries every render became O(N) on a perpetually-growing N and
   the user saw progressive scroll/tab lag. Cap the in-queue
   terminal jobs (done/skipped/error/aborted) at 500 most-recent on
   each batch-done; oldest get pruned with their index entries.

3. sortQueueJobs dynamic-key throttle: status/speed/progress/size
   sorts ran a full O(N log N) sort on every progress tick. Added a
   200ms-window cache for the dynamic-key path so the sort is reused
   within the same UI_UPDATE_INTERVAL — invisibly small reorder lag,
   massive cost savings at 5000+ jobs.

4. renderHistoryTable delegated listeners: every Verlauf-tab switch
   was binding one click listener per row (5000 listeners on a
   long-history user) and rebuilding the entire <tbody> innerHTML.
   Single delegated tbody listener covers both row-click (copy link)
   and th-click (sort), bound once per container via dataset flag.

5. sessionFilesData (recent-files panel) cap at 2000 entries with
   matching _sessionFileKeys cleanup using the existing  separator.
   Stops the lower-panel innerHTML write from inflating to multiple
   MB on long sessions.

87/87 tests still green.
2026-04-28 03:30:33 +02:00
Administrator
e49f5493fe release: v3.2.3 2026-04-22 18:56:51 +02:00
Administrator
58a21ed321 fix(queue): stale sort-cache froze UI when queueJobs was replaced
After importing a backup or restoring the queue at startup, queueJobs
is reassigned to a fresh array. The sort-cache keyed its hit on
"key|direction|length" — identical across a replacement with the same
job count. Result: renderQueueTable kept getting the cached sorted
array, which held references to the DISCARDED job objects (frozen at
status='preview'). Uploads ran perfectly in the background, the
status bar updated from stats events, but every row stayed "Bereit"
with "..." as size. The user had to poke `_queueSortCache={sig:'',…}`
in DevTools to unstick it.

Include the jobs array identity (jobsRef) in the cache check. A
replacement of queueJobs → different reference → cache miss → fresh
sort with the real current objects. O(1) identity check, no CPU cost
on the common case (same array, mutated jobs).
2026-04-22 18:56:24 +02:00
Administrator
5afb56b987 release: v3.2.2 2026-04-22 18:23:59 +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
Administrator
c70f105685 release: v3.2.1 2026-04-22 18:17:35 +02:00
Administrator
0c301c8182 fix(queue): consistent "Bereit" after restart — no more Wartet/Bereit mix
buildPersistedQueueState persisted every non-aborted job's status
as-is. At restart no upload manager exists, so a serialized 'queued'
or 'uploading' job showed up as "Wartet" / "Upload" even though
nothing was actually running — next to a "Bereit" job for the same
file on a different hoster (screenshot the user sent).

Collapse every non-terminal state (queued, getting-server, uploading,
retrying, aborted) to 'preview' during persistence. Terminal states
(done, error, skipped) survive as-is so the user keeps their history
/ error messages. Also clears error/result when collapsing so the
restored preview row doesn't carry stale failure text.
2026-04-22 18:17:10 +02:00
Administrator
65f8d9a0e8 release: v3.2.0 2026-04-22 18:14:18 +02:00
Administrator
b96ccf851a feat(ui): per-job log modal + account label in status
Two related visibility improvements.

1. Status cell now shows which account the job is running on:
   "Upload · Primär", "Retry 2/3 · Fallback #1: <error>", etc.
   - _emitProgress passes task.accountId in every progress event
   - renderer maps accountId → position in config.hosters[hoster] and
     renders "Primär" for index 0 and "Fallback #N" for the rest
   - Applies to uploading/getting-server/retrying (static states like
     done/error already tell their own story)

2. Right-click on a job → "Log anzeigen" opens a modal with the full
   per-job trail: every rot-log entry tagged with that job's jobId
   plus every non-uploading progress transition. Replaces the need to
   grep account-rotation.log for a single filename.
   - UploadManager: all 13 job-scoped _rotLog calls now carry jobId
   - main.js: _jobLogCollector Map<jobId, Array<entry>> with 200-entry
     ring buffer per job; cleared on each new start-upload (fresh
     batch = fresh log). addJobs mid-batch keeps history.
   - New IPC 'get-job-log' returns the array; preload.js exposes
     window.api.getJobLog(jobId)
   - renderer: modal card + context-menu item "Log anzeigen";
     entries formatted as "[HH:MM:SS.mmm] [event] k=v k=v"; copy-to-
     clipboard button
2026-04-22 18:13:53 +02:00
Administrator
329f501a6e release: v3.1.10 2026-04-22 18:03:44 +02:00
Administrator
8680ae6467 ux(queue): show error detail directly in status cell
Status "Fehlgeschlagen" alone forced the user to dig into
account-rotation.log to understand why a job failed. For error /
retrying / skipped statuses, append the (shortened, whitespace-
collapsed) error message — same approach already in place for v2.
Caps at 100 chars so the cell stays readable.
2026-04-22 18:03:17 +02:00
Administrator
141bfd3658 release: v3.1.9 2026-04-22 17:57:29 +02:00
Administrator
05e6d654c4 fix(rotation): retry after batch-done reuses learned fallback state
Previously: clicking "Erneut versuchen" after a batch had already
finished spawned a fresh UploadManager with empty _failedAccounts and
_accountOverrides. The first retry then burned the full retry budget
on the account we already knew was dead (e.g. disk-space-full byse
account) before rotation kicked in again — same problem we fixed for
within-batch flow but for across-batch flow.

- main.js: two module-level maps (_sessionFailedAccounts,
  _sessionAccountOverrides) cache rotation state across batches in the
  same app session. Populated on account-failed and on both
  switchAccount paths (event-driven + save-config re-resolve).

- lib/upload-manager.js: startBatch(tasks, opts) accepts
  primeFailedAccounts + primeOverrides. State is still cleared first
  (legacy behaviour for callers without opts), then re-primed from the
  passed session state. batch-start rot-log entry reports how many
  entries were primed for diagnostics.

- Tests: prime priority is honored (pre-job-swap fires on first
  attempt, no fast-fail, no upload to acc1); back-compat for callers
  that don't pass opts.

App restart remains the reset signal — matches the "neuer Tag, acc1
hat vielleicht wieder Platz" expectation.
2026-04-22 17:56:59 +02:00
Administrator
1616ee8f14 release: v3.1.8 2026-04-21 19:43:19 +02:00
Administrator
d49fe136f2 fix+obs: byse poller race-condition + transient-net tests + memory logging
Three small, unrelated reliability improvements bundled:

1. lib/hosters.js (_resolveByseUploadByName): drop the "only one new
   file → claim it" fallback. Under parallel byse uploads, job A's
   poller could claim job B's newly-uploaded file and return the wrong
   URL. Now requires exact normalized name match. Trade-off: a few
   false negatives if byse rewrites the filename beyond our
   normalizer, but parallel correctness wins.

2. tests/upload-manager.test.js: pin the transient-network classifier
   behaviour with 2 new tests covering common transient strings
   (ENOTFOUND, ECONNRESET, socket hang up, fetch failed, EAI_AGAIN…)
   and verifying real account-level / file-rejected errors are NOT
   misclassified as transient. Baseline stays clean: 82/82 green.

3. main.js: log process.memoryUsage() snapshot at batch-start and
   batch-done. One line each — harmless in the happy path, gives us
   the data points needed to spot long-session RSS/heap growth across
   batches without DevTools instrumentation.
2026-04-21 19:42:54 +02:00
Administrator
22356864c3 release: v3.1.7 2026-04-21 19:33:57 +02:00
Administrator
058c8a2674 perf(renderer): coalesce status-change UI updates into one rAF frame
Non-uploading progress events (queued/getting-server/retrying/done/
error/aborted/skipped) were firing renderQueueTable +
updateQueueActionButtons + updateStatusBar + updateStatsPanel
synchronously on EVERY event. At batch start, 500 jobs going
preview→queued→getting-server within milliseconds meant ~2000 sync DOM
updates — visible jank on large batches.

New scheduleStatusChangeUpdate() uses requestAnimationFrame to coalesce
the four-helper call into at most one run per frame (~60 Hz). Functional
result is identical; the user just sees smooth flips instead of a
briefly frozen renderer.

The uploading-progress throttle (200ms) is unchanged since those events
are much more frequent and the user doesn't need 60 Hz upload-byte
updates.
2026-04-21 19:33:33 +02:00
Administrator
4bf159eda2 release: v3.1.6 2026-04-21 19:31:34 +02:00
Administrator
a6ff2dd587 chore(main): drop JSON.stringify of files/hosters in start-upload log
At 500+ queued jobs these lines bloated upload-debug.log with
megabyte-sized entries per batch-start and added visible latency to
the IPC handler. Log sizes only.
2026-04-21 19:31:07 +02:00
Administrator
f5a5cfdf2c release: v3.1.5 2026-04-21 17:04:35 +02:00
Administrator
bf806cb069 fix(rotation): session-learning for account failures is now complete
Three related gaps closed so one full byse account stops wasting
attempts on every subsequent job and later-added accounts get picked
up without an app restart.

1. Pre-job-swap moved BEHIND the semaphore acquire. At scale (500 jobs
   / 1 slot) every worker was checking _failedAccounts at spawn time
   before the first upload had even tried — so none of them saw the
   failed state. Now each worker re-checks right before its first
   upload attempt.

2. save-config IPC handler re-resolves fallbacks for any account that
   is already in _failedAccounts but has no override set. Previously
   account-failed only fired once per account, so a config change
   after the first mark-failed was silently ignored and the batch
   stayed stuck on the dead account until the app restarted.

3. UploadManager exposes getFailedAccountKeys() and getOverride(hoster)
   so main.js can drive the late re-resolve without poking private
   fields.

4 new tests: pre-job-swap after semaphore, getters contract, fresh
manager resets learned state, late-added fallback is honored by
subsequent jobs. 80/80 green.
2026-04-21 17:03:59 +02:00
Administrator
e5f9f91f4e release: v3.1.4 2026-04-21 16:43:29 +02:00
Administrator
17e9a419b2 fix(rotation): treat byse "disk space" as account-level, not file-rejected
Byse rejects uploads with status like "not enough disk space on your
account" when the account's storage is exhausted. The parser was
flagging every non-OK status as err.fileRejected=true, and the upload-
manager classifier additionally matched the generic "lehnte Datei ab"
prefix as file-rejected. Result: rotation was skipped on a full account
and every subsequent file failed on the same dead account.

- hosters.js: byse parser now distinguishes account-level phrases
  (disk space / storage / quota / insufficient / account full) and sets
  err.accountError=true for those. File-specific failures (Duplicate,
  wrong format, size) keep err.fileRejected=true.
- upload-manager.js: _isFileRejectedError no longer matches the generic
  "lehnte Datei ab" prefix and short-circuits when err.accountError is
  true. _shouldSkipRetryOnAccountError honors the flag and has added
  regex patterns as a safety net.
- Tests: 5 new unit tests covering disk-space/account-level/duplicate
  and the accountError-wins-over-fileRejected precedence.
2026-04-21 16:42:56 +02:00
Administrator
e3a785d4a7 release: v3.1.3 2026-04-21 16:15:32 +02:00
Administrator
f3b1c25d8b perf(queue): halve sync work on retry of many jobs
retrySelectedJobs() was calling renderQueueTable + updateQueueActionButtons
+ updateStatusBar and then immediately awaiting startSelectedUpload(),
which runs the exact same trio right after. At 500+ failed jobs the
double render/sort/button-refresh freezes the UI for several seconds
after clicking "Erneut versuchen".

Drop the outer render trio — startSelectedUpload's one is enough. The
inner call sees the freshly-mutated job state in the same tick, so the
visible result is identical with half the work.
2026-04-21 16:14:58 +02:00
Administrator
187eff2429 release: v3.1.2 2026-04-20 16:10:30 +02:00
Administrator
d8821a46ee release: v3.1.1 2026-04-20 16:09:54 +02:00
Administrator
1e449e3d67 fix(byse): poll file list when response has empty filecode
User reported uploads appearing on the byse dashboard (2+ GB MKV,
Server #262, status OK) even though the app marked them failed. The
byse API sometimes replies with msg=OK + files:[{filecode:"",
status:"Not video file format"}] — a misleading response where the
file is actually being accepted and gets its filecode assigned
asynchronously.

  - Before the upload POST, snapshot the current /api/file/list to
    know what was already there.
  - If parseByseResult returns an empty filecode (or throws a
    fileRejected error), poll /api/file/list up to 15 × 2s looking
    for a new file_code matching the uploaded filename (case/punct
    normalized, extension stripped).
  - If matched, return the real download/embed URLs and let the
    upload complete as successful. Only throw the parser's error
    if polling also finds nothing.
2026-04-20 16:09:28 +02:00
Administrator
927fbc5895 release: v3.1.0 2026-04-20 16:06:33 +02:00
Administrator
7ed227a76e fix(byse/rotation): surface per-file rejection, skip retries and rotation
The log revealed byse's true response shape for rejected files:
  { msg: 'OK', status: 200, files: [{ filecode: '', status: 'Not video file format' }] }

HTTP 200 + msg=OK made the old code treat it as 'success but no
file_code'. The real error ('Not video file format') was buried in
files[0].status. parseByseResult now surfaces that with a dedicated
err.fileRejected flag so the rotation layer can distinguish
file-specific vs account-specific failures.

Rotation behavior:
  - file-rejected errors: no retries, no account blacklist, no
    rotation. The same file is going to get the same verdict on
    any account, so skip straight to 'error' status and keep the
    account available for other files in the batch.
  - network errors (already handled): no account blacklist either.
  - everything else: unchanged (retry then rotate).

Also added pattern matches for common rejections (Duplicate, File
too small/large, Unsupported format, etc.) so other hosters'
per-file errors get the same treatment.
2026-04-20 16:06:09 +02:00
Administrator
c696b0cb0e release: v3.0.9 2026-04-20 15:57:08 +02:00
Administrator
0ea92ad6d0 fix(rotation): transient network errors don't blacklist the account + clearer byse 'OK' error
Two bugs visible in the user's rotation log:

  1. 'error=OK' for byse.sx — the server returned a payload with
     msg='OK' and no file_code anywhere we recognized. Our generic
     uploadFile threw the bare 'OK' as the error message, which is
     useless and misleading. Now when we see an ok-ish msg without
     the expected file_code we throw a descriptive error that
     includes the first ~400 bytes of the payload so the next time
     it happens we can see what's actually being returned (API
     changed, new field name, etc.).

  2. 'getaddrinfo ENOTFOUND s1055.filemoon' was marking accounts as
     permanently failed, blacklisting BOTH byse accounts within the
     same batch even though neither was the actual problem — filemoon
     (byse's storage backend) briefly had a DNS blip. Added
     _isTransientNetworkError() covering DNS/ECONNRESET/ETIMEDOUT/etc.
     When all retries on an account exhaust with a transient error,
     we now fail just that file and emit 'skip-rotation-transient'
     instead of adding the account to _failedAccounts. Other files
     in the same batch still get a fresh try on the same account.
2026-04-20 15:56:44 +02:00
Administrator
22869df8a5 release: v3.0.8 2026-04-20 14:13:38 +02:00
Administrator
bb89de3c93 perf: tab switch O(1), parallel settings save, cached hoster counts, sort-cache reuse
Four user-visible lag sources tracked down from a wider audit:

  - Tab click was running three full querySelectorAll walks per click
    (remove active from all tabs, all views, find new tab). Replaced
    with delegated listener on the tab bar plus cached node maps;
    tab switching is now O(1) and a no-op when clicking the active tab.

  - saveSettings awaited saveHosterSettings + saveGlobalSettings
    serially and then re-fetched the full config from main. With
    autosave firing on every keystroke this added 100–200ms of IPC
    stall per input change. The two saves now run in parallel and the
    post-save getConfig refetch is gone — we know the new state.

  - showContextMenu rebuilt hosterCounts (queueJobs.forEach) on every
    right-click. Replaced with a length-keyed cache; right-click on a
    5000-job queue no longer pauses while counting.

  - Recent-panel shift-click was querying every .recent-file-row in
    the DOM and re-parsing data-order. Reuses _recentSortCache.result
    instead, O(visible) vs O(N).
2026-04-20 14:13:09 +02:00
Administrator
530fd03c22 release: v3.0.7 2026-04-19 23:30:40 +02:00
Administrator
f6b1ef96b7 feat(log): auto-persist fallback path into settings
When the configured log path isn't writable and we fall back to
Desktop/userData, the working fallback now gets saved into
globalSettings.logFilePath automatically. Benefits:

  - Next session writes directly to the known-working path, no
    fallback ladder, no recurring toast warning.
  - The Settings input reflects the actual path in use, so users
    don't stay confused about where their uploads are being logged.
  - Live update via IPC — if the Settings view is currently open,
    the input value updates without needing a view switch.

Daily-log mode is handled: we strip the -YYYY-MM-DD suffix before
persisting so tomorrow's auto-rotation doesn't double-date the
filename.
2026-04-19 23:30:14 +02:00
Administrator
90ba69d1b0 release: v3.0.6 2026-04-19 23:20:44 +02:00
Administrator
c5c31aa323 fix(ui): log-fallback warning is a toast, not a blocking alert()
alert() in Electron halts the renderer main thread until the user
clicks OK — the upload table, status bar and progress all freeze.
During a 170-file batch the dialog popped up mid-upload and froze
everything for however long the user took to dismiss it (which is
why stats updates lagged to one every 3-5s instead of the usual 1s
cadence).

Replaced with the same showCopyToast used elsewhere, with an 8s
duration so the message is still readable. showCopyToast now accepts
an optional durationMs argument.
2026-04-19 23:20:17 +02:00
Administrator
7a5278012b release: v3.0.5 2026-04-19 23:13:53 +02:00
Administrator
63f87a0310 fix(rotation): concurrent jobs now reuse the override instead of failing
When multiple jobs run in parallel on the same hoster and the primary
account starts failing, the first job marks it failed + triggers
rotation. The second job's retries then also exhaust on the same
(already-failed) primary — but the old while-condition
`!_failedAccounts.has(...)` short-circuited the whole rotation loop
for anything already marked, so the second job went straight to
final-error even though a resolved override was sitting right there.

Now the loop always checks for an available override; it only skips
the mark-failed + emit step if the account was already marked by a
concurrent job. Fixed visible symptom: first job rotates A→B, every
other job in the same batch that hit A got final-error instead of
also switching to B.

Also extended fast-fail patterns to include 429 (Too many requests),
CSRF-Token / 'Bist du eingeloggt' — both were showing up as the
primary failure mode in real uploads and were wasting 5 retries
each.
2026-04-19 23:13:25 +02:00
Administrator
b7336eefb8 release: v3.0.4 2026-04-19 23:04:47 +02:00
Administrator
655fb6230b feat(rotation): fast-fail on account-specific errors + open-log-folder button + sync rot-log flush
Three related improvements that landed together while wiring up the
rotation log infrastructure:

  - Fast-fail classifier: errors that clearly indicate the account
    itself is the problem (rate limit, quota, banned/suspended, auth
    failure, 401/403, 'Kein Upload-Server' from delivery-node etc.)
    now skip the remaining retries and go straight to rotation. No
    more waiting 5 × 3s between retries just to end up rotating
    anyway. Emits a 'fast-fail' rot-log event so the shortcut is
    visible.

  - Settings: 'Öffnen' button next to the log-file-path input reveals
    the active log file (or its directory if nothing's written yet)
    in the OS file manager, so users don't have to remember paths.

  - rotLog() writes the rotation log synchronously. Only a handful
    of events fire per batch; the 500ms flush batching was saving
    nothing and made the file look empty when users checked right
    after an event. (The main debug log still uses the batched async
    path — that one is high-volume.)
2026-04-19 23:04:20 +02:00
Administrator
796aeb520d release: v3.0.3 2026-04-19 22:57:48 +02:00
Administrator
126b1e569a feat(account-rotation): dedicated logging + live toast notifications
To trace whether the fallback chain actually engages during real uploads,
every rotation decision now emits a structured 'rot-log' event from the
upload-manager. main.js persists each event to a new account-rotation.log
(same directory as fileuploader.log; falls back to Desktop then userData)
and also mirrors it into the main debug log with a [ROT] prefix for
single-file grepping.

Logged events:
  - batch-start (clears _failedAccounts / _accountOverrides)
  - pre-job-swap / pre-job-swap-blocked (job picks override before first try)
  - retries-exhausted / mark-failed (enters rotation loop)
  - rotate (switched to new account, retry starting)
  - rotation-end (no override / override already failed)
  - final-error (all accounts exhausted)
  - switchAccount (main resolved the next fallback)

The renderer shows a toast on 'rotate', 'rotation-end' and 'final-error'
so fallback behavior is visible live instead of buried in logs.
2026-04-19 22:57:19 +02:00
Administrator
9b5184f76f release: v3.0.2 2026-04-19 22:49:40 +02:00
Administrator
9c679bd442 perf(accounts): event delegation + in-place card updates
The Accounts view rebuilt the whole list on every enable/disable/
check/reorder. Each render destroyed and recreated four click
listeners plus five drag listeners per card (20 accounts = 180
listeners cycled per click), then ran an IPC getConfig round-trip
on top. Typing-fast enable/disable toggles felt sludgy.

  - Single delegated click handler on the accounts container.
  - Single delegated set of drag/drop handlers (one per event type,
    not per card).
  - Listeners are bound once on first render, never rebound.
  - updateAccountCard(accountId) swaps just the one affected card's
    DOM node when its state changes. toggleAccount / checkSingleAccount
    use that instead of calling renderAccounts.
  - Drag-and-drop reorder moves the DOM node in place and re-renders
    only the priority badges of the affected group — no container
    rebuild, no getConfig refetch.
2026-04-19 22:49:11 +02:00
Administrator
00a46dee2e release: v3.0.1 2026-04-19 22:43:43 +02:00
Administrator
6a40fdd435 fix(vidmoly): correct multipart fields & JSON response shape
Captured the real browser upload POST and compared to our request.
Two corrections:

  - The file field is named 'file', not 'file_0'. The XFS-indexed
    naming was a bad guess — the current transit accepts only 'file'.
  - The form also needs 'to_json=1' (forces JSON response instead of
    an HTML redirect page, matching what the browser submits) and
    'fld_id=0' (destination folder, 0 = root). Dropped upload_type,
    srv_tmp_url, utype — those were XFS remnants and aren't part of
    the current server's contract.
  - Response shape is now { status: 'OK', file_code, msg } instead of
    the older { files: [...] } / { result: ... } XFS variants; the
    parser handles all three plus carries the server's msg forward
    on explicit rejections.
2026-04-19 22:43:17 +02:00
Administrator
5d43923217 release: v3.0.0 2026-04-19 22:42:08 +02:00
Administrator
0dcd62ac26 fix(vidmoly): append X-Progress-ID query param to transit upload URL
The transit server runs nginx-upload-progress and requires an
X-Progress-ID query parameter on the POST URL to finalize the
upload session. Without it the server accepts all bytes but never
sends the response — matches the reported 99%-stuck behavior. The
browser appends it automatically before submit; we now do the same.
2026-04-19 22:41:43 +02:00
Administrator
7ea718ee27 release: v2.9.9 2026-04-19 22:37:38 +02:00
Administrator
0405c28245 fix(vidmoly): strip vidmoly.me cookies on cross-origin transit POST + add XFS fields
Upload stalled at 99% because we were sending vidmoly.me cookies to
*.vmwesa.online (transit server rejects them silently). Browsers never
send those cross-origin. Now we omit the Cookie header and match the
Origin/Referer the browser uses. Also added the full classic XFS field
set (upload_type, sess_id, srv_tmp_url, utype) in the order the
server's handler expects.
2026-04-19 22:37:11 +02:00
Administrator
c8aeaf1de0 release: v2.9.8 2026-04-19 22:31:58 +02:00
Administrator
da4ac95c3c fix(vidmoly): login via new POST /api/auth/login with JSON
The SPA redesign killed the old XFS form POST at / with op=login.
The new flow is a JSON POST to /api/auth/login that returns a
vidmoly_session HttpOnly cookie, which is what /api/upload/config
actually authenticates against.

After login we also probe /api/upload/config once to fail fast if
the session was issued but not actually valid for uploads.
2026-04-19 22:31:32 +02:00
Administrator
961d59f8b8 release: v2.9.7 2026-04-19 22:28:13 +02:00
Administrator
5c7bfb48b9 fix(vidmoly): probe /api/upload/config to verify login
The old /my HTML check failed because it couldn't distinguish an XFS
session from a full SPA session. Since /api/upload/config is what the
upload actually needs, probe it directly after login — 200 JSON with
sess_id/upload_url means we're good, anything else means we're out.
2026-04-19 22:27:49 +02:00
Administrator
d0c9df7656 release: v2.9.6 2026-04-19 22:24:33 +02:00
Administrator
0e7ae5ee7b fix(vidmoly): use new /api/upload/config endpoint
The Vidmoly SPA redesign removed the /?op=upload HTML form — the old
regex-scrape of hidden inputs no longer works. The site now exposes
GET /api/upload/config which returns { sess_id, upload_url } plus the
allowed extensions. Rewrote getUploadParams() to use that endpoint;
the rest of the multipart upload flow (sess_id + utype + file_0) is
the same classic XFS shape.
2026-04-19 22:24:10 +02:00
Administrator
8e49733241 release: v2.9.5 2026-04-19 22:11:06 +02:00
Administrator
a0eae7f380 release: v2.9.4 2026-04-19 22:09:29 +02:00
Administrator
bf39b6c180 release: v2.9.3 2026-04-19 22:08:50 +02:00
Administrator
2dc94084ab fix: vidmoly login verification + retry stale-uploadId + faster account toggle
Three fixes bundled:

  - Vidmoly redesign broke login: the old check required either the
    'login' or 'xfsts' cookie, but the new site sets different cookie
    names. Now we verify by fetching /?op=my_account and looking for
    logged-in markers (Logout / My Account / My Files) in the body
    instead of relying on specific cookie names.

  - retrySelectedJobs left the stale uploadId in _jobIndexByUploadId
    when resetting a job. A late 'aborted'/'error' event from the
    original (cancelled) upload could route back to the reset job
    and overwrite its 'preview' state. Now the old uploadId is
    removed from the index and marked in _deletedJobIds so those
    stragglers get dropped.

  - toggleAccount did two IPC round-trips (saveConfig + getConfig) on
    every enable/disable click, plus four re-renders (Accounts,
    HosterSummary, HosterModal, Settings). Rapid clicks felt laggy.
    The getConfig refetch is redundant since we mutated the flag in
    place, and HosterModal/Settings don't depend on account enabled
    state. Click now renders immediately and the save runs async.
2026-04-19 22:08:22 +02:00
Administrator
976be2f566 release: v2.9.2 2026-04-19 22:02:35 +02:00
Administrator
edf35e9636 release: v2.9.1 2026-04-19 22:01:47 +02:00
Administrator
880537dcfb fix: multi-level account rotation + clear failed-accounts per batch + size-sort staleness
Three state bugs found during audit:

  1. _failedAccounts / _accountOverrides survived across batches. A
     rate-limited account from batch 1 stayed permanently blacklisted
     for the rest of the app session, so batch 2 skipped straight to
     the fallback even after the original recovered. Now cleared in
     startBatch so each run evaluates accounts fresh.

  2. Account rotation was one level deep. With three accounts [A,B,C]
     on the same hoster and A + B both failing, the job errored out
     — C was never tried. The fallback-retry was a single if-block.
     Replaced with a while-loop that keeps asking main for the next
     override and rotating until every account is exhausted.

  3. Queue sort cache included 'size' as a static key, but bytesTotal
     goes 0 → actual when previews resolve. A queue sorted by size
     during preview would cache the all-zeros order and never update.
     Removed size from _STATIC_SORT_KEYS — it now re-sorts per render
     like status/speed/progress.
2026-04-19 22:01:20 +02:00
Administrator
5265bcd77a release: v2.9.0 2026-04-19 14:07:23 +02:00
Administrator
4f2d462754 perf: single-pass escapeHtml/escapeAttr
Hot path on large table rebuilds — every text cell runs through one
of these. Switching from 4 chained .replace() calls to a single regex
with a lookup map is ~3× faster. At 5000 rows × 4 fields per rebuild,
80k → 20k regex operations.
2026-04-19 14:06:52 +02:00
Administrator
b4c26f8106 release: v2.8.9 2026-04-19 14:02:59 +02:00
Administrator
2d8b3f1bf9 perf: final sweep — hot-path allocation, cached log target, sort-header skip
Last round of targeted wins:

  - upload-manager progress callback was allocating a fresh
    { jobId, speedKbs, bytesUploaded } object on every fs stream chunk
    (hundreds of times per second per active job). Now a single entry
    is created at job start and mutated in place — zero allocations
    on the steady-state progress tick.

  - upload-manager stats timer's two separate activeJobs.values()
    scans (globalSpeedKbs + inProgressBytes) merged into one pass.

  - clouddrop-upload.js reuses a single Buffer.allocUnsafe(chunkSize)
    across all chunks, taking subarray() only for the tail chunk.
    A 1 GB upload no longer allocates 64× 16 MB = 1 GB of short-lived
    buffers — real GC relief during many-file batches.

  - _resolveUploadLogTarget is now cached; the fallback ladder runs
    once per session (or when the user changes the log path / daily-log
    date rolls), not on every 500ms flush.

  - renderRecentUploadsPanel skips updateRecentSortHeaders on the
    append-only fast path — sort state hasn't changed, headers don't
    need recomputing.
2026-04-19 14:02:34 +02:00
Administrator
c73108afff release: v2.8.8 2026-04-19 13:56:02 +02:00
Administrator
f16dd9ffa6 perf: lazy history refresh + append-only recent panel + queue-cleanup merge
Three more targeted wins:

  - loadHistory() was called unconditionally on every handleBatchDone,
    doing an IPC roundtrip + full history-table rebuild even when the
    user is on the Upload tab and can't see it. Now it sets a dirty
    flag and the actual refresh is deferred until the user switches
    to the Verlauf tab. On a fresh tab click it always runs.

  - renderRecentUploadsPanel append-only fast path: when the sort is
    'date desc' (the default) and the dataset only grew, the panel
    inserts the new rows at the top via insertAdjacentHTML instead
    of rebuilding the 5000-row tbody from scratch. Length shrinks or
    sort-change still trigger a full rebuild.

  - handleBatchDone's removeFromQueueOnDone cleanup now does one pass
    (build keep-list + detach from index together) instead of two
    separate filter() scans over queueJobs.
2026-04-19 13:55:37 +02:00
Administrator
1bcd7a2078 release: v2.8.7 2026-04-19 13:39:02 +02:00
Administrator
879f6ade0e perf: O(1) lookups for selection buttons, applySummaryResults, file-drop dedup; batched upload log
Four more wins targeting batch-heavy paths:

  - updateQueueActionButtons replaced three O(n) queueJobs.some() scans
    with a single O(|selection|) pass over selectedJobIds, using the
    existing _jobIndexById map. Selection change cost on a 1000-job
    queue drops from ~3000 comparisons to |selection|.

  - applySummaryResults built a (fileName+hoster)→job Map once per call
    instead of running queueJobs.find() per result. Big batches
    (hundreds of files × multiple hosters) no longer scale O(n²).

  - addPathsToQueue and the folder-monitor auto-queue path built their
    dedup Set up front instead of running .find() per incoming path.
    Picking a folder with thousands of files now dedups in O(n+m)
    instead of O(n×m).

  - appendUploadLog became async + buffered like debugLog. A burst of
    20 files completing within a second becomes one fs.appendFile
    instead of 20 fs.appendFileSync that each blocked the main event
    loop. Fallback ladder (primary → Desktop → userData) is preserved;
    pending buffer flushes synchronously on before-quit.
2026-04-19 13:38:39 +02:00
Administrator
73e7190913 release: v2.8.6 2026-04-19 13:19:28 +02:00
Administrator
8f304f91d8 perf: buffered debug-log writer, scroll rAF-throttle, Set dedup for recent panel
Three more rounds of lag removal aimed at heavy upload sessions:

  - main-process debugLog() was doing fs.appendFileSync on every call
    and was firing hundreds of times per second during busy uploads
    (progress transitions, unhandled rejection traces, folder-monitor
    events). Replaced with an in-memory buffer flushed every 500ms via
    async appendFile — the main event loop is no longer blocked per
    line. Buffered entries flush synchronously on before-quit.

  - the renderer's 'RX upload-progress' / 'RX upload-stats' listeners
    were emitting one IPC roundtrip per event. For 20 concurrent jobs
    that's 80 IPC messages/sec just for logging. They now skip the
    debug call on the hot 'uploading' tick and only log transitions.

  - _onQueueScroll now coalesces scroll events via requestAnimationFrame
    so a fast trackpad fling triggers one virtual render per frame
    instead of one per wheel event.

  - maybeAddSessionFile switched from O(n) sessionFilesData.some() dedup
    to an O(1) Set lookup keyed on (link, filename, host). Adding 1000
    results to an already-populated panel drops from ~500ms to <5ms.
2026-04-19 13:19:04 +02:00
Administrator
ae46d90dc2 release: v2.8.5 2026-04-19 12:59:33 +02:00
Administrator
9158949480 perf: memoize queue sort, dedup stats scan per tick, skip no-op DOM writes
Three more wins on top of the previous pass:
  - sortQueueJobs memoizes the result for static sort keys
    (filename, host, size) — these don't change during upload, so
    every 200ms progress render now reuses the same sorted array
    instead of running an O(n log n) Collator compare.
  - _computeQueueStats caches within a single tick via queueMicrotask.
    updateStatusBar + updateStatsPanel are always called back-to-back
    and now share one queue scan instead of running two.
  - _updateRowInPlace writes DOM values only when they actually
    changed. Idle/queued/done rows (the majority) incur zero DOM
    mutations per progress tick.
2026-04-19 12:59:10 +02:00
Administrator
571d507889 release: v2.8.4 2026-04-19 12:27:46 +02:00
Administrator
85287aa620 perf: kill lag with 1000s of rows during upload
The two worst hot paths were:
  - clicking a row triggered a full table rebuild with sort+innerHTML
    (queue AND recent panel), and the opposite panel got cleared with
    another full rebuild
  - every upload progress tick (4/sec) scanned queueJobs twice and
    filtered sessionFilesData twice just to update the status bar

Fixes:
  - applyQueueSelectionClasses / applyRecentSelectionClasses toggle the
    .selected class on existing rows instead of rebuilding the tbody.
    Click selection is now O(rendered rows) instead of O(total × sort).
  - maybeAddSessionFile schedules renderRecentUploadsPanel via rAF so
    a batch of 1000 successful uploads coalesces into one render.
  - sortRecentFiles memoizes its result per (sortKey, direction, len)
    — unchanged sort state + unchanged length returns the cached array
    instead of re-sorting thousands of entries.
  - _computeQueueStats now also returns inProgressBytes, dropping the
    second queueJobs scan in updateStatusBar.
  - session done/error counts are maintained incrementally, replacing
    two sessionFilesData.filter().length calls every status-bar tick.
  - handleRowClick uses the _jobIndexById map instead of Array.find.
2026-04-19 12:27:16 +02:00
Administrator
7dc68c7615 release: v2.8.3 2026-04-19 11:54:26 +02:00
Administrator
60ceea41d7 fix: encrypt hoster credentials at rest; history CSV Link column urls-only
Two issues:
1. Verlauf-Export CSV put the opaque file_code in the Link column when
   the upload had no real URL, so the column looked like just a bunch
   of IDs. Now only real http(s) URLs land in that column.
2. Hoster passwords and API keys were stored as plaintext in
   electron-config.json. Now wrapped with Electron's safeStorage (DPAPI
   on Windows, Keychain on macOS, libsecret on Linux) and stored as
   'enc:v1:<base64>'.

Credentials are decrypted on load so in-memory flows stay unchanged,
and backups still export plaintext inside the existing .mhu envelope
so they remain portable between machines/users. Legacy plaintext
configs auto-migrate on next write.
2026-04-19 11:53:59 +02:00
Administrator
b80ca7238d release: v2.8.2 2026-04-19 11:47:17 +02:00
Administrator
415162e058 fix(log): fall back to user's Desktop before AppData, keep daily-log naming
If the configured log path (or the default exe-adjacent path) isn't
writable, we now try the current user's Desktop first — that's where
users actually look — and only fall back to AppData if Desktop is
also unavailable. The daily-log filename suffix is preserved on the
fallback file so the format stays consistent.
2026-04-19 11:46:50 +02:00
Administrator
fdac28040d release: v2.8.1 2026-04-19 11:43:00 +02:00
Administrator
3472f4e1ed fix(import): strip machine-specific paths when importing backup
A backup made on 'Server A' carries absolute paths (logFilePath,
folderMonitor.folderPath, pendingQueue file paths) that do not exist
on 'Server B' — leading to silent log-write failures, folder-monitor
start errors on missing directories, and queue jobs pointing at
non-existent files.

On import, now:
  - clear logFilePath if its parent directory doesn't exist here
  - clear folderMonitor.folderPath + disable it if the directory is missing
  - clear pendingQueue (queue state is inherently per-machine)

Also harden startup: folder-monitor auto-start now verifies the path
exists and persists enabled=false if not, so one missing-path launch
doesn't keep retrying forever.
2026-04-19 11:42:33 +02:00
Administrator
62a459353a release: v2.8.0 2026-04-19 11:36:09 +02:00
Administrator
9a7354fc55 feat(recent): export all recent uploads (name+link+hoster+time)
Adds an 'Exportieren' button next to 'Alle entfernen' that writes a
pipe-delimited log of every row currently shown in the recent-uploads
panel — so session data doesn't get lost if the log file path is wrong.

Also fixes appendUploadLog silently failing: if the configured path is
unwritable (e.g. C:/Users/<nonexistent>/...), entries now go to
<userData>/fileuploader-fallback.log and the renderer warns once.
2026-04-19 11:35:41 +02:00
Administrator
299fa8a4e5 release: v2.7.9 2026-04-17 16:55:18 +02:00
Administrator
161357522e fix(backup): don't pass click event as legacy password
addEventListener('click', doBackupImport) was passing the MouseEvent
as the first argument, which got forwarded to pbkdf2 as an Object.
2026-04-17 16:54:53 +02:00
Administrator
e3c8ccdca4 release: v2.7.8 2026-04-17 11:23:01 +02:00
Administrator
edb614f985 feat(backup): import legacy password-encrypted backups
Try app-internal key first (new format); on failure, signal the
renderer to prompt for the old password and retry. Lets users import
.mhu files that were exported with a custom password in v2.7.6 or
earlier without downgrading.
2026-04-17 11:22:33 +02:00
Administrator
90c7fe297d release: v2.7.7 2026-04-17 11:17:53 +02:00
Administrator
3e9483e222 feat(backup): drop password prompt on export/import
File stays AES-GCM encrypted with a fixed app-internal key — opaque
without the app, which is the only protection we actually need for
locally-stored API keys. Removes the modal and both password dialogs.
2026-04-17 11:17:21 +02:00
Administrator
43433cbc00 release: v2.7.6 2026-04-11 15:09:43 +02:00
Administrator
6780cf3261 fix(clouddrop): route chunk PUTs via upload.clouddrop.cc (bypass CF)
Only the 16 MB chunk stream needs the upload subdomain; init and
complete are tiny and can stay on the main host.
2026-04-11 15:09:13 +02:00
Administrator
0bf3061852 release: v2.7.5 2026-04-11 07:31:11 +02:00
Administrator
79cf8ad002 fix(clouddrop): never throw after all chunks uploaded
/upload/complete was failing (non-JSON response, missing fileId, or
post-processing timeout) after all bytes were already on the server,
causing upload-manager to retry the entire multi-GB upload — which
corrupts the server-side file since two uploads end up interleaved.

Now /complete failures are swallowed and sessionId is used as the
file_code fallback. Upload is considered done once all chunks are in.
2026-04-11 07:30:39 +02:00
Administrator
215a10186e release: v2.7.4 2026-04-11 07:25:50 +02:00
Administrator
f955064524 fix(clouddrop): only upload, skip share-link generation entirely 2026-04-11 07:25:23 +02:00
Administrator
c6c59ce868 release: v2.7.3 2026-04-11 07:18:13 +02:00
Administrator
cba69a7806 fix(clouddrop): retry share-link during post-processing, never fail upload
Upload completes on server but file is still being processed, so
share-link fails. Retry up to 6x with backoff; on final failure, use
fileId-based fallback URL instead of throwing — prevents upload-manager
from retrying the entire multi-GB upload.
2026-04-11 07:17:45 +02:00
Administrator
bc32f4dc95 release: v2.7.2 2026-04-11 07:14:57 +02:00
Administrator
7db08a6ab3 fix(clouddrop): trailing slash on /files endpoint to avoid 301 2026-04-11 07:14:30 +02:00
Administrator
237da99523 release: v2.7.1 2026-04-11 07:12:55 +02:00
Administrator
ff8b0799e0 fix(clouddrop): cap concurrent TCP connections at 50 via undici Agent
Defensive guard to stay under server's cd_conn 100/IP limit even
with aggressive parallel uploads and keep-alive pooling.
2026-04-11 07:12:25 +02:00
Administrator
84f117584c release: v2.7.0 2026-04-11 06:55:52 +02:00
Administrator
1164da37ea feat: add Clouddrop.cc as upload hoster (API key auth, chunked uploads)
- New lib/clouddrop-upload.js with chunked upload support (16 MB chunks)
- Auth via Bearer token (cd_XXX format)
- Files < 16 MB: simple multipart POST /api/cloud/upload
- Files > 16 MB: chunked protocol (init → PUT chunks → complete)
- After upload: auto-creates permanent share link via /api/cloud/share-link
- Health check verifies API key by listing root files

Registered in:
- lib/config-store.js (HOSTER_NAMES, templates, DEFAULTS)
- main.js (hosterAccountHasCreds, checkClouddropHealth, runHosterHealthCheck)
- lib/upload-manager.js (_executeUpload dispatch)
- renderer/app.js (HOSTERS, HOSTER_ADD_OPTIONS, getHosterLabel)

Tests: 74/74 pass. ESLint: 0/0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 06:55:21 +02:00
Administrator
eba85fc924 release: v2.6.9 2026-04-06 23:26:11 +02:00
Administrator
849b1e340b feat: 'Alle entfernen' button for recent files panel
Adds a red 'Alle entfernen' button next to the 'Zuletzt erzeugte
Upload-Links' label that clears all entries from the recent files
panel after confirmation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:25:42 +02:00
Administrator
6af0463425 release: v2.6.8 2026-04-06 23:14:08 +02:00
Administrator
e7dd91ae59 feat: resizable queue table columns (JDownloader-style)
Drag the right edge of any queue column header to resize it. Cursor
changes to col-resize on hover. Widths are saved to localStorage and
restored on next launch.

- Resizer handles in all 8 queue table columns
- Resize state visible via dragging class + body cursor override
- Min width 40px, no max (table can scroll horizontally)
- Click on resizer doesn't trigger column sort
- Persisted across sessions via localStorage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:13:40 +02:00
Administrator
e02926e849 release: v2.6.7 2026-04-06 23:02:20 +02:00
Administrator
31d157b695 test: 3 new tests for addJobs (74/74 pass)
- addJobs injects new tasks into running batch (verified concurrent execution)
- addJobs rejects duplicate jobIds already in batch
- addJobs returns added=0 when not running

These tests verify the fix in v2.6.3 (files added during upload now
get injected into the running batch via addJobsToBatch).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:56:26 +02:00
Administrator
cb6d61a406 🐛 fix: files added during upload now actually get uploaded
When user added new files during an active upload (drag-drop, picker
or folder monitor with pre-selected hosters), the files were pushed to
selectedFiles but NO queue jobs were created (because updateUploadView
skips buildQueuePreview during uploading=true).

The files briefly showed up via folder monitor's direct buildQueuePreview
call, but then handleBatchDone → syncSelectedFilesFromQueue removed them
from selectedFiles because they had no queue jobs.

Now: applyHosterSelection() and folder monitor both detect added files
during upload and:
1. Build preview jobs for the new files
2. Reset them to 'queued' status
3. Inject them into the running batch via addJobsToBatch IPC

The upload-manager has duplicate protection so re-injection is safe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:46:31 +02:00
Administrator
00bf6f126d release: v2.6.6 2026-03-28 19:49:14 +01:00
Administrator
c197a004c8 Add full upload history export and keep complete history 2026-03-28 19:48:28 +01:00
Administrator
29ab989cbe release: v2.6.5 2026-03-26 11:08:48 +01:00
Administrator
b75930cb29 refactor: unify queue start status handling 2026-03-26 11:08:01 +01:00
Administrator
ad46c48c64 release: v2.6.4 2026-03-26 11:01:53 +01:00
Administrator
f288ced84b fix: allow global start to retry failed queue items 2026-03-26 11:00:46 +01:00
Administrator
a3e956e085 release: v2.6.3 2026-03-26 10:18:23 +01:00
Administrator
8b68a7a07e fix: prevent retry jobs from getting stuck in waiting state 2026-03-26 10:17:15 +01:00
Administrator
a75aa85712 release: v2.6.2 2026-03-25 00:15:20 +01:00
Administrator
a5b07c0f73 🐛 fix: 'Ausgewählte starten' on queued jobs now force-adds to batch
Previously, clicking 'Ausgewählte starten' on 'Wartet' jobs during an
active upload just showed a toast. But the jobs might NOT actually be
in the batch (skipped during task building).

Now: ALL selected queued/error/aborted jobs are sent to addJobsToBatch.
The upload-manager has duplicate protection (checks jobAbortControllers)
so jobs already in the batch are skipped. Jobs NOT in the batch get
added and start uploading immediately.

Toast now shows exact counts: "X hinzugefügt, Y waren schon im Batch"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:14:53 +01:00
Administrator
ead6f97115 release: v2.6.1 2026-03-25 00:07:45 +01:00
Administrator
f642122726 🐛 fix: show feedback when 'Wartet' jobs are already in batch
- 'Ausgewählte starten' on already-queued jobs during upload now shows
  toast: "X Jobs warten bereits auf ihren Upload-Slot"
- Only error/aborted/skipped jobs are added to the running batch
  (prevents duplicate task creation for already-queued jobs)
- Toast confirms when error jobs are added to batch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:07:12 +01:00
Administrator
6bd49d80b1 release: v2.6.0 2026-03-24 19:36:59 +01:00
Administrator
bf7f35d06c feat: auto-deduplicate queue against upload log on startup
When the app restarts with a restored queue, it now automatically
reads all fileuploader.log files and removes jobs that were already
successfully uploaded in a previous session.

This prevents re-uploading files that completed before a crash/close.
The dedup runs silently before the UI renders — no user action needed.

Also adds 'read-own-upload-log' IPC that reads all log variants
(base + daily logs) without file picker.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:36:31 +01:00
Administrator
a7b24ec363 release: v2.5.9 2026-03-24 10:02:54 +01:00
Administrator
24cb096ba9 🐛 fix: log import now permanently removes jobs from queue
buildQueuePreview() was re-creating removed jobs because they weren't
in _completedUploadKeys. Now log-imported file+hoster combos are added
to _completedUploadKeys so they stay removed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:02:29 +01:00
Administrator
9b493c7aab release: v2.5.8 2026-03-24 09:57:34 +01:00
Administrator
e07db0532a feat: import upload log to remove already-uploaded jobs from queue
New 'Log importieren' button in queue actions. Opens file picker for
.log/.txt files, parses the fileuploader.log format:
  date|hoster|link||filename|

Matches each log entry against queue jobs by filename+hoster (case-
insensitive). Removes matching jobs that are already uploaded,
shows toast with count.

Use case: after a crash/restart, import the log from a previous
session to skip files that were already successfully uploaded.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:57:09 +01:00
Administrator
ad9299e74c release: v2.5.7 2026-03-23 18:20:22 +01:00
Administrator
ff6f7f8612 🐛 fix: addJobs tracks promises so batch-done waits for them
Previously addJobs() was fire-and-forget — added jobs ran as orphaned
promises. When the original batch completed, batch-done fired and
uploadManager was set to null while added jobs were still running.

Now: added job promises are tracked in _additionalPromises array.
startBatch drains this array after the original tasks complete,
ensuring batch-done only fires when ALL jobs (original + added) finish.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:19:59 +01:00
Administrator
3c84679df1 release: v2.5.6 2026-03-23 18:15:54 +01:00
Administrator
e1b03605fa feat: retry/start selected jobs while upload batch is running
Previously, 'Erneut versuchen' and 'Ausgewählte starten' did nothing
when a batch was already running (uploading=true). Failed jobs were
set to 'Wartet' but never actually uploaded because they couldn't be
added to the running batch.

New: upload-manager.addJobs() allows adding tasks to a running batch.
When a batch is active and user retries/starts jobs, they're injected
into the running batch via IPC 'add-jobs-to-batch'. The upload manager
starts processing them immediately using the existing semaphores.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:15:31 +01:00
Administrator
a1a3e87de8 release: v2.5.5 2026-03-23 18:09:07 +01:00
Administrator
17fbb98c13 🐛 fix: skipped jobs now show error instead of stuck 'Wartet' forever
When buildUploadTasksFromJobs skips jobs (e.g. no valid account for
a hoster), the main process now returns their IDs + reason. The
renderer marks them as 'error' with a descriptive message instead of
leaving them stuck in 'Wartet' (queued) status with no feedback.

Previously: jobs silently stayed at 'Wartet' forever if their hoster
had no configured/enabled account. User had no idea why they weren't
uploading.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:08:41 +01:00
Administrator
c7343175ca release: v2.5.4 2026-03-23 08:04:13 +01:00
Administrator
d538c7da4f 🐛 fix: account fallback now works for ALL files in batch, not just first
When Account A failed, only the first file got the fallback to Account B.
All subsequent files in the same batch still tried Account A (wasting
all retries), then skipped fallback because _failedAccounts already
had the key.

Now: before the retry loop, each job checks if its account is already
known-failed and immediately switches to the fallback account, avoiding
wasted retries on a known-bad account.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:03:44 +01:00
Administrator
f4073a7ada release: v2.5.3 2026-03-22 20:32:40 +01:00
Administrator
94c3c5e4ac 🔧 chore: let→const for never-reassigned Sets/Maps/objects
ESLint prefer-const auto-fix: 12 variables changed from let to const
where the reference is never reassigned (Maps, Sets, sort state objects).

All tools clean:
- ESLint: 0 errors, 0 warnings
- Tests: 70/70 pass
- npm audit (runtime): 0 vulnerabilities

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:29:34 +01:00
Administrator
39b3971bbe release: v2.5.2 2026-03-22 20:20:07 +01:00
Administrator
d02d6944d3 feat: 'Ausgewählte starten' works for failed/aborted jobs too
Previously, 'Ausgewählte starten' only picked up jobs with status
'preview' or 'queued', silently ignoring failed/aborted/skipped jobs.
Users had to click 'Erneut versuchen' separately first.

Now it resets error/aborted/skipped jobs to 'queued' and starts them
in one click — combining retry + start into a single action.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:19:42 +01:00
Administrator
3a890301a5 release: v2.5.1 2026-03-22 19:44:41 +01:00
Administrator
68fc064999 🐛 fix: Ctrl+A selects correct panel (queue vs recent files)
Ctrl+A now properly respects which panel the user last clicked:
- Click in queue table → Ctrl+A selects all queue jobs
- Click in recent files panel → Ctrl+A selects all recent files
- Clicking one panel clears the other panel's selection

Previously, if any recent file was ever selected, Ctrl+A would
always select recent files even when the user was working in the queue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:44:15 +01:00
Administrator
f6afdad5ba release: v2.5.0 2026-03-22 16:34:35 +01:00
Administrator
d7f9cd510f feat: upload starts immediately, no blocking health check alert
Previously, the auto health check before upload would block with an
alert dialog if any hoster check failed (e.g. "byse.sx: fetch failed"),
preventing the upload from starting entirely.

Now the upload starts immediately regardless of health check results.
The startup account check still runs in the background on app launch.
Failed hosters will naturally retry during the actual upload via the
existing retry/fallback mechanism in upload-manager.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 16:34:12 +01:00
Administrator
7fe4a92b66 release: v2.4.4 2026-03-22 14:58:49 +01:00
Administrator
79bce36057 chore: update lockfile 2026-03-22 14:58:26 +01:00
Administrator
26fabaa5c1 🔧 chore: ESLint clean — 0 errors, 0 warnings
- Disable detect-object-injection (78 false positives from config lookups)
- Suppress 2 safe regex warnings in vidmoly HTML parser with comments
- Suppress 2 async loop condition warnings (modified between awaits)

ESLint: 0 errors, 0 warnings. Tests: 70/70 pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 14:58:09 +01:00
Administrator
e229df97f0 chore: additional eslint rules 2026-03-22 14:54:04 +01:00
Administrator
9a32a554e4 chore: update lockfile after eslint install 2026-03-22 14:53:49 +01:00
Administrator
c82edc8d9e 🔧 chore: add ESLint + security plugin, fix all errors
ESLint with eslint-plugin-security configured and all 6 errors fixed:
- Remove unused 'self' variable (doodstream-upload.js)
- Remove unused 'statusCode' destructure (voe-upload.js)
- Remove unused 'powerSaveBlocker' import (main.js)
- Remove dead 'setHealthCheckStatus' function (app.js)
- Add URLSearchParams to ESLint globals
- Rename unused 'mode' param to '_mode'

82 remaining warnings are all security/detect-object-injection
false positives (normal config object access patterns).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 14:53:26 +01:00
Administrator
f2fdeef5d1 release: v2.4.3 2026-03-22 14:50:06 +01:00
Administrator
7e0d4e0b8f chore: remove unused imports (powerSaveBlocker, statusCode) 2026-03-22 14:49:43 +01:00
Administrator
ac7ed316f3 chore: remove unused variable, update package metadata 2026-03-22 14:49:20 +01:00
Administrator
cb70b47242 ♻️ refactor: remove redundant 'X abbrechen' context menu items
'Hoster entfernen' already cancels active uploads AND removes jobs.
The separate 'doodstream.com abbrechen' etc. items were redundant
and confused users with two ways to do the same thing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 14:48:51 +01:00
Administrator
8803f09974 release: v2.4.2 2026-03-22 14:45:17 +01:00
Administrator
1d35f024f2 🐛 fix: re-uploading same file after deleting completed job was blocked
_completedUploadKeys tracked done uploads to prevent phantom preview
jobs when removeFromQueueOnDone auto-removes them. But when user
EXPLICITLY deleted a completed job from queue, the key remained —
silently blocking re-upload of the same file+hoster combination.

Now clears the completed key in removeJobFromIndex so deleted files
can be re-added. Safe with removeFromQueueOnDone because
syncSelectedFilesFromQueue runs before next buildQueuePreview.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 14:44:50 +01:00
Administrator
789d5bf555 release: v2.4.1 2026-03-22 11:11:37 +01:00
Administrator
55d6892963 test: 4 stress tests verify critical fixes (70/70 pass)
- file-not-found produces 'nicht gefunden' (not '0 Bytes')
- zero-byte file produces '0 Bytes' error
- empty batch completes immediately with zero counts
- scaleParallelUploads correctly caps per-hoster concurrency

All 70 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 11:11:13 +01:00
Administrator
1a07b2d712 release: v2.4.0 2026-03-22 10:49:14 +01:00
Administrator
4761d6406c 🐛 fix: await clearHistory() to ensure write completes before response
clearHistory() was the only configStore write call not awaited in its
IPC handler. The renderer received 'success' before the file write
completed — closing the app immediately after could leave history intact.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 10:48:46 +01:00
Administrator
4dfe88a565 release: v2.3.9 2026-03-21 15:35:42 +01:00
Administrator
9c04426950 🐛 fix: response body double-read regression + updater JSON safety
- hosters.js apiGet(): fixed regression from v2.3.8 where res.json()
  consumed the body, making res.text() return empty on parse failure.
  Now reads as text first, then parses JSON (matching VOE fix pattern).
- updater.js fetchJson(): same fix — read text first, parse JSON,
  show actual server response in error message on failure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:35:18 +01:00
Administrator
0fd8dd0634 release: v2.3.8 2026-03-21 15:25:07 +01:00
Administrator
e22784cef8 🐛 fix(hosters): API JSON parse safety + URL-encode API key
- apiGet(): wrap res.json() in try-catch with descriptive error
  message when server returns HTML instead of JSON
- URL-encode apiKey in upload server lookup URL template
  (prevents broken URLs if key contains +, &, = chars)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:24:41 +01:00
Administrator
ede5a192ea release: v2.3.7 2026-03-21 15:21:15 +01:00
Administrator
cd07f52916 🐛 fix: distinguish 'file not found' from 'file empty' error message
Previously, both missing files (fs.statSync throws) and 0-byte files
produced the same error "Datei ist leer (0 Bytes)". Now:
- Missing files: "Datei nicht gefunden"
- Empty files: "Datei ist leer (0 Bytes)"

Also adds 3 edge case tests (throttle consume(0), unlimited rate,
semaphore release-without-acquire). All 66 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:20:37 +01:00
Administrator
8c2a83ecee release: v2.3.6 2026-03-21 15:17:26 +01:00
Administrator
765bec03c0 test: add edge case tests for throttle and semaphore
- throttle: consume(0) resolves immediately
- throttle: updateRate(0) makes consume instant (unlimited)
- semaphore: release without acquire clamps active to 0

All 66 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:16:49 +01:00
Administrator
a56594b1df release: v2.3.5 2026-03-21 15:12:34 +01:00
Administrator
9305d806b0 test: add 4 new tests for untested code paths
- Concurrent saves preserve both hosters and globalSettings (write queue)
- Backup recovery when main config file is corrupted (.bak fallback)
- encrypt() rejects empty/null/undefined password
- decrypt() rejects empty/null password

All 63 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:11:56 +01:00
Administrator
a92147939d release: v2.3.4 2026-03-21 15:06:12 +01:00
Administrator
816f675d90 🐛 fix: broken tests, empty password validation, asset URL check
- Fix 3 failing config-store tests: update expectations to match
  multi-account array format (tests passed with old single-object format)
- backup-crypto: reject empty/null passwords on encrypt+decrypt
  instead of producing weak keys silently
- updater: validate assetUrl and assetName before downloading
  to prevent crash on incomplete update metadata

All 59 tests now passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:05:33 +01:00
Administrator
54daaf0410 release: v2.3.3 2026-03-21 14:58:52 +01:00
Administrator
5dabd44b53 🐛 fix: add missing escapeAttr on remote token input value
Consistent with all other user-data HTML attribute insertions
in the codebase that use escapeAttr().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 14:58:14 +01:00
Administrator
f38b3d6a53 release: v2.3.2 2026-03-21 14:24:54 +01:00
Administrator
a4a2eaa736 🐛 fix: scaleParallelUploads inverted, settings lost on close, IPC leak
- scaleParallelUploads used Math.max instead of Math.min, causing MORE
  concurrent uploads instead of limiting them to the global count
- Settings debounce (350ms) was not flushed on app close — user changes
  made right before closing were lost
- onRemoteClientCount IPC listener was re-registered on every
  renderSettings() call, causing listener accumulation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 14:24:14 +01:00
Administrator
ae318d2c62 release: v2.3.1 2026-03-21 14:17:38 +01:00
Administrator
ada3b31ad1 🐛 fix: health check wait timeout, _deletedJobIds memory cleanup
- Add 30-second timeout to health check wait loop in startUpload/
  startSelectedUpload to prevent infinite spin if healthCheckRunning
  gets stuck
- Clear _deletedJobIds Set when batch completes to prevent unbounded
  memory growth over long sessions with many deletions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 14:16:59 +01:00
Administrator
ffc8fb4026 release: v2.3.0 2026-03-21 13:53:06 +01:00
Administrator
f6c9979ac5 🐛 fix: job index rebuild after restore, drop-target visibility, XSS
- Rebuild _jobIndexById after restoring queue from config on startup
  (prevented progress updates from finding restored jobs)
- Show and focus mainWindow when files are dropped on floating
  drop-target while window is minimized/hidden
- Escape status text in queue table HTML to prevent XSS from
  unexpected status values

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 13:52:22 +01:00
Administrator
b5a853b8d4 release: v2.2.9 2026-03-21 13:32:33 +01:00
Administrator
4ecf406660 🐛 fix: folder monitor re-detect deleted files, atomic sync save
- Folder monitor: clear _seenFiles entry on file unlink so re-added
  files (e.g. re-encoded) are detected again
- Sync IPC save (beforeunload): use atomic write pattern with backup
  (.bak) creation, matching the async _atomicWrite behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 13:31:54 +01:00
Administrator
f6d4a7de3d release: v2.2.8 2026-03-21 13:19:46 +01:00
Administrator
27905d66de 🐛 fix: shutdown countdown ignores mode change, timer leaks
Critical: handleShutdownAfterFinish() captured shutdown mode in a
closure at scheduling time — changing mode during countdown was ignored,
causing unexpected system shutdown/restart/sleep.

Now reads shutdownMode at execution time, clears timer when mode
changes to 'nothing', clears orphaned timers before creating new ones,
and adds error handling on exec() calls.

Also: guard stats timer against double-start in upload-manager.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 13:19:07 +01:00
Administrator
af48a485e8 release: v2.2.7 2026-03-21 11:52:50 +01:00
Administrator
9600195954 🐛 fix: batch-done resilience, input validation, VOE JSON parse
- batch-done handler: appendHistory failure no longer prevents the
  upload-batch-done event from reaching the renderer (UI would get stuck)
- remote:input-event: validate x/y as finite numbers before passing
  to sendInputEvent (prevents NaN/Infinity crash)
- VOE upload server: wrap JSON.parse in try-catch with clear error
  message instead of raw stack trace

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 11:52:13 +01:00
Administrator
55eee8a42e release: v2.2.6 2026-03-21 11:46:41 +01:00
Administrator
61e458b8ea 🐛 fix: skip 0-byte files, fix drag-drop highlight flicker
- Upload manager now rejects empty files (0 bytes) with clear error
  message instead of sending useless uploads to the server
- Fix drag-drop zone highlight flickering caused by dragleave firing
  on child elements (classic browser bug, fixed with enter/leave counter)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 11:46:04 +01:00
Administrator
0fb9d2f62b release: v2.2.5 2026-03-21 11:40:58 +01:00
Administrator
b0a2eda131 🐛 fix(remote): clean up auth timeout and client state on WebSocket error
The error handler was missing clearTimeout for the auth timeout timer
and didn't clean up authenticated client state (signaling disconnect,
destroying capture window when last client drops).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 11:40:18 +01:00
Administrator
389be8f0fc release: v2.2.4 2026-03-21 11:21:46 +01:00
Administrator
6d3b2d3a86 🐛 fix: upload button stuck, abort handling, filename escaping
- Upload button no longer gets permanently stuck if startUpload()
  throws after health check (try-catch with uploading=false reset)
- Wait for running health check instead of silently blocking upload
- Add abort signal check in VOE/Vidmoly upload generators
- Escape filenames with quotes/backslashes in multipart form headers
  (all 4 uploaders: doodstream, voe, vidmoly, byse)
- Validate backup import structure before overwriting config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 11:21:09 +01:00
Administrator
d601bd7986 release: v2.2.3 2026-03-21 10:20:43 +01:00
Administrator
7ba2c63d51 🐛 fix: config race conditions, quit safety, update data loss
- Config write serialization via _writeQueue prevents concurrent
  read-modify-write races between settings/queue/history saves
- Cancel active uploads on app quit (prevents zombie processes)
- Persist queue before update install (prevents queue loss)
- Sync IPC save in beforeunload (guarantees save before close)
- Fix double configStore.load() call
- Guard against status regression in handleProgress (done→uploading)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 10:20:07 +01:00
Administrator
9ea9212637 release: v2.2.2 2026-03-21 09:03:48 +01:00
Administrator
a64ebd1587 feat(queue): add "Hoster entfernen" submenu to context menu
Right-click on queue now shows a "Hoster entfernen ▸" submenu listing
all hosters with job count (e.g. "Vidmoly (3)"). Clicking removes all
jobs for that hoster, cancels active uploads, and saves immediately.

Also fixes submenu viewport flip measurement (was reading offsetWidth
on display:none elements).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 09:03:13 +01:00
Administrator
ccfb7c18ba release: v2.2.1 2026-03-21 08:46:57 +01:00
Administrator
176cadc2dd 🐛 fix(queue): deleted jobs reappear after restart
Three root causes fixed:
- handleProgress() re-created deleted jobs from stale progress callbacks
- Queue save was debounced (10s during uploads), deletion lost on app close
- Delete was blocked during active uploads (removed !uploading guard)

Now: deletions save immediately, deleted IDs are tracked to prevent
re-creation, and active uploads are cancelled when their jobs are deleted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 08:46:19 +01:00
Administrator
5569c690a1 release: v2.2.0 2026-03-20 16:11:41 +01:00
Administrator
beba96c21b feat(doodstream): add OTP input support for web login
When Doodstream requires 2FA, the account modal now dynamically
shows an OTP input field so the user can enter the code from
their email and complete the login without restarting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:09:33 +01:00
Administrator
8f077868cc fix: account for invisible DWM frame borders in click mapping
Windows 10/11 getBounds() includes ~7px invisible resize borders that
are not included in the window capture, causing click offset.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:00:18 +01:00
Administrator
f19d883a69 fix: native resolution capture + correct click offset for title bar
- Remove restrictive resolution constraints, capture at native res
- Account for window frame/title bar when mapping click coordinates
  (capture includes title bar but sendInputEvent is content-relative)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:55:46 +01:00
Administrator
b4211a7d50 fix: use getMediaSourceId() for exact window capture
Instead of enumerating all sources and matching by title (which falls
back to full screen capture), use BrowserWindow.getMediaSourceId() to
get the exact media source ID for the app window.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:52:22 +01:00
Administrator
c9d038d588 debug: send capture errors back via signaling channel
If getCaptureStream fails, send error back through WebSocket so it
appears in proxy logs for diagnosis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:49:07 +01:00
Administrator
82b597506b debug: add IPC logging from capture window to main process
Capture window logs now forwarded to main process via IPC to diagnose
why video tracks are missing from the WebRTC answer SDP.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:46:18 +01:00
Administrator
6b47181572 fix: serialize WebRTC objects before IPC transfer
RTCSessionDescription and RTCIceCandidate objects lose their properties
when sent through Electron's contextBridge IPC. Convert to plain objects
with explicit property extraction before sending.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:40:57 +01:00
Administrator
d8a2ec6443 fix: robust capture source detection + diagnostic logging
- desktopCapturer now searches window+screen types with fallbacks
- Partial title match and screen fallback if exact match fails
- Error messages sent back from capture window via IPC
- Detailed logging for capture source selection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:38:41 +01:00
Administrator
efcaa760df fix: buffer WebRTC signaling messages until capture window is ready
The capture window creation is async but the browser's WebRTC offer
arrives immediately after auth. Messages were silently dropped during
window initialization, preventing video stream from establishing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 07:39:20 +01:00
Administrator
e19c36b1fb release: v2.1.1 2026-03-12 07:26:50 +01:00
Administrator
a5c5041ec8 fix: add STUN server for WebRTC NAT traversal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 07:26:28 +01:00
Administrator
7b9362756d release: v2.1.0 2026-03-12 07:20:07 +01:00
Administrator
ad9b866afe chore: add ws dependency for remote control
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 07:19:38 +01:00
Administrator
f13bf7f5bc feat(remote): add Fernsteuerung settings panel with token management and status display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 06:58:29 +01:00
Administrator
d1513a58b3 feat(remote): wire up remote server, capture window, and IPC handlers in main process
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 06:56:47 +01:00
Administrator
90bb298dbe feat(remote): add remote control bridge methods to preload 2026-03-12 06:56:09 +01:00
Administrator
9fa047b399 feat(remote): add WebSocket server with auth, signaling relay, and rate limiting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 06:54:51 +01:00
Administrator
c2932a1577 feat(remote): add remote control defaults to config store
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 06:54:46 +01:00
Administrator
92e94b1e8a feat: add remote-capture preload and HTML for WebRTC screen sharing
Adds the hidden BrowserWindow assets for remote desktop streaming:
- lib/remote-capture-preload.js: IPC bridge for desktopCapturer source ID,
  WebRTC signaling relay, input event forwarding, and client count tracking
- lib/remote-capture.html: WebRTC logic handling multiple concurrent clients
  via RTCPeerConnection, stream capture via getUserMedia with desktop source ID,
  and DataChannel input forwarding

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 06:54:07 +01:00
Administrator
23dd010a95 release: v2.0.6
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 06:02:46 +01:00
Administrator
0c945e21b8 fix: prevent double-click race condition in upload start
Move `uploading = true` guard to immediately after the check in both
startUpload() and startSelectedUpload(), before any async calls.
Previously the flag was set after await executeHealthCheck(), allowing
a fast double-click to bypass the guard and start duplicate batches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 06:02:30 +01:00
Administrator
6233b192ab release: v2.0.5
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 05:40:35 +01:00
Administrator
fecf773caf fix: prevent duplicate queue entries after removeFromQueueOnDone
- Track completed uploads in _completedUploadKeys Set so buildQueuePreview
  won't re-create jobs for files already uploaded this session
- Deduplicate queue on restore: when loading pendingQueue, keep only the
  job with the best status per file+hoster pair (removes existing dupes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 05:40:25 +01:00
Administrator
68e05503f6 release: v2.0.4
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 05:25:33 +01:00
Administrator
3d8979797c fix: queue table not updating during uploads (virtual scrolling bug)
The in-place update path for virtual scrolling would silently skip the
full DOM rebuild when row IDs didn't match due to sort order changes.
The break statement only exited the for-loop but return still fired,
preventing any update. Now tracks allMatch flag and falls through to
innerHTML rebuild when needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 05:25:22 +01:00
Administrator
261463bbe5 release: v2.0.3
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 05:23:13 +01:00
Administrator
5aaa1ef578 feat: daily log files instead of per-session
Log files are now created per day (e.g. fileuploader-2026-03-12.log)
instead of per app session. Multiple sessions on the same day append
to the same file. Rolls over automatically at midnight.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 05:23:05 +01:00
Administrator
127d3fd830 release: v2.0.2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 05:18:15 +01:00
Administrator
c79f61b4b5 perf: use cached Intl.Collator for all sort operations
Replaces inline localeCompare() calls with a shared Intl.Collator
instance across queue, recent files, and history sorting. Eliminates
~12,000 Collator object allocations per sort on large queues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 05:18:06 +01:00
Administrator
3d8e81560c release: v2.0.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 05:12:49 +01:00
Administrator
cd3493e52c fix: hover flicker on queue rows during active uploads
Virtual scrolling (>200 rows) now uses in-place DOM updates when the
visible range hasn't changed, preserving :hover state instead of
rebuilding innerHTML on every progress tick.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 05:12:39 +01:00
Administrator
d53eea443e feat: multi-account support with primary/fallback and separate API/login types
- Multiple accounts per hoster with drag-sortable priority (primary + fallbacks)
- Separate account types: Web Login and API selectable per hoster
- Account fallback: after all retries fail, automatically switches to next fallback account
- Fix: Byse health check returning [Fehler] OK when API responds with msg "OK"
- Fix: retry during active upload sets status to "Wartet" instead of "Bereit"
- Config migration from single-object to multi-account array format

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 05:00:33 +01:00
Administrator
2c9726a33d fix: session-based counters and hoster cancel context menu
- Done/Error counters now use sessionFilesData (survives removeFromQueueOnDone)
- Uploaded/Total bytes tracked via session accumulators (never decrease)
- Errors no longer shown in Files list (stay in queue for retry)
- Right-click context menu: "hoster abbrechen" cancels all jobs for a hoster

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 04:34:11 +01:00
Administrator
052bd940f1 feat: add account enable/disable toggle
- Toggle button on each account card to activate/deactivate hosters
- Disabled accounts are greyed out and excluded from upload selection
- Credentials are preserved when deactivated

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 04:17:46 +01:00
Administrator
0851bb09fc feat: floating drop target window and English column labels
- Small always-on-top drop target window (toggle in Settings > Allgemein)
- Files dropped on it get added to the queue with hoster modal
- Auto-shows on app start if previously enabled
- Column headers now in English (Filename, Uploaded/Size, Progress)
- Statusbar labels in English (Connections, Total)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 04:11:01 +01:00
Administrator
c0b9ec9d17 feat: add drop target overlay and statusbar colons
- Full-window drop overlay with large "+" icon when dragging files over the app
- Works from any tab, not just the upload view
- Added colons to all statusbar labels for consistency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 04:01:09 +01:00
Administrator
a5c1ec362d release: v1.9.5 2026-03-12 03:46:57 +01:00
Administrator
2ad9f2d1eb fix: statusbar shows uploaded/total + add Done counter
- Statusbar: uploaded / total (not remaining) so right side stays constant
- New "Done" counter in statusbar showing completed uploads

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 03:46:30 +01:00
Administrator
13d220bd30 release: v1.9.4 2026-03-12 01:57:49 +01:00
Administrator
22a378d36c feat: hoster preset in folder monitor + badge color fix
- Hoster pre-selection in Ordnerüberwachung settings (only configured accounts shown)
- With preset hosters: files go directly to queue without modal
- Without preset: hoster modal opens as before
- Fix: Aktiv badge now green on initial render

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 01:57:22 +01:00
Administrator
02f208c302 release: v1.9.3 2026-03-12 01:52:58 +01:00
Administrator
0de9236e42 fix: UI polish - settings layout, context menu, folder monitor badge
- Ordnerüberwachung panel: proper section layout matching Allgemein style
- Checkbox rows: compact spacing, checkbox before label via CSS order
- Upload inputs: consistent width, stacked vertically
- Backup section: moved to collapsible panel in settings
- Allgemein panel: collapsible
- Context menu: hidden when queue is empty
- Folder monitor badge: instant update on checkbox/path change
- Separator between system and hoster panels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 01:52:30 +01:00
Administrator
b04de4036f release: v1.9.2 2026-03-12 01:27:51 +01:00
Administrator
2cfd10834e feat: manual update check button in settings + update debug logging
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 01:27:28 +01:00
Administrator
dfe94db1d3 release: v1.9.1 2026-03-12 01:23:20 +01:00
Administrator
dc1c338d97 fix: rcedit import in afterPack - icon was not being embedded in exe
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 01:22:56 +01:00
Administrator
ea35bfa065 release: v1.9.0 2026-03-12 01:22:04 +01:00
Administrator
b5841c69f5 feat: add folder monitoring (Ordnerüberwachung) and fix statusbar display
- New FolderMonitor class with chokidar for watching folders
- Settings UI panel with all options (extensions filter, recursive, auto-start, skip duplicates)
- Auto-queue and auto-upload when files appear in monitored folder
- Fix statusbar to show uploaded/remaining instead of cumulative session bytes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 01:21:42 +01:00
Administrator
0480da0437 feat: add folder support and system tray icon
- Add "+ Ordner" button for recursive folder upload
- Drag & drop auto-detects folders and resolves files recursively
- Minimize to system tray instead of taskbar
- Tray icon with context menu (Öffnen/Beenden)
- Tray tooltip shows upload progress during active uploads
- Fix folder detection heuristic (size === 0, not % 4096)
- Fix concurrent drop guard to prevent double modal
- Fix duplicate "Erneut versuchen" context menu entry
- Add .catch() on async drop handlers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:44:14 +01:00
Administrator
6b2b2ca04c perf: major rendering optimization for large concurrent uploads
- Throttle progress events to 250ms intervals (was every byte chunk)
- Batch UI updates during uploads (render/statusbar/stats every 200ms)
- In-place row updates instead of full innerHTML table rebuild
- Single-pass queue stats computation (was 9 separate array filters)
- Remove CSS transition on progress bars (caused layout thrashing)
- Event delegation for recent files table (was per-row listener rebind)
- Increase persist debounce to 10s during uploads (was 3s)
- Remove redundant "Ziele auswählen" button (hoster selection on file add)
- Dark title bar via nativeTheme

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:18:43 +01:00
Administrator
b4c786cf04 release: v1.8.4 2026-03-11 23:35:45 +01:00
Administrator
92b4a35425 fix: health check only checks hosters with jobs in queue
Previously checked all selected hosters, blocking uploads when an
unrelated hoster (e.g. vidmoly) was down. Now only checks hosters
that actually have jobs to start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 23:35:23 +01:00
Administrator
660d0b76a1 release: v1.8.3 2026-03-11 23:31:10 +01:00
Administrator
d99645a9df feat: custom app icon (arrows-up design)
Replace default Electron icon with custom multi-upload arrows icon.
ICO includes all sizes: 16, 24, 32, 48, 64, 128, 256px.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 23:30:46 +01:00
Administrator
1f3559ab22 release: v1.8.2 2026-03-11 21:45:37 +01:00
Administrator
35334e365f feat: per-session log files
New "Neues Log pro Session" checkbox in settings. When enabled,
each app session creates a separate log file with timestamp
(e.g. fileuploader-2026-03-11_20-30-15.log). File is only created
when an upload actually completes. When disabled, behaves as before
(single appending log file).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:45:08 +01:00
Administrator
ff2991cabd release: v1.8.1 2026-03-11 20:25:48 +01:00
Administrator
399e2fbe70 feat: upload progress display, semaphore fix, context menu polish
- Status bar shows uploaded/total bytes (e.g. "16 GB / 281 GB")
  Total is sum of all queue jobs (100GB x 4 hosters = 400GB)
- Fix semaphore acquisition order: hoster-first then global prevents
  jobs waiting on a hoster slot from wasting global semaphore slots,
  significantly increasing active connection utilization
- Context menu: dynamic count on all labels, singular/plural for
  single selection, user-adjusted grouping with separators

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:25:13 +01:00
Administrator
d955403c7a release: v1.8.0 2026-03-11 19:53:24 +01:00
Administrator
bb30b58037 feat: sticky tab bar, improved context menu, instant retry
- Sticky tab bar: stays fixed at top when scrolling settings/history
- Context menu improvements:
  - Click on empty queue area deselects all selected jobs
  - Dynamic labels with selection count (e.g. "Links kopieren (3)")
  - Singular/plural for single selection ("Link kopieren" vs "Links kopieren")
  - "Alle entfernen" to clear entire queue
  - Reorganized menu items into logical groups with separators
- Instant retry: "Erneut versuchen" now immediately starts uploading
  the selected files instead of just resetting status to preview

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:52:24 +01:00
Administrator
6f939103b9 release: v1.7.1 2026-03-11 19:28:56 +01:00
Administrator
60498fecc4 fix: multiple backup import issues found in code review
- Single atomic write instead of two-phase (prevents split state on crash)
- Timestamped pre-import backup (multiple imports don't overwrite safety net)
- Fix UI refresh: correct function names + refresh globalSettings/alwaysOnTop
- Zero sensitive buffers (key, plaintext, decrypted) after use

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:28:25 +01:00
Administrator
fb4dd94827 release: v1.7.0 2026-03-11 19:21:04 +01:00
Administrator
ffc5b5576b feat: encrypted backup import/export
AES-256-GCM + PBKDF2 encrypted config backup (.mhu files).
Export/import all accounts, settings, and history.
Pre-import safety backup of current config.
Password modal with confirmation for export.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:20:41 +01:00
Administrator
39ccb904ef release: v1.6.9 2026-03-11 13:47:19 +01:00
Administrator
e389b625d6 fix: prevent double context menu on recent files right-click
stopPropagation prevents the event from bubbling to the upload-view
handler which was showing a second context menu.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:46:54 +01:00
Administrator
25a6b77650 fix: multiple bugs found in deep code analysis
- Guard startBatch against null uploadManager in nextTick (race on fast cancel)
- Fix updateSettings not creating globalThrottle when none existed at start
- Fix updateSettings not updating globalSemaphore limit live
- Fix retry pause: 2500ms → 3000ms as intended
- Remove dead isError code in history (was always false after continue)
- Add signal.aborted check in API upload generator (hosters.js)
- Add extra signal check in throttle consume loop for faster abort
- Fix doodstream debug log path (process.cwd → __dirname)
- Fix updater fetchJson signal listener leak
- Make progress column sortable in queue table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 04:16:50 +01:00
Administrator
153ea2b193 fix: atomic config writes to prevent data loss on update/crash
- All config writes now go through _atomicWrite() (write to .tmp, backup
  to .bak, rename .tmp to main config)
- load() falls back to .bak if main config is empty or corrupt
- Prevents 0KB config files caused by process termination during write

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 04:06:41 +01:00
65 changed files with 12514 additions and 3327 deletions

12
.gitignore vendored
View File

@ -2,3 +2,15 @@ node_modules/
release/ release/
__pycache__/ __pycache__/
*.pyc *.pyc
electron-config.json
electron-config.json.bak
electron-config.json.tmp
electron-config.pre-import-*.json
*.log
debug.log
fileuploader.log
account-rotation.log
doodstream-debug.log
upload-debug.log
release-*.log

BIN
assets/app_icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
assets/app_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@ -1,426 +0,0 @@
{
"hosters": {
"doodstream.com": {
"enabled": false,
"apiKey": "",
"username": "",
"password": ""
},
"voe.sx": {
"enabled": true,
"apiKey": "exZEXqkwEnb8eLR79eUI6WVt3JYGFzAfuPsjuGp2nAn7NATGaYhY86NVK5EX1PzD"
},
"vidmoly.me": {
"enabled": true,
"authType": "login",
"username": "bariusgariusdi",
"password": "Paluffel123!"
},
"byse.sx": {
"enabled": true,
"apiKey": "83124r74v61t9dmojm4gz"
}
},
"hosterSettings": {
"doodstream.com": {
"retries": 3,
"maxSpeedKbs": 0,
"parallelCount": 2,
"restartBelowKbs": 0,
"timeIntervalSec": 0,
"maxSizeMb": 0
},
"voe.sx": {
"retries": 3,
"maxSpeedKbs": 0,
"parallelCount": 2,
"restartBelowKbs": 0,
"timeIntervalSec": 0,
"maxSizeMb": 0
},
"vidmoly.me": {
"retries": 25,
"maxSpeedKbs": 0,
"parallelCount": 2,
"restartBelowKbs": 0,
"timeIntervalSec": 1,
"maxSizeMb": 0
},
"byse.sx": {
"retries": 3,
"maxSpeedKbs": 0,
"parallelCount": 2,
"restartBelowKbs": 0,
"timeIntervalSec": 0,
"maxSizeMb": 0
}
},
"globalSettings": {
"alwaysOnTop": false,
"shutdownAfterFinish": "nothing",
"logFilePath": "",
"resumeQueueOnLaunch": true,
"parallelUploadCount": 0,
"scaleParallelUploads": true,
"removeFromQueueOnDone": false,
"pendingQueue": {
"selectedUploadHosters": [
"doodstream.com",
"voe.sx",
"vidmoly.me",
"byse.sx"
],
"selectedFiles": [
{
"path": "C:\\Users\\ploet\\Downloads\\Einfach mal die Fresse halten!!!.mp4",
"name": "Einfach mal die Fresse halten!!!.mp4",
"size": 0
}
],
"queueJobs": [
{
"id": "preview-1773193741983-c1qa7p",
"file": "C:\\Users\\ploet\\Downloads\\Einfach mal die Fresse halten!!!.mp4",
"fileName": "Einfach mal die Fresse halten!!!.mp4",
"hoster": "doodstream.com",
"status": "preview",
"bytesTotal": 0,
"error": null,
"result": null,
"maxAttempts": 0
},
{
"id": "preview-1773193741983-bvlsvn",
"file": "C:\\Users\\ploet\\Downloads\\Einfach mal die Fresse halten!!!.mp4",
"fileName": "Einfach mal die Fresse halten!!!.mp4",
"hoster": "voe.sx",
"status": "preview",
"bytesTotal": 0,
"error": null,
"result": null,
"maxAttempts": 0
},
{
"id": "preview-1773193741983-a7aixs",
"file": "C:\\Users\\ploet\\Downloads\\Einfach mal die Fresse halten!!!.mp4",
"fileName": "Einfach mal die Fresse halten!!!.mp4",
"hoster": "vidmoly.me",
"status": "preview",
"bytesTotal": 0,
"error": null,
"result": null,
"maxAttempts": 0
},
{
"id": "preview-1773193741983-39jnfg",
"file": "C:\\Users\\ploet\\Downloads\\Einfach mal die Fresse halten!!!.mp4",
"fileName": "Einfach mal die Fresse halten!!!.mp4",
"hoster": "byse.sx",
"status": "preview",
"bytesTotal": 0,
"error": null,
"result": null,
"maxAttempts": 0
}
]
},
"scramble": {
"active": false,
"prefix": "",
"suffix": "",
"chars": "both",
"length": 0
}
},
"history": [
{
"id": "batch-1771639560711",
"timestamp": "2026-02-21T02:06:04.634Z",
"total": 3,
"succeeded": 1,
"failed": 2,
"files": [
{
"name": "ssstwitter.com_1770829061540.mp4",
"size": 7799235,
"results": [
{
"hoster": "doodstream.com",
"status": "error",
"error": "Invalid URL",
"download_url": null,
"embed_url": null,
"file_code": null
},
{
"hoster": "byse.sx",
"status": "error",
"error": "Kein Upload-Server erhalten. API-Key pruefen.",
"download_url": null,
"embed_url": null,
"file_code": null
},
{
"hoster": "voe.sx",
"status": "done",
"download_url": "https://voe.sx/nnxl9k1bsmpj",
"embed_url": "https://voe.sx/e/nnxl9k1bsmpj",
"file_code": "nnxl9k1bsmpj"
}
]
}
]
},
{
"id": "batch-1771639617785",
"timestamp": "2026-02-21T02:07:01.083Z",
"total": 4,
"succeeded": 1,
"failed": 3,
"files": [
{
"name": "ssstwitter.com_1770829061540.mp4",
"size": 7799235,
"results": [
{
"hoster": "vidmoly.me",
"status": "error",
"error": "maxRedirections is not supported, use the redirect interceptor",
"download_url": null,
"embed_url": null,
"file_code": null
},
{
"hoster": "byse.sx",
"status": "error",
"error": "Kein Upload-Server erhalten. API-Key pruefen.",
"download_url": null,
"embed_url": null,
"file_code": null
},
{
"hoster": "doodstream.com",
"status": "error",
"error": "Invalid URL",
"download_url": null,
"embed_url": null,
"file_code": null
},
{
"hoster": "voe.sx",
"status": "done",
"download_url": "https://voe.sx/ujoqyizmrayw",
"embed_url": "https://voe.sx/e/ujoqyizmrayw",
"file_code": "ujoqyizmrayw"
}
]
}
]
},
{
"id": "batch-1771639907565",
"timestamp": "2026-02-21T02:13:33.560Z",
"total": 4,
"succeeded": 3,
"failed": 1,
"files": [
{
"name": "video_1770829348221_0hmfi8.mp4",
"size": 107220796,
"results": [
{
"hoster": "vidmoly.me",
"status": "error",
"error": "Vidmoly Upload-Ergebnis: Kein Download-Link gefunden",
"download_url": null,
"embed_url": null,
"file_code": null
},
{
"hoster": "voe.sx",
"status": "done",
"download_url": "https://voe.sx/f38bgbhvia4x",
"embed_url": "https://voe.sx/e/f38bgbhvia4x",
"file_code": "f38bgbhvia4x"
},
{
"hoster": "byse.sx",
"status": "done",
"download_url": "https://byse.sx/zwbsud9yjxks",
"embed_url": "https://byse.sx/e/zwbsud9yjxks",
"file_code": "zwbsud9yjxks"
},
{
"hoster": "doodstream.com",
"status": "done",
"download_url": "https://dsvplay.com/d/cv1y50vfrf7f",
"embed_url": "https://dsvplay.com/e/cv1y50vfrf7f",
"file_code": "cv1y50vfrf7f"
}
]
}
]
},
{
"id": "batch-1771640325234",
"timestamp": "2026-02-21T02:18:52.471Z",
"total": 4,
"succeeded": 2,
"failed": 2,
"files": [
{
"name": "ssstwitter.com_1770829061540.mp4",
"size": 7799235,
"results": [
{
"hoster": "doodstream.com",
"status": "error",
"error": "Invalid URL",
"download_url": null,
"embed_url": null,
"file_code": null
},
{
"hoster": "voe.sx",
"status": "done",
"download_url": "https://voe.sx/y4zhied9n4f5",
"embed_url": "https://voe.sx/e/y4zhied9n4f5",
"file_code": "y4zhied9n4f5"
},
{
"hoster": "vidmoly.me",
"status": "error",
"error": "Vidmoly Upload-Ergebnis: Kein Download-Link gefunden",
"download_url": null,
"embed_url": null,
"file_code": null
},
{
"hoster": "byse.sx",
"status": "done",
"download_url": "https://byse.sx/3caubwbj6jxu",
"embed_url": "https://byse.sx/e/3caubwbj6jxu",
"file_code": "3caubwbj6jxu"
}
]
}
]
},
{
"id": "batch-1771643316134",
"timestamp": "2026-02-21T03:09:10.532Z",
"total": 4,
"succeeded": 4,
"failed": 0,
"files": [
{
"name": "ssstwitter.com_1770829061540.mp4",
"size": 7799235,
"results": [
{
"hoster": "voe.sx",
"status": "done",
"download_url": "https://voe.sx/juoamb17cdea",
"embed_url": "https://voe.sx/e/juoamb17cdea",
"file_code": "juoamb17cdea"
},
{
"hoster": "byse.sx",
"status": "done",
"download_url": "https://byse.sx/mu8p6ikpsabf",
"embed_url": "https://byse.sx/e/mu8p6ikpsabf",
"file_code": "mu8p6ikpsabf"
},
{
"hoster": "vidmoly.me",
"status": "done",
"download_url": "https://vidmoly.me/w/7460ei78oj22",
"embed_url": "https://vidmoly.me/embed-7460ei78oj22.html",
"file_code": "7460ei78oj22"
},
{
"hoster": "doodstream.com",
"status": "done",
"download_url": "https://dsvplay.com/d/l4rm1kbpkgt0",
"embed_url": "https://dsvplay.com/e/l4rm1kbpkgt0",
"file_code": "l4rm1kbpkgt0"
}
]
}
]
},
{
"id": "batch-1773173725103",
"timestamp": "2026-03-10T20:15:25.339Z",
"total": 1,
"succeeded": 1,
"failed": 0,
"files": [
{
"name": "test-e2e-upload.txt",
"size": 22,
"results": [
{
"hoster": "voe.sx",
"status": "done",
"download_url": null,
"embed_url": null,
"file_code": null
}
]
}
]
},
{
"id": "batch-1773176124038",
"timestamp": "2026-03-10T20:55:24.931Z",
"total": 1,
"succeeded": 1,
"failed": 0,
"files": [
{
"name": "Einfach mal die Fresse halten!!!.mp4",
"size": 172248,
"results": [
{
"hoster": "voe.sx",
"status": "done",
"download_url": "https://voe.sx/nlvswooic50v",
"embed_url": "https://voe.sx/e/nlvswooic50v",
"file_code": "nlvswooic50v"
}
]
}
]
},
{
"id": "batch-1773176209320",
"timestamp": "2026-03-10T20:56:59.349Z",
"total": 2,
"succeeded": 2,
"failed": 0,
"files": [
{
"name": "export_1771588307185.mov",
"size": 7330963,
"results": [
{
"hoster": "voe.sx",
"status": "done",
"download_url": "https://voe.sx/qh1jriyz5up7",
"embed_url": "https://voe.sx/e/qh1jriyz5up7",
"file_code": "qh1jriyz5up7"
},
{
"hoster": "doodstream.com",
"status": "done",
"download_url": "https://dsvplay.com/d/q5tib39woqq4",
"embed_url": "https://dsvplay.com/e/q5tib39woqq4",
"file_code": "q5tib39woqq4"
}
]
}
]
}
]
}

85
eslint.config.mjs Normal file
View File

@ -0,0 +1,85 @@
import security from 'eslint-plugin-security';
export default [
{
files: ['**/*.js'],
ignores: ['node_modules/**', 'release/**', 'tests/**'],
plugins: { security },
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
globals: {
require: 'readonly',
module: 'readonly',
exports: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
process: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
setImmediate: 'readonly',
Buffer: 'readonly',
URL: 'readonly',
fetch: 'readonly',
AbortController: 'readonly',
AbortSignal: 'readonly',
navigator: 'readonly',
document: 'readonly',
window: 'readonly',
localStorage: 'readonly',
HTMLElement: 'readonly',
alert: 'readonly',
confirm: 'readonly',
requestAnimationFrame: 'readonly',
queueMicrotask: 'readonly',
Intl: 'readonly',
crypto: 'readonly',
URLSearchParams: 'readonly',
EventSource: 'readonly',
}
},
rules: {
// Security rules
// detect-object-injection disabled: 78 false positives from config lookups like obj[hosterName]
'security/detect-object-injection': 'off',
'security/detect-non-literal-regexp': 'warn',
'security/detect-unsafe-regex': 'warn',
'security/detect-buffer-noassert': 'warn',
'security/detect-eval-with-expression': 'error',
'security/detect-no-csrf-before-method-override': 'warn',
'security/detect-possible-timing-attacks': 'warn',
'security/detect-pseudoRandomBytes': 'warn',
// Code quality
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'no-undef': 'error',
'no-constant-condition': 'warn',
'no-debugger': 'error',
'no-duplicate-case': 'error',
'no-empty': ['warn', { allowEmptyCatch: true }],
'no-ex-assign': 'error',
'no-extra-boolean-cast': 'warn',
'no-func-assign': 'error',
'no-inner-declarations': 'error',
'no-irregular-whitespace': 'error',
'no-unreachable': 'error',
'use-isnan': 'error',
'valid-typeof': 'error',
'eqeqeq': ['warn', 'always'],
'no-caller': 'error',
'no-eval': 'error',
'no-implied-eval': 'error',
'no-new-func': 'error',
'no-throw-literal': 'warn',
'no-self-assign': 'error',
'no-self-compare': 'error',
'no-loss-of-precision': 'error',
'no-dupe-keys': 'error',
'no-unsafe-finally': 'error',
'no-unmodified-loop-condition': 'warn',
'no-template-curly-in-string': 'warn',
}
}
];

36
lib/account-auth.js Normal file
View File

@ -0,0 +1,36 @@
// Decides which credential an upload task should use for a given hoster.
// Extracted from main.js buildTaskFromAccount so the routing can be unit-tested
// without Electron.
//
// DOODSTREAM SPECIAL CASE: prefer the official doodapi.co API key whenever the
// account has one. The web-login path (username/password) drives doodstream's
// browser upload flow, which hands the filecode back inside an XFileSharing
// HTML form. On long/large uploads that form comes back empty (no fn) because a
// per-page-load sess_id token ages out over the multi-minute upload and/or the
// server-side file-registration callback times out — the upload then "succeeds"
// (bytes sent, HTTP 200) but yields no link. The JSON API returns the filecode
// directly in result[0].filecode and authenticates with a persistent api_key,
// so it has no empty-form failure mode for result retrieval. The API path was
// doodstream's ORIGINAL upload path (present since the initial commit); web
// login was added later only as an alternative for keyless accounts — so
// preferring the key here restores the intended primary path, it doesn't fight
// a deliberate choice. Keyless accounts keep using web login unchanged.
function selectUploadAuth(hoster, account) {
if (!account || typeof account !== 'object') return {};
if (hoster === 'doodstream.com' && account.apiKey) {
return { apiKey: account.apiKey };
}
if (account.authType === 'api' && account.apiKey) {
return { apiKey: account.apiKey };
}
if (account.username && account.password) {
return { username: account.username, password: account.password };
}
if (account.apiKey) {
return { apiKey: account.apiKey };
}
return {};
}
module.exports = { selectUploadAuth };

95
lib/backup-crypto.js Normal file
View File

@ -0,0 +1,95 @@
const crypto = require('crypto');
const MAGIC = Buffer.from('MHU1');
const SALT_LEN = 16;
const IV_LEN = 12;
const TAG_LEN = 16;
const KEY_LEN = 32;
const ITERATIONS = 100_000;
const DIGEST = 'sha512';
const ALGO = 'aes-256-gcm';
// Fixed app-internal passphrase — backups are opaque without the app, which is
// enough protection for API keys stored locally. We keep the AES-GCM envelope
// (with random salt/iv) so each export is still distinct and authenticated.
const APP_PASSPHRASE = 'multi-hoster-upload::backup::v1';
function deriveKey(passphrase, salt) {
return crypto.pbkdf2Sync(passphrase, salt, ITERATIONS, KEY_LEN, DIGEST);
}
/**
* Encrypt a config object.
* Returns a Buffer: MHU1 | salt(16) | iv(12) | tag(16) | ciphertext
*/
function encrypt(config) {
const plaintext = Buffer.from(JSON.stringify(config), 'utf-8');
const salt = crypto.randomBytes(SALT_LEN);
const iv = crypto.randomBytes(IV_LEN);
const key = deriveKey(APP_PASSPHRASE, salt);
const cipher = crypto.createCipheriv(ALGO, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
plaintext.fill(0);
key.fill(0);
return Buffer.concat([MAGIC, salt, iv, tag, encrypted]);
}
/**
* Decrypt a .mhu buffer.
* Tries the app's built-in key first; if that fails and a user password is
* provided, falls back to legacy password-based decryption. Throws a special
* error with `needsPassword = true` if the app key fails and no password was
* given, so callers can prompt the user for the legacy password.
*/
function decrypt(buffer, userPassword) {
if (buffer.length < MAGIC.length + SALT_LEN + IV_LEN + TAG_LEN + 1) {
throw new Error('Ungültiges Backup-Format');
}
const magic = buffer.subarray(0, 4);
if (!magic.equals(MAGIC)) {
throw new Error('Keine gültige .mhu Backup-Datei');
}
let offset = MAGIC.length;
const salt = buffer.subarray(offset, offset += SALT_LEN);
const iv = buffer.subarray(offset, offset += IV_LEN);
const tag = buffer.subarray(offset, offset += TAG_LEN);
const ciphertext = buffer.subarray(offset);
const tryPassphrase = (passphrase) => {
const key = deriveKey(passphrase, salt);
const decipher = crypto.createDecipheriv(ALGO, key, iv);
decipher.setAuthTag(tag);
try {
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
const result = JSON.parse(decrypted.toString('utf-8'));
decrypted.fill(0);
key.fill(0);
return result;
} catch {
key.fill(0);
return null;
}
};
// 1) Try the app-internal key (new format, no password required).
const fromApp = tryPassphrase(APP_PASSPHRASE);
if (fromApp) return fromApp;
// 2) Legacy format: user had set their own password.
if (userPassword) {
const fromUser = tryPassphrase(userPassword);
if (fromUser) return fromUser;
throw new Error('Falsches Passwort oder beschädigte Datei');
}
const err = new Error('Dieses Backup wurde mit einem Passwort verschlüsselt');
err.needsPassword = true;
throw err;
}
module.exports = { encrypt, decrypt };

239
lib/clouddrop-upload.js Normal file
View File

@ -0,0 +1,239 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { request, Agent } = require('undici');
const BASE_URL = 'https://clouddrop.cc';
const API_BASE = `${BASE_URL}/api/cloud`;
const CHUNK_UPLOAD_BASE = 'https://upload.clouddrop.cc/api/cloud';
const USER_AGENT = 'multi-hoster-uploader/1.0';
const SIMPLE_UPLOAD_LIMIT = 16 * 1024 * 1024; // 16 MB
const CHUNK_SIZE = 16 * 1024 * 1024; // 16 MB — server's fixed chunk size
const INIT_TIMEOUT = 60_000;
const CHUNK_TIMEOUT = 30 * 60_000; // 30 min per chunk
const COMPLETE_TIMEOUT = 5 * 60_000;
const SIMPLE_UPLOAD_TIMEOUT = 30 * 60_000;
// Cap concurrent TCP connections to clouddrop.cc at 50 to stay well under
// the server's per-IP limit of 100 concurrent connections (cd_conn).
// Shared across all ClouddropUploader instances via module-level agent.
const clouddropAgent = new Agent({
connections: 50,
pipelining: 1,
keepAliveTimeout: 30_000,
keepAliveMaxTimeout: 60_000
});
/**
* Clouddrop.cc uploader uses API Key (Bearer) authentication.
* Files > 16 MB use the chunked protocol, smaller files use simple upload.
* After upload, a share link is created and returned as download_url.
*/
class ClouddropUploader {
constructor(apiKey) {
this.apiKey = String(apiKey || '').trim();
}
_headers(extra) {
return {
'Authorization': `Bearer ${this.apiKey}`,
'User-Agent': USER_AGENT,
'Accept': 'application/json',
...(extra || {})
};
}
async _parseJsonResponse(res) {
const text = await res.body.text();
let payload = null;
try { payload = text ? JSON.parse(text) : {}; } catch {
throw new Error(`Clouddrop: API-Antwort war kein JSON (HTTP ${res.statusCode}): ${text.slice(0, 200)}`);
}
if (res.statusCode < 200 || res.statusCode >= 300) {
const msg = (payload && (payload.error || payload.message))
|| `HTTP ${res.statusCode}`;
const err = new Error(`Clouddrop: ${msg}`);
err.status = res.statusCode;
throw err;
}
return payload;
}
/**
* Upload a file. Returns { download_url, embed_url, file_code }.
*/
async upload(filePath, progressCb, signal, throttle) {
if (!this.apiKey) throw new Error('Clouddrop: API-Key fehlt');
const fileName = path.basename(filePath);
let fileSize = 0;
try { fileSize = fs.statSync(filePath).size; }
catch { throw new Error(`Clouddrop: Datei nicht lesbar: ${fileName}`); }
if (fileSize <= 0) throw new Error('Clouddrop: Datei ist leer');
let fileId;
if (fileSize <= SIMPLE_UPLOAD_LIMIT) {
fileId = await this._uploadSimple(filePath, fileName, fileSize, progressCb, signal, throttle);
} else {
fileId = await this._uploadChunked(filePath, fileName, fileSize, progressCb, signal, throttle);
}
return {
download_url: `${BASE_URL}/share/${fileId}`,
embed_url: null,
file_code: fileId
};
}
/**
* Simple upload for files < 16 MB single multipart POST.
*/
async _uploadSimple(filePath, fileName, fileSize, progressCb, signal, throttle) {
const boundary = '----FormBoundary' + crypto.randomBytes(16).toString('hex');
const safeFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const preamble =
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="${safeFileName}"\r\n` +
`Content-Type: application/octet-stream\r\n\r\n`;
const epilogue = `\r\n--${boundary}--\r\n`;
const preambleBuf = Buffer.from(preamble, 'utf-8');
const epilogueBuf = Buffer.from(epilogue, 'utf-8');
const totalSize = preambleBuf.length + fileSize + epilogueBuf.length;
let bytesRead = 0;
async function* generate() {
yield preambleBuf;
const fileStream = fs.createReadStream(filePath, { highWaterMark: 256 * 1024 });
for await (const chunk of fileStream) {
if (signal && signal.aborted) throw new Error('Aborted');
if (throttle) await throttle.consume(chunk.length, signal);
bytesRead += chunk.length;
yield chunk;
if (progressCb) progressCb(bytesRead, fileSize);
}
yield epilogueBuf;
}
const res = await request(`${API_BASE}/upload?mode=rename`, {
method: 'POST',
dispatcher: clouddropAgent,
body: generate(),
signal,
headers: this._headers({
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(totalSize)
}),
headersTimeout: SIMPLE_UPLOAD_TIMEOUT,
bodyTimeout: SIMPLE_UPLOAD_TIMEOUT
});
const payload = await this._parseJsonResponse(res);
if (!payload.fileId) throw new Error(`Clouddrop: Keine fileId in Upload-Antwort`);
return payload.fileId;
}
/**
* Chunked upload for files > 16 MB.
* Flow: POST /upload/init PUT /upload/:sessionId/chunk/:n (0-based) POST /upload/:sessionId/complete
*/
async _uploadChunked(filePath, fileName, fileSize, progressCb, signal, throttle) {
// 1. Init session
const initRes = await request(`${API_BASE}/upload/init`, {
method: 'POST',
dispatcher: clouddropAgent,
signal,
headers: this._headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ filename: fileName, size: fileSize, parentId: null }),
headersTimeout: INIT_TIMEOUT,
bodyTimeout: INIT_TIMEOUT
});
const initPayload = await this._parseJsonResponse(initRes);
const sessionId = initPayload.sessionId;
const chunkSize = initPayload.chunkSize || CHUNK_SIZE;
const totalChunks = initPayload.totalChunks || Math.ceil(fileSize / chunkSize);
if (!sessionId) throw new Error('Clouddrop: Keine sessionId von /upload/init');
// 2. Read file and PUT chunks sequentially.
// Reuse a single buffer for all chunks (only the last chunk may be smaller,
// in which case we slice a view). Avoids 64× 16 MB allocations on a 1 GB
// file — real GC pressure during busy uploads.
const fd = fs.openSync(filePath, 'r');
let bytesSent = 0;
const reusableBuf = Buffer.allocUnsafe(chunkSize);
try {
for (let i = 0; i < totalChunks; i++) {
if (signal && signal.aborted) throw new Error('Aborted');
const offset = i * chunkSize;
const remaining = fileSize - offset;
const thisChunkSize = Math.min(chunkSize, remaining);
fs.readSync(fd, reusableBuf, 0, thisChunkSize, offset);
const body = thisChunkSize === chunkSize
? reusableBuf
: reusableBuf.subarray(0, thisChunkSize);
if (throttle) await throttle.consume(thisChunkSize, signal);
const chunkRes = await request(`${CHUNK_UPLOAD_BASE}/upload/${sessionId}/chunk/${i}`, {
method: 'PUT',
dispatcher: clouddropAgent,
signal,
body,
headers: this._headers({
'Content-Type': 'application/octet-stream',
'Content-Length': String(thisChunkSize)
}),
headersTimeout: CHUNK_TIMEOUT,
bodyTimeout: CHUNK_TIMEOUT
});
await this._parseJsonResponse(chunkRes);
bytesSent += thisChunkSize;
if (progressCb) progressCb(bytesSent, fileSize);
}
} finally {
try { fs.closeSync(fd); } catch {}
}
// 3. Complete session — all bytes are already on the server at this point.
// We MUST NOT throw here, otherwise the upload-manager would retry the entire
// multi-GB upload. Any failure (timeout, non-JSON, missing fileId, server still
// post-processing) is swallowed and we fall back to sessionId as file_code.
try {
const completeRes = await request(`${API_BASE}/upload/${sessionId}/complete`, {
method: 'POST',
dispatcher: clouddropAgent,
signal,
headers: this._headers({ 'Content-Type': 'application/json' }),
body: '{}',
headersTimeout: COMPLETE_TIMEOUT,
bodyTimeout: COMPLETE_TIMEOUT
});
const completePayload = await this._parseJsonResponse(completeRes).catch(() => ({}));
return completePayload.fileId || completePayload.id || sessionId;
} catch {
return sessionId;
}
}
/**
* Lightweight auth check GET /api/cloud/files (list root, small response).
*/
async checkAuth(signal) {
if (!this.apiKey) throw new Error('Clouddrop: API-Key fehlt');
const res = await request(`${API_BASE}/files/?limit=1`, {
method: 'GET',
dispatcher: clouddropAgent,
signal,
headers: this._headers(),
headersTimeout: 15_000,
bodyTimeout: 15_000
});
await this._parseJsonResponse(res);
return true;
}
}
module.exports = ClouddropUploader;

73
lib/coalesced-set.js Normal file
View File

@ -0,0 +1,73 @@
// Microtask-coalesced set. Adds are O(1); the apply callback runs once per
// scheduler tick with every id collected since the last flush.
//
// Used by the renderer to merge a burst of done-jobs (e.g. 500 jobs all
// finishing within milliseconds) into a single queueJobs.filter() pass —
// without this each event was its own O(N) sweep, so 500 finishes were
// O(N²) and visibly froze the UI on completion.
//
// Loaded both as a CommonJS module (Node tests) and as a browser global
// (renderer/app.js via index.html script tag).
(function (root) {
'use strict';
/**
* Build a coalesced set.
* @param {{ apply: (Set) => void, scheduler?: (cb: () => void) => void }} opts
* apply: called once per scheduler tick with the accumulated ids.
* scheduler: defaults to queueMicrotask. Tests can pass a synchronous
* stand-in to avoid async waits.
*/
function makeCoalescedSet(opts) {
if (!opts || typeof opts.apply !== 'function') {
throw new TypeError('makeCoalescedSet: { apply: fn } required');
}
const apply = opts.apply;
const scheduler = typeof opts.scheduler === 'function'
? opts.scheduler
: (typeof queueMicrotask === 'function' ? queueMicrotask : (cb) => Promise.resolve().then(cb));
let pending = new Set();
let scheduled = false;
function flush() {
scheduled = false;
if (pending.size === 0) return;
const drop = pending;
pending = new Set();
try { apply(drop); } catch (e) {
// Don't let a failing apply lock out the next batch — surface it
// but keep the coalescer usable.
if (typeof console !== 'undefined' && console.error) console.error(e);
}
}
return {
add(id) {
pending.add(id);
if (!scheduled) {
scheduled = true;
scheduler(flush);
}
},
/**
* Synchronously consume any pending ids. Used by beforeunload paths
* where we can't wait for the next microtask before persisting.
*/
drainSync() {
if (pending.size === 0) return;
const drop = pending;
pending = new Set();
scheduled = false;
apply(drop);
},
/** Introspection for tests + diagnostics. */
pendingSize() { return pending.size; },
isScheduled() { return scheduled; }
};
}
const api = { makeCoalescedSet };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else if (root) root.CoalescedSet = api;
})(typeof window !== 'undefined' ? window : this);

View File

@ -1,5 +1,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const secretStore = require('./secret-store');
const { normalizeLogMode } = require('./log-mode');
const HOSTER_SETTINGS_DEFAULTS = { const HOSTER_SETTINGS_DEFAULTS = {
retries: 3, retries: 3,
@ -7,30 +9,65 @@ const HOSTER_SETTINGS_DEFAULTS = {
parallelCount: 2, // 1-100 parallelCount: 2, // 1-100
restartBelowKbs: 0, // 0 = off restartBelowKbs: 0, // 0 = off
timeIntervalSec: 0, // delay between jobs timeIntervalSec: 0, // delay between jobs
maxSizeMb: 0 // 0 = unlimited maxSizeMb: 0, // 0 = unlimited
logToFile: true // write this hoster's successful links to fileuploader.log
}; };
// Template for each hoster type (used as defaults for new accounts)
const HOSTER_ACCOUNT_TEMPLATES = {
'doodstream.com': { enabled: true, authType: 'login', username: '', password: '' },
'doodstream.com:api': { enabled: true, authType: 'api', apiKey: '' },
'voe.sx': { enabled: true, authType: 'login', username: '', password: '' },
'voe.sx:api': { enabled: true, authType: 'api', apiKey: '' },
'vidmoly.me': { enabled: true, authType: 'login', username: '', password: '' },
'byse.sx': { enabled: true, authType: 'api', apiKey: '' },
'clouddrop.cc': { enabled: true, authType: 'api', apiKey: '' }
};
// All known hoster names (used for iteration)
const HOSTER_NAMES = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc'];
// Dropdown options for "Add Account" modal: value -> label
const HOSTER_ADD_OPTIONS = [
{ value: 'doodstream.com', label: 'Doodstream (Web Login)', hoster: 'doodstream.com', authType: 'login' },
{ value: 'doodstream.com:api', label: 'Doodstream (API)', hoster: 'doodstream.com', authType: 'api' },
{ value: 'voe.sx', label: 'Voe (Web Login)', hoster: 'voe.sx', authType: 'login' },
{ value: 'voe.sx:api', label: 'Voe (API)', hoster: 'voe.sx', authType: 'api' },
{ value: 'vidmoly.me', label: 'Vidmoly (Web Login)', hoster: 'vidmoly.me', authType: 'login' },
{ value: 'byse.sx', label: 'Byse (API)', hoster: 'byse.sx', authType: 'api' },
{ value: 'clouddrop.cc', label: 'Clouddrop (API)', hoster: 'clouddrop.cc', authType: 'api' }
];
const DEFAULTS = { const DEFAULTS = {
hosters: { hosters: {
'doodstream.com': { enabled: true, apiKey: '', username: '', password: '' }, 'doodstream.com': [],
'voe.sx': { enabled: true, apiKey: '' }, 'voe.sx': [],
'vidmoly.me': { enabled: true, authType: 'login', username: '', password: '' }, 'vidmoly.me': [],
'byse.sx': { enabled: true, apiKey: '' } 'byse.sx': [],
'clouddrop.cc': []
}, },
hosterSettings: { hosterSettings: {
'doodstream.com': { ...HOSTER_SETTINGS_DEFAULTS }, 'doodstream.com': { ...HOSTER_SETTINGS_DEFAULTS },
'voe.sx': { ...HOSTER_SETTINGS_DEFAULTS }, 'voe.sx': { ...HOSTER_SETTINGS_DEFAULTS },
'vidmoly.me': { ...HOSTER_SETTINGS_DEFAULTS }, 'vidmoly.me': { ...HOSTER_SETTINGS_DEFAULTS },
'byse.sx': { ...HOSTER_SETTINGS_DEFAULTS } 'byse.sx': { ...HOSTER_SETTINGS_DEFAULTS },
'clouddrop.cc': { ...HOSTER_SETTINGS_DEFAULTS }
}, },
globalSettings: { globalSettings: {
alwaysOnTop: false, alwaysOnTop: false,
shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart
logFilePath: '', logFilePath: '',
sessionLog: false, // legacy boolean (kept for back-compat reads); normalized into logMode on load
logVerbose: false, // when true, [DEBUG] level entries are written to debug.log
// NOTE: logMode is intentionally NOT in DEFAULTS. If it were, the deep-merge
// would seed logMode='single' for every load, which would beat (and silently
// erase) the legacy sessionLog:true → "daily" migration. normalizeLogMode in
// load() sets logMode after the merge, looking at the saved-only data.
resumeQueueOnLaunch: true, resumeQueueOnLaunch: true,
parallelUploadCount: 0, // 0 = use per-hoster limits only parallelUploadCount: 0, // 0 = use per-hoster limits only
scaleParallelUploads: false, scaleParallelUploads: false,
removeFromQueueOnDone: false, removeFromQueueOnDone: false,
showDropTarget: false,
globalMaxSpeedKbs: 0, // 0 = unlimited global speed globalMaxSpeedKbs: 0, // 0 = unlimited global speed
pendingQueue: null, pendingQueue: null,
scramble: { scramble: {
@ -39,19 +76,35 @@ const DEFAULTS = {
suffix: '', suffix: '',
chars: 'both', // 'letters' | 'numbers' | 'both' chars: 'both', // 'letters' | 'numbers' | 'both'
length: 0 // 0 = same as original basename length length: 0 // 0 = same as original basename length
},
folderMonitor: {
enabled: false,
folderPath: '',
recursive: false,
filterMode: 'include', // 'include' | 'exclude'
extensions: '', // comma-separated: 'mp4,mkv,avi'
skipDuplicates: true,
delaySec: 3,
autoStart: true,
hosters: [] // pre-selected hosters, empty = ask via modal
},
remote: {
enabled: false,
port: 9100,
token: '',
allowInput: true
} }
}, },
history: [] history: []
}; };
const MAX_HISTORY = 100;
class ConfigStore { class ConfigStore {
constructor(app) { constructor(app) {
const dir = app && app.isPackaged const dir = app && app.isPackaged
? app.getPath('userData') ? app.getPath('userData')
: path.join(__dirname, '..'); : path.join(__dirname, '..');
this.filePath = path.join(dir, 'electron-config.json'); this.filePath = path.join(dir, 'electron-config.json');
this._writeQueue = Promise.resolve(); // Serializes all writes to prevent race conditions
// Migrate config from old location if current doesn't exist // Migrate config from old location if current doesn't exist
if (!fs.existsSync(this.filePath) && app && app.isPackaged) { if (!fs.existsSync(this.filePath) && app && app.isPackaged) {
@ -82,17 +135,68 @@ class ConfigStore {
} catch {} } catch {}
} }
_readAndParse(filePath) {
const raw = fs.readFileSync(filePath, 'utf-8');
if (!raw || raw.trim().length < 2) return null;
return JSON.parse(raw);
}
load() { load() {
try { try {
const raw = fs.readFileSync(this.filePath, 'utf-8'); let data = null;
const data = JSON.parse(raw); // Try main config
// Merge with defaults so new hosters are always present try { data = this._readAndParse(this.filePath); } catch {}
const hosters = { ...DEFAULTS.hosters }; // Fallback to backup if main is empty/corrupt
if (!data) {
const backupPath = this.filePath + '.bak';
try { data = this._readAndParse(backupPath); } catch {}
}
if (!data) {
const fresh = JSON.parse(JSON.stringify(DEFAULTS));
fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings);
return fresh;
}
// Migrate old single-object format to array format
for (const [name, val] of Object.entries(data.hosters || {})) { for (const [name, val] of Object.entries(data.hosters || {})) {
if (hosters[name]) { if (val && !Array.isArray(val)) {
hosters[name] = { ...hosters[name], ...val }; if (!val.id) val.id = `${name}-migrated-${Date.now()}`;
// Infer authType for old format accounts
if (!val.authType) {
if (name === 'byse.sx') val.authType = 'api';
else if (name === 'vidmoly.me') val.authType = 'login';
else if (val.username && val.password) val.authType = 'login';
else if (val.apiKey) val.authType = 'api';
else val.authType = 'login';
}
data.hosters[name] = [val];
} }
} }
// Merge hosters: ensure all known hosters exist as arrays
const hosters = {};
for (const name of HOSTER_NAMES) {
const saved = data.hosters && data.hosters[name];
if (Array.isArray(saved) && saved.length > 0) {
hosters[name] = saved.map((acc, i) => {
// Ensure authType is set on every account
if (!acc.authType) {
if (name === 'byse.sx') acc.authType = 'api';
else if (name === 'vidmoly.me') acc.authType = 'login';
else if (acc.username && acc.password) acc.authType = 'login';
else if (acc.apiKey) acc.authType = 'api';
else acc.authType = 'login';
}
return {
...acc,
id: acc.id || `${name}-${Date.now()}-${i}`
};
});
} else {
hosters[name] = [];
}
}
// Merge hoster settings with defaults // Merge hoster settings with defaults
const hosterSettings = {}; const hosterSettings = {};
for (const name of Object.keys(DEFAULTS.hosterSettings)) { for (const name of Object.keys(DEFAULTS.hosterSettings)) {
@ -101,26 +205,55 @@ class ConfigStore {
...(data.hosterSettings && data.hosterSettings[name] || {}) ...(data.hosterSettings && data.hosterSettings[name] || {})
}; };
} }
const savedGlobal = data.globalSettings || {};
const globalSettings = { const globalSettings = {
...DEFAULTS.globalSettings, ...DEFAULTS.globalSettings,
...(data.globalSettings || {}) ...savedGlobal
}; };
return { hosters, hosterSettings, globalSettings, history: data.history || [] }; // Deep-merge nested objects so new keys are always present
for (const key of Object.keys(DEFAULTS.globalSettings)) {
const def = DEFAULTS.globalSettings[key];
if (def && typeof def === 'object' && !Array.isArray(def)) {
globalSettings[key] = { ...def, ...(savedGlobal[key] || {}) };
}
}
// Normalize logMode at this single boundary. Legacy sessionLog: true
// means *daily* (the old field was named after a misnomer); see log-mode.js.
// Downstream readers consume logMode only and must NOT derive from
// sessionLog at call sites.
globalSettings.logMode = normalizeLogMode(globalSettings);
const result = { hosters, hosterSettings, globalSettings, history: data.history || [] };
// Decrypt credentials stored with safeStorage so the rest of the app
// keeps working with plaintext in memory.
secretStore.decryptCredentials(result);
return result;
} catch { } catch {
return JSON.parse(JSON.stringify(DEFAULTS)); const fresh = JSON.parse(JSON.stringify(DEFAULTS));
fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings);
return fresh;
} }
} }
// Deep-clone a config and encrypt its credential fields. Never mutate the
// caller's object — the rest of the app holds plaintext references.
_serializeForDisk(config) {
const clone = JSON.parse(JSON.stringify(config));
secretStore.encryptCredentials(clone);
return JSON.stringify(clone, null, 2);
}
_enqueueWrite(fn) {
this._writeQueue = this._writeQueue.then(fn, fn);
return this._writeQueue;
}
save(config) { save(config) {
return this._enqueueWrite(() => {
const current = this.load(); const current = this.load();
if (config.hosters) current.hosters = config.hosters; if (config.hosters) current.hosters = config.hosters;
if (config.hosterSettings) current.hosterSettings = config.hosterSettings; if (config.hosterSettings) current.hosterSettings = config.hosterSettings;
if (config.globalSettings) current.globalSettings = config.globalSettings; if (config.globalSettings) current.globalSettings = config.globalSettings;
const data = JSON.stringify(current, null, 2); return this._atomicWrite(this._serializeForDisk(current));
return new Promise((resolve, reject) => {
fs.writeFile(this.filePath, data, 'utf-8', (err) => {
if (err) reject(err); else resolve();
});
}); });
} }
@ -129,30 +262,50 @@ class ConfigStore {
return config.history || []; return config.history || [];
} }
appendHistory(entry) { _atomicWrite(data) {
const config = this.load();
config.history.push(entry);
if (config.history.length > MAX_HISTORY) {
config.history = config.history.slice(-MAX_HISTORY);
}
const data = JSON.stringify(config, null, 2);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
fs.writeFile(this.filePath, data, 'utf-8', (err) => { const tmpPath = this.filePath + '.tmp';
if (err) reject(err); else resolve(); const backupPath = this.filePath + '.bak';
fs.writeFile(tmpPath, data, 'utf-8', (err) => {
if (err) return reject(err);
try {
// Refresh .bak from the previous live file. Wrapped in try/catch
// so an AV/indexer briefly locking the file doesn't fail the whole
// save — the rename to the live path is the part that matters,
// a stale .bak is preferable to losing the new write entirely.
try {
if (fs.existsSync(this.filePath)) {
const existing = fs.readFileSync(this.filePath, 'utf-8');
if (existing && existing.trim().length > 2) {
fs.writeFileSync(backupPath, existing, 'utf-8');
}
}
} catch {}
fs.renameSync(tmpPath, this.filePath);
} catch (e) { return reject(e); }
resolve();
}); });
}); });
} }
appendHistory(entry) {
return this._enqueueWrite(() => {
const config = this.load();
config.history.push(entry);
return this._atomicWrite(this._serializeForDisk(config));
});
}
clearHistory() { clearHistory() {
return this._enqueueWrite(() => {
const config = this.load(); const config = this.load();
config.history = []; config.history = [];
const data = JSON.stringify(config, null, 2); return this._atomicWrite(this._serializeForDisk(config));
return new Promise((resolve, reject) => {
fs.writeFile(this.filePath, data, 'utf-8', (err) => {
if (err) reject(err); else resolve();
});
}); });
} }
} }
module.exports = ConfigStore; module.exports = ConfigStore;
module.exports.HOSTER_ACCOUNT_TEMPLATES = HOSTER_ACCOUNT_TEMPLATES;
module.exports.HOSTER_NAMES = HOSTER_NAMES;
module.exports.HOSTER_ADD_OPTIONS = HOSTER_ADD_OPTIONS;

View File

@ -7,10 +7,32 @@ const BASE_URL = 'https://doodstream.com';
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
const UPLOAD_TIMEOUT = 1800000; // 30 min const UPLOAD_TIMEOUT = 1800000; // 30 min
// Cap doodstream's per-hoster debug log alongside the main log files so
// dev-mode sessions don't accumulate gigabytes of upload trace.
const { maybeRotateLogFile } = require('./log-rotation');
const _DOODSTREAM_LOG_MAX_BYTES = 10 * 1024 * 1024;
const _DOODSTREAM_LOG_MAX_BACKUPS = 1;
// Resolve the log path at write-time. In a packaged build __dirname lives
// inside app.asar (read-only) — writing there fails silently and we lose every
// production trace. Prefer Electron's writable userData dir, fall back to the
// repo root only when running outside Electron (tests / plain node).
function _doodstreamLogPath() {
try {
const { app } = require('electron');
if (app && typeof app.getPath === 'function') {
return path.join(app.getPath('userData'), 'doodstream-debug.log');
}
} catch { /* not running under Electron */ }
return path.join(__dirname, '..', 'doodstream-debug.log');
}
function _debugLog(msg) { function _debugLog(msg) {
try { try {
const logPath = _doodstreamLogPath();
maybeRotateLogFile(logPath, _DOODSTREAM_LOG_MAX_BYTES, _DOODSTREAM_LOG_MAX_BACKUPS);
const ts = new Date().toISOString(); const ts = new Date().toISOString();
fs.appendFileSync(path.join(process.cwd(), 'doodstream-debug.log'), `[${ts}] ${msg}\n`); fs.appendFileSync(logPath, `[${ts}] ${msg}\n`);
} catch {} } catch {}
} }
@ -18,6 +40,7 @@ class DoodstreamUploader {
constructor() { constructor() {
this.cookies = new Map(); this.cookies = new Map();
this.sessId = ''; this.sessId = '';
this.apiKey = ''; // optionally derived from the logged-in session (deriveApiKey)
} }
_cookieHeader() { _cookieHeader() {
@ -54,11 +77,27 @@ class DoodstreamUploader {
headers['Cookie'] = this._cookieHeader(); headers['Cookie'] = this._cookieHeader();
} }
const res = await fetch(url, { // The small discovery/result requests that bookend a multi-minute upload
...opts, // occasionally hit a transient blip ("fetch failed", ECONNRESET, a hung TLS
headers, // handshake). A blip here shouldn't throw away the whole upload, so retry a
redirect: 'manual' // few times with short backoff. Each attempt gets its own 20s timeout —
}); // Node's fetch has none by default, and a hung socket would otherwise stall
// the attempt for minutes. The big file upload (undici) is retried at the
// upload-manager level, not here.
let res;
for (let attempt = 1; ; attempt++) {
const timeoutSignal = AbortSignal.timeout(20000);
const signal = opts.signal ? AbortSignal.any([opts.signal, timeoutSignal]) : timeoutSignal;
try {
res = await fetch(url, { ...opts, headers, redirect: 'manual', signal });
break;
} catch (err) {
if (opts.signal && opts.signal.aborted) throw err; // caller abort: don't retry
if (attempt >= 3) throw err;
_debugLog(`_fetch transient (${attempt}/3) ${url}: ${err && err.message}; retry`);
await new Promise(r => setTimeout(r, 400 * attempt));
}
}
this._parseCookiesFromHeaders(res.headers); this._parseCookiesFromHeaders(res.headers);
@ -78,7 +117,7 @@ class DoodstreamUploader {
/** /**
* Login to DoodStream via web form * Login to DoodStream via web form
*/ */
async login(username, password) { async login(username, password, otp) {
// GET homepage first to collect cookies // GET homepage first to collect cookies
const homeRes = await this._fetch(BASE_URL); const homeRes = await this._fetch(BASE_URL);
await homeRes.text(); await homeRes.text();
@ -88,7 +127,7 @@ class DoodstreamUploader {
op: 'login_ajax', op: 'login_ajax',
login: username, login: username,
password: password, password: password,
loginotp: '' loginotp: otp || ''
}); });
// Use raw fetch with redirect: 'manual' to detect success redirects // Use raw fetch with redirect: 'manual' to detect success redirects
@ -122,6 +161,11 @@ class DoodstreamUploader {
if (json && json.status === 'success') { if (json && json.status === 'success') {
// Explicit success response // Explicit success response
} else if (json && json.message && /otp/i.test(json.message)) {
// OTP required — signal caller to collect OTP from user
const err = new Error(`Doodstream Login: ${json.message}`);
err.otpRequired = true;
throw err;
} else if (json && json.status === 'fail') { } else if (json && json.status === 'fail') {
throw new Error(`Doodstream Login: ${json.message || 'Login fehlgeschlagen'}`); throw new Error(`Doodstream Login: ${json.message || 'Login fehlgeschlagen'}`);
} else if (body.includes('Dashboard')) { } else if (body.includes('Dashboard')) {
@ -171,6 +215,8 @@ class DoodstreamUploader {
// Use the standard upload server endpoint // Use the standard upload server endpoint
const res = await this._fetch(BASE_URL + '/?op=upload_server'); const res = await this._fetch(BASE_URL + '/?op=upload_server');
const text = await res.text(); const text = await res.text();
const ctype = (res.headers && res.headers.get) ? (res.headers.get('content-type') || '') : '';
_debugLog(`upload_server: status=${res.status} ctype=${ctype} body(800)=${(text || '').slice(0, 800)}`);
let json; let json;
try { json = JSON.parse(text); } catch { json = null; } try { json = JSON.parse(text); } catch { json = null; }
@ -181,11 +227,78 @@ class DoodstreamUploader {
// Fallback: try fetching from upload page HTML // Fallback: try fetching from upload page HTML
const pageRes = await this._fetch(BASE_URL + '/?op=upload'); const pageRes = await this._fetch(BASE_URL + '/?op=upload');
const html = await pageRes.text(); const html = await pageRes.text();
// Current doodstream format: the upload server is the action of the
// multipart upload form, e.g.
// <form name="file" enctype="multipart/form-data"
// action="https://xxx.cloudatacdn.com/upload/01?SESSID" ...>
// <input type="hidden" name="sess_id" value="SESSID">
// The node is assigned per page-load and the action carries a session token
// in its query string that matches the page's hidden sess_id. We refresh
// this.sessId from THIS page so the multipart sess_id field matches the node
// URL — login-time and node tokens otherwise diverge and the upload comes
// back with an empty filecode.
const actionMatch = html.match(/action=["'](https?:\/\/[^"']+\/upload\/[^"']*)["']/i);
if (actionMatch) {
const url = actionMatch[1].replace(/&amp;/g, '&'); // un-escape HTML entities in query
const freshSess = html.match(/name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']/);
if (freshSess) {
this.sessId = freshSess[1];
} else {
_debugLog('upload_server: form action found but no sess_id on page; keeping existing sessId');
}
// Capture the form's real fields so upload() submits exactly what the
// browser would (file_title, submit_btn, …) instead of stale hardcoded ones.
this._uploadFormFields = this._parseUploadFormFields(html);
_debugLog(`upload_server: using form action node=${url} sess=${this.sessId} fields=${Object.keys(this._uploadFormFields).join(',')}`);
return url;
}
// Legacy fallback: srv_url JS variable (older doodstream theme).
const srvMatch = html.match(/srv_url['":\s]+['"]?(https?:\/\/[^'">\s]+)['"]?/i); const srvMatch = html.match(/srv_url['":\s]+['"]?(https?:\/\/[^'">\s]+)['"]?/i);
if (srvMatch) return srvMatch[1]; if (srvMatch) return srvMatch[1];
// Last resort fallback // No upload server could be extracted. We MUST NOT silently fall back to a
return 'https://tr1128ve.cloudatacdn.com/upload/01'; // hardcoded node: that node is stale and accepts the bytes but returns an
// empty form (no filecode) — so the user wastes ~90s uploading 95 MB into a
// dead end and gets a cryptic "kein Filecode" 90s later. Fail fast and put
// the raw responses in the error so the real format change is diagnosable.
const urlHints = (html.match(/https?:\/\/[^'">\s]+/g) || []).slice(0, 4).join(' , ');
_debugLog(`upload_server: NO SERVER. upload-page html(2000)=${(html || '').slice(0, 2000)}`);
throw new Error(
`Doodstream: konnte Upload-Server nicht ermitteln (Endpoint geaendert?). ` +
`op=upload_server status=${res.status} ctype=${ctype} body=${(text || '').slice(0, 300)} ` +
`| upload-page URL-Treffer: ${urlHints || 'keine'}`
);
}
/**
* Replicate the non-file fields of doodstream's CURRENT upload form so our
* POST matches what the browser actually submits. Doodstream dropped the old
* `utype` field and added file_title / fakefilepc / submit_btn; submitting a
* stale/incomplete field set can make the node accept the bytes but skip
* registration ( empty result form). We parse the live form rather than
* hardcode, so we track whatever fields doodstream uses now. The file input
* (type=file) is excluded the file is streamed separately.
*/
_parseUploadFormFields(html) {
const fields = {};
if (!html) return fields;
// Narrow to the upload form (its action points at a /upload/ node).
const formMatch = html.match(/<form[^>]*\baction=["'][^"']*\/upload\/[^"']*["'][\s\S]*?<\/form>/i);
const scope = formMatch ? formMatch[0] : html;
const re = /<(?:input|button)\b([^>]*)>/gi;
let m;
while ((m = re.exec(scope)) !== null) {
const attrs = m[1];
const typeM = attrs.match(/\btype=["']([^"']*)["']/i);
if (typeM && typeM[1].toLowerCase() === 'file') continue;
const nameM = attrs.match(/\bname=["']([^"']+)["']/i);
if (!nameM) continue;
const valM = attrs.match(/\bvalue=["']([^"']*)["']/i);
fields[nameM[1]] = valM ? valM[1] : '';
}
return fields;
} }
/** /**
@ -197,15 +310,25 @@ class DoodstreamUploader {
// Get upload server // Get upload server
const uploadUrl = await this._getUploadServer(); const uploadUrl = await this._getUploadServer();
// Remember which CDN node handled this upload so a later parse failure can
// report it — failures sometimes correlate with a specific node.
this._lastUploadUrl = uploadUrl;
// Build multipart form // Build multipart form
const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString('hex')}`; const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString('hex')}`;
// Build form parts // Build form parts. Submit the live form's fields (parsed in
// _getUploadServer) so our POST matches the browser; merge in sess_id (the
// fresh node token) and keep utype=reg as a harmless compatibility extra.
// Falls back to the minimal known-good set if the form wasn't parsed.
const formFields = { utype: 'reg', ...(this._uploadFormFields || {}) };
formFields.sess_id = this.sessId;
let preamble = ''; let preamble = '';
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="sess_id"\r\n\r\n${this.sessId}\r\n`; for (const [name, value] of Object.entries(formFields)) {
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="utype"\r\n\r\nreg\r\n`; preamble += `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`;
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`; }
const safeFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${safeFileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`;
const epilogue = `\r\n--${boundary}--\r\n`; const epilogue = `\r\n--${boundary}--\r\n`;
const preambleBuf = Buffer.from(preamble, 'utf-8'); const preambleBuf = Buffer.from(preamble, 'utf-8');
@ -215,7 +338,6 @@ class DoodstreamUploader {
const CHUNK_SIZE = 256 * 1024; const CHUNK_SIZE = 256 * 1024;
let bytesRead = 0; let bytesRead = 0;
const self = this;
async function* generate() { async function* generate() {
yield preambleBuf; yield preambleBuf;
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE }); const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
@ -229,7 +351,9 @@ class DoodstreamUploader {
yield epilogueBuf; yield epilogueBuf;
} }
const uploadRes = await request(uploadUrl, { let uploadRes;
try {
uploadRes = await request(uploadUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Type': `multipart/form-data; boundary=${boundary}`,
@ -242,6 +366,16 @@ class DoodstreamUploader {
bodyTimeout: UPLOAD_TIMEOUT, bodyTimeout: UPLOAD_TIMEOUT,
headersTimeout: 60000 headersTimeout: 60000
}); });
} catch (err) {
// Label which phase failed so a future "fetch failed"/"terminated" is
// attributable to the big upload POST vs the small bookend requests. The
// original message is preserved as a substring so upload-manager's
// transient classification still matches. NOTE: undici may surface
// "terminated"/"other side closed", which are not yet in that transient
// list — revisit if logs show them.
const mb = Math.round(bytesRead / 1048576);
throw new Error(`Doodstream Upload-POST (${mb} MB an ${uploadUrl}): ${err && err.message ? err.message : err}`);
}
const statusCode = uploadRes.statusCode; const statusCode = uploadRes.statusCode;
_debugLog(`Upload response status: ${statusCode}`); _debugLog(`Upload response status: ${statusCode}`);
@ -338,6 +472,8 @@ class DoodstreamUploader {
_debugLog(`Submitting upload_result to ${BASE_URL}/ with fields: ${JSON.stringify(hiddenFields)}`); _debugLog(`Submitting upload_result to ${BASE_URL}/ with fields: ${JSON.stringify(hiddenFields)}`);
const formData = new URLSearchParams(hiddenFields); const formData = new URLSearchParams(hiddenFields);
let followText = '';
try {
const followRes = await this._fetch(BASE_URL + '/', { const followRes = await this._fetch(BASE_URL + '/', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -346,7 +482,18 @@ class DoodstreamUploader {
}, },
body: formData.toString() body: formData.toString()
}); });
const followText = await followRes.text(); followText = await followRes.text();
} catch (err) {
// The file already uploaded to the CDN; this POST only registers it on
// doodstream's side. If it fails transiently (even after _fetch's own
// retries) but we already hold the filecode, the upload succeeded from
// the user's view — return it rather than discarding a done upload.
if (fnCode && fnCode.length >= 8) {
_debugLog(`upload_result submit failed (${err && err.message}); using fn ${fnCode}`);
return this._buildResult(fnCode);
}
throw err;
}
_debugLog(`upload_result response (first 500): ${followText.slice(0, 500)}`); _debugLog(`upload_result response (first 500): ${followText.slice(0, 500)}`);
// Try to find filecode in result page // Try to find filecode in result page
@ -366,7 +513,28 @@ class DoodstreamUploader {
return this._buildResult(dlMatch[1]); return this._buildResult(dlMatch[1]);
} }
throw new Error(`Doodstream Upload: upload_result Seite hat keinen filecode (${followText.slice(0, 150)})`); // No filecode anywhere. Surface WHY: XFileSharing puts the real reason
// in the `st` field (anything other than "OK" means the backend refused
// the file — copyright/hash match, duplicate, size, quota, …). The
// download link being empty while the page structure is unchanged points
// at doodstream's backend, not at a parsing bug on our side.
const st = hiddenFields.st || '';
const fnInfo = fnCode ? `"${fnCode}"(len ${fnCode.length})` : 'fehlt/leer';
const node = this._lastUploadUrl || '?';
_debugLog(`No filecode. st=${st} fn=${fnInfo} node=${node} CDN-body=${(resText || '').slice(0, 400)}`);
if (st && st !== 'OK') {
throw new Error(`Doodstream lehnt Datei ab (Server-Status: ${st}). CDN=${node}`);
}
// Empty form (no fn, no st) is a doodstream-side processing flake — same
// account + same file works on a later attempt. Tag it explicitly so the
// upload-manager classifies this as a hoster-transient error and does NOT
// blacklist the account (otherwise one of these flakes poisons the whole
// session and later batches hit `pre-job-swap-blocked` for no fault of
// the account). The flag is the primary signal; the message text is a
// belt-and-suspenders regex fallback in the classifier.
const emptyLinkErr = new Error(`Doodstream Upload: kein Filecode — Server gab leeren Link zurueck (st=${st || '?'}, fn=${fnInfo}, CDN=${node}). CDN-Antwort: ${(resText || '').slice(0, 200)}`);
emptyLinkErr.hosterTransient = true;
throw emptyLinkErr;
} }
// 4. Fallback: follow form action as-is (for non-XFS forms) // 4. Fallback: follow form action as-is (for non-XFS forms)
@ -448,6 +616,85 @@ class DoodstreamUploader {
file_code: fileCode file_code: fileCode
}; };
} }
/**
* Pull candidate API-key tokens out of a logged-in settings page. We do NOT
* rely on knowing doodstream's exact (cookie-gated, unseen) settings DOM
* instead we gather every plausible long token from form-field values and
* element contents, ranked so tokens near an "api" mention are tried first.
* The caller validates each against the official API, so a wrong guess is
* harmless (it just fails validation). Returned newest-/most-likely-first.
*/
_extractApiKeyCandidates(html) {
if (!html) return [];
const cands = new Set();
const patterns = [
/value=["']([A-Za-z0-9]{20,})["']/gi, // <input value="KEY">
/<(?:textarea|code|span|pre|input)[^>]*>\s*([A-Za-z0-9]{20,})\s*</gi, // <textarea>KEY</textarea>
/\b(?:api[_-]?key|apikey)\b["':\s=>]*["']?([A-Za-z0-9]{20,})/gi // api_key: "KEY"
];
for (const re of patterns) {
let m;
while ((m = re.exec(html)) !== null) cands.add(m[1]);
}
// Rank tokens whose preceding context mentions "api" ahead of the rest.
return [...cands]
.map(t => {
const idx = html.indexOf(t);
const ctx = html.slice(Math.max(0, idx - 160), idx).toLowerCase();
return { t, near: /api/.test(ctx) ? 0 : 1 };
})
.sort((a, b) => a.near - b.near)
.map(s => s.t);
}
/**
* Validate a candidate key against the official API. Only the account's real
* key returns status 200, so this is what makes the brute-force extraction
* safe regardless of the settings-page markup.
*/
async _validateApiKey(key) {
try {
const res = await fetch(`https://doodapi.co/api/account/info?key=${encodeURIComponent(key)}`, {
method: 'GET', redirect: 'follow', signal: AbortSignal.timeout(15000)
});
const json = await res.json().catch(() => null);
return !!(json && Number(json.status) === 200);
} catch {
return false;
}
}
/**
* Derive the account's doodapi API key from the logged-in web session, so a
* login-only account can upload via the reliable JSON API (which returns the
* filecode directly) instead of the fragile web upload form. Best-effort:
* returns null if no valid key can be found, and the caller falls back to the
* web-form upload. Requires login() to have run first (needs the cookies).
*/
async deriveApiKey() {
if (this.apiKey) return this.apiKey;
let html = '';
for (const page of ['/?op=my_account', '/settings', '/?op=profile']) {
try {
const res = await this._fetch(BASE_URL + page);
const text = await res.text();
if (text && /api[\s_-]?key/i.test(text)) { html = text; break; }
if (text && !html) html = text;
} catch { /* try next page */ }
}
const candidates = this._extractApiKeyCandidates(html);
// Cap validation calls (rate limit 10/s; settings page yields few tokens).
for (const key of candidates.slice(0, 15)) {
if (await this._validateApiKey(key)) {
this.apiKey = key;
_debugLog(`api-key derive: validated key (len ${key.length})`);
return key;
}
}
_debugLog(`api-key derive: ${candidates.length} candidate(s), none validated. settings html(2500)=${(html || '').slice(0, 2500)}`);
return null;
}
} }
module.exports = DoodstreamUploader; module.exports = DoodstreamUploader;

73
lib/file-probe.js Normal file
View File

@ -0,0 +1,73 @@
const fs = require('fs');
const SIGNATURES = [
{ kind: 'mp4-iso', test: (b) => b.length >= 12 && b.slice(4, 8).toString('ascii') === 'ftyp' },
{ kind: 'matroska', test: (b) => b.length >= 4 && b[0] === 0x1A && b[1] === 0x45 && b[2] === 0xDF && b[3] === 0xA3 },
{ kind: 'avi', test: (b) => b.length >= 12 && b.slice(0, 4).toString('ascii') === 'RIFF' && b.slice(8, 12).toString('ascii') === 'AVI ' },
{ kind: 'wav', test: (b) => b.length >= 12 && b.slice(0, 4).toString('ascii') === 'RIFF' && b.slice(8, 12).toString('ascii') === 'WAVE' },
{ kind: 'flv', test: (b) => b.length >= 3 && b.slice(0, 3).toString('ascii') === 'FLV' },
{ kind: 'asf-wmv', test: (b) => b.length >= 4 && b[0] === 0x30 && b[1] === 0x26 && b[2] === 0xB2 && b[3] === 0x75 },
{ kind: 'mpeg-ps', test: (b) => b.length >= 4 && b[0] === 0x00 && b[1] === 0x00 && b[2] === 0x01 && (b[3] === 0xBA || b[3] === 0xB3) },
{ kind: 'mpeg-ts', test: (b) => b.length >= 1 && b[0] === 0x47 },
{ kind: 'mp3', test: (b) => b.length >= 3 && (b.slice(0, 3).toString('ascii') === 'ID3' || (b[0] === 0xFF && (b[1] & 0xE0) === 0xE0)) },
{ kind: 'ogg', test: (b) => b.length >= 4 && b.slice(0, 4).toString('ascii') === 'OggS' },
{ kind: 'jpeg', test: (b) => b.length >= 3 && b[0] === 0xFF && b[1] === 0xD8 && b[2] === 0xFF },
{ kind: 'png', test: (b) => b.length >= 8 && b[0] === 0x89 && b.slice(1, 4).toString('ascii') === 'PNG' },
{ kind: 'pdf', test: (b) => b.length >= 5 && b.slice(0, 5).toString('ascii') === '%PDF-' },
{ kind: 'zip', test: (b) => b.length >= 4 && b[0] === 0x50 && b[1] === 0x4B && (b[2] === 0x03 || b[2] === 0x05 || b[2] === 0x07) },
{ kind: 'html', test: (b) => {
const s = b.toString('ascii', 0, Math.min(b.length, 64)).trimStart().toLowerCase();
return s.startsWith('<!doctype html') || s.startsWith('<html');
} }
];
const VIDEO_KINDS = new Set(['mp4-iso', 'matroska', 'avi', 'flv', 'asf-wmv', 'mpeg-ps', 'mpeg-ts']);
function detectKind(buf) {
if (!buf || buf.length === 0) return 'empty';
for (const sig of SIGNATURES) {
try { if (sig.test(buf)) return sig.kind; } catch { /* ignore malformed buffer slice */ }
}
return 'unknown';
}
function isVideoLikeKind(kind) {
return VIDEO_KINDS.has(kind);
}
function probeFileHead(filePath, bytes) {
const want = Number.isFinite(bytes) && bytes > 0 ? bytes : 64;
return new Promise((resolve) => {
fs.open(filePath, 'r', (err, fd) => {
if (err) return resolve({ ok: false, error: err.message, kind: 'unreadable' });
const buf = Buffer.alloc(want);
fs.read(fd, buf, 0, want, 0, (rerr, bytesRead) => {
fs.close(fd, () => {});
if (rerr) return resolve({ ok: false, error: rerr.message, kind: 'unreadable' });
const slice = buf.slice(0, bytesRead);
resolve({
ok: true,
bytesRead,
kind: detectKind(slice),
isVideoLike: isVideoLikeKind(detectKind(slice)),
headHex: slice.toString('hex')
});
});
});
});
}
function summarizeFileStat(filePath) {
try {
const st = fs.statSync(filePath);
return {
size: st.size,
mtime: st.mtime.toISOString(),
isFile: st.isFile()
};
} catch (err) {
return { error: err.message };
}
}
module.exports = { detectKind, isVideoLikeKind, probeFileHead, summarizeFileStat, VIDEO_KINDS, SIGNATURES };

103
lib/folder-monitor.js Normal file
View File

@ -0,0 +1,103 @@
const { EventEmitter } = require('events');
const path = require('path');
const chokidar = require('chokidar');
class FolderMonitor extends EventEmitter {
constructor() {
super();
this._watcher = null;
this._settings = null;
this._seenFiles = new Set();
this._batchBuffer = [];
this._batchTimer = null;
}
get running() {
return !!this._watcher;
}
start(settings) {
this.stop();
this._settings = settings;
const folderPath = String(settings.folderPath || '').trim();
if (!folderPath) throw new Error('Kein Ordnerpfad angegeben');
const watchOptions = {
persistent: true,
ignoreInitial: true,
depth: settings.recursive ? undefined : 0,
awaitWriteFinish: {
stabilityThreshold: Math.max(1000, (settings.delaySec || 3) * 1000),
pollInterval: 500
}
};
this._watcher = chokidar.watch(folderPath, watchOptions);
this._watcher.on('add', (filePath) => this._onNewFile(filePath));
this._watcher.on('unlink', (filePath) => {
// Allow re-added files (e.g. re-encoded) to be detected again
const normalized = filePath.replace(/\\/g, '/').toLowerCase();
this._seenFiles.delete(normalized);
});
this._watcher.on('error', (err) => this.emit('error', err));
}
stop() {
if (this._watcher) {
this._watcher.close().catch(() => {});
this._watcher = null;
}
if (this._batchTimer) {
clearTimeout(this._batchTimer);
this._batchTimer = null;
}
this._batchBuffer = [];
this._seenFiles = new Set();
}
status() {
return {
running: this.running,
folderPath: this._settings ? this._settings.folderPath : '',
seenCount: this._seenFiles.size
};
}
_onNewFile(filePath) {
const settings = this._settings;
if (!settings) return;
// Extension filter
const ext = path.extname(filePath).replace(/^\./, '').toLowerCase();
const rawExtensions = String(settings.extensions || '').trim();
if (rawExtensions) {
const extList = rawExtensions.split(',').map(e => e.trim().toLowerCase().replace(/^\./, '')).filter(Boolean);
if (extList.length > 0) {
const matches = extList.includes(ext);
if (settings.filterMode === 'include' && !matches) return;
if (settings.filterMode === 'exclude' && matches) return;
}
}
// Skip duplicates (session-based)
if (settings.skipDuplicates) {
const normalized = filePath.replace(/\\/g, '/').toLowerCase();
if (this._seenFiles.has(normalized)) return;
this._seenFiles.add(normalized);
}
// Batch: collect files over 200ms window then emit together
this._batchBuffer.push(filePath);
if (this._batchTimer) clearTimeout(this._batchTimer);
this._batchTimer = setTimeout(() => {
const files = this._batchBuffer.splice(0);
this._batchTimer = null;
if (files.length > 0) {
this.emit('new-files', files);
}
}, 200);
}
}
module.exports = FolderMonitor;

View File

@ -34,7 +34,10 @@ const HOSTER_CONFIGS = {
'doodstream.com': { 'doodstream.com': {
apiBase: 'https://doodapi.co', apiBase: 'https://doodapi.co',
serverEndpoints: ['/api/upload/server'], serverEndpoints: ['/api/upload/server'],
fallbackUploadServers: ['https://tr1128ve.cloudatacdn.com/upload/01'], // No hardcoded fallback node: that stale CDN host (tr1128ve.cloudatacdn.com)
// accepts the bytes but returns an empty result form with no filecode, so a
// failed server lookup must throw cleanly rather than upload ~1 GB into a
// dead end. (Same reasoning as the web-session path's fail-fast.)
buildUploadUrl: (url, key) => appendRawQuery(url, key), buildUploadUrl: (url, key) => appendRawQuery(url, key),
formFields: (key) => ({ api_key: key }), formFields: (key) => ({ api_key: key }),
parseResult: parseDoodstreamResult parseResult: parseDoodstreamResult
@ -160,7 +163,9 @@ function sleep(ms, signal) {
// Doodstream: { result: [{ download_url, protected_embed, filecode, protected_dl }] } // Doodstream: { result: [{ download_url, protected_embed, filecode, protected_dl }] }
function parseDoodstreamResult(payload) { function parseDoodstreamResult(payload) {
let item = {}; let item = {};
const result = payload.result; // Defensive: also handle direct callers that bypass uploadFile's payload
// normalisation (e.g. unit tests, future callers).
const result = payload && payload.result;
if (Array.isArray(result) && result.length > 0) { if (Array.isArray(result) && result.length > 0) {
item = result[0]; item = result[0];
} else if (result && typeof result === 'object') { } else if (result && typeof result === 'object') {
@ -195,11 +200,22 @@ function parseVoeResult(payload) {
// Byse: { files: [{ filecode, filename, status }] } // Byse: { files: [{ filecode, filename, status }] }
function parseByseResult(payload) { function parseByseResult(payload) {
// Defensive: bypass-callers may pass null/non-object directly.
if (!payload || typeof payload !== 'object') payload = {};
let file_code = null; let file_code = null;
let perFileError = null;
// Primary: files array (per official Byse API docs) // Primary: files array (per official Byse API docs)
if (Array.isArray(payload.files) && payload.files.length > 0) { if (Array.isArray(payload.files) && payload.files.length > 0) {
file_code = payload.files[0].filecode || payload.files[0].file_code; const f = payload.files[0];
file_code = f && (f.filecode || f.file_code) || null;
// Byse returns HTTP 200 + msg=OK even when a specific file was rejected
// ("Not video file format", "Duplicate", "File too small", ...). When
// filecode is empty and status carries a non-OK message, that IS the
// actual per-file error, not a server problem.
if (!file_code && f && f.status && !/^(ok|success|done)$/i.test(String(f.status))) {
perFileError = String(f.status).trim();
}
} }
// Fallback: result object // Fallback: result object
if (!file_code && payload.result) { if (!file_code && payload.result) {
@ -211,6 +227,19 @@ function parseByseResult(payload) {
} }
} }
if (!file_code && perFileError) {
// Distinguish account-level from file-level failure. "not enough disk
// space", "quota exceeded", "storage full" etc. mean the ACCOUNT is
// exhausted — every further file on the same account will hit the same
// wall, so we must rotate. File-specific rejections (Duplicate, wrong
// format, too small/large) ARE per-file and rotation is pointless.
const accountLevel = /(not enough (disk )?(space|storage)|insufficient (disk )?space|disk (space )?full|storage (exhausted|full|voll|limit)|quota (exceeded|voll|überschritten)|account (full|voll|suspended|banned))/i.test(perFileError);
const err = new Error(`Byse lehnte Datei ab: ${perFileError}`);
if (accountLevel) err.accountError = true;
else err.fileRejected = true;
throw err;
}
return { return {
download_url: file_code ? `https://byse.sx/d/${file_code}` : null, download_url: file_code ? `https://byse.sx/d/${file_code}` : null,
embed_url: file_code ? `https://byse.sx/e/${file_code}` : null, embed_url: file_code ? `https://byse.sx/e/${file_code}` : null,
@ -232,7 +261,8 @@ function buildMultipart(filePath, formFields) {
preamble += `${value}\r\n`; preamble += `${value}\r\n`;
} }
preamble += `--${boundary}\r\n`; preamble += `--${boundary}\r\n`;
preamble += `Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n`; const safeFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
preamble += `Content-Disposition: form-data; name="file"; filename="${safeFileName}"\r\n`;
preamble += `Content-Type: application/octet-stream\r\n\r\n`; preamble += `Content-Type: application/octet-stream\r\n\r\n`;
const epilogue = `\r\n--${boundary}--\r\n`; const epilogue = `\r\n--${boundary}--\r\n`;
@ -254,6 +284,7 @@ function createUploadBody(filePath, formFields, onProgress, throttle, signal) {
yield preambleBuf; yield preambleBuf;
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE }); const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
for await (const chunk of fileStream) { for await (const chunk of fileStream) {
if (signal && signal.aborted) throw new Error('Aborted');
if (throttle) await throttle.consume(chunk.length, signal); if (throttle) await throttle.consume(chunk.length, signal);
bytesRead += chunk.length; bytesRead += chunk.length;
yield chunk; yield chunk;
@ -279,7 +310,13 @@ async function apiGet(url, signal) {
signal: controller.signal, signal: controller.signal,
redirect: 'follow' redirect: 'follow'
}); });
const data = await res.json(); const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
throw new Error(`API-Antwort war kein JSON (HTTP ${res.status}): ${(text || '').slice(0, 200)}`);
}
if (data.status && [401, 403, 429, 500].includes(data.status)) { if (data.status && [401, 403, 429, 500].includes(data.status)) {
throw new Error(data.msg || data.message || JSON.stringify(data)); throw new Error(data.msg || data.message || JSON.stringify(data));
@ -298,7 +335,7 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
for (let attempt = 1; attempt <= SERVER_RETRY_ATTEMPTS; attempt++) { for (let attempt = 1; attempt <= SERVER_RETRY_ATTEMPTS; attempt++) {
for (const endpoint of hosterConfig.serverEndpoints) { for (const endpoint of hosterConfig.serverEndpoints) {
const url = `${hosterConfig.apiBase}${endpoint}?key=${apiKey}`; const url = `${hosterConfig.apiBase}${endpoint}?key=${encodeURIComponent(apiKey)}`;
try { try {
const data = await apiGet(url, signal); const data = await apiGet(url, signal);
const uploadUrl = extractUploadServerUrl(data, hosterConfig.apiBase); const uploadUrl = extractUploadServerUrl(data, hosterConfig.apiBase);
@ -341,15 +378,150 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
} }
if (lastMessage) { if (lastMessage) {
throw new Error(`Kein Upload-Server erhalten: ${lastMessage}`); const e = new Error(`Kein Upload-Server erhalten: ${lastMessage}`);
// "no servers available" / busy / try-again is a transient hoster-side
// condition, not an account fault — tag it so the account isn't blacklisted.
// Genuine auth failures (invalid key / unauthorized / forbidden) make
// shouldRetryServerLookup return false and stay classified as account errors.
if (shouldRetryServerLookup(lastMessage)) e.hosterTransient = true;
throw e;
} }
throw new Error('Kein Upload-Server erhalten. API-Key pruefen.'); throw new Error('Kein Upload-Server erhalten. API-Key pruefen.');
} }
async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, throttle) { async function _fetchByseFileList(apiKey, signal) {
// Byse's file-list endpoint. Returns up to 100 most-recent files — enough
// to match the upload we just did against what the server has. The API
// shape is typical XFS: { status, msg, result: { files: [...] } } or
// { status, msg, files: [...] }.
const url = `https://api.byse.sx/api/file/list?key=${encodeURIComponent(apiKey)}&per_page=100&sort=date&order=desc`;
try {
const { body, statusCode } = await request(url, {
method: 'GET', signal,
headers: { 'Accept': 'application/json', 'User-Agent': 'multi-hoster-uploader/1.1' },
headersTimeout: 30_000, bodyTimeout: 30_000
});
const text = await body.text();
if (statusCode < 200 || statusCode >= 300) return [];
const data = JSON.parse(text);
const src = Array.isArray(data.files) ? data.files
: (data.result && Array.isArray(data.result.files) ? data.result.files
: (Array.isArray(data.result) ? data.result : []));
return src.map(f => ({
file_code: String(f.file_code || f.filecode || '').trim(),
file_name: String(f.title || f.name || f.file_name || '').trim()
})).filter(f => f.file_code);
} catch {
return [];
}
}
function _normalizeFileTitle(s) {
return String(s || '').toLowerCase().replace(/\.[a-z0-9]+$/i, '').replace(/[^a-z0-9]+/g, '');
}
async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal) {
const expected = _normalizeFileTitle(fileName);
const POLL_ATTEMPTS = 15;
const POLL_DELAY_MS = 2000;
for (let i = 0; i < POLL_ATTEMPTS; i++) {
if (signal && signal.aborted) return null;
const list = await _fetchByseFileList(apiKey, signal);
const newFiles = list.filter(f => !baselineCodes.has(f.file_code));
// Exact-normalized filename match ONLY. The old fallback ("only one new
// file → take it") was unsafe during parallel byse uploads: job A's
// poller could claim job B's newly appeared file and return the wrong
// URL. At the cost of a few false-negatives when byse mangles the
// filename beyond our normalizer, correctness for parallel uploads wins.
const match = newFiles.find(f => _normalizeFileTitle(f.file_name) === expected);
if (match) {
return {
download_url: `https://byse.sx/d/${match.file_code}`,
embed_url: `https://byse.sx/e/${match.file_code}`,
file_code: match.file_code
};
}
if (i < POLL_ATTEMPTS - 1) await sleep(POLL_DELAY_MS, signal);
}
return null;
}
async function _fetchDoodstreamFileList(apiKey, signal) {
// doodapi.co file list: { msg, status:200, result: { files: [{ file_code, title, uploaded, ... }] } }
// sort=created&order=desc forces newest-first — VERIFIED against a real 90k-file
// account, where a single page without it could miss a just-uploaded file. The
// recovery only needs the most recent uploads, so page 1 newest-first suffices.
const url = `https://doodapi.co/api/file/list?key=${encodeURIComponent(apiKey)}&per_page=200&sort=created&order=desc`;
try {
const { body, statusCode } = await request(url, {
method: 'GET', signal,
headers: { 'Accept': 'application/json', 'User-Agent': 'multi-hoster-uploader/1.1' },
headersTimeout: 30_000, bodyTimeout: 30_000
});
const text = await body.text();
if (statusCode < 200 || statusCode >= 300) return [];
const data = JSON.parse(text);
const files = data && data.result && Array.isArray(data.result.files) ? data.result.files : [];
return files.map(f => ({
file_code: String(f.file_code || f.filecode || '').trim(),
file_name: String(f.title || f.file_name || f.name || '').trim()
})).filter(f => f.file_code);
} catch {
return [];
}
}
const DOODSTREAM_POLL = { attempts: 12, delayMs: 2500 }; // test-tunable via __test
async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, signal) {
// Same recovery byse uses: the upload POST returned no filecode, but the file
// may register in the account a little later. Poll the list for a NEW file
// whose normalized title matches what we uploaded. Exact-name match only
// (never "take the only new one") so parallel doodstream uploads can't claim
// each other's files.
const expected = _normalizeFileTitle(fileName);
const POLL_ATTEMPTS = DOODSTREAM_POLL.attempts;
const POLL_DELAY_MS = DOODSTREAM_POLL.delayMs;
for (let i = 0; i < POLL_ATTEMPTS; i++) {
if (signal && signal.aborted) return null;
const list = await _fetchDoodstreamFileList(apiKey, signal);
const fresh = list.filter(f => !baselineCodes.has(f.file_code));
const match = fresh.find(f => _normalizeFileTitle(f.file_name) === expected);
if (match) {
return {
download_url: `https://doodstream.com/d/${match.file_code}`,
embed_url: `https://doodstream.com/e/${match.file_code}`,
file_code: match.file_code
};
}
if (i < POLL_ATTEMPTS - 1) await sleep(POLL_DELAY_MS, signal);
}
return null;
}
async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, throttle, opts) {
const config = HOSTER_CONFIGS[hosterName]; const config = HOSTER_CONFIGS[hosterName];
if (!config) throw new Error(`Unbekannter Hoster: ${hosterName}`); if (!config) throw new Error(`Unbekannter Hoster: ${hosterName}`);
let byseBaseline = null;
if (hosterName === 'byse.sx') {
if (opts && opts.byseBaseline instanceof Set) {
byseBaseline = opts.byseBaseline;
} else {
const baseline = await _fetchByseFileList(apiKey, signal);
byseBaseline = new Set(baseline.map(f => f.file_code));
}
}
let doodBaseline = null;
if (hosterName === 'doodstream.com') {
if (opts && opts.doodBaseline instanceof Set) {
doodBaseline = opts.doodBaseline;
} else {
const baseline = await _fetchDoodstreamFileList(apiKey, signal);
doodBaseline = new Set(baseline.map(f => f.file_code));
}
}
// Step 1: Get upload server // Step 1: Get upload server
const uploadUrl = await getUploadServer(hosterName, config, apiKey, signal); const uploadUrl = await getUploadServer(hosterName, config, apiKey, signal);
@ -383,6 +555,16 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
`Upload-Antwort von ${hosterName} war kein JSON (HTTP ${statusCode}${snippet ? `): ${snippet}` : ')'}` `Upload-Antwort von ${hosterName} war kein JSON (HTTP ${statusCode}${snippet ? `): ${snippet}` : ')'}`
); );
} }
// Normalize valid-but-not-object JSON (JSON.parse('null') → null;
// JSON.parse('"foo"') → string; JSON.parse('[1]') → array). Without this
// the downstream `payload.msg` / `payload.status` / parseResult(payload)
// calls crash with a confusing TypeError instead of letting the existing
// fallback defaults kick in. Arrays from servers that return a top-level
// list (rare but seen in the wild) are kept addressable as `payload.X`
// → undefined, which the parsers already handle.
if (payload === null || typeof payload !== 'object') {
payload = {};
}
if (statusCode < 200 || statusCode >= 300) { if (statusCode < 200 || statusCode >= 300) {
throw new Error( throw new Error(
@ -396,27 +578,101 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
throw new Error(payload.msg || payload.message || JSON.stringify(payload)); throw new Error(payload.msg || payload.message || JSON.stringify(payload));
} }
const result = config.parseResult(payload); let result = null;
if (result?.file_code || result?.download_url || result?.embed_url) { let parseErr = null;
try {
result = config.parseResult(payload);
} catch (err) {
if (err && typeof err === 'object' && !err.diagnostic) {
try {
err.diagnostic = {
hoster: hosterName,
http: statusCode,
contentType: (headers && headers['content-type']) || null,
payloadSnippet: JSON.stringify(payload).slice(0, 1000),
uploadUrl: targetUrl
};
} catch { /* JSON cycle — skip diagnostic */ }
}
parseErr = err;
}
if (result && (result.file_code || result.download_url || result.embed_url)) {
return result; return result;
} }
// Byse-specific async handling: server accepts the file but responds with
// filecode="" + misleading status ("Not video file format"). The file shows
// up in the account shortly after — poll the list to claim it. User observed
// this with 2+ GB MKV uploads that appeared as "OK" on the byse dashboard
// even after our uploader gave up.
if (hosterName === 'byse.sx' && byseBaseline) {
const fileName = path.basename(filePath);
const polled = await _resolveByseUploadByName(apiKey, fileName, byseBaseline, signal);
if (polled) return polled;
}
// Doodstream: the doodapi upload POST returned no filecode (the same backend
// hiccup that empties the web form). Poll the account file list by name — if
// the file did register, claim its code instead of failing the upload.
if (hosterName === 'doodstream.com' && doodBaseline) {
const fileName = path.basename(filePath);
const polled = await _resolveDoodstreamUploadByName(apiKey, fileName, doodBaseline, signal);
if (polled) return polled;
}
if (parseErr) throw parseErr;
if (payload.success === false) { if (payload.success === false) {
throw new Error(payload.msg || payload.message || `Upload zu ${hosterName} wurde vom Server abgelehnt.`); throw new Error(payload.msg || payload.message || `Upload zu ${hosterName} wurde vom Server abgelehnt.`);
} }
throw new Error( // Avoid throwing a bare "OK" / "SUCCESS" as the error message — that happens
payload.msg // when the server says "msg: OK" but ships no file_code anywhere we know
|| payload.message // about, typically an API change. Surface the full (trimmed) payload so
|| `Upload zu ${hosterName} lieferte keine verwendbaren Dateidaten zurueck.` // future logs actually show what the server returned.
const msg = String(payload.msg || payload.message || '').trim();
const isOkishNoPayload = /^(ok|success|done|accepted)$/i.test(msg);
if (isOkishNoPayload || !msg) {
const snippet = JSON.stringify(payload).slice(0, 400);
// 2xx with no filecode: the hoster accepted the upload (bytes sent, status
// OK) but returned no usable link. For doodstream this is the API-path
// analog of the web empty-form — the backend file-registration timing out
// under large-file load. It's a hoster-side flake, NOT an account problem,
// so tag it hosterTransient: the upload-manager then fails this file WITHOUT
// blacklisting the account (same protection the web path got in 3.3.29) and
// the account stays usable for the next retry/batch.
const err = new Error(
`Upload zu ${hosterName} lieferte keine file_code-Antwort (Payload: ${snippet})`
); );
err.hosterTransient = true;
throw err;
}
throw new Error(msg);
}
async function prefetchBaseline(hosterName, apiKey, signal) {
try {
if (hosterName === 'byse.sx') {
const baseline = await _fetchByseFileList(apiKey, signal);
return new Set(baseline.map(f => f.file_code));
}
if (hosterName === 'doodstream.com') {
const baseline = await _fetchDoodstreamFileList(apiKey, signal);
return new Set(baseline.map(f => f.file_code));
}
} catch { /* leave caller to fall back to per-job fetch */ }
return null;
} }
module.exports = { module.exports = {
uploadFile, uploadFile,
prefetchBaseline,
HOSTER_CONFIGS, HOSTER_CONFIGS,
__test: { __test: {
extractUploadServerUrl, extractUploadServerUrl,
parseVoeResult parseVoeResult,
parseDoodstreamResult,
parseByseResult,
DOODSTREAM_POLL
} }
}; };

107
lib/log-mode.js Normal file
View File

@ -0,0 +1,107 @@
// Log-file mode resolution for fileuploader.log:
// - "single" → one file: fileuploader.log
// - "daily" → per-day: fileuploader-YYYY-MM-DD.log
// - "session" → per-launch: fileuploader-session-YYYY-MM-DD_HH-MM-SS-<pid>.log
//
// Pure functions only — no fs, no Date.now() at call time — so they unit-test
// cleanly and the main.js call sites pass in `new Date()` + the session stamp.
//
// MIGRATION TRAP this lib protects against: the legacy boolean was named
// `sessionLog` but actually toggled *daily* mode. A naive rename would silently
// flip every per-day user onto per-session. normalizeLogMode below maps the
// legacy `sessionLog: true` to "daily", NOT "session". Read logMode everywhere
// downstream; do not derive from sessionLog at call sites.
//
// Loaded both as CommonJS (main.js, tests) and as a browser global
// (renderer/app.js via index.html script tag) so a single implementation backs
// runtime and tests — same pattern as queue-prune.js / queue-dedup.js.
(function (root) {
'use strict';
const VALID_MODES = new Set(['single', 'daily', 'session']);
function normalizeLogMode(globalSettings) {
const gs = globalSettings && typeof globalSettings === 'object' ? globalSettings : {};
if (typeof gs.logMode === 'string' && VALID_MODES.has(gs.logMode)) {
return gs.logMode;
}
// Legacy boolean migration: sessionLog *named* like "session" but actually
// implemented "daily" — preserve daily users on the migration path.
if (gs.sessionLog === true) return 'daily';
return 'single';
}
function _two(n) { return String(n).padStart(2, '0'); }
function formatDateStamp(date) {
return `${date.getFullYear()}-${_two(date.getMonth() + 1)}-${_two(date.getDate())}`;
}
function formatSessionStamp(date, pid) {
const d = `${date.getFullYear()}-${_two(date.getMonth() + 1)}-${_two(date.getDate())}`;
const t = `${_two(date.getHours())}-${_two(date.getMinutes())}-${_two(date.getSeconds())}`;
// PID disambiguates a same-second close→reopen — a human can't but two
// automated runs might. Cheap belt to a suspenders-not-required problem.
const pidStr = pid !== undefined && pid !== null ? `-${pid}` : '';
return `${d}_${t}${pidStr}`;
}
/**
* Compute the log filename for the given mode + clock.
* @param {Object} args
* @param {string} args.baseName e.g. "fileuploader"
* @param {string} args.ext e.g. ".log"
* @param {string} args.mode "single" | "daily" | "session"
* @param {Date} args.date current timestamp
* @param {string} [args.sessionId] required when mode === "session"
* @returns {string} the bare filename (no directory)
*/
function resolveLogFileName(args) {
const a = args || {};
const base = String(a.baseName || 'fileuploader');
const ext = String(a.ext || '.log');
const mode = VALID_MODES.has(a.mode) ? a.mode : 'single';
if (mode === 'single') return `${base}${ext}`;
if (mode === 'daily') {
const date = a.date instanceof Date ? a.date : new Date();
return `${base}-${formatDateStamp(date)}${ext}`;
}
// session
const sid = a.sessionId && String(a.sessionId).trim();
if (sid) return `${base}-session-${sid}${ext}`;
// Defensive: if a session-id wasn't passed, fall back to single rather
// than emit a malformed name. main.js always supplies one.
return `${base}${ext}`;
}
/**
* Reverse of resolveLogFileName: given a full filename like
* "fileuploader-2026-06-03.log" or
* "fileuploader-session-2026-06-03_18-16-20-8132.log", strip the mode-stamp
* so the bare base ("fileuploader.log") remains. Used when persisting an
* auto-resolved fallback path back into config otherwise the saved path
* would keep growing a new stamp on every reload.
*/
function stripModeStampFromFileName(fileName) {
if (!fileName || typeof fileName !== 'string') return fileName;
// Order matters: session first (longer, more specific) before daily.
// Both regexes are anchored to $ with no nested/ambiguous quantifiers, so
// matching is linear — the eslint security warning is precautionary.
// eslint-disable-next-line security/detect-unsafe-regex
const sessionRe = /-session-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}(?:-\d+)?(\.[^.]+)?$/;
// eslint-disable-next-line security/detect-unsafe-regex
const dailyRe = /-\d{4}-\d{2}-\d{2}(\.[^.]+)?$/;
let out = fileName.replace(sessionRe, (m, ext) => ext || '');
out = out.replace(dailyRe, (m, ext) => ext || '');
return out;
}
const api = { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp, stripModeStampFromFileName, VALID_MODES };
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
} else if (root) {
root.LogMode = api;
}
})(typeof window !== 'undefined' ? window : this);

17
lib/log-policy.js Normal file
View File

@ -0,0 +1,17 @@
// Per-hoster upload-log policy. Decides whether a hoster's successful upload
// links get written to fileuploader.log. Pure + dependency-free so it's
// trivially unit-testable and shared between the runtime decision and tests.
//
// Contract: logging is ON unless the hoster's settings explicitly set
// logToFile === false. Missing settings / missing hoster / malformed input
// all default to ON, so the feature is strictly opt-out and never silently
// drops links because a config key wasn't present.
function hosterLogToFileEnabled(hosterSettings, hoster) {
if (!hosterSettings || typeof hosterSettings !== 'object') return true;
const hs = hosterSettings[hoster];
if (!hs || typeof hs !== 'object') return true;
return hs.logToFile !== false;
}
module.exports = { hosterLogToFileEnabled };

52
lib/log-rotation.js Normal file
View File

@ -0,0 +1,52 @@
// Generic numbered-backup log rotation. Used by the upload log + can be
// reused by other long-lived log files (debug log, account-rotation log).
//
// Behaviour:
// - File missing → no-op, returns false.
// - File ≤ maxBytes → no-op, returns false.
// - File > maxBytes → drop oldest .N backup, shift .K → .K+1, rename live
// file to .1, return true. Caller (or the next append) creates a fresh
// primary on demand.
//
// Errors are reported via `log` (e.g. debugLog) but never thrown — rotation
// is best-effort; the caller's append happens anyway.
const fs = require('fs');
const path = require('path');
function maybeRotateLogFile(filePath, maxBytes, maxBackups = 3, log = () => {}) {
if (!filePath || !Number.isFinite(maxBytes) || maxBytes <= 0) return false;
let size = 0;
try {
const st = fs.statSync(filePath);
size = st.size;
} catch (err) {
// ENOENT is normal — nothing to rotate yet.
if (err && err.code !== 'ENOENT') {
log(`logRotation: stat ${filePath} failed: ${err.message}`);
}
return false;
}
if (size <= maxBytes) return false;
const ext = path.extname(filePath);
const base = filePath.slice(0, filePath.length - ext.length);
// Drop the oldest backup if it exists, then shift each numbered backup up
// one slot. Errors are ignored: missing intermediate backups are normal,
// failed renames just mean we'll rotate again next time.
try { fs.unlinkSync(`${base}.${maxBackups}${ext}`); } catch {}
for (let i = maxBackups - 1; i >= 1; i--) {
try { fs.renameSync(`${base}.${i}${ext}`, `${base}.${i + 1}${ext}`); } catch {}
}
try {
fs.renameSync(filePath, `${base}.1${ext}`);
log(`logRotation: rotated ${filePath} (${(size / 1024 / 1024).toFixed(1)} MB) → ${base}.1${ext}`);
return true;
} catch (err) {
log(`logRotation: rename ${filePath}${base}.1${ext} failed: ${err.message}`);
return false;
}
}
module.exports = { maybeRotateLogFile };

65
lib/queue-dedup.js Normal file
View File

@ -0,0 +1,65 @@
// Startup queue auto-dedup logic. Extracted from renderer/app.js
// _autoDeduplicateFromLog so the decision can be unit-tested without a DOM or
// the renderer's module-level state.
//
// Loaded both as a CommonJS module (Node tests) and as a browser global
// (renderer/app.js via index.html script tag) so a single implementation backs
// runtime and tests — no drift.
//
// Behaviour: on launch the restored queue is compared against the lifetime
// upload log. ONLY genuinely-completed ('done') jobs that also appear in the
// log are dropped — that's pure decluttering of work that already finished.
//
// Pending jobs (preview / queued) and failed ones (error / aborted) are NEVER
// dropped here, even if a same-name+hoster line exists in the log. Those are
// work the user intentionally has queued (often a deliberate re-upload of a
// file that was uploaded before). The old code filtered on log-presence alone,
// regardless of status, so the ENTIRE restored queue vanished on the next
// restart/update whenever the files had been uploaded previously — surfacing as
// an empty "Dateien hierhin ziehen oder klicken" queue. Manual log import
// (importUploadLog) stays separate and explicit for users who do want bulk
// dedup of pending jobs.
(function (root) {
'use strict';
function _key(fileName, hoster) {
return `${String(fileName).toLowerCase()}|${String(hoster).toLowerCase()}`;
}
/**
* Partition restored queue jobs into kept vs removed, given lifetime log
* entries. Removes only 'done' jobs whose fileName|hoster is in the log.
* @param {Array<{status:string,fileName:string,hoster:string}>} jobs
* @param {Array<{fileName:string,hoster:string}>} logEntries
* @returns {{ kept: Array, removed: Array }}
*/
function partitionRestoredJobsByLog(jobs, logEntries) {
const kept = [];
const removed = [];
if (!Array.isArray(jobs) || jobs.length === 0) return { kept, removed };
const logKeys = new Set();
for (const e of (Array.isArray(logEntries) ? logEntries : [])) {
if (e && e.fileName && e.hoster) logKeys.add(_key(e.fileName, e.hoster));
}
for (const job of jobs) {
const isDone = job && job.status === 'done' && job.fileName && job.hoster;
if (isDone && logKeys.has(_key(job.fileName, job.hoster))) {
removed.push(job);
} else {
kept.push(job);
}
}
return { kept, removed };
}
const api = { partitionRestoredJobsByLog };
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
} else if (root) {
root.QueueDedup = api;
}
})(typeof window !== 'undefined' ? window : this);

59
lib/queue-prune.js Normal file
View File

@ -0,0 +1,59 @@
// Queue auto-prune logic. Extracted from renderer/app.js handleBatchDone so
// the algorithm can be unit-tested without needing a DOM or the renderer's
// module-level state (queueJobs, _jobIndexById).
//
// Loaded both as a CommonJS module (Node tests) and as a browser global
// (renderer/app.js via index.html script tag) so the same single
// implementation backs both runtime and tests — no drift between them.
//
// Behaviour: when the number of terminal-status jobs (done / skipped /
// error / aborted) in the queue exceeds `limit`, drop the oldest terminal
// jobs (insertion order) until we're back at the limit. Non-terminal jobs
// (queued / preview / uploading / retrying / getting-server) are always
// kept — those are work the user can still act on. Without this cap a
// long session accumulates thousands of done rows and every render becomes
// O(N) on a perpetually-growing N.
(function (root) {
'use strict';
const TERMINAL_STATUSES = new Set(['done', 'skipped', 'error', 'aborted']);
/**
* Compute which jobs to keep vs drop, given a queue and a terminal-jobs cap.
* @param {Array<{id: string, status: string}>} jobs the current queue
* @param {number} limit max terminal jobs to keep
* @returns {null | { kept: Array, dropped: Array }} null when nothing changed
*/
function pruneOldestTerminalJobs(jobs, limit) {
if (!Array.isArray(jobs) || jobs.length === 0) return null;
if (!Number.isFinite(limit) || limit < 0) return null;
// Walk once, record indices of terminal jobs in insertion order.
const terminalIdxs = [];
for (let i = 0; i < jobs.length; i++) {
const j = jobs[i];
if (j && TERMINAL_STATUSES.has(j.status)) terminalIdxs.push(i);
}
if (terminalIdxs.length <= limit) return null;
const dropCount = terminalIdxs.length - limit;
const dropSet = new Set(terminalIdxs.slice(0, dropCount));
const kept = [];
const dropped = [];
for (let i = 0; i < jobs.length; i++) {
if (dropSet.has(i)) dropped.push(jobs[i]);
else kept.push(jobs[i]);
}
return { kept, dropped };
}
const api = { pruneOldestTerminalJobs, TERMINAL_STATUSES };
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
} else if (root) {
root.QueuePrune = api;
}
})(typeof window !== 'undefined' ? window : this);

View File

@ -0,0 +1,23 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('capture', {
// Get capture source ID from main process (desktopCapturer runs in main)
getSourceId: () => ipcRenderer.invoke('remote:get-capture-source-id'),
// Signaling: receive offer/ICE from main process (relayed from dashboard)
onSignaling: (callback) => {
ipcRenderer.on('remote:signaling-to-capture', (_event, data) => callback(data));
},
// Signaling: send answer/ICE back to main process (relayed to dashboard)
sendSignaling: (data) => ipcRenderer.send('remote:signaling-from-capture', data),
// Input: forward input events from DataChannel to main process
sendInput: (data) => ipcRenderer.send('remote:input-event', data),
// Notify main process of client connection/disconnection
notifyClientCount: (count) => ipcRenderer.send('remote:client-count', count),
// Debug logging to main process
log: (...args) => ipcRenderer.send('remote:capture-log', args.join(' '))
});

150
lib/remote-capture.html Normal file
View File

@ -0,0 +1,150 @@
<!DOCTYPE html>
<html>
<head><title>Remote Capture</title></head>
<body>
<script>
// Maps clientId -> { pc: RTCPeerConnection, dc: RTCDataChannel }
const clients = new Map();
let captureStream = null;
async function getCaptureStream() {
if (captureStream) return captureStream;
// desktopCapturer runs in main process (Electron 33+), we get the source ID via IPC
const sourceId = await window.capture.getSourceId();
window.capture.log('getSourceId returned:', sourceId || 'NULL');
if (!sourceId) throw new Error('No capture source ID from main process');
try {
captureStream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: sourceId,
maxFrameRate: 15
}
}
});
const tracks = captureStream.getTracks();
window.capture.log('getUserMedia OK, tracks:', tracks.length, tracks.map(t => `${t.kind}:${t.readyState}`).join(','));
return captureStream;
} catch (err) {
window.capture.log('getUserMedia FAILED:', err.message);
throw err;
}
}
async function handleOffer(clientId, offer, role) {
window.capture.log('handleOffer called for', clientId);
let stream;
try {
stream = await getCaptureStream();
} catch (err) {
window.capture.log('FATAL: getCaptureStream failed:', err.message);
// Send diagnostic back to dashboard
window.capture.sendSignaling({ type: 'capture-error', clientId, error: err.message });
return;
}
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
clients.set(clientId, { pc, role });
// Add video tracks
const tracks = stream.getTracks();
window.capture.log('Adding', tracks.length, 'tracks to peer connection');
for (const track of tracks) {
window.capture.log('addTrack:', track.kind, track.label, track.readyState);
pc.addTrack(track, stream);
}
window.capture.log('Senders after addTrack:', pc.getSenders().length);
// Handle DataChannel from dashboard (dashboard creates it as offerer)
pc.ondatachannel = (event) => {
const dc = event.channel;
clients.get(clientId).dc = dc;
dc.onmessage = (msg) => {
try {
const input = JSON.parse(msg.data);
input.clientId = clientId;
input.role = role;
window.capture.sendInput(input);
} catch {}
};
};
// ICE candidates — serialize to plain object (WebRTC objects don't survive IPC)
pc.onicecandidate = (event) => {
if (event.candidate) {
window.capture.sendSignaling({
type: 'ice-candidate',
clientId,
candidate: {
candidate: event.candidate.candidate,
sdpMid: event.candidate.sdpMid,
sdpMLineIndex: event.candidate.sdpMLineIndex,
usernameFragment: event.candidate.usernameFragment
}
});
}
};
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
removeClient(clientId);
}
};
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// Serialize to plain object (RTCSessionDescription doesn't survive IPC)
window.capture.sendSignaling({
type: 'answer',
clientId,
answer: { type: pc.localDescription.type, sdp: pc.localDescription.sdp }
});
window.capture.notifyClientCount(clients.size);
}
function handleIceCandidate(clientId, candidate) {
const client = clients.get(clientId);
if (client && client.pc) {
client.pc.addIceCandidate(new RTCIceCandidate(candidate)).catch(() => {});
}
}
function removeClient(clientId) {
const client = clients.get(clientId);
if (client) {
if (client.dc) client.dc.close();
client.pc.close();
clients.delete(clientId);
window.capture.notifyClientCount(clients.size);
}
}
// Listen for signaling messages from main process
window.capture.onSignaling((data) => {
switch (data.type) {
case 'offer':
handleOffer(data.clientId, data.offer, data.role).catch(err => {
console.error('Failed to handle offer:', err);
window.capture.sendSignaling({ type: 'error', clientId: data.clientId, error: err.message });
});
break;
case 'ice-candidate':
handleIceCandidate(data.clientId, data.candidate);
break;
case 'client-disconnected':
removeClient(data.clientId);
break;
}
});
</script>
</body>
</html>

184
lib/remote-server.js Normal file
View File

@ -0,0 +1,184 @@
const { WebSocketServer } = require('ws');
const crypto = require('crypto');
class RemoteServer {
constructor() {
this._wss = null;
this._clients = new Map(); // ws -> { id, role, authenticated }
this._config = null;
this._failedAttempts = new Map(); // ip -> { count, blockedUntil }
}
start(opts) {
return new Promise((resolve, reject) => {
this._config = opts;
this._wss = new WebSocketServer({ port: opts.port }, () => {
resolve();
});
this._wss.on('error', (err) => {
reject(err);
});
this._wss.on('connection', (ws, req) => {
this._handleConnection(ws, req);
});
});
}
stop() {
if (this._wss) {
for (const [ws] of this._clients) {
ws.close(1000, 'Server shutting down');
}
this._clients.clear();
this._wss.close();
this._wss = null;
}
}
getClientCount() {
let count = 0;
for (const [, client] of this._clients) {
if (client.authenticated) count++;
}
return count;
}
getPort() {
if (this._wss && this._wss.address()) {
return this._wss.address().port;
}
return null;
}
_handleConnection(ws, req) {
const ip = req.socket.remoteAddress || 'unknown';
if (this._isBlocked(ip)) {
ws.close(4003, 'Too many failed attempts');
return;
}
const clientId = crypto.randomUUID();
this._clients.set(ws, { id: clientId, role: null, authenticated: false });
let authReceived = false;
const authTimeout = setTimeout(() => {
if (!authReceived) {
ws.close(4001, 'Auth timeout');
this._clients.delete(ws);
}
}, 5000);
ws.on('message', (raw) => {
let msg;
try { msg = JSON.parse(raw); } catch { return; }
const client = this._clients.get(ws);
if (!client) return;
if (!client.authenticated) {
authReceived = true;
clearTimeout(authTimeout);
if (msg.type === 'auth' && msg.token === this._config.token) {
client.authenticated = true;
client.role = msg.role || 'viewer';
ws.send(JSON.stringify({ type: 'auth-ok', clientId }));
if (this.getClientCount() === 1) {
this._config.onCreateCaptureWindow();
}
} else {
this._recordFailedAttempt(ip);
ws.close(4002, 'Invalid token');
this._clients.delete(ws);
}
return;
}
if (msg.type === 'offer' || msg.type === 'ice-candidate') {
msg.clientId = client.id;
msg.role = client.role;
this._config.onSignalingToCapture(msg);
}
});
ws.on('close', () => {
clearTimeout(authTimeout);
const client = this._clients.get(ws);
const wasAuthenticated = client && client.authenticated;
this._clients.delete(ws);
if (wasAuthenticated) {
this._config.onSignalingToCapture({
type: 'client-disconnected',
clientId: client.id
});
if (this.getClientCount() === 0) {
this._config.onDestroyCaptureWindow();
}
}
});
ws.on('error', () => {
clearTimeout(authTimeout);
const client = this._clients.get(ws);
const wasAuthenticated = client && client.authenticated;
this._clients.delete(ws);
if (wasAuthenticated) {
this._config.onSignalingToCapture({
type: 'client-disconnected',
clientId: client.id
});
if (this.getClientCount() === 0) {
this._config.onDestroyCaptureWindow();
}
}
});
}
sendToClient(clientId, data) {
for (const [ws, client] of this._clients) {
if (client.id === clientId && client.authenticated) {
ws.send(JSON.stringify(data));
break;
}
}
}
broadcast(data) {
const msg = JSON.stringify(data);
for (const [ws, client] of this._clients) {
if (client.authenticated && ws.readyState === 1) {
ws.send(msg);
}
}
}
_isBlocked(ip) {
const entry = this._failedAttempts.get(ip);
if (!entry) return false;
if (entry.blockedUntil && Date.now() < entry.blockedUntil) return true;
if (entry.blockedUntil && Date.now() >= entry.blockedUntil) {
this._failedAttempts.delete(ip);
return false;
}
return false;
}
_recordFailedAttempt(ip) {
const entry = this._failedAttempts.get(ip) || { count: 0, blockedUntil: null };
entry.count++;
if (entry.count >= 5) {
entry.blockedUntil = Date.now() + 60000;
}
this._failedAttempts.set(ip, entry);
}
}
module.exports = RemoteServer;

75
lib/secret-store.js Normal file
View File

@ -0,0 +1,75 @@
// Wraps Electron's safeStorage (OS-level credential encryption: DPAPI on
// Windows, Keychain on macOS, libsecret on Linux) to keep hoster passwords and
// API keys out of the plaintext electron-config.json.
//
// On Windows the DPAPI key is tied to the current user profile, so credentials
// encrypted here are only readable by the same Windows user. For backups we
// export to plaintext (the .mhu envelope has its own AES-GCM layer) so moving
// between machines/users works transparently.
const SENTINEL = 'enc:v1:';
const CRED_FIELDS = ['password', 'apiKey'];
let _safeStorageCache = undefined;
function getSafeStorage() {
if (_safeStorageCache !== undefined) return _safeStorageCache;
try {
const { safeStorage } = require('electron');
if (safeStorage && typeof safeStorage.isEncryptionAvailable === 'function'
&& safeStorage.isEncryptionAvailable()) {
_safeStorageCache = safeStorage;
return _safeStorageCache;
}
} catch {}
_safeStorageCache = null;
return null;
}
function isEncrypted(value) {
return typeof value === 'string' && value.startsWith(SENTINEL);
}
function encryptField(value) {
if (!value || typeof value !== 'string') return value;
if (isEncrypted(value)) return value;
const ss = getSafeStorage();
if (!ss) return value;
try {
const buf = ss.encryptString(value);
return SENTINEL + buf.toString('base64');
} catch {
return value;
}
}
function decryptField(value) {
if (!value || typeof value !== 'string') return value;
if (!isEncrypted(value)) return value;
const ss = getSafeStorage();
if (!ss) return '';
try {
const buf = Buffer.from(value.slice(SENTINEL.length), 'base64');
return ss.decryptString(buf);
} catch {
return '';
}
}
function mapHosterAccounts(config, fn) {
if (!config || !config.hosters || typeof config.hosters !== 'object') return config;
for (const accounts of Object.values(config.hosters)) {
if (!Array.isArray(accounts)) continue;
for (const acc of accounts) {
if (!acc || typeof acc !== 'object') continue;
for (const f of CRED_FIELDS) {
if (acc[f]) acc[f] = fn(acc[f]);
}
}
}
return config;
}
function encryptCredentials(config) { return mapHosterAccounts(config, encryptField); }
function decryptCredentials(config) { return mapHosterAccounts(config, decryptField); }
module.exports = { encryptField, decryptField, encryptCredentials, decryptCredentials, isEncrypted };

142
lib/stats.js Normal file
View File

@ -0,0 +1,142 @@
(function (root) {
function summarizePerHoster(history, opts) {
const out = {};
if (!Array.isArray(history)) return out;
const cutoff = opts && Number.isFinite(opts.sinceMs) ? opts.sinceMs : null;
const limitBatches = opts && Number.isFinite(opts.lastNBatches) && opts.lastNBatches > 0 ? opts.lastNBatches : null;
const entries = [...history];
entries.sort((a, b) => {
const ta = a && a.timestamp ? Date.parse(a.timestamp) : 0;
const tb = b && b.timestamp ? Date.parse(b.timestamp) : 0;
return tb - ta;
});
const sliced = limitBatches ? entries.slice(0, limitBatches) : entries;
for (const batch of sliced) {
if (!batch || !Array.isArray(batch.files)) continue;
if (cutoff !== null) {
const ts = batch.timestamp ? Date.parse(batch.timestamp) : 0;
if (!ts || ts < cutoff) continue;
}
for (const file of batch.files) {
if (!file || !Array.isArray(file.results)) continue;
for (const r of file.results) {
if (!r || !r.hoster) continue;
const bucket = out[r.hoster] || (out[r.hoster] = { ok: 0, fail: 0, total: 0 });
bucket.total++;
if (r.status === 'done') bucket.ok++;
else bucket.fail++;
}
}
}
for (const h of Object.keys(out)) {
const b = out[h];
b.rate = b.total > 0 ? b.ok / b.total : null;
}
return out;
}
function classifyErrorCategory(err) {
if (!err || typeof err !== 'string') return 'unknown';
const s = err.toLowerCase();
if (/abgebrochen|aborted|cancel/.test(s)) return 'aborted';
if (/not video file format|kein videoformat|invalid file|wrong format|duplicate|already exists|file too (small|big|large)|datei zu (gro|klein)/.test(s)) return 'file-rejected';
if (/quota|storage (full|exhausted|voll)|account (full|banned|suspended)|disk (space )?full|insufficient (disk )?space|not enough (disk )?(space|storage)/.test(s)) return 'account-error';
if (/csrf|kein upload-server|server.*?(busy|unavailable|try again)|no servers available|filecode|kein filecode|empty.*?(form|response)/.test(s)) return 'hoster-transient';
if (/timeout|econnreset|enotfound|fetch failed|network|socket hang up|abort/.test(s)) return 'network';
return 'unknown';
}
function summarizeBatchErrors(batchSummary) {
const buckets = {
'file-rejected': [],
'account-error': [],
'hoster-transient': [],
'network': [],
'unknown': [],
'aborted': []
};
if (!batchSummary || !Array.isArray(batchSummary.files)) return buckets;
for (const f of batchSummary.files) {
if (!f || !Array.isArray(f.results)) continue;
for (const r of f.results) {
if (!r || r.status === 'done') continue;
const cat = classifyErrorCategory(r.error);
buckets[cat].push({
fileName: f.name || f.fileName || '',
hoster: r.hoster || '',
error: r.error || '',
jobId: r.jobId || null
});
}
}
return buckets;
}
const RETRYABLE_CATEGORIES = new Set(['hoster-transient', 'network', 'unknown']);
function isRetryableCategory(cat) {
return RETRYABLE_CATEGORIES.has(cat);
}
const CATEGORY_LABELS = {
'file-rejected': 'Datei abgelehnt',
'account-error': 'Account-Problem',
'hoster-transient': 'Hoster-Flake',
'network': 'Netzwerk',
'unknown': 'Unbekannt',
'aborted': 'Abgebrochen'
};
function formatLinks(rows, format) {
if (!Array.isArray(rows)) return '';
const safe = rows.filter(r => r && r.url);
if (safe.length === 0) return '';
switch (format) {
case 'plain':
return safe.map(r => r.url).join('\n');
case 'bbcode':
return safe.map(r => {
const label = r.fileName || r.hoster || r.url;
return `[url=${r.url}]${label}[/url]`;
}).join('\n');
case 'markdown':
return safe.map(r => {
const label = r.fileName || r.hoster || r.url;
return `- [${label}](${r.url})`;
}).join('\n');
case 'html':
return safe.map(r => {
const label = r.fileName || r.hoster || r.url;
return `<a href="${r.url}">${label}</a>`;
}).join('\n');
case 'csv': {
const head = 'fileName,hoster,url\n';
return head + safe.map(r => {
const esc = (v) => `"${String(v || '').replace(/"/g, '""')}"`;
return [esc(r.fileName), esc(r.hoster), esc(r.url)].join(',');
}).join('\n');
}
case 'json':
return JSON.stringify(safe.map(r => ({ fileName: r.fileName || '', hoster: r.hoster || '', url: r.url })), null, 2);
default:
return safe.map(r => r.url).join('\n');
}
}
const api = {
summarizePerHoster,
classifyErrorCategory,
summarizeBatchErrors,
isRetryableCategory,
RETRYABLE_CATEGORIES,
CATEGORY_LABELS,
formatLinks
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
} else if (root) {
root.Stats = api;
}
})(typeof window !== 'undefined' ? window : this);

64
lib/support-bundle.js Normal file
View File

@ -0,0 +1,64 @@
const fs = require('fs');
const CRED_KEYS = new Set(['password', 'apiKey', 'token', 'cookie', 'sessionId']);
const REDACTED = '<redacted>';
function sanitizeConfig(config) {
if (!config || typeof config !== 'object') return config;
const clone = JSON.parse(JSON.stringify(config));
(function walk(o) {
if (!o) return;
if (Array.isArray(o)) { for (const e of o) walk(e); return; }
if (typeof o !== 'object') return;
for (const k of Object.keys(o)) {
if (CRED_KEYS.has(k) && typeof o[k] === 'string' && o[k]) o[k] = REDACTED;
else walk(o[k]);
}
})(clone);
return clone;
}
function collectFile(filePath, label, maxBytes) {
if (!filePath) return `=== ${label} ===\n<no path configured>\n\n`;
let stat;
try { stat = fs.statSync(filePath); }
catch (err) {
if (err && err.code === 'ENOENT') return `=== ${label} (${filePath}) ===\n<file does not exist yet>\n\n`;
return `=== ${label} (${filePath}) ===\n<stat error: ${err.message}>\n\n`;
}
const cap = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : 5 * 1024 * 1024;
let content;
try {
if (stat.size > cap) {
const fd = fs.openSync(filePath, 'r');
const buf = Buffer.alloc(cap);
fs.readSync(fd, buf, 0, cap, stat.size - cap);
fs.closeSync(fd);
const skipped = stat.size - cap;
content = `<truncated: skipped first ${skipped} bytes; showing last ${cap} bytes of ${stat.size}>\n` + buf.toString('utf-8');
} else {
content = fs.readFileSync(filePath, 'utf-8');
}
} catch (err) {
content = `<read error: ${err.message}>`;
}
return `=== ${label} (${filePath}, size=${stat.size} bytes) ===\n${content}\n\n`;
}
function buildSupportBundleText({ header, sanitizedConfig, files }) {
const parts = [];
parts.push('=== Multi-Hoster-Upload Support Bundle ===\n');
if (header && typeof header === 'object') {
for (const [k, v] of Object.entries(header)) parts.push(`${k}: ${v}\n`);
}
parts.push('\n');
parts.push('=== Config (sanitized — password/apiKey/token/cookie/sessionId redacted) ===\n');
parts.push(JSON.stringify(sanitizedConfig, null, 2));
parts.push('\n\n');
for (const f of (files || [])) {
parts.push(collectFile(f.path, f.label || f.path, f.maxBytes));
}
return parts.join('');
}
module.exports = { sanitizeConfig, collectFile, buildSupportBundleText, CRED_KEYS, REDACTED };

View File

@ -21,6 +21,7 @@ class Throttle {
bytes -= available; bytes -= available;
} }
if (bytes > 0) { if (bytes > 0) {
if (signal && signal.aborted) return;
// Wait 50ms for tokens to refill // Wait 50ms for tokens to refill
await new Promise((r) => setTimeout(r, 50)); await new Promise((r) => setTimeout(r, 50));
} }

51
lib/throttled-cache.js Normal file
View File

@ -0,0 +1,51 @@
// Time-windowed memoization. Reuses a previously-computed value if the
// signature + input identity match AND the cached entry is younger than
// `refreshMs`. Used by the renderer's dynamic-key sort throttle (every
// progress tick re-sorts a 5000-row queue → reuse for 200 ms, the user
// can't perceive sub-200 ms reorder lag).
//
// Loaded both as a CommonJS module (Node tests) and as a browser global
// (renderer/app.js via index.html script tag) — same single implementation
// across runtime and tests.
(function (root) {
'use strict';
/**
* Build a throttled cache. The clock is injected so tests don't have to
* sleep pass `() => fakeClock.value` from tests.
*
* @param {number} refreshMs cache TTL in milliseconds
* @param {() => number} [now] clock source, defaults to Date.now
*/
function makeThrottledCache(refreshMs, now) {
if (!Number.isFinite(refreshMs) || refreshMs < 0) {
throw new TypeError('refreshMs must be a non-negative finite number');
}
const clock = typeof now === 'function' ? now : () => Date.now();
let entry = null;
return {
get(sig, input) {
if (!entry) return undefined;
if (entry.sig !== sig) return undefined;
if (entry.input !== input) return undefined;
if (clock() - entry.ts >= refreshMs) return undefined;
return entry.value;
},
set(sig, input, value) {
entry = { sig, input, value, ts: clock() };
return value;
},
clear() { entry = null; },
// Introspection (mainly for tests/debug). Returns null when empty.
peek() {
if (!entry) return null;
return { sig: entry.sig, ts: entry.ts, age: clock() - entry.ts };
}
};
}
const api = { makeThrottledCache };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else if (root) root.ThrottledCache = api;
})(typeof window !== 'undefined' ? window : this);

View File

@ -56,7 +56,8 @@ function findLatestYml(assets) {
async function fetchJson(url, signal) { async function fetchJson(url, signal) {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT); const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT);
if (signal) signal.addEventListener('abort', () => controller.abort()); const onAbort = () => controller.abort();
if (signal) signal.addEventListener('abort', onAbort);
try { try {
const res = await fetch(url, { const res = await fetch(url, {
@ -64,9 +65,15 @@ async function fetchJson(url, signal) {
signal: controller.signal, signal: controller.signal,
redirect: 'follow' redirect: 'follow'
}); });
return await res.json(); const text = await res.text();
try {
return JSON.parse(text);
} catch {
throw new Error(`Update-Server Antwort war kein JSON (HTTP ${res.status}): ${text.slice(0, 200)}`);
}
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);
if (signal) signal.removeEventListener('abort', onAbort);
} }
} }
@ -150,6 +157,9 @@ async function installUpdate(onProgress) {
if (!check || !check.available) { if (!check || !check.available) {
throw new Error('Kein Update verfuegbar'); throw new Error('Kein Update verfuegbar');
} }
if (!check.assetUrl || !check.assetName) {
throw new Error('Update-Asset unvollstaendig (URL oder Name fehlt)');
}
// Stage: downloading // Stage: downloading
const tmpDir = app.getPath('temp'); const tmpDir = app.getPath('temp');

View File

@ -2,12 +2,14 @@ const { EventEmitter } = require('events');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const crypto = require('crypto'); const crypto = require('crypto');
const { uploadFile } = require('./hosters'); const { uploadFile, prefetchBaseline } = require('./hosters');
const VidmolyUploader = require('./vidmoly-upload'); const VidmolyUploader = require('./vidmoly-upload');
const VoeUploader = require('./voe-upload'); const VoeUploader = require('./voe-upload');
const DoodstreamUploader = require('./doodstream-upload'); const DoodstreamUploader = require('./doodstream-upload');
const ClouddropUploader = require('./clouddrop-upload');
const Semaphore = require('./semaphore'); const Semaphore = require('./semaphore');
const Throttle = require('./throttle'); const Throttle = require('./throttle');
const { probeFileHead } = require('./file-probe');
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
retries: 3, retries: 3,
@ -37,6 +39,161 @@ class UploadManager extends EventEmitter {
this.lastStartTime = {}; // hoster -> timestamp of last upload start this.lastStartTime = {}; // hoster -> timestamp of last upload start
this.intervalLocks = {}; // hoster -> Promise chain for serialized interval waits this.intervalLocks = {}; // hoster -> Promise chain for serialized interval waits
this.globalThrottle = null; this.globalThrottle = null;
this._failedAccounts = new Map(); // hoster -> Set of failed accountIds
this._accountOverrides = new Map(); // hoster -> fallback account object
this._doodApiKeyCache = new Map(); // accountId/username -> derived doodstream API key ('' = tried, none)
this._baselineCache = new Map(); // hoster:apiKey -> Promise<Set<file_code>> (one fetch shared across all jobs in batch)
}
switchAccount(hoster, fallbackAccount) {
const prev = this._accountOverrides.get(hoster);
this._accountOverrides.set(hoster, fallbackAccount);
this._rotLog('switchAccount', {
hoster,
prevOverrideId: prev ? prev.id : null,
toAccountId: fallbackAccount ? fallbackAccount.id : null
});
}
// Introspection helpers used by main.js to re-resolve fallbacks when the
// config changes mid-batch (e.g. user adds a new account after their only
// one ran out of space). Without this, an account that got marked failed
// before a fallback existed stays stuck until the app restarts.
getFailedAccountKeys() {
return Array.from(this._failedAccounts.keys());
}
getOverride(hoster) {
return this._accountOverrides.get(hoster) || null;
}
clearFailedAccount(hoster, accountId) {
return this._failedAccounts.delete(`${hoster}:${accountId}`);
}
clearAllFailedAccounts() {
const n = this._failedAccounts.size;
this._failedAccounts.clear();
return n;
}
// True if the hoster has a usable override stored that differs from the
// account currently in the task and isn't itself already marked failed.
// Used by the retry loop to decide "retry on same account vs break to
// rotation" — skipping wasted attempts on a likely-bad primary when a
// pre-resolved fallback is ready to try.
_hasPendingOverride(hoster, currentAccountId) {
const override = this._accountOverrides.get(hoster);
if (!override) return false;
if (override.id === currentAccountId) return false;
if (this._failedAccounts.has(hoster + ':' + override.id)) return false;
return true;
}
_rotLog(event, data) {
this.emit('rot-log', { ts: Date.now(), event, ...data });
}
// File-specific rejections from the hoster: the same file will get rejected
// on any account, so rotation is pointless. Matches the `err.fileRejected`
// flag set by parsers plus known rejection phrases.
// NOTE: We deliberately do NOT match the generic "lehnte Datei ab" prefix
// here — that phrase is used by the Byse parser for both file- AND
// account-level errors. Account-level ones set err.accountError instead,
// which takes priority in _shouldSkipRetryOnAccountError.
_isFileRejectedError(err) {
if (!err) return false;
if (err.accountError === true) return false; // explicit account-level wins
if (err.fileRejected === true) return true;
if (!err.message) return false;
const m = String(err.message);
return /(Not video file format|Duplicate|Datei zu (klein|gross|groß)|File too (small|large)|Invalid file|Unsupported format)/i.test(m);
}
// Hoster-side transient flake — the hoster's backend accepted the upload but
// returned a malformed/empty result (e.g. doodstream CDN form with no fn/no
// st). Same account + same file works on a later attempt; this is NOT an
// account problem. Treated exactly like a transient network error: skip
// remaining in-batch retries (the flake won't clear in 3s and a re-upload of
// 95 MB is expensive), don't blacklist the account, fail this file cleanly.
// The user's next manual retry — or a later batch — can use the same account.
_isHosterTransientError(err) {
if (!err) return false;
if (err.hosterTransient === true) return true; // explicit flag — primary
if (!err.message) return false;
// Defensive fallback: catch the same class of error if it bubbles up
// wrapped (e.g. through a different code path) without the flag set.
return /Server gab leeren Link zurueck|kein Filecode/i.test(String(err.message));
}
// Transient network errors — the account is fine, the network or the
// hoster's own backend hiccuped. Retrying on the SAME account is the right
// move; marking it failed would wrongly poison the fallback chain. If all
// retries on the current account still hit this class of error, we bail
// out for this file without blacklisting the account, so other jobs in the
// batch still get a fresh chance on it.
_isTransientNetworkError(err) {
if (!err || !err.message) return false;
const m = String(err.message);
const TRANSIENT = [
/ENOTFOUND/i,
/ECONNRESET/i,
/ECONNREFUSED/i,
/ETIMEDOUT/i,
/EAI_AGAIN/i,
/EHOSTUNREACH/i,
/ENETUNREACH/i,
/EPIPE/i,
/socket hang up/i,
/network (error|failure|problem)/i,
/dns (lookup|error|failed)/i,
/getaddrinfo/i,
/fetch failed/i,
/\bconnect (ETIMEDOUT|ECONN)/i
];
return TRANSIENT.some(p => p.test(m));
}
// Error classes that mean "this account is the problem, retrying on it won't
// help" — we skip the remaining retries and go straight to the fallback
// account. Keeps single runs fast when an account is rate-limited, banned,
// or out of quota.
_shouldSkipRetryOnAccountError(err) {
if (!err) return false;
// Explicit account-level flag from hoster parsers — highest priority.
if (err.accountError === true) return true;
if (!err.message) return false;
const m = String(err.message);
const PATTERNS = [
/Kein Upload-Server/i,
/No upload server/i,
/kein server/i,
/quota/i,
/limit (reached|exceeded|überschritten)/i,
/rate[- ]?limit/i,
/too many requests/i,
/\b(401|403|429)\b/,
/Falscher (User|Username|Passwort)/i,
/Incorrect (Login|Password)/i,
/invalid (credentials|api[- ]?key|token|session)/i,
/(account|user) (banned|suspended|disabled|gesperrt)/i,
/not authorized/i,
/forbidden/i,
/session (expired|abgelaufen)/i,
// Session/CSRF hints — the account's server session went stale, which
// no amount of retrying will fix. Re-login happens on the next account.
/CSRF[- ]?Token nicht gefunden/i,
/CSRF[- ]?token not found/i,
/Bist du eingeloggt/i,
/not logged in/i,
// Storage exhaustion — account is full. Rotate instead of hammering it.
/not enough (disk )?(space|storage)/i,
/insufficient (disk )?space/i,
/disk (space )?full/i,
/storage (exhausted|full|voll|limit)/i,
/account (full|voll)/i
];
return PATTERNS.some(p => p.test(m));
} }
updateSettings(hosterSettings, globalSettings) { updateSettings(hosterSettings, globalSettings) {
@ -48,13 +205,20 @@ class UploadManager extends EventEmitter {
sem.updateLimit(settings.parallelCount); sem.updateLimit(settings.parallelCount);
} }
// Update global throttle if speed limit changed // Update global throttle if speed limit changed
if (this.globalThrottle) {
const newKbs = (this.globalSettings.globalMaxSpeedKbs || 0); const newKbs = (this.globalSettings.globalMaxSpeedKbs || 0);
if (newKbs > 0) { if (newKbs > 0) {
if (this.globalThrottle) {
this.globalThrottle.updateRate(newKbs * 1024); this.globalThrottle.updateRate(newKbs * 1024);
} else {
this.globalThrottle = new Throttle(newKbs * 1024);
}
} else { } else {
this.globalThrottle = null; this.globalThrottle = null;
} }
// Update global semaphore live
const globalLimit = this._getGlobalParallelLimit();
if (globalLimit > 0 && this.globalSemaphore) {
this.globalSemaphore.updateLimit(globalLimit);
} }
} }
@ -62,7 +226,7 @@ class UploadManager extends EventEmitter {
const settings = { ...DEFAULT_SETTINGS, ...(this.hosterSettings[hoster] || {}) }; const settings = { ...DEFAULT_SETTINGS, ...(this.hosterSettings[hoster] || {}) };
const globalLimit = this._getGlobalParallelLimit(); const globalLimit = this._getGlobalParallelLimit();
if (this.globalSettings.scaleParallelUploads && globalLimit > 0) { if (this.globalSettings.scaleParallelUploads && globalLimit > 0) {
settings.parallelCount = Math.max(settings.parallelCount || 1, globalLimit); settings.parallelCount = Math.min(settings.parallelCount || 1, globalLimit);
} }
return settings; return settings;
} }
@ -105,7 +269,7 @@ class UploadManager extends EventEmitter {
return this.semaphores[hoster]; return this.semaphores[hoster];
} }
async startBatch(tasks) { async startBatch(tasks, opts = {}) {
this.running = true; this.running = true;
this.stopAfterActive = false; this.stopAfterActive = false;
this.abortController = new AbortController(); this.abortController = new AbortController();
@ -114,28 +278,68 @@ class UploadManager extends EventEmitter {
this.activeJobs.clear(); this.activeJobs.clear();
this.jobAbortControllers.clear(); this.jobAbortControllers.clear();
this.cancelledJobIds.clear(); this.cancelledJobIds.clear();
this._doodApiKeyCache.clear(); // re-derive doodstream keys fresh each batch
this._baselineCache.clear(); // re-fetch baselines per batch (a long batch could outlast remote-side relevance)
this.semaphores = {}; this.semaphores = {};
this.globalSemaphore = null; this.globalSemaphore = null;
this.globalThrottle = null; this.globalThrottle = null;
this.lastStartTime = {}; this.lastStartTime = {};
// Reset account-rotation state each batch — but optionally re-prime from
// app-session memory so a "Retry failed" right after batch-done doesn't
// burn 5 retries on the account we already know is dead. Caller (main.js)
// passes the session-scoped failed/override state.
this._failedAccounts.clear();
this._accountOverrides.clear();
if (Array.isArray(opts.primeFailedAccounts)) {
for (const key of opts.primeFailedAccounts) this._failedAccounts.set(key, true);
}
if (Array.isArray(opts.primeOverrides)) {
for (const entry of opts.primeOverrides) {
if (Array.isArray(entry) && entry.length === 2) this._accountOverrides.set(entry[0], entry[1]);
}
}
this._rotLog('batch-start', {
taskCount: tasks.length,
primedFailed: this._failedAccounts.size,
primedOverrides: this._accountOverrides.size
});
const { signal } = this.abortController; const { signal } = this.abortController;
const batchId = `batch-${Date.now()}`; const batchId = `batch-${Date.now()}`;
const results = new Map(); // filePath -> { name, size, results: [] } const results = new Map(); // filePath -> { name, size, results: [] }
this._batchResults = results;
this._additionalPromises = []; // Track jobs added mid-batch via addJobs()
for (const task of tasks) { const DEDUP_CHUNK = 200;
const fileName = path.basename(task.file); for (let i = 0; i < tasks.length; i += DEDUP_CHUNK) {
const end = Math.min(i + DEDUP_CHUNK, tasks.length);
for (let j = i; j < end; j++) {
const task = tasks[j];
if (!results.has(task.file)) { if (!results.has(task.file)) {
const fileName = path.basename(task.file);
let size = 0; let size = 0;
try { size = fs.statSync(task.file).size; } catch {} try { size = fs.statSync(task.file).size; } catch {}
results.set(task.file, { name: fileName, size, results: [] }); results.set(task.file, { name: fileName, size, results: [] });
} }
} }
if (end < tasks.length) await new Promise(setImmediate);
}
this._startStatsTimer(); this._startStatsTimer();
const promises = tasks.map((task) => this._runJob(task, results, signal)); const SPAWN_CHUNK = 100;
const promises = [];
for (let i = 0; i < tasks.length; i += SPAWN_CHUNK) {
const end = Math.min(i + SPAWN_CHUNK, tasks.length);
for (let j = i; j < end; j++) promises.push(this._runJob(tasks[j], results, signal));
if (end < tasks.length) await new Promise(setImmediate);
}
await Promise.allSettled(promises); await Promise.allSettled(promises);
// Wait for any jobs added mid-batch via addJobs()
while (this._additionalPromises.length > 0) {
const batch = this._additionalPromises.splice(0);
await Promise.allSettled(batch);
}
this._stopStatsTimer(); this._stopStatsTimer();
this.running = false; this.running = false;
@ -164,7 +368,13 @@ class UploadManager extends EventEmitter {
const jobId = task.jobId || uploadId; const jobId = task.jobId || uploadId;
const fileName = path.basename(task.file); const fileName = path.basename(task.file);
let fileSize = 0; let fileSize = 0;
try { fileSize = fs.statSync(task.file).size; } catch {} let fileNotFound = false;
const cachedResult = results && results.get(task.file);
if (cachedResult && typeof cachedResult.size === 'number' && cachedResult.size > 0) {
fileSize = cachedResult.size;
} else {
try { fileSize = fs.statSync(task.file).size; } catch { fileNotFound = true; }
}
const maxAttempts = Math.max(1, (settings.retries || 0) + 1); const maxAttempts = Math.max(1, (settings.retries || 0) + 1);
const jobAbortController = new AbortController(); const jobAbortController = new AbortController();
@ -193,7 +403,7 @@ class UploadManager extends EventEmitter {
}; };
const emitFinalStatus = (status, payload = {}) => { const emitFinalStatus = (status, payload = {}) => {
this._emitProgress(uploadId, fileName, task.hoster, { this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
jobId, jobId,
status, status,
progress: status === 'done' ? 1 : 0, progress: status === 'done' ? 1 : 0,
@ -210,6 +420,18 @@ class UploadManager extends EventEmitter {
}; };
try { try {
if (fileNotFound) {
const error = 'Datei nicht gefunden';
emitFinalStatus('skipped', { error, attempt: 0 });
recordFinalResult('error', { error });
return;
}
if (fileSize <= 0) {
const error = 'Datei ist leer (0 Bytes)';
emitFinalStatus('skipped', { error, attempt: 0 });
recordFinalResult('error', { error });
return;
}
if (settings.maxSizeMb > 0 && fileSize > settings.maxSizeMb * 1024 * 1024) { if (settings.maxSizeMb > 0 && fileSize > settings.maxSizeMb * 1024 * 1024) {
const error = `Datei zu groß (Max: ${settings.maxSizeMb} MB)`; const error = `Datei zu groß (Max: ${settings.maxSizeMb} MB)`;
emitFinalStatus('skipped', { error, attempt: 0 }); emitFinalStatus('skipped', { error, attempt: 0 });
@ -217,19 +439,28 @@ class UploadManager extends EventEmitter {
return; return;
} }
this._emitProgress(uploadId, fileName, task.hoster, { // The initial 'queued' emit per job is suppressed: with N=2000+ tasks
jobId, // it produces 2000+ main→renderer IPCs back-to-back at startBatch and
status: 'queued', // freezes the renderer event loop for tens of seconds. The renderer
progress: 0, // already holds each job in 'queued'/'preview' state from its own
bytesUploaded: 0, // queueJobs array; the first event it actually needs from main is the
bytesTotal: fileSize, // 'getting-server' / 'uploading' transition for the jobs that the
speedKbs: 0, // semaphore lets through.
elapsed: 0, await hosterSemaphore.acquire(signal);
remaining: 0, hosterSlotAcquired = true;
error: null,
result: null, let fileProbe = null;
attempt: 0, try {
maxAttempts fileProbe = await probeFileHead(task.file, 64);
} catch (err) {
fileProbe = { ok: false, error: err && err.message, kind: 'unreadable' };
}
this._rotLog('upload-start', {
jobId, hoster: task.hoster, accountId: task.accountId, fileName,
fileSize,
detectedKind: fileProbe && fileProbe.kind ? fileProbe.kind : 'unknown',
isVideoLike: !!(fileProbe && fileProbe.isVideoLike),
headHex: fileProbe && fileProbe.headHex ? fileProbe.headHex.slice(0, 32) : null
}); });
if (globalSemaphore) { if (globalSemaphore) {
@ -237,18 +468,39 @@ class UploadManager extends EventEmitter {
globalSlotAcquired = true; globalSlotAcquired = true;
} }
await hosterSemaphore.acquire(signal);
hosterSlotAcquired = true;
if (settings.timeIntervalSec > 0) { if (settings.timeIntervalSec > 0) {
await this._waitForInterval(task.hoster, settings.timeIntervalSec * 1000, signal); await this._waitForInterval(task.hoster, settings.timeIntervalSec * 1000, signal);
} }
// Pre-job-swap: if this account was marked failed WHILE this task was
// waiting in the semaphore queue, jump straight to the override instead
// of burning a guaranteed-to-fail upload attempt. Critical at scale:
// with 500 queued jobs and 1 parallel slot, without this check every
// job still hits the original dead account first.
if (task.accountId && this._failedAccounts.has(task.hoster + ':' + task.accountId)) {
const override = this._accountOverrides.get(task.hoster);
if (override && !this._failedAccounts.has(task.hoster + ':' + override.id)) {
this._rotLog('pre-job-swap', {
jobId, hoster: task.hoster, fileName, fromAccountId: task.accountId, toAccountId: override.id
});
task.accountId = override.id;
task.username = override.username;
task.password = override.password;
task.apiKey = override.apiKey;
} else {
this._rotLog('pre-job-swap-blocked', {
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
hasOverride: !!override,
overrideAlsoFailed: override ? this._failedAccounts.has(task.hoster + ':' + override.id) : false
});
}
}
for (let attempt = 1; attempt <= maxAttempts; attempt++) { for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (signal.aborted || this.stopAfterActive) break; if (signal.aborted || this.stopAfterActive) break;
if (attempt > 1) { if (attempt > 1) {
this._emitProgress(uploadId, fileName, task.hoster, { this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
jobId, jobId,
status: 'retrying', status: 'retrying',
progress: 0, progress: 0,
@ -262,7 +514,7 @@ class UploadManager extends EventEmitter {
attempt, attempt,
maxAttempts maxAttempts
}); });
await this._sleep(2500, signal); await this._sleep(3000, signal);
} }
const jobStart = Date.now(); const jobStart = Date.now();
@ -275,7 +527,7 @@ class UploadManager extends EventEmitter {
let uploadSignalBundle = { signal, cleanup() {} }; let uploadSignalBundle = { signal, cleanup() {} };
try { try {
this._emitProgress(uploadId, fileName, task.hoster, { this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
jobId, jobId,
status: 'getting-server', status: 'getting-server',
progress: 0, progress: 0,
@ -313,7 +565,14 @@ class UploadManager extends EventEmitter {
}, 2000); }, 2000);
} }
this.activeJobs.set(uploadId, { jobId, speedKbs: 0, bytesUploaded: 0 }); // Mutate this single object on each progress callback instead of
// allocating a fresh one — callback fires on every stream chunk
// (hundreds/sec per active job).
const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0 };
this.activeJobs.set(uploadId, activeEntry);
let lastEmitTime = 0;
const PROGRESS_EMIT_INTERVAL = 250; // ms throttle UI updates
const progressCb = (bytesUploaded, bytesTotal) => { const progressCb = (bytesUploaded, bytesTotal) => {
const now = Date.now(); const now = Date.now();
@ -326,13 +585,18 @@ class UploadManager extends EventEmitter {
lastSpeedTime = now; lastSpeedTime = now;
} }
activeEntry.speedKbs = currentSpeedKbs;
activeEntry.bytesUploaded = bytesUploaded;
// Throttle progress emissions to reduce IPC + rendering overhead
if (now - lastEmitTime < PROGRESS_EMIT_INTERVAL) return;
lastEmitTime = now;
const remaining = currentSpeedKbs > 0 const remaining = currentSpeedKbs > 0
? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024)) ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024))
: 0; : 0;
this.activeJobs.set(uploadId, { jobId, speedKbs: currentSpeedKbs, bytesUploaded }); this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
this._emitProgress(uploadId, fileName, task.hoster, {
jobId, jobId,
status: 'uploading', status: 'uploading',
progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0, progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0,
@ -348,22 +612,7 @@ class UploadManager extends EventEmitter {
}); });
}; };
let result; const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle);
if (task.hoster === 'vidmoly.me' && task.username) {
const vidmoly = new VidmolyUploader();
await vidmoly.login(task.username, task.password);
result = await vidmoly.upload(task.file, progressCb, uploadSignalBundle.signal, throttle);
} else if (task.hoster === 'voe.sx' && task.username) {
const voe = new VoeUploader();
await voe.login(task.username, task.password);
result = await voe.upload(task.file, progressCb, uploadSignalBundle.signal, throttle);
} else if (task.hoster === 'doodstream.com' && task.username) {
const dood = new DoodstreamUploader();
await dood.login(task.username, task.password);
result = await dood.upload(task.file, progressCb, uploadSignalBundle.signal, throttle);
} else {
result = await uploadFile(task.hoster, task.file, task.apiKey, progressCb, uploadSignalBundle.signal, throttle);
}
const elapsed = Math.round((Date.now() - jobStart) / 1000); const elapsed = Math.round((Date.now() - jobStart) / 1000);
this.sessionBytes += fileSize; this.sessionBytes += fileSize;
@ -381,6 +630,23 @@ class UploadManager extends EventEmitter {
this.activeJobs.delete(uploadId); this.activeJobs.delete(uploadId);
const isSpeedRestart = speedAbort && speedAbort.signal.aborted && !signal.aborted; const isSpeedRestart = speedAbort && speedAbort.signal.aborted && !signal.aborted;
if (!signal.aborted && !isSpeedRestart) {
const diag = (err && typeof err === 'object' && err.diagnostic) || {};
this._rotLog('upload-failure', {
jobId, hoster: task.hoster, accountId: task.accountId, fileName,
attempt,
error: err && err.message ? err.message : String(err),
fileRejected: !!(err && err.fileRejected),
accountError: !!(err && err.accountError),
hosterTransient: !!(err && err.hosterTransient),
http: diag.http || null,
contentType: diag.contentType || null,
detectedKind: (typeof fileProbe !== 'undefined' && fileProbe && fileProbe.kind) ? fileProbe.kind : null,
isVideoLike: !!(typeof fileProbe !== 'undefined' && fileProbe && fileProbe.isVideoLike),
headHex: (typeof fileProbe !== 'undefined' && fileProbe && fileProbe.headHex) ? fileProbe.headHex.slice(0, 32) : null,
payloadSnippet: diag.payloadSnippet || null
});
}
if (signal.aborted) { if (signal.aborted) {
lastError = new Error('Abgebrochen'); lastError = new Error('Abgebrochen');
break; break;
@ -398,6 +664,43 @@ class UploadManager extends EventEmitter {
} }
lastError = err; lastError = err;
// File-specific rejection — re-uploading won't change the server's
// mind. Break out immediately; the outer file-rejected branch then
// records the final error without burning through 5 × 3s retries.
if (this._isFileRejectedError(err)) break;
// Hoster-side transient flake (e.g. doodstream empty CDN form). Server
// flake won't clear in 3s and re-uploading the whole file 4× is pure
// bandwidth waste; bail out of the retry loop so the post-loop branch
// can fail this file without blacklisting the account.
if (this._isHosterTransientError(err)) {
this._rotLog('hoster-transient', {
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
attempt, error: err && err.message ? err.message : String(err)
});
break;
}
// Account-specific errors — don't waste retries on the same account,
// jump straight to rotation.
if (this._shouldSkipRetryOnAccountError(err)) {
this._rotLog('fast-fail', {
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
attempt, error: err && err.message ? err.message : String(err)
});
break;
}
// Generic non-transient error AND a fallback is already resolved for
// this hoster: bail to rotation instead of burning more retries on a
// possibly-dead primary. The fallback (pre-resolved at batch-start)
// deserves a real shot. Transient network errors stay on the same
// account — the network is the issue, not the account.
if (!this._isTransientNetworkError(err) &&
this._hasPendingOverride(task.hoster, task.accountId)) {
this._rotLog('try-alternate-after-fail', {
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
attempt, error: err && err.message ? err.message : String(err)
});
break;
}
if (attempt >= maxAttempts) break; if (attempt >= maxAttempts) break;
// Wait 3 seconds before retry // Wait 3 seconds before retry
await this._sleep(3000, signal); await this._sleep(3000, signal);
@ -416,7 +719,181 @@ class UploadManager extends EventEmitter {
return; return;
} }
// Account rotation: mark the current account failed (if not already),
// wait for main to resolve the next fallback, then retry. Loops so
// A → B → C → ... works for hosters with 3+ accounts.
//
// CRITICAL: we must ALWAYS check for an existing override, even if this
// account is already in _failedAccounts (e.g. another concurrent job
// already marked it failed). Otherwise the second job falls straight
// through to final-error instead of using the already-resolved fallback.
this._rotLog('retries-exhausted', {
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
lastError: lastError ? lastError.message : null
});
// File-specific rejection → same file will get the same verdict on
// every other account, rotation is pointless. Don't blacklist, don't
// retry siblings, just fail this file cleanly.
if (this._isFileRejectedError(lastError)) {
this._rotLog('skip-rotation-file-rejected', {
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
lastError: lastError ? lastError.message : null
});
const error = lastError.message || 'Datei abgelehnt';
emitFinalStatus('error', { error });
recordFinalResult('error', { error });
return;
}
// Hoster-side transient flake → identical handling to network-transient:
// the account is fine, don't blacklist it, just fail this file. Critical
// to keep the account usable across batches — otherwise one empty-form
// response poisons every subsequent batch with `pre-job-swap-blocked`.
if (this._isHosterTransientError(lastError)) {
this._rotLog('skip-rotation-hoster-transient', {
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
lastError: lastError ? lastError.message : null
});
const error = lastError.message || 'Hoster-Backend lieferte leeres Ergebnis';
emitFinalStatus('error', { error });
recordFinalResult('error', { error });
return;
}
// If the reason for failure was a transient network error we do NOT
// blacklist the account. Other jobs on the same account in this batch
// can still try fresh. This file just errors out for now.
if (this._isTransientNetworkError(lastError)) {
this._rotLog('skip-rotation-transient', {
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
lastError: lastError ? lastError.message : null
});
const error = lastError.message || 'Netzwerkfehler';
emitFinalStatus('error', { error });
recordFinalResult('error', { error });
return;
}
while (task.accountId) {
if (signal.aborted || this.stopAfterActive) break;
const alreadyMarked = this._failedAccounts.has(task.hoster + ':' + task.accountId);
if (!alreadyMarked) {
this._failedAccounts.set(task.hoster + ':' + task.accountId, true);
this._rotLog('mark-failed', {
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
lastError: lastError ? lastError.message : null
});
this.emit('account-failed', { hoster: task.hoster, accountId: task.accountId });
await this._sleep(800, signal);
// Re-check after the await: the user could have cancelled while
// we were waiting for main.js to resolve the fallback. Without
// this, rotation proceeds another full attempt-loop's worth of
// work before the next signal-check inside _executeUpload notices.
if (signal.aborted || this.stopAfterActive) break;
} else {
this._rotLog('already-marked', {
jobId, hoster: task.hoster, fileName, accountId: task.accountId
});
}
const override = this._accountOverrides.get(task.hoster);
if (!override) {
this._rotLog('rotation-end', {
jobId, hoster: task.hoster, fileName, reason: 'no-override-set',
lastFailedAccountId: task.accountId
});
break;
}
if (this._failedAccounts.has(task.hoster + ':' + override.id)) {
this._rotLog('rotation-end', {
jobId, hoster: task.hoster, fileName, reason: 'override-already-failed',
overrideId: override.id, lastFailedAccountId: task.accountId
});
break;
}
if (override.id === task.accountId) {
this._rotLog('rotation-end', {
jobId, hoster: task.hoster, fileName, reason: 'override-same-as-current',
lastFailedAccountId: task.accountId
});
break;
}
// Switch to fallback account and retry this file
this._rotLog('rotate', {
jobId, hoster: task.hoster, fileName,
fromAccountId: task.accountId, toAccountId: override.id
});
task.accountId = override.id;
task.username = override.username;
task.password = override.password;
task.apiKey = override.apiKey;
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
jobId, status: 'retrying', progress: 0, bytesUploaded: 0, bytesTotal: fileSize,
speedKbs: 0, elapsed: 0, remaining: 0,
error: 'Account-Wechsel zu Fallback', result: null, attempt: 1, maxAttempts
});
// Retry loop with the new account. On exhausted failure, the while
// loop iterates: marks this account failed too, asks main for the next
// fallback, and so on.
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (signal.aborted || this.stopAfterActive) break;
if (attempt > 1) {
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
jobId, status: 'retrying', progress: 0, bytesUploaded: 0, bytesTotal: fileSize,
speedKbs: 0, elapsed: 0, remaining: 0,
error: lastError ? lastError.message : '', result: null, attempt, maxAttempts
});
await this._sleep(3000, signal);
}
try {
const jobStart = Date.now();
let lastBytes = 0;
let lastSpeedTime = jobStart;
let currentSpeedKbs = 0;
const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0 };
this.activeJobs.set(uploadId, activeEntry);
const progressCb = (bytesUploaded, bytesTotal) => {
const now = Date.now();
const timeDelta = (now - lastSpeedTime) / 1000;
if (timeDelta >= 1) {
currentSpeedKbs = Math.round((bytesUploaded - lastBytes) / timeDelta / 1024);
lastBytes = bytesUploaded;
lastSpeedTime = now;
}
activeEntry.speedKbs = currentSpeedKbs;
activeEntry.bytesUploaded = bytesUploaded;
const elapsed = Math.round((now - jobStart) / 1000);
const remaining = currentSpeedKbs > 0 ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024)) : 0;
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
jobId, status: 'uploading',
progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0,
bytesUploaded, bytesTotal, speedKbs: currentSpeedKbs,
elapsed, remaining, error: null, result: null, attempt, maxAttempts
});
};
const hosterThrottle = settings.maxSpeedKbs > 0 ? new Throttle(settings.maxSpeedKbs * 1024) : null;
const globalThrottle = this._getGlobalThrottle();
const throttle = hosterThrottle && globalThrottle
? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } }
: hosterThrottle || globalThrottle;
const result = await this._executeUpload(task, progressCb, signal, throttle);
this.activeJobs.delete(uploadId);
this.sessionBytes += fileSize;
emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt });
recordFinalResult('done', { result });
return;
} catch (err) {
this.activeJobs.delete(uploadId);
lastError = err;
if (signal.aborted || this.stopAfterActive) break;
if (attempt >= maxAttempts) break;
}
}
}
const error = lastError && lastError.message ? lastError.message : 'Unbekannter Fehler'; const error = lastError && lastError.message ? lastError.message : 'Unbekannter Fehler';
this._rotLog('final-error', {
jobId, hoster: task.hoster, fileName, lastFailedAccountId: task.accountId, error
});
emitFinalStatus('error', { error }); emitFinalStatus('error', { error });
recordFinalResult('error', { error }); recordFinalResult('error', { error });
} catch (err) { } catch (err) {
@ -431,29 +908,100 @@ class UploadManager extends EventEmitter {
this.activeJobs.delete(uploadId); this.activeJobs.delete(uploadId);
this.jobAbortControllers.delete(jobId); this.jobAbortControllers.delete(jobId);
cleanupSignals(); cleanupSignals();
if (hosterSlotAcquired) hosterSemaphore.release(); // Release in reverse order of acquire (global first, then hoster)
if (globalSlotAcquired && globalSemaphore) globalSemaphore.release(); if (globalSlotAcquired && globalSemaphore) globalSemaphore.release();
if (hosterSlotAcquired) hosterSemaphore.release();
} }
} }
async _executeUpload(task, progressCb, signal, throttle) {
if (task.hoster === 'vidmoly.me' && task.username) {
const vidmoly = new VidmolyUploader();
await vidmoly.login(task.username, task.password);
return vidmoly.upload(task.file, progressCb, signal, throttle);
} else if (task.hoster === 'voe.sx' && task.username) {
const voe = new VoeUploader();
await voe.login(task.username, task.password);
return voe.upload(task.file, progressCb, signal, throttle);
} else if (task.hoster === 'doodstream.com' && task.username) {
// Login-path reliability fix: the web-form upload returns the filecode in
// an HTML form that comes back empty for large files (doodstream backend
// registration timeout). Derive the account's API key from the logged-in
// session ONCE per batch and upload via the official API instead — it
// returns result[0].filecode directly and has no empty-form failure mode.
// Falls back to the web-form upload if no valid key can be derived.
const apiKey = await this._resolveDoodstreamApiKey(task);
if (apiKey) {
this._rotLog('doodstream-via-api', { accountId: task.accountId, fileName: path.basename(task.file) });
return uploadFile('doodstream.com', task.file, apiKey, progressCb, signal, throttle, {
doodBaseline: await this._getBaseline('doodstream.com', apiKey, signal)
});
}
this._rotLog('doodstream-via-web', { accountId: task.accountId, fileName: path.basename(task.file) });
const dood = new DoodstreamUploader();
await dood.login(task.username, task.password);
return dood.upload(task.file, progressCb, signal, throttle);
} else if (task.hoster === 'clouddrop.cc') {
const clouddrop = new ClouddropUploader(task.apiKey);
return clouddrop.upload(task.file, progressCb, signal, throttle);
} else {
const baselineOpts = {};
if (task.hoster === 'byse.sx') baselineOpts.byseBaseline = await this._getBaseline('byse.sx', task.apiKey, signal);
if (task.hoster === 'doodstream.com') baselineOpts.doodBaseline = await this._getBaseline('doodstream.com', task.apiKey, signal);
return uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, baselineOpts);
}
}
_getBaseline(hosterName, apiKey, signal) {
if (!apiKey) return Promise.resolve(null);
const key = `${hosterName}:${apiKey}`;
let pending = this._baselineCache.get(key);
if (pending) return pending;
pending = prefetchBaseline(hosterName, apiKey, signal);
this._baselineCache.set(key, pending);
return pending;
}
// Resolve (and cache per batch) the doodstream API key for a login-only
// account by logging in once and scraping+validating it from the session.
// Returns the key string, or '' when none could be derived (cached either way
// so a 40-file batch logs in + derives ONCE, not per file). The empty-string
// sentinel distinguishes "tried, none" from "not yet tried" (undefined).
async _resolveDoodstreamApiKey(task) {
const cacheKey = task.accountId || task.username;
const cached = this._doodApiKeyCache.get(cacheKey);
if (cached !== undefined) return cached || null;
let key = '';
try {
const probe = new DoodstreamUploader();
await probe.login(task.username, task.password);
key = (await probe.deriveApiKey()) || '';
} catch {
key = '';
}
this._doodApiKeyCache.set(cacheKey, key);
return key || null;
}
_emitProgress(uploadId, fileName, hoster, data) { _emitProgress(uploadId, fileName, hoster, data) {
this.emit('progress', { uploadId, fileName, hoster, ...data }); this.emit('progress', { uploadId, fileName, hoster, ...data });
} }
_startStatsTimer() { _startStatsTimer() {
if (this.statsInterval) clearInterval(this.statsInterval);
this.statsInterval = setInterval(() => { this.statsInterval = setInterval(() => {
// Single pass over active jobs instead of two.
let globalSpeedKbs = 0; let globalSpeedKbs = 0;
let activeCount = 0; let activeCount = 0;
let inProgressBytes = 0;
for (const job of this.activeJobs.values()) { for (const job of this.activeJobs.values()) {
globalSpeedKbs += job.speedKbs || 0; globalSpeedKbs += job.speedKbs || 0;
inProgressBytes += job.bytesUploaded || 0;
activeCount++; activeCount++;
} }
const elapsed = Math.round((Date.now() - this.startTime) / 1000); const elapsed = Math.round((Date.now() - this.startTime) / 1000);
let inProgressBytes = 0;
for (const job of this.activeJobs.values()) {
inProgressBytes += job.bytesUploaded || 0;
}
this.emit('stats', { this.emit('stats', {
state: this.running ? (this.stopAfterActive ? 'stopping' : 'uploading') : 'idle', state: this.running ? (this.stopAfterActive ? 'stopping' : 'uploading') : 'idle',
@ -561,6 +1109,31 @@ class UploadManager extends EventEmitter {
return next; return next;
} }
addJobs(tasks) {
if (!this.running || !tasks || tasks.length === 0) {
return { added: 0, alreadyInBatchJobIds: [] };
}
const { signal } = this.abortController;
const results = this._batchResults || new Map();
const addResult = { added: 0, alreadyInBatchJobIds: [] };
for (const task of tasks) {
// Skip if this job is already being processed (prevent duplicates)
if (task.jobId && this.jobAbortControllers.has(task.jobId)) {
addResult.alreadyInBatchJobIds.push(task.jobId);
continue;
}
const fileName = path.basename(task.file);
if (!results.has(task.file)) {
let size = 0;
try { size = fs.statSync(task.file).size; } catch {}
results.set(task.file, { name: fileName, size, results: [] });
}
this._additionalPromises.push(this._runJob(task, results, signal));
addResult.added++;
}
return addResult;
}
cancelJobs(jobIds) { cancelJobs(jobIds) {
for (const jobId of jobIds || []) { for (const jobId of jobIds || []) {
if (!jobId) continue; if (!jobId) continue;

View File

@ -81,95 +81,71 @@ class VidmolyUploader {
} }
/** /**
* Login to Vidmoly * Login to Vidmoly via the new JSON API (replaces the old XFS form POST
* at `/` with `op=login`, which the SPA redesign deprecated). The response
* sets a `vidmoly_session` HttpOnly cookie that the upload API checks.
*/ */
async login(username, password) { async login(username, password) {
// First GET the main page to get initial cookies // Warm up — get baseline cookies (cf_clearance etc.)
try {
const initRes = await this._fetch(BASE_URL); const initRes = await this._fetch(BASE_URL);
await initRes.text(); await initRes.text();
} catch {}
// POST login const res = await this._fetch(`${BASE_URL}/api/auth/login`, {
const loginData = new URLSearchParams({
op: 'login',
login: username,
password: password,
redirect: ''
});
const res = await this._fetch(BASE_URL, {
method: 'POST', method: 'POST',
body: loginData.toString(), body: JSON.stringify({ login: username, password }),
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/json',
'Referer': BASE_URL 'Accept': 'application/json',
'Origin': BASE_URL,
'Referer': `${BASE_URL}/login`
} }
}); });
const body = await res.text(); const body = await res.text();
if (res.status === 401 || res.status === 403 || /incorrect|invalid|wrong/i.test(body)) {
if (body.includes('Incorrect Login or Password')) {
throw new Error('Vidmoly Login fehlgeschlagen: Falscher Username oder Passwort'); throw new Error('Vidmoly Login fehlgeschlagen: Falscher Username oder Passwort');
} }
if (res.status < 200 || res.status >= 300) {
throw new Error(`Vidmoly Login fehlgeschlagen: HTTP ${res.status}`);
}
if (!this.cookies.has('vidmoly_session')) {
throw new Error('Vidmoly Login fehlgeschlagen: Keine Session erhalten (vidmoly_session fehlt)');
}
// Check for login cookie // Probe the upload API so downstream getUploadParams() has a warm path.
const hasSession = this.cookies.has('login') || this.cookies.has('xfsts') || const probe = await this._fetch(`${BASE_URL}/api/upload/config`);
this.cookies.size > 1; const probeBody = await probe.text();
if (!hasSession) { let probeJson = null;
throw new Error('Vidmoly Login fehlgeschlagen: Keine Session erhalten'); try { probeJson = JSON.parse(probeBody); } catch {}
if (!probeJson || !probeJson.sess_id || !probeJson.upload_url) {
throw new Error('Vidmoly Login fehlgeschlagen: Session konnte nicht verifiziert werden (API-Probe)');
} }
} }
/** /**
* Get upload form parameters from the upload page * Fetch the upload session config from Vidmoly's new SPA API.
* Replaces the old HTML-form scrape at /?op=upload which the redesign
* removed. Returns an XFS-style session token + a transit-server URL.
*/ */
async getUploadParams() { async getUploadParams() {
const res = await this._fetch(`${BASE_URL}/?op=upload`); const res = await this._fetch(`${BASE_URL}/api/upload/config`);
const html = await res.text(); const body = await res.text();
let payload = null;
// Parse hidden form fields from XFS upload form try { payload = JSON.parse(body); } catch {
const params = {}; throw new Error('Vidmoly: /api/upload/config lieferte kein JSON — evtl. nicht eingeloggt?');
const inputRegex = /<input[^>]*type=["']hidden["'][^>]*>/gi;
let match;
while ((match = inputRegex.exec(html)) !== null) {
const tag = match[0];
const nameMatch = tag.match(/name=["']([^"']+)["']/);
const valueMatch = tag.match(/value=["']([^"']*?)["']/);
if (nameMatch) {
params[nameMatch[1]] = valueMatch ? valueMatch[1] : '';
} }
if (!payload || !payload.sess_id || !payload.upload_url) {
throw new Error('Vidmoly: /api/upload/config unvollständig (sess_id/upload_url fehlt)');
} }
return {
// Extract form action uploadUrl: payload.upload_url,
const formMatch = html.match(/<form[^>]*id=["']?file_upload["']?[^>]*action=["']([^"']+)["']/i) // Fields verified from a real browser POST capture.
|| html.match(/<form[^>]*enctype=["']multipart\/form-data["'][^>]*action=["']([^"']+)["']/i) // to_json=1 forces a JSON response instead of an HTML redirect page.
|| html.match(/<form[^>]*action=["']([^"']+)["'][^>]*enctype=["']multipart\/form-data["']/i); params: { sess_id: payload.sess_id, to_json: '1', fld_id: '0' },
fileFieldName: 'file'
let uploadUrl = null; };
if (formMatch) {
uploadUrl = formMatch[1];
} else if (params.srv_tmp_url) {
uploadUrl = params.srv_tmp_url;
}
if (!uploadUrl) {
const cgiMatch = html.match(/(https?:\/\/[^"'\s]+\/cgi-bin\/upload\.cgi[^"'\s]*)/i)
|| html.match(/(https?:\/\/[^"'\s]+\/upload\/\d+)/i);
if (cgiMatch) uploadUrl = cgiMatch[1];
}
if (!uploadUrl) {
throw new Error('Vidmoly Upload-URL nicht gefunden. Bist du eingeloggt?');
}
let fileFieldName = 'file';
const fileInputMatch = html.match(/<input[^>]*type=["']file["'][^>]*name=["']([^"']+)["']/i)
|| html.match(/<input[^>]*name=["']([^"']+)["'][^>]*type=["']file["']/i);
if (fileInputMatch && fileInputMatch[1]) {
fileFieldName = fileInputMatch[1].trim();
}
return { uploadUrl, params, fileFieldName };
} }
/** /**
@ -187,7 +163,7 @@ class VidmolyUploader {
// XFS form fields // XFS form fields
const formFields = {}; const formFields = {};
for (const [k, v] of Object.entries(params)) { for (const [k, v] of Object.entries(params)) {
if (!/^file(?:_\d+)?$/i.test(k)) { if (!/^file(?:_\d+)?$/i.test(k)) { // eslint-disable-line security/detect-unsafe-regex -- safe: no backtracking
formFields[k] = v; formFields[k] = v;
} }
} }
@ -200,7 +176,8 @@ class VidmolyUploader {
preamble += `${value}\r\n`; preamble += `${value}\r\n`;
} }
preamble += `--${boundary}\r\n`; preamble += `--${boundary}\r\n`;
preamble += `Content-Disposition: form-data; name="${fileFieldName || 'file'}"; filename="${fileName}"\r\n`; const safeFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
preamble += `Content-Disposition: form-data; name="${fileFieldName || 'file'}"; filename="${safeFileName}"\r\n`;
preamble += `Content-Type: application/octet-stream\r\n\r\n`; preamble += `Content-Type: application/octet-stream\r\n\r\n`;
const epilogue = `\r\n--${boundary}--\r\n`; const epilogue = `\r\n--${boundary}--\r\n`;
@ -216,6 +193,7 @@ class VidmolyUploader {
yield preambleBuf; yield preambleBuf;
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE }); const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
for await (const chunk of fileStream) { for await (const chunk of fileStream) {
if (signal && signal.aborted) throw new Error('Aborted');
if (throttle) await throttle.consume(chunk.length, signal); if (throttle) await throttle.consume(chunk.length, signal);
bytesRead += chunk.length; bytesRead += chunk.length;
yield chunk; yield chunk;
@ -224,17 +202,26 @@ class VidmolyUploader {
yield epilogueBuf; yield epilogueBuf;
} }
// Use undici.request for the upload (streaming body for progress) // Transit server lives on a different domain (*.vmwesa.online) and runs
const { body, statusCode, headers } = await request(uploadUrl, { // the nginx-upload-progress module. It requires an X-Progress-ID query
// parameter on the POST URL — without it the upload hangs at the final
// byte because the module can't finalize the session. Browsers append it
// automatically before submitting the form.
const progressId = Date.now().toString() + Math.floor(Math.random() * 1e6).toString().padStart(6, '0');
const targetUrl = uploadUrl + (uploadUrl.includes('?') ? '&' : '?') + 'X-Progress-ID=' + progressId;
// Browsers don't send vidmoly.me cookies across origins, so we don't either.
const { body, statusCode, headers } = await request(targetUrl, {
method: 'POST', method: 'POST',
body: generate(), body: generate(),
signal, signal,
headers: { headers: {
'User-Agent': USER_AGENT, 'User-Agent': USER_AGENT,
'Cookie': this._cookieHeader(), 'Accept': '*/*',
'Origin': BASE_URL,
'Referer': `${BASE_URL}/`,
'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(totalSize), 'Content-Length': String(totalSize)
'Referer': `${BASE_URL}/upload.html`
}, },
headersTimeout: UPLOAD_TIMEOUT, headersTimeout: UPLOAD_TIMEOUT,
bodyTimeout: UPLOAD_TIMEOUT bodyTimeout: UPLOAD_TIMEOUT
@ -258,28 +245,33 @@ class VidmolyUploader {
resultHtml = await body.text(); resultHtml = await body.text();
} }
// Try JSON first (some XFS versions return JSON) // Try JSON first. The current transit server returns
// { status: "OK", file_code: "...", msg: "Upload Completed" }.
// Legacy XFS shapes (json.files / json.result) are kept as fallback.
try { try {
const json = JSON.parse(resultHtml); const json = JSON.parse(resultHtml);
if (json.status && /ok/i.test(json.status) && json.file_code) {
return this._buildUrlsFromCode(json.file_code);
}
if (json.file_code || json.filecode) {
return this._buildUrlsFromCode(json.file_code || json.filecode);
}
if (json.files && json.files.length > 0) { if (json.files && json.files.length > 0) {
const f = json.files[0]; const f = json.files[0];
const code = f.filecode || f.file_code; return this._buildUrlsFromCode(f.filecode || f.file_code);
return {
download_url: code ? `${BASE_URL}/w/${code}` : null,
embed_url: code ? `${BASE_URL}/embed-${code}.html` : null,
file_code: code
};
} }
if (json.result) { if (json.result) {
const r = Array.isArray(json.result) ? json.result[0] : json.result; const r = Array.isArray(json.result) ? json.result[0] : json.result;
const code = r.filecode || r.file_code; const code = r.filecode || r.file_code;
return { const urls = this._buildUrlsFromCode(code);
download_url: r.download_url || (code ? `${BASE_URL}/w/${code}` : null), if (urls) return urls;
embed_url: r.embed_url || (code ? `${BASE_URL}/embed-${code}.html` : null), }
file_code: code if (json.status && !/ok/i.test(json.status) && json.msg) {
}; throw new Error(`Vidmoly Upload abgelehnt: ${json.msg}`);
}
} catch (err) {
if (err && /Vidmoly Upload abgelehnt/.test(err.message)) throw err;
} }
} catch {}
try { try {
return this._parseUploadResult(resultHtml); return this._parseUploadResult(resultHtml);
@ -447,7 +439,7 @@ class VidmolyUploader {
let embed_url = null; let embed_url = null;
let file_code = null; let file_code = null;
const fnMatch = html.match(/<(?:input|textarea)[^>]*name=["']fn["'][^>]*(?:value=["']([^"']+)["'])?[^>]*>([^<]*)/i); const fnMatch = html.match(/<(?:input|textarea)[^>]*name=["']fn["'][^>]*(?:value=["']([^"']+)["'])?[^>]*>([^<]*)/i); // eslint-disable-line security/detect-unsafe-regex -- parses trusted hoster HTML only
if (fnMatch) { if (fnMatch) {
const codeFromFn = (fnMatch[1] || fnMatch[2] || '').trim(); const codeFromFn = (fnMatch[1] || fnMatch[2] || '').trim();
if (/^[a-z0-9]{8,16}$/i.test(codeFromFn)) { if (/^[a-z0-9]{8,16}$/i.test(codeFromFn)) {

View File

@ -168,7 +168,10 @@ class VoeUploader {
} }
}); });
const body = await res.text(); const body = await res.text();
const data = JSON.parse(body); let data;
try { data = JSON.parse(body); } catch {
throw new Error(`VOE: Upload-Server Antwort war kein JSON: ${body.slice(0, 200)}`);
}
if (!data || !data.success || !data.server) { if (!data || !data.success || !data.server) {
throw new Error('VOE: Kein Upload-Server erhalten von delivery-node'); throw new Error('VOE: Kein Upload-Server erhalten von delivery-node');
@ -228,7 +231,8 @@ class VoeUploader {
preamble += `${sessionId}\r\n`; preamble += `${sessionId}\r\n`;
} }
preamble += `--${boundary}\r\n`; preamble += `--${boundary}\r\n`;
preamble += `Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n`; const safeFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
preamble += `Content-Disposition: form-data; name="file"; filename="${safeFileName}"\r\n`;
preamble += `Content-Type: application/octet-stream\r\n\r\n`; preamble += `Content-Type: application/octet-stream\r\n\r\n`;
const epilogue = `\r\n--${boundary}--\r\n`; const epilogue = `\r\n--${boundary}--\r\n`;
@ -244,6 +248,7 @@ class VoeUploader {
yield preambleBuf; yield preambleBuf;
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE }); const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
for await (const chunk of fileStream) { for await (const chunk of fileStream) {
if (signal && signal.aborted) throw new Error('Aborted');
if (throttle) await throttle.consume(chunk.length, signal); if (throttle) await throttle.consume(chunk.length, signal);
bytesRead += chunk.length; bytesRead += chunk.length;
yield chunk; yield chunk;
@ -253,7 +258,7 @@ class VoeUploader {
} }
// Step 3: POST file to CDN upload server // Step 3: POST file to CDN upload server
const { body, statusCode, headers } = await request(uploadServer, { const { body, headers } = await request(uploadServer, {
method: 'POST', method: 'POST',
body: generate(), body: generate(),
signal, signal,

2006
main.js

File diff suppressed because it is too large Load Diff

3278
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "1.6.7", "version": "3.3.51",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
@ -11,11 +11,15 @@
"release:gitea": "node scripts/release_gitea.mjs" "release:gitea": "node scripts/release_gitea.mjs"
}, },
"dependencies": { "dependencies": {
"undici": "^7.16.0" "chokidar": "^3.6.0",
"undici": "^7.16.0",
"ws": "^8.19.0"
}, },
"devDependencies": { "devDependencies": {
"electron": "^33.0.0", "electron": "^41.3.0",
"electron-builder": "^25.0.0", "electron-builder": "^26.8.1",
"eslint": "^10.1.0",
"eslint-plugin-security": "^4.0.0",
"rcedit": "^4.0.1" "rcedit": "^4.0.1"
}, },
"build": { "build": {

5
preload-drop-target.js Normal file
View File

@ -0,0 +1,5 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('dropTargetApi', {
sendFiles: (paths) => ipcRenderer.send('drop-target:files', paths)
});

View File

@ -6,6 +6,8 @@ contextBridge.exposeInMainWorld('api', {
saveConfig: (config) => ipcRenderer.invoke('save-config', config), saveConfig: (config) => ipcRenderer.invoke('save-config', config),
getHistory: () => ipcRenderer.invoke('get-history'), getHistory: () => ipcRenderer.invoke('get-history'),
clearHistory: () => ipcRenderer.invoke('clear-history'), clearHistory: () => ipcRenderer.invoke('clear-history'),
exportHistory: (format) => ipcRenderer.invoke('export-history', format),
saveTextFile: (defaultName, content, filters) => ipcRenderer.invoke('save-text-file', defaultName, content, filters),
// Hoster settings // Hoster settings
getHosterSettings: () => ipcRenderer.invoke('get-hoster-settings'), getHosterSettings: () => ipcRenderer.invoke('get-hoster-settings'),
@ -14,6 +16,7 @@ contextBridge.exposeInMainWorld('api', {
// Global settings // Global settings
getGlobalSettings: () => ipcRenderer.invoke('get-global-settings'), getGlobalSettings: () => ipcRenderer.invoke('get-global-settings'),
saveGlobalSettings: (settings) => ipcRenderer.invoke('save-global-settings', settings), saveGlobalSettings: (settings) => ipcRenderer.invoke('save-global-settings', settings),
saveGlobalSettingsSync: (settings) => ipcRenderer.sendSync('save-global-settings-sync', settings),
// Always on top // Always on top
setAlwaysOnTop: (value) => ipcRenderer.invoke('set-always-on-top', value), setAlwaysOnTop: (value) => ipcRenderer.invoke('set-always-on-top', value),
@ -27,13 +30,20 @@ contextBridge.exposeInMainWorld('api', {
// File selection // File selection
selectFiles: () => ipcRenderer.invoke('select-files'), selectFiles: () => ipcRenderer.invoke('select-files'),
selectFolder: () => ipcRenderer.invoke('select-folder'), selectFolder: () => ipcRenderer.invoke('select-folder'),
resolveFolderFiles: (folderPath) => ipcRenderer.invoke('resolve-folder-files', folderPath),
// Upload control // Upload control
startUpload: (payload) => ipcRenderer.invoke('start-upload', payload), startUpload: (payload) => ipcRenderer.invoke('start-upload', payload),
cancelUpload: () => ipcRenderer.invoke('cancel-upload'), cancelUpload: () => ipcRenderer.invoke('cancel-upload'),
cancelSelectedJobs: (jobIds) => ipcRenderer.invoke('cancel-selected-jobs', jobIds), cancelSelectedJobs: (jobIds) => ipcRenderer.invoke('cancel-selected-jobs', jobIds),
addJobsToBatch: (payload) => ipcRenderer.invoke('add-jobs-to-batch', payload),
finishAfterActive: () => ipcRenderer.invoke('finish-after-active'), finishAfterActive: () => ipcRenderer.invoke('finish-after-active'),
runHealthCheck: (payload) => ipcRenderer.invoke('run-health-check', payload), runHealthCheck: (payload) => ipcRenderer.invoke('run-health-check', payload),
validateCredentials: (payload) => ipcRenderer.invoke('validate-credentials', payload),
// Log import
readOwnUploadLog: () => ipcRenderer.invoke('read-own-upload-log'),
importUploadLog: () => ipcRenderer.invoke('import-upload-log'),
// Clipboard // Clipboard
copyToClipboard: (text) => ipcRenderer.invoke('copy-to-clipboard', text), copyToClipboard: (text) => ipcRenderer.invoke('copy-to-clipboard', text),
@ -50,6 +60,31 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.on('app:update-progress', (_event, data) => callback(data)); ipcRenderer.on('app:update-progress', (_event, data) => callback(data));
}, },
// Backup
exportBackup: () => ipcRenderer.invoke('export-backup'),
importBackup: (legacyPassword) => ipcRenderer.invoke('import-backup', legacyPassword),
// Folder Monitor
folderMonitorStart: (settings) => ipcRenderer.invoke('folder-monitor:start', settings),
folderMonitorStop: () => ipcRenderer.invoke('folder-monitor:stop'),
folderMonitorStatus: () => ipcRenderer.invoke('folder-monitor:status'),
folderMonitorSelectFolder: () => ipcRenderer.invoke('folder-monitor:select-folder'),
onFolderMonitorNewFiles: (callback) => {
ipcRenderer.on('folder-monitor:new-files', (_event, data) => callback(data));
},
// Account switched event
onAccountSwitched: (callback) => {
ipcRenderer.on('account-switched', (_event, data) => callback(data));
},
// Drop Target
showDropTarget: () => ipcRenderer.invoke('show-drop-target'),
hideDropTarget: () => ipcRenderer.invoke('hide-drop-target'),
onDropTargetFiles: (callback) => {
ipcRenderer.on('drop-target:files', (_event, paths) => callback(paths));
},
// Debug // Debug
debugTestUpload: () => ipcRenderer.invoke('debug-test-upload'), debugTestUpload: () => ipcRenderer.invoke('debug-test-upload'),
debugLog: (msg) => ipcRenderer.invoke('debug-log', msg), debugLog: (msg) => ipcRenderer.invoke('debug-log', msg),
@ -58,6 +93,9 @@ contextBridge.exposeInMainWorld('api', {
onUploadProgress: (callback) => { onUploadProgress: (callback) => {
ipcRenderer.on('upload-progress', (_event, data) => callback(data)); ipcRenderer.on('upload-progress', (_event, data) => callback(data));
}, },
onUploadProgressBatch: (callback) => {
ipcRenderer.on('upload-progress-batch', (_event, batch) => callback(batch));
},
onUploadBatchDone: (callback) => { onUploadBatchDone: (callback) => {
ipcRenderer.on('upload-batch-done', (_event, data) => callback(data)); ipcRenderer.on('upload-batch-done', (_event, data) => callback(data));
}, },
@ -67,6 +105,34 @@ contextBridge.exposeInMainWorld('api', {
onShutdownCountdown: (callback) => { onShutdownCountdown: (callback) => {
ipcRenderer.on('shutdown-countdown', (_event, data) => callback(data)); ipcRenderer.on('shutdown-countdown', (_event, data) => callback(data));
}, },
onUploadLogFallback: (callback) => {
ipcRenderer.on('upload-log-fallback', (_event, data) => callback(data));
},
onAccountRotationLog: (callback) => {
ipcRenderer.on('account-rotation-log', (_event, data) => callback(data));
},
openLogFolder: () => ipcRenderer.invoke('open-log-folder'),
getJobLog: (jobId) => ipcRenderer.invoke('get-job-log', jobId),
getSessionFailedAccounts: () => ipcRenderer.invoke('get-session-failed-accounts'),
resetSessionFailedAccount: (payload) => ipcRenderer.invoke('reset-session-failed-account', payload),
resetAllSessionFailedAccounts: () => ipcRenderer.invoke('reset-all-session-failed-accounts'),
getLogPaths: () => ipcRenderer.invoke('get-log-paths'),
revealLogFile: (target) => ipcRenderer.invoke('reveal-log-file', target),
setLogVerbose: (enabled) => ipcRenderer.invoke('set-log-verbose', enabled),
createSupportBundle: () => ipcRenderer.invoke('create-support-bundle'),
getAppInfo: () => ipcRenderer.invoke('get-app-info'),
onLogPathAutoUpdated: (callback) => {
ipcRenderer.on('log-path-auto-updated', (_event, data) => callback(data));
},
// Remote Control
remoteGetSettings: () => ipcRenderer.invoke('remote:get-settings'),
remoteSaveSettings: (settings) => ipcRenderer.invoke('remote:save-settings', settings),
remoteGenerateToken: () => ipcRenderer.invoke('remote:generate-token'),
remoteStatus: () => ipcRenderer.invoke('remote:status'),
onRemoteClientCount: (callback) => {
ipcRenderer.on('remote:client-count', (_event, count) => callback(count));
},
// File path from drag & drop (Electron 33+ compatible) // File path from drag & drop (Electron 33+ compatible)
getPathForFile: (file) => webUtils.getPathForFile(file), getPathForFile: (file) => webUtils.getPathForFile(file),
removeAllListeners: () => { removeAllListeners: () => {
@ -76,5 +142,9 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.removeAllListeners('app:update-available'); ipcRenderer.removeAllListeners('app:update-available');
ipcRenderer.removeAllListeners('app:update-progress'); ipcRenderer.removeAllListeners('app:update-progress');
ipcRenderer.removeAllListeners('shutdown-countdown'); ipcRenderer.removeAllListeners('shutdown-countdown');
ipcRenderer.removeAllListeners('folder-monitor:new-files');
ipcRenderer.removeAllListeners('drop-target:files');
ipcRenderer.removeAllListeners('account-switched');
ipcRenderer.removeAllListeners('remote:client-count');
} }
}); });

File diff suppressed because it is too large Load Diff

68
renderer/drop-target.html Normal file
View File

@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
-webkit-app-region: drag;
user-select: none;
}
.target {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed rgba(126, 220, 255, 0.5);
border-radius: 10px;
background: rgba(22, 24, 28, 0.85);
transition: border-color 0.15s, background 0.15s;
}
.target.drag-over {
border-color: rgba(126, 220, 255, 0.9);
background: rgba(62, 167, 255, 0.15);
}
.icon {
font-size: 64px;
font-weight: 200;
color: rgba(126, 220, 255, 0.7);
line-height: 1;
-webkit-app-region: no-drag;
}
</style>
</head>
<body>
<div class="target" id="target">
<div class="icon">+</div>
</div>
<script>
const target = document.getElementById('target');
target.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
target.classList.add('drag-over');
});
target.addEventListener('dragleave', (e) => {
e.preventDefault();
target.classList.remove('drag-over');
});
target.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
target.classList.remove('drag-over');
const paths = [];
for (const file of e.dataTransfer.files) {
if (file.path) paths.push(file.path);
}
if (paths.length > 0) {
window.dropTargetApi.sendFiles(paths);
}
});
</script>
</body>
</html>

View File

@ -24,11 +24,11 @@
<div id="upload-view" class="view active"> <div id="upload-view" class="view active">
<div class="upload-toolbar"> <div class="upload-toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
<button class="btn btn-xs btn-secondary" id="chooseHostersBtn">Ziele auswählen</button>
<span class="hoster-summary" id="hosterSummary" style="display:none"></span> <span class="hoster-summary" id="hosterSummary" style="display:none"></span>
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<button class="btn btn-xs btn-primary" id="addFilesBtn">+ Dateien</button> <button class="btn btn-xs btn-primary" id="addFilesBtn">+ Dateien</button>
<button class="btn btn-xs btn-secondary" id="addFolderBtn">+ Ordner</button>
</div> </div>
</div> </div>
@ -77,14 +77,14 @@
<table class="queue-table" id="queueTable"> <table class="queue-table" id="queueTable">
<thead> <thead>
<tr> <tr>
<th class="col-filename sortable" data-sort="filename">Dateiname</th> <th class="col-filename sortable" data-col="filename" data-sort="filename">Filename<span class="col-resizer"></span></th>
<th class="col-size sortable" data-sort="size">Hochgeladen / Größe</th> <th class="col-size sortable" data-col="size" data-sort="size">Uploaded / Size<span class="col-resizer"></span></th>
<th class="col-host sortable" data-sort="host">Host</th> <th class="col-host sortable" data-col="host" data-sort="host">Host<span class="col-resizer"></span></th>
<th class="col-status sortable" data-sort="status">Status</th> <th class="col-status sortable" data-col="status" data-sort="status">Status<span class="col-resizer"></span></th>
<th class="col-elapsed">Zeit</th> <th class="col-elapsed" data-col="elapsed">Zeit<span class="col-resizer"></span></th>
<th class="col-remaining">Rest</th> <th class="col-remaining" data-col="remaining">Rest<span class="col-resizer"></span></th>
<th class="col-speed sortable" data-sort="speed">Speed</th> <th class="col-speed sortable" data-col="speed" data-sort="speed">Speed<span class="col-resizer"></span></th>
<th class="col-progress">Fortschritt</th> <th class="col-progress sortable" data-col="progress" data-sort="progress">Progress</th>
</tr> </tr>
</thead> </thead>
<tbody id="queueBody"></tbody> <tbody id="queueBody"></tbody>
@ -93,7 +93,16 @@
<div class="queue-actions" id="queueActions" style="display:none"> <div class="queue-actions" id="queueActions" style="display:none">
<button class="btn btn-xs btn-primary" id="copyAllLinksBtn">Alle Links kopieren</button> <button class="btn btn-xs btn-primary" id="copyAllLinksBtn">Alle Links kopieren</button>
<select class="hs-input" id="linkExportFormat" title="Ausgabe-Format der kopierten Links" style="max-width:none;width:auto;min-width:130px">
<option value="plain">Plaintext</option>
<option value="bbcode">BBCode</option>
<option value="markdown">Markdown</option>
<option value="html">HTML</option>
<option value="csv">CSV</option>
<option value="json">JSON</option>
</select>
<button class="btn btn-xs btn-secondary" id="retryFailedBtn" style="display:none">Fehlgeschlagene erneut</button> <button class="btn btn-xs btn-secondary" id="retryFailedBtn" style="display:none">Fehlgeschlagene erneut</button>
<button class="btn btn-xs btn-secondary" id="importLogBtn" title="Log importieren — bereits hochgeladene aus Queue entfernen">Log importieren</button>
</div> </div>
<div class="resize-handle" id="recentFilesResizer"></div> <div class="resize-handle" id="recentFilesResizer"></div>
@ -104,6 +113,8 @@
<button class="recent-tab" data-panel="statsTab">Stats</button> <button class="recent-tab" data-panel="statsTab">Stats</button>
</div> </div>
<span class="recent-files-hint" id="recentFilesHint">Zuletzt erzeugte Upload-Links</span> <span class="recent-files-hint" id="recentFilesHint">Zuletzt erzeugte Upload-Links</span>
<button class="btn btn-xs btn-secondary" id="exportRecentFilesBtn" title="Alle Zeilen als Datei exportieren (Zeit, Hoster, Link, Dateiname)">Exportieren</button>
<button class="btn btn-xs btn-danger" id="clearRecentFilesBtn" title="Alle Links aus diesem Panel entfernen">Alle entfernen</button>
</div> </div>
<div class="recent-tab-body active" id="filesTab"> <div class="recent-tab-body active" id="filesTab">
<div class="recent-files-table-wrap"> <div class="recent-files-table-wrap">
@ -111,7 +122,7 @@
<thead id="recentFilesHead"> <thead id="recentFilesHead">
<tr> <tr>
<th class="col-date sortable" data-recent-sort="date">Datum<span class="sort-indicator"></span></th> <th class="col-date sortable" data-recent-sort="date">Datum<span class="sort-indicator"></span></th>
<th class="col-filename sortable" data-recent-sort="filename">Dateiname<span class="sort-indicator"></span></th> <th class="col-filename sortable" data-recent-sort="filename">Filename<span class="sort-indicator"></span></th>
<th class="col-host sortable" data-recent-sort="host">Host<span class="sort-indicator"></span></th> <th class="col-host sortable" data-recent-sort="host">Host<span class="sort-indicator"></span></th>
<th class="col-link sortable" data-recent-sort="link">Link<span class="sort-indicator"></span></th> <th class="col-link sortable" data-recent-sort="link">Link<span class="sort-indicator"></span></th>
</tr> </tr>
@ -184,6 +195,10 @@
<label>Hoster</label> <label>Hoster</label>
<select class="key-input" id="accountHosterSelect" style="max-width:300px"></select> <select class="key-input" id="accountHosterSelect" style="max-width:300px"></select>
</div> </div>
<div class="settings-row">
<label>Label (optional)</label>
<input type="text" class="key-input" id="accField_label" placeholder="z.B. Hauptaccount, Premium, Kunde XY" maxlength="60">
</div>
<div id="accountCredsFields"></div> <div id="accountCredsFields"></div>
<div class="account-modal-status" id="accountModalStatus"></div> <div class="account-modal-status" id="accountModalStatus"></div>
</div> </div>
@ -194,6 +209,22 @@
</div> </div>
</div> </div>
<div class="modal-overlay" id="jobLogModal" style="display:none">
<div class="modal-card" style="width:min(820px,96%);max-height:80vh;display:flex;flex-direction:column">
<div class="modal-header">
<div><h3 id="jobLogTitle">Upload-Log</h3></div>
<button class="icon-btn" id="closeJobLogBtn" aria-label="Schließen">&times;</button>
</div>
<div class="modal-body" style="flex:1 1 auto;overflow:auto">
<pre id="jobLogBody" style="white-space:pre-wrap;font-family:ui-monospace,Consolas,Menlo,monospace;font-size:12px;line-height:1.4;margin:0">Keine Einträge.</pre>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="copyJobLogBtn">In Zwischenablage</button>
<button class="btn btn-primary" id="closeJobLogBtn2">Schließen</button>
</div>
</div>
</div>
<div class="modal-overlay" id="deleteAccountModal" style="display:none"> <div class="modal-overlay" id="deleteAccountModal" style="display:none">
<div class="modal-card" style="width:min(400px,100%)"> <div class="modal-card" style="width:min(400px,100%)">
<div class="modal-header"> <div class="modal-header">
@ -226,19 +257,29 @@
<div class="history-container"> <div class="history-container">
<div class="history-header"> <div class="history-header">
<h2>Upload-Verlauf</h2> <h2>Upload-Verlauf</h2>
<div style="display:flex; gap:8px">
<button class="btn btn-secondary" id="exportHistoryBtn">Verlauf exportieren</button>
<button class="btn btn-secondary" id="clearHistoryBtn">Verlauf löschen</button> <button class="btn btn-secondary" id="clearHistoryBtn">Verlauf löschen</button>
</div> </div>
</div>
<div id="historyContainer"></div> <div id="historyContainer"></div>
</div> </div>
</div> </div>
<div class="context-menu" id="contextMenu" style="display:none"> <div class="context-menu" id="contextMenu" style="display:none">
<div class="ctx-item" data-action="start-selected">Ausgewählte starten</div> <div class="ctx-item" data-action="start-selected">Ausgewählte starten</div>
<div class="ctx-item" data-action="copy-links">Links kopieren</div>
<div class="ctx-item" data-action="retry-selected">Erneut versuchen</div> <div class="ctx-item" data-action="retry-selected">Erneut versuchen</div>
<div class="ctx-item" data-action="delete-selected">Entfernen</div> <div class="ctx-item" data-action="show-log">Log anzeigen</div>
<div class="ctx-separator"></div> <div class="ctx-separator"></div>
<div class="ctx-item" data-action="copy-links">Links kopieren</div>
<div class="ctx-item" data-action="copy-all-links">Alle Links kopieren</div> <div class="ctx-item" data-action="copy-all-links">Alle Links kopieren</div>
<div class="ctx-separator"></div>
<div class="ctx-item" data-action="delete-selected">Entfernen</div>
<div class="ctx-item" data-action="delete-all">Alle entfernen</div>
<div class="ctx-submenu ctx-hoster-delete-submenu" style="display:none">
<div class="ctx-item ctx-item-danger">Hoster entfernen &#9656;</div>
<div class="ctx-submenu-items ctx-hoster-delete-items"></div>
</div>
</div> </div>
<div class="context-menu" id="recentContextMenu" style="display:none"> <div class="context-menu" id="recentContextMenu" style="display:none">
@ -263,6 +304,8 @@
<span class="sb-separator">|</span> <span class="sb-separator">|</span>
<span class="sb-progress-count" id="sbInProgressCount">In Progress 0</span> <span class="sb-progress-count" id="sbInProgressCount">In Progress 0</span>
<span class="sb-separator">|</span> <span class="sb-separator">|</span>
<span class="sb-done-count" id="sbDoneCount">Done 0</span>
<span class="sb-separator">|</span>
<span class="sb-error-count" id="sbErrorCount">Error 0</span> <span class="sb-error-count" id="sbErrorCount">Error 0</span>
</div> </div>
@ -299,6 +342,28 @@
</div> </div>
</div> </div>
<div class="modal" id="batchSummaryModal" style="display:none">
<div class="modal-content" style="max-width:680px">
<div class="modal-header">
<h2>Batch-Zusammenfassung</h2>
<button class="icon-btn" id="batchSummaryClose" aria-label="Schließen">&times;</button>
</div>
<div class="modal-body">
<div id="batchSummaryList"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="batchSummaryRetryTransient">Transiente erneut hochladen</button>
<button class="btn btn-primary" id="batchSummaryRetryAll">Alle Fehler erneut versuchen</button>
</div>
</div>
</div>
<script src="../lib/queue-prune.js"></script>
<script src="../lib/queue-dedup.js"></script>
<script src="../lib/log-mode.js"></script>
<script src="../lib/stats.js"></script>
<script src="../lib/throttled-cache.js"></script>
<script src="../lib/coalesced-set.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

View File

@ -26,7 +26,8 @@ body {
background: background:
radial-gradient(circle at top right, rgba(62, 167, 255, 0.08), transparent 28%), radial-gradient(circle at top right, rgba(62, 167, 255, 0.08), transparent 28%),
linear-gradient(180deg, #14171b 0%, #191d23 100%); linear-gradient(180deg, #14171b 0%, #191d23 100%);
min-height: 100vh; height: 100vh;
overflow: hidden;
color: var(--text); color: var(--text);
user-select: none; user-select: none;
display: flex; display: flex;
@ -41,6 +42,9 @@ body {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(0, 0, 0, 0.08)); background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(0, 0, 0, 0.08));
flex-shrink: 0; flex-shrink: 0;
position: sticky;
top: 0;
z-index: 100;
} }
.tab { .tab {
@ -263,6 +267,23 @@ body {
} }
.queue-table th.sortable { cursor: pointer; } .queue-table th.sortable { cursor: pointer; }
.queue-table th.sortable:hover { color: var(--text); } .queue-table th.sortable:hover { color: var(--text); }
.queue-table th { position: relative; }
.col-resizer {
position: absolute;
top: 0;
right: 0;
width: 6px;
height: 100%;
cursor: col-resize;
user-select: none;
z-index: 6;
background: transparent;
transition: background 0.15s;
}
.col-resizer:hover { background: rgba(102, 126, 234, 0.4); }
.col-resizer.dragging { background: rgba(102, 126, 234, 0.6); }
body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-select: none !important; }
.col-filename { width: 30%; } .col-filename { width: 30%; }
.col-size { width: 12%; } .col-size { width: 12%; }
@ -284,8 +305,12 @@ body {
.virtual-spacer td { padding: 0 !important; border: none !important; } .virtual-spacer td { padding: 0 !important; border: none !important; }
/* Queue Row States */ /* Queue Row States */
.queue-row { transition: background 0.15s; cursor: pointer; } /* Transition only on hover-enter/leave so that status flips during a busy
.queue-row:hover { background: rgba(255, 255, 255, 0.04); } upload (queuedgetting-serveruploadingdone) don't trigger compositor
repaints with 150ms tweens for every visible row. With 30+ rows flipping
simultaneously the overlapping transitions cost real GPU time. */
.queue-row { cursor: pointer; }
.queue-row:hover { background: rgba(255, 255, 255, 0.04); transition: background 0.15s; }
.queue-row.selected { background: rgba(102, 126, 234, 0.12) !important; } .queue-row.selected { background: rgba(102, 126, 234, 0.12) !important; }
.queue-row.status-uploading { background: rgba(102, 126, 234, 0.08); } .queue-row.status-uploading { background: rgba(102, 126, 234, 0.08); }
@ -325,8 +350,8 @@ body {
} }
.progress-bar-fill { .progress-bar-fill {
height: 100%; height: 100%;
transition: width 0.3s ease;
border-radius: 2px; border-radius: 2px;
will-change: width;
} }
.progress-bar-fill.status-uploading { background: linear-gradient(90deg, #4a90d9, #5dabf7); } .progress-bar-fill.status-uploading { background: linear-gradient(90deg, #4a90d9, #5dabf7); }
.progress-bar-fill.status-getting-server { background: var(--accent); } .progress-bar-fill.status-getting-server { background: var(--accent); }
@ -405,6 +430,10 @@ body {
.recent-files-hint { .recent-files-hint {
font-size: 11px; font-size: 11px;
color: var(--text-dim); color: var(--text-dim);
margin-left: auto;
}
.recent-files-header #clearRecentFilesBtn {
margin-left: 8px;
} }
.stats-grid { .stats-grid {
display: flex; display: flex;
@ -601,6 +630,8 @@ body {
position: relative; position: relative;
} }
.ctx-item:hover { background: rgba(102, 126, 234, 0.2); } .ctx-item:hover { background: rgba(102, 126, 234, 0.2); }
.ctx-item-danger { color: var(--danger); }
.ctx-item-danger:hover { background: rgba(231, 76, 60, 0.2); }
.ctx-separator { height: 1px; margin: 4px 8px; background: var(--border); } .ctx-separator { height: 1px; margin: 4px 8px; background: var(--border); }
.ctx-submenu { position: relative; } .ctx-submenu { position: relative; }
.ctx-submenu-items { .ctx-submenu-items {
@ -658,7 +689,11 @@ body {
gap: 8px; gap: 8px;
margin-bottom: 6px; margin-bottom: 6px;
} }
.checkbox-row {
margin-bottom: 0;
}
.checkbox-row input[type="checkbox"] { .checkbox-row input[type="checkbox"] {
order: -1;
width: 16px; width: 16px;
height: 16px; height: 16px;
} }
@ -678,8 +713,21 @@ body {
font-size: 12px; font-size: 12px;
max-width: 300px; max-width: 300px;
} }
/* Checkbox-type .hs-input (e.g. per-hoster "Links in Log schreiben") must
not inherit the stretched text-input box styling above render it as a
normal small checkbox sitting next to its label. */
.hs-input[type="checkbox"] {
flex: none;
width: 16px;
height: 16px;
max-width: none;
padding: 0;
background: none;
border: none;
}
.key-input:focus, .hs-input:focus { border-color: var(--accent); outline: none; } .key-input:focus, .hs-input:focus { border-color: var(--accent); outline: none; }
.hs-input { max-width: 100px; } .hs-input { max-width: 100px; }
select.hs-input { max-width: none; width: auto; min-width: 140px; }
.hint { font-size: 10px; color: var(--text-dim); } .hint { font-size: 10px; color: var(--text-dim); }
.settings-section-label { .settings-section-label {
font-size: 10px; font-size: 10px;
@ -705,6 +753,8 @@ body {
.toggle-vis:hover { border-color: var(--border-hover); } .toggle-vis:hover { border-color: var(--border-hover); }
.settings-save-row { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-top: 12px; } .settings-save-row { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-top: 12px; }
.settings-backup-section { margin-top: 24px; border-top: 1px solid var(--border); padding-top: 16px; }
.settings-backup-buttons { display: flex; gap: 8px; }
.save-feedback { font-size: 12px; color: var(--success); } .save-feedback { font-size: 12px; color: var(--success); }
.settings-empty { .settings-empty {
padding: 28px 16px; padding: 28px 16px;
@ -775,6 +825,9 @@ body {
.account-status.status-error { background: rgba(231, 76, 60, 0.2); color: var(--danger); } .account-status.status-error { background: rgba(231, 76, 60, 0.2); color: var(--danger); }
.account-status.status-warn { background: rgba(240, 195, 108, 0.2); color: var(--warning); } .account-status.status-warn { background: rgba(240, 195, 108, 0.2); color: var(--warning); }
.account-status.status-unchecked { background: rgba(255, 255, 255, 0.05); color: var(--text-dim); } .account-status.status-unchecked { background: rgba(255, 255, 255, 0.05); color: var(--text-dim); }
.account-status.status-disabled { background: rgba(255, 255, 255, 0.05); color: var(--text-muted); }
.account-card.account-disabled { opacity: 0.5; }
.account-card.account-disabled:hover { opacity: 0.7; }
.account-status-dot { .account-status-dot {
width: 7px; width: 7px;
@ -797,6 +850,123 @@ body {
.account-modal-status.ok { color: var(--success); } .account-modal-status.ok { color: var(--success); }
.account-modal-status.error { color: var(--danger); } .account-modal-status.error { color: var(--danger); }
/* Multi-account: drag handle, priority badge, hoster group */
.account-card-drag-handle {
cursor: grab;
font-size: 14px;
color: var(--text-dim);
padding: 2px 4px;
flex-shrink: 0;
user-select: none;
}
.account-card-drag-handle:hover { color: var(--text-muted); }
.account-card.dragging { opacity: 0.4; }
.account-card.drag-over-above { border-top: 2px solid var(--accent); }
.account-card.drag-over-below { border-bottom: 2px solid var(--accent); }
.account-priority-badge {
font-size: 10px;
font-weight: 500;
color: var(--text-dim);
background: rgba(255, 255, 255, 0.06);
padding: 1px 6px;
border-radius: 3px;
margin-left: 6px;
}
.account-hoster-group {
margin-bottom: 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
overflow: hidden;
}
.account-hoster-group-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
user-select: none;
background: var(--bg-card);
transition: background 0.1s;
}
.account-hoster-group-header:hover { background: var(--bg-card-hover); }
.account-hoster-group-title {
font-size: 12px;
font-weight: 600;
color: var(--text);
text-transform: uppercase;
letter-spacing: 0.5px;
flex: 1;
}
.account-hoster-group-count {
font-size: 12px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.account-hoster-group-meta {
font-size: 11px;
color: var(--text-muted);
padding: 1px 6px;
border-radius: 4px;
background: rgba(255,255,255,0.04);
}
.account-hoster-group-meta.error {
color: var(--danger, #e57373);
background: rgba(229, 115, 115, 0.12);
}
.account-session-paused {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: #f0c36c;
background: rgba(240, 195, 108, 0.12);
padding: 1px 6px;
border-radius: 4px;
margin-left: 6px;
}
.account-session-reactivate {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 0 2px;
}
.account-session-reactivate:hover { color: #fff; }
.account-session-paused-card { opacity: 0.85; }
.batch-cat {
margin-bottom: 10px;
padding: 6px 8px;
border-radius: 6px;
background: rgba(255,255,255,0.03);
}
.batch-cat-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; font-size: 13px; }
.batch-cat-count { color: var(--text-muted); font-variant-numeric: tabular-nums; }
.batch-cat-tag { font-size: 10px; padding: 1px 6px; border-radius: 4px; background: rgba(255,255,255,0.06); color: var(--text-muted); }
.batch-cat-tag.retryable { background: rgba(76, 175, 80, 0.18); color: #a5d6a7; }
.batch-cat-list { margin: 0; padding-left: 18px; font-size: 11px; color: var(--text-muted); }
.batch-cat-list em { color: var(--text-muted); font-style: italic; }
.account-hoster-group-body {
padding: 8px;
border-top: 1px solid var(--border);
}
.account-hoster-group .account-card { margin-bottom: 4px; }
.account-hoster-group .account-card:last-child { margin-bottom: 0; }
.account-status-dot.status-ok { background: #4caf50; }
.account-status-dot.status-error { background: #e57373; }
.account-status-dot.status-checking { background: #f0c36c; }
.account-status-dot.status-unchecked { background: #6c757d; }
.account-hoster-group-header .account-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.accounts-empty { .accounts-empty {
text-align: center; text-align: center;
padding: 48px 16px; padding: 48px 16px;

View File

@ -3,7 +3,7 @@ const path = require("path");
module.exports = async function afterPack(context) { module.exports = async function afterPack(context) {
let rcedit; let rcedit;
try { try {
rcedit = require("rcedit").rcedit; rcedit = require("rcedit");
} catch { } catch {
console.warn(" rcedit: skipped - rcedit not installed"); console.warn(" rcedit: skipped - rcedit not installed");
return; return;

64
tasks/lessons.md Normal file
View File

@ -0,0 +1,64 @@
# Lessons
## 2026-04-21 — DOM-Doppelrender bei Bulk-State-Changes
**Symptom:** User klickt auf "Erneut versuchen" mit 500+ Jobs → App hängt sekundenlang.
**Root cause:** `retrySelectedJobs()` ruft `renderQueueTable + updateQueueActionButtons + updateStatusBar` auf, `startSelectedUpload()` ruft direkt danach genau dieselben Funktionen nochmal auf.
**Regel:** Wenn ein Click-Handler `await anotherHandler()` aufruft und der innere Handler seinen eigenen kompletten Render-Zyklus hat, NIEMALS noch einen davor. Einmal ist genug — der folgende innere Render sieht die frischen State-Mutationen ohnehin.
**Wie anwenden:** Vor jeder `await fn()`-Folge in einem Handler prüfen: macht `fn` schon `renderQueueTable()`? Wenn ja, äußere Render-Calls löschen.
## 2026-04-21 — State-Checks MÜSSEN hinter die Semaphore-Queue
**Symptom:** Pre-Job-Swap prüfte `_failedAccounts` vor `semaphore.acquire`. Bei N parallelen Workers war der Check zum Start für ALLE leer — niemand hat geswapt. Erst nachdem alle im Semaphore ordentlich gewartet hatten und einer fehlschlug, wurde _failedAccounts befüllt, aber die anderen hatten ihren Check längst hinter sich.
**Regel:** State-basierte Entscheidungen (failed accounts, overrides, cached stats) gehören direkt vor die Aktion die sie betreffen — **nach** jeder async `await` die die Position in der Queue bestimmt. Nicht am Task-Start für später wichtigen State abfragen.
**Wie anwenden:** Bei Queue-basierten Pipelines prüfen: "Was kann sich zwischen Task-Start und dem tatsächlichen Execute ändern?" Alles was sich ändern kann, muss direkt vor dem Execute geprüft werden, nicht davor.
## 2026-04-21 — Reaktive Config-Updates für laufende State-Maschinen
**Symptom:** User fügt mid-batch einen neuen Account hinzu, aber der UploadManager merkt nicht dass die Config sich geändert hat. `account-failed` Event feuert nur einmal pro Account → keine zweite Re-Resolve-Chance.
**Regel:** Wenn ein State nur bei Events neu evaluiert wird und Events "nur einmal" feuern, muss jede externe Zustandsänderung (Config-Save, User-Action) den State explizit triggern.
**Wie anwenden:** Save-Handler müssen aktive State-Maschinen informieren. Lieber einen überflüssigen Re-Resolve-Call als einen verpassten. Für Upload-Manager: nach saveConfig → re-evaluate failed accounts ohne Override.
## 2026-04-21 — Error-Klassifikation: fileRejected vs accountError
**Symptom:** Voller Byse-Account wurde nicht rotiert — `skip-rotation-file-rejected` geloggt für jede Datei.
**Root cause:** Generisches Match auf Prefix-String (`"lehnte Datei ab"`) klassifizierte ALLE Byse-Errors als file-level, inklusive Account-voll-Meldungen.
**Regel:** Hoster-Parser setzen den **spezifischen Flag** (`fileRejected` ODER `accountError`), nicht beide nie. Classifier matcht **konkrete Phrasen** (Duplicate, Not video format, …), niemals generische Wrapper-Strings die für mehrere Fehlerarten benutzt werden.
**Wie anwenden:**
- Bei neuen Hostern: per-status-Klassifikation bereits im Parser, nicht erst im Upload-Manager.
- Classifier-Regexes auf Rejection-Kernphrasen, nicht auf UI-Prefix.
- Defensive: `accountError === true` gewinnt immer gegen `fileRejected` — Account-Rotation ist weniger schlimm als endlose Fails auf einem toten Account.
## 2026-04-21 — Keine fake Build-ETAs
**Symptom:** User wartet 5+ min auf Tauri-Build den ich mit "1-2min" angekündigt habe.
**Regel:** Tauri-Release-Builds brauchen real 3-6 min (Rust + NSIS + MSI). Keine Zeitangabe oder ehrlich "kann 3-6min dauern" schreiben.
**Wie anwenden:** Wenn User nach Status fragt: sofort `tail` des Logs + `ls` des Bundle-Ordners zitieren, nicht raten.
## 2026-05-24 — Packaged-Electron Log-Pfade: nie __dirname/.. zum Schreiben
**Symptom:** doodstream-debug.log hatte auf dem Server null aktuelle Einträge; nur alte Dev-Logs. Fehler "kein Filecode" war nicht diagnostizierbar.
**Root cause:** `path.join(__dirname, '..', 'x.log')` zeigt im gepackten Build in `resources/app.asar` (read-only). `fs.appendFileSync` wirft EACCES, der `try/catch` schluckt es → null Production-Logs.
**Regel:** Schreibbare Pfade IMMER über `app.getPath('userData')` (lazy `require('electron')`, Fallback `__dirname/..` nur für Tests/plain-node). Gilt für jede Datei die der gepackte App schreibt.
**Wie anwenden:** Bei jedem neuen Log/Cache/State-File prüfen: wohin schreibt das im NSIS-Build? Nicht ins Install-Verzeichnis, nicht in asar.
## 2026-05-24 — Hoster-Fehler: echten Status surfacen, nicht generisch schlucken
**Symptom:** "upload_result Seite hat keinen filecode (<leeres textarea>)" — nichtssagend; User dachte doodstream-Format geändert.
**Root cause:** XFileSharing liefert den echten Grund im `st`-Feld (Error: duplicate / file too big / …). Code ignorierte `st` komplett und warf nur den leeren Body.
**Regel:** Bei Hoster-Parsefehlern immer die Server-Statusfelder (st/msg/code) + Kontext (welcher CDN-Node, war filecode da) in die Fehlermeldung packen. Format-Struktur unverändert + leerer Inhalt = Backend-Ablehnung, kein Parsing-Bug.
## 2026-05-25 — Queue leer nach Update: Auto-Dedup zu aggressiv (nicht Save/Restore)
**Symptom:** Queue gestoppt, App-Update -> nach Neustart Queue leer ("Dateien hierhin ziehen"). User dachte Save/Restore kaputt.
**Root cause:** Queue WIRD korrekt gespeichert (pendingQueue) + restored. ABER `_autoDeduplicateFromLog` (läuft bei init nach restore) entfernte Jobs per `fileName|hoster`-Match gegen das GESAMTE Lifetime-fileuploader.log — UNABHÄNGIG vom Status. Pending 'preview'-Jobs, deren Datei früher mal hochgeladen wurde, flogen alle raus -> komplette Queue weg. "Update-spezifisch" nur weil der Server-App nur beim Update neustartet (normaler Restart hätte dasselbe getan).
**Verifiziert:** Reale electron-config.json: 4 preview-Jobs, alle 4 Keys im Log -> alte Logik entfernt 4/4. Neue Logik (nur status==='done' droppen) entfernt 0/4.
**Regel:** Auto-Cleanup/Dedup darf NIE pending/actionable User-Arbeit löschen. Nur genuin abgeschlossene ('done') Jobs decluttern. Lifetime-Logs sind Historie, nicht Session-Fortschritt — nicht als "schon erledigt"-Quelle für pending Jobs missbrauchen.
**Wie anwenden:** Bei jeder Filter/Remove-Logik auf User-State: nach Status gaten, nicht nur nach Identitäts-Match gegen historische Daten.
## 2026-05-28 — Doodstream "kein Filecode": Web-Scraping ist die falsche Ebene, API ist der Fix
**Symptom:** Wiederkehrend "kein Filecode — Server gab leeren Link zurueck" bei großen Dateien (~1GB/7min Upload), trotz 3.3.26-3.3.29. Queue voll roter Fehler.
**Root cause (recherchiert + verifiziert):** Der Web-Upload holt den Filecode aus einem XFileSharing-HTML-Formular. Bei langen Uploads kommt das Formular leer zurück, weil (a) der per-Seitenaufruf sess_id-Token über den 7min-Upload altert UND (b) der server-seitige File-Registration-Callback (cgi-bin/fs.cgi-Äquivalent) unter Last timeoutet → kein file_code gemintet. Wichtig: Das ist KEIN async-delay — die Datei taucht NICHT später in der Liste auf (die Registrierung, die sie listen würde, ist genau das was failt). File-list-Polling (wie Byse) hilft hier also kaum.
**Fix:** Die offizielle doodapi.co JSON-API nutzen, wenn ein API-Key da ist — sie liefert result[0].filecode DIREKT in JSON (kein HTML-Formular) und nutzt einen persistenten api_key (kein alternder sess_id). Git-Historie: die API war der ORIGINAL-Pfad (initial commit); Web-Login kam später nur "als Alternative zum API-Key" — Key-Bevorzugung stellt also den gedachten Primärpfad wieder her, kämpft nicht gegen eine bewusste Entscheidung.
**Regel:** Bei Hoster-Integrationen die offizielle API der Web-Scraping-Ebene vorziehen wo möglich. Empty-form/codeless-2xx = Hoster-Backend-Flake (hosterTransient), Account NICHT als tot markieren — auf BEIDEN Pfaden (Web + API) gleich klassifizieren.
**Voraussetzung:** Engagiert nur wenn der Doodstream-Account einen gültigen API-Key hat (doodstream.com/settings). Keyless-Accounts bleiben beim Web-Pfad.
## 2026-05-28 — Doodstream empty-form: live diagnosis confirmed API path is the fix
**Verifiziert mit echtem Account-Key (read-only API-Calls):**
- account/info → status 200, Key gültig, Storage unlimited. Premium ABGELAUFEN (2025-10-03) — Uploads gehen TROTZDEM.
- upload/server → liefert gültigen Node (cv1130ed.cloudatacdn.com) auch ohne Premium → API-Upload-Pfad nutzbar.
- file/list → 90.548 Dateien; Uploads landen server-seitig INTERMITTIEREND (viele Burn-Notice-Folgen genau im "Fehler"-Zeitfenster vorhanden). Das leere Formular ist also nicht "immer kaputt", sondern manchmal — der Web-Form-Registrierungs-Callback (fs-public.intconnect.net) timeoutet sporadisch.
**Konsequenz:** API-Weg (result[0].filecode inline) umgeht den failenden Callback → richtiger Fix. file/list-Recovery ist NICHT tote Last (Dateien erscheinen ja) — aber bei 90k-Accounts MUSS man sort=created&order=desc erzwingen, sonst ist die frische Datei nicht auf Seite 1.
**Regel:** Bei "geht manchmal/manchmal nicht" + Hoster mit offizieller API: erst per read-only API-Call (account/info, file/list) gegen den ECHTEN Account verifizieren statt am Client weiterzuraten. Das beendet Spekulations-Schleifen.

22
tasks/todo.md Normal file
View File

@ -0,0 +1,22 @@
# Feature: Per-Hoster Toggle "Links in fileuploader.log schreiben"
## Goal
Pro Hoster ein-/ausschaltbar machen ob dessen erfolgreiche Upload-Links in die fileuploader.log geschrieben werden.
## Plan
- [x] `lib/config-store.js``logToFile: true` zu `HOSTER_SETTINGS_DEFAULTS` (default an).
- [x] `renderer/app.js renderSettings` — Checkbox "Links in Log schreiben" pro Hoster-Panel (`data-hs="logToFile"`, type=checkbox).
- [x] `renderer/app.js saveSettings` — collection-loop erweitert: checkbox → boolean.
- [x] `lib/log-policy.js` (neu, testbar) — `hosterLogToFileEnabled(hosterSettings, hoster)`, opt-out semantics.
- [x] `main.js``shouldLogHosterToFile(hoster)` liest live uploadManager.hosterSettings, fallback configStore, dann default true. Guard vor appendUploadLog im done-handler.
- [x] Tests: 8 log-policy + 2 config-store (default true, persist false). 147/147 grün.
- [x] ESLint clean. Backup-Import robust (default-true bei fehlendem key).
## Verifikation
- logToFile default true → bestehendes Verhalten unverändert für alle die's nicht togglen.
- Toggle off für Hoster X → uploads von X werden NICHT geloggt, andere Hoster weiter schon.
- Live-Wirkung: `uploadManager.hosterSettings` wird via updateSettings aktualisiert → greift auch mid-batch nach save.
## Seiteneffekte zu prüfen
- Backup-Import/Export: hosterSettings inkl. logToFile mitnehmen (sollte automatisch da generisches Objekt).
- Settings-autosave (checkbox change-event ist bereits gehandhabt in der bind-loop).

View File

@ -0,0 +1,44 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { selectUploadAuth } = require('../lib/account-auth');
test('doodstream prefers the API key even when username/password are also set', () => {
const auth = selectUploadAuth('doodstream.com', {
apiKey: 'KEY123', username: 'u', password: 'p'
});
assert.deepEqual(auth, { apiKey: 'KEY123' }); // API path — no username leaks through
});
test('doodstream with only username/password uses web login (keyless fallback)', () => {
const auth = selectUploadAuth('doodstream.com', { username: 'u', password: 'p' });
assert.deepEqual(auth, { username: 'u', password: 'p' });
});
test('doodstream with empty apiKey + creds falls back to web login (no false API route)', () => {
const auth = selectUploadAuth('doodstream.com', { apiKey: '', username: 'u', password: 'p' });
assert.deepEqual(auth, { username: 'u', password: 'p' });
});
test('doodstream with nothing usable returns empty', () => {
assert.deepEqual(selectUploadAuth('doodstream.com', { apiKey: '', username: '', password: '' }), {});
});
test('voe.sx is unaffected by the doodstream special-case: username/password wins', () => {
// voe also supports both, but the empty-form bug is doodstream-specific; do
// not change voe routing.
const auth = selectUploadAuth('voe.sx', { apiKey: 'VKEY', username: 'u', password: 'p' });
assert.deepEqual(auth, { username: 'u', password: 'p' });
});
test('authType=api forces the API key for any hoster', () => {
assert.deepEqual(selectUploadAuth('voe.sx', { authType: 'api', apiKey: 'K', username: 'u', password: 'p' }), { apiKey: 'K' });
});
test('api-key-only account (no creds) uses the key', () => {
assert.deepEqual(selectUploadAuth('byse.sx', { apiKey: 'BKEY' }), { apiKey: 'BKEY' });
});
test('null / non-object account does not throw', () => {
assert.deepEqual(selectUploadAuth('doodstream.com', null), {});
assert.deepEqual(selectUploadAuth('doodstream.com', undefined), {});
});

View File

@ -0,0 +1,73 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { encrypt, decrypt } = require('../lib/backup-crypto');
describe('backup-crypto', () => {
const sampleConfig = {
hosters: { 'doodstream.com': { enabled: true, apiKey: 'test-key-123' } },
hosterSettings: { 'doodstream.com': { retries: 3 } },
globalSettings: { alwaysOnTop: false },
history: [{ file: 'test.mkv', link: 'https://example.com/abc' }]
};
it('encrypt then decrypt round-trips', () => {
const buf = encrypt(sampleConfig);
const result = decrypt(buf);
assert.deepStrictEqual(result, sampleConfig);
});
it('decrypt with corrupted data throws', () => {
const buf = encrypt(sampleConfig);
buf[buf.length - 1] ^= 0xff; // flip last byte
// With no password: app-key fails → needsPassword surfaces.
assert.throws(() => decrypt(buf), (err) => err.needsPassword === true);
// With a password: both app-key and password fail → Falsches Passwort.
assert.throws(() => decrypt(buf, 'anything'), /Falsches Passwort/);
});
it('decrypt with invalid magic throws', () => {
// Buffer must be long enough to pass the length check (>= 4+16+12+16+1 = 49)
const buf = Buffer.alloc(60, 0x41); // 60 bytes of 'A'
assert.throws(() => decrypt(buf), /Keine gültige/);
});
it('decrypt with too-short buffer throws', () => {
assert.throws(() => decrypt(Buffer.alloc(10)), /Ungültiges Backup-Format/);
});
it('handles empty config gracefully', () => {
const empty = { hosters: {}, hosterSettings: {}, globalSettings: {}, history: [] };
const buf = encrypt(empty);
assert.deepStrictEqual(decrypt(buf), empty);
});
it('decrypts legacy password-encrypted buffer when password is provided', () => {
// Reproduce the old format: same envelope, but key derived from user password.
const crypto = require('crypto');
const plaintext = Buffer.from(JSON.stringify(sampleConfig), 'utf-8');
const salt = crypto.randomBytes(16);
const iv = crypto.randomBytes(12);
const key = crypto.pbkdf2Sync('oldUserPw', salt, 100_000, 32, 'sha512');
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const enc = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
const legacyBuf = Buffer.concat([Buffer.from('MHU1'), salt, iv, tag, enc]);
// Without password → should throw needsPassword
assert.throws(() => decrypt(legacyBuf), (err) => err.needsPassword === true);
// With correct password → should decrypt
assert.deepStrictEqual(decrypt(legacyBuf, 'oldUserPw'), sampleConfig);
// With wrong password → should throw (not needsPassword)
assert.throws(() => decrypt(legacyBuf, 'wrongPw'), /Falsches Passwort/);
});
it('each encryption produces different output (random salt/iv)', () => {
const a = encrypt(sampleConfig);
const b = encrypt(sampleConfig);
assert.ok(!a.equals(b), 'two encryptions should differ');
// but both decrypt to same result
assert.deepStrictEqual(decrypt(a), decrypt(b));
});
});

144
tests/coalesced-set.test.js Normal file
View File

@ -0,0 +1,144 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { makeCoalescedSet } = require('../lib/coalesced-set');
// Synchronous scheduler stand-in: collects callbacks instead of running
// them, so tests can drive the timing explicitly.
function makeManualScheduler() {
const queue = [];
const fn = (cb) => queue.push(cb);
fn.flush = () => {
while (queue.length) {
const cb = queue.shift();
cb();
}
};
fn.queueLength = () => queue.length;
return fn;
}
test('throws if apply callback missing', () => {
assert.throws(() => makeCoalescedSet());
assert.throws(() => makeCoalescedSet({}));
assert.throws(() => makeCoalescedSet({ apply: 'not-a-fn' }));
});
test('multiple adds in one tick coalesce into one apply call', () => {
const sched = makeManualScheduler();
const calls = [];
const cs = makeCoalescedSet({
apply: (drop) => calls.push([...drop].sort()),
scheduler: sched
});
cs.add('a'); cs.add('b'); cs.add('c');
assert.equal(sched.queueLength(), 1, 'only one microtask scheduled');
assert.equal(cs.pendingSize(), 3);
sched.flush();
assert.deepEqual(calls, [['a', 'b', 'c']]);
assert.equal(cs.pendingSize(), 0);
});
test('duplicate adds are deduplicated', () => {
const sched = makeManualScheduler();
const calls = [];
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]), scheduler: sched });
cs.add('a'); cs.add('a'); cs.add('a');
sched.flush();
assert.deepEqual(calls, [['a']]);
});
test('two batches in series stay independent', () => {
const sched = makeManualScheduler();
const calls = [];
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]), scheduler: sched });
cs.add('x'); cs.add('y');
sched.flush();
cs.add('z');
sched.flush();
assert.deepEqual(calls, [['x', 'y'], ['z']]);
});
test('add after flush re-schedules a new microtask', () => {
const sched = makeManualScheduler();
const cs = makeCoalescedSet({ apply: () => {}, scheduler: sched });
cs.add('a');
assert.equal(sched.queueLength(), 1);
sched.flush();
assert.equal(sched.queueLength(), 0);
assert.equal(cs.isScheduled(), false);
cs.add('b');
assert.equal(sched.queueLength(), 1, 'new add → new microtask');
});
test('drainSync flushes synchronously without waiting for scheduler', () => {
const sched = makeManualScheduler();
const calls = [];
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]), scheduler: sched });
cs.add('p'); cs.add('q');
cs.drainSync();
assert.deepEqual(calls, [['p', 'q']]);
assert.equal(cs.pendingSize(), 0);
// Pending microtask was for the same ids — when it runs, pending is empty
// → apply NOT called twice.
sched.flush();
assert.equal(calls.length, 1, 'queued microtask is a no-op after drainSync');
});
test('drainSync on empty set is a no-op', () => {
let called = 0;
const cs = makeCoalescedSet({ apply: () => called++ });
cs.drainSync();
assert.equal(called, 0);
});
test('throwing apply does not lock out subsequent batches', () => {
const sched = makeManualScheduler();
let attempt = 0;
const cs = makeCoalescedSet({
apply: () => { attempt++; if (attempt === 1) throw new Error('boom'); },
scheduler: sched
});
cs.add('a');
// First flush throws inside apply but is swallowed; coalescer must still work.
sched.flush();
cs.add('b');
sched.flush();
assert.equal(attempt, 2, 'second batch still ran despite first throwing');
});
test('default scheduler is queueMicrotask (or Promise fallback) — runs eventually', async () => {
const calls = [];
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]) });
cs.add('z');
// Wait one microtask
await Promise.resolve();
assert.deepEqual(calls, [['z']]);
});
test('no-op tick: scheduler fires while pending is empty (e.g. drained)', () => {
const sched = makeManualScheduler();
let called = 0;
const cs = makeCoalescedSet({ apply: () => called++, scheduler: sched });
cs.add('a');
cs.drainSync();
assert.equal(called, 1);
// Pending microtask still in queue → flush; pending is empty → apply NOT called again.
sched.flush();
assert.equal(called, 1);
});
test('large burst of 5000 adds coalesces to one apply call', () => {
const sched = makeManualScheduler();
const calls = [];
const cs = makeCoalescedSet({ apply: (d) => calls.push(d.size), scheduler: sched });
for (let i = 0; i < 5000; i++) cs.add('id-' + i);
assert.equal(sched.queueLength(), 1);
sched.flush();
assert.deepEqual(calls, [5000]);
});

View File

@ -51,22 +51,46 @@ describe('ConfigStore', () => {
}); });
it('save then load round-trips', async () => { it('save then load round-trips', async () => {
await store.save({ hosters: { 'doodstream.com': { enabled: true, apiKey: 'test-key-123' } } }); await store.save({ hosters: { 'doodstream.com': [{ id: 'test-1', enabled: true, authType: 'api', apiKey: 'test-key-123' }] } });
const config = store.load(); const config = store.load();
assert.equal(config.hosters['doodstream.com'].apiKey, 'test-key-123'); assert.equal(config.hosters['doodstream.com'][0].apiKey, 'test-key-123');
});
it('default logMode is "single"', () => {
const config = store.load();
assert.equal(config.globalSettings.logMode, 'single');
});
it('regression: legacy sessionLog:true on disk normalizes to logMode "daily" (NOT "session")', async () => {
// Write a config with the legacy boolean only (what an existing user has).
await store.save({ globalSettings: { sessionLog: true } });
const config = store.load();
// The misnamed legacy field MUST map to daily — mapping to "session" would
// silently change every per-day user's behaviour on upgrade.
assert.equal(config.globalSettings.logMode, 'daily');
});
it('logMode round-trips for all three values', async () => {
for (const mode of ['single', 'daily', 'session']) {
await store.save({ globalSettings: { logMode: mode } });
const config = store.load();
assert.equal(config.globalSettings.logMode, mode, `mode ${mode}`);
}
}); });
it('load merges with defaults for missing hosters', () => { it('load merges with defaults for missing hosters', () => {
// Write partial config // Write partial config in old single-object format (triggers migration)
fs.writeFileSync(store.filePath, JSON.stringify({ fs.writeFileSync(store.filePath, JSON.stringify({
hosters: { 'doodstream.com': { apiKey: 'abc' } } hosters: { 'doodstream.com': { apiKey: 'abc' } }
}), 'utf-8'); }), 'utf-8');
const config = store.load(); const config = store.load();
assert.equal(config.hosters['doodstream.com'].apiKey, 'abc'); // Old format is migrated to array
// Other hosters should still have defaults assert.ok(Array.isArray(config.hosters['doodstream.com']));
assert.equal(config.hosters['voe.sx'].enabled, true); assert.equal(config.hosters['doodstream.com'][0].apiKey, 'abc');
assert.equal(config.hosters['voe.sx'].apiKey, ''); // Other hosters should still have defaults (empty arrays)
assert.ok(Array.isArray(config.hosters['voe.sx']));
assert.equal(config.hosters['voe.sx'].length, 0);
}); });
it('hosterSettings merge fills gaps with defaults', () => { it('hosterSettings merge fills gaps with defaults', () => {
@ -78,27 +102,42 @@ describe('ConfigStore', () => {
assert.equal(config.hosterSettings['voe.sx'].retries, 5); assert.equal(config.hosterSettings['voe.sx'].retries, 5);
assert.equal(config.hosterSettings['voe.sx'].parallelCount, 2); // default assert.equal(config.hosterSettings['voe.sx'].parallelCount, 2); // default
assert.equal(config.hosterSettings['voe.sx'].maxSpeedKbs, 0); // default assert.equal(config.hosterSettings['voe.sx'].maxSpeedKbs, 0); // default
assert.equal(config.hosterSettings['voe.sx'].logToFile, true); // default on
});
it('logToFile defaults to true for every hoster', () => {
const config = store.load();
for (const name of ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc']) {
assert.equal(config.hosterSettings[name].logToFile, true, `${name} should default logToFile=true`);
}
});
it('logToFile=false persists and survives reload', async () => {
await store.save({ hosterSettings: { 'voe.sx': { logToFile: false } } });
const config = store.load();
assert.equal(config.hosterSettings['voe.sx'].logToFile, false, 'explicit false preserved');
assert.equal(config.hosterSettings['byse.sx'].logToFile, true, 'other hoster still defaults on');
}); });
it('save only updates provided sections', async () => { it('save only updates provided sections', async () => {
// Save hoster settings first // Save hoster settings first
await store.save({ hosterSettings: { 'doodstream.com': { retries: 10, maxSpeedKbs: 0, parallelCount: 2, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } } }); await store.save({ hosterSettings: { 'doodstream.com': { retries: 10, maxSpeedKbs: 0, parallelCount: 2, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } } });
// Save hosters credentials separately // Save hosters credentials separately (array format)
await store.save({ hosters: { 'doodstream.com': { enabled: true, apiKey: 'key123' } } }); await store.save({ hosters: { 'doodstream.com': [{ id: 'test-1', enabled: true, authType: 'api', apiKey: 'key123' }] } });
const config = store.load(); const config = store.load();
assert.equal(config.hosters['doodstream.com'].apiKey, 'key123'); assert.equal(config.hosters['doodstream.com'][0].apiKey, 'key123');
assert.equal(config.hosterSettings['doodstream.com'].retries, 10); // preserved assert.equal(config.hosterSettings['doodstream.com'].retries, 10); // preserved
}); });
it('appendHistory adds entries and caps at 100', async () => { it('appendHistory keeps complete history without truncation', async () => {
for (let i = 0; i < 105; i++) { for (let i = 0; i < 105; i++) {
await store.appendHistory({ id: `batch-${i}`, timestamp: new Date().toISOString(), files: [] }); await store.appendHistory({ id: `batch-${i}`, timestamp: new Date().toISOString(), files: [] });
} }
const history = store.loadHistory(); const history = store.loadHistory();
assert.equal(history.length, 100); assert.equal(history.length, 105);
assert.equal(history[0].id, 'batch-5'); // first 5 dropped assert.equal(history[0].id, 'batch-0');
assert.equal(history[99].id, 'batch-104'); assert.equal(history[104].id, 'batch-104');
}); });
it('clearHistory empties the array', async () => { it('clearHistory empties the array', async () => {
@ -129,4 +168,27 @@ describe('ConfigStore', () => {
assert.equal(config.globalSettings.scaleParallelUploads, false); assert.equal(config.globalSettings.scaleParallelUploads, false);
assert.equal(config.globalSettings.logFilePath, ''); assert.equal(config.globalSettings.logFilePath, '');
}); });
it('concurrent saves preserve both sections', async () => {
const save1 = store.save({ hosters: { 'doodstream.com': [{ id: 'c1', enabled: true, authType: 'api', apiKey: 'concurrent-key' }] } });
const save2 = store.save({ globalSettings: { alwaysOnTop: true } });
await Promise.all([save1, save2]);
const config = store.load();
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'concurrent-key');
assert.equal(config.globalSettings.alwaysOnTop, true);
});
it('backup recovery when main file is corrupted', () => {
// Write valid config first
fs.writeFileSync(store.filePath, JSON.stringify({
hosters: { 'doodstream.com': [{ id: 'bak-1', authType: 'api', apiKey: 'from-backup' }] },
hosterSettings: {}, globalSettings: {}, history: []
}), 'utf-8');
// Copy to backup
fs.copyFileSync(store.filePath, store.filePath + '.bak');
// Corrupt main file
fs.writeFileSync(store.filePath, 'CORRUPTED!!!', 'utf-8');
const config = store.load();
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'from-backup');
});
}); });

View File

@ -0,0 +1,105 @@
const { test, before, after } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
// Mock the undici transport BEFORE requiring hosters so the destructured
// `request` picks up our stub. apiGet (getUploadServer) uses global fetch, which
// we override per-test. This exercises the FULL doodstream API upload + recovery
// orchestration against the doc-verified response shapes — the gap between the
// already-tested parseDoodstreamResult helper and the real uploadFile path.
// (mock.module needs an experimental flag npm test doesn't pass, so we reassign
// undici.request on the module object and refresh the hosters cache instead.)
let requestRouter = async () => ({ statusCode: 200, headers: {}, body: { text: async () => '{}' } });
const undici = require('undici');
const _origUndiciRequest = undici.request;
undici.request = (...a) => requestRouter(...a);
delete require.cache[require.resolve('../lib/hosters')];
const hostersMod = require('../lib/hosters');
const { uploadFile } = hostersMod;
let tmpFile;
let origFetch;
before(() => {
tmpFile = path.join(os.tmpdir(), `dood-itest-${process.pid}.mkv`);
fs.writeFileSync(tmpFile, Buffer.alloc(2048, 7));
origFetch = global.fetch;
// Keep the "never appears" recovery test fast (real default is 12 × 2.5 s).
hostersMod.__test.DOODSTREAM_POLL.attempts = 3;
hostersMod.__test.DOODSTREAM_POLL.delayMs = 5;
});
after(() => {
global.fetch = origFetch;
undici.request = _origUndiciRequest; // restore real transport for other test files
delete require.cache[require.resolve('../lib/hosters')];
try { fs.unlinkSync(tmpFile); } catch {}
});
// getUploadServer hits /api/upload/server via global fetch.
function stubUploadServer() {
global.fetch = async (url) => {
if (/upload\/server/.test(String(url))) {
return { status: 200, text: async () => JSON.stringify({ status: 200, result: 'https://node1.cloudatacdn.com/upload/01' }) };
}
return { status: 200, text: async () => '{"status":200}' };
};
}
// Build an undici-style router. uploadBody is the POST result; listBodies is a
// queue consumed by successive /api/file/list calls (baseline, then polls).
function routeWith(uploadBody, listBodies = []) {
return async (url, opts) => {
const u = String(url);
if (/\/api\/file\/list/.test(u)) {
const body = listBodies.length ? listBodies.shift() : '{"status":200,"result":{"files":[]}}';
return { statusCode: 200, headers: {}, body: { text: async () => body } };
}
// Upload POST: drain the streamed body so the file handle closes.
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
}
return { statusCode: uploadBody.status, headers: { 'content-type': 'application/json' }, body: { text: async () => uploadBody.body } };
};
}
test('doodstream API upload: filecode returned directly is used', async () => {
stubUploadServer();
requestRouter = routeWith({
status: 200,
body: JSON.stringify({ status: 200, result: [{ filecode: 'DOODCODE1234', download_url: 'https://doodstream.com/d/DOODCODE1234', protected_embed: 'https://doodstream.com/e/DOODCODE1234' }] })
});
const res = await uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null);
assert.equal(res.file_code, 'DOODCODE1234');
assert.equal(res.download_url, 'https://doodstream.com/d/DOODCODE1234');
});
test('doodstream API upload: codeless result recovered via file-list name match', async () => {
stubUploadServer();
const fileName = path.basename(tmpFile).replace(/\.[^.]+$/, ''); // title doodstream stores
requestRouter = routeWith(
{ status: 200, body: JSON.stringify({ status: 200, msg: 'OK' }) }, // codeless upload
[
'{"status":200,"result":{"files":[]}}', // baseline (pre-upload)
`{"status":200,"result":{"files":[{"file_code":"RECOVER9999","title":"${fileName}"}]}}` // poll finds it
]
);
const res = await uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null);
assert.equal(res.file_code, 'RECOVER9999');
assert.equal(res.download_url, 'https://doodstream.com/d/RECOVER9999');
});
test('doodstream API upload: codeless + file never appears → throws hosterTransient (no account poison)', async () => {
stubUploadServer();
requestRouter = routeWith(
{ status: 200, body: JSON.stringify({ status: 200, msg: 'OK' }) },
[] // every file/list returns empty
);
await assert.rejects(
() => uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null),
(err) => {
assert.equal(err.hosterTransient, true, 'codeless result must be tagged hosterTransient');
return true;
}
);
});

View File

@ -0,0 +1,214 @@
const { test } = require('node:test');
const assert = require('node:assert');
const DoodstreamUploader = require('../lib/doodstream-upload');
// The CDN hands back an XFileSharing form. `fn` is the filecode, `st` is the
// status ("OK" on success, an error string when the backend refuses the file).
// These tests pin the parse/error behaviour of _parseUploadResponse without
// touching the network — _fetch is stubbed to return the upload_result page.
function cdnForm({ fn = '', st = 'OK' } = {}) {
return `<HTML><BODY><Form name='F1' action='https://cdn.example/' method='POST'>` +
`<textarea name="op">upload_result</textarea>` +
`<textarea name="fn">${fn}</textarea>` +
`<textarea name="st">${st}</textarea>` +
`</Form></BODY></HTML>`;
}
const EMPTY_RESULT = '<textarea id="copy_dl" readonly class="form-control" rows="5"></textarea>';
const LINK_RESULT = (code) => `<textarea id="copy_dl" readonly class="form-control" rows="5">https://myvidplay.com/d/${code}</textarea>`;
function uploaderWithResult(resultHtml) {
const up = new DoodstreamUploader();
up._lastUploadUrl = 'https://cdn.example/upload/01';
// Stub the second-step submit so no real request goes out.
up._fetch = async () => ({ text: async () => resultHtml });
return up;
}
test('rejected file: empty fn + non-OK st surfaces the real status', async () => {
const up = uploaderWithResult(EMPTY_RESULT);
await assert.rejects(
() => up._parseUploadResponse(cdnForm({ fn: '', st: 'Error: file already exists' })),
(err) => {
assert.match(err.message, /lehnt Datei ab/);
assert.match(err.message, /file already exists/);
return true;
}
);
});
test('empty fn + st OK: generic error still reports st, fn-state and CDN node', async () => {
const up = uploaderWithResult(EMPTY_RESULT);
await assert.rejects(
() => up._parseUploadResponse(cdnForm({ fn: '', st: 'OK' })),
(err) => {
assert.match(err.message, /kein Filecode/);
assert.match(err.message, /st=OK/);
assert.match(err.message, /fehlt\/leer/);
assert.match(err.message, /cdn\.example/);
return true;
}
);
});
test('valid fn but empty result page: still resolves via fn (no regression)', async () => {
const up = uploaderWithResult(EMPTY_RESULT);
const res = await up._parseUploadResponse(cdnForm({ fn: '7mnp8xna3123', st: 'OK' }));
assert.equal(res.file_code, '7mnp8xna3123');
assert.equal(res.download_url, 'https://doodstream.com/d/7mnp8xna3123');
});
test('happy path: link in result page wins', async () => {
const up = uploaderWithResult(LINK_RESULT('jjsuhr931ds9'));
const res = await up._parseUploadResponse(cdnForm({ fn: 'jjsuhr931ds9', st: 'OK' }));
assert.equal(res.file_code, 'jjsuhr931ds9');
});
// --- _parseUploadFormFields: replicate the current upload form faithfully ---
test('_parseUploadFormFields extracts the real form fields and excludes the file input', () => {
const up = new DoodstreamUploader();
const html = `
<form name="file" enctype="multipart/form-data" action="https://uxg.cloudatacdn.com/upload/01?TOK" method="post">
<input type="hidden" name="sess_id" value="TOK">
<input name="file" type="file" size="30" id="filepc">
<input name="fakefilepc" class="d-none" type="text" id="fakefilepc">
<input type="text" name="file_title" class="form-control">
<button type="submit" name="submit_btn" class="btn">Upload</button>
</form>`;
const f = up._parseUploadFormFields(html);
assert.equal(f.sess_id, 'TOK');
assert.equal(f.fakefilepc, '');
assert.equal(f.file_title, '');
assert.ok('submit_btn' in f);
assert.ok(!('file' in f), 'the file input must be excluded (streamed separately)');
});
test('_parseUploadFormFields returns {} for markup without a form', () => {
const up = new DoodstreamUploader();
assert.deepEqual(up._parseUploadFormFields('<div>no form here</div>'), {});
assert.deepEqual(up._parseUploadFormFields(''), {});
});
// --- deriveApiKey: pull + validate the account API key from the web session ---
test('_extractApiKeyCandidates finds the key in an input value and ranks api-context first', () => {
const up = new DoodstreamUploader();
const html = `
<input type="text" name="csrf" value="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa">
<div class="panel">API Key <input readonly value="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"></div>
`;
const cands = up._extractApiKeyCandidates(html);
// The token whose preceding context mentions "API" must rank first.
assert.equal(cands[0], 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
assert.ok(cands.includes('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'));
});
test('_extractApiKeyCandidates handles textarea + api_key: "x" shapes and empty input', () => {
const up = new DoodstreamUploader();
assert.deepEqual(up._extractApiKeyCandidates(''), []);
const ta = up._extractApiKeyCandidates('<textarea id="k">cccccccccccccccccccccccccccccccc</textarea>');
assert.ok(ta.includes('cccccccccccccccccccccccccccccccc'));
const js = up._extractApiKeyCandidates('var x = {"api_key":"dddddddddddddddddddddddddddddddd"};');
assert.ok(js.includes('dddddddddddddddddddddddddddddddd'));
});
test('deriveApiKey returns the candidate that validates against the API', async () => {
const up = new DoodstreamUploader();
up._fetch = async () => ({ text: async () => '<div>API Key <input value="REALKEY1234567890abcdefGHIJK"></div><input value="notthekey000000000000000000">' });
up._validateApiKey = async (key) => key === 'REALKEY1234567890abcdefGHIJK';
const key = await up.deriveApiKey();
assert.equal(key, 'REALKEY1234567890abcdefGHIJK');
assert.equal(up.apiKey, 'REALKEY1234567890abcdefGHIJK'); // cached on the instance
});
test('deriveApiKey returns null when no candidate validates (→ caller uses web fallback)', async () => {
const up = new DoodstreamUploader();
up._fetch = async () => ({ text: async () => '<input value="bogustoken0000000000000000000">' });
up._validateApiKey = async () => false;
assert.equal(await up.deriveApiKey(), null);
assert.equal(up.apiKey, '');
});
test('deriveApiKey short-circuits when a key is already set', async () => {
const up = new DoodstreamUploader();
up.apiKey = 'PRESET';
let fetched = false;
up._fetch = async () => { fetched = true; return { text: async () => '' }; };
assert.equal(await up.deriveApiKey(), 'PRESET');
assert.equal(fetched, false);
});
// --- _fetch: transient network blips on the small requests self-heal ---
test('_fetch retries a transient network failure then succeeds', async () => {
const up = new DoodstreamUploader();
const origFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls++;
if (calls === 1) throw new TypeError('fetch failed');
return { status: 200, headers: { getSetCookie: () => [], get: () => null }, text: async () => 'ok' };
};
try {
const res = await up._fetch('https://example.test/x');
assert.equal(calls, 2); // failed once, retried, succeeded
assert.equal(await res.text(), 'ok');
} finally {
globalThis.fetch = origFetch;
}
});
// --- _getUploadServer: discovery must never fall back to a hardcoded node ---
function fakeRes(body, { status = 200, ctype = 'text/html' } = {}) {
return { status, headers: { get: (h) => (h.toLowerCase() === 'content-type' ? ctype : null) }, text: async () => body };
}
test('getUploadServer: returns JSON result when present', async () => {
const up = new DoodstreamUploader();
up._fetch = async (url) => {
assert.match(url, /op=upload_server/);
return fakeRes(JSON.stringify({ result: 'https://node42.cloudatacdn.com/upload/01' }), { ctype: 'application/json' });
};
assert.equal(await up._getUploadServer(), 'https://node42.cloudatacdn.com/upload/01');
});
test('getUploadServer: falls back to srv_url in upload-page HTML', async () => {
const up = new DoodstreamUploader();
up._fetch = async (url) => {
if (/op=upload_server/.test(url)) return fakeRes('<html>not json</html>');
return fakeRes('<script>var srv_url: "https://node7.cloudatacdn.com/upload/01";</script>');
};
assert.equal(await up._getUploadServer(), 'https://node7.cloudatacdn.com/upload/01');
});
test('getUploadServer: parses current form-action node and refreshes sess_id from the same page', async () => {
const up = new DoodstreamUploader();
up.sessId = 'stale-from-login';
up._fetch = async (url) => {
if (/op=upload_server/.test(url)) return fakeRes('<html>not json</html>');
return fakeRes('<form name="file" enctype="multipart/form-data" action="https://n9.cloudatacdn.com/upload/01?FRESH123" method="post"><input type="hidden" name="sess_id" value="FRESH123"></form>');
};
const url = await up._getUploadServer();
assert.equal(url, 'https://n9.cloudatacdn.com/upload/01?FRESH123');
assert.equal(up.sessId, 'FRESH123'); // critical: form-field token must match the node URL token
});
test('getUploadServer: un-escapes &amp; in the form-action query string', async () => {
const up = new DoodstreamUploader();
up._fetch = async (url) => {
if (/op=upload_server/.test(url)) return fakeRes('<html>not json</html>');
return fakeRes('<form name="file" enctype="multipart/form-data" action="https://n9.cloudatacdn.com/upload/01?a=1&amp;b=2" method="post"></form>');
};
assert.equal(await up._getUploadServer(), 'https://n9.cloudatacdn.com/upload/01?a=1&b=2');
});
test('getUploadServer: throws (no silent dead fallback) when discovery fails', async () => {
const up = new DoodstreamUploader();
up._fetch = async () => fakeRes('<html><body>login required</body></html>', { status: 200 });
await assert.rejects(
() => up._getUploadServer(),
(err) => {
assert.match(err.message, /konnte Upload-Server nicht ermitteln/);
assert.doesNotMatch(err.message, /tr1128ve\.cloudatacdn\.com/); // never the hardcoded node
return true;
}
);
});

100
tests/file-probe.test.js Normal file
View File

@ -0,0 +1,100 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { detectKind, isVideoLikeKind, probeFileHead, summarizeFileStat } = require('../lib/file-probe');
function tmpWrite(name, buf) {
const p = path.join(os.tmpdir(), `mhu-probe-${Date.now()}-${name}`);
fs.writeFileSync(p, buf);
return p;
}
test('detectKind recognizes ISO-MP4 (ftyp box at offset 4)', () => {
const buf = Buffer.concat([Buffer.from([0x00, 0x00, 0x00, 0x20]), Buffer.from('ftypisom', 'ascii'), Buffer.alloc(8, 0)]);
assert.strictEqual(detectKind(buf), 'mp4-iso');
assert.strictEqual(isVideoLikeKind('mp4-iso'), true);
});
test('detectKind recognizes Matroska / WebM EBML header', () => {
const buf = Buffer.from([0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00]);
assert.strictEqual(detectKind(buf), 'matroska');
assert.strictEqual(isVideoLikeKind('matroska'), true);
});
test('detectKind recognizes AVI (RIFF...AVI )', () => {
const buf = Buffer.concat([Buffer.from('RIFF', 'ascii'), Buffer.from([0x00, 0x00, 0x00, 0x00]), Buffer.from('AVI ', 'ascii')]);
assert.strictEqual(detectKind(buf), 'avi');
});
test('detectKind recognizes FLV', () => {
const buf = Buffer.concat([Buffer.from('FLV', 'ascii'), Buffer.from([0x01])]);
assert.strictEqual(detectKind(buf), 'flv');
});
test('detectKind recognizes ASF (WMV)', () => {
const buf = Buffer.from([0x30, 0x26, 0xB2, 0x75, 0x00, 0x00]);
assert.strictEqual(detectKind(buf), 'asf-wmv');
});
test('detectKind recognizes MPEG-PS (00 00 01 BA)', () => {
const buf = Buffer.from([0x00, 0x00, 0x01, 0xBA, 0x00]);
assert.strictEqual(detectKind(buf), 'mpeg-ps');
});
test('detectKind recognizes JPEG (non-video)', () => {
const buf = Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]);
assert.strictEqual(detectKind(buf), 'jpeg');
assert.strictEqual(isVideoLikeKind('jpeg'), false);
});
test('detectKind recognizes HTML response (non-video)', () => {
const buf = Buffer.from('<!DOCTYPE html><html><head>', 'ascii');
assert.strictEqual(detectKind(buf), 'html');
assert.strictEqual(isVideoLikeKind('html'), false);
});
test('detectKind returns empty for zero-length and unknown for noise', () => {
assert.strictEqual(detectKind(Buffer.alloc(0)), 'empty');
assert.strictEqual(detectKind(Buffer.from([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF])), 'unknown');
});
test('probeFileHead reads first bytes and returns hex + kind for an MP4-like file', async () => {
const mp4Head = Buffer.concat([Buffer.from([0x00, 0x00, 0x00, 0x20]), Buffer.from('ftypisom', 'ascii'), Buffer.alloc(16, 0xAA)]);
const p = tmpWrite('fake.mp4', mp4Head);
try {
const res = await probeFileHead(p, 64);
assert.strictEqual(res.ok, true);
assert.strictEqual(res.kind, 'mp4-iso');
assert.strictEqual(res.isVideoLike, true);
assert.ok(res.headHex.startsWith('0000002066747970'));
assert.strictEqual(res.bytesRead, mp4Head.length);
} finally {
fs.unlinkSync(p);
}
});
test('probeFileHead returns ok:false with kind=unreadable for missing file', async () => {
const res = await probeFileHead(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.mp4`), 32);
assert.strictEqual(res.ok, false);
assert.strictEqual(res.kind, 'unreadable');
assert.ok(res.error);
});
test('summarizeFileStat returns size + mtime for a real file', () => {
const p = tmpWrite('stat.bin', Buffer.alloc(123, 0xCC));
try {
const stat = summarizeFileStat(p);
assert.strictEqual(stat.size, 123);
assert.strictEqual(stat.isFile, true);
assert.ok(stat.mtime);
} finally {
fs.unlinkSync(p);
}
});
test('summarizeFileStat returns error for missing file', () => {
const stat = summarizeFileStat(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.bin`));
assert.ok(stat.error);
});

View File

@ -31,4 +31,65 @@ describe('hosters helpers', () => {
assert.equal(url, 'https://delivery-hydra.voe-network.net/upload/01'); assert.equal(url, 'https://delivery-hydra.voe-network.net/upload/01');
}); });
it('parseDoodstreamResult tolerates null/non-object payload without throwing', () => {
// Direct callers may bypass uploadFile's normalisation. The parser must
// never throw on bad input — empty fields are the contract.
for (const bad of [null, undefined, 'string', 42, true]) {
const r = __test.parseDoodstreamResult(bad);
assert.equal(r.file_code, null);
assert.equal(r.download_url, null);
assert.equal(r.embed_url, null);
}
});
it('parseDoodstreamResult handles result-as-array and result-as-object', () => {
const arr = __test.parseDoodstreamResult({ result: [{ filecode: 'AB1', protected_dl: 'https://x/1', protected_embed: 'https://x/e/1' }] });
assert.equal(arr.file_code, 'AB1');
assert.equal(arr.download_url, 'https://x/1');
assert.equal(arr.embed_url, 'https://x/e/1');
const obj = __test.parseDoodstreamResult({ result: { filecode: 'OBJ1', download_url: 'https://x/2' } });
assert.equal(obj.file_code, 'OBJ1');
assert.equal(obj.download_url, 'https://x/2');
});
it('parseByseResult tolerates null/non-object payload without throwing', () => {
for (const bad of [null, undefined, 'string', 42, []]) {
const r = __test.parseByseResult(bad);
assert.equal(r.file_code, null);
assert.equal(r.download_url, null);
assert.equal(r.embed_url, null);
}
});
it('parseByseResult handles malformed files entries (null, missing fields)', () => {
// Files array with a null first element (server returned [null])
const a = __test.parseByseResult({ files: [null] });
assert.equal(a.file_code, null);
// Files array with object missing both filecode and status
const b = __test.parseByseResult({ files: [{}] });
assert.equal(b.file_code, null);
});
it('parseByseResult throws fileRejected for non-OK status with empty filecode', () => {
assert.throws(
() => __test.parseByseResult({ files: [{ status: 'Not video file format' }] }),
(err) => err.fileRejected === true && /Not video file format/i.test(err.message)
);
});
it('parseByseResult flips to accountError for storage-exhausted phrasing', () => {
assert.throws(
() => __test.parseByseResult({ files: [{ status: 'not enough disk space on your account' }] }),
(err) => err.accountError === true
);
});
it('parseByseResult succeeds with valid filecode in files[0]', () => {
const r = __test.parseByseResult({ files: [{ filecode: 'GOOD123', status: 'OK' }] });
assert.equal(r.file_code, 'GOOD123');
assert.equal(r.download_url, 'https://byse.sx/d/GOOD123');
assert.equal(r.embed_url, 'https://byse.sx/e/GOOD123');
});
}); });

140
tests/log-mode.test.js Normal file
View File

@ -0,0 +1,140 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp } = require('../lib/log-mode');
// --- normalizeLogMode ---
test('normalizeLogMode: default for empty/null/undefined is "single"', () => {
assert.equal(normalizeLogMode(), 'single');
assert.equal(normalizeLogMode(null), 'single');
assert.equal(normalizeLogMode({}), 'single');
});
test('normalizeLogMode: explicit logMode wins for all three valid values', () => {
assert.equal(normalizeLogMode({ logMode: 'single' }), 'single');
assert.equal(normalizeLogMode({ logMode: 'daily' }), 'daily');
assert.equal(normalizeLogMode({ logMode: 'session' }), 'session');
});
test('regression: legacy sessionLog:true maps to "daily", NOT "session"', () => {
// The legacy boolean field was named after a misnomer — it actually toggled
// per-day logging. Mapping it to "session" would silently flip every existing
// per-day user onto per-session, which is exactly the bug the migration trap
// exists to prevent.
assert.equal(normalizeLogMode({ sessionLog: true }), 'daily');
});
test('normalizeLogMode: sessionLog:false / missing maps to "single"', () => {
assert.equal(normalizeLogMode({ sessionLog: false }), 'single');
});
test('normalizeLogMode: explicit logMode beats the legacy sessionLog field', () => {
// Once a user picks a mode in 3.3.35+, the legacy boolean must NOT override.
assert.equal(normalizeLogMode({ logMode: 'session', sessionLog: true }), 'session');
assert.equal(normalizeLogMode({ logMode: 'single', sessionLog: true }), 'single');
});
test('normalizeLogMode: invalid logMode strings fall through to single (or legacy if present)', () => {
assert.equal(normalizeLogMode({ logMode: 'lolnope' }), 'single');
assert.equal(normalizeLogMode({ logMode: '' }), 'single');
assert.equal(normalizeLogMode({ logMode: 'lolnope', sessionLog: true }), 'daily');
});
// --- resolveLogFileName ---
test('resolveLogFileName: single mode → bare basename + ext', () => {
assert.equal(
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'single' }),
'fileuploader.log'
);
});
test('resolveLogFileName: daily mode → fileuploader-YYYY-MM-DD.log', () => {
const d = new Date(2026, 4, 28); // May 28, 2026 — month is 0-indexed
assert.equal(
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'daily', date: d }),
'fileuploader-2026-05-28.log'
);
});
test('resolveLogFileName: session mode → fileuploader-session-<id>.log', () => {
assert.equal(
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session', sessionId: '2026-05-28_22-44-52-12345' }),
'fileuploader-session-2026-05-28_22-44-52-12345.log'
);
});
test('resolveLogFileName: session mode with missing sessionId falls back to single (never emits malformed name)', () => {
assert.equal(
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session' }),
'fileuploader.log'
);
});
test('resolveLogFileName: unknown mode is treated as single', () => {
assert.equal(
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'lolnope' }),
'fileuploader.log'
);
});
// --- stripModeStampFromFileName ---
const { stripModeStampFromFileName } = require('../lib/log-mode');
test('stripModeStampFromFileName: leaves bare names alone', () => {
assert.equal(stripModeStampFromFileName('fileuploader.log'), 'fileuploader.log');
assert.equal(stripModeStampFromFileName('fileuploader'), 'fileuploader');
});
test('stripModeStampFromFileName: strips a daily YYYY-MM-DD suffix', () => {
assert.equal(stripModeStampFromFileName('fileuploader-2026-06-03.log'), 'fileuploader.log');
});
test('stripModeStampFromFileName: strips a session-stamp suffix (with and without pid)', () => {
assert.equal(
stripModeStampFromFileName('fileuploader-session-2026-06-03_18-16-20-8132.log'),
'fileuploader.log'
);
assert.equal(
stripModeStampFromFileName('fileuploader-session-2026-06-03_18-16-20.log'),
'fileuploader.log'
);
});
test('regression: resolveLogFileName(stripModeStampFromFileName(...)) is idempotent — persisting then re-resolving never compounds stamps', () => {
// This is the exact bug shape: persist the resolved path, then on next call
// re-resolve from the saved base — must produce the same file, not a doubled
// session-stamped one. The fix is the strip; this test guards against
// regressing _persistFallbackLogPath into the 3.3.35 bug.
const sessionId = '2026-06-03_18-16-20-8132';
const dailyDate = new Date(2026, 5, 3);
for (const mode of ['daily', 'session']) {
const date = mode === 'daily' ? dailyDate : new Date();
const initial = resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode, date, sessionId });
const stripped = stripModeStampFromFileName(initial);
// After strip, the base should be back to the bare name.
assert.equal(stripped, 'fileuploader.log', `${mode}: strip should produce bare base`);
// Re-resolving from the bare base gives the same final filename — no doubling.
const reBase = stripped.replace(/\.log$/, '');
const second = resolveLogFileName({ baseName: reBase, ext: '.log', mode, date, sessionId });
assert.equal(second, initial, `${mode}: round-trip must be idempotent`);
}
});
// --- format helpers ---
test('formatDateStamp: zero-pads month and day', () => {
assert.equal(formatDateStamp(new Date(2026, 0, 3)), '2026-01-03');
assert.equal(formatDateStamp(new Date(2026, 11, 31)), '2026-12-31');
});
test('formatSessionStamp: produces YYYY-MM-DD_HH-MM-SS-pid', () => {
const d = new Date(2026, 4, 28, 7, 9, 5);
assert.equal(formatSessionStamp(d, 12345), '2026-05-28_07-09-05-12345');
});
test('formatSessionStamp: omits the pid suffix when none provided', () => {
const d = new Date(2026, 4, 28, 22, 44, 52);
assert.equal(formatSessionStamp(d), '2026-05-28_22-44-52');
});

52
tests/log-policy.test.js Normal file
View File

@ -0,0 +1,52 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { hosterLogToFileEnabled } = require('../lib/log-policy');
test('enabled by default when settings missing entirely', () => {
assert.equal(hosterLogToFileEnabled(null, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled(undefined, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled('not-an-object', 'voe.sx'), true);
});
test('enabled when hoster has no settings entry', () => {
assert.equal(hosterLogToFileEnabled({}, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled({ 'byse.sx': { logToFile: false } }, 'voe.sx'), true);
});
test('enabled when hoster entry has no logToFile key (back-compat with old configs)', () => {
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { retries: 3 } }, 'voe.sx'), true);
});
test('enabled when logToFile is explicitly true', () => {
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: true } }, 'voe.sx'), true);
});
test('DISABLED only when logToFile is explicitly false', () => {
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: false } }, 'voe.sx'), false);
});
test('truthy-but-not-true values do not accidentally disable', () => {
// Only the strict boolean false disables — guards against e.g. a stored 0/""
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: 0 } }, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: '' } }, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: null } }, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: undefined } }, 'voe.sx'), true);
});
test('per-hoster independence: one off, others on', () => {
const settings = {
'voe.sx': { logToFile: false },
'byse.sx': { logToFile: true },
'doodstream.com': { retries: 3 }
};
assert.equal(hosterLogToFileEnabled(settings, 'voe.sx'), false);
assert.equal(hosterLogToFileEnabled(settings, 'byse.sx'), true);
assert.equal(hosterLogToFileEnabled(settings, 'doodstream.com'), true);
assert.equal(hosterLogToFileEnabled(settings, 'clouddrop.cc'), true); // not present → on
});
test('malformed hoster entry (string/number) defaults to on', () => {
assert.equal(hosterLogToFileEnabled({ 'voe.sx': 'broken' }, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled({ 'voe.sx': 42 }, 'voe.sx'), true);
});

134
tests/log-rotation.test.js Normal file
View File

@ -0,0 +1,134 @@
const { test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { maybeRotateLogFile } = require('../lib/log-rotation');
let tmpDir;
let logFile;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-log-rotation-'));
logFile = path.join(tmpDir, 'fileuploader.log');
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
});
function writeBytes(p, n, fill = 'a') {
fs.writeFileSync(p, fill.repeat(n), 'utf-8');
}
test('returns false and skips rotation when file does not exist', () => {
const result = maybeRotateLogFile(logFile, 100);
assert.equal(result, false);
assert.equal(fs.existsSync(logFile), false);
});
test('returns false when file is below the size cap', () => {
writeBytes(logFile, 50);
const result = maybeRotateLogFile(logFile, 100);
assert.equal(result, false);
assert.equal(fs.statSync(logFile).size, 50, 'live file untouched');
assert.equal(fs.existsSync(logFile + '.1'), false, 'no .1 created');
});
test('rotates live file to .1 when over cap', () => {
writeBytes(logFile, 200, 'X');
const result = maybeRotateLogFile(logFile, 100, 3);
assert.equal(result, true);
assert.equal(fs.existsSync(logFile), false, 'live file moved away');
const expectedBackup = path.join(tmpDir, 'fileuploader.1.log');
assert.equal(fs.existsSync(expectedBackup), true, '.1 backup exists');
assert.equal(fs.statSync(expectedBackup).size, 200);
});
test('shifts existing backups up: .1 → .2, .2 → .3 on rotation', () => {
writeBytes(path.join(tmpDir, 'fileuploader.2.log'), 10, 'B');
writeBytes(path.join(tmpDir, 'fileuploader.1.log'), 20, 'A');
writeBytes(logFile, 200, 'L');
const result = maybeRotateLogFile(logFile, 100, 3);
assert.equal(result, true);
// Live file → .1 (latest live data)
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.1.log')).size, 200);
// Old .1 → .2
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.2.log')).size, 20);
// Old .2 → .3
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.3.log')).size, 10);
});
test('drops oldest backup when at maxBackups limit', () => {
// Pre-populate all three backup slots.
writeBytes(path.join(tmpDir, 'fileuploader.3.log'), 5, 'C'); // oldest, will be dropped
writeBytes(path.join(tmpDir, 'fileuploader.2.log'), 10, 'B');
writeBytes(path.join(tmpDir, 'fileuploader.1.log'), 20, 'A');
writeBytes(logFile, 200, 'L');
const result = maybeRotateLogFile(logFile, 100, 3);
assert.equal(result, true);
// Old .3 (5 bytes 'C') gone, replaced by old .2.
const f3 = fs.statSync(path.join(tmpDir, 'fileuploader.3.log'));
assert.equal(f3.size, 10, 'old .2 became new .3 (the C-file was dropped)');
// .2 = old .1
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.2.log')).size, 20);
// .1 = the live file we just rotated
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.1.log')).size, 200);
});
test('is idempotent — second call on still-large file rotates again', () => {
writeBytes(logFile, 200, 'X');
maybeRotateLogFile(logFile, 100, 3);
// Simulate fresh writes after the first rotation
writeBytes(logFile, 200, 'Y');
const result = maybeRotateLogFile(logFile, 100, 3);
assert.equal(result, true);
// The .Y file is now .1, the .X file moved to .2
assert.equal(fs.readFileSync(path.join(tmpDir, 'fileuploader.1.log'), 'utf-8')[0], 'Y');
assert.equal(fs.readFileSync(path.join(tmpDir, 'fileuploader.2.log'), 'utf-8')[0], 'X');
});
test('maxBackups=1: only keeps a single .1 backup, never .2', () => {
writeBytes(logFile, 200, 'L');
maybeRotateLogFile(logFile, 100, 1);
writeBytes(logFile, 200, 'M');
maybeRotateLogFile(logFile, 100, 1);
// .1 holds the latest rotated content (M)
assert.equal(fs.readFileSync(path.join(tmpDir, 'fileuploader.1.log'), 'utf-8')[0], 'M');
// .2 must NOT exist
assert.equal(fs.existsSync(path.join(tmpDir, 'fileuploader.2.log')), false);
});
test('invalid maxBytes (0, negative, NaN) is a no-op', () => {
writeBytes(logFile, 1000, 'X');
for (const max of [0, -1, NaN]) {
const r = maybeRotateLogFile(logFile, max);
assert.equal(r, false, `maxBytes=${max} should be no-op`);
}
assert.equal(fs.existsSync(logFile), true);
assert.equal(fs.existsSync(logFile + '.1'), false);
});
test('logs through provided debug callback on rotation', () => {
writeBytes(logFile, 200, 'X');
const messages = [];
maybeRotateLogFile(logFile, 100, 3, (m) => messages.push(m));
assert.ok(messages.length >= 1, 'at least one log message');
assert.ok(messages.some(m => m.includes('rotated')), `expected "rotated" in: ${messages.join(' | ')}`);
});
test('handles file without extension correctly', () => {
const noExtFile = path.join(tmpDir, 'plainlog');
writeBytes(noExtFile, 200, 'P');
const result = maybeRotateLogFile(noExtFile, 100, 3);
assert.equal(result, true);
// base = the full path, ext = '', so backup name is "plainlog.1"
assert.equal(fs.existsSync(path.join(tmpDir, 'plainlog.1')), true);
assert.equal(fs.existsSync(noExtFile), false);
});

72
tests/queue-dedup.test.js Normal file
View File

@ -0,0 +1,72 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { partitionRestoredJobsByLog } = require('../lib/queue-dedup');
function job(status, fileName, hoster) {
return { status, fileName, hoster, file: `C:/dl/${fileName}` };
}
test('regression: pending preview jobs are NEVER dropped, even when all match the log', () => {
// Exact shape of the reproduced bug: 4 preview jobs for one file across 4
// hosters, every fileName|hoster present in the lifetime upload log.
const jobs = [
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'doodstream.com'),
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'voe.sx'),
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'vidmoly.me'),
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'byse.sx')
];
const log = [
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'doodstream.com' },
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'voe.sx' },
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'vidmoly.me' },
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'byse.sx' }
];
const { kept, removed } = partitionRestoredJobsByLog(jobs, log);
assert.equal(removed.length, 0, 'no pending job may be removed');
assert.equal(kept.length, 4, 'all 4 pending jobs survive restart/update');
});
test('done jobs in the log are dropped (declutter); pending/error/aborted kept', () => {
const jobs = [
job('done', 'a.mkv', 'doodstream.com'),
job('preview', 'a.mkv', 'voe.sx'),
job('error', 'b.mkv', 'doodstream.com'),
job('aborted', 'c.mkv', 'doodstream.com')
];
const log = [
{ fileName: 'a.mkv', hoster: 'doodstream.com' },
{ fileName: 'a.mkv', hoster: 'voe.sx' },
{ fileName: 'b.mkv', hoster: 'doodstream.com' },
{ fileName: 'c.mkv', hoster: 'doodstream.com' }
];
const { kept, removed } = partitionRestoredJobsByLog(jobs, log);
assert.equal(removed.length, 1);
assert.equal(removed[0].status, 'done');
assert.equal(removed[0].hoster, 'doodstream.com');
// The preview a.mkv|voe.sx, error b.mkv, aborted c.mkv all survive.
assert.equal(kept.length, 3);
assert.ok(kept.some(j => j.status === 'preview' && j.hoster === 'voe.sx'));
assert.ok(kept.some(j => j.status === 'error'));
assert.ok(kept.some(j => j.status === 'aborted'));
});
test('done job NOT in the log is kept (e.g. hoster had logToFile disabled)', () => {
const jobs = [job('done', 'd.mkv', 'doodstream.com')];
const { kept, removed } = partitionRestoredJobsByLog(jobs, []);
assert.equal(removed.length, 0);
assert.equal(kept.length, 1);
});
test('case-insensitive match on fileName and hoster', () => {
const jobs = [job('done', 'Movie.MKV', 'DoodStream.com')];
const log = [{ fileName: 'movie.mkv', hoster: 'doodstream.com' }];
const { removed } = partitionRestoredJobsByLog(jobs, log);
assert.equal(removed.length, 1);
});
test('empty/missing inputs do not throw', () => {
assert.deepEqual(partitionRestoredJobsByLog([], []), { kept: [], removed: [] });
assert.deepEqual(partitionRestoredJobsByLog(null, null), { kept: [], removed: [] });
const jobs = [job('done', 'x.mkv', 'voe.sx')];
assert.equal(partitionRestoredJobsByLog(jobs, undefined).kept.length, 1);
});

115
tests/queue-prune.test.js Normal file
View File

@ -0,0 +1,115 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { pruneOldestTerminalJobs, TERMINAL_STATUSES } = require('../lib/queue-prune');
const j = (id, status) => ({ id, status });
test('returns null on empty / non-array input', () => {
assert.equal(pruneOldestTerminalJobs([], 5), null);
assert.equal(pruneOldestTerminalJobs(null, 5), null);
assert.equal(pruneOldestTerminalJobs(undefined, 5), null);
});
test('returns null when all jobs are non-terminal regardless of limit', () => {
const jobs = [j('a', 'queued'), j('b', 'uploading'), j('c', 'preview')];
assert.equal(pruneOldestTerminalJobs(jobs, 0), null);
assert.equal(pruneOldestTerminalJobs(jobs, 100), null);
});
test('returns null when terminal count is at or under the limit', () => {
const jobs = [j('a', 'done'), j('b', 'done'), j('c', 'queued')];
assert.equal(pruneOldestTerminalJobs(jobs, 2), null, 'terminal=2, limit=2 → no-op');
assert.equal(pruneOldestTerminalJobs(jobs, 3), null, 'terminal=2, limit=3 → no-op');
});
test('drops oldest terminal jobs when over the limit, keeps non-terminal', () => {
const jobs = [
j('t1', 'done'), // oldest terminal — should be dropped
j('t2', 'done'), // should be dropped
j('queued1', 'queued'),
j('t3', 'error'), // newest of the dropped block
j('uploading1', 'uploading'),
j('t4', 'done'), // kept (within limit window)
j('t5', 'skipped'), // kept
j('t6', 'aborted'), // kept
];
// 6 terminal, limit 3 → drop 3 oldest (t1, t2, t3)
const result = pruneOldestTerminalJobs(jobs, 3);
assert.notEqual(result, null);
const droppedIds = result.dropped.map(x => x.id).sort();
assert.deepEqual(droppedIds, ['t1', 't2', 't3']);
// Non-terminal jobs always kept; surviving terminals are the newest 3
const keptIds = result.kept.map(x => x.id);
assert.deepEqual(keptIds, ['queued1', 'uploading1', 't4', 't5', 't6']);
});
test('respects insertion order (oldest by index, not by status)', () => {
const jobs = [
j('older-error', 'error'),
j('newer-done', 'done'),
j('newest-aborted', 'aborted'),
];
const result = pruneOldestTerminalJobs(jobs, 1);
assert.deepEqual(result.dropped.map(x => x.id), ['older-error', 'newer-done']);
assert.deepEqual(result.kept.map(x => x.id), ['newest-aborted']);
});
test('drops everything terminal when limit is 0', () => {
const jobs = [
j('q', 'queued'),
j('d1', 'done'),
j('d2', 'done'),
j('e1', 'error'),
];
const result = pruneOldestTerminalJobs(jobs, 0);
assert.deepEqual(result.dropped.map(x => x.id), ['d1', 'd2', 'e1']);
assert.deepEqual(result.kept.map(x => x.id), ['q']);
});
test('rejects negative or non-finite limits', () => {
const jobs = [j('a', 'done'), j('b', 'done')];
assert.equal(pruneOldestTerminalJobs(jobs, -1), null);
assert.equal(pruneOldestTerminalJobs(jobs, NaN), null);
assert.equal(pruneOldestTerminalJobs(jobs, Infinity), null,
'Infinity is technically not finite; safer to treat as no-op');
});
test('TERMINAL_STATUSES set covers all 4 terminal kinds', () => {
assert.ok(TERMINAL_STATUSES.has('done'));
assert.ok(TERMINAL_STATUSES.has('skipped'));
assert.ok(TERMINAL_STATUSES.has('error'));
assert.ok(TERMINAL_STATUSES.has('aborted'));
assert.equal(TERMINAL_STATUSES.size, 4);
// Non-terminal must not be in the set
for (const s of ['queued', 'preview', 'uploading', 'retrying', 'getting-server']) {
assert.equal(TERMINAL_STATUSES.has(s), false, `${s} must not be terminal`);
}
});
test('handles malformed entries (null / missing status) without throwing', () => {
const jobs = [
null,
j('a', 'done'),
{ id: 'no-status' }, // no status
j('b', 'done'),
];
// 2 terminal, limit 1 → drop oldest (a). null and no-status entries stay
// because they aren't terminal. The function must not throw on them.
const result = pruneOldestTerminalJobs(jobs, 1);
assert.notEqual(result, null);
assert.deepEqual(result.dropped.map(x => x && x.id), ['a']);
assert.equal(result.kept.length, 3);
});
test('large queue: keeps the newest `limit` terminals', () => {
const jobs = [];
for (let i = 0; i < 5000; i++) jobs.push(j(`done-${i}`, 'done'));
const result = pruneOldestTerminalJobs(jobs, 500);
assert.notEqual(result, null);
assert.equal(result.dropped.length, 4500);
assert.equal(result.kept.length, 500);
// First kept = done-4500 (the 4501st original entry)
assert.equal(result.kept[0].id, 'done-4500');
assert.equal(result.kept[result.kept.length - 1].id, 'done-4999');
});

View File

@ -0,0 +1,53 @@
const { describe, it } = require('node:test');
const assert = require('node:assert');
const path = require('path');
const fs = require('fs');
const os = require('os');
// Minimal app mock for ConfigStore
function createTestConfigStore() {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-test-'));
const mockApp = {
isPackaged: false,
getPath: (name) => tmpDir,
getPath: () => tmpDir
};
const ConfigStore = require('../lib/config-store');
const store = new ConfigStore(mockApp);
store.filePath = path.join(tmpDir, 'test-config.json');
return { store, tmpDir };
}
describe('remote config defaults', () => {
it('should include remote settings in defaults', () => {
const { store } = createTestConfigStore();
const config = store.load();
const remote = config.globalSettings.remote;
assert.strictEqual(remote.enabled, false);
assert.strictEqual(remote.port, 9100);
assert.strictEqual(typeof remote.token, 'string');
assert.strictEqual(remote.token, '');
assert.strictEqual(remote.allowInput, true);
});
it('should deep-merge remote settings with existing config', async () => {
const { store } = createTestConfigStore();
// Save config with partial remote settings
await store.save({
globalSettings: {
remote: { enabled: true, port: 9200 }
}
});
const config = store.load();
const remote = config.globalSettings.remote;
// Saved values preserved
assert.strictEqual(remote.enabled, true);
assert.strictEqual(remote.port, 9200);
// Defaults merged in
assert.strictEqual(remote.allowInput, true);
assert.strictEqual(remote.token, '');
});
});

View File

@ -0,0 +1,41 @@
const { describe, it, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
// Test the module can be required and has the expected API
describe('RemoteServer', () => {
it('should export a class with start/stop methods', () => {
const RemoteServer = require('../lib/remote-server');
assert.strictEqual(typeof RemoteServer, 'function');
assert.strictEqual(typeof RemoteServer.prototype.start, 'function');
assert.strictEqual(typeof RemoteServer.prototype.stop, 'function');
assert.strictEqual(typeof RemoteServer.prototype.getClientCount, 'function');
});
it('should start and stop without errors', async () => {
const RemoteServer = require('../lib/remote-server');
const server = new RemoteServer();
// Mock mainWindow
const mockMainWindow = {
isDestroyed: () => false,
getTitle: () => 'Test Window',
getContentBounds: () => ({ x: 0, y: 0, width: 1920, height: 1080 }),
webContents: {
sendInputEvent: () => {}
}
};
await server.start({
port: 0, // random available port
token: 'test-token-123',
allowInput: true,
mainWindow: mockMainWindow,
onSignalingToCapture: () => {},
onCreateCaptureWindow: () => {},
onDestroyCaptureWindow: () => {}
});
assert.strictEqual(server.getClientCount(), 0);
server.stop();
});
});

View File

@ -162,4 +162,13 @@ describe('Semaphore', () => {
await new Promise(r => setTimeout(r, 5)); await new Promise(r => setTimeout(r, 5));
assert.equal(sem.pending, 1); assert.equal(sem.pending, 1);
}); });
it('release without acquire clamps active to 0', () => {
const sem = new Semaphore(2);
assert.equal(sem.active, 0);
sem.release();
assert.equal(sem.active, 0, 'should not go negative');
sem.release();
assert.equal(sem.active, 0, 'should still be 0');
});
}); });

132
tests/stats.test.js Normal file
View File

@ -0,0 +1,132 @@
const test = require('node:test');
const assert = require('node:assert');
const {
summarizePerHoster,
classifyErrorCategory,
summarizeBatchErrors,
isRetryableCategory
} = require('../lib/stats');
function makeBatch(timestamp, results) {
return {
id: 'b-' + timestamp,
timestamp: new Date(timestamp).toISOString(),
files: [{ name: 'foo.mp4', size: 1, results }]
};
}
test('summarizePerHoster counts ok and fail per hoster across all batches', () => {
const history = [
makeBatch(1, [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'byse.sx', status: 'error', error: 'Not video file format' }
]),
makeBatch(2, [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'voe.sx', status: 'error', error: 'CSRF' },
{ hoster: 'byse.sx', status: 'done' }
])
];
const s = summarizePerHoster(history);
assert.strictEqual(s['voe.sx'].ok, 2);
assert.strictEqual(s['voe.sx'].fail, 1);
assert.strictEqual(s['voe.sx'].total, 3);
assert.strictEqual(Math.round(s['voe.sx'].rate * 100), 67);
assert.strictEqual(s['byse.sx'].ok, 1);
assert.strictEqual(s['byse.sx'].fail, 1);
assert.strictEqual(s['byse.sx'].rate, 0.5);
});
test('summarizePerHoster honors sinceMs cutoff', () => {
const history = [
makeBatch(1000, [{ hoster: 'voe.sx', status: 'done' }]),
makeBatch(5000, [{ hoster: 'voe.sx', status: 'error', error: 'x' }])
];
const s = summarizePerHoster(history, { sinceMs: 3000 });
assert.strictEqual(s['voe.sx'].ok, 0);
assert.strictEqual(s['voe.sx'].fail, 1);
});
test('summarizePerHoster honors lastNBatches (newest first)', () => {
const history = [
makeBatch(1000, [{ hoster: 'voe.sx', status: 'done' }]),
makeBatch(2000, [{ hoster: 'voe.sx', status: 'done' }]),
makeBatch(3000, [{ hoster: 'voe.sx', status: 'error', error: 'x' }])
];
const s = summarizePerHoster(history, { lastNBatches: 1 });
assert.strictEqual(s['voe.sx'].ok, 0);
assert.strictEqual(s['voe.sx'].fail, 1);
});
test('summarizePerHoster handles empty / malformed input', () => {
assert.deepStrictEqual(summarizePerHoster(null), {});
assert.deepStrictEqual(summarizePerHoster([]), {});
assert.deepStrictEqual(summarizePerHoster([{ id: 'x', files: null }]), {});
});
test('classifyErrorCategory: file-rejected phrases', () => {
assert.strictEqual(classifyErrorCategory('Byse lehnte Datei ab: Not video file format'), 'file-rejected');
assert.strictEqual(classifyErrorCategory('Duplicate file already exists'), 'file-rejected');
assert.strictEqual(classifyErrorCategory('Datei zu groß (Max: 5 GB)'), 'file-rejected');
});
test('classifyErrorCategory: account-error phrases', () => {
assert.strictEqual(classifyErrorCategory('Quota exceeded'), 'account-error');
assert.strictEqual(classifyErrorCategory('account banned'), 'account-error');
assert.strictEqual(classifyErrorCategory('not enough disk space'), 'account-error');
});
test('classifyErrorCategory: hoster-transient phrases', () => {
assert.strictEqual(classifyErrorCategory('CSRF-Token nicht gefunden'), 'hoster-transient');
assert.strictEqual(classifyErrorCategory('Kein Upload-Server erhalten: server busy'), 'hoster-transient');
assert.strictEqual(classifyErrorCategory('Kein Filecode'), 'hoster-transient');
});
test('classifyErrorCategory: network phrases', () => {
assert.strictEqual(classifyErrorCategory('socket hang up'), 'network');
assert.strictEqual(classifyErrorCategory('fetch failed'), 'network');
assert.strictEqual(classifyErrorCategory('Timeout while reading'), 'network');
});
test('classifyErrorCategory: aborted is its own bucket (not retryable)', () => {
assert.strictEqual(classifyErrorCategory('Abgebrochen'), 'aborted');
assert.strictEqual(isRetryableCategory('aborted'), false);
});
test('classifyErrorCategory: unknown for everything else', () => {
assert.strictEqual(classifyErrorCategory(''), 'unknown');
assert.strictEqual(classifyErrorCategory(null), 'unknown');
assert.strictEqual(classifyErrorCategory('Some weird thing'), 'unknown');
});
test('summarizeBatchErrors buckets results by category', () => {
const summary = {
files: [
{ name: 'a.mp4', results: [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'byse.sx', status: 'error', error: 'Not video file format' }
] },
{ name: 'b.mp4', results: [
{ hoster: 'voe.sx', status: 'error', error: 'CSRF' },
{ hoster: 'doodstream.com', status: 'error', error: 'socket hang up' }
] }
]
};
const buckets = summarizeBatchErrors(summary);
assert.strictEqual(buckets['file-rejected'].length, 1);
assert.strictEqual(buckets['file-rejected'][0].hoster, 'byse.sx');
assert.strictEqual(buckets['hoster-transient'].length, 1);
assert.strictEqual(buckets['hoster-transient'][0].hoster, 'voe.sx');
assert.strictEqual(buckets['network'].length, 1);
assert.strictEqual(buckets['network'][0].hoster, 'doodstream.com');
assert.strictEqual(buckets['account-error'].length, 0);
});
test('isRetryableCategory: only transient + network + unknown retry-worthy', () => {
assert.strictEqual(isRetryableCategory('hoster-transient'), true);
assert.strictEqual(isRetryableCategory('network'), true);
assert.strictEqual(isRetryableCategory('unknown'), true);
assert.strictEqual(isRetryableCategory('file-rejected'), false);
assert.strictEqual(isRetryableCategory('account-error'), false);
assert.strictEqual(isRetryableCategory('aborted'), false);
});

View File

@ -0,0 +1,94 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { sanitizeConfig, collectFile, buildSupportBundleText, REDACTED } = require('../lib/support-bundle');
test('sanitizeConfig redacts known credential keys at any nesting depth', () => {
const input = {
hosters: {
'voe.sx': [{ username: 'u', password: 'p1', apiKey: 'k1', enabled: true }],
'byse.sx': [{ apiKey: 'k2' }, { apiKey: 'k3', token: 't1', label: 'main' }]
},
globalSettings: { remote: { token: 'remT' }, scramble: { active: false } }
};
const out = sanitizeConfig(input);
assert.strictEqual(out.hosters['voe.sx'][0].password, REDACTED);
assert.strictEqual(out.hosters['voe.sx'][0].apiKey, REDACTED);
assert.strictEqual(out.hosters['voe.sx'][0].username, 'u');
assert.strictEqual(out.hosters['voe.sx'][0].enabled, true);
assert.strictEqual(out.hosters['byse.sx'][1].apiKey, REDACTED);
assert.strictEqual(out.hosters['byse.sx'][1].token, REDACTED);
assert.strictEqual(out.hosters['byse.sx'][1].label, 'main');
assert.strictEqual(out.globalSettings.remote.token, REDACTED);
});
test('sanitizeConfig does not mutate input', () => {
const input = { hosters: { 'voe.sx': [{ password: 'secret' }] } };
const clone = JSON.parse(JSON.stringify(input));
sanitizeConfig(input);
assert.deepStrictEqual(input, clone);
});
test('sanitizeConfig leaves empty/missing credentials alone', () => {
const input = { hosters: { 'voe.sx': [{ password: '', apiKey: null }] } };
const out = sanitizeConfig(input);
assert.strictEqual(out.hosters['voe.sx'][0].password, '');
assert.strictEqual(out.hosters['voe.sx'][0].apiKey, null);
});
test('sanitizeConfig handles null/undefined input', () => {
assert.strictEqual(sanitizeConfig(null), null);
assert.strictEqual(sanitizeConfig(undefined), undefined);
});
test('collectFile tails when file exceeds maxBytes', () => {
const tmp = path.join(os.tmpdir(), `mhu-bundle-${Date.now()}.log`);
const bigLine = 'x'.repeat(1000) + '\n';
fs.writeFileSync(tmp, bigLine.repeat(100));
try {
const section = collectFile(tmp, 'big.log', 5000);
assert.match(section, /truncated: skipped first \d+ bytes/);
assert.ok(section.length < bigLine.length * 100, 'section should be truncated');
} finally {
fs.unlinkSync(tmp);
}
});
test('collectFile returns placeholder for missing file', () => {
const section = collectFile(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.log`), 'missing');
assert.match(section, /<file does not exist yet>/);
});
test('collectFile returns placeholder for null path', () => {
const section = collectFile(null, 'no-path');
assert.match(section, /<no path configured>/);
});
test('buildSupportBundleText produces structured output with header + config + file sections', () => {
const tmp = path.join(os.tmpdir(), `mhu-bundle-text-${Date.now()}.log`);
fs.writeFileSync(tmp, 'line one\nline two\n');
try {
const text = buildSupportBundleText({
header: { Version: '3.3.41', Platform: 'win32' },
sanitizedConfig: { hosters: { 'voe.sx': [{ apiKey: '<redacted>' }] } },
files: [{ label: 'debug.log', path: tmp }]
});
assert.match(text, /^=== Multi-Hoster-Upload Support Bundle ===/);
assert.match(text, /Version: 3\.3\.41/);
assert.match(text, /Platform: win32/);
assert.match(text, /=== Config \(sanitized/);
assert.match(text, /"apiKey": "<redacted>"/);
assert.match(text, /=== debug\.log/);
assert.match(text, /line one\nline two/);
} finally {
fs.unlinkSync(tmp);
}
});
test('buildSupportBundleText handles empty file list and missing header', () => {
const text = buildSupportBundleText({ sanitizedConfig: {}, files: [] });
assert.match(text, /=== Multi-Hoster-Upload Support Bundle ===/);
assert.match(text, /=== Config/);
});

View File

@ -83,4 +83,19 @@ describe('Throttle', () => {
const elapsed = Date.now() - start2; const elapsed = Date.now() - start2;
assert.ok(elapsed >= 150, `third consume should wait for refill, took ${elapsed}ms`); assert.ok(elapsed >= 150, `third consume should wait for refill, took ${elapsed}ms`);
}); });
it('consume(0) resolves immediately', async () => {
const t = new Throttle(100);
const start = Date.now();
await t.consume(0);
assert.ok(Date.now() - start < 50);
});
it('updateRate to unlimited (0) makes consume instant', async () => {
const t = new Throttle(100); // very slow
t.updateRate(0); // unlimited
const start = Date.now();
await t.consume(1_000_000);
assert.ok(Date.now() - start < 50, 'unlimited rate should be instant');
});
}); });

View File

@ -0,0 +1,113 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { makeThrottledCache } = require('../lib/throttled-cache');
function fakeClock(start = 0) {
let t = start;
const fn = () => t;
fn.advance = (ms) => { t += ms; };
fn.set = (ms) => { t = ms; };
return fn;
}
test('returns undefined when empty', () => {
const c = makeThrottledCache(100);
assert.equal(c.get('any', {}), undefined);
});
test('returns the set value within the window', () => {
const clock = fakeClock();
const c = makeThrottledCache(100, clock);
const input = [1, 2, 3];
c.set('sig-a', input, 'value-1');
assert.equal(c.get('sig-a', input), 'value-1');
clock.advance(50);
assert.equal(c.get('sig-a', input), 'value-1', 'still valid at 50/100 ms');
clock.advance(49);
assert.equal(c.get('sig-a', input), 'value-1', 'still valid at 99/100 ms');
});
test('expires exactly at refreshMs boundary', () => {
const clock = fakeClock();
const c = makeThrottledCache(100, clock);
c.set('s', {}, 'v');
clock.advance(100);
assert.equal(c.get('s', {}), undefined, '>= refreshMs is a miss');
});
test('miss on different signature', () => {
const c = makeThrottledCache(1000, fakeClock());
const input = {};
c.set('sig-a', input, 'v');
assert.equal(c.get('sig-b', input), undefined);
});
test('miss on different input identity even with same signature', () => {
const c = makeThrottledCache(1000, fakeClock());
c.set('sig-a', { a: 1 }, 'v');
// Different object identity — the cache compares by ===, not by contents
assert.equal(c.get('sig-a', { a: 1 }), undefined);
});
test('overwrite by re-setting same signature', () => {
const clock = fakeClock();
const c = makeThrottledCache(100, clock);
const input = [];
c.set('s', input, 'old');
clock.advance(50);
c.set('s', input, 'new');
// The new entry has a fresh timestamp → still valid for another 100 ms
clock.advance(99);
assert.equal(c.get('s', input), 'new');
});
test('clear empties the cache', () => {
const c = makeThrottledCache(1000, fakeClock());
c.set('s', {}, 'v');
c.clear();
assert.equal(c.get('s', {}), undefined);
assert.equal(c.peek(), null);
});
test('peek reports age and signature', () => {
const clock = fakeClock();
const c = makeThrottledCache(1000, clock);
c.set('mysig', {}, 'v');
clock.advance(42);
const p = c.peek();
assert.equal(p.sig, 'mysig');
assert.equal(p.age, 42);
assert.equal(p.ts, 0);
});
test('throws on invalid refreshMs', () => {
assert.throws(() => makeThrottledCache(-1));
assert.throws(() => makeThrottledCache(NaN));
assert.throws(() => makeThrottledCache('100'));
});
test('refreshMs=0 means every call misses', () => {
const clock = fakeClock();
const c = makeThrottledCache(0, clock);
const input = {};
c.set('s', input, 'v');
// Same tick: 0 - 0 = 0 → not less than refreshMs (0) → miss
assert.equal(c.get('s', input), undefined);
});
test('default clock is Date.now when none provided', () => {
const c = makeThrottledCache(10000);
const input = {}; // single ref — get and set must use the SAME identity
c.set('x', input, 'v');
assert.equal(c.get('x', input), 'v');
});
test('large input arrays are tracked by identity, not value', () => {
const c = makeThrottledCache(1000, fakeClock());
const arr1 = new Array(10000).fill(0);
const arr2 = new Array(10000).fill(0);
c.set('s', arr1, 'cached');
assert.equal(c.get('s', arr1), 'cached');
assert.equal(c.get('s', arr2), undefined, 'different array → miss');
});

View File

@ -31,6 +31,7 @@ describe('UploadManager', () => {
const origRequire = module.constructor.prototype.require; const origRequire = module.constructor.prototype.require;
const hosters = require('../lib/hosters'); const hosters = require('../lib/hosters');
hosters.uploadFile = mockUploadFile; hosters.uploadFile = mockUploadFile;
hosters.prefetchBaseline = async () => null;
// Mock fs.statSync for test file paths // Mock fs.statSync for test file paths
const fs = require('fs'); const fs = require('fs');
@ -55,8 +56,8 @@ describe('UploadManager', () => {
]); ]);
const statuses = events.map(e => e.status); const statuses = events.map(e => e.status);
assert.ok(statuses.includes('queued'), 'should have queued status');
assert.ok(statuses.includes('done'), 'should have done status'); assert.ok(statuses.includes('done'), 'should have done status');
assert.ok(events.length > 0, 'should emit at least one progress event');
}); });
it('emits batch-done with correct summary', async () => { it('emits batch-done with correct summary', async () => {
@ -242,6 +243,55 @@ describe('UploadManager', () => {
assert.ok(statuses.some((entry) => entry.jobId === 'selected-job' && entry.status === 'aborted')); assert.ok(statuses.some((entry) => entry.jobId === 'selected-job' && entry.status === 'aborted'));
}); });
it('addJobs returns duplicate info and still runs newly queued jobs', async () => {
let releaseFirst = null;
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => {
if (filePath.endsWith('/first.mp4')) {
await new Promise((resolve, reject) => {
releaseFirst = resolve;
if (signal) {
signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true });
}
});
} else {
await new Promise((resolve) => setTimeout(resolve, 20));
}
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' };
});
const mgr = new UploadManager({
'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
});
const statuses = [];
mgr.on('progress', (data) => statuses.push({ jobId: data.jobId, status: data.status }));
const batchPromise = mgr.startBatch([
{ jobId: 'job-first', file: '/test/first.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
]);
for (let i = 0; i < 50 && !releaseFirst; i++) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.equal(typeof releaseFirst, 'function', 'first job should be running before addJobs');
const addResult = mgr.addJobs([
{ jobId: 'job-first', file: '/test/first.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
{ jobId: 'job-second', file: '/test/second.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
{ jobId: 'job-third', file: '/test/third.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
]);
assert.equal(addResult.added, 2);
assert.deepEqual(addResult.alreadyInBatchJobIds, ['job-first']);
releaseFirst();
await batchPromise;
assert.ok(statuses.some((entry) => entry.jobId === 'job-second' && entry.status === 'done'));
assert.ok(statuses.some((entry) => entry.jobId === 'job-third' && entry.status === 'done'));
});
it('_combineSignals propagates abort from either source', () => { it('_combineSignals propagates abort from either source', () => {
const mgr = new UploadManager({}); const mgr = new UploadManager({});
const ac1 = new AbortController(); const ac1 = new AbortController();
@ -280,6 +330,155 @@ describe('UploadManager', () => {
await assert.rejects(mgr._sleep(5000, ac.signal), /Aborted/); await assert.rejects(mgr._sleep(5000, ac.signal), /Aborted/);
}); });
it('file not found produces descriptive error', async () => {
// Override fs.statSync to throw ENOENT for a specific path
const fs = require('fs');
const origStat = fs.statSync;
fs.statSync = function(p) {
if (p === '/test/deleted.mp4') throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
return origStat.call(this, p);
};
const mgr = new UploadManager({});
const errors = [];
mgr.on('progress', (d) => { if (d.error) errors.push(d.error); });
await mgr.startBatch([
{ file: '/test/deleted.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
]);
fs.statSync = origStat;
assert.ok(errors.some(e => e.includes('nicht gefunden')), `expected "nicht gefunden" error, got: ${errors.join(', ')}`);
});
it('zero-byte file produces descriptive error', async () => {
fakeFileSize = 0;
const mgr = new UploadManager({});
const errors = [];
mgr.on('progress', (d) => { if (d.error) errors.push(d.error); });
await mgr.startBatch([
{ file: '/test/empty.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
]);
assert.ok(errors.some(e => e.includes('0 Bytes')), `expected "0 Bytes" error, got: ${errors.join(', ')}`);
});
it('empty batch completes immediately with zero counts', async () => {
const mgr = new UploadManager({});
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
const start = Date.now();
await mgr.startBatch([]);
const elapsed = Date.now() - start;
assert.ok(summary, 'batch-done should be emitted');
assert.equal(summary.total, 0);
assert.equal(summary.succeeded, 0);
assert.equal(summary.failed, 0);
assert.ok(elapsed < 200, `empty batch should complete fast, took ${elapsed}ms`);
});
it('scaleParallelUploads limits per-hoster count to global limit', async () => {
let concurrent = 0;
let maxConcurrent = 0;
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
concurrent++;
maxConcurrent = Math.max(maxConcurrent, concurrent);
await new Promise(r => setTimeout(r, 40));
concurrent--;
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
});
const mgr = new UploadManager(
{ 'doodstream.com': { retries: 0, parallelCount: 10, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } },
{ parallelUploadCount: 2, scaleParallelUploads: true }
);
await mgr.startBatch([
{ file: '/test/a.mp4', hoster: 'doodstream.com', apiKey: 'k' },
{ file: '/test/b.mp4', hoster: 'doodstream.com', apiKey: 'k' },
{ file: '/test/c.mp4', hoster: 'doodstream.com', apiKey: 'k' },
{ file: '/test/d.mp4', hoster: 'doodstream.com', apiKey: 'k' },
{ file: '/test/e.mp4', hoster: 'doodstream.com', apiKey: 'k' }
]);
assert.ok(maxConcurrent <= 2, `scaleParallelUploads should cap at 2, was ${maxConcurrent}`);
});
it('addJobs injects new tasks into running batch', async () => {
let started = 0;
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
started++;
await new Promise(r => setTimeout(r, 100));
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
});
const mgr = new UploadManager({});
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
// Start batch with 2 tasks
const batchPromise = mgr.startBatch([
{ jobId: 'job-1', file: '/test/a.mp4', hoster: 'doodstream.com', apiKey: 'k' },
{ jobId: 'job-2', file: '/test/b.mp4', hoster: 'doodstream.com', apiKey: 'k' }
]);
// After 30ms (during upload), inject 2 more tasks
await new Promise(r => setTimeout(r, 30));
const result = mgr.addJobs([
{ jobId: 'job-3', file: '/test/c.mp4', hoster: 'doodstream.com', apiKey: 'k' },
{ jobId: 'job-4', file: '/test/d.mp4', hoster: 'doodstream.com', apiKey: 'k' }
]);
assert.equal(result.added, 2, 'should add 2 new jobs');
assert.equal(result.alreadyInBatchJobIds.length, 0);
await batchPromise;
assert.ok(summary);
assert.equal(started, 4, 'all 4 jobs should have run');
});
it('addJobs rejects duplicates already in running batch', async () => {
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => {
// Slow upload so we can add jobs while it's running
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, 200);
if (signal) signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('Aborted')); });
});
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
});
const mgr = new UploadManager({});
const batchPromise = mgr.startBatch([
{ jobId: 'job-A', file: '/test/x.mp4', hoster: 'doodstream.com', apiKey: 'k' }
]);
// Try to add the SAME jobId while it's running
await new Promise(r => setTimeout(r, 50));
const result = mgr.addJobs([
{ jobId: 'job-A', file: '/test/x.mp4', hoster: 'doodstream.com', apiKey: 'k' },
{ jobId: 'job-B', file: '/test/y.mp4', hoster: 'doodstream.com', apiKey: 'k' }
]);
assert.equal(result.added, 1, 'should skip duplicate jobId, add only the new one');
assert.deepEqual(result.alreadyInBatchJobIds, ['job-A']);
await batchPromise;
});
it('addJobs returns added=0 when not running', () => {
const mgr = new UploadManager({});
const result = mgr.addJobs([
{ jobId: 'job-1', file: '/test/a.mp4', hoster: 'doodstream.com', apiKey: 'k' }
]);
assert.equal(result.added, 0);
});
it('stats event contains expected fields', async () => { it('stats event contains expected fields', async () => {
// Make upload take long enough for stats interval to fire // Make upload take long enough for stats interval to fire
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => { mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => {
@ -303,4 +502,474 @@ describe('UploadManager', () => {
assert.ok('elapsed' in stat); assert.ok('elapsed' in stat);
assert.ok('activeJobs' in stat); assert.ok('activeJobs' in stat);
}); });
describe('error classification', () => {
it('treats "not enough disk space" as account-level, not file-rejected', () => {
const mgr = new UploadManager({});
// Shape matches what lib/hosters.js attaches for byse account-storage-full
const err = new Error('Byse lehnte Datei ab: 0:0:0:not enough disk space on your account');
err.accountError = true;
assert.equal(mgr._isFileRejectedError(err), false,
'account-level error must NOT be classified as file-rejected');
assert.equal(mgr._shouldSkipRetryOnAccountError(err), true,
'account-storage-full must trigger account rotation');
});
it('classifies disk-space errors by message alone (safety net)', () => {
const mgr = new UploadManager({});
const err = new Error('Byse lehnte Datei ab: not enough disk space');
// No flag set — regex alone must catch it.
assert.equal(mgr._shouldSkipRetryOnAccountError(err), true);
assert.equal(mgr._isFileRejectedError(err), false,
'must not match generic "lehnte Datei ab" as file-rejected');
});
it('keeps true file rejections as file-rejected', () => {
const mgr = new UploadManager({});
const err = new Error('Byse lehnte Datei ab: Duplicate');
err.fileRejected = true;
assert.equal(mgr._isFileRejectedError(err), true);
assert.equal(mgr._shouldSkipRetryOnAccountError(err), false);
});
it('file-rejected regex still matches known phrases without flag', () => {
const mgr = new UploadManager({});
for (const msg of [
'Not video file format',
'Duplicate',
'Datei zu klein',
'File too large',
'Invalid file'
]) {
assert.equal(mgr._isFileRejectedError(new Error(msg)), true, `should match: ${msg}`);
}
});
it('accountError flag beats fileRejected if both set (defensive)', () => {
const mgr = new UploadManager({});
const err = new Error('weird');
err.fileRejected = true;
err.accountError = true;
assert.equal(mgr._isFileRejectedError(err), false,
'account-level always wins — rotation must happen');
assert.equal(mgr._shouldSkipRetryOnAccountError(err), true);
});
});
describe('session-level account memory', () => {
// Scenario: user has 2 byse accounts. Account 1 is full ("not enough
// disk space"). First job fails on acc1 → rotation to acc2. Second job
// must NOT re-probe acc1; pre-job-swap has to kick in.
it('after account is marked failed, next job swaps straight to override without retrying acc1', async () => {
// Only acc1 throws disk-space; acc2 succeeds. Mock decides by apiKey.
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
if (apiKey === 'acc1-key') {
const err = new Error('Byse lehnte Datei ab: 0:0:0:not enough disk space on your account');
err.accountError = true;
throw err;
}
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'https://byse.sx/ok', embed_url: null, file_code: 'ok' };
});
const mgr = new UploadManager(
{ 'byse.sx': { retries: 3, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
);
// Simulate main.js: on account-failed, resolve fallback → switchAccount
mgr.on('account-failed', ({ hoster, accountId }) => {
mgr.switchAccount(hoster, { id: 'acc2', username: 'u2', password: 'p2', apiKey: 'acc2-key' });
});
const rotEvents = [];
mgr.on('rot-log', (e) => rotEvents.push(e));
const progress = [];
mgr.on('progress', (d) => progress.push({ fileName: d.fileName, status: d.status, error: d.error }));
await mgr.startBatch([
{ file: '/test/a.mp4', hoster: 'byse.sx', apiKey: 'acc1-key', accountId: 'acc1', username: 'u1', password: 'p1' },
{ file: '/test/b.mp4', hoster: 'byse.sx', apiKey: 'acc1-key', accountId: 'acc1', username: 'u1', password: 'p1' }
]);
// Event sequence we expect:
// - job A: fast-fail on acc1 → mark-failed → switchAccount → rotate → upload with acc2 → done
// - job B: pre-job-swap from acc1 → acc2 (no attempts on acc1!) → done
const events = rotEvents.map(e => e.event);
assert.ok(events.includes('fast-fail'), `expected fast-fail, got: ${events.join(',')}`);
assert.ok(events.includes('mark-failed'), `expected mark-failed, got: ${events.join(',')}`);
assert.ok(events.includes('switchAccount'), `expected switchAccount, got: ${events.join(',')}`);
assert.ok(events.includes('pre-job-swap'), `expected pre-job-swap for 2nd job, got: ${events.join(',')}`);
// job B's pre-job-swap MUST predate any upload attempt for /test/b.mp4.
// If acc1 was probed for B, the mock would have thrown and we'd see
// another fast-fail or retrying event for b.mp4.
const bProgressErrors = progress
.filter(p => p.fileName && p.fileName.includes('b.mp4') && p.error)
.map(p => p.error);
assert.equal(bProgressErrors.length, 0,
`job B should never have touched acc1; got errors: ${bProgressErrors.join(' | ')}`);
// Both jobs should be done at the end.
const doneFiles = progress.filter(p => p.status === 'done').map(p => p.fileName);
assert.ok(doneFiles.some(f => f && f.includes('a.mp4')), 'a.mp4 should finish via rotation');
assert.ok(doneFiles.some(f => f && f.includes('b.mp4')), 'b.mp4 should finish via pre-job-swap');
// Sanity: mock was called with acc2-key more often than acc1-key.
const byKey = { acc1: 0, acc2: 0 };
for (const call of mockUploadFile.mock.calls) {
if (call.arguments[2] === 'acc1-key') byKey.acc1++;
else if (call.arguments[2] === 'acc2-key') byKey.acc2++;
}
assert.ok(byKey.acc1 <= 1, `acc1 should only be tried once (for job A); got ${byKey.acc1}`);
assert.ok(byKey.acc2 >= 2, `acc2 should handle both jobs after rotation; got ${byKey.acc2}`);
});
it('on fresh UploadManager (simulates app restart), failed-account memory is gone', () => {
const mgr1 = new UploadManager({});
mgr1._failedAccounts.set('byse.sx:acc1', true);
mgr1.switchAccount('byse.sx', { id: 'acc2' });
assert.equal(mgr1._failedAccounts.size, 1);
assert.equal(mgr1._accountOverrides.size, 1);
const mgr2 = new UploadManager({});
assert.equal(mgr2._failedAccounts.size, 0, 'new manager must start clean');
assert.equal(mgr2._accountOverrides.size, 0, 'override map must be empty on fresh manager');
});
it('exposes failed-account introspection (for main.js mid-batch re-resolve)', () => {
const mgr = new UploadManager({});
assert.deepEqual(mgr.getFailedAccountKeys(), []);
assert.equal(mgr.getOverride('byse.sx'), null);
mgr._failedAccounts.set('byse.sx:acc1', true);
mgr._failedAccounts.set('voe.sx:other', true);
assert.deepEqual(mgr.getFailedAccountKeys().sort(), ['byse.sx:acc1', 'voe.sx:other']);
assert.equal(mgr.getOverride('byse.sx'), null, 'no override yet');
mgr.switchAccount('byse.sx', { id: 'acc2', apiKey: 'k2' });
assert.equal(mgr.getOverride('byse.sx').id, 'acc2');
assert.equal(mgr.getOverride('voe.sx'), null, 'unrelated hoster still has no override');
});
it('startBatch primes failed-accounts + overrides — retry after batch-done skips dead account', async () => {
// acc1 fails with disk-space; acc2 succeeds.
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
if (apiKey === 'acc1-key') {
const err = new Error('Byse lehnte Datei ab: not enough disk space');
err.accountError = true;
throw err;
}
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
});
const mgr = new UploadManager(
{ 'byse.sx': { retries: 3, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
);
const rotEvents = [];
mgr.on('rot-log', (e) => rotEvents.push(e));
// Simulate a retry-after-batch-done: main.js would pass the
// session-cached failed-accounts + overrides from the previous batch.
await mgr.startBatch([
{ file: '/test/a.mp4', hoster: 'byse.sx', apiKey: 'acc1-key', accountId: 'acc1', username: 'u1', password: 'p1' }
], {
primeFailedAccounts: ['byse.sx:acc1'],
primeOverrides: [['byse.sx', { id: 'acc2', username: 'u2', password: 'p2', apiKey: 'acc2-key' }]]
});
const events = rotEvents.map(e => e.event);
// pre-job-swap should fire on the very first attempt — no fast-fail
// because acc1 was never touched.
assert.ok(events.includes('pre-job-swap'),
`expected pre-job-swap from primed state; got: ${events.join(',')}`);
assert.ok(!events.includes('fast-fail'),
`must NOT burn a fast-fail on primed-dead acc1; got: ${events.join(',')}`);
assert.ok(!events.includes('mark-failed'),
`acc1 was already marked failed (primed); must not emit mark-failed again; got: ${events.join(',')}`);
// acc1 must not be touched at all.
const acc1Calls = mockUploadFile.mock.calls.filter(c => c.arguments[2] === 'acc1-key').length;
assert.equal(acc1Calls, 0, 'primed-dead acc1 must not receive any upload attempts');
});
it('generic error + pre-resolved override: rotates after 1 attempt (no more 5x on primary)', async () => {
// acc1 throws a generic non-transient, non-account-specific error.
// acc2 succeeds. With a pre-resolved override (from main.js at batch
// start), the retry loop must break after 1 attempt on acc1 and rotate.
let acc1Calls = 0;
let acc2Calls = 0;
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
if (apiKey === 'acc1-key') {
acc1Calls++;
throw new Error('VOE Upload: irgendein generischer Fehler');
}
acc2Calls++;
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
});
const mgr = new UploadManager(
{ 'voe.sx': { retries: 5, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
);
const events = [];
mgr.on('rot-log', (e) => events.push(e.event));
await mgr.startBatch([
{ file: '/test/a.mp4', hoster: 'voe.sx', apiKey: 'acc1-key', accountId: 'acc1' }
], {
// Main.js pre-resolves the fallback at batch start.
primeOverrides: [['voe.sx', { id: 'acc2', apiKey: 'acc2-key' }]]
});
assert.equal(acc1Calls, 1, 'acc1 must get exactly 1 attempt before rotation kicks in');
assert.ok(acc2Calls >= 1, 'acc2 must take over');
assert.ok(events.includes('try-alternate-after-fail'),
`expected try-alternate-after-fail; got: ${events.join(',')}`);
});
it('transient error + pre-resolved override: retries SAME acc (network, not acc, is the issue)', async () => {
let acc1Calls = 0;
let acc2Calls = 0;
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
if (apiKey === 'acc1-key') {
acc1Calls++;
if (acc1Calls <= 2) throw new Error('connect ECONNRESET 1.2.3.4:443');
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok-on-acc1-retry', embed_url: null, file_code: 'ok' };
}
acc2Calls++;
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
});
const mgr = new UploadManager(
{ 'voe.sx': { retries: 5, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
);
const events = [];
mgr.on('rot-log', (e) => events.push(e.event));
await mgr.startBatch([
{ file: '/test/a.mp4', hoster: 'voe.sx', apiKey: 'acc1-key', accountId: 'acc1' }
], {
primeOverrides: [['voe.sx', { id: 'acc2', apiKey: 'acc2-key' }]]
});
assert.equal(acc1Calls, 3, 'transient must retry same acc until success');
assert.equal(acc2Calls, 0, 'must NOT rotate away on transient network errors');
assert.ok(!events.includes('try-alternate-after-fail'),
`transient should NOT trigger try-alternate-after-fail; got: ${events.join(',')}`);
});
it('generic error + NO override: falls back to classic retry on same account', async () => {
let acc1Calls = 0;
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
acc1Calls++;
throw new Error('something generic');
});
const mgr = new UploadManager(
{ 'voe.sx': { retries: 3, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
);
await mgr.startBatch([
{ file: '/test/a.mp4', hoster: 'voe.sx', apiKey: 'acc1-key', accountId: 'acc1' }
]);
// retries=3 → maxAttempts=4 (retries + 1). Without an override to rotate
// to, must exhaust all 4 attempts on acc1.
assert.equal(acc1Calls, 4, 'single-account hoster must retry N+1 times on same account');
});
it('startBatch without prime opts still clears state (back-compat)', async () => {
const mgr = new UploadManager({});
mgr._failedAccounts.set('byse.sx:acc1', true);
mgr._accountOverrides.set('byse.sx', { id: 'leftover' });
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
});
await mgr.startBatch([
{ file: '/test/a.mp4', hoster: 'doodstream.com', apiKey: 'k' }
]);
assert.equal(mgr._failedAccounts.size, 0, 'legacy callers still get a clean slate');
assert.equal(mgr._accountOverrides.size, 0, 'legacy callers still get a clean slate for overrides');
});
it('transient network errors skip rotation (account stays fine)', () => {
const mgr = new UploadManager({});
const cases = [
'getaddrinfo ENOTFOUND api.byse.sx',
'connect ECONNRESET 104.18.10.10:443',
'connect ETIMEDOUT 1.2.3.4:443',
'socket hang up',
'request to https://voe.sx failed, reason: getaddrinfo EAI_AGAIN',
'fetch failed',
'connect ECONNREFUSED 127.0.0.1:443',
'network error'
];
for (const msg of cases) {
const err = new Error(msg);
assert.equal(mgr._isTransientNetworkError(err), true, `should mark transient: ${msg}`);
assert.equal(mgr._isFileRejectedError(err), false, `transient must NOT be file-rejected: ${msg}`);
assert.equal(mgr._shouldSkipRetryOnAccountError(err), false, `transient must NOT be account-specific: ${msg}`);
}
});
it('transient classification does not swallow real account failures', () => {
const mgr = new UploadManager({});
const notTransient = [
'HTTP 429 Too Many Requests',
'quota exceeded',
'account suspended',
'Byse lehnte Datei ab: Duplicate',
'Falscher Passwort',
'Session expired'
];
for (const msg of notTransient) {
const err = new Error(msg);
assert.equal(mgr._isTransientNetworkError(err), false,
`must NOT be transient: "${msg}"`);
}
});
it('hoster-transient flag is recognised (primary path)', () => {
const mgr = new UploadManager({});
const err = new Error('whatever');
err.hosterTransient = true;
assert.equal(mgr._isHosterTransientError(err), true);
// Must not be confused with other classes.
assert.equal(mgr._isFileRejectedError(err), false);
assert.equal(mgr._isTransientNetworkError(err), false);
assert.equal(mgr._shouldSkipRetryOnAccountError(err), false);
});
it('hoster-transient regex fallback catches wrapped doodstream empty-form errors', () => {
const mgr = new UploadManager({});
const cases = [
'Doodstream Upload: kein Filecode — Server gab leeren Link zurueck (st=?, fn=fehlt/leer ...)',
'wrapper: Server gab leeren Link zurueck while parsing'
];
for (const msg of cases) {
assert.equal(mgr._isHosterTransientError(new Error(msg)), true, `should match: ${msg}`);
}
// Plain network and account errors must NOT match the hoster-transient class.
const negatives = [
'fetch failed',
'getaddrinfo ENOTFOUND',
'HTTP 429',
'quota exceeded',
'Byse lehnte Datei ab: Duplicate'
];
for (const msg of negatives) {
assert.equal(mgr._isHosterTransientError(new Error(msg)), false, `must NOT match: ${msg}`);
}
});
it('regression: hoster-transient does NOT blacklist the account (account stays usable across batches)', async () => {
// Simulate doodstream-upload throwing the tagged empty-form error.
mockUploadFile.mock.mockImplementation(async () => {
const err = new Error('Doodstream Upload: kein Filecode — Server gab leeren Link zurueck (st=?, fn=fehlt/leer ...)');
err.hosterTransient = true;
throw err;
});
const mgr = new UploadManager(
{ 'doodstream.com': { retries: 3, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
);
const rotEvents = [];
mgr.on('rot-log', (e) => rotEvents.push(e));
// No username/password so the manager routes through the mocked
// hosters.uploadFile (instead of DoodstreamUploader directly).
await mgr.startBatch([
{ file: '/test/Arrested.Development.mkv', hoster: 'doodstream.com', apiKey: 'acc1-key', accountId: 'acc1' }
]);
const events = rotEvents.map(e => e.event);
// Must NOT poison the account — that's the entire point of this fix.
assert.equal(mgr._failedAccounts.size, 0, `account must NOT be blacklisted; _failedAccounts=${JSON.stringify(mgr.getFailedAccountKeys())}`);
assert.ok(!events.includes('mark-failed'), `must NOT mark-failed for hoster-transient; got: ${events.join(',')}`);
// The in-loop fast-break and the post-loop classification must both fire.
assert.ok(events.includes('hoster-transient'),
`expected hoster-transient (in-loop break, no wasted retries); got: ${events.join(',')}`);
assert.ok(events.includes('skip-rotation-hoster-transient'),
`expected skip-rotation-hoster-transient (post-loop branch); got: ${events.join(',')}`);
// And the retry loop must NOT burn the full retries=3 -> only 1 attempt on this account.
assert.equal(mockUploadFile.mock.calls.length, 1,
`must fail fast on hoster-transient, not re-upload the file 4× wasting bandwidth; got ${mockUploadFile.mock.calls.length} calls`);
});
it('late-resolved override is honored by subsequent jobs (simulates mid-batch config add)', async () => {
// Only acc1 throws; acc2 succeeds.
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
if (apiKey === 'acc1-key') {
const err = new Error('Byse lehnte Datei ab: not enough disk space');
err.accountError = true;
throw err;
}
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
});
const mgr = new UploadManager(
{ 'byse.sx': { retries: 1, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
);
// Scenario: initially config has ONLY acc1. No account-failed listener
// resolves a fallback (because none exists in config yet). Job A fails
// with rotation-end.
const rotEvents = [];
mgr.on('rot-log', (e) => rotEvents.push(e));
await mgr.startBatch([
{ file: '/test/a.mp4', hoster: 'byse.sx', apiKey: 'acc1-key', accountId: 'acc1', username: 'u1', password: 'p1' }
]);
// Job A should have ended with rotation-end (no fallback available).
const eventsA = rotEvents.map(e => e.event);
assert.ok(eventsA.includes('mark-failed'), 'acc1 must be marked failed');
assert.ok(eventsA.includes('rotation-end'), 'expected rotation-end without a fallback');
assert.equal(mgr.getFailedAccountKeys().length, 1);
assert.equal(mgr.getOverride('byse.sx'), null, 'no override set during first batch');
// --- Simulate: user adds acc2 in Settings → save-config handler finds
// that byse.sx:acc1 is failed without an override → resolves + switches.
mgr.switchAccount('byse.sx', { id: 'acc2', username: 'u2', password: 'p2', apiKey: 'acc2-key' });
// Now a follow-up batch (same running session — in production, addJobs
// or a new startBatch without clearing maps would reach this state).
// We need to ALSO clear _failedAccounts manually here because startBatch
// resets it — so we poke the inner state to emulate "still mid-batch
// with late config". The switchAccount-after-fail path is what matters.
rotEvents.length = 0;
// Re-run just the _runJob path by manually setting up state and using
// addJobs — simulates mid-batch job add after config change.
mgr.running = true;
mgr._batchResults = new Map();
mgr._batchResults.set('/test/b.mp4', { name: 'b.mp4', size: fakeFileSize, results: [] });
mgr._failedAccounts.set('byse.sx:acc1', true); // re-establish failed state
mgr._additionalPromises = [];
// Spawn a new job through addJobs() path (uses _runJob internally)
const addResult = await mgr.addJobs([
{ file: '/test/b.mp4', hoster: 'byse.sx', apiKey: 'acc1-key', accountId: 'acc1', username: 'u1', password: 'p1', jobId: 'jb' }
]);
assert.ok(addResult.added >= 1 || addResult.alreadyInBatch === 0,
`addJobs should accept new job: ${JSON.stringify(addResult)}`);
await Promise.allSettled(mgr._additionalPromises);
const eventsB = rotEvents.map(e => e.event);
assert.ok(eventsB.includes('pre-job-swap'),
`job B should have pre-job-swap after late override was set; got: ${eventsB.join(',')}`);
// Mock must have been called with acc2-key for the new job (not acc1-key again)
const acc1ForB = mockUploadFile.mock.calls.filter(c =>
c.arguments[1] === '/test/b.mp4' && c.arguments[2] === 'acc1-key').length;
assert.equal(acc1ForB, 0, 'job B must never touch acc1-key after late fallback was set');
});
});
}); });

View File

@ -0,0 +1,195 @@
// Pure unit tests for the validate-credentials shape contract — does NOT spin
// up Electron or the real per-hoster checkers. Those need network. We verify
// the SHAPE the ephemeral hosterConfig is built into (which the per-hoster
// checkers consume) plus the snapshot-key/invalidation invariants that the
// renderer relies on to enforce "validated creds only".
//
// The three assertions the advisor called out as the regression guard for the
// user's "mehrfach angelegt" complaint:
// (a) failed validation persists nothing to config.hosters
// (b) a second "Anlegen" click with the guard set persists exactly one entry
// (c) OTP-required path persists nothing
// are exercised at the state-machine level by simulating the renderer's logic
// (re-implemented here as pure functions for testability — the real ones live
// in renderer/app.js which can't run under node:test).
const { test } = require('node:test');
const assert = require('node:assert');
// ---- Re-implementations of the renderer's pure helpers ----
// These mirror the production code exactly so the tests serve as both a guard
// and executable spec for what saveAccount() must do.
function credsSnapshotKey(authType, creds) {
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
return `api:${creds.apiKey || ''}`;
}
function buildEphemeralHosterConfig(payload) {
return {
username: payload.username || '',
password: payload.password || '',
apiKey: payload.apiKey || '',
enabled: true
};
}
// State-machine simulator that mirrors saveAccount() WITHOUT DOM/IPC.
function makeStateMachine({ validateImpl, persistImpl }) {
let busy = false;
let validated = null; // { hosterName, authType, snapshot, status }
const log = []; // log of every persist call, for assertions
async function click(ctx, creds, otp = '') {
if (busy) { log.push({ type: 'click-ignored-busy' }); return; }
const snapshot = credsSnapshotKey(ctx.authType, creds);
// STEP 2: commit if validated matches.
if (validated &&
validated.hosterName === ctx.hosterName &&
validated.authType === ctx.authType &&
validated.snapshot === snapshot) {
busy = true;
try {
await persistImpl(ctx, creds);
log.push({ type: 'persisted', accountId: ctx.accountId || `${ctx.hosterName}-NEW` });
} finally { busy = false; }
return;
}
// STEP 1: ephemeral validate.
busy = true;
let row;
try {
row = await validateImpl({ hoster: ctx.hosterName, authType: ctx.authType, ...creds, otp });
} finally { busy = false; }
if (row && (row.status === 'ok' || row.status === 'warn')) {
validated = { hosterName: ctx.hosterName, authType: ctx.authType, snapshot, status: row.status };
log.push({ type: 'validated', status: row.status });
return;
}
if (row && row.status === 'otp_required') {
log.push({ type: 'otp-required' });
return;
}
log.push({ type: 'validation-failed', message: row && row.message });
}
function editField() { validated = null; log.push({ type: 'invalidated-by-edit' }); }
return { click, editField, log: () => log.slice(), getValidated: () => validated };
}
// ---- Tests ----
test('regression (a): failed validation persists NOTHING to config.hosters', async () => {
const persistCalls = [];
const sm = makeStateMachine({
validateImpl: async () => ({ status: 'error', message: 'Falsches Passwort' }),
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
});
await sm.click({ hosterName: 'doodstream.com', authType: 'login', isEdit: false }, { username: 'u', password: 'wrong' });
assert.equal(persistCalls.length, 0, 'no persist should happen on failed validation');
assert.equal(sm.getValidated(), null);
assert.deepEqual(sm.log().map(e => e.type), ['validation-failed']);
});
test('regression (b): second click with guard set persists exactly ONE entry — no duplication', async () => {
const persistCalls = [];
let validateCount = 0;
const sm = makeStateMachine({
validateImpl: async () => { validateCount++; return { status: 'ok' }; },
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
});
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
const creds = { username: 'u', password: 'p' };
// Click 1 = validate → green.
await sm.click(ctx, creds);
// Click 2 = commit (same creds, validated snapshot matches).
await sm.click(ctx, creds);
// Click 3 = guard prevents a second commit because after persistImpl the
// state-machine in real code closes the modal. In this simulator the
// validated snapshot is still set — but a real double-click WHILE persistImpl
// is in flight would be caught by busy. Simulate that:
const sm2 = makeStateMachine({
validateImpl: async () => ({ status: 'ok' }),
persistImpl: () => new Promise(r => setTimeout(() => { persistCalls.push('slow'); r(); }, 30))
});
await sm2.click(ctx, creds); // validate
const p1 = sm2.click(ctx, creds); // start commit
const p2 = sm2.click(ctx, creds); // racing click — must be ignored
await Promise.all([p1, p2]);
assert.equal(persistCalls.length, 2, 'one persist from the deliberate two-step flow + one from sm2; racing click ignored');
assert.equal(validateCount, 1, 'second click reused the validated snapshot — no re-validate');
// The racing click MUST have been ignored by the busy guard.
assert.ok(sm2.log().some(e => e.type === 'click-ignored-busy'), 'busy guard fired on racing click');
});
test('regression (c): OTP-required persists NOTHING — and a follow-up click with OTP re-validates ephemerally', async () => {
const persistCalls = [];
let calls = 0;
const sm = makeStateMachine({
validateImpl: async (payload) => {
calls++;
if (!payload.otp) return { status: 'otp_required', message: 'OTP sent' };
if (payload.otp === '123456') return { status: 'ok' };
return { status: 'error', message: 'Bad OTP' };
},
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
});
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
const creds = { username: 'u', password: 'p' };
await sm.click(ctx, creds, ''); // first click → otp_required
await sm.click(ctx, creds, '123456'); // retry with otp → ok
await sm.click(ctx, creds); // final click → commit
assert.equal(persistCalls.length, 1, 'exactly one persist after OTP confirmed');
assert.equal(calls, 2, 'validate ran twice (initial + OTP) before commit');
assert.deepEqual(
sm.log().map(e => e.type),
['otp-required', 'validated', 'persisted']
);
});
test('field edit after green check invalidates the snapshot — next click is a re-Prüfen, not a commit', async () => {
const persistCalls = [];
let validateCount = 0;
const sm = makeStateMachine({
validateImpl: async () => { validateCount++; return { status: 'ok' }; },
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
});
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
await sm.click(ctx, { username: 'u', password: 'p' }); // validate → green
sm.editField(); // user edits cred field → snapshot dropped
await sm.click(ctx, { username: 'u', password: 'newpw' }); // creds differ → re-validate
await sm.click(ctx, { username: 'u', password: 'newpw' }); // now commit the NEW creds
assert.equal(persistCalls.length, 1, 'one persist of the new (re-validated) creds');
assert.equal(persistCalls[0].creds.password, 'newpw', 'persisted creds match the re-validated set');
assert.equal(validateCount, 2, 'second validate was forced by the edit-induced invalidation');
});
test('snapshot key is identical for same creds and DIFFERENT for any cred change (excluding label)', () => {
// Label changes must NOT invalidate validation — label is metadata, not a credential.
assert.equal(credsSnapshotKey('login', { username: 'u', password: 'p' }),
credsSnapshotKey('login', { username: 'u', password: 'p', label: 'XYZ' }));
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }),
credsSnapshotKey('login', { username: 'u', password: 'P' })); // password char-case
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }),
credsSnapshotKey('login', { username: 'U', password: 'p' })); // username diff
assert.equal(credsSnapshotKey('api', { apiKey: 'KEY' }),
credsSnapshotKey('api', { apiKey: 'KEY', label: 'mein key' }));
assert.notEqual(credsSnapshotKey('api', { apiKey: 'KEY' }),
credsSnapshotKey('api', { apiKey: 'KEY2' }));
});
test('ephemeral hosterConfig shape matches what per-hoster checkers expect', () => {
// The per-hoster checkers in main.js read .username/.password/.apiKey directly.
// This guards the validate-credentials IPC contract from drifting.
const cfg = buildEphemeralHosterConfig({ hoster: 'doodstream.com', username: 'u', password: 'p' });
assert.equal(cfg.username, 'u');
assert.equal(cfg.password, 'p');
assert.equal(cfg.apiKey, '');
assert.equal(cfg.enabled, true);
const cfg2 = buildEphemeralHosterConfig({ hoster: 'byse.sx', apiKey: 'K' });
assert.equal(cfg2.apiKey, 'K');
assert.equal(cfg2.username, '');
});