Add encrypted Deepbrid API accounts with account validation, provider routing, fallback, usage tracking, safe error handling, and verified 1Fichier downloads. Restore persistent recurring daily starts with local-calendar deduplication and legacy schedule compatibility. Add durable Discord package, run, remaining-volume, stall, and recovery notifications with privacy-safe telemetry and disk-failure recovery.
296 lines
12 KiB
TypeScript
296 lines
12 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { defaultSettings } from "../src/main/constants";
|
|
import { DownloadManager } from "../src/main/download-manager";
|
|
import { createStoragePaths, emptySession } from "../src/main/storage";
|
|
import {
|
|
RollingAccountStatisticsAccumulator,
|
|
addStatisticsAccountBytesInPlace,
|
|
aggregateStatisticsRange,
|
|
createStatisticsLedger,
|
|
loadStatisticsLedger,
|
|
normalizeStatisticsLedger,
|
|
projectStatisticsLedger,
|
|
recordStatisticsActiveInterval,
|
|
recordStatisticsBytes,
|
|
recordStatisticsOutcome,
|
|
saveStatisticsLedger
|
|
} from "../src/main/statistics-ledger";
|
|
|
|
const roots: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const root of roots.splice(0)) {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function localTime(day: number, hour = 12): number {
|
|
return new Date(2026, 7, day, hour, 0, 0, 0).getTime();
|
|
}
|
|
|
|
describe("statistics ledger", () => {
|
|
it("normalizes and records Deepbrid provider statistics", () => {
|
|
const now = localTime(10);
|
|
let ledger = recordStatisticsBytes(createStatisticsLedger(now), "deepbrid", 2_048, now);
|
|
ledger = recordStatisticsOutcome(ledger, "deepbrid", "completed", now);
|
|
addStatisticsAccountBytesInPlace(ledger, "deepbrid", 1_024, "svc-deepbrid", "Deepbrid", now);
|
|
|
|
const normalized = normalizeStatisticsLedger(ledger, now);
|
|
expect(normalized.days[0]?.providers.deepbrid).toEqual({ bytes: 2_048, completed: 1, failed: 0 });
|
|
expect(normalized.minutes[0]?.accounts["svc-deepbrid"]).toEqual({
|
|
provider: "deepbrid",
|
|
label: "Deepbrid",
|
|
bytes: 1_024
|
|
});
|
|
});
|
|
|
|
it("migrates version one ledgers without inventing minute history", () => {
|
|
const now = localTime(10);
|
|
const legacy = {
|
|
version: 1,
|
|
startedAt: now - 1_000,
|
|
days: [{
|
|
day: "2026-08-10",
|
|
downloadedBytes: 1_024,
|
|
measuredBytes: 1_024,
|
|
completedFiles: 1,
|
|
failedFiles: 0,
|
|
activeDownloadMs: 100,
|
|
providers: { realdebrid: { bytes: 1_024, completed: 1, failed: 0 } }
|
|
}]
|
|
};
|
|
|
|
const migrated = normalizeStatisticsLedger(legacy, now);
|
|
|
|
expect(migrated.version).toBe(2);
|
|
expect(migrated.days).toEqual(legacy.days);
|
|
expect(migrated.minutes).toEqual([]);
|
|
});
|
|
|
|
it("normalizes, merges, and bounds sparse account minute history", () => {
|
|
const now = localTime(10);
|
|
const currentMinute = Math.floor(now / 60_000) * 60_000;
|
|
const keptMinute = currentMinute - (47 * 60 * 60 * 1_000);
|
|
const raw = {
|
|
version: 2,
|
|
startedAt: now - 1_000,
|
|
days: [],
|
|
minutes: [
|
|
{
|
|
minute: keptMinute,
|
|
downloadedBytes: 1,
|
|
accounts: {
|
|
rdw_one: { provider: "realdebrid", label: "secret@example.test", bytes: 20 }
|
|
}
|
|
},
|
|
{
|
|
minute: keptMinute,
|
|
downloadedBytes: 1,
|
|
accounts: {
|
|
rdw_one: { provider: "realdebrid", label: "New label", bytes: 30 },
|
|
"https://unsafe": { provider: "realdebrid", label: "Unsafe", bytes: 99 }
|
|
}
|
|
},
|
|
{
|
|
minute: currentMinute - (49 * 60 * 60 * 1_000),
|
|
accounts: { rdw_old: { provider: "realdebrid", label: "Old", bytes: 500 } }
|
|
},
|
|
{
|
|
minute: currentMinute + 120_000,
|
|
accounts: { rdw_future: { provider: "realdebrid", label: "Future", bytes: 500 } }
|
|
}
|
|
]
|
|
};
|
|
|
|
const normalized = normalizeStatisticsLedger(raw, now);
|
|
|
|
expect(normalized.minutes).toHaveLength(1);
|
|
expect(normalized.minutes[0].minute).toBe(keptMinute);
|
|
expect(normalized.minutes[0].downloadedBytes).toBe(50);
|
|
expect(normalized.minutes[0].accounts.rdw_one.bytes).toBe(50);
|
|
expect(normalized.minutes[0].accounts.rdw_one.label).not.toContain("secret@example.test");
|
|
expect(normalized.minutes[0].accounts).not.toHaveProperty("https://unsafe");
|
|
});
|
|
|
|
it("records separate accounts and projects no minute history into renderer snapshots", () => {
|
|
const now = localTime(10);
|
|
const ledger = createStatisticsLedger(now);
|
|
|
|
addStatisticsAccountBytesInPlace(ledger, "realdebrid", 50, "rdw_one", "Primary", now);
|
|
addStatisticsAccountBytesInPlace(ledger, "realdebrid", 75, "rdw_two", "Secondary", now);
|
|
addStatisticsAccountBytesInPlace(ledger, "debridlink", 25, undefined, undefined, now);
|
|
|
|
expect(ledger.minutes).toHaveLength(1);
|
|
expect(ledger.minutes[0].downloadedBytes).toBe(150);
|
|
expect(ledger.minutes[0].accounts).toMatchObject({
|
|
rdw_one: { provider: "realdebrid", label: "Primary", bytes: 50 },
|
|
rdw_two: { provider: "realdebrid", label: "Secondary", bytes: 75 },
|
|
"provider:debridlink": { provider: "debridlink", label: "Debrid-Link", bytes: 25 }
|
|
});
|
|
expect(projectStatisticsLedger(ledger, now).minutes).toEqual([]);
|
|
expect(ledger.minutes).toHaveLength(1);
|
|
});
|
|
|
|
it("maintains rolling account totals incrementally and expires the boundary minute", () => {
|
|
const now = localTime(10);
|
|
const oldMinute = now - (24 * 60 * 60 * 1_000) - 60_000;
|
|
const boundaryMinute = Math.floor((now - (24 * 60 * 60 * 1_000)) / 60_000) * 60_000;
|
|
const ledger = createStatisticsLedger(now);
|
|
addStatisticsAccountBytesInPlace(ledger, "realdebrid", 500, "rdw_old", "Old", oldMinute);
|
|
addStatisticsAccountBytesInPlace(ledger, "realdebrid", 100, "rdw_boundary", "Boundary", boundaryMinute);
|
|
const accumulator = new RollingAccountStatisticsAccumulator(ledger, now);
|
|
|
|
accumulator.record("realdebrid", 50, "rdw_one", "Primary", now);
|
|
accumulator.record("realdebrid", 75, "rdw_two", "Secondary", now);
|
|
const current = accumulator.snapshot(now);
|
|
|
|
expect(current.downloadedBytes).toBe(225);
|
|
expect(current.accounts.map((account) => [account.id, account.bytes])).toEqual([
|
|
["rdw_boundary", 100],
|
|
["rdw_two", 75],
|
|
["rdw_one", 50]
|
|
]);
|
|
expect(current.accounts).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "rdw_old" })]));
|
|
|
|
const expired = accumulator.snapshot(now + 60_000);
|
|
expect(expired.downloadedBytes).toBe(125);
|
|
expect(expired.accounts).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "rdw_boundary" })]));
|
|
});
|
|
|
|
it("aggregates every available day inside a rolling seven-day window without requiring seven complete days", () => {
|
|
let ledger = createStatisticsLedger(localTime(10));
|
|
ledger = recordStatisticsBytes(ledger, "realdebrid", 100, localTime(7));
|
|
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", localTime(7));
|
|
ledger = recordStatisticsBytes(ledger, "debridlink", 200, localTime(8));
|
|
ledger = recordStatisticsOutcome(ledger, "debridlink", "failed", localTime(8));
|
|
ledger = recordStatisticsBytes(ledger, "realdebrid", 300, localTime(10));
|
|
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", localTime(10));
|
|
|
|
const aggregate = aggregateStatisticsRange(ledger, 7, localTime(10));
|
|
|
|
expect(aggregate).toMatchObject({
|
|
downloadedBytes: 600,
|
|
completedFiles: 2,
|
|
failedFiles: 1,
|
|
coveredDays: 3
|
|
});
|
|
expect(aggregate.providers).toEqual({
|
|
debridlink: { bytes: 200, completed: 0, failed: 1 },
|
|
realdebrid: { bytes: 400, completed: 2, failed: 0 }
|
|
});
|
|
});
|
|
|
|
it("excludes data outside the requested calendar window and computes average speed from measured active time", () => {
|
|
let ledger = createStatisticsLedger(localTime(10));
|
|
ledger = recordStatisticsBytes(ledger, "realdebrid", 8_000, localTime(10));
|
|
ledger = recordStatisticsActiveInterval(ledger, localTime(10, 10), localTime(10, 10) + 2_000);
|
|
ledger = recordStatisticsBytes(ledger, "debridlink", 99_000, localTime(3));
|
|
ledger = recordStatisticsActiveInterval(ledger, localTime(3, 10), localTime(3, 10) + 1_000);
|
|
|
|
const today = aggregateStatisticsRange(ledger, 1, localTime(10));
|
|
const week = aggregateStatisticsRange(ledger, 7, localTime(10));
|
|
const month = aggregateStatisticsRange(ledger, 30, localTime(10));
|
|
|
|
expect(today).toMatchObject({ downloadedBytes: 8_000, activeDownloadMs: 2_000, averageSpeedBps: 4_000 });
|
|
expect(week.downloadedBytes).toBe(8_000);
|
|
expect(month).toMatchObject({ downloadedBytes: 107_000, activeDownloadMs: 3_000 });
|
|
});
|
|
|
|
it("splits active intervals across local calendar days", () => {
|
|
const start = new Date(2026, 7, 9, 23, 59, 59, 500).getTime();
|
|
const end = new Date(2026, 7, 10, 0, 0, 0, 500).getTime();
|
|
const ledger = recordStatisticsActiveInterval(createStatisticsLedger(start), start, end);
|
|
|
|
expect(aggregateStatisticsRange(ledger, 1, localTime(10)).activeDownloadMs).toBe(500);
|
|
expect(aggregateStatisticsRange(ledger, 1, localTime(9)).activeDownloadMs).toBe(500);
|
|
});
|
|
|
|
it("persists normalized statistics and recovers safely from malformed files", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-statistics-ledger-"));
|
|
roots.push(root);
|
|
const filePath = path.join(root, "rd_statistics.json");
|
|
const ledger = recordStatisticsOutcome(
|
|
recordStatisticsBytes(createStatisticsLedger(localTime(10)), "realdebrid", 4_096, localTime(10)),
|
|
"realdebrid",
|
|
"completed",
|
|
localTime(10)
|
|
);
|
|
|
|
saveStatisticsLedger(filePath, ledger);
|
|
expect(loadStatisticsLedger(filePath, localTime(10))).toEqual(ledger);
|
|
|
|
fs.writeFileSync(filePath, "{broken", "utf8");
|
|
expect(loadStatisticsLedger(filePath, localTime(11))).toEqual(createStatisticsLedger(localTime(11)));
|
|
});
|
|
|
|
it("retries transient Windows rename failures while preserving the statistics file", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-statistics-rename-"));
|
|
roots.push(root);
|
|
const filePath = path.join(root, "rd_statistics.json");
|
|
const ledger = recordStatisticsBytes(createStatisticsLedger(localTime(10)), "realdebrid", 8_192, localTime(10));
|
|
const rename = fs.renameSync.bind(fs);
|
|
let attempts = 0;
|
|
const spy = vi.spyOn(fs, "renameSync").mockImplementation((source, target) => {
|
|
attempts += 1;
|
|
if (attempts < 3) {
|
|
throw Object.assign(new Error("busy"), { code: "EPERM" });
|
|
}
|
|
return rename(source, target);
|
|
});
|
|
|
|
saveStatisticsLedger(filePath, ledger);
|
|
|
|
expect(attempts).toBe(3);
|
|
expect(loadStatisticsLedger(filePath, localTime(10))).toEqual(ledger);
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it("records provider bytes and terminal outcomes through the download manager and restores them after restart", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-statistics-manager-"));
|
|
roots.push(root);
|
|
const paths = createStoragePaths(root);
|
|
const session = emptySession();
|
|
session.items.item = {
|
|
id: "item",
|
|
packageId: "package",
|
|
url: "https://example.test/file",
|
|
provider: "realdebrid",
|
|
providerLabel: "Real-Debrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 4_096,
|
|
totalBytes: 4_096,
|
|
progressPercent: 100,
|
|
fileName: "file.bin",
|
|
targetPath: path.join(root, "file.bin"),
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig",
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now()
|
|
};
|
|
const manager = new DownloadManager(defaultSettings(), session, paths);
|
|
|
|
(manager as any).runItemIds.add("item");
|
|
(manager as any).recordProviderDownloadedBytes("realdebrid", 4_096);
|
|
(manager as any).recordRunOutcome("item", "completed");
|
|
manager.persistNowSync();
|
|
|
|
const current = aggregateStatisticsRange(manager.getStats().statistics, 1);
|
|
expect(current).toMatchObject({ downloadedBytes: 4_096, completedFiles: 1, failedFiles: 0 });
|
|
expect(current.providers.realdebrid).toEqual({ bytes: 4_096, completed: 1, failed: 0 });
|
|
expect(fs.existsSync(paths.statisticsFile)).toBe(true);
|
|
|
|
const restored = new DownloadManager(defaultSettings(), emptySession(), paths);
|
|
expect(aggregateStatisticsRange(restored.getStats().statistics, 1)).toMatchObject({
|
|
downloadedBytes: 4_096,
|
|
completedFiles: 1
|
|
});
|
|
});
|
|
});
|