release: harden provider rotation and download recovery
Apply account and provider changes to active conversions without restarting, isolate API and Web state, and abort the exact fallback attempt when settings change. Bound resume recovery, make disk reservations abortable, preserve cleanup totals and history, stabilize compact UI state, and canonicalize RapidGator host aliases. Expand bounded support diagnostics while redacting account identities, local paths, package names, and file names from current and rotated logs. Add regression coverage for rotation, live settings, HTTP 416 recovery, disk waits, cleanup, context menus, history failures, and support bundle privacy.
This commit is contained in:
@@ -2,6 +2,40 @@
|
|||||||
|
|
||||||
All notable changes to Multi-Debrid Downloader are documented in this file.
|
All notable changes to Multi-Debrid Downloader are documented in this file.
|
||||||
|
|
||||||
|
## [2.0.33] - 2026-08-13
|
||||||
|
|
||||||
|
### Account and provider lifecycle
|
||||||
|
|
||||||
|
- Separated Mega-Debrid API and Web account status, rotation position, sticky selection, cooldowns, and reloaded status records.
|
||||||
|
- Applied credential, access-mode, provider enable, routing, and account-pool changes to active link conversions without requiring an application restart.
|
||||||
|
- Tracked the provider currently handling each fallback attempt so live provider changes abort the exact in-flight conversion before stale credentials can start a download.
|
||||||
|
- Cleared stale provider cooldowns when accounts or providers are re-enabled and immediately retried affected queued work through the current routing rules.
|
||||||
|
- Made Real-Debrid, AllDebrid, and BestDebrid Web conversions abortable even when a remote login callback does not observe cancellation.
|
||||||
|
- Closed and discarded crashed Real-Debrid and AllDebrid login windows so a new login attempt starts with a clean browser session.
|
||||||
|
|
||||||
|
### Download and recovery reliability
|
||||||
|
|
||||||
|
- Bounded HTTP 416 clean-restart recovery across application restarts and preserved locked partial files without reporting false progress or looping indefinitely.
|
||||||
|
- Updated disk reservations from the authoritative HTTP content length before writing data when a provider did not report the final file size.
|
||||||
|
- Made post-header disk capacity checks abortable so Pause and Stop release the response and reservation queue even when a volume query stalls.
|
||||||
|
- Preserved completed package totals and history values when automatic cleanup removes finished files or packages.
|
||||||
|
- Kept failed and recovery-relevant queue entries represented in bounded support diagnostics even when large queues contain newer waiting items.
|
||||||
|
|
||||||
|
### Interface and history consistency
|
||||||
|
|
||||||
|
- Kept context menus inside the current window bounds while the application is resized.
|
||||||
|
- Shortened visible provider, archive extraction, and recovery errors while retaining their diagnostic details outside the compact status column.
|
||||||
|
- Canonicalized RapidGator and rg.to history entries as one hoster.
|
||||||
|
- Preserved mode-specific account status records across settings reloads.
|
||||||
|
- Prevented automatic history cleanup failures from blocking startup, retained the previous retention setting when a requested cleanup fails, and surfaced manual deletion failures accurately.
|
||||||
|
|
||||||
|
### Support privacy
|
||||||
|
|
||||||
|
- Removed configured account identifiers and credential-derived provider details from support summaries.
|
||||||
|
- Redacted package and file names discovered inside embedded package and item logs before writing the support archive.
|
||||||
|
- Redacted snapshot and runtime package and file names from current and rotated main downloader logs included in support archives.
|
||||||
|
- Prioritized active, failed, and recovery-pending diagnostics while keeping support bundle collection bounded and responsive.
|
||||||
|
|
||||||
## [2.0.32] - 2026-08-13
|
## [2.0.32] - 2026-08-13
|
||||||
|
|
||||||
### Account controls
|
### Account controls
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.32",
|
"version": "2.0.33",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.32",
|
"version": "2.0.33",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "0.6.0",
|
"adm-zip": "0.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.32",
|
"version": "2.0.33",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AppSettings, DebridAccountStatus } from "../shared/types";
|
import type { AppSettings, DebridAccountStatus } from "../shared/types";
|
||||||
import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
|
import { getMegaDebridAccountsForMode, getMegaDebridAccountStatusId, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
|
||||||
import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/debrid-link-keys";
|
import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/debrid-link-keys";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { compactErrorText } from "./utils";
|
import { compactErrorText } from "./utils";
|
||||||
@@ -49,7 +49,7 @@ export async function checkMegaDebridAccount(
|
|||||||
now = Date.now()
|
now = Date.now()
|
||||||
): Promise<DebridAccountStatus> {
|
): Promise<DebridAccountStatus> {
|
||||||
const base: DebridAccountStatus = {
|
const base: DebridAccountStatus = {
|
||||||
accountId: account.id,
|
accountId: account.mode ? getMegaDebridAccountStatusId(account.id, account.mode) : account.id,
|
||||||
provider: "megadebrid",
|
provider: "megadebrid",
|
||||||
label: account.label,
|
label: account.label,
|
||||||
maskedLogin: account.maskedLogin,
|
maskedLogin: account.maskedLogin,
|
||||||
@@ -163,7 +163,8 @@ export async function checkAllDebridAccounts(
|
|||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<DebridAccountStatus[]> {
|
): Promise<DebridAccountStatus[]> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
|
const megaAccounts = (["api", "web"] as const)
|
||||||
|
.flatMap((mode) => getMegaDebridAccountsForMode(settings, mode));
|
||||||
const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
|
const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
|
||||||
|
|
||||||
const taskFns: Array<() => Promise<DebridAccountStatus>> = [
|
const taskFns: Array<() => Promise<DebridAccountStatus>> = [
|
||||||
|
|||||||
@@ -310,6 +310,15 @@ export class AllDebridWebFallback {
|
|||||||
externalHosts: ALLDEBRID_LOGIN_HOSTS
|
externalHosts: ALLDEBRID_LOGIN_HOSTS
|
||||||
});
|
});
|
||||||
window.setMenuBarVisibility(false);
|
window.setMenuBarVisibility(false);
|
||||||
|
window.webContents.on("render-process-gone", () => {
|
||||||
|
if (this.loginWindow === window) {
|
||||||
|
this.loginWindow = null;
|
||||||
|
this.loginWindowPartition = "";
|
||||||
|
}
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
window.on("closed", () => {
|
window.on("closed", () => {
|
||||||
if (this.loginWindow === window) {
|
if (this.loginWindow === window) {
|
||||||
this.loginWindow = null;
|
this.loginWindow = null;
|
||||||
|
|||||||
+22
-11
@@ -474,6 +474,16 @@ export class AppController {
|
|||||||
return previousSettings;
|
return previousSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode;
|
||||||
|
const historyLimitsChanged = previousSettings.historyMaxEntries !== nextSettings.historyMaxEntries
|
||||||
|
|| previousSettings.historyMaxAgeDays !== nextSettings.historyMaxAgeDays;
|
||||||
|
if (retentionChanged && !resetHistoryForRetention(this.storagePaths, nextSettings.historyRetentionMode)) {
|
||||||
|
this.audit("ERROR", "Verlaufseinstellung nicht geändert", {
|
||||||
|
requestedMode: nextSettings.historyRetentionMode,
|
||||||
|
activeMode: previousSettings.historyRetentionMode
|
||||||
|
});
|
||||||
|
return previousSettings;
|
||||||
|
}
|
||||||
if (previousSettings.logStorageLocation !== nextSettings.logStorageLocation
|
if (previousSettings.logStorageLocation !== nextSettings.logStorageLocation
|
||||||
&& !this.reconfigureLogStorage(nextSettings.logStorageLocation)) {
|
&& !this.reconfigureLogStorage(nextSettings.logStorageLocation)) {
|
||||||
nextSettings = normalizeSettings({
|
nextSettings = normalizeSettings({
|
||||||
@@ -482,13 +492,8 @@ export class AppController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.overlayLiveUsageCounters(nextSettings);
|
this.overlayLiveUsageCounters(nextSettings);
|
||||||
const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode;
|
|
||||||
const historyLimitsChanged = previousSettings.historyMaxEntries !== nextSettings.historyMaxEntries
|
|
||||||
|| previousSettings.historyMaxAgeDays !== nextSettings.historyMaxAgeDays;
|
|
||||||
this.settings = nextSettings;
|
this.settings = nextSettings;
|
||||||
if (retentionChanged) {
|
if (historyLimitsChanged && this.settings.historyRetentionMode !== "never") {
|
||||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
|
||||||
} else if (historyLimitsChanged && this.settings.historyRetentionMode !== "never") {
|
|
||||||
saveHistory(this.storagePaths, loadHistory(this.storagePaths), this.historyLimits());
|
saveHistory(this.storagePaths, loadHistory(this.storagePaths), this.historyLimits());
|
||||||
}
|
}
|
||||||
saveSettings(this.storagePaths, this.settings);
|
saveSettings(this.storagePaths, this.settings);
|
||||||
@@ -1080,9 +1085,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
shutdownAccountRotationLog();
|
shutdownAccountRotationLog();
|
||||||
shutdownConversionLog();
|
shutdownConversionLog();
|
||||||
shutdownAuditLog();
|
shutdownAuditLog();
|
||||||
if (this.settings.historyRetentionMode === "session") {
|
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode === "session" ? "session" : "permanent");
|
||||||
clearHistory(this.storagePaths);
|
|
||||||
}
|
|
||||||
logger.info("App beendet");
|
logger.info("App beendet");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1151,8 +1154,16 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public clearHistory(): void {
|
public clearHistory(): void {
|
||||||
this.audit("WARN", "Verlauf geleert");
|
try {
|
||||||
clearHistory(this.storagePaths);
|
clearHistory(this.storagePaths);
|
||||||
|
this.audit("WARN", "Verlauf geleert");
|
||||||
|
} catch (error) {
|
||||||
|
const code = error && typeof error === "object" && "code" in error
|
||||||
|
? String((error as NodeJS.ErrnoException).code || "UNKNOWN")
|
||||||
|
: "UNKNOWN";
|
||||||
|
this.audit("ERROR", "Verlauf konnte nicht geleert werden", { code });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public setPackagePriority(packageId: string, priority: PackagePriority): void {
|
public setPackagePriority(packageId: string, priority: PackagePriority): void {
|
||||||
|
|||||||
+30
-16
@@ -311,8 +311,10 @@ function getMegaDebridAbortMinRunMs(): number {
|
|||||||
const megaDebridEmptyResponseStreaks = new Map<string, number>();
|
const megaDebridEmptyResponseStreaks = new Map<string, number>();
|
||||||
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 10;
|
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 10;
|
||||||
|
|
||||||
let megaDebridRotationCursor = 0;
|
const megaDebridRotationState: Record<"api" | "web", { cursor: number; stickyCount: number }> = {
|
||||||
let megaDebridStickyCount = 0;
|
api: { cursor: 0, stickyCount: 0 },
|
||||||
|
web: { cursor: 0, stickyCount: 0 }
|
||||||
|
};
|
||||||
// Mega-Web cacht Sessions pro Account (~20 Min). Wuerde jede Link-Aufloesung den
|
// Mega-Web cacht Sessions pro Account (~20 Min). Wuerde jede Link-Aufloesung den
|
||||||
// Account wechseln (reines Round-Robin), zahlte JEDER Link einen kalten Login in
|
// Account wechseln (reines Round-Robin), zahlte JEDER Link einen kalten Login in
|
||||||
// die serielle Single-Flight-Queue → minutenlanger Vorlauf. Stattdessen bleibt die
|
// die serielle Single-Flight-Queue → minutenlanger Vorlauf. Stattdessen bleibt die
|
||||||
@@ -371,8 +373,8 @@ export function clearMegaDebridAccountRuntimeStates(accountKeys: Iterable<string
|
|||||||
export function resetMegaDebridRuntimeStateForTests(): void {
|
export function resetMegaDebridRuntimeStateForTests(): void {
|
||||||
megaDebridAccountCooldowns.clear();
|
megaDebridAccountCooldowns.clear();
|
||||||
megaDebridEmptyResponseStreaks.clear();
|
megaDebridEmptyResponseStreaks.clear();
|
||||||
megaDebridRotationCursor = 0;
|
megaDebridRotationState.api = { cursor: 0, stickyCount: 0 };
|
||||||
megaDebridStickyCount = 0;
|
megaDebridRotationState.web = { cursor: 0, stickyCount: 0 };
|
||||||
megaDebridInFlight.clear();
|
megaDebridInFlight.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,8 +573,8 @@ export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSna
|
|||||||
return {
|
return {
|
||||||
capturedAtMs: now,
|
capturedAtMs: now,
|
||||||
megaDebrid: {
|
megaDebrid: {
|
||||||
rotationCursor: megaDebridRotationCursor,
|
rotationCursor: Math.max(megaDebridRotationState.api.cursor, megaDebridRotationState.web.cursor),
|
||||||
stickyCount: megaDebridStickyCount,
|
stickyCount: Math.max(megaDebridRotationState.api.stickyCount, megaDebridRotationState.web.stickyCount),
|
||||||
accounts: megaAccounts
|
accounts: megaAccounts
|
||||||
},
|
},
|
||||||
debridLink: { keys: dlKeys, hostCooldowns: dlHostCooldowns }
|
debridLink: { keys: dlKeys, hostCooldowns: dlHostCooldowns }
|
||||||
@@ -2160,7 +2162,8 @@ class MegaDebridClient {
|
|||||||
// Checks bleiben. Der Cursor wird erst im Erfolgszweig weitergesetzt — nach einem
|
// Checks bleiben. Der Cursor wird erst im Erfolgszweig weitergesetzt — nach einem
|
||||||
// Schwung erfolgreicher Umwandlungen (MEGA_DEBRID_STICKY_LINKS) — sodass aufeinander
|
// Schwung erfolgreicher Umwandlungen (MEGA_DEBRID_STICKY_LINKS) — sodass aufeinander
|
||||||
// folgende Links auf demselben warmen Account laufen statt jeweils neu einzuloggen.
|
// folgende Links auf demselben warmen Account laufen statt jeweils neu einzuloggen.
|
||||||
const startOffset = ((megaDebridRotationCursor % accounts.length) + accounts.length) % accounts.length;
|
const rotationState = megaDebridRotationState[mode];
|
||||||
|
const startOffset = ((rotationState.cursor % accounts.length) + accounts.length) % accounts.length;
|
||||||
const cursorOrder: { account: MegaDebridAccountEntry; idx: number }[] = [];
|
const cursorOrder: { account: MegaDebridAccountEntry; idx: number }[] = [];
|
||||||
for (let step = 0; step < accounts.length; step += 1) {
|
for (let step = 0; step < accounts.length; step += 1) {
|
||||||
const idx = (startOffset + step) % accounts.length;
|
const idx = (startOffset + step) % accounts.length;
|
||||||
@@ -2238,12 +2241,12 @@ class MegaDebridClient {
|
|||||||
clearMegaDebridEmptyResponseStreak(cooldownKey);
|
clearMegaDebridEmptyResponseStreak(cooldownKey);
|
||||||
const elapsedMs = Date.now() - testStartedAt;
|
const elapsedMs = Date.now() - testStartedAt;
|
||||||
traceConversionPhase({ phase: "mega-account", provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", account: rotationLabel, workMs: elapsedMs, outcome: "ok" });
|
traceConversionPhase({ phase: "mega-account", provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", account: rotationLabel, workMs: elapsedMs, outcome: "ok" });
|
||||||
megaDebridStickyCount += 1;
|
rotationState.stickyCount += 1;
|
||||||
if (megaDebridStickyCount >= MEGA_DEBRID_STICKY_LINKS) {
|
if (rotationState.stickyCount >= MEGA_DEBRID_STICKY_LINKS) {
|
||||||
megaDebridRotationCursor = idx + 1;
|
rotationState.cursor = idx + 1;
|
||||||
megaDebridStickyCount = 0;
|
rotationState.stickyCount = 0;
|
||||||
} else {
|
} else {
|
||||||
megaDebridRotationCursor = idx;
|
rotationState.cursor = idx;
|
||||||
}
|
}
|
||||||
logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`);
|
logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`);
|
||||||
logAccountRotation("INFO", providerName, rotationLabel, "OK", {
|
logAccountRotation("INFO", providerName, rotationLabel, "OK", {
|
||||||
@@ -3977,7 +3980,13 @@ export class DebridService {
|
|||||||
return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`;
|
return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings, preferredLeadProvider?: DebridProvider | null): Promise<ProviderUnrestrictedLink> {
|
public async unrestrictLink(
|
||||||
|
link: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
settingsSnapshot?: AppSettings,
|
||||||
|
preferredLeadProvider?: DebridProvider | null,
|
||||||
|
onProviderAttempt?: (provider: DebridProvider) => void
|
||||||
|
): Promise<ProviderUnrestrictedLink> {
|
||||||
const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);
|
const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);
|
||||||
|
|
||||||
const routing = settings.hosterRouting || {};
|
const routing = settings.hosterRouting || {};
|
||||||
@@ -3987,6 +3996,7 @@ export class DebridService {
|
|||||||
if (this.isProviderSelectableFor(settings, routedProvider)) {
|
if (this.isProviderSelectableFor(settings, routedProvider)) {
|
||||||
logger.info(`Hoster-Zuordnung: ${hosterKey} → ${PROVIDER_LABELS[routedProvider]}`);
|
logger.info(`Hoster-Zuordnung: ${hosterKey} → ${PROVIDER_LABELS[routedProvider]}`);
|
||||||
try {
|
try {
|
||||||
|
onProviderAttempt?.(routedProvider);
|
||||||
const result = await this.unrestrictViaProvider(settings, routedProvider, link, signal);
|
const result = await this.unrestrictViaProvider(settings, routedProvider, link, signal);
|
||||||
let fileName = result.fileName;
|
let fileName = result.fileName;
|
||||||
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
|
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
|
||||||
@@ -4018,6 +4028,7 @@ export class DebridService {
|
|||||||
|
|
||||||
if (ONEFICHIER_URL_RE.test(link) && this.isProviderSelectableFor(settings, "onefichier")) {
|
if (ONEFICHIER_URL_RE.test(link) && this.isProviderSelectableFor(settings, "onefichier")) {
|
||||||
try {
|
try {
|
||||||
|
onProviderAttempt?.("onefichier");
|
||||||
const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal);
|
const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
@@ -4037,6 +4048,7 @@ export class DebridService {
|
|||||||
|
|
||||||
if (DDOWNLOAD_URL_RE.test(link) && this.isProviderSelectableFor(settings, "ddownload")) {
|
if (DDOWNLOAD_URL_RE.test(link) && this.isProviderSelectableFor(settings, "ddownload")) {
|
||||||
try {
|
try {
|
||||||
|
onProviderAttempt?.("ddownload");
|
||||||
const result = await this.unrestrictViaProvider(settings, "ddownload", link, signal);
|
const result = await this.unrestrictViaProvider(settings, "ddownload", link, signal);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
@@ -4071,6 +4083,7 @@ export class DebridService {
|
|||||||
throw new Error(this.formatProviderLimitMessage(settings, primary));
|
throw new Error(this.formatProviderLimitMessage(settings, primary));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
onProviderAttempt?.(selectedProvider);
|
||||||
const result = await this.unrestrictViaProvider(settings, selectedProvider, link, signal);
|
const result = await this.unrestrictViaProvider(settings, selectedProvider, link, signal);
|
||||||
let fileName = result.fileName;
|
let fileName = result.fileName;
|
||||||
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
|
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
|
||||||
@@ -4113,6 +4126,7 @@ export class DebridService {
|
|||||||
const providerStartedAt = Date.now();
|
const providerStartedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
logger.info(`Provider-Kette: versuche ${PROVIDER_LABELS[provider]}`);
|
logger.info(`Provider-Kette: versuche ${PROVIDER_LABELS[provider]}`);
|
||||||
|
onProviderAttempt?.(provider);
|
||||||
traceConversionPhase({ phase: "chain-try", provider });
|
traceConversionPhase({ phase: "chain-try", provider });
|
||||||
const result = await this.unrestrictViaProvider(settings, provider, link, signal);
|
const result = await this.unrestrictViaProvider(settings, provider, link, signal);
|
||||||
traceConversionPhase({ phase: "chain-ok", provider, workMs: Date.now() - providerStartedAt, outcome: "ok" });
|
traceConversionPhase({ phase: "chain-ok", provider, workMs: Date.now() - providerStartedAt, outcome: "ok" });
|
||||||
@@ -4196,7 +4210,7 @@ export class DebridService {
|
|||||||
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
|
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
|
||||||
if (effectiveProvider === "realdebrid") {
|
if (effectiveProvider === "realdebrid") {
|
||||||
if (this.shouldUseRealDebridWeb(settings) && this.options.realDebridWebUnrestrict) {
|
if (this.shouldUseRealDebridWeb(settings) && this.options.realDebridWebUnrestrict) {
|
||||||
const result = await this.options.realDebridWebUnrestrict(link, signal);
|
const result = await waitForPromiseWithSignal(this.options.realDebridWebUnrestrict(link, signal), signal);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error("Real-Debrid-Web-Fallback nicht verfügbar");
|
throw new Error("Real-Debrid-Web-Fallback nicht verfügbar");
|
||||||
}
|
}
|
||||||
@@ -4215,7 +4229,7 @@ export class DebridService {
|
|||||||
}
|
}
|
||||||
if (effectiveProvider === "alldebrid") {
|
if (effectiveProvider === "alldebrid") {
|
||||||
if (this.shouldUseAllDebridWeb(settings) && this.options.allDebridWebUnrestrict) {
|
if (this.shouldUseAllDebridWeb(settings) && this.options.allDebridWebUnrestrict) {
|
||||||
const result = await this.options.allDebridWebUnrestrict(link, signal);
|
const result = await waitForPromiseWithSignal(this.options.allDebridWebUnrestrict(link, signal), signal);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error("AllDebrid-Web-Fallback nicht verfügbar");
|
throw new Error("AllDebrid-Web-Fallback nicht verfügbar");
|
||||||
}
|
}
|
||||||
@@ -4241,7 +4255,7 @@ export class DebridService {
|
|||||||
return this.getLinkSnappyClient(settings.linkSnappyLogin, settings.linkSnappyPassword).unrestrictLink(link, signal);
|
return this.getLinkSnappyClient(settings.linkSnappyLogin, settings.linkSnappyPassword).unrestrictLink(link, signal);
|
||||||
}
|
}
|
||||||
if (this.shouldUseBestDebridWeb(settings) && this.options.bestDebridWebUnrestrict) {
|
if (this.shouldUseBestDebridWeb(settings) && this.options.bestDebridWebUnrestrict) {
|
||||||
const bdResult = await this.options.bestDebridWebUnrestrict(link, signal);
|
const bdResult = await waitForPromiseWithSignal(this.options.bestDebridWebUnrestrict(link, signal), signal);
|
||||||
if (!bdResult) {
|
if (!bdResult) {
|
||||||
throw new Error("BestDebrid-Web-Fallback nicht verfügbar");
|
throw new Error("BestDebrid-Web-Fallback nicht verfügbar");
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-3
@@ -16,6 +16,7 @@ export type DiskReservationRequest = {
|
|||||||
targetPath: string;
|
targetPath: string;
|
||||||
requiredBytes: number | null;
|
requiredBytes: number | null;
|
||||||
alreadyPresentBytes?: number;
|
alreadyPresentBytes?: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiskWaitEvent = {
|
export type DiskWaitEvent = {
|
||||||
@@ -46,7 +47,7 @@ type DiskReservationCoordinatorOptions = {
|
|||||||
statVolume?: (targetPath: string) => Promise<DiskVolumeStats>;
|
statVolume?: (targetPath: string) => Promise<DiskVolumeStats>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DiskReservationUpdate = Pick<DiskReservationRequest, "requiredBytes" | "alreadyPresentBytes">;
|
type DiskReservationUpdate = Pick<DiskReservationRequest, "requiredBytes" | "alreadyPresentBytes" | "signal">;
|
||||||
|
|
||||||
export type DiskReservationLease = {
|
export type DiskReservationLease = {
|
||||||
readonly volumeKey: string | null;
|
readonly volumeKey: string | null;
|
||||||
@@ -85,6 +86,31 @@ async function defaultStatVolume(targetPath: string): Promise<DiskVolumeStats> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function waitForDiskOperation<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||||
|
if (!signal) return operation;
|
||||||
|
if (signal.aborted) {
|
||||||
|
void operation.catch(() => {});
|
||||||
|
return Promise.reject(new Error("aborted:disk-reservation"));
|
||||||
|
}
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const onAbort = (): void => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
reject(new Error("aborted:disk-reservation"));
|
||||||
|
};
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
void operation.then(
|
||||||
|
(value) => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export class DiskReservationCoordinator {
|
export class DiskReservationCoordinator {
|
||||||
private readonly safetyBytes: number;
|
private readonly safetyBytes: number;
|
||||||
private readonly retryDelayMs: number;
|
private readonly retryDelayMs: number;
|
||||||
@@ -105,7 +131,7 @@ export class DiskReservationCoordinator {
|
|||||||
return this.enqueue(async () => {
|
return this.enqueue(async () => {
|
||||||
const requiredBytes = calculateRemainingReservationBytes(request.requiredBytes, request.alreadyPresentBytes ?? 0);
|
const requiredBytes = calculateRemainingReservationBytes(request.requiredBytes, request.alreadyPresentBytes ?? 0);
|
||||||
if (requiredBytes === null) return this.createLease(request.ownerId, request.targetPath, null, 0);
|
if (requiredBytes === null) return this.createLease(request.ownerId, request.targetPath, null, 0);
|
||||||
const volume = await this.statVolume(request.targetPath);
|
const volume = await waitForDiskOperation(this.statVolume(request.targetPath), request.signal);
|
||||||
const reserved = this.reservedByVolume.get(volume.volumeKey) ?? 0;
|
const reserved = this.reservedByVolume.get(volume.volumeKey) ?? 0;
|
||||||
const availableBytes = Math.max(0, Math.floor(volume.freeBytes) - reserved - this.safetyBytes);
|
const availableBytes = Math.max(0, Math.floor(volume.freeBytes) - reserved - this.safetyBytes);
|
||||||
if (requiredBytes > availableBytes) {
|
if (requiredBytes > availableBytes) {
|
||||||
@@ -149,7 +175,7 @@ export class DiskReservationCoordinator {
|
|||||||
if (nextBytes === null) return;
|
if (nextBytes === null) return;
|
||||||
const delta = nextBytes - lease.reservedBytes;
|
const delta = nextBytes - lease.reservedBytes;
|
||||||
if (delta > 0) {
|
if (delta > 0) {
|
||||||
const volume = await coordinator.statVolume(lease.targetPath);
|
const volume = await waitForDiskOperation(coordinator.statVolume(lease.targetPath), update.signal);
|
||||||
const available = Math.max(0, Math.floor(volume.freeBytes) - (coordinator.reservedByVolume.get(volumeKey) ?? 0) - coordinator.safetyBytes);
|
const available = Math.max(0, Math.floor(volume.freeBytes) - (coordinator.reservedByVolume.get(volumeKey) ?? 0) - coordinator.safetyBytes);
|
||||||
if (delta > available) throw new DiskCapacityError({ phase: "download", ownerId, volumeKey, requiredBytes: nextBytes, availableBytes: available, deficitBytes: delta - available, safetyBytes: coordinator.safetyBytes, retryAt: coordinator.now() + coordinator.retryDelayMs });
|
if (delta > available) throw new DiskCapacityError({ phase: "download", ownerId, volumeKey, requiredBytes: nextBytes, availableBytes: available, deficitBytes: delta - available, safetyBytes: coordinator.safetyBytes, retryAt: coordinator.now() + coordinator.retryDelayMs });
|
||||||
}
|
}
|
||||||
|
|||||||
+152
-26
@@ -86,6 +86,7 @@ type ActiveTask = {
|
|||||||
stallRetries?: number;
|
stallRetries?: number;
|
||||||
genericErrorRetries?: number;
|
genericErrorRetries?: number;
|
||||||
unrestrictRetries?: number;
|
unrestrictRetries?: number;
|
||||||
|
validationProvider?: DebridProvider | null;
|
||||||
blockedOnDiskWrite?: boolean;
|
blockedOnDiskWrite?: boolean;
|
||||||
blockedOnDiskSince?: number;
|
blockedOnDiskSince?: number;
|
||||||
};
|
};
|
||||||
@@ -2328,6 +2329,13 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean; settingsOnlyImport?: boolean }): void {
|
public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean; settingsOnlyImport?: boolean }): void {
|
||||||
const previous = this.settings;
|
const previous = this.settings;
|
||||||
|
const activeValidationProviders = new Map<string, DebridProvider | null>();
|
||||||
|
for (const active of this.activeTasks.values()) {
|
||||||
|
const item = this.session.items[active.itemId];
|
||||||
|
if (item?.status === "validating") {
|
||||||
|
activeValidationProviders.set(active.itemId, active.validationProvider || item.provider || this.getExpectedProviderForItem(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
const previousMegaAccounts = (["api", "web"] as const)
|
const previousMegaAccounts = (["api", "web"] as const)
|
||||||
.flatMap((mode) => getAvailableMegaDebridAccounts(previous, mode).map((account) => ({ mode, account })));
|
.flatMap((mode) => getAvailableMegaDebridAccounts(previous, mode).map((account) => ({ mode, account })));
|
||||||
const previousMegaPoolEntries = new Map<string, string>(previousMegaAccounts.map(({ mode, account }) => [`${account.id}:${mode}`, account.password]));
|
const previousMegaPoolEntries = new Map<string, string>(previousMegaAccounts.map(({ mode, account }) => [`${account.id}:${mode}`, account.password]));
|
||||||
@@ -2383,7 +2391,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const nextOrder = JSON.stringify(next.providerOrder ?? []);
|
const nextOrder = JSON.stringify(next.providerOrder ?? []);
|
||||||
const prevRouting = JSON.stringify(previous.hosterRouting ?? {});
|
const prevRouting = JSON.stringify(previous.hosterRouting ?? {});
|
||||||
const nextRouting = JSON.stringify(next.hosterRouting ?? {});
|
const nextRouting = JSON.stringify(next.hosterRouting ?? {});
|
||||||
if (!opts?.settingsOnlyImport && (prevOrder !== nextOrder || prevRouting !== nextRouting)) {
|
const downloadRoutingChanged = prevOrder !== nextOrder || prevRouting !== nextRouting;
|
||||||
|
if (!opts?.settingsOnlyImport && downloadRoutingChanged) {
|
||||||
const activeItemIds = new Set([...this.activeTasks.values()].map((t) => t.itemId));
|
const activeItemIds = new Set([...this.activeTasks.values()].map((t) => t.itemId));
|
||||||
for (const item of Object.values(this.session.items)) {
|
for (const item of Object.values(this.session.items)) {
|
||||||
if (!activeItemIds.has(item.id) && item.status !== "completed" && item.status !== "failed") {
|
if (!activeItemIds.has(item.id) && item.status !== "completed" && item.status !== "failed") {
|
||||||
@@ -2402,23 +2411,55 @@ export class DownloadManager extends EventEmitter {
|
|||||||
logger.info(`Archiv-Passwortliste geaendert (${pwCount} Eintraege): Extractor-Caches zurueckgesetzt (learned=${reset.learnedCleared}, daemonRestart=${reset.daemonRestarted})`);
|
logger.info(`Archiv-Passwortliste geaendert (${pwCount} Eintraege): Extractor-Caches zurueckgesetzt (learned=${reset.learnedCleared}, daemonRestart=${reset.daemonRestarted})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const credChanges: Array<{ prev: string; next: string; providers: string[] }> = [
|
const disabledProviderFingerprint = (settings: AppSettings, provider: DebridProvider): string => String((settings.disabledProviders || []).includes(provider));
|
||||||
{ prev: previous.token || "", next: next.token || "", providers: ["realdebrid"] },
|
const credChanges: Array<{ prev: string; next: string; providers: DebridProvider[] }> = [
|
||||||
{ prev: previous.allDebridToken || "", next: next.allDebridToken || "", providers: ["alldebrid"] },
|
|
||||||
{ prev: previous.bestToken || "", next: next.bestToken || "", providers: ["bestdebrid"] },
|
|
||||||
{ prev: previousDebridLinkPool, next: nextDebridLinkPool, providers: ["debridlink"] },
|
|
||||||
{ prev: previous.linkSnappyLogin + "|" + previous.linkSnappyPassword, next: next.linkSnappyLogin + "|" + next.linkSnappyPassword, providers: ["linksnappy"] },
|
|
||||||
{ prev: previous.ddownloadLogin + "|" + previous.ddownloadPassword, next: next.ddownloadLogin + "|" + next.ddownloadPassword, providers: ["ddownload"] },
|
|
||||||
{
|
{
|
||||||
prev: `${previous.megaDebridApiCredentials}|${previous.megaDebridWebCredentials}|${previous.megaDebridApiEnabled}|${previous.megaDebridWebEnabled}`,
|
prev: `${previous.token || ""}|${previous.realDebridUseWebLogin}|${disabledProviderFingerprint(previous, "realdebrid")}`,
|
||||||
next: `${next.megaDebridApiCredentials}|${next.megaDebridWebCredentials}|${next.megaDebridApiEnabled}|${next.megaDebridWebEnabled}`,
|
next: `${next.token || ""}|${next.realDebridUseWebLogin}|${disabledProviderFingerprint(next, "realdebrid")}`,
|
||||||
|
providers: ["realdebrid"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prev: `${previous.allDebridToken || ""}|${previous.allDebridUseWebLogin}|${disabledProviderFingerprint(previous, "alldebrid")}`,
|
||||||
|
next: `${next.allDebridToken || ""}|${next.allDebridUseWebLogin}|${disabledProviderFingerprint(next, "alldebrid")}`,
|
||||||
|
providers: ["alldebrid"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prev: `${previous.bestToken || ""}|${previous.bestDebridUseWebLogin}|${disabledProviderFingerprint(previous, "bestdebrid")}`,
|
||||||
|
next: `${next.bestToken || ""}|${next.bestDebridUseWebLogin}|${disabledProviderFingerprint(next, "bestdebrid")}`,
|
||||||
|
providers: ["bestdebrid"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prev: `${previousDebridLinkPool}|${disabledProviderFingerprint(previous, "debridlink")}`,
|
||||||
|
next: `${nextDebridLinkPool}|${disabledProviderFingerprint(next, "debridlink")}`,
|
||||||
|
providers: ["debridlink"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prev: `${previous.linkSnappyLogin}|${previous.linkSnappyPassword}|${disabledProviderFingerprint(previous, "linksnappy")}`,
|
||||||
|
next: `${next.linkSnappyLogin}|${next.linkSnappyPassword}|${disabledProviderFingerprint(next, "linksnappy")}`,
|
||||||
|
providers: ["linksnappy"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prev: `${previous.ddownloadLogin}|${previous.ddownloadPassword}|${disabledProviderFingerprint(previous, "ddownload")}`,
|
||||||
|
next: `${next.ddownloadLogin}|${next.ddownloadPassword}|${disabledProviderFingerprint(next, "ddownload")}`,
|
||||||
|
providers: ["ddownload"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prev: `${previous.oneFichierApiKey}|${disabledProviderFingerprint(previous, "onefichier")}`,
|
||||||
|
next: `${next.oneFichierApiKey}|${disabledProviderFingerprint(next, "onefichier")}`,
|
||||||
|
providers: ["onefichier"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prev: `${previous.megaDebridApiCredentials}|${previous.megaDebridWebCredentials}|${previous.megaDebridApiEnabled}|${previous.megaDebridWebEnabled}|${disabledProviderFingerprint(previous, "megadebrid")}|${disabledProviderFingerprint(previous, "megadebrid-api")}|${disabledProviderFingerprint(previous, "megadebrid-web")}`,
|
||||||
|
next: `${next.megaDebridApiCredentials}|${next.megaDebridWebCredentials}|${next.megaDebridApiEnabled}|${next.megaDebridWebEnabled}|${disabledProviderFingerprint(next, "megadebrid")}|${disabledProviderFingerprint(next, "megadebrid-api")}|${disabledProviderFingerprint(next, "megadebrid-web")}`,
|
||||||
providers: ["megadebrid", "megadebrid-api", "megadebrid-web"]
|
providers: ["megadebrid", "megadebrid-api", "megadebrid-web"]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
const changedProviders = new Set<DebridProvider>();
|
||||||
let clearedProviderFailures = 0;
|
let clearedProviderFailures = 0;
|
||||||
for (const change of credChanges) {
|
for (const change of credChanges) {
|
||||||
if (change.prev === change.next) continue;
|
if (change.prev === change.next) continue;
|
||||||
for (const provider of change.providers) {
|
for (const provider of change.providers) {
|
||||||
|
changedProviders.add(provider);
|
||||||
for (const key of [...this.providerFailures.keys()]) {
|
for (const key of [...this.providerFailures.keys()]) {
|
||||||
if (key === provider || key.startsWith(`${provider}:`)) {
|
if (key === provider || key.startsWith(`${provider}:`)) {
|
||||||
this.providerFailures.delete(key);
|
this.providerFailures.delete(key);
|
||||||
@@ -2459,7 +2500,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (!item || item.status !== "validating") {
|
if (!item || item.status !== "validating") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const provider = String(item.provider || this.getExpectedProviderForItem(item) || "");
|
const provider = String(active.validationProvider || item.provider || this.getExpectedProviderForItem(item) || "");
|
||||||
if (provider !== "megadebrid" && provider !== "megadebrid-api" && provider !== "megadebrid-web") {
|
if (provider !== "megadebrid" && provider !== "megadebrid-api" && provider !== "megadebrid-web") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -2474,7 +2515,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (!item || item.status !== "validating") {
|
if (!item || item.status !== "validating") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const provider = String(item.provider || this.getExpectedProviderForItem(item) || "");
|
const provider = String(active.validationProvider || item.provider || this.getExpectedProviderForItem(item) || "");
|
||||||
if (provider !== "debridlink") {
|
if (provider !== "debridlink") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -2483,6 +2524,21 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!opts?.settingsOnlyImport && (downloadRoutingChanged || changedProviders.size > 0)) {
|
||||||
|
for (const active of this.activeTasks.values()) {
|
||||||
|
const item = this.session.items[active.itemId];
|
||||||
|
if (!item || item.status !== "validating" || active.abortController.signal.aborted) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const provider = activeValidationProviders.get(active.itemId) || item.provider || null;
|
||||||
|
if (!downloadRoutingChanged && (!provider || !changedProviders.has(provider))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
active.abortReason = "settings_refresh";
|
||||||
|
active.abortController.abort("settings_refresh");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!opts?.settingsOnlyImport && this.session.running) {
|
if (!opts?.settingsOnlyImport && this.session.running) {
|
||||||
if (!this.hasUsableDownloadAccount()) {
|
if (!this.hasUsableDownloadAccount()) {
|
||||||
if (!this.session.paused) {
|
if (!this.session.paused) {
|
||||||
@@ -6578,6 +6634,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
logger.info(`applyOnStartCleanupPolicy: entferne ${completedItemIds.length} completed Items aus Paket ${pkg.name} (${pkg.itemIds.length} Items verbleiben)`);
|
logger.info(`applyOnStartCleanupPolicy: entferne ${completedItemIds.length} completed Items aus Paket ${pkg.name} (${pkg.itemIds.length} Items verbleiben)`);
|
||||||
}
|
}
|
||||||
for (const itemId of completedItemIds) {
|
for (const itemId of completedItemIds) {
|
||||||
|
const item = this.session.items[itemId];
|
||||||
|
if (item) {
|
||||||
|
this.captureCompletedItemCleanup(pkg, item);
|
||||||
|
}
|
||||||
delete this.session.items[itemId];
|
delete this.session.items[itemId];
|
||||||
this.itemCount = Math.max(0, this.itemCount - 1);
|
this.itemCount = Math.max(0, this.itemCount - 1);
|
||||||
}
|
}
|
||||||
@@ -6614,6 +6674,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.removePackageFromSession(pkgId, completedItemIds);
|
this.removePackageFromSession(pkgId, completedItemIds);
|
||||||
} else {
|
} else {
|
||||||
for (const itemId of completedItemIds) {
|
for (const itemId of completedItemIds) {
|
||||||
|
const item = this.session.items[itemId];
|
||||||
|
if (item) {
|
||||||
|
this.captureCompletedItemCleanup(pkg, item);
|
||||||
|
}
|
||||||
delete this.session.items[itemId];
|
delete this.session.items[itemId];
|
||||||
this.itemCount = Math.max(0, this.itemCount - 1);
|
this.itemCount = Math.max(0, this.itemCount - 1);
|
||||||
}
|
}
|
||||||
@@ -9321,7 +9385,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.emitState();
|
this.emitState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
delete item.http416FreshRestarts;
|
item.http416FreshRestarts = Math.max(freshRestarts, MAX_HTTP416_FRESH_RESTARTS);
|
||||||
item.status = "failed";
|
item.status = "failed";
|
||||||
this.recordRunOutcome(item.id, "failed");
|
this.recordRunOutcome(item.id, "failed");
|
||||||
item.lastError = errorText;
|
item.lastError = errorText;
|
||||||
@@ -9543,7 +9607,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
traceConversionNote("slots", this.describeSlotOccupancy());
|
traceConversionNote("slots", this.describeSlotOccupancy());
|
||||||
traceConversionNote("retry", Number(active.unrestrictRetries || 0));
|
traceConversionNote("retry", Number(active.unrestrictRetries || 0));
|
||||||
try {
|
try {
|
||||||
return await this.debridService.unrestrictLink(item.url, unrestrictedSignal, undefined, preferredLeadProvider);
|
return await this.debridService.unrestrictLink(
|
||||||
|
item.url,
|
||||||
|
unrestrictedSignal,
|
||||||
|
undefined,
|
||||||
|
preferredLeadProvider,
|
||||||
|
(provider) => {
|
||||||
|
active.validationProvider = provider;
|
||||||
|
}
|
||||||
|
);
|
||||||
} catch (innerError) {
|
} catch (innerError) {
|
||||||
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
||||||
traceConversionPhase({
|
traceConversionPhase({
|
||||||
@@ -9615,7 +9687,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
ownerId: item.id,
|
ownerId: item.id,
|
||||||
targetPath: item.targetPath,
|
targetPath: item.targetPath,
|
||||||
requiredBytes: item.totalBytes,
|
requiredBytes: item.totalBytes,
|
||||||
alreadyPresentBytes: item.downloadedBytes
|
alreadyPresentBytes: item.downloadedBytes,
|
||||||
|
signal: active.abortController.signal
|
||||||
});
|
});
|
||||||
this.diskLeasesByOwner.get(item.id)?.release();
|
this.diskLeasesByOwner.get(item.id)?.release();
|
||||||
this.diskLeasesByOwner.set(item.id, diskLease);
|
this.diskLeasesByOwner.set(item.id, diskLease);
|
||||||
@@ -10004,6 +10077,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.retryStateByItem.delete(item.id);
|
this.retryStateByItem.delete(item.id);
|
||||||
} else {
|
} else {
|
||||||
const errorText = compactErrorText(error);
|
const errorText = compactErrorText(error);
|
||||||
|
if (error instanceof DiskCapacityError) {
|
||||||
|
this.recordDiskWait(error.event, { itemId: item.id, packageId: pkg.id });
|
||||||
|
this.releaseTargetPath(item.id);
|
||||||
|
this.queueRetry(item, active, Math.max(1000, error.event.retryAt - nowMs()), "Warte auf Festplatte");
|
||||||
|
item.lastError = "Nicht genügend freier Speicherplatz";
|
||||||
|
this.persistSoon();
|
||||||
|
this.emitState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.tryFinalizeItemFromDisk(pkg, item, "Error-Recovery", errorText)) {
|
if (this.tryFinalizeItemFromDisk(pkg, item, "Error-Recovery", errorText)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -10764,6 +10846,36 @@ export class DownloadManager extends EventEmitter {
|
|||||||
} else if (contentLength > 0) {
|
} else if (contentLength > 0) {
|
||||||
item.totalBytes = response.status === 206 ? existingBytes + contentLength : contentLength;
|
item.totalBytes = response.status === 206 ? existingBytes + contentLength : contentLength;
|
||||||
}
|
}
|
||||||
|
if (item.totalBytes && item.totalBytes > 0) {
|
||||||
|
const existingLease = this.diskLeasesByOwner.get(item.id);
|
||||||
|
try {
|
||||||
|
if (existingLease?.volumeKey) {
|
||||||
|
await existingLease.update({
|
||||||
|
requiredBytes: item.totalBytes,
|
||||||
|
alreadyPresentBytes: existingBytes,
|
||||||
|
signal: active.abortController.signal
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
existingLease?.release();
|
||||||
|
const updatedLease = await this.diskReservations.reserve({
|
||||||
|
phase: "download",
|
||||||
|
ownerId: item.id,
|
||||||
|
targetPath: effectiveTargetPath,
|
||||||
|
requiredBytes: item.totalBytes,
|
||||||
|
alreadyPresentBytes: existingBytes,
|
||||||
|
signal: active.abortController.signal
|
||||||
|
});
|
||||||
|
this.diskLeasesByOwner.set(item.id, updatedLease);
|
||||||
|
}
|
||||||
|
this.resolveDiskWait(item.id, "download");
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await response.body?.cancel();
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
const completionPlan = planDownloadCompletion({
|
const completionPlan = planDownloadCompletion({
|
||||||
existingBytes,
|
existingBytes,
|
||||||
responseStatus: response.status,
|
responseStatus: response.status,
|
||||||
@@ -11414,6 +11526,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (active.abortController.signal.aborted || String(error).includes("aborted:")) {
|
if (active.abortController.signal.aborted || String(error).includes("aborted:")) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
if (error instanceof DiskCapacityError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
lastError = compactErrorText(error);
|
lastError = compactErrorText(error);
|
||||||
const normalizedLastError = lastError.replace(/^Error:\s*/i, "");
|
const normalizedLastError = lastError.replace(/^Error:\s*/i, "");
|
||||||
const diskCause = classifyDiskError(error);
|
const diskCause = classifyDiskError(error);
|
||||||
@@ -11541,6 +11656,13 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const hasZeroByteArchive = await this.hasZeroByteArchiveArtifact(item);
|
const hasZeroByteArchive = await this.hasZeroByteArchiveArtifact(item);
|
||||||
|
|
||||||
if (item.status === "failed") {
|
if (item.status === "failed") {
|
||||||
|
if (is416Failure && Math.max(0, Number(item.http416FreshRestarts || 0)) >= MAX_HTTP416_FRESH_RESTARTS) {
|
||||||
|
logger.warn(
|
||||||
|
`Auto-Retry-Recovery (${trigger}) übersprungen: HTTP-416-Budget ausgeschöpft ` +
|
||||||
|
`für item=${item.fileName || item.id}, freshRestarts=${item.http416FreshRestarts}/${MAX_HTTP416_FRESH_RESTARTS}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!is416Failure && !hasZeroByteArchive && item.retries >= maxAutoRetryFailures) {
|
if (!is416Failure && !hasZeroByteArchive && item.retries >= maxAutoRetryFailures) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -13334,17 +13456,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pkg.cleanedCompletedItemCount = Math.max(0, Number(pkg.cleanedCompletedItemCount || 0)) + 1;
|
this.captureCompletedItemCleanup(pkg, item);
|
||||||
if (isExtractedLabel(item.fullStatus || "")) {
|
|
||||||
pkg.cleanedExtractedItemCount = Math.max(0, Number(pkg.cleanedExtractedItemCount || 0)) + 1;
|
|
||||||
}
|
|
||||||
pkg.cleanedDownloadedBytes = Math.max(0, Number(pkg.cleanedDownloadedBytes || 0)) + Math.max(0, item.downloadedBytes || 0);
|
|
||||||
pkg.cleanedTotalBytes = Math.max(0, Number(pkg.cleanedTotalBytes || 0)) + Math.max(0, item.totalBytes || item.downloadedBytes || 0);
|
|
||||||
pkg.cleanedUrls = [...new Set([...(pkg.cleanedUrls || []), item.url].filter(Boolean))];
|
|
||||||
pkg.cleanedProviders = item.provider
|
|
||||||
? [...new Set([...(pkg.cleanedProviders || []), item.provider])]
|
|
||||||
: [...(pkg.cleanedProviders || [])];
|
|
||||||
pkg.updatedAt = nowMs();
|
|
||||||
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
|
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
|
||||||
this.releaseTargetPath(itemId);
|
this.releaseTargetPath(itemId);
|
||||||
this.dropItemContribution(itemId);
|
this.dropItemContribution(itemId);
|
||||||
@@ -13384,6 +13496,20 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private captureCompletedItemCleanup(pkg: PackageEntry, item: DownloadItem): void {
|
||||||
|
pkg.cleanedCompletedItemCount = Math.max(0, Number(pkg.cleanedCompletedItemCount || 0)) + 1;
|
||||||
|
if (isExtractedLabel(item.fullStatus || "")) {
|
||||||
|
pkg.cleanedExtractedItemCount = Math.max(0, Number(pkg.cleanedExtractedItemCount || 0)) + 1;
|
||||||
|
}
|
||||||
|
pkg.cleanedDownloadedBytes = Math.max(0, Number(pkg.cleanedDownloadedBytes || 0)) + Math.max(0, item.downloadedBytes || 0);
|
||||||
|
pkg.cleanedTotalBytes = Math.max(0, Number(pkg.cleanedTotalBytes || 0)) + Math.max(0, item.totalBytes || item.downloadedBytes || 0);
|
||||||
|
pkg.cleanedUrls = [...new Set([...(pkg.cleanedUrls || []), item.url].filter(Boolean))];
|
||||||
|
pkg.cleanedProviders = item.provider
|
||||||
|
? [...new Set([...(pkg.cleanedProviders || []), item.provider])]
|
||||||
|
: [...(pkg.cleanedProviders || [])];
|
||||||
|
pkg.updatedAt = nowMs();
|
||||||
|
}
|
||||||
|
|
||||||
private finishRun(): void {
|
private finishRun(): void {
|
||||||
const runStartedAt = this.session.runStartedAt;
|
const runStartedAt = this.session.runStartedAt;
|
||||||
this.session.running = false;
|
this.session.running = false;
|
||||||
|
|||||||
@@ -232,6 +232,15 @@ export class RealDebridWebFallback {
|
|||||||
window.webContents.on("did-finish-load", primeFromWindow);
|
window.webContents.on("did-finish-load", primeFromWindow);
|
||||||
window.webContents.on("did-navigate", primeFromWindow);
|
window.webContents.on("did-navigate", primeFromWindow);
|
||||||
window.webContents.on("did-navigate-in-page", primeFromWindow);
|
window.webContents.on("did-navigate-in-page", primeFromWindow);
|
||||||
|
window.webContents.on("render-process-gone", () => {
|
||||||
|
if (this.loginWindow === window) {
|
||||||
|
this.loginWindow = null;
|
||||||
|
this.loginWindowPartition = "";
|
||||||
|
}
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
window.on("close", () => {
|
window.on("close", () => {
|
||||||
void this.primeTokenFromWindow(window);
|
void this.primeTokenFromWindow(window);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||||
import { getMegaDebridAccountsForMode, getMegaDebridDisabledAccountIdsForMode } from "../shared/mega-debrid-accounts";
|
import { getMegaDebridAccountsForMode, getMegaDebridAccountStatusId, getMegaDebridDisabledAccountIdsForMode } from "../shared/mega-debrid-accounts";
|
||||||
import type { AppSettings, DebridAccountStatus, DebridProvider, RendererAccount, RendererAccountKind, RendererSettings } from "../shared/types";
|
import type { AppSettings, DebridAccountStatus, DebridProvider, RendererAccount, RendererAccountKind, RendererSettings } from "../shared/types";
|
||||||
import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus } from "./account-status-sanitizer";
|
import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus } from "./account-status-sanitizer";
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ export function createRendererAccounts(settings: AppSettings): RendererAccount[]
|
|||||||
dailyLimitBytes: settings.megaDebridAccountDailyLimitBytes[account.id] || 0,
|
dailyLimitBytes: settings.megaDebridAccountDailyLimitBytes[account.id] || 0,
|
||||||
dailyUsageBytes: settings.megaDebridAccountDailyUsageBytes[account.id] || 0,
|
dailyUsageBytes: settings.megaDebridAccountDailyUsageBytes[account.id] || 0,
|
||||||
totalUsageBytes: settings.megaDebridAccountTotalUsageBytes[account.id] || 0,
|
totalUsageBytes: settings.megaDebridAccountTotalUsageBytes[account.id] || 0,
|
||||||
status: safeStatus(settings.debridAccountStatuses[account.id], redactions)
|
status: safeStatus(settings.debridAccountStatuses[getMegaDebridAccountStatusId(account.id, mode)], redactions)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||||
import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools } from "../shared/mega-debrid-accounts";
|
import { getMegaDebridAccountIds, getMegaDebridAccountStatusId, mergeMegaDebridCredentialPools } from "../shared/mega-debrid-accounts";
|
||||||
import type { AppSettings } from "../shared/types";
|
import type { AppSettings } from "../shared/types";
|
||||||
|
|
||||||
export function overlayLiveUsageCounters(target: AppSettings, liveSettings: AppSettings, liveTotalRuntimeMs: number): void {
|
export function overlayLiveUsageCounters(target: AppSettings, liveSettings: AppSettings, liveTotalRuntimeMs: number): void {
|
||||||
const debridLinkKeyIds = new Set(getDebridLinkApiKeyIds(target.debridLinkApiKeys));
|
const debridLinkKeyIds = new Set(getDebridLinkApiKeyIds(target.debridLinkApiKeys));
|
||||||
const megaAccountIds = new Set(getMegaDebridAccountIds(mergeMegaDebridCredentialPools(target.megaDebridApiCredentials || "", target.megaDebridWebCredentials || "") || target.megaCredentials || "", target.megaPassword || ""));
|
const megaAccountIds = new Set(getMegaDebridAccountIds(mergeMegaDebridCredentialPools(target.megaDebridApiCredentials || "", target.megaDebridWebCredentials || "") || target.megaCredentials || "", target.megaPassword || ""));
|
||||||
const validAccountIds = new Set([...debridLinkKeyIds, ...megaAccountIds]);
|
const megaAccountStatusIds = [...megaAccountIds].flatMap((accountId) => [
|
||||||
|
accountId,
|
||||||
|
getMegaDebridAccountStatusId(accountId, "api"),
|
||||||
|
getMegaDebridAccountStatusId(accountId, "web")
|
||||||
|
]);
|
||||||
|
const validAccountIds = new Set([...debridLinkKeyIds, ...megaAccountStatusIds]);
|
||||||
target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
|
target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
|
||||||
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
|
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
|
||||||
target.totalRuntimeAllTimeMs = Math.max(target.totalRuntimeAllTimeMs || 0, liveTotalRuntimeMs);
|
target.totalRuntimeAllTimeMs = Math.max(target.totalRuntimeAllTimeMs || 0, liveTotalRuntimeMs);
|
||||||
|
|||||||
+22
-9
@@ -3,7 +3,7 @@ import fsp from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||||
import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
import { getMegaDebridAccountIds, getMegaDebridAccountStatusId, mergeMegaDebridCredentialPools, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||||
import { AppSettings, AudioStripSummary, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PackageEntry, PackagePriority, SessionState } from "../shared/types";
|
import { AppSettings, AudioStripSummary, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PackageEntry, PackagePriority, SessionState } from "../shared/types";
|
||||||
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
||||||
import { defaultSettings } from "./constants";
|
import { defaultSettings } from "./constants";
|
||||||
@@ -271,7 +271,14 @@ function normalizeDebridAccountStatuses(
|
|||||||
megaIds: string[],
|
megaIds: string[],
|
||||||
debridLinkIds: string[]
|
debridLinkIds: string[]
|
||||||
): Record<string, DebridAccountStatus> {
|
): Record<string, DebridAccountStatus> {
|
||||||
const allowed = new Set([...megaIds, ...debridLinkIds]);
|
const allowed = new Set([
|
||||||
|
...megaIds,
|
||||||
|
...megaIds.flatMap((accountId) => [
|
||||||
|
getMegaDebridAccountStatusId(accountId, "api"),
|
||||||
|
getMegaDebridAccountStatusId(accountId, "web")
|
||||||
|
]),
|
||||||
|
...debridLinkIds
|
||||||
|
]);
|
||||||
const result: Record<string, DebridAccountStatus> = {};
|
const result: Record<string, DebridAccountStatus> = {};
|
||||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
||||||
@@ -1465,11 +1472,20 @@ export function addHistoryEntryForRetention(paths: StoragePaths, retentionMode:
|
|||||||
return addHistoryEntry(paths, entry, limits);
|
return addHistoryEntry(paths, entry, limits);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): void {
|
export function resetHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): boolean {
|
||||||
if (retentionMode === "permanent") {
|
if (retentionMode === "permanent") {
|
||||||
return;
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
clearHistory(paths);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const code = error && typeof error === "object" && "code" in error
|
||||||
|
? String((error as NodeJS.ErrnoException).code || "UNKNOWN")
|
||||||
|
: "UNKNOWN";
|
||||||
|
logger.warn(`Automatische Verlaufbereinigung fehlgeschlagen (${code})`);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
clearHistory(paths);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeHistoryEntry(paths: StoragePaths, entryId: string): HistoryEntry[] {
|
export function removeHistoryEntry(paths: StoragePaths, entryId: string): HistoryEntry[] {
|
||||||
@@ -1482,9 +1498,6 @@ export function removeHistoryEntry(paths: StoragePaths, entryId: string): Histor
|
|||||||
export function clearHistory(paths: StoragePaths): void {
|
export function clearHistory(paths: StoragePaths): void {
|
||||||
ensureBaseDir(paths.baseDir);
|
ensureBaseDir(paths.baseDir);
|
||||||
if (fs.existsSync(paths.historyFile)) {
|
if (fs.existsSync(paths.historyFile)) {
|
||||||
try {
|
fs.unlinkSync(paths.historyFile);
|
||||||
fs.unlinkSync(paths.historyFile);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+252
-29
@@ -42,6 +42,7 @@ const MAX_PACKAGE_DTOS = 200;
|
|||||||
const MAX_ITEM_DTOS = 500;
|
const MAX_ITEM_DTOS = 500;
|
||||||
const MAX_HISTORY_FILE_BYTES = 1024 * 1024;
|
const MAX_HISTORY_FILE_BYTES = 1024 * 1024;
|
||||||
const MAX_HISTORY_ENTRIES = 100;
|
const MAX_HISTORY_ENTRIES = 100;
|
||||||
|
const MAX_RUNTIME_PRIVATE_NAMES = 64;
|
||||||
|
|
||||||
interface TextBudget {
|
interface TextBudget {
|
||||||
remainingBytes: number;
|
remainingBytes: number;
|
||||||
@@ -100,6 +101,164 @@ function collectSensitiveValues(value: unknown, key = "", output = new Set<strin
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addRuntimePrivateName(output: Set<string>, value: string): boolean {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || output.has(trimmed)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (output.size >= MAX_RUNTIME_PRIVATE_NAMES) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
output.add(trimmed);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSnapshotPrivateNames(
|
||||||
|
packageEntries: readonly PackageEntry[],
|
||||||
|
itemEntries: readonly DownloadItem[]
|
||||||
|
): string[] {
|
||||||
|
const names = new Set<string>();
|
||||||
|
const addName = (name: string): boolean => {
|
||||||
|
const trimmed = String(name || "").trim();
|
||||||
|
if (trimmed) {
|
||||||
|
names.add(trimmed);
|
||||||
|
}
|
||||||
|
return names.size <= MAX_RUNTIME_PRIVATE_NAMES;
|
||||||
|
};
|
||||||
|
for (const entry of packageEntries) {
|
||||||
|
if (!addName(entry.name)) {
|
||||||
|
return [...names];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const entry of itemEntries) {
|
||||||
|
if (!addName(entry.fileName)) {
|
||||||
|
return [...names];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...names];
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRuntimeLogPrivateNames(value: string, output: Set<string>): boolean {
|
||||||
|
const lines = value.split(/\r?\n/);
|
||||||
|
const itemFileNames = new Set<string>();
|
||||||
|
for (const line of lines) {
|
||||||
|
const footerIndex = line.lastIndexOf(" ===");
|
||||||
|
const logKeyIndex = line.indexOf(" | logKey=");
|
||||||
|
if (footerIndex <= logKeyIndex) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith("=== Paket-Log Start:")) {
|
||||||
|
const marker = " | name=";
|
||||||
|
const markerIndex = line.indexOf(marker, logKeyIndex + 1);
|
||||||
|
if (markerIndex > logKeyIndex && !addRuntimePrivateName(output, line.slice(markerIndex + marker.length, footerIndex))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (line.startsWith("=== Item-Log Start:")) {
|
||||||
|
const marker = " | fileName=";
|
||||||
|
const markerIndex = line.indexOf(marker, logKeyIndex + 1);
|
||||||
|
if (markerIndex > logKeyIndex) {
|
||||||
|
const fileName = line.slice(markerIndex + marker.length, footerIndex);
|
||||||
|
if (!addRuntimePrivateName(output, fileName) || !addRuntimePrivateName(itemFileNames, fileName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const line of lines) {
|
||||||
|
const contextIndex = line.indexOf("Item-Kontext initialisiert");
|
||||||
|
if (contextIndex < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const packageMarker = " | packageName=";
|
||||||
|
const packageStart = line.indexOf(packageMarker, contextIndex);
|
||||||
|
if (packageStart < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const fileName of itemFileNames) {
|
||||||
|
const fileMarker = ` | fileName=${fileName} | targetPath=`;
|
||||||
|
const fileStart = line.lastIndexOf(fileMarker);
|
||||||
|
if (fileStart > packageStart) {
|
||||||
|
if (!addRuntimePrivateName(output, line.slice(packageStart + packageMarker.length, fileStart))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactRuntimeMetadataLines(value: string): string {
|
||||||
|
return value.split(/\r?\n/).map((line) => {
|
||||||
|
if (line.startsWith("=== Paket-Log Start:")) {
|
||||||
|
const logKeyIndex = line.indexOf(" | logKey=");
|
||||||
|
const nameIndex = line.indexOf(" | name=", logKeyIndex + 1);
|
||||||
|
return logKeyIndex > 0 && nameIndex > logKeyIndex
|
||||||
|
? `${line.slice(0, nameIndex)} | name=<redacted> ===`
|
||||||
|
: "=== Laufzeitprotokoll-Kontext entfernt ===";
|
||||||
|
}
|
||||||
|
if (line.startsWith("=== Item-Log Start:")) {
|
||||||
|
const logKeyIndex = line.indexOf(" | logKey=");
|
||||||
|
const nameIndex = line.indexOf(" | fileName=", logKeyIndex + 1);
|
||||||
|
return logKeyIndex > 0 && nameIndex > logKeyIndex
|
||||||
|
? `${line.slice(0, nameIndex)} | fileName=<redacted> ===`
|
||||||
|
: "=== Laufzeitprotokoll-Kontext entfernt ===";
|
||||||
|
}
|
||||||
|
if (line.includes("Paket-Kontext initialisiert") || line.includes("Item-Kontext initialisiert")) {
|
||||||
|
return "Laufzeitprotokoll-Kontext entfernt";
|
||||||
|
}
|
||||||
|
if (/\b(?:name|packageName|fileName)\s*=/.test(line)) {
|
||||||
|
return "Laufzeitprotokoll-Namensfeld entfernt";
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactMainDownloaderLogText(value: string): string {
|
||||||
|
return value.split(/\r?\n/).map((line) => {
|
||||||
|
const lifecycleMatch = /\b(Download (?:Start|fertig):)/.exec(line);
|
||||||
|
if (lifecycleMatch?.index !== undefined) {
|
||||||
|
return `${line.slice(0, lifecycleMatch.index)}${lifecycleMatch[1]} <redacted>`;
|
||||||
|
}
|
||||||
|
if (/\b(?:pkg|item)\s*=/.test(line)) {
|
||||||
|
const prefix = /^(?:.*?\[(?:TRACE|DEBUG|INFO|WARN|ERROR|FATAL)\]\s*)/i.exec(line)?.[0] || "";
|
||||||
|
return `${prefix}Laufzeitprotokoll-Namensfeld entfernt`;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactRuntimeLogText(
|
||||||
|
value: string,
|
||||||
|
context: string,
|
||||||
|
sensitiveValues: ReadonlySet<string>,
|
||||||
|
knownPrivateNames: readonly string[]
|
||||||
|
): string {
|
||||||
|
const privateNames = new Set<string>();
|
||||||
|
for (const privateName of knownPrivateNames) {
|
||||||
|
if (!addRuntimePrivateName(privateNames, privateName)) {
|
||||||
|
return "Laufzeitprotokoll-Kontext entfernt\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!collectRuntimeLogPrivateNames(context, privateNames) || !collectRuntimeLogPrivateNames(value, privateNames)) {
|
||||||
|
return "Laufzeitprotokoll-Kontext entfernt\n";
|
||||||
|
}
|
||||||
|
const fieldPattern = /\b(?:name|packageName|fileName)\s*=\s*(.*?)(?=\s+\|\s+|\s+===|\r?$)/gim;
|
||||||
|
for (const match of value.matchAll(fieldPattern)) {
|
||||||
|
if (!addRuntimePrivateName(privateNames, match[1] || "")) {
|
||||||
|
return "Laufzeitprotokoll-Kontext entfernt\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let output = value;
|
||||||
|
for (const privateName of [...privateNames].sort((left, right) => right.length - left.length)) {
|
||||||
|
const escaped = escapeRegExp(privateName);
|
||||||
|
output = privateName.length >= 4
|
||||||
|
? output.replaceAll(privateName, "<redacted>")
|
||||||
|
: output.replace(new RegExp(`(^|[^A-Za-z0-9])${escaped}(?=$|[^A-Za-z0-9])`, "g"), "$1<redacted>");
|
||||||
|
}
|
||||||
|
return redactSupportText(redactRuntimeMetadataLines(output), sensitiveValues);
|
||||||
|
}
|
||||||
|
|
||||||
function redactSupportText(value: string, sensitiveValues: ReadonlySet<string>): string {
|
function redactSupportText(value: string, sensitiveValues: ReadonlySet<string>): string {
|
||||||
const raw = String(value || "").replace(/\0/g, "");
|
const raw = String(value || "").replace(/\0/g, "");
|
||||||
const findMarker = (source: string, offset: number): string => {
|
const findMarker = (source: string, offset: number): string => {
|
||||||
@@ -205,7 +364,12 @@ function getSourcePathKey(sourcePath: string): string {
|
|||||||
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readTextTail(filePath: string, maxBytes: number): Promise<string> {
|
interface TextTailResult {
|
||||||
|
text: string;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTextHead(filePath: string, maxBytes: number): Promise<string> {
|
||||||
const stats = await fsp.stat(filePath);
|
const stats = await fsp.stat(filePath);
|
||||||
const bytesToRead = Math.min(stats.size, Math.max(0, maxBytes));
|
const bytesToRead = Math.min(stats.size, Math.max(0, maxBytes));
|
||||||
if (bytesToRead <= 0) {
|
if (bytesToRead <= 0) {
|
||||||
@@ -214,14 +378,44 @@ async function readTextTail(filePath: string, maxBytes: number): Promise<string>
|
|||||||
const handle = await fsp.open(filePath, "r");
|
const handle = await fsp.open(filePath, "r");
|
||||||
try {
|
try {
|
||||||
const buffer = Buffer.alloc(bytesToRead);
|
const buffer = Buffer.alloc(bytesToRead);
|
||||||
const { bytesRead } = await handle.read(buffer, 0, bytesToRead, Math.max(0, stats.size - bytesToRead));
|
const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0);
|
||||||
const text = buffer.subarray(0, bytesRead).toString("utf8");
|
return buffer.subarray(0, bytesRead).toString("utf8");
|
||||||
return stats.size > bytesRead ? `[gekürzt: letzte ${bytesRead} Bytes]\n${text}` : text;
|
|
||||||
} finally {
|
} finally {
|
||||||
await handle.close();
|
await handle.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readTextTail(filePath: string, maxBytes: number): Promise<TextTailResult> {
|
||||||
|
const stats = await fsp.stat(filePath);
|
||||||
|
const bytesToRead = Math.min(stats.size, Math.max(0, maxBytes));
|
||||||
|
if (bytesToRead <= 0) {
|
||||||
|
return { text: "", truncated: false };
|
||||||
|
}
|
||||||
|
const handle = await fsp.open(filePath, "r");
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.alloc(bytesToRead);
|
||||||
|
const { bytesRead } = await handle.read(buffer, 0, bytesToRead, Math.max(0, stats.size - bytesToRead));
|
||||||
|
const truncated = stats.size > bytesRead;
|
||||||
|
let text = buffer.subarray(0, bytesRead).toString("utf8");
|
||||||
|
if (truncated) {
|
||||||
|
const firstLineEnd = text.indexOf("\n");
|
||||||
|
text = firstLineEnd >= 0 ? text.slice(firstLineEnd + 1) : "";
|
||||||
|
text = `[gekürzt: letzte ${bytesRead} Bytes]\n${text}`;
|
||||||
|
}
|
||||||
|
return { text, truncated };
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimTextBufferToCompleteLines(buffer: Buffer, maxBytes: number): Buffer {
|
||||||
|
if (buffer.length <= maxBytes) {
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
const firstLineEnd = buffer.indexOf(0x0a, buffer.length - maxBytes);
|
||||||
|
return firstLineEnd >= 0 ? buffer.subarray(firstLineEnd + 1) : Buffer.alloc(0);
|
||||||
|
}
|
||||||
|
|
||||||
async function addTextFileIfExists(
|
async function addTextFileIfExists(
|
||||||
zip: AdmZip,
|
zip: AdmZip,
|
||||||
sourcePath: string | null,
|
sourcePath: string | null,
|
||||||
@@ -231,7 +425,8 @@ async function addTextFileIfExists(
|
|||||||
budget: TextBudget,
|
budget: TextBudget,
|
||||||
maxFileBytes: number,
|
maxFileBytes: number,
|
||||||
maxAgeMs?: number,
|
maxAgeMs?: number,
|
||||||
redactArchiveFileName = false
|
redactArchiveFileName = false,
|
||||||
|
knownPrivateNames: readonly string[] = []
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!sourcePath || budget.remainingBytes <= 0) {
|
if (!sourcePath || budget.remainingBytes <= 0) {
|
||||||
return false;
|
return false;
|
||||||
@@ -245,11 +440,21 @@ async function addTextFileIfExists(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const allowedBytes = Math.min(maxFileBytes, budget.remainingBytes);
|
const allowedBytes = Math.min(maxFileBytes, budget.remainingBytes);
|
||||||
const text = redactSupportText(await readTextTail(sourcePath, allowedBytes), sensitiveValues);
|
const tail = await readTextTail(sourcePath, allowedBytes);
|
||||||
let buffer = Buffer.from(text, "utf8");
|
const normalizedZipPath = zipPath.replace(/\\/g, "/");
|
||||||
if (buffer.length > allowedBytes) {
|
const contextualRuntimeLog = /^(?:logs\/)?(?:package|item)-logs\//.test(normalizedZipPath);
|
||||||
buffer = Buffer.from(buffer.subarray(buffer.length - allowedBytes).toString("utf8"), "utf8");
|
const mainDownloaderLog = /^logs\/rd_downloader\.log(?:\.old)?$/.test(normalizedZipPath);
|
||||||
}
|
const runtimeLog = contextualRuntimeLog || mainDownloaderLog;
|
||||||
|
const context = contextualRuntimeLog && tail.truncated ? await readTextHead(sourcePath, MAX_TEXT_FILE_BYTES) : tail.text;
|
||||||
|
const text = runtimeLog
|
||||||
|
? redactRuntimeLogText(
|
||||||
|
mainDownloaderLog ? redactMainDownloaderLogText(tail.text) : tail.text,
|
||||||
|
mainDownloaderLog ? redactMainDownloaderLogText(context) : context,
|
||||||
|
sensitiveValues,
|
||||||
|
knownPrivateNames
|
||||||
|
)
|
||||||
|
: redactSupportText(tail.text, sensitiveValues);
|
||||||
|
const buffer = trimTextBufferToCompleteLines(Buffer.from(text, "utf8"), allowedBytes);
|
||||||
await yieldToEventLoop();
|
await yieldToEventLoop();
|
||||||
zip.addFile(sanitizeArchivePath(zipPath, sensitiveValues, redactArchiveFileName), buffer);
|
zip.addFile(sanitizeArchivePath(zipPath, sensitiveValues, redactArchiveFileName), buffer);
|
||||||
includedSourcePaths.add(sourcePathKey);
|
includedSourcePaths.add(sourcePathKey);
|
||||||
@@ -333,7 +538,8 @@ async function addRelevantLogFiles<T extends { id: string }>(
|
|||||||
maxFiles: number,
|
maxFiles: number,
|
||||||
includedSourcePaths: Set<string>,
|
includedSourcePaths: Set<string>,
|
||||||
sensitiveValues: ReadonlySet<string>,
|
sensitiveValues: ReadonlySet<string>,
|
||||||
budget: TextBudget
|
budget: TextBudget,
|
||||||
|
resolvePrivateNames: (entry: T) => readonly string[]
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
let added = 0;
|
let added = 0;
|
||||||
for (const entry of entries.slice(0, maxFiles)) {
|
for (const entry of entries.slice(0, maxFiles)) {
|
||||||
@@ -353,7 +559,8 @@ async function addRelevantLogFiles<T extends { id: string }>(
|
|||||||
budget,
|
budget,
|
||||||
MAX_TEXT_FILE_BYTES,
|
MAX_TEXT_FILE_BYTES,
|
||||||
undefined,
|
undefined,
|
||||||
true
|
true,
|
||||||
|
resolvePrivateNames(entry)
|
||||||
)) {
|
)) {
|
||||||
added += 1;
|
added += 1;
|
||||||
}
|
}
|
||||||
@@ -448,8 +655,16 @@ function createItemDto(entry: DownloadItem, fileName: string): Record<string, un
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectRelevantEntries<T extends { status: unknown; updatedAt: number }>(entries: T[], limit: number): T[] {
|
function diagnosticPriority(entry: { status: unknown; resumeResetPending?: unknown; retries?: unknown; lastError?: unknown }): number {
|
||||||
return entries.sort((a, b) => Number(isActiveStatus(b.status)) - Number(isActiveStatus(a.status)) || b.updatedAt - a.updatedAt).slice(0, limit);
|
const status = String(entry.status || "");
|
||||||
|
if (["downloading", "converting", "reconnect_wait", "extracting", "finalizing"].includes(status)) return 3;
|
||||||
|
if (status === "failed" || entry.resumeResetPending === true || Number(entry.retries || 0) > 0 || String(entry.lastError || "").trim()) return 2;
|
||||||
|
if (isActiveStatus(status)) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRelevantEntries<T extends { status: unknown; updatedAt: number; resumeResetPending?: unknown; retries?: unknown; lastError?: unknown }>(entries: T[], limit: number): T[] {
|
||||||
|
return entries.sort((a, b) => diagnosticPriority(b) - diagnosticPriority(a) || b.updatedAt - a.updatedAt).slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSessionDto(session: SessionState): Record<string, unknown> {
|
function createSessionDto(session: SessionState): Record<string, unknown> {
|
||||||
@@ -849,6 +1064,7 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
|||||||
const snapshot = manager.getSnapshot();
|
const snapshot = manager.getSnapshot();
|
||||||
const packageEntries = Object.values(snapshot.session.packages);
|
const packageEntries = Object.values(snapshot.session.packages);
|
||||||
const itemEntries = Object.values(snapshot.session.items);
|
const itemEntries = Object.values(snapshot.session.items);
|
||||||
|
const snapshotPrivateNames = collectSnapshotPrivateNames(packageEntries, itemEntries);
|
||||||
const selectedPackageEntries = selectRelevantEntries(packageEntries, MAX_PACKAGE_DTOS);
|
const selectedPackageEntries = selectRelevantEntries(packageEntries, MAX_PACKAGE_DTOS);
|
||||||
const selectedItemEntries = selectRelevantEntries(itemEntries, MAX_ITEM_DTOS);
|
const selectedItemEntries = selectRelevantEntries(itemEntries, MAX_ITEM_DTOS);
|
||||||
const selectedPackages = selectedPackageEntries
|
const selectedPackages = selectedPackageEntries
|
||||||
@@ -925,16 +1141,7 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
|||||||
textBudget,
|
textBudget,
|
||||||
MAX_RUNTIME_FILE_BYTES
|
MAX_RUNTIME_FILE_BYTES
|
||||||
);
|
);
|
||||||
const addCurrentLog = (sourcePath: string | null, zipPath: string): Promise<boolean> => addTextFileIfExists(
|
const addCurrentLog = (sourcePath: string | null, zipPath: string, privateNames: readonly string[] = []): Promise<boolean> => addTextFileIfExists(
|
||||||
zip,
|
|
||||||
sourcePath,
|
|
||||||
zipPath,
|
|
||||||
includedSourcePaths,
|
|
||||||
sensitiveValues,
|
|
||||||
textBudget,
|
|
||||||
MAX_TEXT_FILE_BYTES
|
|
||||||
);
|
|
||||||
const addRotatedLog = (sourcePath: string | null, zipPath: string): Promise<boolean> => addTextFileIfExists(
|
|
||||||
zip,
|
zip,
|
||||||
sourcePath,
|
sourcePath,
|
||||||
zipPath,
|
zipPath,
|
||||||
@@ -942,7 +1149,21 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
|||||||
sensitiveValues,
|
sensitiveValues,
|
||||||
textBudget,
|
textBudget,
|
||||||
MAX_TEXT_FILE_BYTES,
|
MAX_TEXT_FILE_BYTES,
|
||||||
SUPPORT_BUNDLE_LOG_WINDOW_MS
|
undefined,
|
||||||
|
false,
|
||||||
|
privateNames
|
||||||
|
);
|
||||||
|
const addRotatedLog = (sourcePath: string | null, zipPath: string, privateNames: readonly string[] = []): Promise<boolean> => addTextFileIfExists(
|
||||||
|
zip,
|
||||||
|
sourcePath,
|
||||||
|
zipPath,
|
||||||
|
includedSourcePaths,
|
||||||
|
sensitiveValues,
|
||||||
|
textBudget,
|
||||||
|
MAX_TEXT_FILE_BYTES,
|
||||||
|
SUPPORT_BUNDLE_LOG_WINDOW_MS,
|
||||||
|
false,
|
||||||
|
privateNames
|
||||||
);
|
);
|
||||||
|
|
||||||
await addRuntimeFile(path.join(baseDir, SUPPORT_MANIFEST_FILE), `runtime/${SUPPORT_MANIFEST_FILE}`);
|
await addRuntimeFile(path.join(baseDir, SUPPORT_MANIFEST_FILE), `runtime/${SUPPORT_MANIFEST_FILE}`);
|
||||||
@@ -964,7 +1185,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
|||||||
MAX_PACKAGE_LOG_FILES,
|
MAX_PACKAGE_LOG_FILES,
|
||||||
includedSourcePaths,
|
includedSourcePaths,
|
||||||
sensitiveValues,
|
sensitiveValues,
|
||||||
textBudget
|
textBudget,
|
||||||
|
(entry) => [entry.name]
|
||||||
);
|
);
|
||||||
const relevantItemLogCount = await addRelevantLogFiles(
|
const relevantItemLogCount = await addRelevantLogFiles(
|
||||||
zip,
|
zip,
|
||||||
@@ -974,7 +1196,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
|||||||
MAX_ITEM_LOG_FILES,
|
MAX_ITEM_LOG_FILES,
|
||||||
includedSourcePaths,
|
includedSourcePaths,
|
||||||
sensitiveValues,
|
sensitiveValues,
|
||||||
textBudget
|
textBudget,
|
||||||
|
(entry) => [entry.fileName, snapshot.session.packages[entry.packageId]?.name || ""]
|
||||||
);
|
);
|
||||||
|
|
||||||
const mainLogPath = getLogFilePath();
|
const mainLogPath = getLogFilePath();
|
||||||
@@ -983,8 +1206,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
|||||||
const traceLogPath = getTraceLogPath();
|
const traceLogPath = getTraceLogPath();
|
||||||
const accountRotationLogPath = getAccountRotationLogPath();
|
const accountRotationLogPath = getAccountRotationLogPath();
|
||||||
const conversionLogPath = getConversionLogPath();
|
const conversionLogPath = getConversionLogPath();
|
||||||
await addCurrentLog(mainLogPath, "logs/rd_downloader.log");
|
await addCurrentLog(mainLogPath, "logs/rd_downloader.log", snapshotPrivateNames);
|
||||||
await addRotatedLog(`${mainLogPath}.old`, "logs/rd_downloader.log.old");
|
await addRotatedLog(`${mainLogPath}.old`, "logs/rd_downloader.log.old", snapshotPrivateNames);
|
||||||
await addCurrentLog(auditLogPath, "logs/audit.log");
|
await addCurrentLog(auditLogPath, "logs/audit.log");
|
||||||
await addRotatedLog(auditLogPath ? `${auditLogPath}.old` : null, "logs/audit.log.old");
|
await addRotatedLog(auditLogPath ? `${auditLogPath}.old` : null, "logs/audit.log.old");
|
||||||
await addCurrentLog(renameLogPath, "logs/rename.log");
|
await addCurrentLog(renameLogPath, "logs/rename.log");
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ function hasText(value: unknown): boolean {
|
|||||||
return String(value || "").trim().length > 0;
|
return String(value || "").trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sumUsage(values: Record<string, number> | undefined): number {
|
||||||
|
return Object.values(values || {}).reduce((sum, value) => sum + Math.max(0, Number(value) || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildAccountSummary(settings: AppSettings): Record<string, unknown> {
|
export function buildAccountSummary(settings: AppSettings): Record<string, unknown> {
|
||||||
const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys);
|
const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys);
|
||||||
const disabledDebridLinkIds = new Set(settings.debridLinkDisabledKeyIds || []);
|
const disabledDebridLinkIds = new Set(settings.debridLinkDisabledKeyIds || []);
|
||||||
@@ -113,7 +117,7 @@ export function buildRedactedSettingsPayload(settings: AppSettings): Record<stri
|
|||||||
completedCleanupPolicy: settings.completedCleanupPolicy
|
completedCleanupPolicy: settings.completedCleanupPolicy
|
||||||
},
|
},
|
||||||
ui: {
|
ui: {
|
||||||
packageName: settings.packageName,
|
packageNameConfigured: hasText(settings.packageName),
|
||||||
theme: settings.theme,
|
theme: settings.theme,
|
||||||
collapseNewPackages: settings.collapseNewPackages,
|
collapseNewPackages: settings.collapseNewPackages,
|
||||||
hideExtractedItems: settings.hideExtractedItems,
|
hideExtractedItems: settings.hideExtractedItems,
|
||||||
@@ -147,9 +151,9 @@ export function buildRedactedSettingsPayload(settings: AppSettings): Record<stri
|
|||||||
providerDailyLimitBytes: settings.providerDailyLimitBytes,
|
providerDailyLimitBytes: settings.providerDailyLimitBytes,
|
||||||
providerDailyUsageBytes: settings.providerDailyUsageBytes,
|
providerDailyUsageBytes: settings.providerDailyUsageBytes,
|
||||||
providerTotalUsageBytes: settings.providerTotalUsageBytes,
|
providerTotalUsageBytes: settings.providerTotalUsageBytes,
|
||||||
debridLinkApiKeyDailyLimitBytes: settings.debridLinkApiKeyDailyLimitBytes,
|
debridLinkApiKeyDailyLimitBytes: sumUsage(settings.debridLinkApiKeyDailyLimitBytes),
|
||||||
debridLinkApiKeyDailyUsageBytes: settings.debridLinkApiKeyDailyUsageBytes,
|
debridLinkApiKeyDailyUsageBytes: sumUsage(settings.debridLinkApiKeyDailyUsageBytes),
|
||||||
debridLinkApiKeyTotalUsageBytes: settings.debridLinkApiKeyTotalUsageBytes,
|
debridLinkApiKeyTotalUsageBytes: sumUsage(settings.debridLinkApiKeyTotalUsageBytes),
|
||||||
providerDailyUsageDay: settings.providerDailyUsageDay
|
providerDailyUsageDay: settings.providerDailyUsageDay
|
||||||
},
|
},
|
||||||
accounts: buildAccountSummary(settings)
|
accounts: buildAccountSummary(settings)
|
||||||
|
|||||||
+28
-3
@@ -1,12 +1,14 @@
|
|||||||
import { DragEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { DragEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||||
|
import { getMegaDebridAccountStatusId } from "../shared/mega-debrid-accounts";
|
||||||
import type {
|
import type {
|
||||||
AccountCreateCommand,
|
AccountCreateCommand,
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
AppTheme,
|
AppTheme,
|
||||||
BandwidthScheduleEntry,
|
BandwidthScheduleEntry,
|
||||||
DebugSetupCheckResult,
|
DebugSetupCheckResult,
|
||||||
|
DebridAccountStatus,
|
||||||
DebridFallbackProvider,
|
DebridFallbackProvider,
|
||||||
DebridLinkHostLimitInfo,
|
DebridLinkHostLimitInfo,
|
||||||
DebridProvider,
|
DebridProvider,
|
||||||
@@ -878,6 +880,24 @@ export function getSnapshotRenderDelay(_itemCount: number, _running: boolean, _a
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveAccountStatus(
|
||||||
|
statuses: Readonly<Record<string, DebridAccountStatus>>,
|
||||||
|
accountId: string | null,
|
||||||
|
kind: AccountKind
|
||||||
|
): DebridAccountStatus | undefined {
|
||||||
|
if (!accountId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const mode = kind === "megadebrid-api"
|
||||||
|
? "api"
|
||||||
|
: kind === "megadebrid-web"
|
||||||
|
? "web"
|
||||||
|
: null;
|
||||||
|
return mode
|
||||||
|
? statuses[getMegaDebridAccountStatusId(accountId, mode)] ?? statuses[accountId]
|
||||||
|
: statuses[accountId];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResetUiActionGate {
|
export interface ResetUiActionGate {
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
}
|
}
|
||||||
@@ -2513,7 +2533,9 @@ export function App(): ReactElement {
|
|||||||
), [configuredAccountServices]);
|
), [configuredAccountServices]);
|
||||||
const accountEditOption = accountEditDialog ? findAccountOption(accountEditDialog.target.kind) : null;
|
const accountEditOption = accountEditDialog ? findAccountOption(accountEditDialog.target.kind) : null;
|
||||||
const accountEditRow = accountEditDialog ? accountRows.find((row) => row.rowKey === accountEditDialog.target.rowKey) ?? null : null;
|
const accountEditRow = accountEditDialog ? accountRows.find((row) => row.rowKey === accountEditDialog.target.rowKey) ?? null : null;
|
||||||
const accountEditStatus = accountEditRow?.accountId ? snapshot.settings.debridAccountStatuses?.[accountEditRow.accountId] ?? null : null;
|
const accountEditStatus = accountEditRow
|
||||||
|
? resolveAccountStatus(snapshot.settings.debridAccountStatuses, accountEditRow.accountId, accountEditRow.entry.kind) ?? null
|
||||||
|
: null;
|
||||||
const accountEditQuickAction = accountEditOption ? getAccountQuickActionMeta(accountEditOption.kind) : null;
|
const accountEditQuickAction = accountEditOption ? getAccountQuickActionMeta(accountEditOption.kind) : null;
|
||||||
const accountDialogOption = accountDialog?.kind ? findAccountOption(accountDialog.kind) : null;
|
const accountDialogOption = accountDialog?.kind ? findAccountOption(accountDialog.kind) : null;
|
||||||
const accountDialogSelectableOptions = useMemo(() => {
|
const accountDialogSelectableOptions = useMemo(() => {
|
||||||
@@ -3041,7 +3063,10 @@ export function App(): ReactElement {
|
|||||||
const removeAccountTableRow = (row: AccountTableRow): void => {
|
const removeAccountTableRow = (row: AccountTableRow): void => {
|
||||||
setAccountContextMenu(null);
|
setAccountContextMenu(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const username = resolveAccountUsername(row.username, row.accountId ? snapshot.settings.debridAccountStatuses?.[row.accountId]?.email : undefined);
|
const username = resolveAccountUsername(
|
||||||
|
row.username,
|
||||||
|
resolveAccountStatus(snapshot.settings.debridAccountStatuses, row.accountId, row.entry.kind)?.email
|
||||||
|
);
|
||||||
const confirmed = await askConfirmPrompt({
|
const confirmed = await askConfirmPrompt({
|
||||||
title: `${row.hosterLabel} entfernen`,
|
title: `${row.hosterLabel} entfernen`,
|
||||||
message: `Soll ${row.hosterLabel}${username !== "—" ? ` (${username})` : ""} wirklich entfernt werden?`,
|
message: `Soll ${row.hosterLabel}${username !== "—" ? ` (${username})` : ""} wirklich entfernt werden?`,
|
||||||
@@ -4987,7 +5012,7 @@ export function App(): ReactElement {
|
|||||||
? accountRowBindings.get(accountContextMenu.rowId) ?? null
|
? accountRowBindings.get(accountContextMenu.rowId) ?? null
|
||||||
: null;
|
: null;
|
||||||
const accountSources = useMemo<AccountRowSource[]>(() => accountRows.map((row) => {
|
const accountSources = useMemo<AccountRowSource[]>(() => accountRows.map((row) => {
|
||||||
const checkedStatus = row.accountId ? snapshot.settings.debridAccountStatuses?.[row.accountId] : undefined;
|
const checkedStatus = resolveAccountStatus(snapshot.settings.debridAccountStatuses, row.accountId, row.entry.kind);
|
||||||
const state: AccountRowSource["status"]["state"] = row.disabled
|
const state: AccountRowSource["status"]["state"] = row.disabled
|
||||||
? "disabled"
|
? "disabled"
|
||||||
: !checkedStatus
|
: !checkedStatus
|
||||||
|
|||||||
@@ -49,6 +49,28 @@ export function clampContextMenuPosition(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function observeContextMenuPosition(
|
||||||
|
menu: Pick<HTMLElement, "getBoundingClientRect">,
|
||||||
|
anchor: () => { x: number; y: number },
|
||||||
|
onPosition: (position: { x: number; y: number }) => void
|
||||||
|
): () => void {
|
||||||
|
const reposition = (): void => {
|
||||||
|
const rect = menu.getBoundingClientRect();
|
||||||
|
const point = anchor();
|
||||||
|
onPosition(clampContextMenuPosition(
|
||||||
|
point.x,
|
||||||
|
point.y,
|
||||||
|
rect.width,
|
||||||
|
rect.height,
|
||||||
|
window.innerWidth,
|
||||||
|
window.innerHeight
|
||||||
|
));
|
||||||
|
};
|
||||||
|
reposition();
|
||||||
|
window.addEventListener("resize", reposition);
|
||||||
|
return () => window.removeEventListener("resize", reposition);
|
||||||
|
}
|
||||||
|
|
||||||
export function getContextSubmenuPosition(
|
export function getContextSubmenuPosition(
|
||||||
trigger: { left: number; right: number; top: number },
|
trigger: { left: number; right: number; top: number },
|
||||||
submenu: { width: number; height: number },
|
submenu: { width: number; height: number },
|
||||||
@@ -223,12 +245,13 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(function
|
|||||||
if (!previousFocusRef.current) {
|
if (!previousFocusRef.current) {
|
||||||
previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
}
|
}
|
||||||
const rect = menuRef.current.getBoundingClientRect();
|
const stopObserving = observeContextMenuPosition(menuRef.current, () => ({ x, y }), (next) => {
|
||||||
const next = clampContextMenuPosition(x, y, rect.width, rect.height, window.innerWidth, window.innerHeight);
|
setPosition((current) => current.x === next.x && current.y === next.y && current.sourceX === x && current.sourceY === y && current.ready
|
||||||
setPosition((current) => current.x === next.x && current.y === next.y && current.sourceX === x && current.sourceY === y && current.ready
|
? current
|
||||||
? current
|
: { ...next, sourceX: x, sourceY: y, ready: true });
|
||||||
: { ...next, sourceX: x, sourceY: y, ready: true });
|
});
|
||||||
getTopLevelMenuItems(menuRef.current)[0]?.focus();
|
getTopLevelMenuItems(menuRef.current)[0]?.focus();
|
||||||
|
return stopObserving;
|
||||||
}, [open, x, y]);
|
}, [open, x, y]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ export function compactDownloadStatus(value: string): string {
|
|||||||
}
|
}
|
||||||
if (/^Entpack-Fehler\b/i.test(status)) return "Entpack-Fehler";
|
if (/^Entpack-Fehler\b/i.test(status)) return "Entpack-Fehler";
|
||||||
if (/^Extraction error\b/i.test(status)) return "Extraction error";
|
if (/^Extraction error\b/i.test(status)) return "Extraction error";
|
||||||
|
if (/^Entpacken\s*-\s*(?:Error|Fehler)\b/i.test(status)) return "Entpack-Fehler";
|
||||||
|
if (/^Extracting\s*-\s*(?:Error|Fehler)\b/i.test(status)) return "Extraction error";
|
||||||
const extractionPending = status.match(/^(Entpacken|Extracting)\s*-\s*(Ausstehend|Pending|Warten auf Parts|Waiting for parts)/i);
|
const extractionPending = status.match(/^(Entpacken|Extracting)\s*-\s*(Ausstehend|Pending|Warten auf Parts|Waiting for parts)/i);
|
||||||
if (extractionPending) return `${extractionPending[1]} - ${extractionPending[2]}`;
|
if (extractionPending) return `${extractionPending[1]} - ${extractionPending[2]}`;
|
||||||
const extracting = status.match(/Entpacken\s+(\d+)%/i);
|
const extracting = status.match(/Entpacken\s+(\d+)%/i);
|
||||||
@@ -168,6 +170,8 @@ export function compactDownloadStatus(value: string): string {
|
|||||||
if (percentage) return `${finalizing[1]} - ${progress(Number(percentage[1]))}%`;
|
if (percentage) return `${finalizing[1]} - ${progress(Number(percentage[1]))}%`;
|
||||||
return finalizing[1];
|
return finalizing[1];
|
||||||
}
|
}
|
||||||
|
if (/^Fehler(?:\s*:|$)/i.test(status)) return "Fehler";
|
||||||
|
if (/^Error(?:\s*:|$)/i.test(status)) return "Error";
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { DebridProvider, HistoryEntry } from "../../../shared/types";
|
import type { DebridProvider, HistoryEntry } from "../../../shared/types";
|
||||||
|
import { normalizeHosterHostname } from "../../../shared/hoster";
|
||||||
|
|
||||||
export type HistoryFilter = "all" | "today" | "week" | "older" | "completed" | "deleted" | "failed";
|
export type HistoryFilter = "all" | "today" | "week" | "older" | "completed" | "deleted" | "failed";
|
||||||
export type HistoryViewStatus = HistoryEntry["status"] | "failed";
|
export type HistoryViewStatus = HistoryEntry["status"] | "failed";
|
||||||
@@ -168,11 +169,12 @@ export function deriveHistoryHoster(urls: string[] | undefined): string {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const hostname = url.hostname.toLocaleLowerCase("de-DE");
|
const hostname = url.hostname.toLocaleLowerCase("de-DE");
|
||||||
if (!hostname || seen.has(hostname)) {
|
const hoster = normalizeHosterHostname(hostname) === "rapidgator" ? "rapidgator.net" : hostname;
|
||||||
|
if (!hostname || seen.has(hoster)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
seen.add(hostname);
|
seen.add(hoster);
|
||||||
hostnames.push(hostname);
|
hostnames.push(hoster);
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface MegaDebridAccountEntry {
|
|||||||
index: number;
|
index: number;
|
||||||
label: string;
|
label: string;
|
||||||
maskedLogin: string;
|
maskedLogin: string;
|
||||||
|
mode?: MegaDebridAccountMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MegaDebridAccountMode = "api" | "web";
|
export type MegaDebridAccountMode = "api" | "web";
|
||||||
@@ -39,6 +40,10 @@ export function getMegaDebridAccountId(login: string): string {
|
|||||||
return `mda_${fnv1a64(login.trim().toLowerCase())}`;
|
return `mda_${fnv1a64(login.trim().toLowerCase())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMegaDebridAccountStatusId(accountId: string, mode: MegaDebridAccountMode): string {
|
||||||
|
return `${accountId}:${mode}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function maskMegaDebridLogin(login: string): string {
|
export function maskMegaDebridLogin(login: string): string {
|
||||||
const trimmed = login.trim();
|
const trimmed = login.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -124,7 +129,8 @@ export function getMegaDebridCredentialsForMode(settings: MegaDebridModeSettings
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getMegaDebridAccountsForMode(settings: MegaDebridModeSettings, mode: MegaDebridAccountMode): MegaDebridAccountEntry[] {
|
export function getMegaDebridAccountsForMode(settings: MegaDebridModeSettings, mode: MegaDebridAccountMode): MegaDebridAccountEntry[] {
|
||||||
return parseMegaDebridAccounts(getMegaDebridCredentialsForMode(settings, mode), settings.megaPassword || "");
|
return parseMegaDebridAccounts(getMegaDebridCredentialsForMode(settings, mode), settings.megaPassword || "")
|
||||||
|
.map((account) => ({ ...account, mode }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMegaDebridDisabledAccountIdsForMode(settings: MegaDebridModeSettings, mode: MegaDebridAccountMode): string[] {
|
export function getMegaDebridDisabledAccountIdsForMode(settings: MegaDebridModeSettings, mode: MegaDebridAccountMode): string[] {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts } from "../src/main/account-check";
|
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts } from "../src/main/account-check";
|
||||||
import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
|
import { getMegaDebridAccountId, type MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
|
||||||
import type { DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
|
import type { DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
|
||||||
import type { AppSettings } from "../src/shared/types";
|
import type { AppSettings } from "../src/shared/types";
|
||||||
|
|
||||||
@@ -139,6 +139,37 @@ describe("checkAllDebridAccounts", () => {
|
|||||||
expect(result.every((r) => r.valid)).toBe(true);
|
expect(result.every((r) => r.valid)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps API and Web status identities separate when both modes use the same login", async () => {
|
||||||
|
const futureSec = Math.floor(Date.now() / 1000) + 1000;
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = new URL(String(input));
|
||||||
|
const password = url.searchParams.get("password");
|
||||||
|
if (password === "api-pass") {
|
||||||
|
return { ok: true, status: 200, text: async () => JSON.stringify({ response_code: "ok", token: "api-token", vip_end: String(futureSec) }) };
|
||||||
|
}
|
||||||
|
return { ok: true, status: 200, text: async () => JSON.stringify({ response_code: "error", response_text: "web credentials rejected" }) };
|
||||||
|
}) as unknown as typeof fetch);
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
megaCredentials: "shared@example.test:api-pass",
|
||||||
|
megaPassword: "",
|
||||||
|
megaDebridApiCredentials: "shared@example.test:api-pass",
|
||||||
|
megaDebridWebCredentials: "shared@example.test:web-pass",
|
||||||
|
megaDebridApiEnabled: true,
|
||||||
|
megaDebridWebEnabled: true,
|
||||||
|
megaDebridPreferApi: true,
|
||||||
|
debridLinkApiKeys: ""
|
||||||
|
} as unknown as AppSettings;
|
||||||
|
|
||||||
|
const result = await checkAllDebridAccounts(settings);
|
||||||
|
const baseId = getMegaDebridAccountId("shared@example.test");
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((status) => status.accountId)).toEqual([`${baseId}:api`, `${baseId}:web`]);
|
||||||
|
expect(result[0]).toMatchObject({ valid: true, isPremium: true });
|
||||||
|
expect(result[1]).toMatchObject({ valid: false, isPremium: false });
|
||||||
|
});
|
||||||
|
|
||||||
it("caps concurrency (never more than 4 in flight) and preserves result order", async () => {
|
it("caps concurrency (never more than 4 in flight) and preserves result order", async () => {
|
||||||
let inFlight = 0;
|
let inFlight = 0;
|
||||||
let maxInFlight = 0;
|
let maxInFlight = 0;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveAccountStatus } from "../src/renderer/App";
|
||||||
import {
|
import {
|
||||||
buildConfiguredProviderOrder,
|
buildConfiguredProviderOrder,
|
||||||
getAccountDialogSelectableOptions,
|
getAccountDialogSelectableOptions,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
resolveAccountUsername,
|
resolveAccountUsername,
|
||||||
resolveVisibleAccountKind
|
resolveVisibleAccountKind
|
||||||
} from "../src/renderer/account-ui";
|
} from "../src/renderer/account-ui";
|
||||||
|
import type { DebridAccountStatus } from "../src/shared/types";
|
||||||
|
|
||||||
describe("account mode filter", () => {
|
describe("account mode filter", () => {
|
||||||
it("shows only API options for the API filter", () => {
|
it("shows only API options for the API filter", () => {
|
||||||
@@ -69,3 +71,36 @@ describe("account usernames", () => {
|
|||||||
expect(resolveAccountUsername("", undefined)).toBe("—");
|
expect(resolveAccountUsername("", undefined)).toBe("—");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("account row statuses", () => {
|
||||||
|
it("shows the status matching each Mega-Debrid mode", () => {
|
||||||
|
const apiStatus: DebridAccountStatus = {
|
||||||
|
accountId: "mda_shared:api",
|
||||||
|
provider: "megadebrid",
|
||||||
|
label: "Mega-Debrid API",
|
||||||
|
maskedLogin: "sh***ed",
|
||||||
|
valid: false,
|
||||||
|
isPremium: false,
|
||||||
|
premiumUntilMs: null,
|
||||||
|
message: "API ungültig",
|
||||||
|
checkedAt: 10
|
||||||
|
};
|
||||||
|
const webStatus: DebridAccountStatus = {
|
||||||
|
accountId: "mda_shared:web",
|
||||||
|
provider: "megadebrid",
|
||||||
|
label: "Mega-Debrid Web",
|
||||||
|
maskedLogin: "sh***ed",
|
||||||
|
valid: true,
|
||||||
|
isPremium: true,
|
||||||
|
premiumUntilMs: 2_000,
|
||||||
|
message: "Web gültig",
|
||||||
|
checkedAt: 20
|
||||||
|
};
|
||||||
|
const statuses = {
|
||||||
|
"mda_shared:api": apiStatus,
|
||||||
|
"mda_shared:web": webStatus
|
||||||
|
};
|
||||||
|
expect(resolveAccountStatus(statuses, "mda_shared", "megadebrid-api")?.message).toBe("API ungültig");
|
||||||
|
expect(resolveAccountStatus(statuses, "mda_shared", "megadebrid-web")?.message).toBe("Web gültig");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const {
|
|||||||
mockSession,
|
mockSession,
|
||||||
mockFetch,
|
mockFetch,
|
||||||
mockBrowserWindowCtor,
|
mockBrowserWindowCtor,
|
||||||
|
mockBrowserWindow,
|
||||||
mockLoadURL,
|
mockLoadURL,
|
||||||
mockShow,
|
mockShow,
|
||||||
mockFocus,
|
mockFocus,
|
||||||
@@ -63,6 +64,7 @@ const {
|
|||||||
},
|
},
|
||||||
mockFetch: fetch,
|
mockFetch: fetch,
|
||||||
mockBrowserWindowCtor: BrowserWindowCtor,
|
mockBrowserWindowCtor: BrowserWindowCtor,
|
||||||
|
mockBrowserWindow: browserWindow,
|
||||||
mockLoadURL: loadURL,
|
mockLoadURL: loadURL,
|
||||||
mockShow: show,
|
mockShow: show,
|
||||||
mockFocus: focus,
|
mockFocus: focus,
|
||||||
@@ -117,6 +119,21 @@ describe("alldebrid-web", () => {
|
|||||||
expect(mockFocus).toHaveBeenCalled();
|
expect(mockFocus).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("replaces a login window after its renderer process crashes", async () => {
|
||||||
|
const fallback = new AllDebridWebFallback(() => true);
|
||||||
|
|
||||||
|
await fallback.openLoginWindow();
|
||||||
|
const crashHandler = mockBrowserWindow.webContents.on.mock.calls.find(([event]) => event === "render-process-gone")?.[1];
|
||||||
|
|
||||||
|
expect(crashHandler).toBeTypeOf("function");
|
||||||
|
crashHandler?.();
|
||||||
|
await fallback.openLoginWindow();
|
||||||
|
|
||||||
|
expect(mockClose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockLoadURL).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses an existing AllDebrid Web session to unrestrict without opening a login window", async () => {
|
it("uses an existing AllDebrid Web session to unrestrict without opening a login window", async () => {
|
||||||
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({
|
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
link: "https://alldebrid.direct/session-file.bin",
|
link: "https://alldebrid.direct/session-file.bin",
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { defaultSettings } from "../src/main/constants";
|
||||||
|
import { AppController } from "../src/main/app-controller";
|
||||||
|
import { createStoragePaths, loadHistory, loadSettings, saveHistory, saveSettings } from "../src/main/storage";
|
||||||
|
|
||||||
|
const electronState = vi.hoisted(() => ({ userDataDir: "" }));
|
||||||
|
|
||||||
|
vi.mock("electron", () => ({
|
||||||
|
app: {
|
||||||
|
getPath: () => electronState.userDataDir
|
||||||
|
},
|
||||||
|
BrowserWindow: class {},
|
||||||
|
clipboard: {},
|
||||||
|
dialog: {},
|
||||||
|
ipcMain: { handle: vi.fn(), on: vi.fn() },
|
||||||
|
Menu: { buildFromTemplate: vi.fn(), setApplicationMenu: vi.fn() },
|
||||||
|
safeStorage: { isEncryptionAvailable: () => false, encryptString: vi.fn(), decryptString: vi.fn() },
|
||||||
|
shell: {},
|
||||||
|
Tray: class {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
function createHistoryEntry(outputDir: string) {
|
||||||
|
return {
|
||||||
|
id: "history-locked",
|
||||||
|
name: "locked",
|
||||||
|
totalBytes: 1,
|
||||||
|
downloadedBytes: 1,
|
||||||
|
fileCount: 1,
|
||||||
|
provider: "realdebrid" as const,
|
||||||
|
completedAt: 100,
|
||||||
|
durationSeconds: 1,
|
||||||
|
status: "completed" as const,
|
||||||
|
outputDir,
|
||||||
|
urls: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function failHistoryDeletion(historyFile: string) {
|
||||||
|
const originalUnlink = fs.unlinkSync;
|
||||||
|
return vi.spyOn(fs, "unlinkSync").mockImplementation((target) => {
|
||||||
|
if (target === historyFile) {
|
||||||
|
const error = new Error("EPERM: history file is locked") as NodeJS.ErrnoException;
|
||||||
|
error.code = "EPERM";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return originalUnlink(target);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
electronState.userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-controller-history-"));
|
||||||
|
tempDirs.push(electronState.userDataDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AppController history retention", () => {
|
||||||
|
it("remains startable when session-history cleanup returns EPERM", () => {
|
||||||
|
const paths = createStoragePaths(path.join(electronState.userDataDir, "runtime"));
|
||||||
|
saveSettings(paths, { ...defaultSettings(), historyRetentionMode: "session" });
|
||||||
|
saveHistory(paths, [createHistoryEntry(path.join(electronState.userDataDir, "out"))]);
|
||||||
|
const unlinkSpy = failHistoryDeletion(paths.historyFile);
|
||||||
|
|
||||||
|
let controller!: AppController;
|
||||||
|
expect(() => {
|
||||||
|
controller = new AppController();
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(loadHistory(paths)).toHaveLength(1);
|
||||||
|
|
||||||
|
unlinkSpy.mockRestore();
|
||||||
|
controller.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back a retention update when history deletion fails", () => {
|
||||||
|
const paths = createStoragePaths(path.join(electronState.userDataDir, "runtime"));
|
||||||
|
saveSettings(paths, { ...defaultSettings(), historyRetentionMode: "permanent" });
|
||||||
|
saveHistory(paths, [createHistoryEntry(path.join(electronState.userDataDir, "out"))]);
|
||||||
|
const controller = new AppController();
|
||||||
|
const unlinkSpy = failHistoryDeletion(paths.historyFile);
|
||||||
|
|
||||||
|
const result = controller.updateSettings({ historyRetentionMode: "session" });
|
||||||
|
|
||||||
|
expect(result.historyRetentionMode).toBe("permanent");
|
||||||
|
expect(controller.getSettings().historyRetentionMode).toBe("permanent");
|
||||||
|
expect(loadSettings(paths).historyRetentionMode).toBe("permanent");
|
||||||
|
expect(loadHistory(paths)).toHaveLength(1);
|
||||||
|
|
||||||
|
unlinkSpy.mockRestore();
|
||||||
|
controller.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps manual history deletion failures visible without auditing false success", () => {
|
||||||
|
const paths = createStoragePaths(path.join(electronState.userDataDir, "runtime"));
|
||||||
|
saveSettings(paths, { ...defaultSettings(), historyRetentionMode: "permanent" });
|
||||||
|
saveHistory(paths, [createHistoryEntry(path.join(electronState.userDataDir, "out"))]);
|
||||||
|
const controller = new AppController();
|
||||||
|
const audit = vi.fn();
|
||||||
|
(controller as unknown as { audit: typeof audit }).audit = audit;
|
||||||
|
const unlinkSpy = failHistoryDeletion(paths.historyFile);
|
||||||
|
|
||||||
|
expect(() => controller.clearHistory()).toThrow(/EPERM/);
|
||||||
|
expect(loadHistory(paths)).toHaveLength(1);
|
||||||
|
expect(audit.mock.calls.map((call) => call[1])).toEqual(["Verlauf konnte nicht geleert werden"]);
|
||||||
|
|
||||||
|
unlinkSpy.mockRestore();
|
||||||
|
controller.shutdown();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
clampContextMenuPosition,
|
clampContextMenuPosition,
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
getContextMenuKeyboardAction,
|
getContextMenuKeyboardAction,
|
||||||
getContextMenuSubmenuKeyboardAction,
|
getContextMenuSubmenuKeyboardAction,
|
||||||
getContextSubmenuPosition
|
getContextSubmenuPosition,
|
||||||
|
observeContextMenuPosition
|
||||||
} from "../src/renderer/ui/ContextMenu";
|
} from "../src/renderer/ui/ContextMenu";
|
||||||
|
|
||||||
const contextMenuSource = readFileSync(new URL("../src/renderer/ui/ContextMenu.tsx", import.meta.url), "utf8");
|
const contextMenuSource = readFileSync(new URL("../src/renderer/ui/ContextMenu.tsx", import.meta.url), "utf8");
|
||||||
const stylesSource = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
|
const stylesSource = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
describe("ContextMenu", () => {
|
describe("ContextMenu", () => {
|
||||||
it("renders menu semantics and marks buttons as menu items", () => {
|
it("renders menu semantics and marks buttons as menu items", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
@@ -104,6 +109,29 @@ describe("ContextMenu", () => {
|
|||||||
)).toEqual({ x: 590, y: 450 });
|
)).toEqual({ x: 590, y: 450 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("repositions an open menu immediately when the viewport is resized", () => {
|
||||||
|
const viewport = Object.assign(new EventTarget(), { innerWidth: 800, innerHeight: 600 });
|
||||||
|
vi.stubGlobal("window", viewport);
|
||||||
|
const menu = {
|
||||||
|
getBoundingClientRect: () => ({ width: 200, height: 150 })
|
||||||
|
} as Pick<HTMLElement, "getBoundingClientRect">;
|
||||||
|
const onPosition = vi.fn();
|
||||||
|
const stop = observeContextMenuPosition(menu, () => ({ x: 700, y: 550 }), onPosition);
|
||||||
|
|
||||||
|
expect(onPosition).toHaveBeenLastCalledWith({ x: 600, y: 450 });
|
||||||
|
|
||||||
|
viewport.innerWidth = 500;
|
||||||
|
viewport.innerHeight = 350;
|
||||||
|
viewport.dispatchEvent(new Event("resize"));
|
||||||
|
|
||||||
|
expect(onPosition).toHaveBeenLastCalledWith({ x: 300, y: 200 });
|
||||||
|
expect(onPosition).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
stop();
|
||||||
|
viewport.dispatchEvent(new Event("resize"));
|
||||||
|
expect(onPosition).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps submenus hidden until their viewport-safe position is ready", () => {
|
it("keeps submenus hidden until their viewport-safe position is ready", () => {
|
||||||
expect(contextMenuSource).toContain('position.ready && position.sourceX === x && position.sourceY === y ? "is-positioned" : ""');
|
expect(contextMenuSource).toContain('position.ready && position.sourceX === x && position.sourceY === y ? "is-positioned" : ""');
|
||||||
expect(contextMenuSource).toContain('parts.items.classList.add("is-positioned")');
|
expect(contextMenuSource).toContain('parts.items.classList.add("is-positioned")');
|
||||||
|
|||||||
@@ -1324,6 +1324,45 @@ describe("debrid service", () => {
|
|||||||
expect(fetchSpy).toHaveBeenCalledTimes(0);
|
expect(fetchSpy).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["Real-Debrid", "realdebrid", "realDebridWebUnrestrict", { token: "rd-token", realDebridUseWebLogin: true }],
|
||||||
|
["AllDebrid", "alldebrid", "allDebridWebUnrestrict", { allDebridToken: "ad-token", allDebridUseWebLogin: true }],
|
||||||
|
["BestDebrid", "bestdebrid", "bestDebridWebUnrestrict", { bestToken: "best-token", bestDebridUseWebLogin: true }]
|
||||||
|
] as const)("aborts a hanging %s Web provider callback even when it ignores the signal", async (_label, providerName, callbackName, settingsPatch) => {
|
||||||
|
let markStarted: () => void = () => {};
|
||||||
|
const started = new Promise<void>((resolve) => {
|
||||||
|
markStarted = resolve;
|
||||||
|
});
|
||||||
|
const providerCallback = vi.fn(() => {
|
||||||
|
markStarted();
|
||||||
|
return new Promise<never>(() => {});
|
||||||
|
});
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
...settingsPatch,
|
||||||
|
providerOrder: [] as const,
|
||||||
|
providerPrimary: providerName,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
const service = new DebridService(settings, { [callbackName]: providerCallback });
|
||||||
|
const controller = new AbortController();
|
||||||
|
const outcome = service.unrestrictLink("https://rapidgator.net/file/hanging-web-provider", controller.signal).then(
|
||||||
|
() => "fulfilled",
|
||||||
|
(error: unknown) => String(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
await started;
|
||||||
|
controller.abort("pause");
|
||||||
|
const result = await Promise.race([
|
||||||
|
outcome,
|
||||||
|
new Promise<string>((resolve) => setTimeout(() => resolve("timeout"), 100))
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toMatch(/aborted/i);
|
||||||
|
});
|
||||||
|
|
||||||
it("treats Real-Debrid web mode as not configured when callback is unavailable and no token", async () => {
|
it("treats Real-Debrid web mode as not configured when callback is unavailable and no token", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
@@ -1496,6 +1535,47 @@ describe("debrid service", () => {
|
|||||||
expect(megaWeb).toHaveBeenCalledTimes(1);
|
expect(megaWeb).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports each provider as soon as its conversion attempt starts", async () => {
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
megaLogin: "user",
|
||||||
|
megaPassword: "pass",
|
||||||
|
megaCredentials: "user:pass",
|
||||||
|
providerOrder: ["realdebrid", "megadebrid"] as const,
|
||||||
|
autoProviderFallback: true
|
||||||
|
};
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
||||||
|
return new Response(JSON.stringify({ error: "traffic_limit" }), {
|
||||||
|
status: 403,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response("not-found", { status: 404 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
const service = new DebridService(settings, {
|
||||||
|
megaWebUnrestrict: vi.fn(async () => ({
|
||||||
|
fileName: "file.bin",
|
||||||
|
directUrl: "https://mega-web.example/file.bin",
|
||||||
|
fileSize: null,
|
||||||
|
retriesUsed: 0
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
const attempts: string[] = [];
|
||||||
|
|
||||||
|
await service.unrestrictLink(
|
||||||
|
"https://rapidgator.net/file/provider-attempts.rar.html",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
(provider) => attempts.push(provider)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(attempts).toEqual(["realdebrid", "megadebrid"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses Mega web fallback when API fails", async () => {
|
it("uses Mega web fallback when API fails", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
@@ -2252,6 +2332,115 @@ describe("debrid service", () => {
|
|||||||
expect(usedIds[MEGA_DEBRID_STICKY_LINKS]).toBe(getMegaDebridAccountId("user2"));
|
expect(usedIds[MEGA_DEBRID_STICKY_LINKS]).toBe(getMegaDebridAccountId("user2"));
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
|
it("keeps the Mega-Debrid Web cursor independent from API rotation", async () => {
|
||||||
|
const apiSettings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "", bestToken: "", allDebridToken: "",
|
||||||
|
megaCredentials: "cursor-api-1:pass1\ncursor-api-2:pass2",
|
||||||
|
megaDebridApiCredentials: "cursor-api-1:pass1\ncursor-api-2:pass2",
|
||||||
|
megaDebridWebCredentials: "",
|
||||||
|
megaDebridApiEnabled: true,
|
||||||
|
megaDebridWebEnabled: false,
|
||||||
|
providerOrder: [] as const, providerPrimary: "megadebrid-api" as const,
|
||||||
|
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes("action=connectUser")) {
|
||||||
|
return new Response(JSON.stringify({ response_code: "ok", token: "cursor-api-token" }), { status: 200 });
|
||||||
|
}
|
||||||
|
if (url.includes("action=getLink")) {
|
||||||
|
return new Response(JSON.stringify({ response_code: "ok", debridLink: "https://mega-cdn.example/cursor.rar", filename: "cursor.rar" }), { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response("not-found", { status: 404 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const apiService = new DebridService(apiSettings);
|
||||||
|
for (let index = 0; index < MEGA_DEBRID_STICKY_LINKS; index += 1) {
|
||||||
|
await apiService.unrestrictLink(`https://rapidgator.net/file/api-cursor-${index}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const webLogins: string[] = [];
|
||||||
|
const webSettings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "", bestToken: "", allDebridToken: "",
|
||||||
|
megaCredentials: "cursor-web-1:pass1\ncursor-web-2:pass2",
|
||||||
|
megaDebridApiCredentials: "",
|
||||||
|
megaDebridWebCredentials: "cursor-web-1:pass1\ncursor-web-2:pass2",
|
||||||
|
megaDebridApiEnabled: false,
|
||||||
|
megaDebridWebEnabled: true,
|
||||||
|
providerOrder: [] as const, providerPrimary: "megadebrid-web" as const,
|
||||||
|
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
const webService = new DebridService(webSettings, {
|
||||||
|
megaWebUnrestrict: async (_link, _signal, account) => {
|
||||||
|
webLogins.push(account?.login || "");
|
||||||
|
return { fileName: "web-cursor.rar", directUrl: "https://mega-web.example/web-cursor.rar", fileSize: null, retriesUsed: 0 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await webService.unrestrictLink("https://rapidgator.net/file/web-cursor");
|
||||||
|
|
||||||
|
expect(webLogins).toEqual(["cursor-web-1"]);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it("keeps the Mega-Debrid Web sticky counter independent from API successes", async () => {
|
||||||
|
const apiSettings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "", bestToken: "", allDebridToken: "",
|
||||||
|
megaCredentials: "sticky-api-1:pass1\nsticky-api-2:pass2",
|
||||||
|
megaDebridApiCredentials: "sticky-api-1:pass1\nsticky-api-2:pass2",
|
||||||
|
megaDebridWebCredentials: "",
|
||||||
|
megaDebridApiEnabled: true,
|
||||||
|
megaDebridWebEnabled: false,
|
||||||
|
providerOrder: [] as const, providerPrimary: "megadebrid-api" as const,
|
||||||
|
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes("action=connectUser")) {
|
||||||
|
return new Response(JSON.stringify({ response_code: "ok", token: "sticky-api-token" }), { status: 200 });
|
||||||
|
}
|
||||||
|
if (url.includes("action=getLink")) {
|
||||||
|
return new Response(JSON.stringify({ response_code: "ok", debridLink: "https://mega-cdn.example/sticky.rar", filename: "sticky.rar" }), { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response("not-found", { status: 404 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const apiService = new DebridService(apiSettings);
|
||||||
|
for (let index = 0; index < MEGA_DEBRID_STICKY_LINKS - 1; index += 1) {
|
||||||
|
await apiService.unrestrictLink(`https://rapidgator.net/file/api-sticky-${index}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const webLogins: string[] = [];
|
||||||
|
const webSettings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "", bestToken: "", allDebridToken: "",
|
||||||
|
megaCredentials: "sticky-web-1:pass1\nsticky-web-2:pass2",
|
||||||
|
megaDebridApiCredentials: "",
|
||||||
|
megaDebridWebCredentials: "sticky-web-1:pass1\nsticky-web-2:pass2",
|
||||||
|
megaDebridApiEnabled: false,
|
||||||
|
megaDebridWebEnabled: true,
|
||||||
|
providerOrder: [] as const, providerPrimary: "megadebrid-web" as const,
|
||||||
|
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
const webService = new DebridService(webSettings, {
|
||||||
|
megaWebUnrestrict: async (_link, _signal, account) => {
|
||||||
|
webLogins.push(account?.login || "");
|
||||||
|
return { fileName: "web-sticky.rar", directUrl: "https://mega-web.example/web-sticky.rar", fileSize: null, retriesUsed: 0 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await webService.unrestrictLink("https://rapidgator.net/file/web-sticky-1");
|
||||||
|
await webService.unrestrictLink("https://rapidgator.net/file/web-sticky-2");
|
||||||
|
|
||||||
|
expect(webLogins).toEqual(["sticky-web-1", "sticky-web-1"]);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
it("ueberspringt einen gesperrten Account und bleibt dann klebrig beim naechsten", async () => {
|
it("ueberspringt einen gesperrten Account und bleibt dann klebrig beim naechsten", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
|
|||||||
+305
-17
@@ -188,12 +188,12 @@ describe("disk write recovery", () => {
|
|||||||
(manager as any).debridService.unrestrictLink = async () => ({
|
(manager as any).debridService.unrestrictLink = async () => ({
|
||||||
fileName: "reserve-download.bin",
|
fileName: "reserve-download.bin",
|
||||||
directUrl: "https://dummy/reserve-download",
|
directUrl: "https://dummy/reserve-download",
|
||||||
fileSize: 1_024,
|
fileSize: null,
|
||||||
retriesUsed: 0,
|
retriesUsed: 0,
|
||||||
provider: "realdebrid",
|
provider: "realdebrid",
|
||||||
providerLabel: "Real-Debrid"
|
providerLabel: "Real-Debrid"
|
||||||
});
|
});
|
||||||
globalThis.fetch = vi.fn(async () => new Response(Buffer.alloc(16, 1), {
|
globalThis.fetch = vi.fn(async () => new Response(Buffer.alloc(1_024, 1), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"content-length": "1024",
|
"content-length": "1024",
|
||||||
@@ -232,6 +232,107 @@ describe("disk write recovery", () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("releases an active download when Stop aborts a blocked post-header disk reservation", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-reserve-abort-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = "reserve-abort-package";
|
||||||
|
const itemId = "reserve-abort-item";
|
||||||
|
const outputDir = path.join(root, "downloads", "reserve-abort");
|
||||||
|
const createdAt = Date.now();
|
||||||
|
session.running = true;
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId,
|
||||||
|
name: "reserve-abort",
|
||||||
|
outputDir,
|
||||||
|
extractDir: path.join(root, "extract", "reserve-abort"),
|
||||||
|
status: "downloading",
|
||||||
|
itemIds: [itemId],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
session.items[itemId] = {
|
||||||
|
id: itemId,
|
||||||
|
packageId,
|
||||||
|
url: "https://rapidgator.net/file/reserve-abort",
|
||||||
|
provider: "realdebrid",
|
||||||
|
status: "downloading",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: null,
|
||||||
|
progressPercent: 0,
|
||||||
|
fileName: "reserve-abort.bin",
|
||||||
|
targetPath: "",
|
||||||
|
resumable: true,
|
||||||
|
attempts: 0,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Download läuft",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
let reservationStarted = false;
|
||||||
|
let statCalls = 0;
|
||||||
|
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"))
|
||||||
|
);
|
||||||
|
(manager as any).diskReservations = new DiskReservationCoordinator({
|
||||||
|
safetyBytes: 0,
|
||||||
|
statVolume: async (targetPath) => {
|
||||||
|
statCalls += 1;
|
||||||
|
reservationStarted = true;
|
||||||
|
if (statCalls === 1) {
|
||||||
|
return await new Promise(() => undefined);
|
||||||
|
}
|
||||||
|
return { path: targetPath, volumeKey: "follow-up-volume", freeBytes: 4_096, totalBytes: 8_192 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(manager as any).debridService.unrestrictLink = async () => ({
|
||||||
|
fileName: "reserve-abort.bin",
|
||||||
|
directUrl: "https://dummy/reserve-abort",
|
||||||
|
fileSize: null,
|
||||||
|
retriesUsed: 0,
|
||||||
|
provider: "realdebrid",
|
||||||
|
providerLabel: "Real-Debrid"
|
||||||
|
});
|
||||||
|
const response = new Response(Buffer.alloc(1_024, 3), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-length": "1024", "accept-ranges": "bytes" }
|
||||||
|
});
|
||||||
|
const cancelSpy = vi.spyOn(response.body!, "cancel");
|
||||||
|
globalThis.fetch = vi.fn(async () => response) as typeof fetch;
|
||||||
|
const active = { itemId, packageId, abortController: new AbortController(), abortReason: "none", resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0 };
|
||||||
|
(manager as any).activeTasks.set(itemId, active);
|
||||||
|
|
||||||
|
const processing = (manager as any).processItem(active) as Promise<void>;
|
||||||
|
await waitFor(() => reservationStarted, 2_000);
|
||||||
|
active.abortReason = "stop";
|
||||||
|
active.abortController.abort("stop");
|
||||||
|
|
||||||
|
await expect(Promise.race([
|
||||||
|
processing.then(() => "released"),
|
||||||
|
new Promise<string>((resolve) => setTimeout(() => resolve("blocked"), 500))
|
||||||
|
])).resolves.toBe("released");
|
||||||
|
expect(cancelSpy).toHaveBeenCalled();
|
||||||
|
const followUpLease = await Promise.race([
|
||||||
|
(manager as any).diskReservations.reserve({
|
||||||
|
phase: "download",
|
||||||
|
ownerId: "follow-up",
|
||||||
|
targetPath: path.join(root, "follow-up.bin"),
|
||||||
|
requiredBytes: 512,
|
||||||
|
alreadyPresentBytes: 0
|
||||||
|
}),
|
||||||
|
new Promise<null>((resolve) => setTimeout(() => resolve(null), 500))
|
||||||
|
]);
|
||||||
|
expect(followUpLease).not.toBeNull();
|
||||||
|
followUpLease?.release();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps disk-wait downloads out of the scheduler until their capacity retry is due", async () => {
|
it("keeps disk-wait downloads out of the scheduler until their capacity retry is due", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-reserve-resume-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-reserve-resume-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -990,6 +1091,113 @@ describe("download manager", () => {
|
|||||||
expect(failures.has("realdebrid")).toBe(true);
|
expect(failures.has("realdebrid")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("aborts active Real-Debrid validation when the provider is disabled live", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-real-live-disable-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
allDebridToken: "ad-token",
|
||||||
|
providerOrder: ["realdebrid", "alldebrid"] as const
|
||||||
|
};
|
||||||
|
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
||||||
|
manager.addPackages([{ name: "live-disable", links: ["https://rapidgator.net/file/live-disable"] }]);
|
||||||
|
const session = (manager as any).session;
|
||||||
|
const item = Object.values(session.items)[0] as any;
|
||||||
|
item.provider = null;
|
||||||
|
item.status = "validating";
|
||||||
|
session.running = true;
|
||||||
|
const active = {
|
||||||
|
itemId: item.id,
|
||||||
|
packageId: item.packageId,
|
||||||
|
abortController: new AbortController(),
|
||||||
|
abortReason: "none",
|
||||||
|
resumable: true,
|
||||||
|
nonResumableCounted: false
|
||||||
|
};
|
||||||
|
(manager as any).activeTasks.set(item.id, active);
|
||||||
|
const failures = (manager as any).providerFailures as Map<string, unknown>;
|
||||||
|
failures.set("realdebrid:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
||||||
|
failures.set("alldebrid:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
||||||
|
|
||||||
|
manager.setSettings({
|
||||||
|
...settings,
|
||||||
|
disabledProviders: ["realdebrid"]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(active.abortController.signal.aborted).toBe(true);
|
||||||
|
expect(active.abortReason).toBe("settings_refresh");
|
||||||
|
expect(failures.has("realdebrid:rapidgator.net")).toBe(false);
|
||||||
|
expect(failures.has("alldebrid:rapidgator.net")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts the active fallback provider when its settings change live", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-fallback-live-disable-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
allDebridToken: "ad-token",
|
||||||
|
providerOrder: ["realdebrid", "alldebrid"] as const,
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: false,
|
||||||
|
maxParallel: 1
|
||||||
|
};
|
||||||
|
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
||||||
|
let fallbackStarted = false;
|
||||||
|
let providerSignal: AbortSignal | undefined;
|
||||||
|
(manager as any).debridService.unrestrictLink = async (
|
||||||
|
_link: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
_settingsSnapshot?: AppSettings,
|
||||||
|
_preferredLeadProvider?: DebridProvider | null,
|
||||||
|
onProviderAttempt?: (provider: DebridProvider) => void
|
||||||
|
) => {
|
||||||
|
providerSignal = signal;
|
||||||
|
onProviderAttempt?.("alldebrid");
|
||||||
|
fallbackStarted = true;
|
||||||
|
return await new Promise((_resolve, reject) => {
|
||||||
|
const onAbort = (): void => reject(new Error("aborted:settings-refresh"));
|
||||||
|
if (signal?.aborted) {
|
||||||
|
onAbort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
manager.addPackages([{ name: "fallback-live-disable", links: ["https://rapidgator.net/file/fallback-live-disable"] }]);
|
||||||
|
|
||||||
|
await manager.start();
|
||||||
|
await waitFor(() => fallbackStarted, 5_000);
|
||||||
|
manager.setSettings({ ...settings, disabledProviders: ["alldebrid"] });
|
||||||
|
|
||||||
|
expect(providerSignal?.aborted).toBe(true);
|
||||||
|
manager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a provider cooldown when the provider is re-enabled live", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-real-live-reenable-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const settings: AppSettings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
disabledProviders: ["realdebrid"]
|
||||||
|
};
|
||||||
|
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
||||||
|
const failures = (manager as any).providerFailures as Map<string, unknown>;
|
||||||
|
failures.set("realdebrid", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
||||||
|
failures.set("realdebrid:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
||||||
|
|
||||||
|
manager.setSettings({
|
||||||
|
...settings,
|
||||||
|
disabledProviders: []
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(failures.has("realdebrid")).toBe(false);
|
||||||
|
expect(failures.has("realdebrid:rapidgator.net")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("invalidates only the Mega-Debrid Web session when Web credentials change", () => {
|
it("invalidates only the Mega-Debrid Web session when Web credentials change", () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-web-session-refresh-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-web-session-refresh-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -3840,7 +4048,7 @@ describe("download manager", () => {
|
|||||||
expect(item?.status).toBe("failed");
|
expect(item?.status).toBe("failed");
|
||||||
expect(downloadCalls).toBeGreaterThan(4);
|
expect(downloadCalls).toBeGreaterThan(4);
|
||||||
expect(downloadCalls).toBeLessThan(30);
|
expect(downloadCalls).toBeLessThan(30);
|
||||||
expect(item.http416FreshRestarts).toBeUndefined();
|
expect(item.http416FreshRestarts).toBe(2);
|
||||||
} finally {
|
} finally {
|
||||||
if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; }
|
if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; }
|
||||||
}
|
}
|
||||||
@@ -3907,7 +4115,7 @@ describe("download manager", () => {
|
|||||||
await (manager as any).escalateHttp416OrFail(item, active, "", "HTTP 416");
|
await (manager as any).escalateHttp416OrFail(item, active, "", "HTTP 416");
|
||||||
|
|
||||||
expect(item.status).toBe("failed");
|
expect(item.status).toBe("failed");
|
||||||
expect(item.http416FreshRestarts).toBeUndefined();
|
expect(item.http416FreshRestarts).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retries HTTP 416 in-session when using Debrid-Link API and then completes", async () => {
|
it("retries HTTP 416 in-session when using Debrid-Link API and then completes", async () => {
|
||||||
@@ -6304,7 +6512,7 @@ describe("download manager", () => {
|
|||||||
expect(snapshot.canStart).toBe(true);
|
expect(snapshot.canStart).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requeues failed HTTP 416 items automatically on startup", async () => {
|
it("does not requeue an exhausted HTTP 416 item after restart", 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);
|
||||||
|
|
||||||
@@ -6368,17 +6576,17 @@ describe("download manager", () => {
|
|||||||
|
|
||||||
const snapshot = manager.getSnapshot();
|
const snapshot = manager.getSnapshot();
|
||||||
const item = snapshot.session.items[itemId];
|
const item = snapshot.session.items[itemId];
|
||||||
expect(item?.status).toBe("queued");
|
expect(item?.status).toBe("failed");
|
||||||
expect(item?.attempts).toBe(0);
|
expect(item?.attempts).toBe(3);
|
||||||
expect(item?.downloadedBytes).toBe(0);
|
expect(item?.downloadedBytes).toBe(12 * 1024);
|
||||||
expect(item?.progressPercent).toBe(0);
|
expect(item?.progressPercent).toBe(100);
|
||||||
expect(item?.fullStatus).toContain("Auto-Retry");
|
expect(item?.fullStatus).toContain("Fehler");
|
||||||
expect(item?.http416FreshRestarts).toBe(2);
|
expect(item?.http416FreshRestarts).toBe(2);
|
||||||
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
|
expect(snapshot.session.packages[packageId]?.status).toBe("failed");
|
||||||
expect(fs.existsSync(targetPath)).toBe(false);
|
expect(fs.existsSync(targetPath)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a locked HTTP 416 partial intact and persists a pending clean reset", async () => {
|
it("keeps an exhausted locked HTTP 416 partial intact without requeueing it after restart", 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);
|
||||||
|
|
||||||
@@ -6450,17 +6658,18 @@ describe("download manager", () => {
|
|||||||
createStoragePaths(path.join(root, "state"))
|
createStoragePaths(path.join(root, "state"))
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => manager.getSnapshot().session.items[itemId]?.status === "queued", 2000);
|
await manager.waitForStartupRecovery();
|
||||||
|
|
||||||
const item = manager.getSnapshot().session.items[itemId];
|
const item = manager.getSnapshot().session.items[itemId];
|
||||||
expect(item).toMatchObject({
|
expect(item).toMatchObject({
|
||||||
status: "queued",
|
status: "failed",
|
||||||
downloadedBytes: partialBytes,
|
downloadedBytes: partialBytes,
|
||||||
totalBytes: partialBytes * 2,
|
totalBytes: partialBytes * 2,
|
||||||
progressPercent: 50,
|
progressPercent: 50,
|
||||||
resumeResetPending: true,
|
http416FreshRestarts: 2,
|
||||||
fullStatus: "Warte auf Teildatei-Freigabe"
|
fullStatus: "Fehler: Error: HTTP 416"
|
||||||
});
|
});
|
||||||
|
expect(item?.resumeResetPending).toBeUndefined();
|
||||||
expect(fs.existsSync(targetPath)).toBe(true);
|
expect(fs.existsSync(targetPath)).toBe(true);
|
||||||
expect(fs.statSync(targetPath).size).toBe(partialBytes);
|
expect(fs.statSync(targetPath).size).toBe(partialBytes);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -10267,6 +10476,85 @@ describe("download manager", () => {
|
|||||||
expect(packageEntry.cleanedTotalBytes).toBe(1_000);
|
expect(packageEntry.cleanedTotalBytes).toBe(1_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["on_start", "on_start"],
|
||||||
|
["retroactive immediate", "never"]
|
||||||
|
] as const)("preserves completed package progress during %s cleanup", (_name, initialPolicy) => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = `cleanup-${initialPolicy}-package`;
|
||||||
|
const completedItemId = `cleanup-${initialPolicy}-completed`;
|
||||||
|
const queuedItemId = `cleanup-${initialPolicy}-queued`;
|
||||||
|
const createdAt = Date.now();
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId,
|
||||||
|
name: `cleanup-${initialPolicy}`,
|
||||||
|
outputDir: path.join(root, "downloads", `cleanup-${initialPolicy}`),
|
||||||
|
extractDir: path.join(root, "extract", `cleanup-${initialPolicy}`),
|
||||||
|
status: "queued",
|
||||||
|
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-${initialPolicy}`, "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,
|
||||||
|
totalBytes: 2_000,
|
||||||
|
progressPercent: 0,
|
||||||
|
fileName: "queued.rar",
|
||||||
|
targetPath: path.join(root, "downloads", `cleanup-${initialPolicy}`, "queued.rar"),
|
||||||
|
fullStatus: "Wartet"
|
||||||
|
};
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: true,
|
||||||
|
completedCleanupPolicy: initialPolicy
|
||||||
|
};
|
||||||
|
const manager = new DownloadManager(settings, session, createStoragePaths(path.join(root, "state")));
|
||||||
|
|
||||||
|
if (initialPolicy === "never") {
|
||||||
|
manager.setSettings({ ...settings, completedCleanupPolicy: "immediate" });
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
expect(packageEntry.cleanedUrls).toEqual(["https://dummy/completed"]);
|
||||||
|
expect(packageEntry.cleanedProviders).toEqual(["realdebrid"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("includes immediately cleaned items in the final package history entry", () => {
|
it("includes immediately cleaned items in the final package history entry", () => {
|
||||||
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);
|
||||||
|
|||||||
@@ -1412,6 +1412,13 @@ describe("download table row contracts", () => {
|
|||||||
expect(compactDownloadStatus("Warte auf Festplatte (Mega-Debrid Web)")).toBe("Warte auf Festplatte");
|
expect(compactDownloadStatus("Warte auf Festplatte (Mega-Debrid Web)")).toBe("Warte auf Festplatte");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps provider and hybrid extraction diagnostics out of the visible status", () => {
|
||||||
|
expect(compactDownloadStatus("Fehler: Mega-Debrid API: Kein Server verfügbar")).toBe("Fehler");
|
||||||
|
expect(compactDownloadStatus("Error: Mega-Debrid Web: No server available")).toBe("Error");
|
||||||
|
expect(compactDownloadStatus("Entpacken - Error")).toBe("Entpack-Fehler");
|
||||||
|
expect(compactDownloadStatus("Extracting - Error")).toBe("Extraction error");
|
||||||
|
});
|
||||||
|
|
||||||
it("prioritizes disk waits and extraction errors in package status", () => {
|
it("prioritizes disk waits and extraction errors in package status", () => {
|
||||||
const diskPackage = pkg("disk-package", "Disk package", ["disk-item", "active-item"]);
|
const diskPackage = pkg("disk-package", "Disk package", ["disk-item", "active-item"]);
|
||||||
const diskHtml = renderToStaticMarkup(PackageCardContent({
|
const diskHtml = renderToStaticMarkup(PackageCardContent({
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ describe("history model", () => {
|
|||||||
|
|
||||||
it("derives hosters only from valid URL hostnames and clamps the calculated start time", () => {
|
it("derives hosters only from valid URL hostnames and clamps the calculated start time", () => {
|
||||||
expect(deriveHistoryHoster(["https://rapidgator.net/a", "https://rapidgator.net/b", "https://ddownload.com/c", "not a url"])).toBe("rapidgator.net, ddownload.com");
|
expect(deriveHistoryHoster(["https://rapidgator.net/a", "https://rapidgator.net/b", "https://ddownload.com/c", "not a url"])).toBe("rapidgator.net, ddownload.com");
|
||||||
|
expect(deriveHistoryHoster(["https://rapidgator.net/a", "https://rg.to/b", "https://cdn.rg.to/c"])).toBe("rapidgator.net");
|
||||||
expect(deriveHistoryHoster([])).toBe("—");
|
expect(deriveHistoryHoster([])).toBe("—");
|
||||||
expect(deriveHistoryHoster(undefined)).toBe("—");
|
expect(deriveHistoryHoster(undefined)).toBe("—");
|
||||||
expect(deriveHistoryStartAt(entry({ id: "start", name: "Start", completedAt: 20_000, durationSeconds: 3 }))).toBe(17_000);
|
expect(deriveHistoryStartAt(entry({ id: "start", name: "Start", completedAt: 20_000, durationSeconds: 3 }))).toBe(17_000);
|
||||||
@@ -171,6 +172,11 @@ describe("history model", () => {
|
|||||||
expect(row.providerLabel).toBe("Real-Debrid");
|
expect(row.providerLabel).toBe("Real-Debrid");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps unrelated hostnames distinct", () => {
|
||||||
|
expect(deriveHistoryHoster(["https://files.example.com/a", "https://cdn.example.net/b"])).toBe("files.example.com, cdn.example.net");
|
||||||
|
expect(deriveHistoryHoster(["https://foo.co.uk/a", "https://bar.co.uk/b"])).toBe("foo.co.uk, bar.co.uk");
|
||||||
|
});
|
||||||
|
|
||||||
it("prunes removed ids and preserves the original set instance when every id survives", () => {
|
it("prunes removed ids and preserves the original set instance when every id survives", () => {
|
||||||
const stable = new Set(["today", "week"]);
|
const stable = new Set(["today", "week"]);
|
||||||
expect(pruneHistoryIds(stable, ["today", "week", "older"])).toBe(stable);
|
expect(pruneHistoryIds(stable, ["today", "week", "older"])).toBe(stable);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const {
|
|||||||
mockLoadURL,
|
mockLoadURL,
|
||||||
mockShow,
|
mockShow,
|
||||||
mockFocus,
|
mockFocus,
|
||||||
|
mockClose,
|
||||||
mockSetWindowOpenHandler,
|
mockSetWindowOpenHandler,
|
||||||
mockSetPermissionRequestHandler
|
mockSetPermissionRequestHandler
|
||||||
} = vi.hoisted(() => {
|
} = vi.hoisted(() => {
|
||||||
@@ -73,6 +74,7 @@ const {
|
|||||||
mockLoadURL: loadURL,
|
mockLoadURL: loadURL,
|
||||||
mockShow: show,
|
mockShow: show,
|
||||||
mockFocus: focus,
|
mockFocus: focus,
|
||||||
|
mockClose: browserWindow.close,
|
||||||
mockSetWindowOpenHandler: setWindowOpenHandler,
|
mockSetWindowOpenHandler: setWindowOpenHandler,
|
||||||
mockSetPermissionRequestHandler: setPermissionRequestHandler
|
mockSetPermissionRequestHandler: setPermissionRequestHandler
|
||||||
};
|
};
|
||||||
@@ -162,4 +164,19 @@ describe("realdebrid-web", () => {
|
|||||||
expect(apiFetch.mock.calls[0]?.[0]).toBe("https://api.real-debrid.com/rest/1.0/unrestrict/link");
|
expect(apiFetch.mock.calls[0]?.[0]).toBe("https://api.real-debrid.com/rest/1.0/unrestrict/link");
|
||||||
expect(mockBrowserWindow.webContents.executeJavaScript).toHaveBeenCalled();
|
expect(mockBrowserWindow.webContents.executeJavaScript).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("replaces a login window after its renderer process crashes", async () => {
|
||||||
|
const fallback = new RealDebridWebFallback(() => true);
|
||||||
|
|
||||||
|
await fallback.openLoginWindow();
|
||||||
|
const crashHandler = mockBrowserWindow.webContents.on.mock.calls.find(([event]) => event === "render-process-gone")?.[1];
|
||||||
|
|
||||||
|
expect(crashHandler).toBeTypeOf("function");
|
||||||
|
crashHandler?.();
|
||||||
|
await fallback.openLoginWindow();
|
||||||
|
|
||||||
|
expect(mockClose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockLoadURL).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -128,4 +128,54 @@ describe("renderer state serialization", () => {
|
|||||||
expect(serialized).not.toContain(secret);
|
expect(serialized).not.toContain(secret);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("assigns mode-specific statuses to API and Web rows with the same Mega-Debrid login", () => {
|
||||||
|
const login = "shared-status@example.test";
|
||||||
|
const baseId = getMegaDebridAccountId(login);
|
||||||
|
const apiStatus = {
|
||||||
|
accountId: `${baseId}:api`,
|
||||||
|
provider: "megadebrid" as const,
|
||||||
|
label: "Account 1",
|
||||||
|
maskedLogin: "sh***st",
|
||||||
|
valid: true,
|
||||||
|
isPremium: true,
|
||||||
|
premiumUntilMs: 2_000,
|
||||||
|
message: "API valid",
|
||||||
|
checkedAt: 1
|
||||||
|
};
|
||||||
|
const webStatus = {
|
||||||
|
accountId: `${baseId}:web`,
|
||||||
|
provider: "megadebrid" as const,
|
||||||
|
label: "Account 1",
|
||||||
|
maskedLogin: "sh***st",
|
||||||
|
valid: false,
|
||||||
|
isPremium: false,
|
||||||
|
premiumUntilMs: null,
|
||||||
|
message: "Web rejected",
|
||||||
|
checkedAt: 1
|
||||||
|
};
|
||||||
|
const state = createRendererState({
|
||||||
|
...defaultSettings(),
|
||||||
|
megaCredentials: `${login}:api-pass`,
|
||||||
|
megaDebridApiCredentials: `${login}:api-pass`,
|
||||||
|
megaDebridWebCredentials: `${login}:web-pass`,
|
||||||
|
megaDebridApiEnabled: true,
|
||||||
|
megaDebridWebEnabled: true,
|
||||||
|
debridAccountStatuses: {
|
||||||
|
[apiStatus.accountId]: apiStatus,
|
||||||
|
[webStatus.accountId]: webStatus
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.accounts.find((account) => account.kind === "megadebrid-api")?.status).toMatchObject({
|
||||||
|
accountId: `${baseId}:api`,
|
||||||
|
valid: true,
|
||||||
|
message: "API valid"
|
||||||
|
});
|
||||||
|
expect(state.accounts.find((account) => account.kind === "megadebrid-web")?.status).toMatchObject({
|
||||||
|
accountId: `${baseId}:web`,
|
||||||
|
valid: false,
|
||||||
|
message: "Web rejected"
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ describe("live settings overlay", () => {
|
|||||||
it("keeps current Mega-Debrid counters and drops data for identities no longer configured", () => {
|
it("keeps current Mega-Debrid counters and drops data for identities no longer configured", () => {
|
||||||
const keepMegaId = getMegaDebridAccountId("keep@example.com");
|
const keepMegaId = getMegaDebridAccountId("keep@example.com");
|
||||||
const removedMegaId = getMegaDebridAccountId("removed@example.com");
|
const removedMegaId = getMegaDebridAccountId("removed@example.com");
|
||||||
|
const keepMegaApiStatusId = `${keepMegaId}:api`;
|
||||||
|
const keepMegaWebStatusId = `${keepMegaId}:web`;
|
||||||
|
const removedMegaApiStatusId = `${removedMegaId}:api`;
|
||||||
const keepKeyId = getDebridLinkApiKeyId("keep-key");
|
const keepKeyId = getDebridLinkApiKeyId("keep-key");
|
||||||
const removedKeyId = getDebridLinkApiKeyId("removed-key");
|
const removedKeyId = getDebridLinkApiKeyId("removed-key");
|
||||||
const target = {
|
const target = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
megaCredentials: "keep@example.com:pass",
|
megaDebridApiCredentials: "keep@example.com:api-pass",
|
||||||
|
megaDebridWebCredentials: "keep@example.com:web-pass",
|
||||||
megaLogin: "keep@example.com",
|
megaLogin: "keep@example.com",
|
||||||
megaPassword: "pass",
|
megaPassword: "pass",
|
||||||
debridLinkApiKeys: "keep-key",
|
debridLinkApiKeys: "keep-key",
|
||||||
@@ -29,7 +33,10 @@ describe("live settings overlay", () => {
|
|||||||
debridLinkApiKeyTotalUsageBytes: { [keepKeyId]: 5_000, [removedKeyId]: 6_000 },
|
debridLinkApiKeyTotalUsageBytes: { [keepKeyId]: 5_000, [removedKeyId]: 6_000 },
|
||||||
debridAccountStatuses: {
|
debridAccountStatuses: {
|
||||||
[keepMegaId]: { accountId: keepMegaId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "ke***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 },
|
[keepMegaId]: { accountId: keepMegaId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "ke***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 },
|
||||||
|
[keepMegaApiStatusId]: { accountId: keepMegaApiStatusId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "ke***om", valid: false, isPremium: false, premiumUntilMs: null, message: "API ungültig", checkedAt: 2 },
|
||||||
|
[keepMegaWebStatusId]: { accountId: keepMegaWebStatusId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "ke***om", valid: true, isPremium: true, premiumUntilMs: null, message: "Web gültig", checkedAt: 3 },
|
||||||
[removedMegaId]: { accountId: removedMegaId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "re***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 },
|
[removedMegaId]: { accountId: removedMegaId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "re***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 },
|
||||||
|
[removedMegaApiStatusId]: { accountId: removedMegaApiStatusId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "re***om", valid: true, isPremium: true, premiumUntilMs: null, message: "API gültig", checkedAt: 2 },
|
||||||
[keepKeyId]: { accountId: keepKeyId, provider: "debridlink" as const, label: "Key 1", maskedLogin: "kee***key", valid: true, isPremium: false, premiumUntilMs: null, message: "Free", checkedAt: 1 },
|
[keepKeyId]: { accountId: keepKeyId, provider: "debridlink" as const, label: "Key 1", maskedLogin: "kee***key", valid: true, isPremium: false, premiumUntilMs: null, message: "Free", checkedAt: 1 },
|
||||||
[removedKeyId]: { accountId: removedKeyId, provider: "debridlink" as const, label: "Key 2", maskedLogin: "rem***key", valid: true, isPremium: false, premiumUntilMs: null, message: "Free", checkedAt: 1 }
|
[removedKeyId]: { accountId: removedKeyId, provider: "debridlink" as const, label: "Key 2", maskedLogin: "rem***key", valid: true, isPremium: false, premiumUntilMs: null, message: "Free", checkedAt: 1 }
|
||||||
}
|
}
|
||||||
@@ -41,7 +48,14 @@ describe("live settings overlay", () => {
|
|||||||
expect(target.megaDebridAccountTotalUsageBytes).toEqual({ [keepMegaId]: 3_000 });
|
expect(target.megaDebridAccountTotalUsageBytes).toEqual({ [keepMegaId]: 3_000 });
|
||||||
expect(target.debridLinkApiKeyDailyUsageBytes).toEqual({ [keepKeyId]: 500 });
|
expect(target.debridLinkApiKeyDailyUsageBytes).toEqual({ [keepKeyId]: 500 });
|
||||||
expect(target.debridLinkApiKeyTotalUsageBytes).toEqual({ [keepKeyId]: 5_000 });
|
expect(target.debridLinkApiKeyTotalUsageBytes).toEqual({ [keepKeyId]: 5_000 });
|
||||||
expect(Object.keys(target.debridAccountStatuses).sort()).toEqual([keepKeyId, keepMegaId].sort());
|
expect(Object.keys(target.debridAccountStatuses).sort()).toEqual([
|
||||||
|
keepKeyId,
|
||||||
|
keepMegaId,
|
||||||
|
keepMegaApiStatusId,
|
||||||
|
keepMegaWebStatusId
|
||||||
|
].sort());
|
||||||
|
expect(target.debridAccountStatuses[keepMegaApiStatusId]?.message).toBe("API ungültig");
|
||||||
|
expect(target.debridAccountStatuses[keepMegaWebStatusId]?.message).toBe("Web gültig");
|
||||||
expect(target.totalRuntimeAllTimeMs).toBe(9_000);
|
expect(target.totalRuntimeAllTimeMs).toBe(9_000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+78
-3
@@ -1,14 +1,14 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
import { getMegaDebridAccountId, getMegaDebridAccountStatusId } from "../src/shared/mega-debrid-accounts";
|
||||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||||
import { AppSettings } from "../src/shared/types";
|
import { AppSettings } from "../src/shared/types";
|
||||||
import { defaultSettings } from "../src/main/constants";
|
import { defaultSettings } from "../src/main/constants";
|
||||||
import { configureCredentialProtector } from "../src/main/credential-protection";
|
import { configureCredentialProtector } from "../src/main/credential-protection";
|
||||||
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
|
import { addHistoryEntryForRetention, clearHistory, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
type SettingsSaveMode = "sync" | "async";
|
type SettingsSaveMode = "sync" | "async";
|
||||||
@@ -30,6 +30,7 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -204,6 +205,49 @@ describe("settings storage", () => {
|
|||||||
expect(loaded.allDebridToken).toBe("all-token");
|
expect(loaded.allDebridToken).toBe("all-token");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves mode-specific Mega-Debrid account statuses across save and load", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const paths = createStoragePaths(dir);
|
||||||
|
const accountId = getMegaDebridAccountId("shared-login");
|
||||||
|
const apiStatusId = getMegaDebridAccountStatusId(accountId, "api");
|
||||||
|
const webStatusId = getMegaDebridAccountStatusId(accountId, "web");
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
rememberToken: true,
|
||||||
|
megaDebridApiCredentials: "shared-login:api-password",
|
||||||
|
megaDebridWebCredentials: "shared-login:web-password",
|
||||||
|
debridAccountStatuses: {
|
||||||
|
[apiStatusId]: {
|
||||||
|
accountId: apiStatusId,
|
||||||
|
provider: "megadebrid" as const,
|
||||||
|
label: "API account",
|
||||||
|
maskedLogin: "sh*******in",
|
||||||
|
valid: false,
|
||||||
|
isPremium: false,
|
||||||
|
premiumUntilMs: null,
|
||||||
|
message: "API login failed",
|
||||||
|
checkedAt: 100
|
||||||
|
},
|
||||||
|
[webStatusId]: {
|
||||||
|
accountId: webStatusId,
|
||||||
|
provider: "megadebrid" as const,
|
||||||
|
label: "Web account",
|
||||||
|
maskedLogin: "sh*******in",
|
||||||
|
valid: true,
|
||||||
|
isPremium: true,
|
||||||
|
premiumUntilMs: 200,
|
||||||
|
message: "Web login succeeded",
|
||||||
|
checkedAt: 101
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
saveSettings(paths, settings);
|
||||||
|
|
||||||
|
expect(loadSettings(paths).debridAccountStatuses).toEqual(settings.debridAccountStatuses);
|
||||||
|
});
|
||||||
|
|
||||||
it.each(["sync", "async"] as const)("preserves the previous recoverable settings state during a %s save", async (mode) => {
|
it.each(["sync", "async"] as const)("preserves the previous recoverable settings state during a %s save", async (mode) => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
@@ -726,6 +770,37 @@ describe("settings storage", () => {
|
|||||||
expect(loadHistory(paths)).toEqual([]);
|
expect(loadHistory(paths)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("propagates a history deletion failure instead of reporting success", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const paths = createStoragePaths(dir);
|
||||||
|
saveHistory(paths, [{
|
||||||
|
id: "hist-locked",
|
||||||
|
name: "locked",
|
||||||
|
totalBytes: 1,
|
||||||
|
downloadedBytes: 1,
|
||||||
|
fileCount: 1,
|
||||||
|
provider: "realdebrid",
|
||||||
|
completedAt: Date.now(),
|
||||||
|
durationSeconds: 1,
|
||||||
|
status: "completed",
|
||||||
|
outputDir: path.join(dir, "out"),
|
||||||
|
urls: []
|
||||||
|
}]);
|
||||||
|
const originalUnlink = fs.unlinkSync;
|
||||||
|
vi.spyOn(fs, "unlinkSync").mockImplementation((target) => {
|
||||||
|
if (target === paths.historyFile) {
|
||||||
|
const error = new Error("EPERM: history file is locked") as NodeJS.ErrnoException;
|
||||||
|
error.code = "EPERM";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return originalUnlink(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => clearHistory(paths)).toThrow(/EPERM/);
|
||||||
|
expect(loadHistory(paths)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("caps persisted history to the configured maxEntries", () => {
|
it("caps persisted history to the configured maxEntries", () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/ma
|
|||||||
import { initAccountRotationLog, logAccountRotation, shutdownAccountRotationLog } from "../src/main/account-rotation-log";
|
import { initAccountRotationLog, logAccountRotation, shutdownAccountRotationLog } from "../src/main/account-rotation-log";
|
||||||
import { configureLogger, flushLoggerSync, logger } from "../src/main/logger";
|
import { configureLogger, flushLoggerSync, logger } from "../src/main/logger";
|
||||||
import { ensurePackageLog, initPackageLogs, logPackageEvent, shutdownPackageLogs } from "../src/main/package-log";
|
import { ensurePackageLog, initPackageLogs, logPackageEvent, shutdownPackageLogs } from "../src/main/package-log";
|
||||||
import { ensureItemLog, initItemLogs, logItemEvent, shutdownItemLogs } from "../src/main/item-log";
|
import { ensureItemLog, flushItemLogs, initItemLogs, logItemEvent, shutdownItemLogs } from "../src/main/item-log";
|
||||||
import { initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log";
|
import { initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log";
|
||||||
import {
|
import {
|
||||||
primeDebridLinkRuntimeCooldownForTests,
|
primeDebridLinkRuntimeCooldownForTests,
|
||||||
@@ -602,6 +602,289 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
|||||||
expect(itemEntry?.getData().toString("utf8") || "").toContain("item-buffer-marker");
|
expect(itemEntry?.getData().toString("utf8") || "").toContain("item-buffer-marker");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("redacts snapshot and runtime package and file names from the tailed main log", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-main-log-names-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const packageName = "MAINPKG-BEGIN === MAINPKG-MIDDLE | fileName=MAINPKG-END";
|
||||||
|
const fileName = "MAINFILE-BEGIN | packageName=MAINFILE-MIDDLE === MAINFILE-END.rar";
|
||||||
|
const runtimeOnlyName = "RUNTIME-BEGIN | decoy=RUNTIME-MIDDLE === RUNTIME-END";
|
||||||
|
flushLoggerSync();
|
||||||
|
configureLogger(root);
|
||||||
|
const mainLogPath = path.join(root, "rd_downloader.log");
|
||||||
|
const completeTail = [
|
||||||
|
"",
|
||||||
|
"2026-08-13 12:00:00.000 [INFO] main-diagnostic-marker status=downloading",
|
||||||
|
`2026-08-13 12:00:00.000 [INFO] runtime-name-marker packageName=${runtimeOnlyName} | status=queued`,
|
||||||
|
`2026-08-13 12:00:00.000 [INFO] main-package-marker ${packageName}`,
|
||||||
|
`2026-08-13 12:00:00.000 [INFO] main-file-marker ${fileName}`,
|
||||||
|
""
|
||||||
|
].join("\n");
|
||||||
|
const partialOffset = 8;
|
||||||
|
const fillerLength = 128 * 1024 - Buffer.byteLength(packageName.slice(partialOffset) + completeTail, "utf8");
|
||||||
|
fs.appendFileSync(mainLogPath, `${packageName}${completeTail}${"z".repeat(fillerLength)}`, "utf8");
|
||||||
|
const manager = {
|
||||||
|
getSnapshot: () => ({
|
||||||
|
stats: {},
|
||||||
|
session: {
|
||||||
|
version: 1,
|
||||||
|
packages: {
|
||||||
|
"package-main-log": {
|
||||||
|
id: "package-main-log",
|
||||||
|
name: packageName,
|
||||||
|
outputDir: path.join(root, "output", packageName),
|
||||||
|
extractDir: path.join(root, "extract", packageName),
|
||||||
|
status: "downloading",
|
||||||
|
itemIds: ["item-main-log"],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
"item-main-log": {
|
||||||
|
id: "item-main-log",
|
||||||
|
packageId: "package-main-log",
|
||||||
|
url: "https://example.test/main-log",
|
||||||
|
provider: "realdebrid",
|
||||||
|
status: "downloading",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 1,
|
||||||
|
downloadedBytes: 1,
|
||||||
|
totalBytes: 2,
|
||||||
|
progressPercent: 50,
|
||||||
|
fileName,
|
||||||
|
targetPath: path.join(root, "output", packageName, fileName),
|
||||||
|
resumable: true,
|
||||||
|
attempts: 1,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Download läuft",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
packageOrder: ["package-main-log"],
|
||||||
|
running: true,
|
||||||
|
paused: false,
|
||||||
|
updatedAt: 2
|
||||||
|
},
|
||||||
|
speedText: "1 B/s",
|
||||||
|
etaText: "1s",
|
||||||
|
canStart: false,
|
||||||
|
canStop: true,
|
||||||
|
canPause: true
|
||||||
|
}),
|
||||||
|
getPackageLogPath: () => null,
|
||||||
|
getItemLogPath: () => null
|
||||||
|
} as unknown as DownloadManager;
|
||||||
|
|
||||||
|
const buffer = await buildSupportBundle(manager, root, {
|
||||||
|
hostDiagnosticsMode: "none",
|
||||||
|
debugSetupMode: "deferred"
|
||||||
|
});
|
||||||
|
const mainLog = new AdmZip(buffer).getEntry("logs/rd_downloader.log")?.getData().toString("utf8") || "";
|
||||||
|
|
||||||
|
for (const fragment of [
|
||||||
|
"MAINPKG-BEGIN",
|
||||||
|
"MAINPKG-MIDDLE",
|
||||||
|
"MAINPKG-END",
|
||||||
|
"MAINFILE-BEGIN",
|
||||||
|
"MAINFILE-MIDDLE",
|
||||||
|
"MAINFILE-END",
|
||||||
|
"RUNTIME-BEGIN",
|
||||||
|
"RUNTIME-MIDDLE",
|
||||||
|
"RUNTIME-END"
|
||||||
|
]) {
|
||||||
|
expect(mainLog).not.toContain(fragment);
|
||||||
|
}
|
||||||
|
expect(mainLog).toContain("main-diagnostic-marker");
|
||||||
|
expect(mainLog).toContain("main-package-marker");
|
||||||
|
expect(mainLog).toContain("main-file-marker");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts completed download names after the package was removed from the session", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-removed-main-log-names-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
flushLoggerSync();
|
||||||
|
configureLogger(root);
|
||||||
|
const fileName = "REMOVED-PRIVATE-FILE.part01.rar";
|
||||||
|
const packageName = "REMOVED-PRIVATE-PACKAGE";
|
||||||
|
fs.appendFileSync(
|
||||||
|
path.join(root, "rd_downloader.log"),
|
||||||
|
`2026-08-13 12:00:00.000 [INFO] Download fertig: ${fileName} (1.00 GB), pkg=${packageName}\n`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
const buffer = await buildSupportBundle(fakeManager(), root, {
|
||||||
|
hostDiagnosticsMode: "none",
|
||||||
|
debugSetupMode: "deferred"
|
||||||
|
});
|
||||||
|
const mainLog = new AdmZip(buffer).getEntry("logs/rd_downloader.log")?.getData().toString("utf8") || "";
|
||||||
|
|
||||||
|
expect(mainLog).toContain("Download fertig:");
|
||||||
|
expect(mainLog).not.toContain(fileName);
|
||||||
|
expect(mainLog).not.toContain(packageName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts runtime package and file names from included package and item logs", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-runtime-names-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const packageId = "package-runtime-private";
|
||||||
|
const itemId = "item-runtime-private";
|
||||||
|
const privatePackageName = "Family.Vacation.Private.Release";
|
||||||
|
const privateFileName = "Family.Vacation.Private.Release.part01.rar";
|
||||||
|
initPackageLogs(root);
|
||||||
|
initItemLogs(root);
|
||||||
|
ensurePackageLog({
|
||||||
|
packageId,
|
||||||
|
name: privatePackageName,
|
||||||
|
outputDir: path.join(root, "output", privatePackageName),
|
||||||
|
extractDir: path.join(root, "extract", privatePackageName)
|
||||||
|
});
|
||||||
|
ensureItemLog({
|
||||||
|
itemId,
|
||||||
|
packageId,
|
||||||
|
packageName: privatePackageName,
|
||||||
|
fileName: privateFileName,
|
||||||
|
targetPath: path.join(root, "output", privatePackageName, privateFileName)
|
||||||
|
});
|
||||||
|
logPackageEvent(packageId, "INFO", `package-transfer-active ${privatePackageName}`, { status: "downloading" });
|
||||||
|
logItemEvent(itemId, "INFO", `item-transfer-active ${privateFileName}`, { status: "downloading" });
|
||||||
|
|
||||||
|
const manager = {
|
||||||
|
getSnapshot: () => ({
|
||||||
|
stats: {},
|
||||||
|
session: {
|
||||||
|
version: 1,
|
||||||
|
packages: {
|
||||||
|
[packageId]: {
|
||||||
|
id: packageId,
|
||||||
|
name: privatePackageName,
|
||||||
|
outputDir: path.join(root, "output", privatePackageName),
|
||||||
|
extractDir: path.join(root, "extract", privatePackageName),
|
||||||
|
status: "downloading",
|
||||||
|
itemIds: [itemId],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
[itemId]: {
|
||||||
|
id: itemId,
|
||||||
|
packageId,
|
||||||
|
url: "https://rapidgator.net/file/runtime-private",
|
||||||
|
provider: "megadebrid-web",
|
||||||
|
status: "downloading",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 1024,
|
||||||
|
downloadedBytes: 512,
|
||||||
|
totalBytes: 1024,
|
||||||
|
progressPercent: 50,
|
||||||
|
fileName: privateFileName,
|
||||||
|
targetPath: path.join(root, "output", privatePackageName, privateFileName),
|
||||||
|
resumable: true,
|
||||||
|
attempts: 1,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Download läuft",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
packageOrder: [packageId],
|
||||||
|
running: true,
|
||||||
|
paused: false,
|
||||||
|
updatedAt: 2
|
||||||
|
},
|
||||||
|
speedText: "1 KB/s",
|
||||||
|
etaText: "1s",
|
||||||
|
canStart: false,
|
||||||
|
canStop: true,
|
||||||
|
canPause: true
|
||||||
|
}),
|
||||||
|
getPackageLogPath: () => null,
|
||||||
|
getItemLogPath: () => null
|
||||||
|
} as unknown as DownloadManager;
|
||||||
|
|
||||||
|
const buffer = await buildSupportBundle(manager, root, {
|
||||||
|
hostDiagnosticsMode: "none",
|
||||||
|
debugSetupMode: "deferred"
|
||||||
|
});
|
||||||
|
const zip = new AdmZip(buffer);
|
||||||
|
const packageLog = zip.getEntries()
|
||||||
|
.find((entry) => entry.entryName.startsWith("logs/package-logs/"))
|
||||||
|
?.getData().toString("utf8") || "";
|
||||||
|
const itemLog = zip.getEntries()
|
||||||
|
.find((entry) => entry.entryName.startsWith("logs/item-logs/"))
|
||||||
|
?.getData().toString("utf8") || "";
|
||||||
|
const overview = [
|
||||||
|
zip.getEntry("overview/packages.json")?.getData().toString("utf8") || "",
|
||||||
|
zip.getEntry("overview/items.json")?.getData().toString("utf8") || ""
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
expect(`${packageLog}\n${itemLog}`).not.toContain(privatePackageName);
|
||||||
|
expect(`${packageLog}\n${itemLog}`).not.toContain(privateFileName);
|
||||||
|
expect(packageLog).toContain(packageId);
|
||||||
|
expect(itemLog).toContain(itemId);
|
||||||
|
expect(packageLog).toContain("package-transfer-active");
|
||||||
|
expect(itemLog).toContain("item-transfer-active");
|
||||||
|
expect(`${packageLog}\n${itemLog}`).toContain("status=downloading");
|
||||||
|
expect(overview).toContain('"name": "package-001.release"');
|
||||||
|
expect(overview).toContain('"fileName": "item-001.rar"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes delimiter-injected package and file names from complete and tailed runtime logs", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-runtime-name-boundaries-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const packageName = "PRIVATEPKG-BEGIN === PRIVATEPKG-MIDDLE | decoy=PRIVATEPKG-END";
|
||||||
|
const fileName = "PRIVATEFILE-BEGIN | decoyField=PRIVATEFILE-MIDDLE | packageName=PRIVATEFILE-END.rar";
|
||||||
|
initPackageLogs(root);
|
||||||
|
initItemLogs(root);
|
||||||
|
const packageLogPath = ensurePackageLog({
|
||||||
|
packageId: "package-runtime-boundaries",
|
||||||
|
name: packageName,
|
||||||
|
outputDir: path.join(root, "output", packageName),
|
||||||
|
extractDir: path.join(root, "extract", packageName)
|
||||||
|
});
|
||||||
|
const itemLogPath = ensureItemLog({
|
||||||
|
itemId: "item-runtime-boundaries",
|
||||||
|
packageId: "package-runtime-boundaries",
|
||||||
|
packageName,
|
||||||
|
fileName,
|
||||||
|
targetPath: path.join(root, "output", packageName, fileName)
|
||||||
|
});
|
||||||
|
expect(packageLogPath).not.toBeNull();
|
||||||
|
expect(itemLogPath).not.toBeNull();
|
||||||
|
fs.appendFileSync(packageLogPath!, `2026-08-13 12:00:00.000 [INFO] package-diagnostic-marker ${packageName} | status=downloading\n`, "utf8");
|
||||||
|
const partialOffset = 12;
|
||||||
|
const completeTail = `\n2026-08-13 12:00:00.000 [INFO] item-diagnostic-marker ${fileName} | status=downloading\n`;
|
||||||
|
const fillerLength = 128 * 1024 - Buffer.byteLength(fileName.slice(partialOffset) + completeTail, "utf8");
|
||||||
|
fs.appendFileSync(itemLogPath!, `${"p".repeat(1024)}${fileName}${completeTail}${"z".repeat(fillerLength)}`, "utf8");
|
||||||
|
|
||||||
|
const buffer = await buildSupportBundle(fakeManager(), root, {
|
||||||
|
hostDiagnosticsMode: "none",
|
||||||
|
debugSetupMode: "deferred"
|
||||||
|
});
|
||||||
|
const runtimeLogText = new AdmZip(buffer).getEntries()
|
||||||
|
.filter((entry) => /logs\/(?:package|item)-logs\//.test(entry.entryName))
|
||||||
|
.map((entry) => entry.getData().toString("utf8"))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
for (const fragment of [
|
||||||
|
"PRIVATEPKG-BEGIN",
|
||||||
|
"PRIVATEPKG-MIDDLE",
|
||||||
|
"PRIVATEPKG-END",
|
||||||
|
"PRIVATEFILE-BEGIN",
|
||||||
|
"PRIVATEFILE-MIDDLE",
|
||||||
|
"PRIVATEFILE-END"
|
||||||
|
]) {
|
||||||
|
expect(runtimeLogText).not.toContain(fragment);
|
||||||
|
}
|
||||||
|
expect(runtimeLogText).toContain("package-diagnostic-marker");
|
||||||
|
expect(runtimeLogText).toContain("item-diagnostic-marker");
|
||||||
|
});
|
||||||
|
|
||||||
it("bounds recent item logs to the newest diagnostic files", async () => {
|
it("bounds recent item logs to the newest diagnostic files", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -738,9 +1021,14 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
|||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-sensitive-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-sensitive-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
const escapedSecret = "prefix\"suffix\\trail\tend";
|
const escapedSecret = "prefix\"suffix\\trail\tend";
|
||||||
|
const privatePackageName = "Private Default Package Name";
|
||||||
|
const firstKeyId = getDebridLinkApiKeyId("abc123456789xyz");
|
||||||
fs.writeFileSync(path.join(root, "rd_downloader_config.json"), JSON.stringify({
|
fs.writeFileSync(path.join(root, "rd_downloader_config.json"), JSON.stringify({
|
||||||
megaDebridWebCredentials: `primary-user:primary-password-secret\nsecondary-user:secondary-password-secret\nZ9:Q7!\nAlice:${escapedSecret}`,
|
megaDebridWebCredentials: `primary-user:primary-password-secret\nsecondary-user:secondary-password-secret\nZ9:Q7!\nAlice:${escapedSecret}`,
|
||||||
debridLinkApiKeys: "abc123456789xyz,def987654321uvw",
|
debridLinkApiKeys: "abc123456789xyz,def987654321uvw",
|
||||||
|
debridLinkApiKeyDailyUsageBytes: { [firstKeyId]: 1234 },
|
||||||
|
debridLinkApiKeyTotalUsageBytes: { [firstKeyId]: 5678 },
|
||||||
|
packageName: privatePackageName,
|
||||||
megaDebridWebEnabled: true
|
megaDebridWebEnabled: true
|
||||||
}), "utf8");
|
}), "utf8");
|
||||||
initAccountRotationLog(root);
|
initAccountRotationLog(root);
|
||||||
@@ -823,7 +1111,9 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
|||||||
"file-private.bin",
|
"file-private.bin",
|
||||||
"manifest-bearer-secret",
|
"manifest-bearer-secret",
|
||||||
"manifest-query-secret",
|
"manifest-query-secret",
|
||||||
"manifest-fragment"
|
"manifest-fragment",
|
||||||
|
privatePackageName,
|
||||||
|
firstKeyId
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const secret of forbidden) {
|
for (const secret of forbidden) {
|
||||||
@@ -970,6 +1260,92 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
|||||||
expect(timerGaps.length).toBeGreaterThan(2);
|
expect(timerGaps.length).toBeGreaterThan(2);
|
||||||
expect(Math.max(...timerGaps)).toBeLessThan(100);
|
expect(Math.max(...timerGaps)).toBeLessThan(100);
|
||||||
}, 15_000);
|
}, 15_000);
|
||||||
|
|
||||||
|
it("retains failed recovery items when the pending queue exceeds the DTO cap", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-recovery-priority-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const recoveryId = "failed-recovery-item";
|
||||||
|
initItemLogs(root);
|
||||||
|
ensureItemLog({ itemId: recoveryId, packageId: "package-recovery", packageName: "Recovery", fileName: "recovery.rar", targetPath: "C:\\Downloads\\recovery.rar" });
|
||||||
|
logItemEvent(recoveryId, "ERROR", "failed-recovery-marker");
|
||||||
|
flushItemLogs();
|
||||||
|
const queuedItems = Object.fromEntries(Array.from({ length: 501 }, (_, index) => {
|
||||||
|
const id = `queued-${index}`;
|
||||||
|
return [id, {
|
||||||
|
id,
|
||||||
|
packageId: "package-queued",
|
||||||
|
url: `https://rapidgator.net/file/${index}`,
|
||||||
|
provider: "megadebrid-web",
|
||||||
|
status: "queued",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: 100,
|
||||||
|
progressPercent: 0,
|
||||||
|
fileName: `${id}.rar`,
|
||||||
|
targetPath: `C:\\Downloads\\${id}.rar`,
|
||||||
|
resumable: true,
|
||||||
|
attempts: 0,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Wartet",
|
||||||
|
createdAt: index + 1,
|
||||||
|
updatedAt: index + 1
|
||||||
|
}];
|
||||||
|
}));
|
||||||
|
const items = {
|
||||||
|
...queuedItems,
|
||||||
|
[recoveryId]: {
|
||||||
|
id: recoveryId,
|
||||||
|
packageId: "package-recovery",
|
||||||
|
url: "https://rapidgator.net/file/recovery",
|
||||||
|
provider: "megadebrid-web",
|
||||||
|
status: "failed",
|
||||||
|
retries: 8,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: 512,
|
||||||
|
totalBytes: 1024,
|
||||||
|
progressPercent: 50,
|
||||||
|
fileName: "recovery.rar",
|
||||||
|
targetPath: "C:\\Downloads\\recovery.rar",
|
||||||
|
resumable: true,
|
||||||
|
attempts: 9,
|
||||||
|
lastError: "Resume recovery exhausted",
|
||||||
|
fullStatus: "Fehler",
|
||||||
|
resumeResetPending: true,
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const manager = {
|
||||||
|
getSnapshot: () => ({
|
||||||
|
stats: {},
|
||||||
|
session: { version: 1, packages: {}, items, packageOrder: [], running: true, paused: false, updatedAt: 1000 },
|
||||||
|
speedText: "",
|
||||||
|
etaText: "",
|
||||||
|
canStart: false,
|
||||||
|
canStop: true,
|
||||||
|
canPause: true
|
||||||
|
}),
|
||||||
|
getPackageLogPath: () => null,
|
||||||
|
getItemLogPath: () => null
|
||||||
|
} as unknown as DownloadManager;
|
||||||
|
|
||||||
|
const buffer = await buildSupportBundle(manager, root, {
|
||||||
|
hostDiagnosticsMode: "none",
|
||||||
|
debugSetupMode: "deferred"
|
||||||
|
});
|
||||||
|
const zip = new AdmZip(buffer);
|
||||||
|
const itemOverview = JSON.parse(zip.getEntry("overview/items.json")?.getData().toString("utf8") || "{}") as {
|
||||||
|
items?: Array<{ id?: string }>;
|
||||||
|
};
|
||||||
|
const logText = zip.getEntries()
|
||||||
|
.filter((entry) => entry.entryName.startsWith("logs/item-logs/"))
|
||||||
|
.map((entry) => entry.getData().toString("utf8"))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
expect(itemOverview.items?.some((entry) => entry.id === recoveryId)).toBe(true);
|
||||||
|
expect(logText).toContain("failed-recovery-marker");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("support bundle export runner", () => {
|
describe("support bundle export runner", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user