Compare commits
No commits in common. "2f13035725753c0e3d935510e2feb6f499e0438e" and "d8134ce74d97074625de8fdd06b652fb0d8412c9" have entirely different histories.
2f13035725
...
d8134ce74d
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.7.195",
|
"version": "1.7.194",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -696,8 +696,7 @@ 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];
|
||||||
|
|||||||
@ -4661,29 +4661,6 @@ 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);
|
||||||
@ -5272,27 +5249,25 @@ 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);
|
||||||
}
|
}
|
||||||
// A skip can be the package's LAST terminal event; without this trigger the
|
if (this.settings.autoExtract) {
|
||||||
// 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 (this.settings.autoExtract && !hasFailed && hasUnextracted) {
|
if (!hasPending && !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();
|
||||||
}
|
}
|
||||||
@ -5349,7 +5324,6 @@ 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();
|
||||||
@ -5454,7 +5428,6 @@ 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();
|
||||||
@ -5544,7 +5517,6 @@ 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;
|
||||||
@ -5563,7 +5535,6 @@ 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();
|
||||||
@ -5594,7 +5565,6 @@ 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();
|
||||||
@ -5640,7 +5610,6 @@ 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;
|
||||||
@ -5686,19 +5655,6 @@ 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);
|
||||||
}
|
}
|
||||||
@ -7541,11 +7497,6 @@ 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)}`));
|
||||||
@ -7574,8 +7525,6 @@ 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)}`));
|
||||||
@ -8262,14 +8211,6 @@ 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)}`));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -9184,8 +9125,6 @@ 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);
|
||||||
@ -9222,8 +9161,6 @@ 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;
|
||||||
@ -9282,8 +9219,6 @@ 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;
|
||||||
@ -10476,10 +10411,6 @@ 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(
|
||||||
@ -10564,17 +10495,10 @@ 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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -10620,17 +10544,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
pkg.status = "completed";
|
pkg.status = "completed";
|
||||||
}
|
}
|
||||||
pkg.updatedAt = nowMs();
|
pkg.updatedAt = nowMs();
|
||||||
// A package whose LAST terminal event is a failure never (re-)enters
|
// A package whose items ALL failed never enters post-processing, so the
|
||||||
// post-processing (its trigger sits only on completion paths), so the
|
// post-process notify hook can't fire for it — cover that case here.
|
||||||
// post-process notify hook can't fire — cover every failed-transition here.
|
if (pkg.status === "failed" && prevStatus !== "failed" && success === 0) {
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -12203,14 +12120,9 @@ 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 ? `⚠️ ${scope}` : `🏁 ${scope}`,
|
title: failed > 0 ? "⚠️ Durchlauf beendet" : "🏁 Durchlauf beendet",
|
||||||
message: `${success}/${total} erfolgreich, ${failed} fehlgeschlagen, ${cancelled} abgebrochen\nDauer ${duration}s, Durchschnitt ${humanSize(avgSpeed)}/s${postProcessPending ? "\nEntpacken laeuft noch — Paket-Meldungen folgen." : ""}`,
|
message: `${success}/${total} erfolgreich, ${failed} fehlgeschlagen, ${cancelled} abgebrochen\nDauer ${duration}s, Durchschnitt ${humanSize(avgSpeed)}/s`,
|
||||||
mention: this.settings.notifyMention
|
mention: this.settings.notifyMention
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,35 +77,6 @@ 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,
|
||||||
@ -357,7 +328,24 @@ function registerIpcHandlers(): void {
|
|||||||
const result = controller.updateSettings(validated as Partial<AppSettings>);
|
const result = controller.updateSettings(validated as Partial<AppSettings>);
|
||||||
updateClipboardWatcher();
|
updateClipboardWatcher();
|
||||||
updateTray();
|
updateTray();
|
||||||
armScheduledStart(result.scheduledStartEpochMs || 0, { startOnPast: true });
|
if (scheduledStartTimer !== null) {
|
||||||
|
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) => {
|
||||||
@ -819,10 +807,6 @@ 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) {
|
||||||
|
|||||||
@ -8,10 +8,6 @@ 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());
|
||||||
@ -30,23 +26,9 @@ 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 = truncateContent(`${mention ? `${mention} ` : ""}**${payload.title}**\n${payload.message}`);
|
const content = `${mention ? `${mention} ` : ""}**${payload.title}**\n${payload.message}`.slice(0, 2000);
|
||||||
return {
|
return {
|
||||||
url: String(url || "").trim(),
|
url: String(url || "").trim(),
|
||||||
init: {
|
init: {
|
||||||
@ -57,96 +39,20 @@ export function buildNotifyRequest(url: string, payload: NotifyPayload): { url:
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function delayMs(ms: number): Promise<void> {
|
export async function sendNotification(url: string, payload: NotifyPayload, fetchFn: typeof fetch = fetch): Promise<boolean> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
if (!isNotifyUrlValid(url)) {
|
||||||
|
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) });
|
||||||
const bodyText = await consumeBody(response);
|
if (!response.ok) {
|
||||||
if (response.ok) {
|
logger.warn(`Benachrichtigung fehlgeschlagen (HTTP ${response.status}): ${payload.title}`);
|
||||||
return { ok: true, retryable: false, waitMs: 0, detail: "" };
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
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;
|
return true;
|
||||||
}
|
} catch (error) {
|
||||||
lastDetail = outcome.detail;
|
logger.warn(`Benachrichtigung fehlgeschlagen: ${String(error)}`);
|
||||||
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;
|
return false;
|
||||||
});
|
}
|
||||||
sendChain = result.then(() => {
|
|
||||||
lastSendCompletedAt = Date.now();
|
|
||||||
}, () => {
|
|
||||||
lastSendCompletedAt = Date.now();
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
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 {
|
||||||
@ -132,14 +131,6 @@ 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,
|
||||||
|
|||||||
@ -4960,14 +4960,10 @@ 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={actionBusy || !settingsDraft.notifyUrl.trim()} onClick={() => {
|
<button className="btn" disabled={!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);
|
||||||
if (ok) {
|
showToast(ok ? "Test-Nachricht gesendet — schau in Discord" : "Test fehlgeschlagen — Webhook-URL prüfen (Details unter Hilfe → Letzte Fehler)", 4200);
|
||||||
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,153 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,8 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { buildNotifyRequest, isNotifyUrlValid, normalizeDiscordMention, sendNotification, truncateContent } from "../src/main/notify";
|
import { buildNotifyRequest, isNotifyUrlValid, normalizeDiscordMention, sendNotification } 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", () => {
|
||||||
@ -23,52 +20,40 @@ describe("normalizeDiscordMention", () => {
|
|||||||
|
|
||||||
describe("isNotifyUrlValid", () => {
|
describe("isNotifyUrlValid", () => {
|
||||||
it("accepts http/https URLs", () => {
|
it("accepts http/https URLs", () => {
|
||||||
expect(isNotifyUrlValid(WEBHOOK)).toBe(true);
|
expect(isNotifyUrlValid("https://discord.com/api/webhooks/123/abc")).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(` ${WEBHOOK} `)).toBe(true);
|
expect(isNotifyUrlValid(" https://discord.com/api/webhooks/123/abc ")).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(` ${WEBHOOK} `, { title: "✅ Paket fertig", message: "Show.S01\n5 Datei(en)" });
|
const req = buildNotifyRequest(" https://discord.com/api/webhooks/123/abc ", { title: "✅ Paket fertig", message: "Show.S01\n5 Datei(en)" });
|
||||||
expect(req.url).toBe(WEBHOOK);
|
expect(req.url).toBe("https://discord.com/api/webhooks/123/abc");
|
||||||
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(WEBHOOK, { title: "T", message: "M", mention: "123456789012345678" });
|
const req = buildNotifyRequest("https://discord.com/api/webhooks/123/abc", { 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(WEBHOOK, { title: "T", message: "M", mention: "" });
|
const req = buildNotifyRequest("https://discord.com/api/webhooks/123/abc", { 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");
|
||||||
});
|
});
|
||||||
@ -77,51 +62,21 @@ 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(WEBHOOK, { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(true);
|
await expect(sendNotification("https://discord.com/api/webhooks/123/abc", { title: "T", message: "M" }, fetchFn)).resolves.toBe(true);
|
||||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
it("retries a 429 using Discord's retry_after and then succeeds", async () => {
|
it("returns false on HTTP error without throwing", async () => {
|
||||||
const waits: number[] = [];
|
const fetchFn = vi.fn().mockResolvedValue(new Response("", { status: 500 }));
|
||||||
const sleepSpy = async (ms: number): Promise<void> => { waits.push(ms); };
|
await expect(sendNotification("https://discord.com/api/webhooks/123/abc", { title: "T", message: "M" }, fetchFn)).resolves.toBe(false);
|
||||||
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("retries transient 5xx and network errors, then gives up", async () => {
|
it("returns false on network error without throwing", async () => {
|
||||||
const fetchFn = vi.fn().mockResolvedValue(new Response("", { status: 502 }));
|
const fetchFn = vi.fn().mockRejectedValue(new Error("offline"));
|
||||||
await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false);
|
await expect(sendNotification("https://discord.com/api/webhooks/123/abc", { title: "T", message: "M" }, fetchFn)).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, noSleep)).resolves.toBe(false);
|
await expect(sendNotification("", { title: "T", message: "M" }, fetchFn)).resolves.toBe(false);
|
||||||
await expect(sendNotification("kein-url", { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false);
|
await expect(sendNotification("kein-url", { title: "T", message: "M" }, fetchFn)).resolves.toBe(false);
|
||||||
expect(fetchFn).not.toHaveBeenCalled();
|
expect(fetchFn).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user