release: prepare v2.0.18 interface and reset reliability update

Preserve package progress and history across immediate cleanup, make extraction resets wait for all post-processing tasks, and keep archive diagnostics out of compact status cells. Rework account creation and settings selectors, improve context-menu placement, remove accidental row dragging, and expand regression coverage for the corrected workflows.
This commit is contained in:
Sucukdeluxe
2026-08-11 00:46:46 +02:00
parent 9b1eeada2e
commit ef5c20c8df
25 changed files with 1953 additions and 952 deletions
+363 -33
View File
@@ -17,7 +17,8 @@ import { createStoragePaths, emptySession } from "../src/main/storage";
import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
import { UnrestrictedLink } from "../src/main/realdebrid";
import { UnrestrictedLink } from "../src/main/realdebrid";
import type { HistoryEntry } from "../src/shared/types";
const tempDirs: string[] = [];
const originalFetch = globalThis.fetch;
@@ -1134,7 +1135,7 @@ describe("download manager", () => {
};
try {
const manager = new DownloadManager(
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
@@ -1144,10 +1145,9 @@ describe("download manager", () => {
autoReconnect: false
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "retry", links: ["https://dummy/retry"] }]);
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "retry", links: ["https://dummy/retry"] }]);
await manager.start();
await waitFor(() => !manager.getSnapshot().session.running, 25000);
@@ -7487,7 +7487,84 @@ describe("download manager", () => {
expect(snap.settings.providerDailyUsageBytes || {}).toEqual({});
});
it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => {
it("resets extraction state atomically for selected package items", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "reset-extraction-package";
const createdAt = Date.now();
const itemIds = ["reset-a", "reset-b", "reset-c"];
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "reset-extraction",
outputDir: path.join(root, "downloads", "reset-extraction"),
extractDir: path.join(root, "extract", "reset-extraction"),
status: "extracting",
itemIds,
cancelled: false,
enabled: true,
postProcessLabel: "release.part1.rar",
downloadCompletedAt: createdAt,
createdAt,
updatedAt: createdAt
};
for (const itemId of itemIds) {
session.items[itemId] = {
id: itemId,
packageId,
url: `https://dummy/${itemId}`,
provider: "megadebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 1_000,
totalBytes: 1_000,
progressPercent: 100,
fileName: `${itemId}.part1.rar`,
targetPath: path.join(root, "downloads", "reset-extraction", `${itemId}.part1.rar`),
resumable: true,
attempts: 1,
lastError: "Unerwartetes Dateiende",
fullStatus: `Entpack-Fehler [${itemId}.part1.rar]: Unerwartetes Dateiende`,
onlineStatus: "online",
createdAt,
updatedAt: createdAt
};
}
const manager = new DownloadManager(
{
...defaultSettings(),
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: true
},
session,
createStoragePaths(path.join(root, "state"))
);
manager.resetItems(itemIds);
const snapshot = manager.getSnapshot().session;
expect(snapshot.packages[packageId]).toEqual(expect.objectContaining({
status: "queued",
postProcessLabel: undefined,
downloadCompletedAt: 0
}));
for (const itemId of itemIds) {
expect(snapshot.items[itemId]).toEqual(expect.objectContaining({
status: "queued",
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
lastError: "",
fullStatus: "Wartet",
onlineStatus: undefined
}));
}
});
it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
@@ -8004,7 +8081,7 @@ describe("download manager", () => {
}
}, 25000);
it("creates extract directory only at extraction and marks items as Entpackt", async () => {
it("creates extract directory only at extraction and marks items as Entpackt", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
@@ -8069,9 +8146,14 @@ describe("download manager", () => {
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "zip-pack", links: ["https://dummy/archive"] }]);
const pkgId = manager.getSnapshot().session.packageOrder[0];
const extractDir = manager.getSnapshot().session.packages[pkgId]?.extractDir || "";
manager.addPackages([{ name: "zip-pack", links: ["https://dummy/archive"] }]);
const pkgId = manager.getSnapshot().session.packageOrder[0];
const completedPostProcessLabels: Array<string | undefined> = [];
manager.on("state", (state) => {
const emittedPackage = state.session.packages[pkgId];
if (emittedPackage?.status === "completed") completedPostProcessLabels.push(emittedPackage.postProcessLabel);
});
const extractDir = manager.getSnapshot().session.packages[pkgId]?.extractDir || "";
expect(extractDir).toBeTruthy();
expect(fs.existsSync(extractDir)).toBe(false);
@@ -8079,12 +8161,18 @@ describe("download manager", () => {
await new Promise((resolve) => setTimeout(resolve, 140));
expect(fs.existsSync(extractDir)).toBe(false);
await waitFor(() => fs.existsSync(path.join(extractDir, "inside.txt")), 30000);
const snapshot = manager.getSnapshot();
const item = Object.values(snapshot.session.items)[0];
expect(item?.status).toBe("completed");
expect(item?.fullStatus.startsWith("Entpackt - Done")).toBe(true);
await waitFor(() => fs.existsSync(path.join(extractDir, "inside.txt")), 30000);
await waitFor(() => {
const current = manager.getSnapshot().session.packages[pkgId];
return current?.status === "completed" && current.postProcessLabel === undefined;
}, 30000);
const snapshot = manager.getSnapshot();
const item = Object.values(snapshot.session.items)[0];
expect(item?.status).toBe("completed");
expect(item?.fullStatus.startsWith("Entpackt - Done")).toBe(true);
expect(snapshot.session.packages[pkgId]?.postProcessLabel).toBeUndefined();
expect(completedPostProcessLabels.every((label) => label === undefined)).toBe(true);
expect(fs.existsSync(extractDir)).toBe(true);
expect(fs.existsSync(path.join(extractDir, "inside.txt"))).toBe(true);
} finally {
@@ -8093,7 +8181,7 @@ describe("download manager", () => {
}
}, 35000);
it("keeps accurate summary when completed items are cleaned immediately", async () => {
it("keeps accurate summary when completed items are cleaned immediately", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const binary = Buffer.alloc(128 * 1024, 3);
@@ -8167,7 +8255,244 @@ describe("download manager", () => {
server.close();
await once(server, "close");
}
});
});
it("preserves completed package progress when immediate cleanup removes a finished item", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "cleanup-progress-package";
const completedItemId = "cleanup-progress-completed";
const queuedItemId = "cleanup-progress-queued";
const createdAt = Date.now();
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "cleanup-progress",
outputDir: path.join(root, "downloads", "cleanup-progress"),
extractDir: path.join(root, "extract", "cleanup-progress"),
status: "downloading",
itemIds: [completedItemId, queuedItemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
session.items[completedItemId] = {
id: completedItemId,
packageId,
url: "https://dummy/completed",
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 1_000,
totalBytes: 1_000,
progressPercent: 100,
fileName: "completed.rar",
targetPath: path.join(root, "downloads", "cleanup-progress", "completed.rar"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Entpackt - Fertig",
createdAt,
updatedAt: createdAt
};
session.items[queuedItemId] = {
...session.items[completedItemId],
id: queuedItemId,
url: "https://dummy/queued",
status: "queued",
downloadedBytes: 0,
progressPercent: 0,
fileName: "queued.rar",
targetPath: path.join(root, "downloads", "cleanup-progress", "queued.rar"),
fullStatus: "Wartet"
};
const manager = new DownloadManager(
{
...defaultSettings(),
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: true,
completedCleanupPolicy: "immediate"
},
session,
createStoragePaths(path.join(root, "state"))
);
(manager as any).applyCompletedCleanupPolicy(packageId, completedItemId);
(manager as any).applyCompletedCleanupPolicy(packageId, completedItemId);
const packageEntry = manager.getSnapshot().session.packages[packageId];
expect(packageEntry.itemIds).toEqual([queuedItemId]);
expect(packageEntry.cleanedCompletedItemCount).toBe(1);
expect(packageEntry.cleanedExtractedItemCount).toBe(1);
expect(packageEntry.cleanedDownloadedBytes).toBe(1_000);
expect(packageEntry.cleanedTotalBytes).toBe(1_000);
});
it("includes immediately cleaned items in the final package history entry", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "cleanup-history-package";
const firstItemId = "cleanup-history-first";
const secondItemId = "cleanup-history-second";
const createdAt = Date.now() - 5_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "cleanup-history",
outputDir: path.join(root, "downloads", "cleanup-history"),
extractDir: path.join(root, "extract", "cleanup-history"),
status: "downloading",
itemIds: [firstItemId, secondItemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
session.items[firstItemId] = {
id: firstItemId,
packageId,
url: "https://dummy/first",
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 1_000,
totalBytes: 1_000,
progressPercent: 100,
fileName: "first.rar",
targetPath: path.join(root, "downloads", "cleanup-history", "first.rar"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Entpackt - Fertig",
createdAt,
updatedAt: createdAt
};
session.items[secondItemId] = {
...session.items[firstItemId],
id: secondItemId,
url: "https://dummy/second",
provider: "megadebrid-api",
status: "queued",
downloadedBytes: 2_000,
totalBytes: 2_000,
fileName: "second.rar",
targetPath: path.join(root, "downloads", "cleanup-history", "second.rar"),
fullStatus: "Wartet"
};
const history: HistoryEntry[] = [];
const manager = new DownloadManager(
{
...defaultSettings(),
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: true,
completedCleanupPolicy: "immediate"
},
session,
createStoragePaths(path.join(root, "state")),
{ onHistoryEntry: (entry) => history.push(entry) }
);
(manager as any).applyCompletedCleanupPolicy(packageId, firstItemId);
const pkg = (manager as any).session.packages[packageId];
(manager as any).session.items[secondItemId].status = "completed";
(manager as any).session.items[secondItemId].fullStatus = "Entpackt - Fertig";
pkg.status = "completed";
(manager as any).recordPackageHistory(packageId, pkg, [(manager as any).session.items[secondItemId]]);
expect(history).toHaveLength(1);
expect(history[0]).toMatchObject({
totalBytes: 3_000,
downloadedBytes: 3_000,
fileCount: 2,
provider: null,
urls: ["https://dummy/first", "https://dummy/second"]
});
history.length = 0;
(manager as any).historyRecordedPackages.delete(packageId);
(manager as any).removePackageFromSession(packageId, [secondItemId], "deleted");
expect(history).toHaveLength(1);
expect(history[0]).toMatchObject({
totalBytes: 3_000,
downloadedBytes: 3_000,
fileCount: 2,
provider: null,
status: "deleted",
urls: ["https://dummy/first", "https://dummy/second"]
});
});
it("waits for aborted package post-processing before restarting reset items", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "reset-race-package";
const itemId = "reset-race-item";
const createdAt = Date.now() - 5_000;
session.running = true;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "reset-race",
outputDir: path.join(root, "downloads", "reset-race"),
extractDir: path.join(root, "extract", "reset-race"),
status: "failed",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://dummy/reset-race",
provider: "realdebrid",
status: "failed",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: 1_000,
progressPercent: 0,
fileName: "reset-race.rar",
targetPath: "",
resumable: true,
attempts: 1,
lastError: "extract failed",
fullStatus: "Entpack-Fehler",
createdAt,
updatedAt: createdAt
};
const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state")));
let releaseTask = (): void => {};
const task = new Promise<void>((resolve) => { releaseTask = resolve; });
let releaseHybridTask = (): void => {};
const hybridTask = new Promise<void>((resolve) => { releaseHybridTask = resolve; });
const internal = manager as any;
internal.session.running = true;
internal.packagePostProcessTasks.set(packageId, task);
internal.packagePostProcessAbortControllers.set(packageId, new AbortController());
internal.packageHybridPostProcessTasks.set(packageId, new Set([hybridTask]));
internal.packageHybridPostProcessControllers.set(packageId, new Set([new AbortController()]));
internal.ensureScheduler = vi.fn(async () => {});
const resetPromise = Promise.resolve(manager.resetItems([itemId]));
await Promise.resolve();
expect(internal.ensureScheduler).not.toHaveBeenCalled();
releaseTask();
await Promise.resolve();
expect(internal.ensureScheduler).not.toHaveBeenCalled();
releaseHybridTask();
await resetPromise;
expect(internal.ensureScheduler).toHaveBeenCalledTimes(1);
});
it("removes finished package when package_done cleanup policy is enabled", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
@@ -9772,15 +10097,17 @@ describe("download manager", () => {
const createdAt = Date.now() - 20_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "Deferred Reset",
outputDir: sharedDir,
extractDir: sharedDir,
status: "completed",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt,
id: packageId,
name: "Deferred Reset",
outputDir: sharedDir,
extractDir: sharedDir,
status: "completed",
itemIds: [itemId],
cancelled: false,
enabled: true,
downloadStartedAt: createdAt,
downloadCompletedAt: createdAt + 10_000,
createdAt,
updatedAt: createdAt
};
session.items[itemId] = {
@@ -9841,14 +10168,17 @@ describe("download manager", () => {
);
await waitFor(() => renameStarted, 4000);
manager.resetPackage(packageId);
releaseRename();
await deferredPromise;
const resetPromise = manager.resetPackage(packageId);
releaseRename();
await deferredPromise;
await resetPromise;
expect(cleanupRemainingArchiveArtifacts).not.toHaveBeenCalled();
const snapshot = manager.getSnapshot();
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
expect(snapshot.session.items[itemId]?.status).toBe("queued");
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
expect(snapshot.session.packages[packageId]?.downloadStartedAt).toBe(0);
expect(snapshot.session.packages[packageId]?.downloadCompletedAt).toBe(0);
expect(snapshot.session.items[itemId]?.status).toBe("queued");
});
it("does not let cancelled cleanup delete archives for a re-added package in the same folder", async () => {