Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8c4dcc69b | ||
|
|
93a85a0255 | ||
|
|
22926d63e3 | ||
|
|
88993bccaa | ||
|
|
72b081d749 | ||
|
|
c541f1cc92 |
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.7.229",
|
"version": "1.7.232",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -302,7 +302,7 @@ function getMegaDebridAbortMinRunMs(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const megaDebridEmptyResponseStreaks = new Map<string, number>();
|
const megaDebridEmptyResponseStreaks = new Map<string, number>();
|
||||||
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 3;
|
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 10;
|
||||||
|
|
||||||
let megaDebridRotationCursor = 0;
|
let megaDebridRotationCursor = 0;
|
||||||
let megaDebridStickyCount = 0;
|
let megaDebridStickyCount = 0;
|
||||||
@ -2254,7 +2254,7 @@ class MegaDebridClient {
|
|||||||
const streak = recordMegaDebridEmptyResponseStreak(cooldownKey);
|
const streak = recordMegaDebridEmptyResponseStreak(cooldownKey);
|
||||||
if (streak >= MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART) {
|
if (streak >= MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART) {
|
||||||
parkUntilRestart = true;
|
parkUntilRestart = true;
|
||||||
parkMessage = `Tageslimit erreicht (${streak}x kein Server/leere Antwort) — bis zum Tagesreset gesperrt`;
|
parkMessage = `Tageslimit erreicht (${streak}x leere Antwort in Folge) — bis zum Tagesreset gesperrt`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
clearMegaDebridEmptyResponseStreak(cooldownKey);
|
clearMegaDebridEmptyResponseStreak(cooldownKey);
|
||||||
|
|||||||
@ -131,6 +131,17 @@ const ARCHIVE_SETTLE_MAX_WAIT_MS = 5000;
|
|||||||
|
|
||||||
const MAX_SAME_DIRECT_URL_ATTEMPTS = 3;
|
const MAX_SAME_DIRECT_URL_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
const MAX_HTTP416_FRESH_RESTARTS = 2;
|
||||||
|
const HTTP416_FRESH_RESTART_DELAY_MS = 8000;
|
||||||
|
|
||||||
|
function getHttp416FreshRestartDelayMs(): number {
|
||||||
|
const fromEnv = Number(process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS ?? NaN);
|
||||||
|
if (Number.isFinite(fromEnv) && fromEnv >= 0 && fromEnv <= 600000) {
|
||||||
|
return Math.floor(fromEnv);
|
||||||
|
}
|
||||||
|
return HTTP416_FRESH_RESTART_DELAY_MS;
|
||||||
|
}
|
||||||
|
|
||||||
const RESUME_REWIND_BYTES = 256 * 1024;
|
const RESUME_REWIND_BYTES = 256 * 1024;
|
||||||
|
|
||||||
const REALDEBRID_TOTAL_MISMATCH_TOLERANCE_BYTES = 64 * 1024;
|
const REALDEBRID_TOTAL_MISMATCH_TOLERANCE_BYTES = 64 * 1024;
|
||||||
@ -1829,6 +1840,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
unrestrictRetries: number;
|
unrestrictRetries: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
private http416FreshRestartByItem = new Map<string, number>();
|
||||||
|
|
||||||
private providerFailures = new Map<string, { count: number; lastFailAt: number; cooldownUntil: number }>();
|
private providerFailures = new Map<string, { count: number; lastFailAt: number; cooldownUntil: number }>();
|
||||||
|
|
||||||
private allDebridHostInfoCache = new Map<string, { info: AllDebridHostInfo; cachedAt: number }>();
|
private allDebridHostInfoCache = new Map<string, { info: AllDebridHostInfo; cachedAt: number }>();
|
||||||
@ -5678,6 +5691,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.providerStartReservations.clear();
|
this.providerStartReservations.clear();
|
||||||
this.pacedStartReservationByItem.clear();
|
this.pacedStartReservationByItem.clear();
|
||||||
this.retryStateByItem.clear();
|
this.retryStateByItem.clear();
|
||||||
|
this.http416FreshRestartByItem.clear();
|
||||||
this.itemContributedBytes.clear();
|
this.itemContributedBytes.clear();
|
||||||
this.reservedTargetPaths.clear();
|
this.reservedTargetPaths.clear();
|
||||||
this.claimedTargetPathByItem.clear();
|
this.claimedTargetPathByItem.clear();
|
||||||
@ -8727,6 +8741,53 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.queueRetry(item, active, delayMs, `HTTP 416 erkannt, Retry ${active.genericErrorRetries}/${retryDisplayLimit}`);
|
this.queueRetry(item, active, delayMs, `HTTP 416 erkannt, Retry ${active.genericErrorRetries}/${retryDisplayLimit}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private escalateHttp416OrFail(item: DownloadItem, active: ActiveTask, claimedTargetPath: string, errorText: string): void {
|
||||||
|
const freshRestarts = this.http416FreshRestartByItem.get(item.id) || 0;
|
||||||
|
if (freshRestarts < MAX_HTTP416_FRESH_RESTARTS) {
|
||||||
|
this.http416FreshRestartByItem.set(item.id, freshRestarts + 1);
|
||||||
|
const resetTargetPath = claimedTargetPath || String(item.targetPath || "").trim();
|
||||||
|
if (resetTargetPath) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(resetTargetPath, { force: true });
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.releaseTargetPath(item.id);
|
||||||
|
this.dropItemContribution(item.id);
|
||||||
|
item.retries += 1;
|
||||||
|
item.downloadedBytes = 0;
|
||||||
|
item.totalBytes = null;
|
||||||
|
item.progressPercent = 0;
|
||||||
|
item.speedBps = 0;
|
||||||
|
item.lastError = "";
|
||||||
|
active.genericErrorRetries = 0;
|
||||||
|
active.freshRetryUsed = false;
|
||||||
|
active.resumeHardResetUsed = false;
|
||||||
|
logger.warn(
|
||||||
|
`HTTP 416 Budget erschöpft: item=${item.fileName || item.id}, ` +
|
||||||
|
`kompletter Neu-Download ${freshRestarts + 1}/${MAX_HTTP416_FRESH_RESTARTS} (Partial verworfen, kein Resume), provider=${item.provider || "?"}`
|
||||||
|
);
|
||||||
|
this.queueRetry(item, active, getHttp416FreshRestartDelayMs(), `Range-Konflikt (HTTP 416): Neu-Download ${freshRestarts + 1}/${MAX_HTTP416_FRESH_RESTARTS}`);
|
||||||
|
this.persistSoon();
|
||||||
|
this.emitState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.http416FreshRestartByItem.delete(item.id);
|
||||||
|
item.status = "failed";
|
||||||
|
this.recordRunOutcome(item.id, "failed");
|
||||||
|
item.lastError = errorText;
|
||||||
|
item.fullStatus = `Fehler: ${item.lastError}`;
|
||||||
|
item.speedBps = 0;
|
||||||
|
item.updatedAt = nowMs();
|
||||||
|
const failPkg = this.session.packages[item.packageId];
|
||||||
|
if (failPkg) {
|
||||||
|
this.refreshPackageStatus(failPkg);
|
||||||
|
}
|
||||||
|
this.persistSoon();
|
||||||
|
this.emitState();
|
||||||
|
this.retryStateByItem.delete(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
private startItem(packageId: string, itemId: string): void {
|
private startItem(packageId: string, itemId: string): void {
|
||||||
const item = this.session.items[itemId];
|
const item = this.session.items[itemId];
|
||||||
const pkg = this.session.packages[packageId];
|
const pkg = this.session.packages[packageId];
|
||||||
@ -9317,12 +9378,16 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isHttp416Text(exhaustedReason) && active.genericErrorRetries < maxHttp416Retries) {
|
if (isHttp416Text(exhaustedReason)) {
|
||||||
|
if (active.genericErrorRetries < maxHttp416Retries) {
|
||||||
this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath);
|
this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath);
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState();
|
this.emitState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.escalateHttp416OrFail(item, active, claimedTargetPath, exhaustedReason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) {
|
if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) {
|
||||||
active.resumeHardResetUsed = true;
|
active.resumeHardResetUsed = true;
|
||||||
item.retries += 1;
|
item.retries += 1;
|
||||||
@ -9377,17 +9442,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.emitState();
|
this.emitState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
item.status = "failed";
|
this.escalateHttp416OrFail(item, active, claimedTargetPath, errorText);
|
||||||
this.recordRunOutcome(item.id, "failed");
|
|
||||||
item.lastError = errorText;
|
|
||||||
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);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (shouldFreshRetry) {
|
if (shouldFreshRetry) {
|
||||||
|
|||||||
@ -571,7 +571,7 @@ function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => {
|
ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => {
|
||||||
const options = {
|
const options = {
|
||||||
defaultPath: `mdd-backup-${new Date().toISOString().slice(0, 10)}.mdd`,
|
defaultPath: `${new Date().toISOString().slice(0, 10).split("-").reverse().join("-")}-mdd-backup.mdd`,
|
||||||
filters: [{ name: "MDD Backup", extensions: ["mdd"] }]
|
filters: [{ name: "MDD Backup", extensions: ["mdd"] }]
|
||||||
};
|
};
|
||||||
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
||||||
|
|||||||
@ -2140,7 +2140,7 @@ describe("debrid service", () => {
|
|||||||
|
|
||||||
it("escalates a Mega-Debrid account to 'until restart' after the empty-response streak threshold", () => {
|
it("escalates a Mega-Debrid account to 'until restart' after the empty-response streak threshold", () => {
|
||||||
const key = `${getMegaDebridAccountId("user1")}:web`;
|
const key = `${getMegaDebridAccountId("user1")}:web`;
|
||||||
expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(3);
|
expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(10);
|
||||||
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
|
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
|
||||||
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(2);
|
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(2);
|
||||||
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(3);
|
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(3);
|
||||||
@ -2265,8 +2265,9 @@ describe("debrid service", () => {
|
|||||||
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||||
|
|
||||||
const key = `${getMegaDebridAccountId("user1")}:web`;
|
const key = `${getMegaDebridAccountId("user1")}:web`;
|
||||||
|
for (let i = 0; i < MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART - 1; i += 1) {
|
||||||
recordMegaDebridEmptyResponseStreak(key);
|
recordMegaDebridEmptyResponseStreak(key);
|
||||||
recordMegaDebridEmptyResponseStreak(key);
|
}
|
||||||
expect(getMegaDebridAccountCooldownState(key)?.untilRestart ?? false).toBe(false);
|
expect(getMegaDebridAccountCooldownState(key)?.untilRestart ?? false).toBe(false);
|
||||||
|
|
||||||
const megaWeb = vi.fn(async () => null);
|
const megaWeb = vi.fn(async () => null);
|
||||||
|
|||||||
@ -2209,6 +2209,98 @@ describe("download manager", () => {
|
|||||||
expect(fs.statSync(item.targetPath).size).toBe(binary.length);
|
expect(fs.statSync(item.targetPath).size).toBe(binary.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("recovers an HTTP 416 item with a clean fresh restart after the in-budget retries are exhausted", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-fresh-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const binary = Buffer.alloc(160 * 1024, 19);
|
||||||
|
const prevDelay = process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS;
|
||||||
|
process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = "0";
|
||||||
|
let downloadCalls = 0;
|
||||||
|
|
||||||
|
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes("/unrestrict/link")) {
|
||||||
|
return new Response(JSON.stringify({ download: "https://dummy/direct-416-recover", filename: "fresh-416.mkv", filesize: binary.length }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch ${url}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), retryLimit: 2, autoExtract: false, autoReconnect: false },
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
(manager as any).downloadToFile = async (_active: unknown, _directUrl: string, targetPath: string) => {
|
||||||
|
downloadCalls += 1;
|
||||||
|
if (downloadCalls <= 3) {
|
||||||
|
throw new Error("HTTP 416");
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||||
|
fs.writeFileSync(targetPath, binary);
|
||||||
|
const item = Object.values((manager as any).session.items)[0] as { downloadedBytes: number; totalBytes: number; progressPercent: number } | undefined;
|
||||||
|
if (item) {
|
||||||
|
item.downloadedBytes = binary.length;
|
||||||
|
item.totalBytes = binary.length;
|
||||||
|
item.progressPercent = 100;
|
||||||
|
}
|
||||||
|
return { resumable: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.addPackages([{ name: "fresh-416", links: ["https://dummy/fresh-416"] }]);
|
||||||
|
await manager.start();
|
||||||
|
await waitFor(() => !manager.getSnapshot().session.running, 20000);
|
||||||
|
|
||||||
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||||
|
expect(item?.status).toBe("completed");
|
||||||
|
expect(downloadCalls).toBeGreaterThan(3);
|
||||||
|
} finally {
|
||||||
|
if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; }
|
||||||
|
}
|
||||||
|
}, 25000);
|
||||||
|
|
||||||
|
it("bounds HTTP 416 clean restarts and finally fails instead of looping forever or stalling permanently", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-cap-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const prevDelay = process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS;
|
||||||
|
process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = "0";
|
||||||
|
let downloadCalls = 0;
|
||||||
|
|
||||||
|
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes("/unrestrict/link")) {
|
||||||
|
return new Response(JSON.stringify({ download: "https://dummy/direct-416-forever", filename: "always-416.mkv", filesize: 1024 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch ${url}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), retryLimit: 0, autoExtract: false, autoReconnect: false },
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
(manager as any).downloadToFile = async () => {
|
||||||
|
downloadCalls += 1;
|
||||||
|
throw new Error("direct_link_retry_exhausted:HTTP 416");
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.addPackages([{ name: "always-416", links: ["https://dummy/always-416"] }]);
|
||||||
|
await manager.start();
|
||||||
|
await waitFor(() => !manager.getSnapshot().session.running, 20000);
|
||||||
|
|
||||||
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||||
|
expect(item?.status).toBe("failed");
|
||||||
|
expect(downloadCalls).toBeGreaterThan(4);
|
||||||
|
expect(downloadCalls).toBeLessThan(30);
|
||||||
|
expect((manager as any).http416FreshRestartByItem.get(item.id)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; }
|
||||||
|
}
|
||||||
|
}, 25000);
|
||||||
|
|
||||||
it("retries HTTP 416 in-session when using Debrid-Link API and then completes", async () => {
|
it("retries HTTP 416 in-session when using Debrid-Link API and then completes", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user