fix(downloads): restore actionable package states and extraction controls
Resolve child archive selections to complete multipart sets, support bulk package extraction, refresh extraction passwords at execution time, and keep archive failures scoped by their full paths. Restore sortable download columns, preserve verified availability, classify package retry and extraction states, secure link copying through the preload bridge, and release cancelled provider work without stale cooldowns.
This commit is contained in:
+110
-32
@@ -656,8 +656,7 @@ describe("debrid service", () => {
|
||||
expect(getDebridLinkKeyCooldownStateForTests(keyId)).toBeNull();
|
||||
});
|
||||
|
||||
it("cools down a Debrid-Link key on an abort that ran long enough (retry rotates to the next key)", async () => {
|
||||
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
|
||||
it("does not cool down a Debrid-Link key when the caller aborts after more than eight seconds", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
@@ -672,13 +671,16 @@ describe("debrid service", () => {
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
const controller = new AbortController();
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("/downloader/add")) {
|
||||
controller.abort();
|
||||
throw new Error("aborted");
|
||||
};
|
||||
const controller = new AbortController();
|
||||
let now = 1_000_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("/downloader/add")) {
|
||||
now += 9_000;
|
||||
controller.abort("stop");
|
||||
throw new Error("aborted");
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
@@ -689,9 +691,48 @@ describe("debrid service", () => {
|
||||
service.unrestrictLink("https://rapidgator.net/file/dl-long-abort", controller.signal)
|
||||
).rejects.toThrow();
|
||||
|
||||
const cooldown = getDebridLinkKeyCooldownStateForTests(keyId);
|
||||
expect(cooldown?.remainingMs ?? 0).toBeGreaterThan(60_000);
|
||||
});
|
||||
expect(getDebridLinkKeyCooldownStateForTests(keyId)).toBeNull();
|
||||
});
|
||||
|
||||
it("cools down a Debrid-Link key after an internal timeout", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
bestToken: "",
|
||||
allDebridToken: "",
|
||||
megaLogin: "",
|
||||
megaPassword: "",
|
||||
megaCredentials: "",
|
||||
debridLinkApiKeys: "dl-key-one",
|
||||
providerOrder: ["debridlink"] as const,
|
||||
providerPrimary: "debridlink" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
const callerController = new AbortController();
|
||||
const timeoutController = new AbortController();
|
||||
const signal = AbortSignal.any([callerController.signal, timeoutController.signal]);
|
||||
let now = 1_000_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("/downloader/add")) {
|
||||
now += 9_000;
|
||||
timeoutController.abort(new DOMException("The operation timed out", "TimeoutError"));
|
||||
throw new Error("aborted");
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const keyId = parseDebridLinkApiKeys("dl-key-one")[0].id;
|
||||
const service = new DebridService(settings);
|
||||
await expect(
|
||||
service.unrestrictLink("https://rapidgator.net/file/dl-internal-timeout", signal)
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(getDebridLinkKeyCooldownStateForTests(keyId)?.remainingMs ?? 0).toBeGreaterThan(60_000);
|
||||
});
|
||||
|
||||
it("treats bad Debrid-Link file passwords as fatal and does not rotate keys", async () => {
|
||||
const settings = {
|
||||
@@ -2158,13 +2199,11 @@ describe("debrid service", () => {
|
||||
};
|
||||
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||
|
||||
const controller = new AbortController();
|
||||
let calls = 0;
|
||||
const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
controller.abort("simulated-60s-timeout");
|
||||
return Promise.reject(new Error("aborted"));
|
||||
let calls = 0;
|
||||
const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => {
|
||||
calls += 1;
|
||||
if (calls <= REQUEST_RETRIES) {
|
||||
return Promise.reject(new Error("aborted"));
|
||||
}
|
||||
return Promise.resolve({
|
||||
fileName: "healthy.rar",
|
||||
@@ -2176,7 +2215,7 @@ describe("debrid service", () => {
|
||||
|
||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||
|
||||
const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html", controller.signal).then(() => null, (e: unknown) => e);
|
||||
const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html").then(() => null, (e: unknown) => e);
|
||||
expect(err).toBeTruthy();
|
||||
expect(String(err)).toMatch(/mega_debrid_slow_link:\d+:/i);
|
||||
|
||||
@@ -2328,7 +2367,7 @@ describe("debrid service", () => {
|
||||
expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true);
|
||||
}, 20000);
|
||||
|
||||
it("cools down a Mega-Web account that aborts (timeout) so the NEXT unrestrict rotates to the next account", async () => {
|
||||
it("cools down a Mega-Web account that aborts (timeout) so the NEXT unrestrict rotates to the next account", async () => {
|
||||
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; // treat the instant mock abort as a real timeout
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
@@ -2345,13 +2384,17 @@ describe("debrid service", () => {
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||
|
||||
const loginsSeen: Array<string | undefined> = [];
|
||||
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
|
||||
loginsSeen.push(account?.login);
|
||||
if (account?.login === "user1") {
|
||||
throw new Error("aborted:debrid");
|
||||
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||
|
||||
const callerController = new AbortController();
|
||||
const timeoutController = new AbortController();
|
||||
const signal = AbortSignal.any([callerController.signal, timeoutController.signal]);
|
||||
const loginsSeen: Array<string | undefined> = [];
|
||||
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
|
||||
loginsSeen.push(account?.login);
|
||||
if (account?.login === "user1") {
|
||||
timeoutController.abort(new DOMException("The operation timed out", "TimeoutError"));
|
||||
throw new Error("aborted:debrid");
|
||||
}
|
||||
return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 };
|
||||
});
|
||||
@@ -2359,7 +2402,7 @@ describe("debrid service", () => {
|
||||
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
|
||||
|
||||
// Call 1: account 1 aborts -> rotation stops this pass, account 2 NOT tried, but account 1 is cooled down.
|
||||
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-call-1")).rejects.toThrow();
|
||||
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-call-1", signal)).rejects.toThrow();
|
||||
expect(loginsSeen).toContain("user1");
|
||||
expect(loginsSeen).not.toContain("user2");
|
||||
expect(getMegaDebridAccountCooldownState(user1Key)).not.toBeNull();
|
||||
@@ -2372,7 +2415,7 @@ describe("debrid service", () => {
|
||||
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
||||
}, 20000);
|
||||
|
||||
it("does NOT cool down a Mega-Web account on a quick abort (below the min-run threshold = user cancel)", async () => {
|
||||
it("does NOT cool down a Mega-Web account on a quick abort (below the min-run threshold = user cancel)", async () => {
|
||||
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "99999"; // any realistic elapsed stays below -> no cooldown
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
@@ -2396,8 +2439,43 @@ describe("debrid service", () => {
|
||||
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
|
||||
|
||||
await expect(service.unrestrictLink("https://rapidgator.net/file/quick-cancel")).rejects.toThrow();
|
||||
expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull();
|
||||
}, 20000);
|
||||
expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull();
|
||||
}, 20000);
|
||||
|
||||
it("does not cool down a Mega-Web account when the caller aborts after more than eight seconds", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
bestToken: "",
|
||||
allDebridToken: "",
|
||||
megaLogin: "user1",
|
||||
megaPassword: "pass1",
|
||||
megaCredentials: "user1:pass1\nuser2:pass2",
|
||||
megaDebridPreferApi: false,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "megadebrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
const controller = new AbortController();
|
||||
let now = 1_000_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||
const megaWeb = vi.fn(async () => {
|
||||
now += 9_000;
|
||||
controller.abort("stop");
|
||||
throw new Error("aborted:debrid");
|
||||
});
|
||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
|
||||
|
||||
await expect(
|
||||
service.unrestrictLink("https://rapidgator.net/file/long-caller-cancel", controller.signal)
|
||||
).rejects.toThrow(/aborted/i);
|
||||
|
||||
expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull();
|
||||
}, 20000);
|
||||
|
||||
it("respects provider selection and does not append hidden providers", async () => {
|
||||
const settings = {
|
||||
|
||||
Reference in New Issue
Block a user