release: harden provider rotation and download recovery
Apply account and provider changes to active conversions without restarting, isolate API and Web state, and abort the exact fallback attempt when settings change. Bound resume recovery, make disk reservations abortable, preserve cleanup totals and history, stabilize compact UI state, and canonicalize RapidGator host aliases. Expand bounded support diagnostics while redacting account identities, local paths, package names, and file names from current and rotated logs. Add regression coverage for rotation, live settings, HTTP 416 recovery, disk waits, cleanup, context menus, history failures, and support bundle privacy.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts } from "../src/main/account-check";
|
||||
import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts } from "../src/main/account-check";
|
||||
import { getMegaDebridAccountId, type MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
|
||||
import type { DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
|
||||
import type { AppSettings } from "../src/shared/types";
|
||||
|
||||
@@ -117,7 +117,7 @@ describe("checkAllDebridAccounts", () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("checks every configured mega account + debrid-link key", async () => {
|
||||
it("checks every configured mega account + debrid-link key", async () => {
|
||||
const futureSec = Math.floor(Date.now() / 1000) + 1000;
|
||||
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
||||
if (String(url).includes("mega-debrid")) {
|
||||
@@ -136,10 +136,41 @@ describe("checkAllDebridAccounts", () => {
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result.filter((r) => r.provider === "megadebrid")).toHaveLength(2);
|
||||
expect(result.filter((r) => r.provider === "debridlink")).toHaveLength(3);
|
||||
expect(result.every((r) => r.valid)).toBe(true);
|
||||
});
|
||||
|
||||
it("caps concurrency (never more than 4 in flight) and preserves result order", async () => {
|
||||
expect(result.every((r) => r.valid)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps API and Web status identities separate when both modes use the same login", async () => {
|
||||
const futureSec = Math.floor(Date.now() / 1000) + 1000;
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input));
|
||||
const password = url.searchParams.get("password");
|
||||
if (password === "api-pass") {
|
||||
return { ok: true, status: 200, text: async () => JSON.stringify({ response_code: "ok", token: "api-token", vip_end: String(futureSec) }) };
|
||||
}
|
||||
return { ok: true, status: 200, text: async () => JSON.stringify({ response_code: "error", response_text: "web credentials rejected" }) };
|
||||
}) as unknown as typeof fetch);
|
||||
|
||||
const settings = {
|
||||
megaCredentials: "shared@example.test:api-pass",
|
||||
megaPassword: "",
|
||||
megaDebridApiCredentials: "shared@example.test:api-pass",
|
||||
megaDebridWebCredentials: "shared@example.test:web-pass",
|
||||
megaDebridApiEnabled: true,
|
||||
megaDebridWebEnabled: true,
|
||||
megaDebridPreferApi: true,
|
||||
debridLinkApiKeys: ""
|
||||
} as unknown as AppSettings;
|
||||
|
||||
const result = await checkAllDebridAccounts(settings);
|
||||
const baseId = getMegaDebridAccountId("shared@example.test");
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((status) => status.accountId)).toEqual([`${baseId}:api`, `${baseId}:web`]);
|
||||
expect(result[0]).toMatchObject({ valid: true, isPremium: true });
|
||||
expect(result[1]).toMatchObject({ valid: false, isPremium: false });
|
||||
});
|
||||
|
||||
it("caps concurrency (never more than 4 in flight) and preserves result order", async () => {
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
vi.stubGlobal("fetch", vi.fn(async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAccountStatus } from "../src/renderer/App";
|
||||
import {
|
||||
buildConfiguredProviderOrder,
|
||||
getAccountDialogSelectableOptions,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
resolveAccountUsername,
|
||||
resolveVisibleAccountKind
|
||||
} from "../src/renderer/account-ui";
|
||||
import type { DebridAccountStatus } from "../src/shared/types";
|
||||
|
||||
describe("account mode filter", () => {
|
||||
it("shows only API options for the API filter", () => {
|
||||
@@ -69,3 +71,36 @@ describe("account usernames", () => {
|
||||
expect(resolveAccountUsername("", undefined)).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("account row statuses", () => {
|
||||
it("shows the status matching each Mega-Debrid mode", () => {
|
||||
const apiStatus: DebridAccountStatus = {
|
||||
accountId: "mda_shared:api",
|
||||
provider: "megadebrid",
|
||||
label: "Mega-Debrid API",
|
||||
maskedLogin: "sh***ed",
|
||||
valid: false,
|
||||
isPremium: false,
|
||||
premiumUntilMs: null,
|
||||
message: "API ungültig",
|
||||
checkedAt: 10
|
||||
};
|
||||
const webStatus: DebridAccountStatus = {
|
||||
accountId: "mda_shared:web",
|
||||
provider: "megadebrid",
|
||||
label: "Mega-Debrid Web",
|
||||
maskedLogin: "sh***ed",
|
||||
valid: true,
|
||||
isPremium: true,
|
||||
premiumUntilMs: 2_000,
|
||||
message: "Web gültig",
|
||||
checkedAt: 20
|
||||
};
|
||||
const statuses = {
|
||||
"mda_shared:api": apiStatus,
|
||||
"mda_shared:web": webStatus
|
||||
};
|
||||
expect(resolveAccountStatus(statuses, "mda_shared", "megadebrid-api")?.message).toBe("API ungültig");
|
||||
expect(resolveAccountStatus(statuses, "mda_shared", "megadebrid-web")?.message).toBe("Web gültig");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ const {
|
||||
mockSession,
|
||||
mockFetch,
|
||||
mockBrowserWindowCtor,
|
||||
mockBrowserWindow,
|
||||
mockLoadURL,
|
||||
mockShow,
|
||||
mockFocus,
|
||||
@@ -63,6 +64,7 @@ const {
|
||||
},
|
||||
mockFetch: fetch,
|
||||
mockBrowserWindowCtor: BrowserWindowCtor,
|
||||
mockBrowserWindow: browserWindow,
|
||||
mockLoadURL: loadURL,
|
||||
mockShow: show,
|
||||
mockFocus: focus,
|
||||
@@ -117,6 +119,21 @@ describe("alldebrid-web", () => {
|
||||
expect(mockFocus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces a login window after its renderer process crashes", async () => {
|
||||
const fallback = new AllDebridWebFallback(() => true);
|
||||
|
||||
await fallback.openLoginWindow();
|
||||
const crashHandler = mockBrowserWindow.webContents.on.mock.calls.find(([event]) => event === "render-process-gone")?.[1];
|
||||
|
||||
expect(crashHandler).toBeTypeOf("function");
|
||||
crashHandler?.();
|
||||
await fallback.openLoginWindow();
|
||||
|
||||
expect(mockClose).toHaveBeenCalledTimes(1);
|
||||
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(2);
|
||||
expect(mockLoadURL).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses an existing AllDebrid Web session to unrestrict without opening a login window", async () => {
|
||||
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
link: "https://alldebrid.direct/session-file.bin",
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { AppController } from "../src/main/app-controller";
|
||||
import { createStoragePaths, loadHistory, loadSettings, saveHistory, saveSettings } from "../src/main/storage";
|
||||
|
||||
const electronState = vi.hoisted(() => ({ userDataDir: "" }));
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: {
|
||||
getPath: () => electronState.userDataDir
|
||||
},
|
||||
BrowserWindow: class {},
|
||||
clipboard: {},
|
||||
dialog: {},
|
||||
ipcMain: { handle: vi.fn(), on: vi.fn() },
|
||||
Menu: { buildFromTemplate: vi.fn(), setApplicationMenu: vi.fn() },
|
||||
safeStorage: { isEncryptionAvailable: () => false, encryptString: vi.fn(), decryptString: vi.fn() },
|
||||
shell: {},
|
||||
Tray: class {}
|
||||
}));
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createHistoryEntry(outputDir: string) {
|
||||
return {
|
||||
id: "history-locked",
|
||||
name: "locked",
|
||||
totalBytes: 1,
|
||||
downloadedBytes: 1,
|
||||
fileCount: 1,
|
||||
provider: "realdebrid" as const,
|
||||
completedAt: 100,
|
||||
durationSeconds: 1,
|
||||
status: "completed" as const,
|
||||
outputDir,
|
||||
urls: []
|
||||
};
|
||||
}
|
||||
|
||||
function failHistoryDeletion(historyFile: string) {
|
||||
const originalUnlink = fs.unlinkSync;
|
||||
return vi.spyOn(fs, "unlinkSync").mockImplementation((target) => {
|
||||
if (target === historyFile) {
|
||||
const error = new Error("EPERM: history file is locked") as NodeJS.ErrnoException;
|
||||
error.code = "EPERM";
|
||||
throw error;
|
||||
}
|
||||
return originalUnlink(target);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
electronState.userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-controller-history-"));
|
||||
tempDirs.push(electronState.userDataDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("AppController history retention", () => {
|
||||
it("remains startable when session-history cleanup returns EPERM", () => {
|
||||
const paths = createStoragePaths(path.join(electronState.userDataDir, "runtime"));
|
||||
saveSettings(paths, { ...defaultSettings(), historyRetentionMode: "session" });
|
||||
saveHistory(paths, [createHistoryEntry(path.join(electronState.userDataDir, "out"))]);
|
||||
const unlinkSpy = failHistoryDeletion(paths.historyFile);
|
||||
|
||||
let controller!: AppController;
|
||||
expect(() => {
|
||||
controller = new AppController();
|
||||
}).not.toThrow();
|
||||
expect(loadHistory(paths)).toHaveLength(1);
|
||||
|
||||
unlinkSpy.mockRestore();
|
||||
controller.shutdown();
|
||||
});
|
||||
|
||||
it("rolls back a retention update when history deletion fails", () => {
|
||||
const paths = createStoragePaths(path.join(electronState.userDataDir, "runtime"));
|
||||
saveSettings(paths, { ...defaultSettings(), historyRetentionMode: "permanent" });
|
||||
saveHistory(paths, [createHistoryEntry(path.join(electronState.userDataDir, "out"))]);
|
||||
const controller = new AppController();
|
||||
const unlinkSpy = failHistoryDeletion(paths.historyFile);
|
||||
|
||||
const result = controller.updateSettings({ historyRetentionMode: "session" });
|
||||
|
||||
expect(result.historyRetentionMode).toBe("permanent");
|
||||
expect(controller.getSettings().historyRetentionMode).toBe("permanent");
|
||||
expect(loadSettings(paths).historyRetentionMode).toBe("permanent");
|
||||
expect(loadHistory(paths)).toHaveLength(1);
|
||||
|
||||
unlinkSpy.mockRestore();
|
||||
controller.shutdown();
|
||||
});
|
||||
|
||||
it("keeps manual history deletion failures visible without auditing false success", () => {
|
||||
const paths = createStoragePaths(path.join(electronState.userDataDir, "runtime"));
|
||||
saveSettings(paths, { ...defaultSettings(), historyRetentionMode: "permanent" });
|
||||
saveHistory(paths, [createHistoryEntry(path.join(electronState.userDataDir, "out"))]);
|
||||
const controller = new AppController();
|
||||
const audit = vi.fn();
|
||||
(controller as unknown as { audit: typeof audit }).audit = audit;
|
||||
const unlinkSpy = failHistoryDeletion(paths.historyFile);
|
||||
|
||||
expect(() => controller.clearHistory()).toThrow(/EPERM/);
|
||||
expect(loadHistory(paths)).toHaveLength(1);
|
||||
expect(audit.mock.calls.map((call) => call[1])).toEqual(["Verlauf konnte nicht geleert werden"]);
|
||||
|
||||
unlinkSpy.mockRestore();
|
||||
controller.shutdown();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,21 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clampContextMenuPosition,
|
||||
ContextMenu,
|
||||
getContextMenuKeyboardAction,
|
||||
getContextMenuSubmenuKeyboardAction,
|
||||
getContextSubmenuPosition
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clampContextMenuPosition,
|
||||
ContextMenu,
|
||||
getContextMenuKeyboardAction,
|
||||
getContextMenuSubmenuKeyboardAction,
|
||||
getContextSubmenuPosition,
|
||||
observeContextMenuPosition
|
||||
} from "../src/renderer/ui/ContextMenu";
|
||||
|
||||
const contextMenuSource = readFileSync(new URL("../src/renderer/ui/ContextMenu.tsx", import.meta.url), "utf8");
|
||||
const stylesSource = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ContextMenu", () => {
|
||||
it("renders menu semantics and marks buttons as menu items", () => {
|
||||
@@ -40,7 +45,7 @@ describe("ContextMenu", () => {
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
it("clamps every edge to the visible viewport", () => {
|
||||
it("clamps every edge to the visible viewport", () => {
|
||||
expect(clampContextMenuPosition(790, 590, 220, 180, 800, 600)).toEqual({ x: 580, y: 420 });
|
||||
expect(clampContextMenuPosition(-12, -8, 220, 180, 800, 600)).toEqual({ x: 0, y: 0 });
|
||||
expect(clampContextMenuPosition(40, 60, 220, 180, 800, 600)).toEqual({ x: 40, y: 60 });
|
||||
@@ -104,6 +109,29 @@ describe("ContextMenu", () => {
|
||||
)).toEqual({ x: 590, y: 450 });
|
||||
});
|
||||
|
||||
it("repositions an open menu immediately when the viewport is resized", () => {
|
||||
const viewport = Object.assign(new EventTarget(), { innerWidth: 800, innerHeight: 600 });
|
||||
vi.stubGlobal("window", viewport);
|
||||
const menu = {
|
||||
getBoundingClientRect: () => ({ width: 200, height: 150 })
|
||||
} as Pick<HTMLElement, "getBoundingClientRect">;
|
||||
const onPosition = vi.fn();
|
||||
const stop = observeContextMenuPosition(menu, () => ({ x: 700, y: 550 }), onPosition);
|
||||
|
||||
expect(onPosition).toHaveBeenLastCalledWith({ x: 600, y: 450 });
|
||||
|
||||
viewport.innerWidth = 500;
|
||||
viewport.innerHeight = 350;
|
||||
viewport.dispatchEvent(new Event("resize"));
|
||||
|
||||
expect(onPosition).toHaveBeenLastCalledWith({ x: 300, y: 200 });
|
||||
expect(onPosition).toHaveBeenCalledTimes(2);
|
||||
|
||||
stop();
|
||||
viewport.dispatchEvent(new Event("resize"));
|
||||
expect(onPosition).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps submenus hidden until their viewport-safe position is ready", () => {
|
||||
expect(contextMenuSource).toContain('position.ready && position.sourceX === x && position.sourceY === y ? "is-positioned" : ""');
|
||||
expect(contextMenuSource).toContain('parts.items.classList.add("is-positioned")');
|
||||
|
||||
+200
-11
@@ -33,7 +33,7 @@ describe("leadProviderChainWith", () => {
|
||||
});
|
||||
|
||||
describe("debrid service", () => {
|
||||
it("falls back to Mega web when Real-Debrid fails", async () => {
|
||||
it("falls back to Mega web when Real-Debrid fails", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
@@ -1294,7 +1294,7 @@ describe("debrid service", () => {
|
||||
await expect(service.unrestrictLink("https://rapidgator.net/file/missing-alldebrid-web")).rejects.toThrow(/nicht konfiguriert/i);
|
||||
});
|
||||
|
||||
it("uses Real-Debrid web path when enabled", async () => {
|
||||
it("uses Real-Debrid web path when enabled", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
@@ -1321,10 +1321,49 @@ describe("debrid service", () => {
|
||||
expect(result.directUrl).toContain("real-debrid.com/d/");
|
||||
expect(result.fileSize).toBe(5678);
|
||||
expect(realDebridWeb).toHaveBeenCalledTimes(1);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("treats Real-Debrid web mode as not configured when callback is unavailable and no token", async () => {
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Real-Debrid", "realdebrid", "realDebridWebUnrestrict", { token: "rd-token", realDebridUseWebLogin: true }],
|
||||
["AllDebrid", "alldebrid", "allDebridWebUnrestrict", { allDebridToken: "ad-token", allDebridUseWebLogin: true }],
|
||||
["BestDebrid", "bestdebrid", "bestDebridWebUnrestrict", { bestToken: "best-token", bestDebridUseWebLogin: true }]
|
||||
] as const)("aborts a hanging %s Web provider callback even when it ignores the signal", async (_label, providerName, callbackName, settingsPatch) => {
|
||||
let markStarted: () => void = () => {};
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
const providerCallback = vi.fn(() => {
|
||||
markStarted();
|
||||
return new Promise<never>(() => {});
|
||||
});
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
...settingsPatch,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: providerName,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
const service = new DebridService(settings, { [callbackName]: providerCallback });
|
||||
const controller = new AbortController();
|
||||
const outcome = service.unrestrictLink("https://rapidgator.net/file/hanging-web-provider", controller.signal).then(
|
||||
() => "fulfilled",
|
||||
(error: unknown) => String(error)
|
||||
);
|
||||
|
||||
await started;
|
||||
controller.abort("pause");
|
||||
const result = await Promise.race([
|
||||
outcome,
|
||||
new Promise<string>((resolve) => setTimeout(() => resolve("timeout"), 100))
|
||||
]);
|
||||
|
||||
expect(result).toMatch(/aborted/i);
|
||||
});
|
||||
|
||||
it("treats Real-Debrid web mode as not configured when callback is unavailable and no token", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
@@ -1496,6 +1535,47 @@ describe("debrid service", () => {
|
||||
expect(megaWeb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports each provider as soon as its conversion attempt starts", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
megaLogin: "user",
|
||||
megaPassword: "pass",
|
||||
megaCredentials: "user:pass",
|
||||
providerOrder: ["realdebrid", "megadebrid"] as const,
|
||||
autoProviderFallback: true
|
||||
};
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
||||
return new Response(JSON.stringify({ error: "traffic_limit" }), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
const service = new DebridService(settings, {
|
||||
megaWebUnrestrict: vi.fn(async () => ({
|
||||
fileName: "file.bin",
|
||||
directUrl: "https://mega-web.example/file.bin",
|
||||
fileSize: null,
|
||||
retriesUsed: 0
|
||||
}))
|
||||
});
|
||||
const attempts: string[] = [];
|
||||
|
||||
await service.unrestrictLink(
|
||||
"https://rapidgator.net/file/provider-attempts.rar.html",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
(provider) => attempts.push(provider)
|
||||
);
|
||||
|
||||
expect(attempts).toEqual(["realdebrid", "megadebrid"]);
|
||||
});
|
||||
|
||||
it("uses Mega web fallback when API fails", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
@@ -2225,7 +2305,7 @@ describe("debrid service", () => {
|
||||
expect(usedIds).toEqual(new Array(5).fill(getMegaDebridAccountId("user1")));
|
||||
}, 30000);
|
||||
|
||||
it("wechselt erst nach einem Schwung Links auf den naechsten Account", async () => {
|
||||
it("wechselt erst nach einem Schwung Links auf den naechsten Account", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "", bestToken: "", allDebridToken: "",
|
||||
@@ -2249,10 +2329,119 @@ describe("debrid service", () => {
|
||||
}
|
||||
|
||||
expect(usedIds.slice(0, MEGA_DEBRID_STICKY_LINKS)).toEqual(new Array(MEGA_DEBRID_STICKY_LINKS).fill(getMegaDebridAccountId("user1")));
|
||||
expect(usedIds[MEGA_DEBRID_STICKY_LINKS]).toBe(getMegaDebridAccountId("user2"));
|
||||
}, 30000);
|
||||
|
||||
it("ueberspringt einen gesperrten Account und bleibt dann klebrig beim naechsten", async () => {
|
||||
expect(usedIds[MEGA_DEBRID_STICKY_LINKS]).toBe(getMegaDebridAccountId("user2"));
|
||||
}, 30000);
|
||||
|
||||
it("keeps the Mega-Debrid Web cursor independent from API rotation", async () => {
|
||||
const apiSettings = {
|
||||
...defaultSettings(),
|
||||
token: "", bestToken: "", allDebridToken: "",
|
||||
megaCredentials: "cursor-api-1:pass1\ncursor-api-2:pass2",
|
||||
megaDebridApiCredentials: "cursor-api-1:pass1\ncursor-api-2:pass2",
|
||||
megaDebridWebCredentials: "",
|
||||
megaDebridApiEnabled: true,
|
||||
megaDebridWebEnabled: false,
|
||||
providerOrder: [] as const, providerPrimary: "megadebrid-api" as const,
|
||||
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = String(input);
|
||||
if (url.includes("action=connectUser")) {
|
||||
return new Response(JSON.stringify({ response_code: "ok", token: "cursor-api-token" }), { status: 200 });
|
||||
}
|
||||
if (url.includes("action=getLink")) {
|
||||
return new Response(JSON.stringify({ response_code: "ok", debridLink: "https://mega-cdn.example/cursor.rar", filename: "cursor.rar" }), { status: 200 });
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const apiService = new DebridService(apiSettings);
|
||||
for (let index = 0; index < MEGA_DEBRID_STICKY_LINKS; index += 1) {
|
||||
await apiService.unrestrictLink(`https://rapidgator.net/file/api-cursor-${index}`);
|
||||
}
|
||||
|
||||
const webLogins: string[] = [];
|
||||
const webSettings = {
|
||||
...defaultSettings(),
|
||||
token: "", bestToken: "", allDebridToken: "",
|
||||
megaCredentials: "cursor-web-1:pass1\ncursor-web-2:pass2",
|
||||
megaDebridApiCredentials: "",
|
||||
megaDebridWebCredentials: "cursor-web-1:pass1\ncursor-web-2:pass2",
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: true,
|
||||
providerOrder: [] as const, providerPrimary: "megadebrid-web" as const,
|
||||
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
const webService = new DebridService(webSettings, {
|
||||
megaWebUnrestrict: async (_link, _signal, account) => {
|
||||
webLogins.push(account?.login || "");
|
||||
return { fileName: "web-cursor.rar", directUrl: "https://mega-web.example/web-cursor.rar", fileSize: null, retriesUsed: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
await webService.unrestrictLink("https://rapidgator.net/file/web-cursor");
|
||||
|
||||
expect(webLogins).toEqual(["cursor-web-1"]);
|
||||
}, 30000);
|
||||
|
||||
it("keeps the Mega-Debrid Web sticky counter independent from API successes", async () => {
|
||||
const apiSettings = {
|
||||
...defaultSettings(),
|
||||
token: "", bestToken: "", allDebridToken: "",
|
||||
megaCredentials: "sticky-api-1:pass1\nsticky-api-2:pass2",
|
||||
megaDebridApiCredentials: "sticky-api-1:pass1\nsticky-api-2:pass2",
|
||||
megaDebridWebCredentials: "",
|
||||
megaDebridApiEnabled: true,
|
||||
megaDebridWebEnabled: false,
|
||||
providerOrder: [] as const, providerPrimary: "megadebrid-api" as const,
|
||||
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = String(input);
|
||||
if (url.includes("action=connectUser")) {
|
||||
return new Response(JSON.stringify({ response_code: "ok", token: "sticky-api-token" }), { status: 200 });
|
||||
}
|
||||
if (url.includes("action=getLink")) {
|
||||
return new Response(JSON.stringify({ response_code: "ok", debridLink: "https://mega-cdn.example/sticky.rar", filename: "sticky.rar" }), { status: 200 });
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const apiService = new DebridService(apiSettings);
|
||||
for (let index = 0; index < MEGA_DEBRID_STICKY_LINKS - 1; index += 1) {
|
||||
await apiService.unrestrictLink(`https://rapidgator.net/file/api-sticky-${index}`);
|
||||
}
|
||||
|
||||
const webLogins: string[] = [];
|
||||
const webSettings = {
|
||||
...defaultSettings(),
|
||||
token: "", bestToken: "", allDebridToken: "",
|
||||
megaCredentials: "sticky-web-1:pass1\nsticky-web-2:pass2",
|
||||
megaDebridApiCredentials: "",
|
||||
megaDebridWebCredentials: "sticky-web-1:pass1\nsticky-web-2:pass2",
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: true,
|
||||
providerOrder: [] as const, providerPrimary: "megadebrid-web" as const,
|
||||
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
const webService = new DebridService(webSettings, {
|
||||
megaWebUnrestrict: async (_link, _signal, account) => {
|
||||
webLogins.push(account?.login || "");
|
||||
return { fileName: "web-sticky.rar", directUrl: "https://mega-web.example/web-sticky.rar", fileSize: null, retriesUsed: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
await webService.unrestrictLink("https://rapidgator.net/file/web-sticky-1");
|
||||
await webService.unrestrictLink("https://rapidgator.net/file/web-sticky-2");
|
||||
|
||||
expect(webLogins).toEqual(["sticky-web-1", "sticky-web-1"]);
|
||||
}, 30000);
|
||||
|
||||
it("ueberspringt einen gesperrten Account und bleibt dann klebrig beim naechsten", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "", bestToken: "", allDebridToken: "",
|
||||
|
||||
+308
-20
@@ -188,12 +188,12 @@ describe("disk write recovery", () => {
|
||||
(manager as any).debridService.unrestrictLink = async () => ({
|
||||
fileName: "reserve-download.bin",
|
||||
directUrl: "https://dummy/reserve-download",
|
||||
fileSize: 1_024,
|
||||
fileSize: null,
|
||||
retriesUsed: 0,
|
||||
provider: "realdebrid",
|
||||
providerLabel: "Real-Debrid"
|
||||
});
|
||||
globalThis.fetch = vi.fn(async () => new Response(Buffer.alloc(16, 1), {
|
||||
globalThis.fetch = vi.fn(async () => new Response(Buffer.alloc(1_024, 1), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-length": "1024",
|
||||
@@ -232,6 +232,107 @@ describe("disk write recovery", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("releases an active download when Stop aborts a blocked post-header disk reservation", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-reserve-abort-"));
|
||||
tempDirs.push(root);
|
||||
const session = emptySession();
|
||||
const packageId = "reserve-abort-package";
|
||||
const itemId = "reserve-abort-item";
|
||||
const outputDir = path.join(root, "downloads", "reserve-abort");
|
||||
const createdAt = Date.now();
|
||||
session.running = true;
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId,
|
||||
name: "reserve-abort",
|
||||
outputDir,
|
||||
extractDir: path.join(root, "extract", "reserve-abort"),
|
||||
status: "downloading",
|
||||
itemIds: [itemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
session.items[itemId] = {
|
||||
id: itemId,
|
||||
packageId,
|
||||
url: "https://rapidgator.net/file/reserve-abort",
|
||||
provider: "realdebrid",
|
||||
status: "downloading",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
progressPercent: 0,
|
||||
fileName: "reserve-abort.bin",
|
||||
targetPath: "",
|
||||
resumable: true,
|
||||
attempts: 0,
|
||||
lastError: "",
|
||||
fullStatus: "Download läuft",
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
let reservationStarted = false;
|
||||
let statCalls = 0;
|
||||
const manager = new DownloadManager(
|
||||
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), autoExtract: false },
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
(manager as any).diskReservations = new DiskReservationCoordinator({
|
||||
safetyBytes: 0,
|
||||
statVolume: async (targetPath) => {
|
||||
statCalls += 1;
|
||||
reservationStarted = true;
|
||||
if (statCalls === 1) {
|
||||
return await new Promise(() => undefined);
|
||||
}
|
||||
return { path: targetPath, volumeKey: "follow-up-volume", freeBytes: 4_096, totalBytes: 8_192 };
|
||||
}
|
||||
});
|
||||
(manager as any).debridService.unrestrictLink = async () => ({
|
||||
fileName: "reserve-abort.bin",
|
||||
directUrl: "https://dummy/reserve-abort",
|
||||
fileSize: null,
|
||||
retriesUsed: 0,
|
||||
provider: "realdebrid",
|
||||
providerLabel: "Real-Debrid"
|
||||
});
|
||||
const response = new Response(Buffer.alloc(1_024, 3), {
|
||||
status: 200,
|
||||
headers: { "content-length": "1024", "accept-ranges": "bytes" }
|
||||
});
|
||||
const cancelSpy = vi.spyOn(response.body!, "cancel");
|
||||
globalThis.fetch = vi.fn(async () => response) as typeof fetch;
|
||||
const active = { itemId, packageId, abortController: new AbortController(), abortReason: "none", resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0 };
|
||||
(manager as any).activeTasks.set(itemId, active);
|
||||
|
||||
const processing = (manager as any).processItem(active) as Promise<void>;
|
||||
await waitFor(() => reservationStarted, 2_000);
|
||||
active.abortReason = "stop";
|
||||
active.abortController.abort("stop");
|
||||
|
||||
await expect(Promise.race([
|
||||
processing.then(() => "released"),
|
||||
new Promise<string>((resolve) => setTimeout(() => resolve("blocked"), 500))
|
||||
])).resolves.toBe("released");
|
||||
expect(cancelSpy).toHaveBeenCalled();
|
||||
const followUpLease = await Promise.race([
|
||||
(manager as any).diskReservations.reserve({
|
||||
phase: "download",
|
||||
ownerId: "follow-up",
|
||||
targetPath: path.join(root, "follow-up.bin"),
|
||||
requiredBytes: 512,
|
||||
alreadyPresentBytes: 0
|
||||
}),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 500))
|
||||
]);
|
||||
expect(followUpLease).not.toBeNull();
|
||||
followUpLease?.release();
|
||||
});
|
||||
|
||||
it("keeps disk-wait downloads out of the scheduler until their capacity retry is due", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-reserve-resume-"));
|
||||
tempDirs.push(root);
|
||||
@@ -990,6 +1091,113 @@ describe("download manager", () => {
|
||||
expect(failures.has("realdebrid")).toBe(true);
|
||||
});
|
||||
|
||||
it("aborts active Real-Debrid validation when the provider is disabled live", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-real-live-disable-"));
|
||||
tempDirs.push(root);
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
allDebridToken: "ad-token",
|
||||
providerOrder: ["realdebrid", "alldebrid"] as const
|
||||
};
|
||||
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
||||
manager.addPackages([{ name: "live-disable", links: ["https://rapidgator.net/file/live-disable"] }]);
|
||||
const session = (manager as any).session;
|
||||
const item = Object.values(session.items)[0] as any;
|
||||
item.provider = null;
|
||||
item.status = "validating";
|
||||
session.running = true;
|
||||
const active = {
|
||||
itemId: item.id,
|
||||
packageId: item.packageId,
|
||||
abortController: new AbortController(),
|
||||
abortReason: "none",
|
||||
resumable: true,
|
||||
nonResumableCounted: false
|
||||
};
|
||||
(manager as any).activeTasks.set(item.id, active);
|
||||
const failures = (manager as any).providerFailures as Map<string, unknown>;
|
||||
failures.set("realdebrid:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
||||
failures.set("alldebrid:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
||||
|
||||
manager.setSettings({
|
||||
...settings,
|
||||
disabledProviders: ["realdebrid"]
|
||||
});
|
||||
|
||||
expect(active.abortController.signal.aborted).toBe(true);
|
||||
expect(active.abortReason).toBe("settings_refresh");
|
||||
expect(failures.has("realdebrid:rapidgator.net")).toBe(false);
|
||||
expect(failures.has("alldebrid:rapidgator.net")).toBe(true);
|
||||
});
|
||||
|
||||
it("aborts the active fallback provider when its settings change live", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-fallback-live-disable-"));
|
||||
tempDirs.push(root);
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
allDebridToken: "ad-token",
|
||||
providerOrder: ["realdebrid", "alldebrid"] as const,
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: false,
|
||||
maxParallel: 1
|
||||
};
|
||||
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
||||
let fallbackStarted = false;
|
||||
let providerSignal: AbortSignal | undefined;
|
||||
(manager as any).debridService.unrestrictLink = async (
|
||||
_link: string,
|
||||
signal?: AbortSignal,
|
||||
_settingsSnapshot?: AppSettings,
|
||||
_preferredLeadProvider?: DebridProvider | null,
|
||||
onProviderAttempt?: (provider: DebridProvider) => void
|
||||
) => {
|
||||
providerSignal = signal;
|
||||
onProviderAttempt?.("alldebrid");
|
||||
fallbackStarted = true;
|
||||
return await new Promise((_resolve, reject) => {
|
||||
const onAbort = (): void => reject(new Error("aborted:settings-refresh"));
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
};
|
||||
manager.addPackages([{ name: "fallback-live-disable", links: ["https://rapidgator.net/file/fallback-live-disable"] }]);
|
||||
|
||||
await manager.start();
|
||||
await waitFor(() => fallbackStarted, 5_000);
|
||||
manager.setSettings({ ...settings, disabledProviders: ["alldebrid"] });
|
||||
|
||||
expect(providerSignal?.aborted).toBe(true);
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("clears a provider cooldown when the provider is re-enabled live", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-real-live-reenable-"));
|
||||
tempDirs.push(root);
|
||||
const settings: AppSettings = {
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
disabledProviders: ["realdebrid"]
|
||||
};
|
||||
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
||||
const failures = (manager as any).providerFailures as Map<string, unknown>;
|
||||
failures.set("realdebrid", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
||||
failures.set("realdebrid:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
||||
|
||||
manager.setSettings({
|
||||
...settings,
|
||||
disabledProviders: []
|
||||
});
|
||||
|
||||
expect(failures.has("realdebrid")).toBe(false);
|
||||
expect(failures.has("realdebrid:rapidgator.net")).toBe(false);
|
||||
});
|
||||
|
||||
it("invalidates only the Mega-Debrid Web session when Web credentials change", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-web-session-refresh-"));
|
||||
tempDirs.push(root);
|
||||
@@ -3840,7 +4048,7 @@ describe("download manager", () => {
|
||||
expect(item?.status).toBe("failed");
|
||||
expect(downloadCalls).toBeGreaterThan(4);
|
||||
expect(downloadCalls).toBeLessThan(30);
|
||||
expect(item.http416FreshRestarts).toBeUndefined();
|
||||
expect(item.http416FreshRestarts).toBe(2);
|
||||
} finally {
|
||||
if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; }
|
||||
}
|
||||
@@ -3907,7 +4115,7 @@ describe("download manager", () => {
|
||||
await (manager as any).escalateHttp416OrFail(item, active, "", "HTTP 416");
|
||||
|
||||
expect(item.status).toBe("failed");
|
||||
expect(item.http416FreshRestarts).toBeUndefined();
|
||||
expect(item.http416FreshRestarts).toBe(2);
|
||||
});
|
||||
|
||||
it("retries HTTP 416 in-session when using Debrid-Link API and then completes", async () => {
|
||||
@@ -6304,7 +6512,7 @@ describe("download manager", () => {
|
||||
expect(snapshot.canStart).toBe(true);
|
||||
});
|
||||
|
||||
it("requeues failed HTTP 416 items automatically on startup", async () => {
|
||||
it("does not requeue an exhausted HTTP 416 item after restart", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -6366,19 +6574,19 @@ describe("download manager", () => {
|
||||
|
||||
await manager.waitForStartupRecovery();
|
||||
|
||||
const snapshot = manager.getSnapshot();
|
||||
const item = snapshot.session.items[itemId];
|
||||
expect(item?.status).toBe("queued");
|
||||
expect(item?.attempts).toBe(0);
|
||||
expect(item?.downloadedBytes).toBe(0);
|
||||
expect(item?.progressPercent).toBe(0);
|
||||
expect(item?.fullStatus).toContain("Auto-Retry");
|
||||
const snapshot = manager.getSnapshot();
|
||||
const item = snapshot.session.items[itemId];
|
||||
expect(item?.status).toBe("failed");
|
||||
expect(item?.attempts).toBe(3);
|
||||
expect(item?.downloadedBytes).toBe(12 * 1024);
|
||||
expect(item?.progressPercent).toBe(100);
|
||||
expect(item?.fullStatus).toContain("Fehler");
|
||||
expect(item?.http416FreshRestarts).toBe(2);
|
||||
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
|
||||
expect(fs.existsSync(targetPath)).toBe(false);
|
||||
});
|
||||
expect(snapshot.session.packages[packageId]?.status).toBe("failed");
|
||||
expect(fs.existsSync(targetPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a locked HTTP 416 partial intact and persists a pending clean reset", async () => {
|
||||
it("keeps an exhausted locked HTTP 416 partial intact without requeueing it after restart", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -6450,17 +6658,18 @@ describe("download manager", () => {
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
await waitFor(() => manager.getSnapshot().session.items[itemId]?.status === "queued", 2000);
|
||||
await manager.waitForStartupRecovery();
|
||||
|
||||
const item = manager.getSnapshot().session.items[itemId];
|
||||
expect(item).toMatchObject({
|
||||
status: "queued",
|
||||
status: "failed",
|
||||
downloadedBytes: partialBytes,
|
||||
totalBytes: partialBytes * 2,
|
||||
progressPercent: 50,
|
||||
resumeResetPending: true,
|
||||
fullStatus: "Warte auf Teildatei-Freigabe"
|
||||
http416FreshRestarts: 2,
|
||||
fullStatus: "Fehler: Error: HTTP 416"
|
||||
});
|
||||
expect(item?.resumeResetPending).toBeUndefined();
|
||||
expect(fs.existsSync(targetPath)).toBe(true);
|
||||
expect(fs.statSync(targetPath).size).toBe(partialBytes);
|
||||
} finally {
|
||||
@@ -10267,6 +10476,85 @@ describe("download manager", () => {
|
||||
expect(packageEntry.cleanedTotalBytes).toBe(1_000);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["on_start", "on_start"],
|
||||
["retroactive immediate", "never"]
|
||||
] as const)("preserves completed package progress during %s cleanup", (_name, initialPolicy) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
const session = emptySession();
|
||||
const packageId = `cleanup-${initialPolicy}-package`;
|
||||
const completedItemId = `cleanup-${initialPolicy}-completed`;
|
||||
const queuedItemId = `cleanup-${initialPolicy}-queued`;
|
||||
const createdAt = Date.now();
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId,
|
||||
name: `cleanup-${initialPolicy}`,
|
||||
outputDir: path.join(root, "downloads", `cleanup-${initialPolicy}`),
|
||||
extractDir: path.join(root, "extract", `cleanup-${initialPolicy}`),
|
||||
status: "queued",
|
||||
itemIds: [completedItemId, queuedItemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
session.items[completedItemId] = {
|
||||
id: completedItemId,
|
||||
packageId,
|
||||
url: "https://dummy/completed",
|
||||
provider: "realdebrid",
|
||||
status: "completed",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 1_000,
|
||||
totalBytes: 1_000,
|
||||
progressPercent: 100,
|
||||
fileName: "completed.rar",
|
||||
targetPath: path.join(root, "downloads", `cleanup-${initialPolicy}`, "completed.rar"),
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Entpackt - Fertig",
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
session.items[queuedItemId] = {
|
||||
...session.items[completedItemId],
|
||||
id: queuedItemId,
|
||||
url: "https://dummy/queued",
|
||||
status: "queued",
|
||||
downloadedBytes: 0,
|
||||
totalBytes: 2_000,
|
||||
progressPercent: 0,
|
||||
fileName: "queued.rar",
|
||||
targetPath: path.join(root, "downloads", `cleanup-${initialPolicy}`, "queued.rar"),
|
||||
fullStatus: "Wartet"
|
||||
};
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: true,
|
||||
completedCleanupPolicy: initialPolicy
|
||||
};
|
||||
const manager = new DownloadManager(settings, session, createStoragePaths(path.join(root, "state")));
|
||||
|
||||
if (initialPolicy === "never") {
|
||||
manager.setSettings({ ...settings, completedCleanupPolicy: "immediate" });
|
||||
}
|
||||
|
||||
const packageEntry = manager.getSnapshot().session.packages[packageId];
|
||||
expect(packageEntry.itemIds).toEqual([queuedItemId]);
|
||||
expect(packageEntry.cleanedCompletedItemCount).toBe(1);
|
||||
expect(packageEntry.cleanedExtractedItemCount).toBe(1);
|
||||
expect(packageEntry.cleanedDownloadedBytes).toBe(1_000);
|
||||
expect(packageEntry.cleanedTotalBytes).toBe(1_000);
|
||||
expect(packageEntry.cleanedUrls).toEqual(["https://dummy/completed"]);
|
||||
expect(packageEntry.cleanedProviders).toEqual(["realdebrid"]);
|
||||
});
|
||||
|
||||
it("includes immediately cleaned items in the final package history entry", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -1412,6 +1412,13 @@ describe("download table row contracts", () => {
|
||||
expect(compactDownloadStatus("Warte auf Festplatte (Mega-Debrid Web)")).toBe("Warte auf Festplatte");
|
||||
});
|
||||
|
||||
it("keeps provider and hybrid extraction diagnostics out of the visible status", () => {
|
||||
expect(compactDownloadStatus("Fehler: Mega-Debrid API: Kein Server verfügbar")).toBe("Fehler");
|
||||
expect(compactDownloadStatus("Error: Mega-Debrid Web: No server available")).toBe("Error");
|
||||
expect(compactDownloadStatus("Entpacken - Error")).toBe("Entpack-Fehler");
|
||||
expect(compactDownloadStatus("Extracting - Error")).toBe("Extraction error");
|
||||
});
|
||||
|
||||
it("prioritizes disk waits and extraction errors in package status", () => {
|
||||
const diskPackage = pkg("disk-package", "Disk package", ["disk-item", "active-item"]);
|
||||
const diskHtml = renderToStaticMarkup(PackageCardContent({
|
||||
|
||||
@@ -159,19 +159,25 @@ describe("history model", () => {
|
||||
expect(filterHistoryRows(searchable, "all", "d", now).map((row) => row.id)).toEqual(["new", "old"]);
|
||||
});
|
||||
|
||||
it("derives hosters only from valid URL hostnames and clamps the calculated start time", () => {
|
||||
expect(deriveHistoryHoster(["https://rapidgator.net/a", "https://rapidgator.net/b", "https://ddownload.com/c", "not a url"])).toBe("rapidgator.net, ddownload.com");
|
||||
expect(deriveHistoryHoster([])).toBe("—");
|
||||
it("derives hosters only from valid URL hostnames and clamps the calculated start time", () => {
|
||||
expect(deriveHistoryHoster(["https://rapidgator.net/a", "https://rapidgator.net/b", "https://ddownload.com/c", "not a url"])).toBe("rapidgator.net, ddownload.com");
|
||||
expect(deriveHistoryHoster(["https://rapidgator.net/a", "https://rg.to/b", "https://cdn.rg.to/c"])).toBe("rapidgator.net");
|
||||
expect(deriveHistoryHoster([])).toBe("—");
|
||||
expect(deriveHistoryHoster(undefined)).toBe("—");
|
||||
expect(deriveHistoryStartAt(entry({ id: "start", name: "Start", completedAt: 20_000, durationSeconds: 3 }))).toBe(17_000);
|
||||
expect(deriveHistoryStartAt(entry({ id: "clamped", name: "Clamp", completedAt: 2_000, durationSeconds: 3 }))).toBe(0);
|
||||
|
||||
const row = filterHistoryRows([entry({ id: "provider", name: "Provider", provider: "realdebrid", urls: [] })], "all", "", now)[0];
|
||||
expect(row.hoster).toBe("—");
|
||||
expect(row.providerLabel).toBe("Real-Debrid");
|
||||
});
|
||||
|
||||
it("prunes removed ids and preserves the original set instance when every id survives", () => {
|
||||
expect(row.providerLabel).toBe("Real-Debrid");
|
||||
});
|
||||
|
||||
it("keeps unrelated hostnames distinct", () => {
|
||||
expect(deriveHistoryHoster(["https://files.example.com/a", "https://cdn.example.net/b"])).toBe("files.example.com, cdn.example.net");
|
||||
expect(deriveHistoryHoster(["https://foo.co.uk/a", "https://bar.co.uk/b"])).toBe("foo.co.uk, bar.co.uk");
|
||||
});
|
||||
|
||||
it("prunes removed ids and preserves the original set instance when every id survives", () => {
|
||||
const stable = new Set(["today", "week"]);
|
||||
expect(pruneHistoryIds(stable, ["today", "week", "older"])).toBe(stable);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
mockLoadURL,
|
||||
mockShow,
|
||||
mockFocus,
|
||||
mockClose,
|
||||
mockSetWindowOpenHandler,
|
||||
mockSetPermissionRequestHandler
|
||||
} = vi.hoisted(() => {
|
||||
@@ -73,6 +74,7 @@ const {
|
||||
mockLoadURL: loadURL,
|
||||
mockShow: show,
|
||||
mockFocus: focus,
|
||||
mockClose: browserWindow.close,
|
||||
mockSetWindowOpenHandler: setWindowOpenHandler,
|
||||
mockSetPermissionRequestHandler: setPermissionRequestHandler
|
||||
};
|
||||
@@ -120,7 +122,7 @@ describe("realdebrid-web", () => {
|
||||
.toBe("ghi789");
|
||||
});
|
||||
|
||||
it("uses the already logged-in browser window to warm the token cache before unrestricting", async () => {
|
||||
it("uses the already logged-in browser window to warm the token cache before unrestricting", async () => {
|
||||
const apiFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
download: "https://cdn.real-debrid.example/file.bin",
|
||||
filename: "file.bin",
|
||||
@@ -160,6 +162,21 @@ describe("realdebrid-web", () => {
|
||||
expect(mockSessionFetch).not.toHaveBeenCalled();
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
expect(apiFetch.mock.calls[0]?.[0]).toBe("https://api.real-debrid.com/rest/1.0/unrestrict/link");
|
||||
expect(mockBrowserWindow.webContents.executeJavaScript).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
expect(mockBrowserWindow.webContents.executeJavaScript).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces a login window after its renderer process crashes", async () => {
|
||||
const fallback = new RealDebridWebFallback(() => true);
|
||||
|
||||
await fallback.openLoginWindow();
|
||||
const crashHandler = mockBrowserWindow.webContents.on.mock.calls.find(([event]) => event === "render-process-gone")?.[1];
|
||||
|
||||
expect(crashHandler).toBeTypeOf("function");
|
||||
crashHandler?.();
|
||||
await fallback.openLoginWindow();
|
||||
|
||||
expect(mockClose).toHaveBeenCalledTimes(1);
|
||||
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(2);
|
||||
expect(mockLoadURL).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,4 +128,54 @@ describe("renderer state serialization", () => {
|
||||
expect(serialized).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
|
||||
it("assigns mode-specific statuses to API and Web rows with the same Mega-Debrid login", () => {
|
||||
const login = "shared-status@example.test";
|
||||
const baseId = getMegaDebridAccountId(login);
|
||||
const apiStatus = {
|
||||
accountId: `${baseId}:api`,
|
||||
provider: "megadebrid" as const,
|
||||
label: "Account 1",
|
||||
maskedLogin: "sh***st",
|
||||
valid: true,
|
||||
isPremium: true,
|
||||
premiumUntilMs: 2_000,
|
||||
message: "API valid",
|
||||
checkedAt: 1
|
||||
};
|
||||
const webStatus = {
|
||||
accountId: `${baseId}:web`,
|
||||
provider: "megadebrid" as const,
|
||||
label: "Account 1",
|
||||
maskedLogin: "sh***st",
|
||||
valid: false,
|
||||
isPremium: false,
|
||||
premiumUntilMs: null,
|
||||
message: "Web rejected",
|
||||
checkedAt: 1
|
||||
};
|
||||
const state = createRendererState({
|
||||
...defaultSettings(),
|
||||
megaCredentials: `${login}:api-pass`,
|
||||
megaDebridApiCredentials: `${login}:api-pass`,
|
||||
megaDebridWebCredentials: `${login}:web-pass`,
|
||||
megaDebridApiEnabled: true,
|
||||
megaDebridWebEnabled: true,
|
||||
debridAccountStatuses: {
|
||||
[apiStatus.accountId]: apiStatus,
|
||||
[webStatus.accountId]: webStatus
|
||||
}
|
||||
});
|
||||
|
||||
expect(state.accounts.find((account) => account.kind === "megadebrid-api")?.status).toMatchObject({
|
||||
accountId: `${baseId}:api`,
|
||||
valid: true,
|
||||
message: "API valid"
|
||||
});
|
||||
expect(state.accounts.find((account) => account.kind === "megadebrid-web")?.status).toMatchObject({
|
||||
accountId: `${baseId}:web`,
|
||||
valid: false,
|
||||
message: "Web rejected"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,11 +8,15 @@ describe("live settings overlay", () => {
|
||||
it("keeps current Mega-Debrid counters and drops data for identities no longer configured", () => {
|
||||
const keepMegaId = getMegaDebridAccountId("keep@example.com");
|
||||
const removedMegaId = getMegaDebridAccountId("removed@example.com");
|
||||
const keepMegaApiStatusId = `${keepMegaId}:api`;
|
||||
const keepMegaWebStatusId = `${keepMegaId}:web`;
|
||||
const removedMegaApiStatusId = `${removedMegaId}:api`;
|
||||
const keepKeyId = getDebridLinkApiKeyId("keep-key");
|
||||
const removedKeyId = getDebridLinkApiKeyId("removed-key");
|
||||
const target = {
|
||||
...defaultSettings(),
|
||||
megaCredentials: "keep@example.com:pass",
|
||||
megaDebridApiCredentials: "keep@example.com:api-pass",
|
||||
megaDebridWebCredentials: "keep@example.com:web-pass",
|
||||
megaLogin: "keep@example.com",
|
||||
megaPassword: "pass",
|
||||
debridLinkApiKeys: "keep-key",
|
||||
@@ -29,7 +33,10 @@ describe("live settings overlay", () => {
|
||||
debridLinkApiKeyTotalUsageBytes: { [keepKeyId]: 5_000, [removedKeyId]: 6_000 },
|
||||
debridAccountStatuses: {
|
||||
[keepMegaId]: { accountId: keepMegaId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "ke***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 },
|
||||
[keepMegaApiStatusId]: { accountId: keepMegaApiStatusId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "ke***om", valid: false, isPremium: false, premiumUntilMs: null, message: "API ungültig", checkedAt: 2 },
|
||||
[keepMegaWebStatusId]: { accountId: keepMegaWebStatusId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "ke***om", valid: true, isPremium: true, premiumUntilMs: null, message: "Web gültig", checkedAt: 3 },
|
||||
[removedMegaId]: { accountId: removedMegaId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "re***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 },
|
||||
[removedMegaApiStatusId]: { accountId: removedMegaApiStatusId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "re***om", valid: true, isPremium: true, premiumUntilMs: null, message: "API gültig", checkedAt: 2 },
|
||||
[keepKeyId]: { accountId: keepKeyId, provider: "debridlink" as const, label: "Key 1", maskedLogin: "kee***key", valid: true, isPremium: false, premiumUntilMs: null, message: "Free", checkedAt: 1 },
|
||||
[removedKeyId]: { accountId: removedKeyId, provider: "debridlink" as const, label: "Key 2", maskedLogin: "rem***key", valid: true, isPremium: false, premiumUntilMs: null, message: "Free", checkedAt: 1 }
|
||||
}
|
||||
@@ -41,7 +48,14 @@ describe("live settings overlay", () => {
|
||||
expect(target.megaDebridAccountTotalUsageBytes).toEqual({ [keepMegaId]: 3_000 });
|
||||
expect(target.debridLinkApiKeyDailyUsageBytes).toEqual({ [keepKeyId]: 500 });
|
||||
expect(target.debridLinkApiKeyTotalUsageBytes).toEqual({ [keepKeyId]: 5_000 });
|
||||
expect(Object.keys(target.debridAccountStatuses).sort()).toEqual([keepKeyId, keepMegaId].sort());
|
||||
expect(Object.keys(target.debridAccountStatuses).sort()).toEqual([
|
||||
keepKeyId,
|
||||
keepMegaId,
|
||||
keepMegaApiStatusId,
|
||||
keepMegaWebStatusId
|
||||
].sort());
|
||||
expect(target.debridAccountStatuses[keepMegaApiStatusId]?.message).toBe("API ungültig");
|
||||
expect(target.debridAccountStatuses[keepMegaWebStatusId]?.message).toBe("Web gültig");
|
||||
expect(target.totalRuntimeAllTimeMs).toBe(9_000);
|
||||
});
|
||||
});
|
||||
|
||||
+83
-8
@@ -1,14 +1,14 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||
import { getMegaDebridAccountId, getMegaDebridAccountStatusId } from "../src/shared/mega-debrid-accounts";
|
||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||
import { AppSettings } from "../src/shared/types";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { configureCredentialProtector } from "../src/main/credential-protection";
|
||||
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
|
||||
import { addHistoryEntryForRetention, clearHistory, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
type SettingsSaveMode = "sync" | "async";
|
||||
@@ -29,8 +29,9 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -204,6 +205,49 @@ describe("settings storage", () => {
|
||||
expect(loaded.allDebridToken).toBe("all-token");
|
||||
});
|
||||
|
||||
it("preserves mode-specific Mega-Debrid account statuses across save and load", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const accountId = getMegaDebridAccountId("shared-login");
|
||||
const apiStatusId = getMegaDebridAccountStatusId(accountId, "api");
|
||||
const webStatusId = getMegaDebridAccountStatusId(accountId, "web");
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
rememberToken: true,
|
||||
megaDebridApiCredentials: "shared-login:api-password",
|
||||
megaDebridWebCredentials: "shared-login:web-password",
|
||||
debridAccountStatuses: {
|
||||
[apiStatusId]: {
|
||||
accountId: apiStatusId,
|
||||
provider: "megadebrid" as const,
|
||||
label: "API account",
|
||||
maskedLogin: "sh*******in",
|
||||
valid: false,
|
||||
isPremium: false,
|
||||
premiumUntilMs: null,
|
||||
message: "API login failed",
|
||||
checkedAt: 100
|
||||
},
|
||||
[webStatusId]: {
|
||||
accountId: webStatusId,
|
||||
provider: "megadebrid" as const,
|
||||
label: "Web account",
|
||||
maskedLogin: "sh*******in",
|
||||
valid: true,
|
||||
isPremium: true,
|
||||
premiumUntilMs: 200,
|
||||
message: "Web login succeeded",
|
||||
checkedAt: 101
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
saveSettings(paths, settings);
|
||||
|
||||
expect(loadSettings(paths).debridAccountStatuses).toEqual(settings.debridAccountStatuses);
|
||||
});
|
||||
|
||||
it.each(["sync", "async"] as const)("preserves the previous recoverable settings state during a %s save", async (mode) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
@@ -702,7 +746,7 @@ describe("settings storage", () => {
|
||||
expect(loadHistoryForRetention(paths, "never")).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears persisted history for session retention mode", () => {
|
||||
it("clears persisted history for session retention mode", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
@@ -723,8 +767,39 @@ describe("settings storage", () => {
|
||||
|
||||
resetHistoryForRetention(paths, "session");
|
||||
|
||||
expect(loadHistory(paths)).toEqual([]);
|
||||
});
|
||||
expect(loadHistory(paths)).toEqual([]);
|
||||
});
|
||||
|
||||
it("propagates a history deletion failure instead of reporting success", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
saveHistory(paths, [{
|
||||
id: "hist-locked",
|
||||
name: "locked",
|
||||
totalBytes: 1,
|
||||
downloadedBytes: 1,
|
||||
fileCount: 1,
|
||||
provider: "realdebrid",
|
||||
completedAt: Date.now(),
|
||||
durationSeconds: 1,
|
||||
status: "completed",
|
||||
outputDir: path.join(dir, "out"),
|
||||
urls: []
|
||||
}]);
|
||||
const originalUnlink = fs.unlinkSync;
|
||||
vi.spyOn(fs, "unlinkSync").mockImplementation((target) => {
|
||||
if (target === paths.historyFile) {
|
||||
const error = new Error("EPERM: history file is locked") as NodeJS.ErrnoException;
|
||||
error.code = "EPERM";
|
||||
throw error;
|
||||
}
|
||||
return originalUnlink(target);
|
||||
});
|
||||
|
||||
expect(() => clearHistory(paths)).toThrow(/EPERM/);
|
||||
expect(loadHistory(paths)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("caps persisted history to the configured maxEntries", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
|
||||
@@ -14,7 +14,7 @@ import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/ma
|
||||
import { initAccountRotationLog, logAccountRotation, shutdownAccountRotationLog } from "../src/main/account-rotation-log";
|
||||
import { configureLogger, flushLoggerSync, logger } from "../src/main/logger";
|
||||
import { ensurePackageLog, initPackageLogs, logPackageEvent, shutdownPackageLogs } from "../src/main/package-log";
|
||||
import { ensureItemLog, initItemLogs, logItemEvent, shutdownItemLogs } from "../src/main/item-log";
|
||||
import { ensureItemLog, flushItemLogs, initItemLogs, logItemEvent, shutdownItemLogs } from "../src/main/item-log";
|
||||
import { initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log";
|
||||
import {
|
||||
primeDebridLinkRuntimeCooldownForTests,
|
||||
@@ -602,6 +602,289 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
expect(itemEntry?.getData().toString("utf8") || "").toContain("item-buffer-marker");
|
||||
});
|
||||
|
||||
it("redacts snapshot and runtime package and file names from the tailed main log", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-main-log-names-"));
|
||||
tempDirs.push(root);
|
||||
const packageName = "MAINPKG-BEGIN === MAINPKG-MIDDLE | fileName=MAINPKG-END";
|
||||
const fileName = "MAINFILE-BEGIN | packageName=MAINFILE-MIDDLE === MAINFILE-END.rar";
|
||||
const runtimeOnlyName = "RUNTIME-BEGIN | decoy=RUNTIME-MIDDLE === RUNTIME-END";
|
||||
flushLoggerSync();
|
||||
configureLogger(root);
|
||||
const mainLogPath = path.join(root, "rd_downloader.log");
|
||||
const completeTail = [
|
||||
"",
|
||||
"2026-08-13 12:00:00.000 [INFO] main-diagnostic-marker status=downloading",
|
||||
`2026-08-13 12:00:00.000 [INFO] runtime-name-marker packageName=${runtimeOnlyName} | status=queued`,
|
||||
`2026-08-13 12:00:00.000 [INFO] main-package-marker ${packageName}`,
|
||||
`2026-08-13 12:00:00.000 [INFO] main-file-marker ${fileName}`,
|
||||
""
|
||||
].join("\n");
|
||||
const partialOffset = 8;
|
||||
const fillerLength = 128 * 1024 - Buffer.byteLength(packageName.slice(partialOffset) + completeTail, "utf8");
|
||||
fs.appendFileSync(mainLogPath, `${packageName}${completeTail}${"z".repeat(fillerLength)}`, "utf8");
|
||||
const manager = {
|
||||
getSnapshot: () => ({
|
||||
stats: {},
|
||||
session: {
|
||||
version: 1,
|
||||
packages: {
|
||||
"package-main-log": {
|
||||
id: "package-main-log",
|
||||
name: packageName,
|
||||
outputDir: path.join(root, "output", packageName),
|
||||
extractDir: path.join(root, "extract", packageName),
|
||||
status: "downloading",
|
||||
itemIds: ["item-main-log"],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
},
|
||||
items: {
|
||||
"item-main-log": {
|
||||
id: "item-main-log",
|
||||
packageId: "package-main-log",
|
||||
url: "https://example.test/main-log",
|
||||
provider: "realdebrid",
|
||||
status: "downloading",
|
||||
retries: 0,
|
||||
speedBps: 1,
|
||||
downloadedBytes: 1,
|
||||
totalBytes: 2,
|
||||
progressPercent: 50,
|
||||
fileName,
|
||||
targetPath: path.join(root, "output", packageName, fileName),
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Download läuft",
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
},
|
||||
packageOrder: ["package-main-log"],
|
||||
running: true,
|
||||
paused: false,
|
||||
updatedAt: 2
|
||||
},
|
||||
speedText: "1 B/s",
|
||||
etaText: "1s",
|
||||
canStart: false,
|
||||
canStop: true,
|
||||
canPause: true
|
||||
}),
|
||||
getPackageLogPath: () => null,
|
||||
getItemLogPath: () => null
|
||||
} as unknown as DownloadManager;
|
||||
|
||||
const buffer = await buildSupportBundle(manager, root, {
|
||||
hostDiagnosticsMode: "none",
|
||||
debugSetupMode: "deferred"
|
||||
});
|
||||
const mainLog = new AdmZip(buffer).getEntry("logs/rd_downloader.log")?.getData().toString("utf8") || "";
|
||||
|
||||
for (const fragment of [
|
||||
"MAINPKG-BEGIN",
|
||||
"MAINPKG-MIDDLE",
|
||||
"MAINPKG-END",
|
||||
"MAINFILE-BEGIN",
|
||||
"MAINFILE-MIDDLE",
|
||||
"MAINFILE-END",
|
||||
"RUNTIME-BEGIN",
|
||||
"RUNTIME-MIDDLE",
|
||||
"RUNTIME-END"
|
||||
]) {
|
||||
expect(mainLog).not.toContain(fragment);
|
||||
}
|
||||
expect(mainLog).toContain("main-diagnostic-marker");
|
||||
expect(mainLog).toContain("main-package-marker");
|
||||
expect(mainLog).toContain("main-file-marker");
|
||||
});
|
||||
|
||||
it("redacts completed download names after the package was removed from the session", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-removed-main-log-names-"));
|
||||
tempDirs.push(root);
|
||||
flushLoggerSync();
|
||||
configureLogger(root);
|
||||
const fileName = "REMOVED-PRIVATE-FILE.part01.rar";
|
||||
const packageName = "REMOVED-PRIVATE-PACKAGE";
|
||||
fs.appendFileSync(
|
||||
path.join(root, "rd_downloader.log"),
|
||||
`2026-08-13 12:00:00.000 [INFO] Download fertig: ${fileName} (1.00 GB), pkg=${packageName}\n`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const buffer = await buildSupportBundle(fakeManager(), root, {
|
||||
hostDiagnosticsMode: "none",
|
||||
debugSetupMode: "deferred"
|
||||
});
|
||||
const mainLog = new AdmZip(buffer).getEntry("logs/rd_downloader.log")?.getData().toString("utf8") || "";
|
||||
|
||||
expect(mainLog).toContain("Download fertig:");
|
||||
expect(mainLog).not.toContain(fileName);
|
||||
expect(mainLog).not.toContain(packageName);
|
||||
});
|
||||
|
||||
it("redacts runtime package and file names from included package and item logs", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-runtime-names-"));
|
||||
tempDirs.push(root);
|
||||
const packageId = "package-runtime-private";
|
||||
const itemId = "item-runtime-private";
|
||||
const privatePackageName = "Family.Vacation.Private.Release";
|
||||
const privateFileName = "Family.Vacation.Private.Release.part01.rar";
|
||||
initPackageLogs(root);
|
||||
initItemLogs(root);
|
||||
ensurePackageLog({
|
||||
packageId,
|
||||
name: privatePackageName,
|
||||
outputDir: path.join(root, "output", privatePackageName),
|
||||
extractDir: path.join(root, "extract", privatePackageName)
|
||||
});
|
||||
ensureItemLog({
|
||||
itemId,
|
||||
packageId,
|
||||
packageName: privatePackageName,
|
||||
fileName: privateFileName,
|
||||
targetPath: path.join(root, "output", privatePackageName, privateFileName)
|
||||
});
|
||||
logPackageEvent(packageId, "INFO", `package-transfer-active ${privatePackageName}`, { status: "downloading" });
|
||||
logItemEvent(itemId, "INFO", `item-transfer-active ${privateFileName}`, { status: "downloading" });
|
||||
|
||||
const manager = {
|
||||
getSnapshot: () => ({
|
||||
stats: {},
|
||||
session: {
|
||||
version: 1,
|
||||
packages: {
|
||||
[packageId]: {
|
||||
id: packageId,
|
||||
name: privatePackageName,
|
||||
outputDir: path.join(root, "output", privatePackageName),
|
||||
extractDir: path.join(root, "extract", privatePackageName),
|
||||
status: "downloading",
|
||||
itemIds: [itemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
},
|
||||
items: {
|
||||
[itemId]: {
|
||||
id: itemId,
|
||||
packageId,
|
||||
url: "https://rapidgator.net/file/runtime-private",
|
||||
provider: "megadebrid-web",
|
||||
status: "downloading",
|
||||
retries: 0,
|
||||
speedBps: 1024,
|
||||
downloadedBytes: 512,
|
||||
totalBytes: 1024,
|
||||
progressPercent: 50,
|
||||
fileName: privateFileName,
|
||||
targetPath: path.join(root, "output", privatePackageName, privateFileName),
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Download läuft",
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
},
|
||||
packageOrder: [packageId],
|
||||
running: true,
|
||||
paused: false,
|
||||
updatedAt: 2
|
||||
},
|
||||
speedText: "1 KB/s",
|
||||
etaText: "1s",
|
||||
canStart: false,
|
||||
canStop: true,
|
||||
canPause: true
|
||||
}),
|
||||
getPackageLogPath: () => null,
|
||||
getItemLogPath: () => null
|
||||
} as unknown as DownloadManager;
|
||||
|
||||
const buffer = await buildSupportBundle(manager, root, {
|
||||
hostDiagnosticsMode: "none",
|
||||
debugSetupMode: "deferred"
|
||||
});
|
||||
const zip = new AdmZip(buffer);
|
||||
const packageLog = zip.getEntries()
|
||||
.find((entry) => entry.entryName.startsWith("logs/package-logs/"))
|
||||
?.getData().toString("utf8") || "";
|
||||
const itemLog = zip.getEntries()
|
||||
.find((entry) => entry.entryName.startsWith("logs/item-logs/"))
|
||||
?.getData().toString("utf8") || "";
|
||||
const overview = [
|
||||
zip.getEntry("overview/packages.json")?.getData().toString("utf8") || "",
|
||||
zip.getEntry("overview/items.json")?.getData().toString("utf8") || ""
|
||||
].join("\n");
|
||||
|
||||
expect(`${packageLog}\n${itemLog}`).not.toContain(privatePackageName);
|
||||
expect(`${packageLog}\n${itemLog}`).not.toContain(privateFileName);
|
||||
expect(packageLog).toContain(packageId);
|
||||
expect(itemLog).toContain(itemId);
|
||||
expect(packageLog).toContain("package-transfer-active");
|
||||
expect(itemLog).toContain("item-transfer-active");
|
||||
expect(`${packageLog}\n${itemLog}`).toContain("status=downloading");
|
||||
expect(overview).toContain('"name": "package-001.release"');
|
||||
expect(overview).toContain('"fileName": "item-001.rar"');
|
||||
});
|
||||
|
||||
it("removes delimiter-injected package and file names from complete and tailed runtime logs", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-runtime-name-boundaries-"));
|
||||
tempDirs.push(root);
|
||||
const packageName = "PRIVATEPKG-BEGIN === PRIVATEPKG-MIDDLE | decoy=PRIVATEPKG-END";
|
||||
const fileName = "PRIVATEFILE-BEGIN | decoyField=PRIVATEFILE-MIDDLE | packageName=PRIVATEFILE-END.rar";
|
||||
initPackageLogs(root);
|
||||
initItemLogs(root);
|
||||
const packageLogPath = ensurePackageLog({
|
||||
packageId: "package-runtime-boundaries",
|
||||
name: packageName,
|
||||
outputDir: path.join(root, "output", packageName),
|
||||
extractDir: path.join(root, "extract", packageName)
|
||||
});
|
||||
const itemLogPath = ensureItemLog({
|
||||
itemId: "item-runtime-boundaries",
|
||||
packageId: "package-runtime-boundaries",
|
||||
packageName,
|
||||
fileName,
|
||||
targetPath: path.join(root, "output", packageName, fileName)
|
||||
});
|
||||
expect(packageLogPath).not.toBeNull();
|
||||
expect(itemLogPath).not.toBeNull();
|
||||
fs.appendFileSync(packageLogPath!, `2026-08-13 12:00:00.000 [INFO] package-diagnostic-marker ${packageName} | status=downloading\n`, "utf8");
|
||||
const partialOffset = 12;
|
||||
const completeTail = `\n2026-08-13 12:00:00.000 [INFO] item-diagnostic-marker ${fileName} | status=downloading\n`;
|
||||
const fillerLength = 128 * 1024 - Buffer.byteLength(fileName.slice(partialOffset) + completeTail, "utf8");
|
||||
fs.appendFileSync(itemLogPath!, `${"p".repeat(1024)}${fileName}${completeTail}${"z".repeat(fillerLength)}`, "utf8");
|
||||
|
||||
const buffer = await buildSupportBundle(fakeManager(), root, {
|
||||
hostDiagnosticsMode: "none",
|
||||
debugSetupMode: "deferred"
|
||||
});
|
||||
const runtimeLogText = new AdmZip(buffer).getEntries()
|
||||
.filter((entry) => /logs\/(?:package|item)-logs\//.test(entry.entryName))
|
||||
.map((entry) => entry.getData().toString("utf8"))
|
||||
.join("\n");
|
||||
|
||||
for (const fragment of [
|
||||
"PRIVATEPKG-BEGIN",
|
||||
"PRIVATEPKG-MIDDLE",
|
||||
"PRIVATEPKG-END",
|
||||
"PRIVATEFILE-BEGIN",
|
||||
"PRIVATEFILE-MIDDLE",
|
||||
"PRIVATEFILE-END"
|
||||
]) {
|
||||
expect(runtimeLogText).not.toContain(fragment);
|
||||
}
|
||||
expect(runtimeLogText).toContain("package-diagnostic-marker");
|
||||
expect(runtimeLogText).toContain("item-diagnostic-marker");
|
||||
});
|
||||
|
||||
it("bounds recent item logs to the newest diagnostic files", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
|
||||
tempDirs.push(root);
|
||||
@@ -738,9 +1021,14 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-sensitive-"));
|
||||
tempDirs.push(root);
|
||||
const escapedSecret = "prefix\"suffix\\trail\tend";
|
||||
const privatePackageName = "Private Default Package Name";
|
||||
const firstKeyId = getDebridLinkApiKeyId("abc123456789xyz");
|
||||
fs.writeFileSync(path.join(root, "rd_downloader_config.json"), JSON.stringify({
|
||||
megaDebridWebCredentials: `primary-user:primary-password-secret\nsecondary-user:secondary-password-secret\nZ9:Q7!\nAlice:${escapedSecret}`,
|
||||
debridLinkApiKeys: "abc123456789xyz,def987654321uvw",
|
||||
debridLinkApiKeyDailyUsageBytes: { [firstKeyId]: 1234 },
|
||||
debridLinkApiKeyTotalUsageBytes: { [firstKeyId]: 5678 },
|
||||
packageName: privatePackageName,
|
||||
megaDebridWebEnabled: true
|
||||
}), "utf8");
|
||||
initAccountRotationLog(root);
|
||||
@@ -823,7 +1111,9 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
"file-private.bin",
|
||||
"manifest-bearer-secret",
|
||||
"manifest-query-secret",
|
||||
"manifest-fragment"
|
||||
"manifest-fragment",
|
||||
privatePackageName,
|
||||
firstKeyId
|
||||
];
|
||||
|
||||
for (const secret of forbidden) {
|
||||
@@ -970,6 +1260,92 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
expect(timerGaps.length).toBeGreaterThan(2);
|
||||
expect(Math.max(...timerGaps)).toBeLessThan(100);
|
||||
}, 15_000);
|
||||
|
||||
it("retains failed recovery items when the pending queue exceeds the DTO cap", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-recovery-priority-"));
|
||||
tempDirs.push(root);
|
||||
const recoveryId = "failed-recovery-item";
|
||||
initItemLogs(root);
|
||||
ensureItemLog({ itemId: recoveryId, packageId: "package-recovery", packageName: "Recovery", fileName: "recovery.rar", targetPath: "C:\\Downloads\\recovery.rar" });
|
||||
logItemEvent(recoveryId, "ERROR", "failed-recovery-marker");
|
||||
flushItemLogs();
|
||||
const queuedItems = Object.fromEntries(Array.from({ length: 501 }, (_, index) => {
|
||||
const id = `queued-${index}`;
|
||||
return [id, {
|
||||
id,
|
||||
packageId: "package-queued",
|
||||
url: `https://rapidgator.net/file/${index}`,
|
||||
provider: "megadebrid-web",
|
||||
status: "queued",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: 100,
|
||||
progressPercent: 0,
|
||||
fileName: `${id}.rar`,
|
||||
targetPath: `C:\\Downloads\\${id}.rar`,
|
||||
resumable: true,
|
||||
attempts: 0,
|
||||
lastError: "",
|
||||
fullStatus: "Wartet",
|
||||
createdAt: index + 1,
|
||||
updatedAt: index + 1
|
||||
}];
|
||||
}));
|
||||
const items = {
|
||||
...queuedItems,
|
||||
[recoveryId]: {
|
||||
id: recoveryId,
|
||||
packageId: "package-recovery",
|
||||
url: "https://rapidgator.net/file/recovery",
|
||||
provider: "megadebrid-web",
|
||||
status: "failed",
|
||||
retries: 8,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 512,
|
||||
totalBytes: 1024,
|
||||
progressPercent: 50,
|
||||
fileName: "recovery.rar",
|
||||
targetPath: "C:\\Downloads\\recovery.rar",
|
||||
resumable: true,
|
||||
attempts: 9,
|
||||
lastError: "Resume recovery exhausted",
|
||||
fullStatus: "Fehler",
|
||||
resumeResetPending: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
};
|
||||
const manager = {
|
||||
getSnapshot: () => ({
|
||||
stats: {},
|
||||
session: { version: 1, packages: {}, items, packageOrder: [], running: true, paused: false, updatedAt: 1000 },
|
||||
speedText: "",
|
||||
etaText: "",
|
||||
canStart: false,
|
||||
canStop: true,
|
||||
canPause: true
|
||||
}),
|
||||
getPackageLogPath: () => null,
|
||||
getItemLogPath: () => null
|
||||
} as unknown as DownloadManager;
|
||||
|
||||
const buffer = await buildSupportBundle(manager, root, {
|
||||
hostDiagnosticsMode: "none",
|
||||
debugSetupMode: "deferred"
|
||||
});
|
||||
const zip = new AdmZip(buffer);
|
||||
const itemOverview = JSON.parse(zip.getEntry("overview/items.json")?.getData().toString("utf8") || "{}") as {
|
||||
items?: Array<{ id?: string }>;
|
||||
};
|
||||
const logText = zip.getEntries()
|
||||
.filter((entry) => entry.entryName.startsWith("logs/item-logs/"))
|
||||
.map((entry) => entry.getData().toString("utf8"))
|
||||
.join("\n");
|
||||
|
||||
expect(itemOverview.items?.some((entry) => entry.id === recoveryId)).toBe(true);
|
||||
expect(logText).toContain("failed-recovery-marker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("support bundle export runner", () => {
|
||||
|
||||
Reference in New Issue
Block a user