Harden type safety and recovery guards
This commit is contained in:
parent
f6558470c3
commit
17e947fc6b
@ -32,7 +32,7 @@ import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log";
|
|||||||
import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log";
|
import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log";
|
||||||
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
|
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
|
||||||
import { MegaWebFallback } from "./mega-web-fallback";
|
import { MegaWebFallback } from "./mega-web-fallback";
|
||||||
import { addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
|
import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
|
||||||
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
||||||
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
|
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
|
||||||
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
||||||
@ -587,6 +587,8 @@ export class AppController {
|
|||||||
|
|
||||||
// Restore settings — ALL credentials are included (no more masking)
|
// Restore settings — ALL credentials are included (no more masking)
|
||||||
const importedSettings = parsed.settings as AppSettings;
|
const importedSettings = parsed.settings as AppSettings;
|
||||||
|
const importedSettingsRecord = importedSettings as unknown as Record<string, unknown>;
|
||||||
|
const currentSettingsRecord = this.settings as unknown as Record<string, unknown>;
|
||||||
// Legacy backup compatibility: if credentials were masked with ***, keep current values
|
// Legacy backup compatibility: if credentials were masked with ***, keep current values
|
||||||
const SENSITIVE_KEYS: (keyof AppSettings)[] = [
|
const SENSITIVE_KEYS: (keyof AppSettings)[] = [
|
||||||
"token", "megaLogin", "megaPassword", "bestToken", "allDebridToken",
|
"token", "megaLogin", "megaPassword", "bestToken", "allDebridToken",
|
||||||
@ -594,9 +596,9 @@ export class AppController {
|
|||||||
"debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword"
|
"debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword"
|
||||||
];
|
];
|
||||||
for (const key of SENSITIVE_KEYS) {
|
for (const key of SENSITIVE_KEYS) {
|
||||||
const val = (importedSettings as Record<string, unknown>)[key];
|
const val = importedSettingsRecord[key];
|
||||||
if (typeof val === "string" && val.startsWith("***")) {
|
if (typeof val === "string" && val.startsWith("***")) {
|
||||||
(importedSettings as Record<string, unknown>)[key] = (this.settings as Record<string, unknown>)[key];
|
importedSettingsRecord[key] = currentSettingsRecord[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const restoredSettings = normalizeSettings(importedSettings);
|
const restoredSettings = normalizeSettings(importedSettings);
|
||||||
|
|||||||
@ -580,7 +580,7 @@ async function fetchDebridLinkHostLimitForKey(apiKey: { id: string; label: strin
|
|||||||
throw new Error(String(lastError || `Debrid-Link Limits für ${apiKey.label} fehlgeschlagen`).replace(/^Error:\s*/i, ""));
|
throw new Error(String(lastError || `Debrid-Link Limits für ${apiKey.label} fehlgeschlagen`).replace(/^Error:\s*/i, ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueProviderOrder(order: DebridProvider[]): DebridProvider[] {
|
function uniqueProviderOrder(order: readonly DebridProvider[]): DebridProvider[] {
|
||||||
const seen = new Set<DebridProvider>();
|
const seen = new Set<DebridProvider>();
|
||||||
const result: DebridProvider[] = [];
|
const result: DebridProvider[] = [];
|
||||||
for (const provider of order) {
|
for (const provider of order) {
|
||||||
@ -1668,94 +1668,6 @@ class DebridLinkClient {
|
|||||||
: "";
|
: "";
|
||||||
logger.warn(`Debrid-Link${keyLabel}: ${failure.message}${cooldownInfo}, pruefe naechsten Key`);
|
logger.warn(`Debrid-Link${keyLabel}: ${failure.message}${cooldownInfo}, pruefe naechsten Key`);
|
||||||
}
|
}
|
||||||
continue;
|
|
||||||
|
|
||||||
let lastError = "";
|
|
||||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
|
||||||
if (signal?.aborted) throw new Error("aborted:debrid");
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${DEBRID_LINK_API_BASE}/downloader/add`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
Authorization: `Bearer ${apiKey.token}`
|
|
||||||
},
|
|
||||||
body: `url=${encodeURIComponent(link)}`,
|
|
||||||
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
|
|
||||||
});
|
|
||||||
|
|
||||||
const json = await res.json() as Record<string, unknown>;
|
|
||||||
|
|
||||||
if (!json.success) {
|
|
||||||
const errorCode = String(json.error || "");
|
|
||||||
const errorDesc = String(json.error_description || json.error || "Unbekannter Debrid-Link-Fehler");
|
|
||||||
|
|
||||||
if (DEBRID_LINK_QUOTA_ERRORS.has(errorCode)) {
|
|
||||||
logger.warn(`Debrid-Link${keyLabel}: API-Quota erreicht (${errorCode}: ${errorDesc}), wechsle zum naechsten Key`);
|
|
||||||
debridLinkKeyCooldowns.set(apiKey.id, Date.now() + DEBRID_LINK_KEY_COOLDOWN_MS);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (DEBRID_LINK_SKIP_KEY_ERRORS.has(errorCode)) {
|
|
||||||
logger.warn(`Debrid-Link${keyLabel}: Key kann Link nicht verarbeiten (${errorCode}: ${errorDesc}), wechsle zum naechsten Key`);
|
|
||||||
debridLinkKeyCooldowns.set(apiKey.id, Date.now() + DEBRID_LINK_KEY_COOLDOWN_MS);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorCode === "badToken" || errorCode === "expired_token") {
|
|
||||||
throw new Error(`Debrid-Link${keyLabel}: Ungültiger oder abgelaufener API-Key`);
|
|
||||||
}
|
|
||||||
if (errorCode === "floodDetected") {
|
|
||||||
await sleep(retryDelay(attempt), signal);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Debrid-Link${keyLabel}: ${errorDesc}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = json.value as Record<string, unknown> | undefined;
|
|
||||||
if (!value) {
|
|
||||||
throw new Error(`Debrid-Link${keyLabel}: Keine Daten in Antwort`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const directUrl = String(value.downloadUrl || "");
|
|
||||||
if (!directUrl) {
|
|
||||||
throw new Error(`Debrid-Link${keyLabel}: Keine Download-URL in Antwort`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileName = String(value.name || "") || filenameFromUrl(directUrl) || filenameFromUrl(link);
|
|
||||||
const fileSize = typeof value.size === "number" && value.size > 0 ? value.size : null;
|
|
||||||
|
|
||||||
logger.info(`Debrid-Link${keyLabel}: Unrestrict OK → ${fileName || "?"}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
fileName,
|
|
||||||
directUrl,
|
|
||||||
fileSize,
|
|
||||||
retriesUsed: attempt - 1,
|
|
||||||
sourceLabel: apiKey.label,
|
|
||||||
sourceAccountId: apiKey.id,
|
|
||||||
sourceAccountLabel: apiKey.label
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
lastError = compactErrorText(error);
|
|
||||||
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
if (/Ungültig|abgelaufen/i.test(lastError)) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
logger.warn(`Debrid-Link${keyLabel}: Fehler bei Unrestrict-Versuch ${attempt}/${REQUEST_RETRIES}: ${lastError}`);
|
|
||||||
if (attempt < REQUEST_RETRIES) {
|
|
||||||
await sleep(retryDelay(attempt), signal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keyIdx + 1 < this.apiKeys.length) {
|
|
||||||
const nextKey = this.apiKeys[keyIdx + 1];
|
|
||||||
const nextKeyLabel = this.apiKeys.length > 1 ? ` (${nextKey.label})` : "";
|
|
||||||
logger.info(`Debrid-Link${keyLabel}: kein Erfolg, wechsle zu naechstem Key${nextKeyLabel}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!usableKeySeen) {
|
if (!usableKeySeen) {
|
||||||
@ -2299,7 +2211,7 @@ class DdownloadClient {
|
|||||||
if (!xfss) {
|
if (!xfss) {
|
||||||
throw new Error("DDownload Login fehlgeschlagen (kein Session-Cookie)");
|
throw new Error("DDownload Login fehlgeschlagen (kein Session-Cookie)");
|
||||||
}
|
}
|
||||||
this.cookies = [loginCookie, xfss].filter(Boolean).map((c: string) => c.split(";")[0]).join("; ");
|
this.cookies = [loginCookie, xfss].filter((c): c is string => Boolean(c)).map((c) => c.split(";")[0]).join("; ");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { v4 as uuidv4 } from "uuid";
|
|||||||
import {
|
import {
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
|
DebridProvider,
|
||||||
DownloadItem,
|
DownloadItem,
|
||||||
DownloadStats,
|
DownloadStats,
|
||||||
DownloadSummary,
|
DownloadSummary,
|
||||||
@ -2662,7 +2663,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
// Reuse result if same URL was already checked
|
// Reuse result if same URL was already checked
|
||||||
if (checkedUrls.has(url)) {
|
if (checkedUrls.has(url)) {
|
||||||
const cached = checkedUrls.get(url);
|
const cached = checkedUrls.get(url);
|
||||||
|
if (cached !== undefined) {
|
||||||
this.applyRapidgatorCheckResult(item, cached);
|
this.applyRapidgatorCheckResult(item, cached);
|
||||||
|
}
|
||||||
this.emitState();
|
this.emitState();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -6219,8 +6222,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getPackageHistoryDurationSeconds(pkg: PackageEntry): number {
|
private getPackageHistoryDurationSeconds(pkg: PackageEntry): number {
|
||||||
const startedAt = pkg.downloadStartedAt > 0 ? pkg.downloadStartedAt : pkg.createdAt;
|
const startedAt = (pkg.downloadStartedAt || 0) > 0 ? (pkg.downloadStartedAt || 0) : pkg.createdAt;
|
||||||
const finishedAtCandidate = pkg.downloadCompletedAt > 0 ? pkg.downloadCompletedAt : nowMs();
|
const finishedAtCandidate = (pkg.downloadCompletedAt || 0) > 0 ? (pkg.downloadCompletedAt || 0) : nowMs();
|
||||||
const finishedAt = Math.max(startedAt || 0, finishedAtCandidate || 0);
|
const finishedAt = Math.max(startedAt || 0, finishedAtCandidate || 0);
|
||||||
if (startedAt <= 0 || finishedAt <= 0) {
|
if (startedAt <= 0 || finishedAt <= 0) {
|
||||||
return 1;
|
return 1;
|
||||||
@ -6458,7 +6461,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private getProviderOrder(): DebridProvider[] {
|
private getProviderOrder(): DebridProvider[] {
|
||||||
if (this.settings.providerOrder && this.settings.providerOrder.length > 0) {
|
if (this.settings.providerOrder && this.settings.providerOrder.length > 0) {
|
||||||
return this.settings.providerOrder;
|
return [...this.settings.providerOrder];
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
this.settings.providerPrimary,
|
this.settings.providerPrimary,
|
||||||
@ -7195,7 +7198,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
errorText: string,
|
errorText: string,
|
||||||
claimedTargetPath: string
|
claimedTargetPath: string
|
||||||
): void {
|
): void {
|
||||||
active.genericErrorRetries += 1;
|
active.genericErrorRetries = Number(active.genericErrorRetries || 0) + 1;
|
||||||
item.retries += 1;
|
item.retries += 1;
|
||||||
if (claimedTargetPath) {
|
if (claimedTargetPath) {
|
||||||
try {
|
try {
|
||||||
@ -7660,6 +7663,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
// Persist retry counters so shelve logic survives reconnect interruption
|
// Persist retry counters so shelve logic survives reconnect interruption
|
||||||
this.retryStateByItem.set(item.id, {
|
this.retryStateByItem.set(item.id, {
|
||||||
freshRetryUsed: Boolean(active.freshRetryUsed),
|
freshRetryUsed: Boolean(active.freshRetryUsed),
|
||||||
|
resumeHardResetUsed: Boolean(active.resumeHardResetUsed),
|
||||||
stallRetries: Number(active.stallRetries || 0),
|
stallRetries: Number(active.stallRetries || 0),
|
||||||
genericErrorRetries: Number(active.genericErrorRetries || 0),
|
genericErrorRetries: Number(active.genericErrorRetries || 0),
|
||||||
unrestrictRetries: Number(active.unrestrictRetries || 0)
|
unrestrictRetries: Number(active.unrestrictRetries || 0)
|
||||||
@ -7676,6 +7680,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.fullStatus = "Paket gestoppt";
|
item.fullStatus = "Paket gestoppt";
|
||||||
this.retryStateByItem.set(item.id, {
|
this.retryStateByItem.set(item.id, {
|
||||||
freshRetryUsed: Boolean(active.freshRetryUsed),
|
freshRetryUsed: Boolean(active.freshRetryUsed),
|
||||||
|
resumeHardResetUsed: Boolean(active.resumeHardResetUsed),
|
||||||
stallRetries: Number(active.stallRetries || 0),
|
stallRetries: Number(active.stallRetries || 0),
|
||||||
genericErrorRetries: Number(active.genericErrorRetries || 0),
|
genericErrorRetries: Number(active.genericErrorRetries || 0),
|
||||||
unrestrictRetries: Number(active.unrestrictRetries || 0)
|
unrestrictRetries: Number(active.unrestrictRetries || 0)
|
||||||
@ -9389,8 +9394,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const stat = await fs.promises.stat(part);
|
const stat = await fs.promises.stat(part);
|
||||||
// Find the item that owns this file to get its expected totalBytes
|
// Find the item that owns this file to get its expected totalBytes
|
||||||
const ownerItem = this.findItemByDiskPath(pkg, part);
|
const ownerItem = this.findItemByDiskPath(pkg, part);
|
||||||
const minBytes = ownerItem?.totalBytes && ownerItem.totalBytes > 0
|
const ownerTotalBytes = ownerItem?.totalBytes ?? 0;
|
||||||
? ownerItem.totalBytes - ALLOCATION_UNIT_SIZE
|
const minBytes = ownerTotalBytes > 0
|
||||||
|
? ownerTotalBytes - ALLOCATION_UNIT_SIZE
|
||||||
: 10240;
|
: 10240;
|
||||||
if (stat.size < minBytes) {
|
if (stat.size < minBytes) {
|
||||||
allMissingFullOnDisk = false;
|
allMissingFullOnDisk = false;
|
||||||
@ -9933,8 +9939,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
: 10240;
|
: 10240;
|
||||||
if (stat.size >= minSize) {
|
if (stat.size >= minSize) {
|
||||||
// Re-check: another task may have started this item during the await
|
// Re-check: another task may have started this item during the await
|
||||||
if (this.activeTasks.has(item.id) || item.status === "downloading"
|
const latestItem = this.session.items[item.id];
|
||||||
|| item.status === "validating" || item.status === "integrity_check") {
|
if (!latestItem || this.activeTasks.has(item.id) || latestItem.status === "downloading"
|
||||||
|
|| latestItem.status === "validating" || latestItem.status === "integrity_check") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Guard against pre-allocated sparse files from a hard crash: file has
|
// Guard against pre-allocated sparse files from a hard crash: file has
|
||||||
|
|||||||
@ -267,7 +267,7 @@ export function updateTraceConfig(patch: Partial<SupportTraceConfig>): SupportTr
|
|||||||
});
|
});
|
||||||
persistTraceConfig();
|
persistTraceConfig();
|
||||||
scheduleAutoDisable();
|
scheduleAutoDisable();
|
||||||
appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig)}\n`);
|
appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig as unknown as Record<string, unknown>)}\n`);
|
||||||
return getTraceConfig();
|
return getTraceConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -271,8 +271,26 @@ export function nowMs(): number {
|
|||||||
return Date.now();
|
return Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sleep(ms: number): Promise<void> {
|
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve, reject) => {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
reject(new Error(String(signal.reason || "aborted")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
}, ms);
|
||||||
|
const onAbort = (): void => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
cleanup();
|
||||||
|
reject(new Error(String(signal?.reason || "aborted")));
|
||||||
|
};
|
||||||
|
const cleanup = (): void => {
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
};
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatEta(seconds: number): string {
|
export function formatEta(seconds: number): string {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { CSSProperties, DragEvent, KeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { CSSProperties, DragEvent, KeyboardEvent as ReactKeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||||
import type {
|
import type {
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
@ -761,6 +761,7 @@ function validateAccountDialog(dialog: AccountDialogState): string | null {
|
|||||||
const emptyStats = (): DownloadStats => ({
|
const emptyStats = (): DownloadStats => ({
|
||||||
totalDownloaded: 0,
|
totalDownloaded: 0,
|
||||||
totalDownloadedAllTime: 0,
|
totalDownloadedAllTime: 0,
|
||||||
|
totalFiles: 0,
|
||||||
totalFilesSession: 0,
|
totalFilesSession: 0,
|
||||||
totalFilesAllTime: 0,
|
totalFilesAllTime: 0,
|
||||||
totalPackages: 0,
|
totalPackages: 0,
|
||||||
@ -771,6 +772,24 @@ const emptyStats = (): DownloadStats => ({
|
|||||||
runtimeMeasuredAt: 0
|
runtimeMeasuredAt: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type StatsSectionItem = {
|
||||||
|
key: string;
|
||||||
|
eyebrow: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
compactValue?: boolean;
|
||||||
|
danger?: boolean;
|
||||||
|
clickable?: boolean;
|
||||||
|
title?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StatsSection = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
items: StatsSectionItem[];
|
||||||
|
};
|
||||||
|
|
||||||
const emptySnapshot = (): UiSnapshot => ({
|
const emptySnapshot = (): UiSnapshot => ({
|
||||||
settings: {
|
settings: {
|
||||||
token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "",
|
token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "",
|
||||||
@ -3204,6 +3223,7 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
void window.rd.togglePackage(packageId).catch((error) => {
|
void window.rd.togglePackage(packageId).catch((error) => {
|
||||||
if (previousEnabled !== null) {
|
if (previousEnabled !== null) {
|
||||||
|
const revertedEnabled = previousEnabled;
|
||||||
setSnapshot((prev) => {
|
setSnapshot((prev) => {
|
||||||
const pkg = prev.session.packages[packageId];
|
const pkg = prev.session.packages[packageId];
|
||||||
if (!pkg) {
|
if (!pkg) {
|
||||||
@ -3217,8 +3237,8 @@ export function App(): ReactElement {
|
|||||||
...prev.session.packages,
|
...prev.session.packages,
|
||||||
[packageId]: {
|
[packageId]: {
|
||||||
...pkg,
|
...pkg,
|
||||||
enabled: previousEnabled,
|
enabled: revertedEnabled,
|
||||||
status: previousEnabled && pkg.status === "paused" ? "queued" : pkg.status,
|
status: revertedEnabled && pkg.status === "paused" ? "queued" : pkg.status,
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -3554,7 +3574,7 @@ export function App(): ReactElement {
|
|||||||
}, [selectedIds, settingsDraft.confirmDeleteSelection, executeDeleteSelection]);
|
}, [selectedIds, settingsDraft.confirmDeleteSelection, executeDeleteSelection]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
const onKey = (e: globalThis.KeyboardEvent): void => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") {
|
if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") {
|
||||||
@ -3801,7 +3821,7 @@ export function App(): ReactElement {
|
|||||||
.map((it) => it.id);
|
.map((it) => it.id);
|
||||||
void window.rd.resetItems(failedIds).catch(() => {});
|
void window.rd.resetItems(failedIds).catch(() => {});
|
||||||
};
|
};
|
||||||
const statsSections = [
|
const statsSections: StatsSection[] = [
|
||||||
{
|
{
|
||||||
key: "live",
|
key: "live",
|
||||||
title: "Aktuell",
|
title: "Aktuell",
|
||||||
@ -3835,7 +3855,7 @@ export function App(): ReactElement {
|
|||||||
{ key: "files-total", eyebrow: "Gesamt", label: "Fertige Dateien", value: String(snapshot.stats.totalFilesAllTime) }
|
{ key: "files-total", eyebrow: "Gesamt", label: "Fertige Dateien", value: String(snapshot.stats.totalFilesAllTime) }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
] as const;
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -5955,7 +5975,7 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripe
|
|||||||
const exProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50));
|
const exProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50));
|
||||||
const combinedProgress = Math.min(100, useExtractSplit ? dlProgress + exProgress : dlProgress);
|
const combinedProgress = Math.min(100, useExtractSplit ? dlProgress + exProgress : dlProgress);
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>): void => {
|
||||||
if (e.key === "Enter") { onFinishEdit(pkg.id, pkg.name, editingName); }
|
if (e.key === "Enter") { onFinishEdit(pkg.id, pkg.name, editingName); }
|
||||||
if (e.key === "Escape") { onFinishEdit(pkg.id, pkg.name, pkg.name); }
|
if (e.key === "Escape") { onFinishEdit(pkg.id, pkg.name, pkg.name); }
|
||||||
};
|
};
|
||||||
@ -6093,7 +6113,7 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripe
|
|||||||
);
|
);
|
||||||
case "progress": return (
|
case "progress": return (
|
||||||
<span key={col} className="pkg-col pkg-col-progress">
|
<span key={col} className="pkg-col pkg-col-progress">
|
||||||
{item.totalBytes > 0 ? (
|
{(item.totalBytes || 0) > 0 ? (
|
||||||
<span className="progress-inline progress-inline-small">
|
<span className="progress-inline progress-inline-small">
|
||||||
<span className="progress-inline-bar" style={{ width: `${item.progressPercent}%` }} />
|
<span className="progress-inline-bar" style={{ width: `${item.progressPercent}%` }} />
|
||||||
<span className="progress-inline-text">{item.progressPercent}%</span>
|
<span className="progress-inline-text">{item.progressPercent}%</span>
|
||||||
|
|||||||
@ -42,7 +42,7 @@ export interface BandwidthScheduleEntry {
|
|||||||
export interface DownloadStats {
|
export interface DownloadStats {
|
||||||
totalDownloaded: number;
|
totalDownloaded: number;
|
||||||
totalDownloadedAllTime: number;
|
totalDownloadedAllTime: number;
|
||||||
totalFiles: number;
|
totalFiles?: number;
|
||||||
totalFilesSession: number;
|
totalFilesSession: number;
|
||||||
totalFilesAllTime: number;
|
totalFilesAllTime: number;
|
||||||
totalPackages: number;
|
totalPackages: number;
|
||||||
@ -74,7 +74,7 @@ export interface AppSettings {
|
|||||||
linkSnappyPassword: string;
|
linkSnappyPassword: string;
|
||||||
archivePasswordList: string;
|
archivePasswordList: string;
|
||||||
rememberToken: boolean;
|
rememberToken: boolean;
|
||||||
providerOrder: DebridProvider[];
|
providerOrder: readonly DebridProvider[];
|
||||||
providerPrimary: DebridProvider;
|
providerPrimary: DebridProvider;
|
||||||
providerSecondary: DebridFallbackProvider;
|
providerSecondary: DebridFallbackProvider;
|
||||||
providerTertiary: DebridFallbackProvider;
|
providerTertiary: DebridFallbackProvider;
|
||||||
@ -168,7 +168,7 @@ export interface PackageEntry {
|
|||||||
itemIds: string[];
|
itemIds: string[];
|
||||||
cancelled: boolean;
|
cancelled: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
priority: PackagePriority;
|
priority?: PackagePriority;
|
||||||
postProcessLabel?: string;
|
postProcessLabel?: string;
|
||||||
downloadStartedAt?: number;
|
downloadStartedAt?: number;
|
||||||
downloadCompletedAt?: number;
|
downloadCompletedAt?: number;
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item
|
|||||||
import { createStoragePaths, emptySession } from "../src/main/storage";
|
import { createStoragePaths, emptySession } from "../src/main/storage";
|
||||||
import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests } from "../src/main/debrid";
|
import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests } from "../src/main/debrid";
|
||||||
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
|
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
|
||||||
|
import { UnrestrictedLink } from "../src/main/realdebrid";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
@ -5694,7 +5695,7 @@ describe("download manager", () => {
|
|||||||
{
|
{
|
||||||
megaWebUnrestrict: vi.fn(async (_link: string, signal?: AbortSignal) => {
|
megaWebUnrestrict: vi.fn(async (_link: string, signal?: AbortSignal) => {
|
||||||
unrestrictCalls += 1;
|
unrestrictCalls += 1;
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise<UnrestrictedLink | null>((resolve, reject) => {
|
||||||
const rejector = (error: Error): void => {
|
const rejector = (error: Error): void => {
|
||||||
signal?.removeEventListener("abort", onAbort);
|
signal?.removeEventListener("abort", onAbort);
|
||||||
pendingRejectors.delete(rejector);
|
pendingRejectors.delete(rejector);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user