Compare commits

...

8 Commits

Author SHA1 Message Date
Sucukdeluxe
2f13035725 Release v1.7.195 2026-06-10 00:40:36 +02:00
Sucukdeluxe
c2ba225047 Test: Regressionstests fuer die Failed-Transition-Benachrichtigung (Mixed-Paket, Dedup, Run-Guard, Marker-Freigabe) 2026-06-10 00:37:40 +02:00
Sucukdeluxe
86d935e449 Fix: Testen-Button — Speichern-Hinweis + kein stilles Verschlucken
Audit-Befunde R1, R2:
- Nach erfolgreichem Test mit ungespeicherten Einstellungen sagt der Toast
  jetzt "Einstellungen jetzt noch speichern!" — vorher konnte man testen
  (klappt!), nie speichern, und nach dem naechsten App-Neustart/Auto-Update
  feuerte kein einziger Webhook, weil notifyUrl nie persistiert war.
- Button ist waehrend einer laufenden Quick-Action deaktiviert statt den
  Klick still zu verschlucken (performQuickAction returnt sonst kommentarlos).
2026-06-10 00:36:39 +02:00
Sucukdeluxe
5f8b02e970 Fix: Webhook-Settings — Backup-Maskierung + Support-Bundle-Diagnose
Audit-Befunde SET-2, SET-4:
- notifyUrl in die SENSITIVE_KEYS des Backup-Imports aufgenommen: ein mit
  "***" maskierter Wert (haendisch redigierte Backup-Datei) wird wieder durch
  den aktuellen ersetzt statt als kaputte URL persistiert zu werden — die
  Webhook-URL ist ein Capability-Secret wie die Tokens.
- Support-Bundle enthaelt jetzt einen notifications-Block (konfiguriert?,
  URL-Format plausibel?, welche Toggles an) — "warum kam kein Webhook" ist
  damit aus einem Bundle diagnostizierbar, ohne die URL selbst zu leaken.
2026-06-10 00:35:54 +02:00
Sucukdeluxe
3fb9e85ba2 Fix: Run-Lebenszyklus — Stop-Summary, Scheduler-Race, ehrliche Run-Ende-Meldung, Boot-Re-Arm
Audit-Befunde RUN-1 bis RUN-4:

- RUN-1: Manueller Stop beendete den Durchlauf, ohne dass je eine Run-Summary
  kam (der Scheduler bricht an der while-Bedingung ab, finishRun wird nie
  erreicht). stop() schickt jetzt "Durchlauf gestoppt" mit den bis dahin
  gesammelten Zahlen (nur wenn der Run lief; beim Restart/Shutdown-Pfad
  unterdrueckt — der Prozess stirbt gleich).
- RUN-2: Stop->Start innerhalb des Scheduler-Sleeps (~120-220ms) liess den
  neuen Run fuehrerlos zurueck: ensureScheduler returnte (scheduleRunning noch
  true), die alte Schleife exitete auf Generation-Mismatch — danach lief KEIN
  Scheduler mehr, obwohl running=true: keine Downloads, kein finishRun, kein
  Webhook, und erneutes Start() heilte nichts (early-return wegen running).
  Die finally respawnt jetzt den Scheduler, wenn der Run aktiv ist und die
  Generation weitergezogen wurde.
- RUN-3: Geplanter Start ueberlebte keinen App-Neustart (Timer lebte nur im
  Prozess, Setting blieb stehen) — Auto-Update/Reboot verschluckte den
  geplanten Run still. armScheduledStart extrahiert, beim Boot re-armt;
  vergangene Zeit beim Boot wird geloggt+geleert statt blind zu starten
  (Konflikt mit autoResumeOnStart-Gate).
- RUN-4: "Durchlauf beendet" feuerte in der Default-Konfiguration
  (autoExtractWhenStopped) waehrend das Entpacken noch lief. Titel sagt jetzt
  "Downloads beendet" + Hinweis "Entpacken laeuft noch — Paket-Meldungen
  folgen", wenn Post-Processing aussteht.
2026-06-10 00:34:31 +02:00
Sucukdeluxe
8acb22d3af Fix: Dedup-Lifecycle der Benachrichtigungen — Recovery/Retry/Trailing-Pakete
Audit-Befunde DEDUP-1 (HIGH), DEDUP-3, DEDUP-4, NOTIFY-DEDUP-BEFORE-CONFIRM:

- DEDUP-1: recoverRetryableItems (Auto-Requeue gefailter Items beim Start)
  loescht jetzt den notifiedPackages-Eintrag der betroffenen Pakete. Vorher
  blieb der Failed-Marker aus Run 1 kleben — die Erfolgs-Benachrichtigung nach
  geglueckter Recovery in Run 2 (genau die Nachricht, auf die man wartet) kam
  nie; in Discord blieb das Paket fuer immer .
- DEDUP-3: retryExtraction/extractNow loeschen den Dedup-Marker und treten dem
  Run UNBEDINGT bei (runPackageIds.add) — der korrigierende  nach manuell
  wiederholtem Entpacken kam sonst nie an, v.a. wenn der Run schon vorbei war.
  History-Dedup bewusst unangetastet (sonst doppelte History-Eintraege).
- DEDUP-4: start()/startPackages()/startItems() ersetzen runPackageIds; Pakete
  mit noch LAUFENDEM Post-Processing (Task, Deferred, Hybrid) flogen aus dem
  Set und ihre Abschluss-Benachrichtigung wurde nach dem naechsten finishRun
  verworfen (autoExtractWhenStopped beendet den Run ~sofort). Neuer Helper
  addTrailingPostProcessPackageIds ergaenzt das neue Set an allen 5 Stellen —
  inkl. der Start-ohne-Queue-Pfade, die sogar die GERADE angestossenen
  Entpackungen aus dem Set warfen.
- Dedup-Marker wird wieder freigegeben, wenn der Versand (nach den Sender-
  Retries) endgueltig scheiterte — ein transienter Ausfall verbraucht den
  Einmal-Slot nicht mehr dauerhaft.
2026-06-10 00:28:50 +02:00
Sucukdeluxe
99a24592c5 Fix: Paket-Endstatus + Webhook gingen verloren, wenn das letzte Item fehlschlug
Audit-Befunde N1/DEDUP-2 (HIGH), N2 (HIGH), N4 (MEDIUM):

N2: Drei terminale Fehler-Pfade (HTTP-416 erschoepft, permanent toter Link,
Debrid-Link-Terminalfehler) returnten VOR dem gemeinsamen Schwanz mit
refreshPackageStatus. Schlug das LETZTE offene Item eines Pakets ueber einen
dieser Pfade fehl, blieb das Paket bis zum Neustart auf "downloading"/"queued"
haengen und die Fehler-Benachrichtigung kam nie — bei Paketen voller toter
Links (haeufigster Fehlerfall) deterministisch. Jetzt rufen alle drei Pfade
refreshPackageStatus auf (defensiv eingefuegt statt fall-through, damit kein
nachfolgender Retry-Branch ein endgueltig gefailtes Item wieder einreiht).

N1: refreshPackageStatus benachrichtigte beim Failed-Uebergang nur bei
success===0. Gemischte Pakete (teils OK, teils Fehler), deren letztes Item
FEHLSCHLAEGT, erreichen das Post-Processing aber nie (Trigger haengt nur an
Completion-Pfaden) — die Fehler-Benachrichtigung war weg, und zwar genau in
der haeufigen Reihenfolge (Fehl-Items brennen ihre Retries nach den
Geschwistern ab). Gate entfernt; das Dedup-Set verhindert Doppel-Sends, falls
Post-Processing doch laeuft. Zusaetzlich wird fuer den gemischten Fall jetzt
der History-Eintrag geschrieben (fehlte komplett; dedup-sicher via
historyRecordedPackages).

N4: skipItems triggert Post-Processing jetzt fuer JEDES durch den Skip
terminal gewordene Paket (vorher nur autoExtract&&!hasFailed&&unextracted) —
sonst fehlten Completed-/Failed-Benachrichtigung, History-Eintrag und
package_done-Bereinigung, z.B. mit autoExtract aus oder wenn hybrid schon
alles entpackt hatte. Label-Reset bleibt auf den Entpack-Fall begrenzt.
2026-06-10 00:22:49 +02:00
Sucukdeluxe
f060d0238e Fix: Webhook-Zustellung haertbar — Discord-Rate-Limit, Retries, Kappung, Logging
Audit-Befunde N5/RATELIMIT (HIGH), DEDUP-BEFORE-CONFIRM, SLICE-SURROGATE,
BODY-UNCONSUMED, SET-1(a):
- Alle Sends laufen jetzt seriell durch eine Queue mit 450ms Mindestabstand —
  Burst-Completions (viele Pakete gleichzeitig fertig) liefen sonst in Discords
  5-pro-2s-Limit und die ueberzaehligen Benachrichtigungen waren weg.
- 429 wird mit Discords retry_after (Sekunden -> ms, Header oder JSON-Body)
  wiederholt, 5xx/Netzwerkfehler mit Backoff (2 Retries); 4xx bleibt endgueltig.
- Response-Body wird immer konsumiert (undici-Verbindung nicht bis zum GC halten).
- 2000-Zeichen-Kappung surrogat-sicher (kein zerrissenes Emoji -> Discord 400).
- Ungueltige (nicht-leere) Webhook-URL loggt jetzt eine Warnung statt still zu
  verwerfen.
- Tests: 17 (Retry-Pfade 429/5xx/Netz, 4xx ohne Retry, Serialisierung, Kappung).
2026-06-10 00:17:57 +02:00
9 changed files with 483 additions and 73 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.7.194", "version": "1.7.195",
"description": "Desktop downloader", "description": "Desktop downloader",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -696,7 +696,8 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
const SENSITIVE_KEYS: (keyof AppSettings)[] = [ const SENSITIVE_KEYS: (keyof AppSettings)[] = [
"token", "megaLogin", "megaPassword", "bestToken", "allDebridToken", "token", "megaLogin", "megaPassword", "bestToken", "allDebridToken",
"ddownloadLogin", "ddownloadPassword", "oneFichierApiKey", "ddownloadLogin", "ddownloadPassword", "oneFichierApiKey",
"debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword" "debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword",
"notifyUrl"
]; ];
for (const key of SENSITIVE_KEYS) { for (const key of SENSITIVE_KEYS) {
const val = importedSettingsRecord[key]; const val = importedSettingsRecord[key];

View File

@ -4661,6 +4661,29 @@ export class DownloadManager extends EventEmitter {
return false; return false;
} }
// Packages whose post-processing (task, deferred pass or hybrid round) is still
// alive must keep their run-membership when the run set is replaced/cleared —
// otherwise their terminal notification is dropped as soon as session.running
// flips false (the notify guard checks running || runPackageIds).
private addTrailingPostProcessPackageIds(target: Set<string>): void {
for (const id of this.packagePostProcessTasks.keys()) {
target.add(id);
}
for (const [id, controller] of this.packageDeferredPostProcessAbortControllers) {
if (!controller.signal.aborted) {
target.add(id);
}
}
for (const [id, hybridSet] of this.packageHybridPostProcessControllers) {
for (const c of hybridSet) {
if (!c.signal.aborted) {
target.add(id);
break;
}
}
}
}
private buildCollectFolderCandidates(sourcePath: string, sourceRoot: string, pkg: PackageEntry): string[] { private buildCollectFolderCandidates(sourcePath: string, sourceRoot: string, pkg: PackageEntry): string[] {
const folderCandidates: string[] = []; const folderCandidates: string[] = [];
let currentDir = path.dirname(sourcePath); let currentDir = path.dirname(sourcePath);
@ -5249,25 +5272,27 @@ export class DownloadManager extends EventEmitter {
const pkg = this.session.packages[pkgId]; const pkg = this.session.packages[pkgId];
if (pkg) this.refreshPackageStatus(pkg); if (pkg) this.refreshPackageStatus(pkg);
} }
if (this.settings.autoExtract) { // A skip can be the package's LAST terminal event; without this trigger the
// post-processing terminal block (notify, history, cleanup policy) never runs
// for it — earlier rounds returned while the skipped item was still pending.
for (const pkgId of affectedPackageIds) { for (const pkgId of affectedPackageIds) {
const pkg = this.session.packages[pkgId]; const pkg = this.session.packages[pkgId];
if (!pkg || pkg.cancelled || this.packagePostProcessTasks.has(pkgId)) continue; if (!pkg || pkg.cancelled || this.packagePostProcessTasks.has(pkgId)) continue;
const pkgItems = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; const pkgItems = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
const hasPending = pkgItems.some((i) => i.status !== "completed" && i.status !== "failed" && i.status !== "cancelled"); const hasPending = pkgItems.some((i) => i.status !== "completed" && i.status !== "failed" && i.status !== "cancelled");
if (hasPending) continue;
const hasFailed = pkgItems.some((i) => i.status === "failed"); const hasFailed = pkgItems.some((i) => i.status === "failed");
const hasUnextracted = pkgItems.some((i) => i.status === "completed" && shouldAutoRetryExtraction(i.fullStatus || "")); const hasUnextracted = pkgItems.some((i) => i.status === "completed" && shouldAutoRetryExtraction(i.fullStatus || ""));
if (!hasPending && !hasFailed && hasUnextracted) { if (this.settings.autoExtract && !hasFailed && hasUnextracted) {
for (const it of pkgItems) { for (const it of pkgItems) {
if (it.status === "completed" && shouldAutoRetryExtraction(it.fullStatus || "")) { if (it.status === "completed" && shouldAutoRetryExtraction(it.fullStatus || "")) {
it.fullStatus = "Entpacken - Ausstehend"; it.fullStatus = "Entpacken - Ausstehend";
it.updatedAt = nowMs(); it.updatedAt = nowMs();
} }
} }
}
void this.runPackagePostProcessing(pkgId).catch((err) => logger.warn(`Post-processing nach Skip: ${compactErrorText(err)}`)); void this.runPackagePostProcessing(pkgId).catch((err) => logger.warn(`Post-processing nach Skip: ${compactErrorText(err)}`));
} }
}
}
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
} }
@ -5324,6 +5349,7 @@ export class DownloadManager extends EventEmitter {
} }
this.runItemIds = new Set(runItems.map((item) => item.id)); this.runItemIds = new Set(runItems.map((item) => item.id));
this.runPackageIds = new Set(runItems.map((item) => item.packageId)); this.runPackageIds = new Set(runItems.map((item) => item.packageId));
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
@ -5428,6 +5454,7 @@ export class DownloadManager extends EventEmitter {
} }
this.runItemIds = new Set(runItems.map((item) => item.id)); this.runItemIds = new Set(runItems.map((item) => item.id));
this.runPackageIds = new Set(runItems.map((item) => item.packageId)); this.runPackageIds = new Set(runItems.map((item) => item.packageId));
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
@ -5517,6 +5544,7 @@ export class DownloadManager extends EventEmitter {
if (this.packagePostProcessTasks.size > 0) { if (this.packagePostProcessTasks.size > 0) {
this.runItemIds.clear(); this.runItemIds.clear();
this.runPackageIds.clear(); this.runPackageIds.clear();
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.session.running = true; this.session.running = true;
@ -5535,6 +5563,7 @@ export class DownloadManager extends EventEmitter {
} }
this.runItemIds.clear(); this.runItemIds.clear();
this.runPackageIds.clear(); this.runPackageIds.clear();
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
@ -5565,6 +5594,7 @@ export class DownloadManager extends EventEmitter {
} }
this.runItemIds = new Set(runItems.map((item) => item.id)); this.runItemIds = new Set(runItems.map((item) => item.id));
this.runPackageIds = new Set(runItems.map((item) => item.packageId)); this.runPackageIds = new Set(runItems.map((item) => item.packageId));
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
@ -5610,6 +5640,7 @@ export class DownloadManager extends EventEmitter {
const parkForRestart = options?.parkForRestart === true; const parkForRestart = options?.parkForRestart === true;
const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop"; const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop";
const keepExtraction = this.settings.autoExtractWhenStopped; const keepExtraction = this.settings.autoExtractWhenStopped;
const wasRunning = this.session.running;
this.schedulerGeneration += 1; this.schedulerGeneration += 1;
this.session.running = false; this.session.running = false;
this.session.paused = false; this.session.paused = false;
@ -5655,6 +5686,19 @@ export class DownloadManager extends EventEmitter {
pkg.updatedAt = nowMs(); pkg.updatedAt = nowMs();
} }
} }
// A manual stop ends the run without ever reaching finishRun (the scheduler
// loop exits at its while condition), so the run summary would be lost.
// Suppressed for the restart/shutdown path: the process is about to die.
if (wasRunning && !parkForRestart && this.settings.notifyOnRunFinished && this.runItemIds.size > 0) {
const outcomes = Array.from(this.runOutcomes.values());
const success = outcomes.filter((s) => s === "completed").length;
const failed = outcomes.filter((s) => s === "failed").length;
void sendNotification(this.settings.notifyUrl, {
title: "⏹️ Durchlauf gestoppt",
message: `${success}/${this.runItemIds.size} erfolgreich, ${failed} fehlgeschlagen — Rest zurueck in der Warteschlange`,
mention: this.settings.notifyMention
});
}
this.persistSoon(); this.persistSoon();
this.emitState(true); this.emitState(true);
} }
@ -7497,6 +7541,11 @@ export class DownloadManager extends EventEmitter {
completedItems: completedItems.length, completedItems: completedItems.length,
targetedItems: targetItems.length targetedItems: targetItems.length
}); });
// Fresh outcome must notify again (the corrective checkmark after a notified
// extraction failure); unconditional run-membership so the notify guard also
// passes when the retry happens after the run already ended.
this.notifiedPackages.delete(packageId);
this.runPackageIds.add(packageId);
this.persistSoon(); this.persistSoon();
this.emitState(true); this.emitState(true);
void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (retryExtraction): ${compactErrorText(err)}`)); void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (retryExtraction): ${compactErrorText(err)}`));
@ -7525,6 +7574,8 @@ export class DownloadManager extends EventEmitter {
completedItems: completedItems.length, completedItems: completedItems.length,
targetedItems: targetItems.length targetedItems: targetItems.length
}); });
this.notifiedPackages.delete(packageId);
this.runPackageIds.add(packageId);
this.persistSoon(); this.persistSoon();
this.emitState(true); this.emitState(true);
void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`)); void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`));
@ -8211,6 +8262,14 @@ export class DownloadManager extends EventEmitter {
} finally { } finally {
this.scheduleRunning = false; this.scheduleRunning = false;
logger.info(`Scheduler beendet (gen=${myGeneration})`); logger.info(`Scheduler beendet (gen=${myGeneration})`);
// Stop->Start race: a new run can begin while this loop sleeps (start()'s
// ensureScheduler early-returns on scheduleRunning, then this loop exits on
// the generation mismatch). Without a respawn the run sits leaderless
// forever: running=true, but nothing schedules and finishRun never comes.
if (this.session.running && this.schedulerGeneration !== myGeneration) {
logger.warn(`Scheduler-Respawn: Run aktiv, alte Generation ${myGeneration} beendet (Stop->Start-Race)`);
void this.ensureScheduler().catch((error) => logger.error(`Scheduler-Respawn fehlgeschlagen: ${compactErrorText(error)}`));
}
} }
} }
@ -9125,6 +9184,8 @@ export class DownloadManager extends EventEmitter {
item.fullStatus = `Fehler: ${item.lastError}`; item.fullStatus = `Fehler: ${item.lastError}`;
item.speedBps = 0; item.speedBps = 0;
item.updatedAt = nowMs(); item.updatedAt = nowMs();
const failPkg416 = this.session.packages[item.packageId];
if (failPkg416) this.refreshPackageStatus(failPkg416);
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
this.retryStateByItem.delete(item.id); this.retryStateByItem.delete(item.id);
@ -9161,6 +9222,8 @@ export class DownloadManager extends EventEmitter {
item.speedBps = 0; item.speedBps = 0;
item.updatedAt = nowMs(); item.updatedAt = nowMs();
this.retryStateByItem.delete(item.id); this.retryStateByItem.delete(item.id);
const failPkgDead = this.session.packages[item.packageId];
if (failPkgDead) this.refreshPackageStatus(failPkgDead);
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
return; return;
@ -9219,6 +9282,8 @@ export class DownloadManager extends EventEmitter {
item.speedBps = 0; item.speedBps = 0;
item.updatedAt = nowMs(); item.updatedAt = nowMs();
this.retryStateByItem.delete(item.id); this.retryStateByItem.delete(item.id);
const failPkgDl = this.session.packages[item.packageId];
if (failPkgDl) this.refreshPackageStatus(failPkgDl);
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
return; return;
@ -10411,6 +10476,10 @@ export class DownloadManager extends EventEmitter {
if (!pkg) { if (!pkg) {
continue; continue;
} }
// The package gets a fresh chance — its next outcome must notify again
// (a recovery success after a notified failure is the message the user
// most wants). History dedup stays untouched (separate semantics).
this.notifiedPackages.delete(packageId);
this.refreshPackageStatus(pkg); this.refreshPackageStatus(pkg);
} }
logger.warn( logger.warn(
@ -10495,10 +10564,17 @@ export class DownloadManager extends EventEmitter {
return; return;
} }
this.notifiedPackages.add(pkg.id); this.notifiedPackages.add(pkg.id);
// Release the dedup marker if the delivery ultimately failed (after the
// sender's own retries), so a later manual re-run can notify again instead
// of a transient outage permanently consuming the once-per-package slot.
void sendNotification(url, { void sendNotification(url, {
title: kind === "completed" ? "✅ Paket fertig" : "❌ Paket fehlgeschlagen", title: kind === "completed" ? "✅ Paket fertig" : "❌ Paket fehlgeschlagen",
message: `${pkg.name}\n${detail}`, message: `${pkg.name}\n${detail}`,
mention: this.settings.notifyMention mention: this.settings.notifyMention
}).then((ok) => {
if (!ok) {
this.notifiedPackages.delete(pkg.id);
}
}); });
} }
@ -10544,10 +10620,17 @@ export class DownloadManager extends EventEmitter {
pkg.status = "completed"; pkg.status = "completed";
} }
pkg.updatedAt = nowMs(); pkg.updatedAt = nowMs();
// A package whose items ALL failed never enters post-processing, so the // A package whose LAST terminal event is a failure never (re-)enters
// post-process notify hook can't fire for it — cover that case here. // post-processing (its trigger sits only on completion paths), so the
if (pkg.status === "failed" && prevStatus !== "failed" && success === 0) { // post-process notify hook can't fire — cover every failed-transition here.
// That includes mixed packages (success > 0): the dedup set prevents a
// double-fire if post-processing does run later.
if (pkg.status === "failed" && prevStatus !== "failed") {
this.notifyPackageOutcome(pkg, "failed", `${failed} von ${total} Datei(en) fehlgeschlagen`); this.notifyPackageOutcome(pkg, "failed", `${failed} von ${total} Datei(en) fehlgeschlagen`);
if (success > 0) {
const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
this.recordPackageHistory(pkg.id, pkg, items);
}
} }
} }
@ -12120,9 +12203,14 @@ export class DownloadManager extends EventEmitter {
}; };
this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`; this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`;
if (this.settings.notifyOnRunFinished && total > 0) { if (this.settings.notifyOnRunFinished && total > 0) {
// With autoExtractWhenStopped (default) the run ends as soon as downloads
// are done while extraction may still be running — say so instead of
// claiming the whole run is finished.
const postProcessPending = this.packagePostProcessTasks.size > 0 || this.hasAnyDeferredPostProcessPending();
const scope = postProcessPending ? "Downloads beendet" : "Durchlauf beendet";
void sendNotification(this.settings.notifyUrl, { void sendNotification(this.settings.notifyUrl, {
title: failed > 0 ? "⚠️ Durchlauf beendet" : "🏁 Durchlauf beendet", title: failed > 0 ? `⚠️ ${scope}` : `🏁 ${scope}`,
message: `${success}/${total} erfolgreich, ${failed} fehlgeschlagen, ${cancelled} abgebrochen\nDauer ${duration}s, Durchschnitt ${humanSize(avgSpeed)}/s`, message: `${success}/${total} erfolgreich, ${failed} fehlgeschlagen, ${cancelled} abgebrochen\nDauer ${duration}s, Durchschnitt ${humanSize(avgSpeed)}/s${postProcessPending ? "\nEntpacken laeuft noch — Paket-Meldungen folgen." : ""}`,
mention: this.settings.notifyMention mention: this.settings.notifyMention
}); });
} }

View File

@ -77,6 +77,35 @@ function isDevMode(): boolean {
return process.env.NODE_ENV === "development"; return process.env.NODE_ENV === "development";
} }
// Single owner of the scheduled-start timer. startOnPast: a past time entered
// interactively starts right away; at boot a stale past time is cleared instead
// (an unattended auto-start at boot would race autoResumeOnStart's conflict gate).
function armScheduledStart(schedMs: number, opts: { startOnPast: boolean }): void {
if (scheduledStartTimer !== null) {
clearTimeout(scheduledStartTimer);
scheduledStartTimer = null;
}
if (!schedMs || schedMs <= 0) {
return;
}
const delay = schedMs - Date.now();
if (delay <= 0) {
if (opts.startOnPast) {
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
} else {
logger.warn(`Geplanter Start (${new Date(schedMs).toLocaleString()}) lag beim App-Start in der Vergangenheit — verworfen`);
}
controller.updateSettings({ scheduledStartEpochMs: 0 });
return;
}
scheduledStartTimer = setTimeout(() => {
scheduledStartTimer = null;
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
controller.updateSettings({ scheduledStartEpochMs: 0 });
}, delay);
logger.info(`Geplanter Start gearmt: ${new Date(schedMs).toLocaleString()}`);
}
function createWindow(): BrowserWindow { function createWindow(): BrowserWindow {
const window = new BrowserWindow({ const window = new BrowserWindow({
width: 1920, width: 1920,
@ -328,24 +357,7 @@ function registerIpcHandlers(): void {
const result = controller.updateSettings(validated as Partial<AppSettings>); const result = controller.updateSettings(validated as Partial<AppSettings>);
updateClipboardWatcher(); updateClipboardWatcher();
updateTray(); updateTray();
if (scheduledStartTimer !== null) { armScheduledStart(result.scheduledStartEpochMs || 0, { startOnPast: true });
clearTimeout(scheduledStartTimer);
scheduledStartTimer = null;
}
const schedMs = result.scheduledStartEpochMs || 0;
if (schedMs > 0) {
const delay = schedMs - Date.now();
if (delay <= 0) {
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
controller.updateSettings({ scheduledStartEpochMs: 0 });
} else {
scheduledStartTimer = setTimeout(() => {
scheduledStartTimer = null;
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
controller.updateSettings({ scheduledStartEpochMs: 0 });
}, delay);
}
}
return result; return result;
}); });
ipcMain.handle(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => { ipcMain.handle(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => {
@ -807,6 +819,10 @@ app.whenReady().then(() => {
bindMainWindowLifecycle(mainWindow); bindMainWindowLifecycle(mainWindow);
updateClipboardWatcher(); updateClipboardWatcher();
updateTray(); updateTray();
// A scheduled start persists in the settings but its timer lived only in this
// process — without re-arming it here, any restart (auto-update, reboot,
// crash) silently swallowed the planned run.
armScheduledStart(controller.getSettings().scheduledStartEpochMs || 0, { startOnPast: false });
app.on("activate", () => { app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {

View File

@ -8,6 +8,10 @@ export interface NotifyPayload {
const NOTIFY_TIMEOUT_MS = 5000; const NOTIFY_TIMEOUT_MS = 5000;
const WEBHOOK_USERNAME = "Real-Debrid Downloader"; const WEBHOOK_USERNAME = "Real-Debrid Downloader";
const MIN_SEND_GAP_MS = 450;
const RETRY_DELAYS_MS = [1000, 2500];
const RATE_LIMIT_MAX_WAIT_MS = 15_000;
const CONTENT_MAX_CHARS = 2000;
export function isNotifyUrlValid(url: string): boolean { export function isNotifyUrlValid(url: string): boolean {
return /^https?:\/\/\S+$/i.test(String(url || "").trim()); return /^https?:\/\/\S+$/i.test(String(url || "").trim());
@ -26,9 +30,23 @@ export function normalizeDiscordMention(raw: string): string {
return text; return text;
} }
// Discord counts the limit itself; slicing UTF-16 units can split a surrogate
// pair at the boundary, which Discord rejects as invalid content.
export function truncateContent(content: string, maxChars = CONTENT_MAX_CHARS): string {
if (content.length <= maxChars) {
return content;
}
let cut = content.slice(0, maxChars);
const last = cut.charCodeAt(cut.length - 1);
if (last >= 0xd800 && last <= 0xdbff) {
cut = cut.slice(0, -1);
}
return cut;
}
export function buildNotifyRequest(url: string, payload: NotifyPayload): { url: string; init: RequestInit } { export function buildNotifyRequest(url: string, payload: NotifyPayload): { url: string; init: RequestInit } {
const mention = normalizeDiscordMention(payload.mention || ""); const mention = normalizeDiscordMention(payload.mention || "");
const content = `${mention ? `${mention} ` : ""}**${payload.title}**\n${payload.message}`.slice(0, 2000); const content = truncateContent(`${mention ? `${mention} ` : ""}**${payload.title}**\n${payload.message}`);
return { return {
url: String(url || "").trim(), url: String(url || "").trim(),
init: { init: {
@ -39,20 +57,96 @@ export function buildNotifyRequest(url: string, payload: NotifyPayload): { url:
}; };
} }
export async function sendNotification(url: string, payload: NotifyPayload, fetchFn: typeof fetch = fetch): Promise<boolean> { function delayMs(ms: number): Promise<void> {
if (!isNotifyUrlValid(url)) { return new Promise((resolve) => setTimeout(resolve, ms));
return false;
} }
async function consumeBody(response: Response): Promise<string> {
try {
return await response.text();
} catch {
return "";
}
}
function parseRetryAfterMs(response: Response, bodyText: string): number {
const headerSeconds = Number(response.headers.get("X-RateLimit-Reset-After") || response.headers.get("Retry-After") || "");
if (Number.isFinite(headerSeconds) && headerSeconds > 0) {
return Math.ceil(headerSeconds * 1000);
}
try {
const parsed = JSON.parse(bodyText) as { retry_after?: number };
if (typeof parsed.retry_after === "number" && parsed.retry_after > 0) {
return Math.ceil(parsed.retry_after * 1000);
}
} catch {
}
return 1500;
}
async function sendOnce(url: string, payload: NotifyPayload, fetchFn: typeof fetch): Promise<{ ok: boolean; retryable: boolean; waitMs: number; detail: string }> {
try { try {
const request = buildNotifyRequest(url, payload); const request = buildNotifyRequest(url, payload);
const response = await fetchFn(request.url, { ...request.init, signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS) }); const response = await fetchFn(request.url, { ...request.init, signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS) });
if (!response.ok) { const bodyText = await consumeBody(response);
logger.warn(`Benachrichtigung fehlgeschlagen (HTTP ${response.status}): ${payload.title}`); if (response.ok) {
return false; return { ok: true, retryable: false, waitMs: 0, detail: "" };
} }
return true; if (response.status === 429) {
const waitMs = Math.min(RATE_LIMIT_MAX_WAIT_MS, parseRetryAfterMs(response, bodyText));
return { ok: false, retryable: true, waitMs, detail: `HTTP 429 (Rate-Limit, warte ${waitMs}ms)` };
}
if (response.status >= 500) {
return { ok: false, retryable: true, waitMs: 0, detail: `HTTP ${response.status}` };
}
return { ok: false, retryable: false, waitMs: 0, detail: `HTTP ${response.status}` };
} catch (error) { } catch (error) {
logger.warn(`Benachrichtigung fehlgeschlagen: ${String(error)}`); return { ok: false, retryable: true, waitMs: 0, detail: String(error) };
}
}
// All sends share one chain: serialized with a minimum gap so burst completions
// (many packages finishing together) stay under Discord's 5-per-2s webhook
// bucket instead of getting dropped as 429s.
let sendChain: Promise<void> = Promise.resolve();
let lastSendCompletedAt = 0;
export async function sendNotification(
url: string,
payload: NotifyPayload,
fetchFn: typeof fetch = fetch,
sleepFn: (ms: number) => Promise<void> = delayMs
): Promise<boolean> {
if (!isNotifyUrlValid(url)) {
if (String(url || "").trim()) {
logger.warn(`Benachrichtigung nicht gesendet: ungueltige Webhook-URL (muss mit http(s):// beginnen): ${payload.title}`);
}
return false; return false;
} }
const result = sendChain.then(async () => {
const sinceLast = Date.now() - lastSendCompletedAt;
if (sinceLast < MIN_SEND_GAP_MS) {
await sleepFn(MIN_SEND_GAP_MS - sinceLast);
}
let lastDetail = "";
for (let attempt = 0; ; attempt += 1) {
const outcome = await sendOnce(url, payload, fetchFn);
if (outcome.ok) {
return true;
}
lastDetail = outcome.detail;
if (!outcome.retryable || attempt >= RETRY_DELAYS_MS.length) {
break;
}
await sleepFn(outcome.waitMs > 0 ? outcome.waitMs : RETRY_DELAYS_MS[attempt]);
}
logger.warn(`Benachrichtigung fehlgeschlagen (${lastDetail}): ${payload.title}`);
return false;
});
sendChain = result.then(() => {
lastSendCompletedAt = Date.now();
}, () => {
lastSendCompletedAt = Date.now();
});
return result;
} }

View File

@ -1,4 +1,5 @@
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
import { isNotifyUrlValid } from "./notify";
import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types"; import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types";
function hasText(value: unknown): boolean { function hasText(value: unknown): boolean {
@ -131,6 +132,14 @@ export function buildRedactedSettingsPayload(settings: AppSettings): Record<stri
updateRepo: settings.updateRepo, updateRepo: settings.updateRepo,
autoUpdateCheck: settings.autoUpdateCheck autoUpdateCheck: settings.autoUpdateCheck
}, },
notifications: {
notifyUrlConfigured: Boolean(String(settings.notifyUrl || "").trim()),
notifyUrlLooksValid: isNotifyUrlValid(settings.notifyUrl),
notifyMentionConfigured: Boolean(String(settings.notifyMention || "").trim()),
notifyOnPackageCompleted: settings.notifyOnPackageCompleted,
notifyOnPackageFailed: settings.notifyOnPackageFailed,
notifyOnRunFinished: settings.notifyOnRunFinished
},
statistics: { statistics: {
totalDownloadedAllTime: settings.totalDownloadedAllTime, totalDownloadedAllTime: settings.totalDownloadedAllTime,
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime, totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,

View File

@ -4960,10 +4960,14 @@ export function App(): ReactElement {
<label>Webhook-URL (Discord)</label> <label>Webhook-URL (Discord)</label>
<div className="input-row"> <div className="input-row">
<input value={settingsDraft.notifyUrl} placeholder="https://discord.com/api/webhooks/..." onChange={(e) => setText("notifyUrl", e.target.value)} /> <input value={settingsDraft.notifyUrl} placeholder="https://discord.com/api/webhooks/..." onChange={(e) => setText("notifyUrl", e.target.value)} />
<button className="btn" disabled={!settingsDraft.notifyUrl.trim()} onClick={() => { <button className="btn" disabled={actionBusy || !settingsDraft.notifyUrl.trim()} onClick={() => {
void performQuickAction(async () => { void performQuickAction(async () => {
const ok = await window.rd.testNotification(settingsDraft.notifyUrl, settingsDraft.notifyMention); const ok = await window.rd.testNotification(settingsDraft.notifyUrl, settingsDraft.notifyMention);
showToast(ok ? "Test-Nachricht gesendet — schau in Discord" : "Test fehlgeschlagen — Webhook-URL prüfen (Details unter Hilfe → Letzte Fehler)", 4200); if (ok) {
showToast(settingsDirty ? "Test-Nachricht gesendet — Einstellungen jetzt noch speichern!" : "Test-Nachricht gesendet — schau in Discord", 4800);
} else {
showToast("Test fehlgeschlagen — Webhook-URL prüfen (Details unter Hilfe → Letzte Fehler)", 4200);
}
}, (error) => { }, (error) => {
showToast(`Test fehlgeschlagen: ${String(error)}`, 3600); showToast(`Test fehlgeschlagen: ${String(error)}`, 3600);
}); });

153
tests/notify-hooks.test.ts Normal file
View File

@ -0,0 +1,153 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/main/notify", async (importActual) => {
const actual = await importActual<typeof import("../src/main/notify")>();
return { ...actual, sendNotification: vi.fn().mockResolvedValue(true) };
});
import { DownloadManager } from "../src/main/download-manager";
import { defaultSettings } from "../src/main/constants";
import { createStoragePaths, emptySession } from "../src/main/storage";
import { shutdownItemLogs } from "../src/main/item-log";
import { shutdownPackageLogs } from "../src/main/package-log";
import { shutdownRenameLog } from "../src/main/rename-log";
import { sendNotification } from "../src/main/notify";
const mockedSend = sendNotification as unknown as ReturnType<typeof vi.fn>;
const tempDirs: string[] = [];
afterEach(() => {
mockedSend.mockClear();
shutdownItemLogs();
shutdownPackageLogs();
shutdownRenameLog();
for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
function setup(): { manager: DownloadManager; session: ReturnType<typeof emptySession> } {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nh-"));
tempDirs.push(root);
const session = emptySession();
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "out"),
extractDir: path.join(root, "extract"),
notifyUrl: "https://discord.com/api/webhooks/123/abc",
notifyOnPackageCompleted: true,
notifyOnPackageFailed: true
},
session,
createStoragePaths(path.join(root, "state"))
);
return { manager, session };
}
function addPackage(session: ReturnType<typeof emptySession>, itemStatuses: string[]): any {
const pkgId = "pkg-1";
const pkg: any = {
id: pkgId,
name: "Test.Show.S01",
outputDir: "C:/out",
extractDir: "C:/extract",
status: "queued",
itemIds: itemStatuses.map((_s, i) => `it-${i}`),
cancelled: false,
enabled: true,
priority: "normal",
createdAt: 1,
updatedAt: 1
};
session.packages[pkgId] = pkg;
session.packageOrder.push(pkgId);
itemStatuses.forEach((status, i) => {
session.items[`it-${i}`] = {
id: `it-${i}`,
packageId: pkgId,
url: `https://dummy/${i}`,
provider: null,
status,
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: `f${i}.rar`,
targetPath: "",
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "",
createdAt: 1,
updatedAt: 1
} as any;
});
return pkg;
}
describe("refreshPackageStatus failed-transition notify", () => {
it("notifies a MIXED package (some success, last finisher failed) — the lost-webhook case", () => {
const { manager, session } = setup();
const pkg = addPackage(session, ["completed", "failed"]);
session.running = true;
(manager as any).refreshPackageStatus(pkg);
expect(pkg.status).toBe("failed");
expect(mockedSend).toHaveBeenCalledTimes(1);
expect(mockedSend.mock.calls[0][1].title).toBe("❌ Paket fehlgeschlagen");
expect(mockedSend.mock.calls[0][1].message).toContain("1 von 2");
});
it("notifies an all-failed package and dedups repeat refreshes", () => {
const { manager, session } = setup();
const pkg = addPackage(session, ["failed", "failed"]);
session.running = true;
(manager as any).refreshPackageStatus(pkg);
(manager as any).refreshPackageStatus(pkg);
expect(pkg.status).toBe("failed");
expect(mockedSend).toHaveBeenCalledTimes(1);
});
it("stays silent outside a run (startup recovery must not spam)", () => {
const { manager, session } = setup();
const pkg = addPackage(session, ["failed"]);
session.running = false;
(manager as any).refreshPackageStatus(pkg);
expect(pkg.status).toBe("failed");
expect(mockedSend).not.toHaveBeenCalled();
});
it("does not notify while items are still pending", () => {
const { manager, session } = setup();
const pkg = addPackage(session, ["failed", "queued"]);
session.running = true;
(manager as any).refreshPackageStatus(pkg);
expect(pkg.status).toBe("queued");
expect(mockedSend).not.toHaveBeenCalled();
});
it("releases the dedup marker when the send ultimately fails (retro-notify possible)", async () => {
const { manager, session } = setup();
const pkg = addPackage(session, ["failed", "failed"]);
session.running = true;
mockedSend.mockResolvedValueOnce(false);
(manager as any).refreshPackageStatus(pkg);
await new Promise((r) => setTimeout(r, 0));
expect((manager as any).notifiedPackages.has(pkg.id)).toBe(false);
});
});

View File

@ -1,5 +1,8 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { buildNotifyRequest, isNotifyUrlValid, normalizeDiscordMention, sendNotification } from "../src/main/notify"; import { buildNotifyRequest, isNotifyUrlValid, normalizeDiscordMention, sendNotification, truncateContent } from "../src/main/notify";
const noSleep = async (): Promise<void> => {};
const WEBHOOK = "https://discord.com/api/webhooks/123/abc";
describe("normalizeDiscordMention", () => { describe("normalizeDiscordMention", () => {
it("wraps a bare user ID as a pinging mention", () => { it("wraps a bare user ID as a pinging mention", () => {
@ -20,40 +23,52 @@ describe("normalizeDiscordMention", () => {
describe("isNotifyUrlValid", () => { describe("isNotifyUrlValid", () => {
it("accepts http/https URLs", () => { it("accepts http/https URLs", () => {
expect(isNotifyUrlValid("https://discord.com/api/webhooks/123/abc")).toBe(true); expect(isNotifyUrlValid(WEBHOOK)).toBe(true);
expect(isNotifyUrlValid("http://192.168.1.10:8080/hook")).toBe(true); expect(isNotifyUrlValid("http://192.168.1.10:8080/hook")).toBe(true);
expect(isNotifyUrlValid(" https://discord.com/api/webhooks/123/abc ")).toBe(true); expect(isNotifyUrlValid(` ${WEBHOOK} `)).toBe(true);
}); });
it("rejects empty and non-http values", () => { it("rejects empty and non-http values", () => {
expect(isNotifyUrlValid("")).toBe(false); expect(isNotifyUrlValid("")).toBe(false);
expect(isNotifyUrlValid("discord.com/api/webhooks/123/abc")).toBe(false); expect(isNotifyUrlValid("discord.com/api/webhooks/123/abc")).toBe(false);
expect(isNotifyUrlValid("ftp://x")).toBe(false); expect(isNotifyUrlValid("ftp://x")).toBe(false);
expect(isNotifyUrlValid("https:// mit leerzeichen")).toBe(false); expect(isNotifyUrlValid("https:// mit leerzeichen")).toBe(false);
expect(isNotifyUrlValid("***")).toBe(false);
});
});
describe("truncateContent", () => {
it("leaves short content untouched", () => {
expect(truncateContent("hallo")).toBe("hallo");
});
it("caps at the limit", () => {
expect(truncateContent("x".repeat(3000)).length).toBe(2000);
});
it("never splits a surrogate pair at the boundary", () => {
const emoji = "🏁";
const content = "x".repeat(1999) + emoji;
const cut = truncateContent(content);
expect(cut.length).toBe(1999);
expect(/[\uD800-\uDBFF]$/.test(cut)).toBe(false);
}); });
}); });
describe("buildNotifyRequest", () => { describe("buildNotifyRequest", () => {
it("builds a Discord-compatible JSON webhook POST (bold title + message as content)", () => { it("builds a Discord-compatible JSON webhook POST (bold title + message as content)", () => {
const req = buildNotifyRequest(" https://discord.com/api/webhooks/123/abc ", { title: "✅ Paket fertig", message: "Show.S01\n5 Datei(en)" }); const req = buildNotifyRequest(` ${WEBHOOK} `, { title: "✅ Paket fertig", message: "Show.S01\n5 Datei(en)" });
expect(req.url).toBe("https://discord.com/api/webhooks/123/abc"); expect(req.url).toBe(WEBHOOK);
expect(req.init.method).toBe("POST"); expect(req.init.method).toBe("POST");
expect(req.init.headers).toMatchObject({ "Content-Type": "application/json" }); expect(req.init.headers).toMatchObject({ "Content-Type": "application/json" });
const body = JSON.parse(String(req.init.body)); const body = JSON.parse(String(req.init.body));
expect(body.content).toBe("**✅ Paket fertig**\nShow.S01\n5 Datei(en)"); expect(body.content).toBe("**✅ Paket fertig**\nShow.S01\n5 Datei(en)");
expect(body.username).toBe("Real-Debrid Downloader"); expect(body.username).toBe("Real-Debrid Downloader");
}); });
it("caps the content at Discord's 2000-char limit", () => {
const req = buildNotifyRequest("https://discord.com/api/webhooks/123/abc", { title: "T", message: "x".repeat(3000) });
const body = JSON.parse(String(req.init.body));
expect(body.content.length).toBe(2000);
});
it("prepends the mention so Discord pings (bare ID gets wrapped)", () => { it("prepends the mention so Discord pings (bare ID gets wrapped)", () => {
const req = buildNotifyRequest("https://discord.com/api/webhooks/123/abc", { title: "T", message: "M", mention: "123456789012345678" }); const req = buildNotifyRequest(WEBHOOK, { title: "T", message: "M", mention: "123456789012345678" });
const body = JSON.parse(String(req.init.body)); const body = JSON.parse(String(req.init.body));
expect(body.content).toBe("<@123456789012345678> **T**\nM"); expect(body.content).toBe("<@123456789012345678> **T**\nM");
}); });
it("sends no mention prefix when the field is empty", () => { it("sends no mention prefix when the field is empty", () => {
const req = buildNotifyRequest("https://discord.com/api/webhooks/123/abc", { title: "T", message: "M", mention: "" }); const req = buildNotifyRequest(WEBHOOK, { title: "T", message: "M", mention: "" });
const body = JSON.parse(String(req.init.body)); const body = JSON.parse(String(req.init.body));
expect(body.content).toBe("**T**\nM"); expect(body.content).toBe("**T**\nM");
}); });
@ -62,21 +77,51 @@ describe("buildNotifyRequest", () => {
describe("sendNotification", () => { describe("sendNotification", () => {
it("returns true on HTTP ok (Discord answers 204 No Content)", async () => { it("returns true on HTTP ok (Discord answers 204 No Content)", async () => {
const fetchFn = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); const fetchFn = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
await expect(sendNotification("https://discord.com/api/webhooks/123/abc", { title: "T", message: "M" }, fetchFn)).resolves.toBe(true); await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(true);
expect(fetchFn).toHaveBeenCalledTimes(1); expect(fetchFn).toHaveBeenCalledTimes(1);
}); });
it("returns false on HTTP error without throwing", async () => { it("retries a 429 using Discord's retry_after and then succeeds", async () => {
const fetchFn = vi.fn().mockResolvedValue(new Response("", { status: 500 })); const waits: number[] = [];
await expect(sendNotification("https://discord.com/api/webhooks/123/abc", { title: "T", message: "M" }, fetchFn)).resolves.toBe(false); const sleepSpy = async (ms: number): Promise<void> => { waits.push(ms); };
const fetchFn = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ retry_after: 1.2 }), { status: 429 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, sleepSpy)).resolves.toBe(true);
expect(fetchFn).toHaveBeenCalledTimes(2);
expect(waits).toContain(1200); // seconds -> ms
}); });
it("returns false on network error without throwing", async () => { it("retries transient 5xx and network errors, then gives up", async () => {
const fetchFn = vi.fn().mockRejectedValue(new Error("offline")); const fetchFn = vi.fn().mockResolvedValue(new Response("", { status: 502 }));
await expect(sendNotification("https://discord.com/api/webhooks/123/abc", { title: "T", message: "M" }, fetchFn)).resolves.toBe(false); await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false);
expect(fetchFn).toHaveBeenCalledTimes(3); // initial + 2 retries
const fetchErr = vi.fn().mockRejectedValue(new Error("offline"));
await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchErr, noSleep)).resolves.toBe(false);
expect(fetchErr).toHaveBeenCalledTimes(3);
});
it("does not retry a permanent 4xx", async () => {
const fetchFn = vi.fn().mockResolvedValue(new Response("", { status: 404 }));
await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false);
expect(fetchFn).toHaveBeenCalledTimes(1);
});
it("serializes concurrent sends in order (burst protection)", async () => {
const order: string[] = [];
const fetchFn = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => {
order.push(JSON.parse(String(init.body)).content);
return new Response(null, { status: 204 });
});
const sends = [
sendNotification(WEBHOOK, { title: "1", message: "" }, fetchFn, noSleep),
sendNotification(WEBHOOK, { title: "2", message: "" }, fetchFn, noSleep),
sendNotification(WEBHOOK, { title: "3", message: "" }, fetchFn, noSleep)
];
await expect(Promise.all(sends)).resolves.toEqual([true, true, true]);
expect(order).toEqual(["**1**\n", "**2**\n", "**3**\n"]);
}); });
it("does not call fetch for an invalid URL", async () => { it("does not call fetch for an invalid URL", async () => {
const fetchFn = vi.fn(); const fetchFn = vi.fn();
await expect(sendNotification("", { title: "T", message: "M" }, fetchFn)).resolves.toBe(false); await expect(sendNotification("", { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false);
await expect(sendNotification("kein-url", { title: "T", message: "M" }, fetchFn)).resolves.toBe(false); await expect(sendNotification("kein-url", { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false);
expect(fetchFn).not.toHaveBeenCalled(); expect(fetchFn).not.toHaveBeenCalled();
}); });
}); });