Fix shutdown resume state and legacy extracted cleanup backfill v1.3.9
This commit is contained in:
parent
da51e03cef
commit
ef821b69a5
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.3.8",
|
"version": "1.3.9",
|
||||||
"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",
|
||||||
|
|||||||
@ -151,7 +151,7 @@ export class AppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public shutdown(): void {
|
public shutdown(): void {
|
||||||
this.manager.stop();
|
this.manager.prepareForShutdown();
|
||||||
this.megaWebFallback.dispose();
|
this.megaWebFallback.dispose();
|
||||||
logger.info("App beendet");
|
logger.info("App beendet");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ type ActiveTask = {
|
|||||||
itemId: string;
|
itemId: string;
|
||||||
packageId: string;
|
packageId: string;
|
||||||
abortController: AbortController;
|
abortController: AbortController;
|
||||||
abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "stall" | "none";
|
abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "stall" | "shutdown" | "none";
|
||||||
resumable: boolean;
|
resumable: boolean;
|
||||||
speedEvents: Array<{ at: number; bytes: number }>;
|
speedEvents: Array<{ at: number; bytes: number }>;
|
||||||
nonResumableCounted: boolean;
|
nonResumableCounted: boolean;
|
||||||
@ -86,6 +86,11 @@ function canRetryStatus(status: number): boolean {
|
|||||||
return status === 429 || status >= 500;
|
return status === 429 || status >= 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isArchiveLikePath(filePath: string): boolean {
|
||||||
|
const lower = path.basename(filePath).toLowerCase();
|
||||||
|
return /\.(?:part\d+\.rar|rar|r\d{2}|zip|z\d{2}|7z|7z\.\d{3})$/i.test(lower);
|
||||||
|
}
|
||||||
|
|
||||||
function isFetchFailure(errorText: string): boolean {
|
function isFetchFailure(errorText: string): boolean {
|
||||||
const text = String(errorText || "").toLowerCase();
|
const text = String(errorText || "").toLowerCase();
|
||||||
return text.includes("fetch failed") || text.includes("socket hang up") || text.includes("econnreset") || text.includes("network error");
|
return text.includes("fetch failed") || text.includes("socket hang up") || text.includes("econnreset") || text.includes("network error");
|
||||||
@ -601,15 +606,18 @@ export class DownloadManager extends EventEmitter {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const extractedItems = items.filter((item) => item.fullStatus === "Entpackt");
|
const hasExtractMarker = items.some((item) => /entpack/i.test(item.fullStatus));
|
||||||
if (extractedItems.length === 0) {
|
const hasExtractedOutput = this.directoryHasAnyFiles(pkg.extractDir);
|
||||||
|
if (!hasExtractMarker && !hasExtractedOutput) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const packageTargets = cleanupTargetsByPackage.get(packageId) ?? new Set<string>();
|
const packageTargets = cleanupTargetsByPackage.get(packageId) ?? new Set<string>();
|
||||||
for (const item of extractedItems) {
|
for (const item of items) {
|
||||||
const targetPath = String(item.targetPath || "").trim();
|
const rawTargetPath = String(item.targetPath || "").trim();
|
||||||
if (!targetPath) {
|
const fallbackTargetPath = item.fileName ? path.join(pkg.outputDir, sanitizeFilename(item.fileName)) : "";
|
||||||
|
const targetPath = rawTargetPath || fallbackTargetPath;
|
||||||
|
if (!targetPath || !isArchiveLikePath(targetPath)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const cleanupTarget of collectArchiveCleanupTargets(targetPath)) {
|
for (const cleanupTarget of collectArchiveCleanupTargets(targetPath)) {
|
||||||
@ -635,6 +643,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
for (const targetPath of targets) {
|
for (const targetPath of targets) {
|
||||||
|
if (!fs.existsSync(targetPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await fs.promises.rm(targetPath, { force: true });
|
await fs.promises.rm(targetPath, { force: true });
|
||||||
removed += 1;
|
removed += 1;
|
||||||
@ -653,6 +664,32 @@ export class DownloadManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private directoryHasAnyFiles(rootDir: string): boolean {
|
||||||
|
if (!rootDir || !fs.existsSync(rootDir)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const stack = [rootDir];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop() as string;
|
||||||
|
let entries: fs.Dirent[] = [];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isFile()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
stack.push(path.join(current, entry.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public cancelPackage(packageId: string): void {
|
public cancelPackage(packageId: string): void {
|
||||||
const pkg = this.session.packages[packageId];
|
const pkg = this.session.packages[packageId];
|
||||||
if (!pkg) {
|
if (!pkg) {
|
||||||
@ -755,6 +792,48 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.emitState(true);
|
this.emitState(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public prepareForShutdown(): void {
|
||||||
|
this.session.running = false;
|
||||||
|
this.session.paused = false;
|
||||||
|
this.session.reconnectUntil = 0;
|
||||||
|
this.session.reconnectReason = "";
|
||||||
|
|
||||||
|
for (const active of this.activeTasks.values()) {
|
||||||
|
const item = this.session.items[active.itemId];
|
||||||
|
if (item && !isFinishedStatus(item.status)) {
|
||||||
|
item.status = "queued";
|
||||||
|
item.speedBps = 0;
|
||||||
|
const pkg = this.session.packages[item.packageId];
|
||||||
|
item.fullStatus = pkg && !pkg.enabled ? "Paket gestoppt" : "Wartet";
|
||||||
|
item.updatedAt = nowMs();
|
||||||
|
}
|
||||||
|
active.abortReason = "shutdown";
|
||||||
|
active.abortController.abort("shutdown");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pkg of Object.values(this.session.packages)) {
|
||||||
|
if (pkg.status === "downloading"
|
||||||
|
|| pkg.status === "validating"
|
||||||
|
|| pkg.status === "extracting"
|
||||||
|
|| pkg.status === "integrity_check"
|
||||||
|
|| pkg.status === "paused"
|
||||||
|
|| pkg.status === "reconnect_wait") {
|
||||||
|
pkg.status = pkg.enabled ? "queued" : "paused";
|
||||||
|
pkg.updatedAt = nowMs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.speedEvents = [];
|
||||||
|
this.speedBytesLastWindow = 0;
|
||||||
|
this.runItemIds.clear();
|
||||||
|
this.runPackageIds.clear();
|
||||||
|
this.runOutcomes.clear();
|
||||||
|
this.runCompletedPackages.clear();
|
||||||
|
this.session.summaryText = "";
|
||||||
|
this.persistNow();
|
||||||
|
this.emitState(true);
|
||||||
|
}
|
||||||
|
|
||||||
public togglePause(): boolean {
|
public togglePause(): boolean {
|
||||||
if (!this.session.running) {
|
if (!this.session.running) {
|
||||||
return false;
|
return false;
|
||||||
@ -775,6 +854,13 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (item.provider !== "realdebrid" && item.provider !== "megadebrid" && item.provider !== "bestdebrid" && item.provider !== "alldebrid") {
|
if (item.provider !== "realdebrid" && item.provider !== "megadebrid" && item.provider !== "bestdebrid" && item.provider !== "alldebrid") {
|
||||||
item.provider = null;
|
item.provider = null;
|
||||||
}
|
}
|
||||||
|
if (item.status === "cancelled" && item.fullStatus === "Gestoppt") {
|
||||||
|
item.status = "queued";
|
||||||
|
item.fullStatus = "Wartet";
|
||||||
|
item.lastError = "";
|
||||||
|
item.speedBps = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (item.status === "downloading"
|
if (item.status === "downloading"
|
||||||
|| item.status === "validating"
|
|| item.status === "validating"
|
||||||
|| item.status === "extracting"
|
|| item.status === "extracting"
|
||||||
@ -1272,6 +1358,11 @@ export class DownloadManager extends EventEmitter {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
} else if (reason === "shutdown") {
|
||||||
|
item.status = "queued";
|
||||||
|
item.speedBps = 0;
|
||||||
|
const activePkg = this.session.packages[item.packageId];
|
||||||
|
item.fullStatus = activePkg && !activePkg.enabled ? "Paket gestoppt" : "Wartet";
|
||||||
} else if (reason === "reconnect") {
|
} else if (reason === "reconnect") {
|
||||||
item.status = "queued";
|
item.status = "queued";
|
||||||
item.fullStatus = "Wartet auf Reconnect";
|
item.fullStatus = "Wartet auf Reconnect";
|
||||||
|
|||||||
@ -821,6 +821,66 @@ describe("download manager", () => {
|
|||||||
expect(snapshot.canStart).toBe(true);
|
expect(snapshot.canStart).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requeues legacy 'Gestoppt' items on startup", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = "stopped-pkg";
|
||||||
|
const itemId = "stopped-item";
|
||||||
|
const createdAt = Date.now() - 20_000;
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId,
|
||||||
|
name: "stopped",
|
||||||
|
outputDir: path.join(root, "downloads", "stopped"),
|
||||||
|
extractDir: path.join(root, "extract", "stopped"),
|
||||||
|
status: "downloading",
|
||||||
|
itemIds: [itemId],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
session.items[itemId] = {
|
||||||
|
id: itemId,
|
||||||
|
packageId,
|
||||||
|
url: "https://dummy/stopped",
|
||||||
|
provider: "megadebrid",
|
||||||
|
status: "cancelled",
|
||||||
|
retries: 1,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: 512,
|
||||||
|
totalBytes: 2048,
|
||||||
|
progressPercent: 25,
|
||||||
|
fileName: "resume.part01.rar",
|
||||||
|
targetPath: path.join(root, "downloads", "stopped", "resume.part01.rar"),
|
||||||
|
resumable: true,
|
||||||
|
attempts: 1,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Gestoppt",
|
||||||
|
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();
|
||||||
|
expect(snapshot.session.items[itemId]?.status).toBe("queued");
|
||||||
|
expect(snapshot.session.items[itemId]?.fullStatus).toBe("Wartet");
|
||||||
|
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
|
||||||
|
});
|
||||||
|
|
||||||
it("cleans leftover split archives on startup for already extracted packages", async () => {
|
it("cleans leftover split archives on startup for already extracted packages", 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);
|
||||||
@ -891,6 +951,82 @@ describe("download manager", () => {
|
|||||||
expect(fs.existsSync(keep)).toBe(true);
|
expect(fs.existsSync(keep)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("cleans legacy leftovers when package is extracted but marker is old", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
|
||||||
|
const packageDir = path.join(root, "downloads", "legacy-old");
|
||||||
|
const extractDir = path.join(root, "extract", "legacy-old");
|
||||||
|
fs.mkdirSync(packageDir, { recursive: true });
|
||||||
|
fs.mkdirSync(extractDir, { recursive: true });
|
||||||
|
|
||||||
|
const part1 = path.join(packageDir, "legacy.old.part01.rar");
|
||||||
|
const part2 = path.join(packageDir, "legacy.old.part02.rar");
|
||||||
|
const part3 = path.join(packageDir, "legacy.old.part03.rar");
|
||||||
|
const keep = path.join(packageDir, "keep.nfo");
|
||||||
|
fs.writeFileSync(part1, "part1", "utf8");
|
||||||
|
fs.writeFileSync(part2, "part2", "utf8");
|
||||||
|
fs.writeFileSync(part3, "part3", "utf8");
|
||||||
|
fs.writeFileSync(keep, "keep", "utf8");
|
||||||
|
fs.writeFileSync(path.join(extractDir, "episode.mkv"), "video", "utf8");
|
||||||
|
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = "legacy-old-pkg";
|
||||||
|
const itemId = "legacy-old-item";
|
||||||
|
const createdAt = Date.now() - 20_000;
|
||||||
|
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId,
|
||||||
|
name: "legacy-old",
|
||||||
|
outputDir: packageDir,
|
||||||
|
extractDir,
|
||||||
|
status: "completed",
|
||||||
|
itemIds: [itemId],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
session.items[itemId] = {
|
||||||
|
id: itemId,
|
||||||
|
packageId,
|
||||||
|
url: "https://dummy/legacy-old",
|
||||||
|
provider: "realdebrid",
|
||||||
|
status: "completed",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: 123,
|
||||||
|
totalBytes: 123,
|
||||||
|
progressPercent: 100,
|
||||||
|
fileName: path.basename(part1),
|
||||||
|
targetPath: part1,
|
||||||
|
resumable: true,
|
||||||
|
attempts: 1,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Fertig (123 MB)",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
|
||||||
|
new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: false,
|
||||||
|
cleanupMode: "delete"
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => !fs.existsSync(part1) && !fs.existsSync(part2) && !fs.existsSync(part3), 5000);
|
||||||
|
expect(fs.existsSync(keep)).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(extractDir, "episode.mkv"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("resets run counters and reconnect state on start", async () => {
|
it("resets run counters and reconnect state on start", 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);
|
||||||
@ -1633,6 +1769,89 @@ describe("download manager", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps active downloads resumable on shutdown preparation", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const binary = Buffer.alloc(480 * 1024, 5);
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if ((req.url || "") !== "/shutdown") {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end("not-found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Accept-Ranges", "bytes");
|
||||||
|
res.setHeader("Content-Length", String(binary.length));
|
||||||
|
res.write(binary.subarray(0, Math.floor(binary.length / 3)));
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
res.end(binary.subarray(Math.floor(binary.length / 3)));
|
||||||
|
}
|
||||||
|
}, 2200);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(0, "127.0.0.1");
|
||||||
|
await once(server, "listening");
|
||||||
|
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("server address unavailable");
|
||||||
|
}
|
||||||
|
const directUrl = `http://127.0.0.1:${address.port}/shutdown`;
|
||||||
|
|
||||||
|
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): 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: directUrl,
|
||||||
|
filename: "shutdown.part01.rar",
|
||||||
|
filesize: binary.length
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: false,
|
||||||
|
maxParallel: 1
|
||||||
|
},
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.addPackages([{ name: "shutdown-case", links: ["https://dummy/shutdown"] }]);
|
||||||
|
const itemId = Object.values(manager.getSnapshot().session.items)[0]?.id || "";
|
||||||
|
manager.start();
|
||||||
|
await waitFor(() => manager.getSnapshot().session.items[itemId]?.status === "downloading", 12000);
|
||||||
|
|
||||||
|
manager.prepareForShutdown();
|
||||||
|
await waitFor(() => {
|
||||||
|
const state = manager.getSnapshot();
|
||||||
|
return !state.session.running && state.session.items[itemId]?.status === "queued";
|
||||||
|
}, 8000);
|
||||||
|
|
||||||
|
const item = manager.getSnapshot().session.items[itemId];
|
||||||
|
expect(item?.status).toBe("queued");
|
||||||
|
expect(item?.fullStatus).toBe("Wartet");
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
await once(server, "close");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("recovers pending extraction on startup for completed package", async () => {
|
it("recovers pending extraction on startup for completed package", 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