Compare commits

..

No commits in common. "2f13035725753c0e3d935510e2feb6f499e0438e" and "d8134ce74d97074625de8fdd06b652fb0d8412c9" have entirely different histories.

9 changed files with 72 additions and 482 deletions

View File

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

View File

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

View File

@ -4661,29 +4661,6 @@ export class DownloadManager extends EventEmitter {
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[] {
const folderCandidates: string[] = [];
let currentDir = path.dirname(sourcePath);
@ -5272,26 +5249,24 @@ export class DownloadManager extends EventEmitter {
const pkg = this.session.packages[pkgId];
if (pkg) this.refreshPackageStatus(pkg);
}
// 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) {
const pkg = this.session.packages[pkgId];
if (!pkg || pkg.cancelled || this.packagePostProcessTasks.has(pkgId)) continue;
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");
if (hasPending) continue;
const hasFailed = pkgItems.some((i) => i.status === "failed");
const hasUnextracted = pkgItems.some((i) => i.status === "completed" && shouldAutoRetryExtraction(i.fullStatus || ""));
if (this.settings.autoExtract && !hasFailed && hasUnextracted) {
for (const it of pkgItems) {
if (it.status === "completed" && shouldAutoRetryExtraction(it.fullStatus || "")) {
it.fullStatus = "Entpacken - Ausstehend";
it.updatedAt = nowMs();
if (this.settings.autoExtract) {
for (const pkgId of affectedPackageIds) {
const pkg = this.session.packages[pkgId];
if (!pkg || pkg.cancelled || this.packagePostProcessTasks.has(pkgId)) continue;
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 hasFailed = pkgItems.some((i) => i.status === "failed");
const hasUnextracted = pkgItems.some((i) => i.status === "completed" && shouldAutoRetryExtraction(i.fullStatus || ""));
if (!hasPending && !hasFailed && hasUnextracted) {
for (const it of pkgItems) {
if (it.status === "completed" && shouldAutoRetryExtraction(it.fullStatus || "")) {
it.fullStatus = "Entpacken - Ausstehend";
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.emitState();
@ -5349,7 +5324,6 @@ export class DownloadManager extends EventEmitter {
}
this.runItemIds = new Set(runItems.map((item) => item.id));
this.runPackageIds = new Set(runItems.map((item) => item.packageId));
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
@ -5454,7 +5428,6 @@ export class DownloadManager extends EventEmitter {
}
this.runItemIds = new Set(runItems.map((item) => item.id));
this.runPackageIds = new Set(runItems.map((item) => item.packageId));
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
@ -5544,7 +5517,6 @@ export class DownloadManager extends EventEmitter {
if (this.packagePostProcessTasks.size > 0) {
this.runItemIds.clear();
this.runPackageIds.clear();
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.session.running = true;
@ -5563,7 +5535,6 @@ export class DownloadManager extends EventEmitter {
}
this.runItemIds.clear();
this.runPackageIds.clear();
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
@ -5594,7 +5565,6 @@ export class DownloadManager extends EventEmitter {
}
this.runItemIds = new Set(runItems.map((item) => item.id));
this.runPackageIds = new Set(runItems.map((item) => item.packageId));
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
@ -5640,7 +5610,6 @@ export class DownloadManager extends EventEmitter {
const parkForRestart = options?.parkForRestart === true;
const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop";
const keepExtraction = this.settings.autoExtractWhenStopped;
const wasRunning = this.session.running;
this.schedulerGeneration += 1;
this.session.running = false;
this.session.paused = false;
@ -5686,19 +5655,6 @@ export class DownloadManager extends EventEmitter {
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.emitState(true);
}
@ -7541,11 +7497,6 @@ export class DownloadManager extends EventEmitter {
completedItems: completedItems.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.emitState(true);
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,
targetedItems: targetItems.length
});
this.notifiedPackages.delete(packageId);
this.runPackageIds.add(packageId);
this.persistSoon();
this.emitState(true);
void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`));
@ -8262,14 +8211,6 @@ export class DownloadManager extends EventEmitter {
} finally {
this.scheduleRunning = false;
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.speedBps = 0;
item.updatedAt = nowMs();
const failPkg416 = this.session.packages[item.packageId];
if (failPkg416) this.refreshPackageStatus(failPkg416);
this.persistSoon();
this.emitState();
this.retryStateByItem.delete(item.id);
@ -9222,8 +9161,6 @@ export class DownloadManager extends EventEmitter {
item.speedBps = 0;
item.updatedAt = nowMs();
this.retryStateByItem.delete(item.id);
const failPkgDead = this.session.packages[item.packageId];
if (failPkgDead) this.refreshPackageStatus(failPkgDead);
this.persistSoon();
this.emitState();
return;
@ -9282,8 +9219,6 @@ export class DownloadManager extends EventEmitter {
item.speedBps = 0;
item.updatedAt = nowMs();
this.retryStateByItem.delete(item.id);
const failPkgDl = this.session.packages[item.packageId];
if (failPkgDl) this.refreshPackageStatus(failPkgDl);
this.persistSoon();
this.emitState();
return;
@ -10476,10 +10411,6 @@ export class DownloadManager extends EventEmitter {
if (!pkg) {
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);
}
logger.warn(
@ -10564,17 +10495,10 @@ export class DownloadManager extends EventEmitter {
return;
}
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, {
title: kind === "completed" ? "✅ Paket fertig" : "❌ Paket fehlgeschlagen",
message: `${pkg.name}\n${detail}`,
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.updatedAt = nowMs();
// A package whose LAST terminal event is a failure never (re-)enters
// post-processing (its trigger sits only on completion paths), so the
// 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") {
// A package whose items ALL failed never enters post-processing, so the
// post-process notify hook can't fire for it — cover that case here.
if (pkg.status === "failed" && prevStatus !== "failed" && success === 0) {
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}`;
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, {
title: failed > 0 ? `⚠️ ${scope}` : `🏁 ${scope}`,
message: `${success}/${total} erfolgreich, ${failed} fehlgeschlagen, ${cancelled} abgebrochen\nDauer ${duration}s, Durchschnitt ${humanSize(avgSpeed)}/s${postProcessPending ? "\nEntpacken laeuft noch — Paket-Meldungen folgen." : ""}`,
title: failed > 0 ? "⚠️ Durchlauf beendet" : "🏁 Durchlauf beendet",
message: `${success}/${total} erfolgreich, ${failed} fehlgeschlagen, ${cancelled} abgebrochen\nDauer ${duration}s, Durchschnitt ${humanSize(avgSpeed)}/s`,
mention: this.settings.notifyMention
});
}

View File

@ -77,35 +77,6 @@ function isDevMode(): boolean {
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 {
const window = new BrowserWindow({
width: 1920,
@ -357,7 +328,24 @@ function registerIpcHandlers(): void {
const result = controller.updateSettings(validated as Partial<AppSettings>);
updateClipboardWatcher();
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;
});
ipcMain.handle(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => {
@ -819,10 +807,6 @@ app.whenReady().then(() => {
bindMainWindowLifecycle(mainWindow);
updateClipboardWatcher();
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", () => {
if (BrowserWindow.getAllWindows().length === 0) {

View File

@ -8,10 +8,6 @@ export interface NotifyPayload {
const NOTIFY_TIMEOUT_MS = 5000;
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 {
return /^https?:\/\/\S+$/i.test(String(url || "").trim());
@ -30,23 +26,9 @@ export function normalizeDiscordMention(raw: string): string {
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 } {
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 {
url: String(url || "").trim(),
init: {
@ -57,96 +39,20 @@ export function buildNotifyRequest(url: string, payload: NotifyPayload): { url:
};
}
function delayMs(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function consumeBody(response: Response): Promise<string> {
try {
return await response.text();
} catch {
return "";
export async function sendNotification(url: string, payload: NotifyPayload, fetchFn: typeof fetch = fetch): Promise<boolean> {
if (!isNotifyUrlValid(url)) {
return false;
}
}
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 {
const request = buildNotifyRequest(url, payload);
const response = await fetchFn(request.url, { ...request.init, signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS) });
const bodyText = await consumeBody(response);
if (response.ok) {
return { ok: true, retryable: false, waitMs: 0, detail: "" };
if (!response.ok) {
logger.warn(`Benachrichtigung fehlgeschlagen (HTTP ${response.status}): ${payload.title}`);
return false;
}
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}` };
return true;
} 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}`);
}
logger.warn(`Benachrichtigung fehlgeschlagen: ${String(error)}`);
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,5 +1,4 @@
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
import { isNotifyUrlValid } from "./notify";
import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types";
function hasText(value: unknown): boolean {
@ -132,14 +131,6 @@ export function buildRedactedSettingsPayload(settings: AppSettings): Record<stri
updateRepo: settings.updateRepo,
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: {
totalDownloadedAllTime: settings.totalDownloadedAllTime,
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,

View File

@ -4960,14 +4960,10 @@ export function App(): ReactElement {
<label>Webhook-URL (Discord)</label>
<div className="input-row">
<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 () => {
const ok = await window.rd.testNotification(settingsDraft.notifyUrl, settingsDraft.notifyMention);
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);
}
showToast(ok ? "Test-Nachricht gesendet — schau in Discord" : "Test fehlgeschlagen — Webhook-URL prüfen (Details unter Hilfe → Letzte Fehler)", 4200);
}, (error) => {
showToast(`Test fehlgeschlagen: ${String(error)}`, 3600);
});

View File

@ -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);
});
});

View File

@ -1,8 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { buildNotifyRequest, isNotifyUrlValid, normalizeDiscordMention, sendNotification, truncateContent } from "../src/main/notify";
const noSleep = async (): Promise<void> => {};
const WEBHOOK = "https://discord.com/api/webhooks/123/abc";
import { buildNotifyRequest, isNotifyUrlValid, normalizeDiscordMention, sendNotification } from "../src/main/notify";
describe("normalizeDiscordMention", () => {
it("wraps a bare user ID as a pinging mention", () => {
@ -23,52 +20,40 @@ describe("normalizeDiscordMention", () => {
describe("isNotifyUrlValid", () => {
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(` ${WEBHOOK} `)).toBe(true);
expect(isNotifyUrlValid(" https://discord.com/api/webhooks/123/abc ")).toBe(true);
});
it("rejects empty and non-http values", () => {
expect(isNotifyUrlValid("")).toBe(false);
expect(isNotifyUrlValid("discord.com/api/webhooks/123/abc")).toBe(false);
expect(isNotifyUrlValid("ftp://x")).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", () => {
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)" });
expect(req.url).toBe(WEBHOOK);
const req = buildNotifyRequest(" https://discord.com/api/webhooks/123/abc ", { title: "✅ Paket fertig", message: "Show.S01\n5 Datei(en)" });
expect(req.url).toBe("https://discord.com/api/webhooks/123/abc");
expect(req.init.method).toBe("POST");
expect(req.init.headers).toMatchObject({ "Content-Type": "application/json" });
const body = JSON.parse(String(req.init.body));
expect(body.content).toBe("**✅ Paket fertig**\nShow.S01\n5 Datei(en)");
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)", () => {
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));
expect(body.content).toBe("<@123456789012345678> **T**\nM");
});
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));
expect(body.content).toBe("**T**\nM");
});
@ -77,51 +62,21 @@ describe("buildNotifyRequest", () => {
describe("sendNotification", () => {
it("returns true on HTTP ok (Discord answers 204 No Content)", async () => {
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);
});
it("retries a 429 using Discord's retry_after and then succeeds", async () => {
const waits: number[] = [];
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 HTTP error without throwing", async () => {
const fetchFn = vi.fn().mockResolvedValue(new Response("", { status: 500 }));
await expect(sendNotification("https://discord.com/api/webhooks/123/abc", { title: "T", message: "M" }, fetchFn)).resolves.toBe(false);
});
it("retries transient 5xx and network errors, then gives up", async () => {
const fetchFn = vi.fn().mockResolvedValue(new Response("", { status: 502 }));
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("returns false on network error without throwing", async () => {
const fetchFn = vi.fn().mockRejectedValue(new Error("offline"));
await expect(sendNotification("https://discord.com/api/webhooks/123/abc", { title: "T", message: "M" }, fetchFn)).resolves.toBe(false);
});
it("does not call fetch for an invalid URL", async () => {
const fetchFn = vi.fn();
await expect(sendNotification("", { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false);
await expect(sendNotification("kein-url", { 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)).resolves.toBe(false);
expect(fetchFn).not.toHaveBeenCalled();
});
});