Persist collector state and stabilize download controls
Persist collector packages and disclosure state through validated atomic AppData storage with backup recovery and shutdown synchronization. Align download sidebar metrics with the hidden-extracted presentation scope and keep Start available through temporary Real-Debrid cooldowns while preserving hard account blocks.
This commit is contained in:
@@ -7,7 +7,8 @@ const electron = vi.hoisted(() => ({
|
||||
invoke: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => undefined),
|
||||
getPathForFile: vi.fn(() => "C:\\Imports\\dropped.dlc"),
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn()
|
||||
removeListener: vi.fn(),
|
||||
sendSync: vi.fn((_: unknown, state: unknown) => state)
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
@@ -20,6 +21,7 @@ vi.mock("electron", () => ({
|
||||
invoke: electron.invoke,
|
||||
on: electron.on,
|
||||
removeListener: electron.removeListener,
|
||||
sendSync: electron.sendSync,
|
||||
send: vi.fn()
|
||||
},
|
||||
webUtils: { getPathForFile: electron.getPathForFile }
|
||||
@@ -32,6 +34,7 @@ describe("account preload contract", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
electron.invoke.mockClear();
|
||||
electron.sendSync.mockClear();
|
||||
});
|
||||
|
||||
it("forwards submitted secrets only in write-only account commands", async () => {
|
||||
@@ -129,6 +132,20 @@ describe("account preload contract", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("loads and saves the persistent collector state through dedicated channels", async () => {
|
||||
const state = { packages: [], collapsedPackageIds: [] };
|
||||
|
||||
await electron.api?.getCollectorState();
|
||||
await electron.api?.saveCollectorState(state);
|
||||
electron.api?.saveCollectorStateSync(state);
|
||||
|
||||
expect(electron.invoke.mock.calls).toEqual([
|
||||
[IPC_CHANNELS.GET_COLLECTOR_STATE],
|
||||
[IPC_CHANNELS.SAVE_COLLECTOR_STATE, state]
|
||||
]);
|
||||
expect(electron.sendSync).toHaveBeenCalledWith(IPC_CHANNELS.SAVE_COLLECTOR_STATE_SYNC, state);
|
||||
});
|
||||
|
||||
it("resolves dropped files through Electron webUtils without IPC", () => {
|
||||
const file = { name: "dropped.dlc" } as File;
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateCollectorPersistenceState } from "../src/shared/collector";
|
||||
import {
|
||||
createCollectorPersistenceCoordinator,
|
||||
restoreCollectorPersistenceState
|
||||
} from "../src/renderer/views/collector/collector-persistence";
|
||||
|
||||
const packageEntry = {
|
||||
id: "package-one",
|
||||
name: "Staffel Eins",
|
||||
nameSource: "explicit" as const,
|
||||
links: [{
|
||||
id: "link-one",
|
||||
url: "https://1fichier.com/?example",
|
||||
fileName: "episode.part01.rar",
|
||||
fileSizeBytes: 471_859_200,
|
||||
hoster: "1fichier",
|
||||
availability: "online" as const,
|
||||
status: "ready" as const,
|
||||
addedAt: 1_000
|
||||
}],
|
||||
addedAt: 1_000
|
||||
};
|
||||
|
||||
describe("collector persistence payload", () => {
|
||||
it("accepts a complete state and prunes unknown collapsed package ids", () => {
|
||||
const input = { packages: [packageEntry], collapsedPackageIds: ["package-one", "missing", "package-one"] };
|
||||
|
||||
expect(validateCollectorPersistenceState(input)).toEqual({
|
||||
packages: [packageEntry],
|
||||
collapsedPackageIds: ["package-one"]
|
||||
});
|
||||
expect(validateCollectorPersistenceState(input)).not.toBe(input);
|
||||
});
|
||||
|
||||
it("accepts an empty collector without loosening the payload shape", () => {
|
||||
expect(validateCollectorPersistenceState({ packages: [], collapsedPackageIds: [] })).toEqual({ packages: [], collapsedPackageIds: [] });
|
||||
expect(() => validateCollectorPersistenceState({ packages: [], collapsedPackageIds: [], selectedLinkIds: [] })).toThrow("Linksammler-Speicherzustand ist ungültig");
|
||||
});
|
||||
|
||||
it("rejects invalid packages and excessive queue sizes", () => {
|
||||
expect(() => validateCollectorPersistenceState({ packages: [{ ...packageEntry, name: "" }], collapsedPackageIds: [] })).toThrow("Linksammler-Speicherzustand ist ungültig");
|
||||
expect(() => validateCollectorPersistenceState({ packages: Array.from({ length: 2_001 }, () => packageEntry), collapsedPackageIds: [] })).toThrow("Linksammler-Speicherzustand ist ungültig");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collector persistence renderer flow", () => {
|
||||
it("merges a late restore with current imports and keeps collapse state from both sides", () => {
|
||||
const persisted = { packages: [packageEntry], collapsedPackageIds: ["package-one"] };
|
||||
const currentPackage = {
|
||||
...packageEntry,
|
||||
id: "package-current",
|
||||
name: "Aktueller Import",
|
||||
links: [{ ...packageEntry.links[0], id: "link-current", url: "https://example.com/current" }]
|
||||
};
|
||||
|
||||
expect(restoreCollectorPersistenceState(persisted, [currentPackage], new Set(["package-current"]))).toEqual({
|
||||
packages: [packageEntry, currentPackage],
|
||||
collapsedPackageIds: ["package-one", "package-current"]
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes saves and lets the newest queued state win", async () => {
|
||||
const saved: string[] = [];
|
||||
let releaseFirst: () => void = () => {};
|
||||
const coordinator = createCollectorPersistenceCoordinator(async (state) => {
|
||||
saved.push(state.packages[0]?.id || "empty");
|
||||
if (saved.length === 1) await new Promise<void>((resolve) => { releaseFirst = resolve; });
|
||||
return state;
|
||||
}, 0);
|
||||
|
||||
coordinator.schedule({ packages: [{ ...packageEntry, id: "first" }], collapsedPackageIds: [] });
|
||||
const firstFlush = coordinator.flush();
|
||||
await Promise.resolve();
|
||||
coordinator.schedule({ packages: [{ ...packageEntry, id: "latest" }], collapsedPackageIds: [] });
|
||||
releaseFirst();
|
||||
await firstFlush;
|
||||
await coordinator.flush();
|
||||
|
||||
expect(saved).toEqual(["first", "latest"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { CollectorStore } from "../src/main/collector-store";
|
||||
import type { CollectorPersistenceState } from "../src/shared/collector";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
function createFilePath(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-collector-store-"));
|
||||
roots.push(root);
|
||||
return path.join(root, "collector.json");
|
||||
}
|
||||
|
||||
function state(id: string, collapsed = true): CollectorPersistenceState {
|
||||
return {
|
||||
packages: [{
|
||||
id: `package-${id}`,
|
||||
name: `Package ${id}`,
|
||||
nameSource: "explicit",
|
||||
addedAt: 1_700_000_000_000,
|
||||
links: [{
|
||||
id: `link-${id}`,
|
||||
url: `https://example.com/${id}`,
|
||||
fileName: `${id}.rar`,
|
||||
fileSizeBytes: 1024,
|
||||
hoster: "example.com",
|
||||
availability: "online",
|
||||
status: "ready",
|
||||
addedAt: 1_700_000_000_000
|
||||
}]
|
||||
}],
|
||||
collapsedPackageIds: collapsed ? [`package-${id}`] : []
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
for (const root of roots.splice(0)) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("CollectorStore", () => {
|
||||
it("starts empty when no persistence file exists", () => {
|
||||
const store = new CollectorStore(createFilePath());
|
||||
|
||||
expect(store.getState()).toEqual({ packages: [], collapsedPackageIds: [] });
|
||||
});
|
||||
|
||||
it("persists a versioned state and loads an independent clone", () => {
|
||||
const filePath = createFilePath();
|
||||
const store = new CollectorStore(filePath);
|
||||
const expected = state("roundtrip");
|
||||
|
||||
store.update(expected);
|
||||
store.flushSync();
|
||||
|
||||
const payload = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
expect(payload).toMatchObject({
|
||||
version: 1,
|
||||
packages: expected.packages,
|
||||
collapsedPackageIds: expected.collapsedPackageIds
|
||||
});
|
||||
expect(payload.updatedAt).toEqual(expect.any(Number));
|
||||
|
||||
const loaded = new CollectorStore(filePath);
|
||||
const first = loaded.getState();
|
||||
first.packages[0].name = "Changed outside";
|
||||
expect(loaded.getState()).toEqual(expected);
|
||||
});
|
||||
|
||||
it("recovers a valid backup when the primary file is corrupted", () => {
|
||||
const filePath = createFilePath();
|
||||
const expected = state("backup");
|
||||
const store = new CollectorStore(filePath);
|
||||
store.update(expected);
|
||||
store.flushSync();
|
||||
fs.writeFileSync(filePath, "{broken", "utf8");
|
||||
|
||||
const recovered = new CollectorStore(filePath);
|
||||
|
||||
expect(recovered.getState()).toEqual(expected);
|
||||
expect(JSON.parse(fs.readFileSync(filePath, "utf8"))).toMatchObject({
|
||||
version: 1,
|
||||
packages: expected.packages
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers a newer valid backup after an interrupted primary replacement", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_700_000_000_000);
|
||||
const filePath = createFilePath();
|
||||
const store = new CollectorStore(filePath);
|
||||
store.update(state("old"));
|
||||
store.flushSync();
|
||||
const oldPrimary = fs.readFileSync(filePath, "utf8");
|
||||
store.update(state("latest"));
|
||||
store.flushSync();
|
||||
fs.writeFileSync(filePath, oldPrimary, "utf8");
|
||||
|
||||
expect(new CollectorStore(filePath).getState()).toEqual(state("latest"));
|
||||
});
|
||||
|
||||
it("keeps the latest update when a scheduled write and flush overlap", async () => {
|
||||
vi.useFakeTimers();
|
||||
const filePath = createFilePath();
|
||||
const store = new CollectorStore(filePath);
|
||||
store.update(state("old"));
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
const latest = state("latest", false);
|
||||
store.update(latest);
|
||||
store.flushSync();
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(store.getState()).toEqual(latest);
|
||||
expect(new CollectorStore(filePath).getState()).toEqual(latest);
|
||||
});
|
||||
|
||||
it("coalesces pending updates into one delayed persistence", async () => {
|
||||
vi.useFakeTimers();
|
||||
const filePath = createFilePath();
|
||||
const store = new CollectorStore(filePath);
|
||||
|
||||
store.update(state("first"));
|
||||
store.update(state("second"));
|
||||
expect(fs.existsSync(filePath)).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(299);
|
||||
expect(fs.existsSync(filePath)).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
vi.useRealTimers();
|
||||
for (let attempt = 0; attempt < 50 && !fs.existsSync(filePath); attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
expect(new CollectorStore(filePath).getState()).toEqual(state("second"));
|
||||
});
|
||||
|
||||
it("backs off after a persistent write failure instead of retrying every 300 ms", async () => {
|
||||
vi.useFakeTimers();
|
||||
let attempts = 0;
|
||||
const store = new CollectorStore(createFilePath(), async () => {
|
||||
attempts += 1;
|
||||
throw Object.assign(new Error("locked"), { code: "EACCES" });
|
||||
});
|
||||
|
||||
store.update(state("blocked"));
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
expect(attempts).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(attempts).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores invalid and oversized persistence files", () => {
|
||||
const invalidPath = createFilePath();
|
||||
fs.writeFileSync(invalidPath, JSON.stringify({ version: 1, packages: "wrong", collapsedPackageIds: [], updatedAt: 1 }), "utf8");
|
||||
const oversizedPath = createFilePath();
|
||||
fs.writeFileSync(oversizedPath, "", "utf8");
|
||||
fs.truncateSync(oversizedPath, 64 * 1024 * 1024 + 1);
|
||||
|
||||
expect(new CollectorStore(invalidPath).getState()).toEqual({ packages: [], collapsedPackageIds: [] });
|
||||
expect(new CollectorStore(oversizedPath).getState()).toEqual({ packages: [], collapsedPackageIds: [] });
|
||||
});
|
||||
|
||||
it("rejects a state that would be larger than the load limit", () => {
|
||||
const store = new CollectorStore(createFilePath());
|
||||
const longUrl = `https://example.com/${"a".repeat(32_740)}`;
|
||||
const longName = "n".repeat(1_024);
|
||||
const packages = Array.from({ length: 2_000 }, (_, index) => ({
|
||||
...state(String(index)).packages[0],
|
||||
id: `package-${index}`,
|
||||
name: longName,
|
||||
links: [{
|
||||
...state(String(index)).packages[0].links[0],
|
||||
id: `link-${index}`,
|
||||
url: longUrl
|
||||
}]
|
||||
}));
|
||||
|
||||
expect(() => store.update({ packages, collapsedPackageIds: [] })).toThrow("Linksammler-Speicherzustand ist zu groß");
|
||||
});
|
||||
});
|
||||
@@ -962,6 +962,50 @@ describe("download start account gate", () => {
|
||||
expect(getProviderRuntimeSnapshot().realDebrid.accounts.find((entry) => entry.accountId === accountId)?.cooldown ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps start available after stop when the last enabled Real-Debrid account has a transient cooldown", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-survivor-cooldown-gate-"));
|
||||
tempDirs.push(root);
|
||||
const accountIds = ["rdw_first", "rdw_second", "rdw_third", "rdw_survivor"];
|
||||
const session = emptySession();
|
||||
session.running = true;
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
realDebridUseWebLogin: true,
|
||||
realDebridWebAccountIds: accountIds,
|
||||
realDebridDisabledAccountIds: accountIds.slice(0, 3),
|
||||
providerOrder: ["realdebrid"]
|
||||
},
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
primeRealDebridRuntimeCooldownForTests("rdw_survivor", 60_000, "Temporärer Providerfehler");
|
||||
|
||||
await manager.stop();
|
||||
|
||||
expect(manager.getSnapshot().session.running).toBe(false);
|
||||
expect(manager.getSnapshot().canStart).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["invalid", "rate_limit", "quota"] as const)("keeps start blocked for a Real-Debrid %s cooldown", (category) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-${category}-cooldown-gate-`));
|
||||
tempDirs.push(root);
|
||||
const accountId = `rdw_${category}`;
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
realDebridUseWebLogin: true,
|
||||
realDebridWebAccountIds: [accountId],
|
||||
providerOrder: ["realdebrid"]
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
primeRealDebridRuntimeCooldownForTests(accountId, 60_000, "Account nicht nutzbar", category);
|
||||
|
||||
expect(manager.getSnapshot().canStart).toBe(false);
|
||||
});
|
||||
|
||||
it("allows start when an active account is available", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-active-account-gate-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
formatRemainingDownloadBytes,
|
||||
formatRemainingDownloadTooltip,
|
||||
getDownloadQueueTotalBytes,
|
||||
getDownloadQueueStatusMetrics,
|
||||
getRemainingDownloadBytes,
|
||||
getPendingDownloadItemCount,
|
||||
getDownloadSpeedBps,
|
||||
@@ -897,6 +898,52 @@ describe("downloads model", () => {
|
||||
expect(hiddenExtracted.visibleItemIds).not.toContain("done");
|
||||
});
|
||||
|
||||
it("removes hidden extracted downloads from every queue-wide model source", () => {
|
||||
const model = buildDownloadsViewModel(createInput({
|
||||
packageOrder: ["visible-package", "extracted-package"],
|
||||
packages: {
|
||||
"visible-package": pkg("visible-package", "Wartend", ["visible-item"]),
|
||||
"extracted-package": pkg("extracted-package", "Entpackt", ["extracted-item"])
|
||||
},
|
||||
items: {
|
||||
"visible-item": item("visible-item", "visible-package", "queued", { provider: "debridlink", providerLabel: "Debrid-Link" }),
|
||||
"extracted-item": item("extracted-item", "extracted-package", "completed", { provider: "alldebrid", providerLabel: "AllDebrid", fullStatus: "Entpackt" })
|
||||
},
|
||||
hideExtractedItems: true
|
||||
}));
|
||||
|
||||
expect(model.counts).toEqual({ all: 1, active: 0, queued: 1, paused: 0, completed: 0, failed: 0 });
|
||||
expect(model.providerOptions).toEqual([{ id: "debridlink", label: "Debrid-Link" }]);
|
||||
expect(model.eligibleItems.map((entry) => entry.id)).toEqual(["visible-item"]);
|
||||
expect(model.eligiblePackageCount).toBe(1);
|
||||
expect(model.packageRows[0].allItems.map((entry) => entry.id)).toEqual(["visible-item"]);
|
||||
expect(model.empty).toBe(false);
|
||||
expect(model.filteredEmpty).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a queue containing only hidden extracted downloads as empty", () => {
|
||||
const model = buildDownloadsViewModel(createInput({
|
||||
packageOrder: ["extracted-package"],
|
||||
packages: { "extracted-package": pkg("extracted-package", "Entpackt", ["extracted-item"]) },
|
||||
items: { "extracted-item": item("extracted-item", "extracted-package", "completed", { fullStatus: "Entpackt" }) },
|
||||
hideExtractedItems: true
|
||||
}));
|
||||
|
||||
expect(model.eligibleItems).toEqual([]);
|
||||
expect(model.eligiblePackageCount).toBe(0);
|
||||
expect(model.counts.all).toBe(0);
|
||||
expect(model.providerOptions).toEqual([]);
|
||||
expect(model.empty).toBe(true);
|
||||
expect(model.filteredEmpty).toBe(false);
|
||||
expect(getDownloadQueueStatusMetrics(model.eligibleItems)).toEqual({
|
||||
packageCount: 0,
|
||||
pendingItemCount: 0,
|
||||
totalBytes: 0,
|
||||
remaining: { bytes: 0, unknownItems: 0 },
|
||||
hosterCount: 0
|
||||
});
|
||||
});
|
||||
|
||||
it("supports the genuine flat file mode without synthetic package rows", () => {
|
||||
const model = buildDownloadsViewModel(createInput({ displayMode: "files" }));
|
||||
|
||||
|
||||
@@ -73,6 +73,33 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("main shutdown lifecycle", () => {
|
||||
it("continues the full shutdown when the collector state cannot be flushed", async () => {
|
||||
const controller = Object.create(AppController.prototype) as any;
|
||||
controller.runtimeStatsTimer = null;
|
||||
controller.collectorStore = { flushSync: vi.fn(() => { throw new Error("collector locked"); }) };
|
||||
controller.notificationOutbox = { drainForShutdown: vi.fn(async () => undefined) };
|
||||
controller.manager = {
|
||||
suspendDownloadHealthMonitoring: vi.fn(),
|
||||
prepareForShutdown: vi.fn(),
|
||||
flushNotificationsForShutdown: vi.fn(async () => undefined)
|
||||
};
|
||||
controller.downloadHealthTimer = null;
|
||||
controller.downloadHealthEvaluation = null;
|
||||
controller.downloadHealthMonitor = null;
|
||||
controller.megaWebFallback = { dispose: vi.fn() };
|
||||
controller.realDebridWebFallbacks = new Map();
|
||||
controller.pendingRealDebridWebAccountIds = new Map();
|
||||
controller.allDebridWebFallback = { dispose: vi.fn() };
|
||||
controller.bestDebridWebFallback = { dispose: vi.fn() };
|
||||
controller.shutdownLogStorage = vi.fn();
|
||||
controller.audit = vi.fn();
|
||||
controller.settings = { historyRetentionMode: "never" };
|
||||
|
||||
await expect(controller.shutdown()).resolves.toBeUndefined();
|
||||
expect(controller.manager.prepareForShutdown).toHaveBeenCalledTimes(1);
|
||||
expect(controller.shutdownLogStorage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("AppController waits for the bounded outbox drain before disposing runtime owners", async () => {
|
||||
const drain = deferred();
|
||||
const manager = { prepareForShutdown: vi.fn() };
|
||||
|
||||
@@ -63,6 +63,9 @@ export function createVisualElectronApi(
|
||||
prepareCollectorContainers: async () => ({ packages: [], invalidCount: 0, duplicateCount: 0 }),
|
||||
enrichCollectorPackages: async (request) => ({ packages: clone(request.packages), invalidCount: 0, duplicateCount: 0 }),
|
||||
onCollectorEnrichmentProgress: () => () => {},
|
||||
getCollectorState: async () => ({ packages: [], collapsedPackageIds: [] }),
|
||||
saveCollectorState: async (state) => clone(state),
|
||||
saveCollectorStateSync: () => {},
|
||||
getPathForDroppedFile: () => "",
|
||||
getStartConflicts: async () => [],
|
||||
resolveStartConflict: async (_packageId, policy) => ({
|
||||
|
||||
Reference in New Issue
Block a user