Release v1.4.5 with startup auto-recovery and lag hardening
Some checks are pending
Build and Release / build (push) Waiting to run
Some checks are pending
Build and Release / build (push) Waiting to run
This commit is contained in:
parent
6a33e61c38
commit
05a75d0ac5
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.4.4",
|
||||
"version": "1.4.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.4.4",
|
||||
"version": "1.4.5",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.4.4",
|
||||
"version": "1.4.5",
|
||||
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
|
||||
@ -36,7 +36,7 @@ type ActiveTask = {
|
||||
nonResumableCounted: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 120000;
|
||||
const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 60000;
|
||||
|
||||
function getDownloadStallTimeoutMs(): number {
|
||||
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.applyOnStartCleanupPolicy();
|
||||
this.normalizeSessionStatuses();
|
||||
this.recoverRetryableItems("startup");
|
||||
this.recoverPostProcessingOnStartup();
|
||||
this.resolveExistingQueuedOpaqueFilenames();
|
||||
this.cleanupExistingExtractedArchives();
|
||||
@ -253,7 +254,7 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
return {
|
||||
settings: this.settings,
|
||||
session: this.getSession(),
|
||||
session: this.session,
|
||||
summary: this.summary,
|
||||
stats: this.getStats(),
|
||||
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
|
||||
@ -946,6 +947,13 @@ export class DownloadManager extends EventEmitter {
|
||||
if (this.session.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recoveredItems = this.recoverRetryableItems("start");
|
||||
if (recoveredItems > 0) {
|
||||
this.persistSoon();
|
||||
this.emitState(true);
|
||||
}
|
||||
|
||||
const runItems = Object.values(this.session.items)
|
||||
.filter((item) => {
|
||||
if (item.status !== "queued" && item.status !== "reconnect_wait") {
|
||||
@ -1193,10 +1201,20 @@ export class DownloadManager extends EventEmitter {
|
||||
if (this.stateEmitTimer) {
|
||||
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 = null;
|
||||
this.emit("state", this.getSnapshot());
|
||||
}, 260);
|
||||
}, emitDelay);
|
||||
}
|
||||
|
||||
private pruneSpeedEvents(now: number): void {
|
||||
@ -1210,7 +1228,13 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
private recordSpeed(bytes: number): void {
|
||||
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.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;
|
||||
}
|
||||
item.status = "completed";
|
||||
@ -2034,6 +2078,151 @@ export class DownloadManager extends EventEmitter {
|
||||
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 {
|
||||
const schedules = this.settings.bandwidthSchedules;
|
||||
if (schedules.length > 0) {
|
||||
|
||||
@ -1159,6 +1159,145 @@ describe("download manager", () => {
|
||||
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", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user