Add daily traffic limits, auto-sort packages, Debrid-Link multi-key improvements
Daily traffic limits: - Per-provider daily download limit (configurable in GB per provider) - Per Debrid-Link API key daily limit (individual limits per key) - Usage tracking with automatic daily reset at midnight - Provider is skipped when daily limit reached, falls back to next provider - Reset button per provider and per Debrid-Link key in account settings - Hoster routing skips daily-limited providers gracefully Debrid-Link multi-key improvements: - Keys now display with labels (#1, #2...) and masked tokens in account list - Option to show detailed per-key view with individual usage stats - Keys that hit their daily limit are automatically skipped - providerAccountId/providerAccountLabel stored per download item Auto-sort packages by progress: - Active packages automatically sorted to top during downloads - Sorted by completion ratio, then downloaded bytes - Toggle in settings (autoSortPackagesByProgress) UI polish: - Package column headers: flatter, more transparent design - LinkSnappy mode label: "Login" renamed to "Web" - Account list: new toggle for detailed Debrid-Link key display - Account usage stats section with warning styling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
71b3612e82
commit
e212ccc86f
@@ -1,5 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
|
||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||
import { DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, filenameFromRapidgatorUrlPath, normalizeResolvedFilename } from "../src/main/debrid";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -81,6 +83,100 @@ describe("debrid service", () => {
|
||||
expect(megaWeb).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("skips a provider whose daily limit is already reached and uses the next provider", async () => {
|
||||
const calledUrls: string[] = [];
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
debridLinkApiKeys: "dl-token",
|
||||
providerOrder: ["realdebrid", "debridlink"] as const,
|
||||
providerPrimary: "realdebrid" as const,
|
||||
providerSecondary: "debridlink" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: true,
|
||||
providerDailyLimitBytes: { realdebrid: 100 },
|
||||
providerDailyUsageBytes: { realdebrid: 100 },
|
||||
providerDailyUsageDay: getProviderUsageDayKey()
|
||||
};
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
calledUrls.push(url);
|
||||
if (url.includes("debrid-link.com/api/v2/downloader/add")) {
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
value: {
|
||||
downloadUrl: "https://debrid-link.example/file.bin",
|
||||
name: "file.bin",
|
||||
size: 1234
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
||||
throw new Error("Real-Debrid should have been skipped due to daily limit");
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const service = new DebridService(settings);
|
||||
const result = await service.unrestrictLink("https://hoster.example/file.bin");
|
||||
expect(result.provider).toBe("debridlink");
|
||||
expect(result.directUrl).toBe("https://debrid-link.example/file.bin");
|
||||
expect(calledUrls.some((url) => url.includes("api.real-debrid.com/rest/1.0/unrestrict/link"))).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the next Debrid-Link key when the first key hit its local daily limit", async () => {
|
||||
const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
|
||||
let usedAuthHeader = "";
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
||||
providerOrder: ["debridlink"] as const,
|
||||
providerPrimary: "debridlink" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
debridLinkApiKeyDailyLimitBytes: {
|
||||
[keys[0].id]: 100
|
||||
},
|
||||
debridLinkApiKeyDailyUsageBytes: {
|
||||
[keys[0].id]: 100
|
||||
},
|
||||
providerDailyUsageDay: getProviderUsageDayKey()
|
||||
};
|
||||
|
||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const headers = init?.headers;
|
||||
if (headers instanceof Headers) {
|
||||
usedAuthHeader = headers.get("Authorization") || "";
|
||||
} else if (Array.isArray(headers)) {
|
||||
usedAuthHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || "";
|
||||
} else {
|
||||
usedAuthHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
|
||||
}
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
value: {
|
||||
downloadUrl: "https://debrid-link.example/file.bin",
|
||||
name: "file.bin",
|
||||
size: 1234
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const service = new DebridService(settings);
|
||||
const result = await service.unrestrictLink("https://hoster.example/file.bin");
|
||||
|
||||
expect(usedAuthHeader).toBe("Bearer dl-key-two");
|
||||
expect(result.provider).toBe("debridlink");
|
||||
expect(result.providerLabel).toContain("Key 2");
|
||||
});
|
||||
|
||||
it("uses BestDebrid auth header without token query fallback", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
|
||||
@@ -7,6 +7,8 @@ import AdmZip from "adm-zip";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { DownloadManager } from "../src/main/download-manager";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||
import { createStoragePaths, emptySession } from "../src/main/storage";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -2835,7 +2837,7 @@ describe("download manager", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("retries suspicious mini files under 1 MB until the full file arrives", async () => {
|
||||
it("retries suspicious mini files under 100 KB until the full file arrives", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
const binary = Buffer.alloc(2 * 1024 * 1024, 21);
|
||||
@@ -4857,4 +4859,62 @@ describe("download manager", () => {
|
||||
expect(internal.speedEventsHead).toBe(0);
|
||||
expect(internal.speedBytesLastWindow).toBe(0);
|
||||
});
|
||||
|
||||
it("tracks daily usage on the actual provider key without touching other providers", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
megaLogin: "mega-user",
|
||||
megaPassword: "mega-pass",
|
||||
megaDebridApiEnabled: true,
|
||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||
providerDailyUsageBytes: { realdebrid: 512 }
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
const internal = manager as unknown as {
|
||||
recordProviderDownloadedBytes: (provider: "megadebrid", bytes: number) => void;
|
||||
settings: ReturnType<typeof defaultSettings>;
|
||||
};
|
||||
|
||||
internal.recordProviderDownloadedBytes("megadebrid", 1024);
|
||||
|
||||
expect(internal.settings.providerDailyUsageBytes.realdebrid).toBe(512);
|
||||
expect(internal.settings.providerDailyUsageBytes["megadebrid-api"]).toBe(1024);
|
||||
expect((internal.settings.providerDailyUsageBytes as Record<string, number>).megadebrid).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tracks daily usage on the actual Debrid-Link key without touching other keys", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
const [firstKey, secondKey] = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
|
||||
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||
providerDailyUsageBytes: { debridlink: 256 },
|
||||
debridLinkApiKeyDailyUsageBytes: { [secondKey.id]: 512 }
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
const internal = manager as unknown as {
|
||||
recordProviderDownloadedBytes: (provider: "debridlink", bytes: number, providerAccountId?: string) => void;
|
||||
settings: ReturnType<typeof defaultSettings>;
|
||||
};
|
||||
|
||||
internal.recordProviderDownloadedBytes("debridlink", 1024, firstKey.id);
|
||||
|
||||
expect(internal.settings.providerDailyUsageBytes.debridlink).toBe(1280);
|
||||
expect(internal.settings.debridLinkApiKeyDailyUsageBytes[firstKey.id]).toBe(1024);
|
||||
expect(internal.settings.debridLinkApiKeyDailyUsageBytes[secondKey.id]).toBe(512);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DownloadItem, PackageEntry } from "../src/shared/types";
|
||||
import { sortPackagesForDisplay } from "../src/renderer/package-order";
|
||||
|
||||
function createPackage(id: string, itemIds: string[]): PackageEntry {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
outputDir: "",
|
||||
extractDir: "",
|
||||
status: "queued",
|
||||
itemIds,
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
priority: "normal",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
}
|
||||
|
||||
function createItem(id: string, packageId: string, status: DownloadItem["status"], downloadedBytes: number): DownloadItem {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id,
|
||||
packageId,
|
||||
url: `https://hoster.example/${id}`,
|
||||
provider: null,
|
||||
status,
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes,
|
||||
totalBytes: downloadedBytes,
|
||||
progressPercent: downloadedBytes > 0 ? 50 : 0,
|
||||
fileName: `${id}.bin`,
|
||||
targetPath: "",
|
||||
resumable: true,
|
||||
attempts: 0,
|
||||
lastError: "",
|
||||
fullStatus: "",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
}
|
||||
|
||||
describe("sortPackagesForDisplay", () => {
|
||||
it("moves active packages with more progress to the top when auto sort is enabled", () => {
|
||||
const packages = [
|
||||
createPackage("pkg-a", ["a1", "a2"]),
|
||||
createPackage("pkg-b", ["b1", "b2"]),
|
||||
createPackage("pkg-c", ["c1"])
|
||||
];
|
||||
const items: Record<string, DownloadItem> = {
|
||||
a1: createItem("a1", "pkg-a", "downloading", 250),
|
||||
a2: createItem("a2", "pkg-a", "completed", 500),
|
||||
b1: createItem("b1", "pkg-b", "downloading", 800),
|
||||
b2: createItem("b2", "pkg-b", "completed", 900),
|
||||
c1: createItem("c1", "pkg-c", "queued", 0)
|
||||
};
|
||||
|
||||
const sorted = sortPackagesForDisplay(packages, items, true, true);
|
||||
|
||||
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-b", "pkg-a", "pkg-c"]);
|
||||
});
|
||||
|
||||
it("keeps package order untouched when auto sort is disabled", () => {
|
||||
const packages = [
|
||||
createPackage("pkg-a", ["a1"]),
|
||||
createPackage("pkg-b", ["b1"]),
|
||||
createPackage("pkg-c", ["c1"])
|
||||
];
|
||||
const items: Record<string, DownloadItem> = {
|
||||
a1: createItem("a1", "pkg-a", "queued", 0),
|
||||
b1: createItem("b1", "pkg-b", "downloading", 500),
|
||||
c1: createItem("c1", "pkg-c", "queued", 0)
|
||||
};
|
||||
|
||||
const sorted = sortPackagesForDisplay(packages, items, true, false);
|
||||
|
||||
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
|
||||
});
|
||||
});
|
||||
+42
-1
@@ -2,6 +2,8 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||
import { AppSettings } from "../src/shared/types";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { createStoragePaths, emptySession, loadSession, loadSettings, normalizeSettings, saveSession, saveSessionAsync, saveSettings } from "../src/main/storage";
|
||||
@@ -120,7 +122,8 @@ describe("settings storage", () => {
|
||||
retryLimit: "-3",
|
||||
reconnectWaitSeconds: "1",
|
||||
speedLimitMode: "not-valid",
|
||||
updateRepo: ""
|
||||
updateRepo: "",
|
||||
autoSortPackagesByProgress: false
|
||||
}),
|
||||
"utf8"
|
||||
);
|
||||
@@ -133,6 +136,7 @@ describe("settings storage", () => {
|
||||
expect(loaded.reconnectWaitSeconds).toBe(10);
|
||||
expect(loaded.speedLimitMode).toBe("global");
|
||||
expect(loaded.updateRepo).toBe(defaultSettings().updateRepo);
|
||||
expect(loaded.autoSortPackagesByProgress).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps explicit none as fallback provider choice", () => {
|
||||
@@ -176,6 +180,43 @@ describe("settings storage", () => {
|
||||
expect(webNormalized.hosterRouting.rapidgator).toBe("megadebrid-web");
|
||||
});
|
||||
|
||||
it("normalizes provider daily limits and resets stale daily usage", () => {
|
||||
const [debridLinkKey] = parseDebridLinkApiKeys("dl-key-one");
|
||||
const normalized = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
megaLogin: "mega-user",
|
||||
megaPassword: "mega-pass",
|
||||
megaDebridApiEnabled: true,
|
||||
debridLinkApiKeys: "dl-key-one",
|
||||
providerDailyLimitBytes: {
|
||||
realdebrid: 1024,
|
||||
megadebrid: 2048
|
||||
} as AppSettings["providerDailyLimitBytes"],
|
||||
debridLinkApiKeyDailyLimitBytes: {
|
||||
[debridLinkKey.id]: 3072,
|
||||
stale: 1234
|
||||
},
|
||||
providerDailyUsageDay: "2001-01-01",
|
||||
providerDailyUsageBytes: {
|
||||
realdebrid: 4096,
|
||||
megadebrid: 8192
|
||||
} as AppSettings["providerDailyUsageBytes"],
|
||||
debridLinkApiKeyDailyUsageBytes: {
|
||||
[debridLinkKey.id]: 8192,
|
||||
stale: 9999
|
||||
}
|
||||
});
|
||||
|
||||
expect(normalized.providerDailyLimitBytes.realdebrid).toBe(1024);
|
||||
expect(normalized.providerDailyLimitBytes["megadebrid-api"]).toBe(2048);
|
||||
expect(normalized.debridLinkApiKeyDailyLimitBytes).toEqual({
|
||||
[debridLinkKey.id]: 3072
|
||||
});
|
||||
expect(normalized.providerDailyUsageDay).toBe(getProviderUsageDayKey());
|
||||
expect(normalized.providerDailyUsageBytes).toEqual({});
|
||||
expect(normalized.debridLinkApiKeyDailyUsageBytes).toEqual({});
|
||||
});
|
||||
|
||||
it("normalizes archive password list line endings", () => {
|
||||
const normalized = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
|
||||
Reference in New Issue
Block a user