Release v1.4.5 with startup auto-recovery and lag hardening
This commit is contained in:
parent
6a33e61c38
commit
fe1f129af8
4
package-lock.json
generated
4
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user