Release v1.4.5 with startup auto-recovery and lag hardening

This commit is contained in:
Sucukdeluxe 2026-02-27 18:37:32 +01:00
parent 6a33e61c38
commit fe1f129af8
4 changed files with 335 additions and 7 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.4", "version": "1.4.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.4", "version": "1.4.5",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.4", "version": "1.4.5",
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -36,7 +36,7 @@ type ActiveTask = {
nonResumableCounted: boolean; nonResumableCounted: boolean;
}; };
const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 120000; const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 60000;
function getDownloadStallTimeoutMs(): number { function getDownloadStallTimeoutMs(): number {
const fromEnv = Number(process.env.RD_STALL_TIMEOUT_MS ?? NaN); const fromEnv = Number(process.env.RD_STALL_TIMEOUT_MS ?? NaN);
@ -197,6 +197,7 @@ export class DownloadManager extends EventEmitter {
this.debridService = new DebridService(settings, { megaWebUnrestrict: options.megaWebUnrestrict }); this.debridService = new DebridService(settings, { megaWebUnrestrict: options.megaWebUnrestrict });
this.applyOnStartCleanupPolicy(); this.applyOnStartCleanupPolicy();
this.normalizeSessionStatuses(); this.normalizeSessionStatuses();
this.recoverRetryableItems("startup");
this.recoverPostProcessingOnStartup(); this.recoverPostProcessingOnStartup();
this.resolveExistingQueuedOpaqueFilenames(); this.resolveExistingQueuedOpaqueFilenames();
this.cleanupExistingExtractedArchives(); this.cleanupExistingExtractedArchives();
@ -253,7 +254,7 @@ export class DownloadManager extends EventEmitter {
return { return {
settings: this.settings, settings: this.settings,
session: this.getSession(), session: this.session,
summary: this.summary, summary: this.summary,
stats: this.getStats(), stats: this.getStats(),
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`, speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
@ -946,6 +947,13 @@ export class DownloadManager extends EventEmitter {
if (this.session.running) { if (this.session.running) {
return; return;
} }
const recoveredItems = this.recoverRetryableItems("start");
if (recoveredItems > 0) {
this.persistSoon();
this.emitState(true);
}
const runItems = Object.values(this.session.items) const runItems = Object.values(this.session.items)
.filter((item) => { .filter((item) => {
if (item.status !== "queued" && item.status !== "reconnect_wait") { if (item.status !== "queued" && item.status !== "reconnect_wait") {
@ -1193,10 +1201,20 @@ export class DownloadManager extends EventEmitter {
if (this.stateEmitTimer) { if (this.stateEmitTimer) {
return; return;
} }
const itemCount = Object.keys(this.session.items).length;
const emitDelay = this.session.running
? itemCount >= 1500
? 900
: itemCount >= 700
? 650
: itemCount >= 250
? 420
: 280
: 260;
this.stateEmitTimer = setTimeout(() => { this.stateEmitTimer = setTimeout(() => {
this.stateEmitTimer = null; this.stateEmitTimer = null;
this.emit("state", this.getSnapshot()); this.emit("state", this.getSnapshot());
}, 260); }, emitDelay);
} }
private pruneSpeedEvents(now: number): void { private pruneSpeedEvents(now: number): void {
@ -1210,7 +1228,13 @@ export class DownloadManager extends EventEmitter {
private recordSpeed(bytes: number): void { private recordSpeed(bytes: number): void {
const now = nowMs(); const now = nowMs();
this.speedEvents.push({ at: now, bytes }); const bucket = now - (now % 120);
const last = this.speedEvents[this.speedEvents.length - 1];
if (last && last.at === bucket) {
last.bytes += bytes;
} else {
this.speedEvents.push({ at: bucket, bytes });
}
this.speedBytesLastWindow += bytes; this.speedBytesLastWindow += bytes;
this.pruneSpeedEvents(now); this.pruneSpeedEvents(now);
} }
@ -1599,6 +1623,26 @@ export class DownloadManager extends EventEmitter {
} }
} }
const finalTargetPath = String(item.targetPath || "").trim();
const fileSizeOnDisk = finalTargetPath && fs.existsSync(finalTargetPath)
? fs.statSync(finalTargetPath).size
: item.downloadedBytes;
const expectsNonEmptyFile = (item.totalBytes || 0) > 0 || isArchiveLikePath(finalTargetPath || item.fileName);
if (expectsNonEmptyFile && fileSizeOnDisk <= 0) {
try {
fs.rmSync(finalTargetPath, { force: true });
} catch {
// ignore
}
this.releaseTargetPath(item.id);
item.downloadedBytes = 0;
item.progressPercent = 0;
item.totalBytes = (item.totalBytes || 0) > 0 ? item.totalBytes : null;
item.speedBps = 0;
item.updatedAt = nowMs();
throw new Error("Leere Datei erkannt (0 B)");
}
done = true; done = true;
} }
item.status = "completed"; item.status = "completed";
@ -2034,6 +2078,151 @@ export class DownloadManager extends EventEmitter {
throw new Error(lastError || "Download fehlgeschlagen"); throw new Error(lastError || "Download fehlgeschlagen");
} }
private recoverRetryableItems(trigger: "startup" | "start"): number {
let recovered = 0;
const touchedPackages = new Set<string>();
for (const packageId of this.session.packageOrder) {
const pkg = this.session.packages[packageId];
if (!pkg || pkg.cancelled) {
continue;
}
for (const itemId of pkg.itemIds) {
const item = this.session.items[itemId];
if (!item || item.status === "cancelled") {
continue;
}
const is416Failure = this.isHttp416Failure(item);
const hasZeroByteArchive = this.hasZeroByteArchiveArtifact(item);
if (item.status === "failed") {
this.queueItemForRetry(item, {
hardReset: is416Failure || hasZeroByteArchive,
reason: is416Failure
? "Wartet (Auto-Retry: HTTP 416)"
: hasZeroByteArchive
? "Wartet (Auto-Retry: 0B-Datei)"
: "Wartet (Auto-Retry)"
});
recovered += 1;
touchedPackages.add(pkg.id);
continue;
}
if (item.status === "completed" && hasZeroByteArchive) {
this.queueItemForRetry(item, {
hardReset: true,
reason: "Wartet (Auto-Retry: 0B-Datei)"
});
recovered += 1;
touchedPackages.add(pkg.id);
}
}
}
if (recovered > 0) {
for (const packageId of touchedPackages) {
const pkg = this.session.packages[packageId];
if (!pkg) {
continue;
}
this.refreshPackageStatus(pkg);
}
logger.warn(`Auto-Retry-Recovery (${trigger}): ${recovered} Item(s) wieder in Queue gesetzt`);
}
return recovered;
}
private queueItemForRetry(item: DownloadItem, options: { hardReset: boolean; reason: string }): void {
const targetPath = String(item.targetPath || "").trim();
if (options.hardReset && targetPath) {
try {
fs.rmSync(targetPath, { force: true });
} catch {
// ignore
}
this.releaseTargetPath(item.id);
item.downloadedBytes = 0;
item.totalBytes = null;
item.progressPercent = 0;
}
item.status = "queued";
item.speedBps = 0;
item.attempts = 0;
item.lastError = "";
item.resumable = true;
item.fullStatus = options.reason;
item.updatedAt = nowMs();
}
private isHttp416Failure(item: DownloadItem): boolean {
const text = `${item.lastError} ${item.fullStatus}`;
return /(^|\D)416(\D|$)/.test(text);
}
private hasZeroByteArchiveArtifact(item: DownloadItem): boolean {
const targetPath = String(item.targetPath || "").trim();
const archiveCandidate = isArchiveLikePath(targetPath || item.fileName);
if (!archiveCandidate) {
return false;
}
if (targetPath && fs.existsSync(targetPath)) {
try {
return fs.statSync(targetPath).size <= 0;
} catch {
return false;
}
}
if (item.downloadedBytes <= 0 && item.progressPercent >= 100) {
return true;
}
return /\b0\s*B\b/i.test(item.fullStatus || "");
}
private refreshPackageStatus(pkg: PackageEntry): void {
const items = pkg.itemIds
.map((itemId) => this.session.items[itemId])
.filter(Boolean) as DownloadItem[];
if (items.length === 0) {
return;
}
const hasPending = items.some((item) => (
item.status === "queued"
|| item.status === "reconnect_wait"
|| item.status === "validating"
|| item.status === "downloading"
|| item.status === "paused"
|| item.status === "extracting"
|| item.status === "integrity_check"
));
if (hasPending) {
pkg.status = pkg.enabled ? "queued" : "paused";
pkg.updatedAt = nowMs();
return;
}
const success = items.filter((item) => item.status === "completed").length;
const failed = items.filter((item) => item.status === "failed").length;
const cancelled = items.filter((item) => item.status === "cancelled").length;
if (failed > 0) {
pkg.status = "failed";
} else if (cancelled > 0 && success === 0) {
pkg.status = "cancelled";
} else if (success > 0) {
pkg.status = "completed";
}
pkg.updatedAt = nowMs();
}
private getEffectiveSpeedLimitKbps(): number { private getEffectiveSpeedLimitKbps(): number {
const schedules = this.settings.bandwidthSchedules; const schedules = this.settings.bandwidthSchedules;
if (schedules.length > 0) { if (schedules.length > 0) {

View File

@ -1159,6 +1159,145 @@ describe("download manager", () => {
expect(snapshot.canStart).toBe(true); expect(snapshot.canStart).toBe(true);
}); });
it("requeues failed HTTP 416 items automatically on startup", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "retry-416-pkg";
const itemId = "retry-416-item";
const createdAt = Date.now() - 20_000;
const outputDir = path.join(root, "downloads", "retry-416");
const targetPath = path.join(outputDir, "broken.part03.rar");
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(targetPath, Buffer.alloc(12 * 1024, 1));
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "retry-416",
outputDir,
extractDir: path.join(root, "extract", "retry-416"),
status: "failed",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://dummy/retry-416",
provider: "megadebrid",
status: "failed",
retries: 4,
speedBps: 0,
downloadedBytes: 12 * 1024,
totalBytes: 8 * 1024,
progressPercent: 100,
fileName: "broken.part03.rar",
targetPath,
resumable: true,
attempts: 3,
lastError: "Error: HTTP 416",
fullStatus: "Fehler: Error: HTTP 416",
createdAt,
updatedAt: createdAt
};
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"))
);
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");
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
expect(fs.existsSync(targetPath)).toBe(false);
});
it("requeues completed zero-byte archive items automatically on startup", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "zero-byte-pkg";
const itemId = "zero-byte-item";
const createdAt = Date.now() - 20_000;
const outputDir = path.join(root, "downloads", "zero-byte");
const targetPath = path.join(outputDir, "archive.part01.rar");
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(targetPath, Buffer.alloc(0));
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "zero-byte",
outputDir,
extractDir: path.join(root, "extract", "zero-byte"),
status: "completed",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://dummy/zero-byte",
provider: "megadebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 100,
fileName: "archive.part01.rar",
targetPath,
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig (0 B)",
createdAt,
updatedAt: createdAt
};
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"))
);
const snapshot = manager.getSnapshot();
const item = snapshot.session.items[itemId];
expect(item?.status).toBe("queued");
expect(item?.downloadedBytes).toBe(0);
expect(item?.progressPercent).toBe(0);
expect(item?.fullStatus).toContain("0B-Datei");
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
expect(fs.existsSync(targetPath)).toBe(false);
});
it("detects start conflicts when extract output already exists", () => { it("detects start conflicts when extract output already exists", () => {
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);