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:
@@ -1,5 +1,5 @@
|
||||
import type { AppSettings, DebridAccountStatus } from "../shared/types";
|
||||
import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
|
||||
import type { AppSettings, DebridAccountStatus } from "../shared/types";
|
||||
import { getMegaDebridAccountsForMode, getMegaDebridAccountStatusId, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
|
||||
import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/debrid-link-keys";
|
||||
import { logger } from "./logger";
|
||||
import { compactErrorText } from "./utils";
|
||||
@@ -48,8 +48,8 @@ export async function checkMegaDebridAccount(
|
||||
signal?: AbortSignal,
|
||||
now = Date.now()
|
||||
): Promise<DebridAccountStatus> {
|
||||
const base: DebridAccountStatus = {
|
||||
accountId: account.id,
|
||||
const base: DebridAccountStatus = {
|
||||
accountId: account.mode ? getMegaDebridAccountStatusId(account.id, account.mode) : account.id,
|
||||
provider: "megadebrid",
|
||||
label: account.label,
|
||||
maskedLogin: account.maskedLogin,
|
||||
@@ -163,7 +163,8 @@ export async function checkAllDebridAccounts(
|
||||
signal?: AbortSignal
|
||||
): Promise<DebridAccountStatus[]> {
|
||||
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 taskFns: Array<() => Promise<DebridAccountStatus>> = [
|
||||
|
||||
@@ -309,8 +309,17 @@ export class AllDebridWebFallback {
|
||||
providerHosts: ALLDEBRID_LOGIN_HOSTS,
|
||||
externalHosts: ALLDEBRID_LOGIN_HOSTS
|
||||
});
|
||||
window.setMenuBarVisibility(false);
|
||||
window.on("closed", () => {
|
||||
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", () => {
|
||||
if (this.loginWindow === window) {
|
||||
this.loginWindow = null;
|
||||
this.loginWindowPartition = "";
|
||||
|
||||
+26
-15
@@ -474,6 +474,16 @@ export class AppController {
|
||||
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
|
||||
&& !this.reconfigureLogStorage(nextSettings.logStorageLocation)) {
|
||||
nextSettings = normalizeSettings({
|
||||
@@ -482,14 +492,9 @@ export class AppController {
|
||||
});
|
||||
}
|
||||
this.overlayLiveUsageCounters(nextSettings);
|
||||
const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode;
|
||||
const historyLimitsChanged = previousSettings.historyMaxEntries !== nextSettings.historyMaxEntries
|
||||
|| previousSettings.historyMaxAgeDays !== nextSettings.historyMaxAgeDays;
|
||||
this.settings = nextSettings;
|
||||
if (retentionChanged) {
|
||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
||||
} else if (historyLimitsChanged && this.settings.historyRetentionMode !== "never") {
|
||||
saveHistory(this.storagePaths, loadHistory(this.storagePaths), this.historyLimits());
|
||||
this.settings = nextSettings;
|
||||
if (historyLimitsChanged && this.settings.historyRetentionMode !== "never") {
|
||||
saveHistory(this.storagePaths, loadHistory(this.storagePaths), this.historyLimits());
|
||||
}
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.manager.setSettings(this.settings);
|
||||
@@ -1080,9 +1085,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
shutdownAccountRotationLog();
|
||||
shutdownConversionLog();
|
||||
shutdownAuditLog();
|
||||
if (this.settings.historyRetentionMode === "session") {
|
||||
clearHistory(this.storagePaths);
|
||||
}
|
||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode === "session" ? "session" : "permanent");
|
||||
logger.info("App beendet");
|
||||
}
|
||||
|
||||
@@ -1150,10 +1153,18 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits());
|
||||
}
|
||||
|
||||
public clearHistory(): void {
|
||||
this.audit("WARN", "Verlauf geleert");
|
||||
clearHistory(this.storagePaths);
|
||||
}
|
||||
public clearHistory(): void {
|
||||
try {
|
||||
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 {
|
||||
this.audit("INFO", "Paket-Priorität geändert", { packageId, priority });
|
||||
|
||||
+59
-45
@@ -311,8 +311,10 @@ function getMegaDebridAbortMinRunMs(): number {
|
||||
const megaDebridEmptyResponseStreaks = new Map<string, number>();
|
||||
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 10;
|
||||
|
||||
let megaDebridRotationCursor = 0;
|
||||
let megaDebridStickyCount = 0;
|
||||
const megaDebridRotationState: Record<"api" | "web", { cursor: number; stickyCount: number }> = {
|
||||
api: { cursor: 0, stickyCount: 0 },
|
||||
web: { cursor: 0, stickyCount: 0 }
|
||||
};
|
||||
// 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
|
||||
// die serielle Single-Flight-Queue → minutenlanger Vorlauf. Stattdessen bleibt die
|
||||
@@ -368,13 +370,13 @@ export function clearMegaDebridAccountRuntimeStates(accountKeys: Iterable<string
|
||||
}
|
||||
}
|
||||
|
||||
export function resetMegaDebridRuntimeStateForTests(): void {
|
||||
megaDebridAccountCooldowns.clear();
|
||||
megaDebridEmptyResponseStreaks.clear();
|
||||
megaDebridRotationCursor = 0;
|
||||
megaDebridStickyCount = 0;
|
||||
megaDebridInFlight.clear();
|
||||
}
|
||||
export function resetMegaDebridRuntimeStateForTests(): void {
|
||||
megaDebridAccountCooldowns.clear();
|
||||
megaDebridEmptyResponseStreaks.clear();
|
||||
megaDebridRotationState.api = { cursor: 0, stickyCount: 0 };
|
||||
megaDebridRotationState.web = { cursor: 0, stickyCount: 0 };
|
||||
megaDebridInFlight.clear();
|
||||
}
|
||||
|
||||
export function getMegaDebridInFlightCountForMode(mode: "api" | "web"): number {
|
||||
const suffix = `:${mode}`;
|
||||
@@ -569,11 +571,11 @@ export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSna
|
||||
});
|
||||
|
||||
return {
|
||||
capturedAtMs: now,
|
||||
megaDebrid: {
|
||||
rotationCursor: megaDebridRotationCursor,
|
||||
stickyCount: megaDebridStickyCount,
|
||||
accounts: megaAccounts
|
||||
capturedAtMs: now,
|
||||
megaDebrid: {
|
||||
rotationCursor: Math.max(megaDebridRotationState.api.cursor, megaDebridRotationState.web.cursor),
|
||||
stickyCount: Math.max(megaDebridRotationState.api.stickyCount, megaDebridRotationState.web.stickyCount),
|
||||
accounts: megaAccounts
|
||||
},
|
||||
debridLink: { keys: dlKeys, hostCooldowns: dlHostCooldowns }
|
||||
};
|
||||
@@ -2160,7 +2162,8 @@ class MegaDebridClient {
|
||||
// Checks bleiben. Der Cursor wird erst im Erfolgszweig weitergesetzt — nach einem
|
||||
// Schwung erfolgreicher Umwandlungen (MEGA_DEBRID_STICKY_LINKS) — sodass aufeinander
|
||||
// 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 }[] = [];
|
||||
for (let step = 0; step < accounts.length; step += 1) {
|
||||
const idx = (startOffset + step) % accounts.length;
|
||||
@@ -2238,13 +2241,13 @@ class MegaDebridClient {
|
||||
clearMegaDebridEmptyResponseStreak(cooldownKey);
|
||||
const elapsedMs = Date.now() - testStartedAt;
|
||||
traceConversionPhase({ phase: "mega-account", provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", account: rotationLabel, workMs: elapsedMs, outcome: "ok" });
|
||||
megaDebridStickyCount += 1;
|
||||
if (megaDebridStickyCount >= MEGA_DEBRID_STICKY_LINKS) {
|
||||
megaDebridRotationCursor = idx + 1;
|
||||
megaDebridStickyCount = 0;
|
||||
} else {
|
||||
megaDebridRotationCursor = idx;
|
||||
}
|
||||
rotationState.stickyCount += 1;
|
||||
if (rotationState.stickyCount >= MEGA_DEBRID_STICKY_LINKS) {
|
||||
rotationState.cursor = idx + 1;
|
||||
rotationState.stickyCount = 0;
|
||||
} else {
|
||||
rotationState.cursor = idx;
|
||||
}
|
||||
logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`);
|
||||
logAccountRotation("INFO", providerName, rotationLabel, "OK", {
|
||||
elapsedMs,
|
||||
@@ -3977,7 +3980,13 @@ export class DebridService {
|
||||
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 routing = settings.hosterRouting || {};
|
||||
@@ -3985,9 +3994,10 @@ export class DebridService {
|
||||
if (hosterKey && routing[hosterKey]) {
|
||||
const routedProvider = routing[hosterKey];
|
||||
if (this.isProviderSelectableFor(settings, routedProvider)) {
|
||||
logger.info(`Hoster-Zuordnung: ${hosterKey} → ${PROVIDER_LABELS[routedProvider]}`);
|
||||
try {
|
||||
const result = await this.unrestrictViaProvider(settings, routedProvider, link, signal);
|
||||
logger.info(`Hoster-Zuordnung: ${hosterKey} → ${PROVIDER_LABELS[routedProvider]}`);
|
||||
try {
|
||||
onProviderAttempt?.(routedProvider);
|
||||
const result = await this.unrestrictViaProvider(settings, routedProvider, link, signal);
|
||||
let fileName = result.fileName;
|
||||
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
|
||||
const fromPage = await resolveRapidgatorFilename(link, signal);
|
||||
@@ -4016,9 +4026,10 @@ export class DebridService {
|
||||
}
|
||||
}
|
||||
|
||||
if (ONEFICHIER_URL_RE.test(link) && this.isProviderSelectableFor(settings, "onefichier")) {
|
||||
try {
|
||||
const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal);
|
||||
if (ONEFICHIER_URL_RE.test(link) && this.isProviderSelectableFor(settings, "onefichier")) {
|
||||
try {
|
||||
onProviderAttempt?.("onefichier");
|
||||
const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal);
|
||||
return {
|
||||
...result,
|
||||
provider: "onefichier",
|
||||
@@ -4035,9 +4046,10 @@ export class DebridService {
|
||||
}
|
||||
}
|
||||
|
||||
if (DDOWNLOAD_URL_RE.test(link) && this.isProviderSelectableFor(settings, "ddownload")) {
|
||||
try {
|
||||
const result = await this.unrestrictViaProvider(settings, "ddownload", link, signal);
|
||||
if (DDOWNLOAD_URL_RE.test(link) && this.isProviderSelectableFor(settings, "ddownload")) {
|
||||
try {
|
||||
onProviderAttempt?.("ddownload");
|
||||
const result = await this.unrestrictViaProvider(settings, "ddownload", link, signal);
|
||||
return {
|
||||
...result,
|
||||
provider: "ddownload",
|
||||
@@ -4069,9 +4081,10 @@ export class DebridService {
|
||||
: primary;
|
||||
if (!selectedProvider) {
|
||||
throw new Error(this.formatProviderLimitMessage(settings, primary));
|
||||
}
|
||||
try {
|
||||
const result = await this.unrestrictViaProvider(settings, selectedProvider, link, signal);
|
||||
}
|
||||
try {
|
||||
onProviderAttempt?.(selectedProvider);
|
||||
const result = await this.unrestrictViaProvider(settings, selectedProvider, link, signal);
|
||||
let fileName = result.fileName;
|
||||
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
|
||||
const fromPage = await resolveRapidgatorFilename(link, signal);
|
||||
@@ -4110,10 +4123,11 @@ export class DebridService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const providerStartedAt = Date.now();
|
||||
try {
|
||||
logger.info(`Provider-Kette: versuche ${PROVIDER_LABELS[provider]}`);
|
||||
traceConversionPhase({ phase: "chain-try", provider });
|
||||
const providerStartedAt = Date.now();
|
||||
try {
|
||||
logger.info(`Provider-Kette: versuche ${PROVIDER_LABELS[provider]}`);
|
||||
onProviderAttempt?.(provider);
|
||||
traceConversionPhase({ phase: "chain-try", provider });
|
||||
const result = await this.unrestrictViaProvider(settings, provider, link, signal);
|
||||
traceConversionPhase({ phase: "chain-ok", provider, workMs: Date.now() - providerStartedAt, outcome: "ok" });
|
||||
let fileName = result.fileName;
|
||||
@@ -4195,8 +4209,8 @@ export class DebridService {
|
||||
private async unrestrictViaProvider(settings: AppSettings, provider: DebridProvider, link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
||||
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
|
||||
if (effectiveProvider === "realdebrid") {
|
||||
if (this.shouldUseRealDebridWeb(settings) && this.options.realDebridWebUnrestrict) {
|
||||
const result = await this.options.realDebridWebUnrestrict(link, signal);
|
||||
if (this.shouldUseRealDebridWeb(settings) && this.options.realDebridWebUnrestrict) {
|
||||
const result = await waitForPromiseWithSignal(this.options.realDebridWebUnrestrict(link, signal), signal);
|
||||
if (!result) {
|
||||
throw new Error("Real-Debrid-Web-Fallback nicht verfügbar");
|
||||
}
|
||||
@@ -4214,8 +4228,8 @@ export class DebridService {
|
||||
return MegaDebridClient.unrestrictWithAccounts(settings, "web", false, link, this.options.megaWebUnrestrict, signal);
|
||||
}
|
||||
if (effectiveProvider === "alldebrid") {
|
||||
if (this.shouldUseAllDebridWeb(settings) && this.options.allDebridWebUnrestrict) {
|
||||
const result = await this.options.allDebridWebUnrestrict(link, signal);
|
||||
if (this.shouldUseAllDebridWeb(settings) && this.options.allDebridWebUnrestrict) {
|
||||
const result = await waitForPromiseWithSignal(this.options.allDebridWebUnrestrict(link, signal), signal);
|
||||
if (!result) {
|
||||
throw new Error("AllDebrid-Web-Fallback nicht verfügbar");
|
||||
}
|
||||
@@ -4240,8 +4254,8 @@ export class DebridService {
|
||||
if (effectiveProvider === "linksnappy") {
|
||||
return this.getLinkSnappyClient(settings.linkSnappyLogin, settings.linkSnappyPassword).unrestrictLink(link, signal);
|
||||
}
|
||||
if (this.shouldUseBestDebridWeb(settings) && this.options.bestDebridWebUnrestrict) {
|
||||
const bdResult = await this.options.bestDebridWebUnrestrict(link, signal);
|
||||
if (this.shouldUseBestDebridWeb(settings) && this.options.bestDebridWebUnrestrict) {
|
||||
const bdResult = await waitForPromiseWithSignal(this.options.bestDebridWebUnrestrict(link, signal), signal);
|
||||
if (!bdResult) {
|
||||
throw new Error("BestDebrid-Web-Fallback nicht verfügbar");
|
||||
}
|
||||
|
||||
+29
-3
@@ -16,6 +16,7 @@ export type DiskReservationRequest = {
|
||||
targetPath: string;
|
||||
requiredBytes: number | null;
|
||||
alreadyPresentBytes?: number;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type DiskWaitEvent = {
|
||||
@@ -46,7 +47,7 @@ type DiskReservationCoordinatorOptions = {
|
||||
statVolume?: (targetPath: string) => Promise<DiskVolumeStats>;
|
||||
};
|
||||
|
||||
type DiskReservationUpdate = Pick<DiskReservationRequest, "requiredBytes" | "alreadyPresentBytes">;
|
||||
type DiskReservationUpdate = Pick<DiskReservationRequest, "requiredBytes" | "alreadyPresentBytes" | "signal">;
|
||||
|
||||
export type DiskReservationLease = {
|
||||
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 {
|
||||
private readonly safetyBytes: number;
|
||||
private readonly retryDelayMs: number;
|
||||
@@ -105,7 +131,7 @@ export class DiskReservationCoordinator {
|
||||
return this.enqueue(async () => {
|
||||
const requiredBytes = calculateRemainingReservationBytes(request.requiredBytes, request.alreadyPresentBytes ?? 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 availableBytes = Math.max(0, Math.floor(volume.freeBytes) - reserved - this.safetyBytes);
|
||||
if (requiredBytes > availableBytes) {
|
||||
@@ -149,7 +175,7 @@ export class DiskReservationCoordinator {
|
||||
if (nextBytes === null) return;
|
||||
const delta = nextBytes - lease.reservedBytes;
|
||||
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);
|
||||
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 });
|
||||
}
|
||||
|
||||
+205
-79
@@ -85,7 +85,8 @@ type ActiveTask = {
|
||||
resumeHardResetUsed?: boolean;
|
||||
stallRetries?: number;
|
||||
genericErrorRetries?: number;
|
||||
unrestrictRetries?: number;
|
||||
unrestrictRetries?: number;
|
||||
validationProvider?: DebridProvider | null;
|
||||
blockedOnDiskWrite?: boolean;
|
||||
blockedOnDiskSince?: number;
|
||||
};
|
||||
@@ -2328,6 +2329,13 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean; settingsOnlyImport?: boolean }): void {
|
||||
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)
|
||||
.flatMap((mode) => getAvailableMegaDebridAccounts(previous, mode).map((account) => ({ mode, account })));
|
||||
const previousMegaPoolEntries = new Map<string, string>(previousMegaAccounts.map(({ mode, account }) => [`${account.id}:${mode}`, account.password]));
|
||||
@@ -2379,11 +2387,12 @@ export class DownloadManager extends EventEmitter {
|
||||
this.debridService.setSettings(next);
|
||||
this.allDebridHostInfoCache.clear();
|
||||
|
||||
const prevOrder = JSON.stringify(previous.providerOrder ?? []);
|
||||
const nextOrder = JSON.stringify(next.providerOrder ?? []);
|
||||
const prevRouting = JSON.stringify(previous.hosterRouting ?? {});
|
||||
const nextRouting = JSON.stringify(next.hosterRouting ?? {});
|
||||
if (!opts?.settingsOnlyImport && (prevOrder !== nextOrder || prevRouting !== nextRouting)) {
|
||||
const prevOrder = JSON.stringify(previous.providerOrder ?? []);
|
||||
const nextOrder = JSON.stringify(next.providerOrder ?? []);
|
||||
const prevRouting = JSON.stringify(previous.hosterRouting ?? {});
|
||||
const nextRouting = JSON.stringify(next.hosterRouting ?? {});
|
||||
const downloadRoutingChanged = prevOrder !== nextOrder || prevRouting !== nextRouting;
|
||||
if (!opts?.settingsOnlyImport && downloadRoutingChanged) {
|
||||
const activeItemIds = new Set([...this.activeTasks.values()].map((t) => t.itemId));
|
||||
for (const item of Object.values(this.session.items)) {
|
||||
if (!activeItemIds.has(item.id) && item.status !== "completed" && item.status !== "failed") {
|
||||
@@ -2402,24 +2411,56 @@ export class DownloadManager extends EventEmitter {
|
||||
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[] }> = [
|
||||
{ prev: previous.token || "", next: next.token || "", providers: ["realdebrid"] },
|
||||
{ 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"] },
|
||||
const disabledProviderFingerprint = (settings: AppSettings, provider: DebridProvider): string => String((settings.disabledProviders || []).includes(provider));
|
||||
const credChanges: Array<{ prev: string; next: string; providers: DebridProvider[] }> = [
|
||||
{
|
||||
prev: `${previous.megaDebridApiCredentials}|${previous.megaDebridWebCredentials}|${previous.megaDebridApiEnabled}|${previous.megaDebridWebEnabled}`,
|
||||
next: `${next.megaDebridApiCredentials}|${next.megaDebridWebCredentials}|${next.megaDebridApiEnabled}|${next.megaDebridWebEnabled}`,
|
||||
prev: `${previous.token || ""}|${previous.realDebridUseWebLogin}|${disabledProviderFingerprint(previous, "realdebrid")}`,
|
||||
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"]
|
||||
}
|
||||
];
|
||||
let clearedProviderFailures = 0;
|
||||
for (const change of credChanges) {
|
||||
if (change.prev === change.next) continue;
|
||||
for (const provider of change.providers) {
|
||||
for (const key of [...this.providerFailures.keys()]) {
|
||||
];
|
||||
const changedProviders = new Set<DebridProvider>();
|
||||
let clearedProviderFailures = 0;
|
||||
for (const change of credChanges) {
|
||||
if (change.prev === change.next) continue;
|
||||
for (const provider of change.providers) {
|
||||
changedProviders.add(provider);
|
||||
for (const key of [...this.providerFailures.keys()]) {
|
||||
if (key === provider || key.startsWith(`${provider}:`)) {
|
||||
this.providerFailures.delete(key);
|
||||
clearedProviderFailures += 1;
|
||||
@@ -2459,7 +2500,7 @@ export class DownloadManager extends EventEmitter {
|
||||
if (!item || item.status !== "validating") {
|
||||
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") {
|
||||
continue;
|
||||
}
|
||||
@@ -2474,7 +2515,7 @@ export class DownloadManager extends EventEmitter {
|
||||
if (!item || item.status !== "validating") {
|
||||
continue;
|
||||
}
|
||||
const provider = String(item.provider || this.getExpectedProviderForItem(item) || "");
|
||||
const provider = String(active.validationProvider || item.provider || this.getExpectedProviderForItem(item) || "");
|
||||
if (provider !== "debridlink") {
|
||||
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 (!this.hasUsableDownloadAccount()) {
|
||||
if (!this.session.paused) {
|
||||
@@ -6573,14 +6629,18 @@ export class DownloadManager extends EventEmitter {
|
||||
if (pkg.itemIds.length === 0) {
|
||||
logger.info(`applyOnStartCleanupPolicy: entferne Paket ${pkg.name} (${completedItemIds.length} completed Items)`);
|
||||
this.removePackageFromSession(pkgId, completedItemIds);
|
||||
} else {
|
||||
if (completedItemIds.length > 0) {
|
||||
logger.info(`applyOnStartCleanupPolicy: entferne ${completedItemIds.length} completed Items aus Paket ${pkg.name} (${pkg.itemIds.length} Items verbleiben)`);
|
||||
}
|
||||
for (const itemId of completedItemIds) {
|
||||
delete this.session.items[itemId];
|
||||
this.itemCount = Math.max(0, this.itemCount - 1);
|
||||
}
|
||||
} else {
|
||||
if (completedItemIds.length > 0) {
|
||||
logger.info(`applyOnStartCleanupPolicy: entferne ${completedItemIds.length} completed Items aus Paket ${pkg.name} (${pkg.itemIds.length} Items verbleiben)`);
|
||||
}
|
||||
for (const itemId of completedItemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
if (item) {
|
||||
this.captureCompletedItemCleanup(pkg, item);
|
||||
}
|
||||
delete this.session.items[itemId];
|
||||
this.itemCount = Math.max(0, this.itemCount - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info(`applyOnStartCleanupPolicy: ${Object.keys(this.session.packages).length} Pakete, ${Object.keys(this.session.items).length} Items nach Bereinigung`);
|
||||
@@ -6610,13 +6670,17 @@ export class DownloadManager extends EventEmitter {
|
||||
this.retryStateByItem.delete(itemId);
|
||||
removed += 1;
|
||||
}
|
||||
if (pkg.itemIds.length === 0) {
|
||||
this.removePackageFromSession(pkgId, completedItemIds);
|
||||
} else {
|
||||
for (const itemId of completedItemIds) {
|
||||
delete this.session.items[itemId];
|
||||
this.itemCount = Math.max(0, this.itemCount - 1);
|
||||
}
|
||||
if (pkg.itemIds.length === 0) {
|
||||
this.removePackageFromSession(pkgId, completedItemIds);
|
||||
} else {
|
||||
for (const itemId of completedItemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
if (item) {
|
||||
this.captureCompletedItemCleanup(pkg, item);
|
||||
}
|
||||
delete this.session.items[itemId];
|
||||
this.itemCount = Math.max(0, this.itemCount - 1);
|
||||
}
|
||||
}
|
||||
} else if (policy === "package_done" || policy === "on_start") {
|
||||
const allCompleted = pkg.itemIds.every((id) => {
|
||||
@@ -9321,7 +9385,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.emitState();
|
||||
return;
|
||||
}
|
||||
delete item.http416FreshRestarts;
|
||||
item.http416FreshRestarts = Math.max(freshRestarts, MAX_HTTP416_FRESH_RESTARTS);
|
||||
item.status = "failed";
|
||||
this.recordRunOutcome(item.id, "failed");
|
||||
item.lastError = errorText;
|
||||
@@ -9543,7 +9607,15 @@ export class DownloadManager extends EventEmitter {
|
||||
traceConversionNote("slots", this.describeSlotOccupancy());
|
||||
traceConversionNote("retry", Number(active.unrestrictRetries || 0));
|
||||
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) {
|
||||
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
||||
traceConversionPhase({
|
||||
@@ -9615,7 +9687,8 @@ export class DownloadManager extends EventEmitter {
|
||||
ownerId: item.id,
|
||||
targetPath: item.targetPath,
|
||||
requiredBytes: item.totalBytes,
|
||||
alreadyPresentBytes: item.downloadedBytes
|
||||
alreadyPresentBytes: item.downloadedBytes,
|
||||
signal: active.abortController.signal
|
||||
});
|
||||
this.diskLeasesByOwner.get(item.id)?.release();
|
||||
this.diskLeasesByOwner.set(item.id, diskLease);
|
||||
@@ -10002,8 +10075,17 @@ export class DownloadManager extends EventEmitter {
|
||||
item.fullStatus = `Fehler: ${item.lastError}`;
|
||||
this.recordRunOutcome(item.id, "failed");
|
||||
this.retryStateByItem.delete(item.id);
|
||||
} else {
|
||||
} else {
|
||||
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)) {
|
||||
return;
|
||||
}
|
||||
@@ -10499,11 +10581,11 @@ export class DownloadManager extends EventEmitter {
|
||||
headers,
|
||||
signal: AbortSignal.any([active.abortController.signal, connectAbortController.signal])
|
||||
});
|
||||
} catch (error) {
|
||||
if (active.abortController.signal.aborted || String(error).includes("aborted:")) {
|
||||
throw error;
|
||||
}
|
||||
lastError = compactErrorText(error);
|
||||
} catch (error) {
|
||||
if (active.abortController.signal.aborted || String(error).includes("aborted:")) {
|
||||
throw error;
|
||||
}
|
||||
lastError = compactErrorText(error);
|
||||
logAttemptEvent("WARN", "HTTP-Verbindung fehlgeschlagen", {
|
||||
attempt,
|
||||
error: lastError
|
||||
@@ -10761,10 +10843,40 @@ export class DownloadManager extends EventEmitter {
|
||||
item.totalBytes = knownTotal;
|
||||
} else if (totalFromRange) {
|
||||
item.totalBytes = totalFromRange;
|
||||
} else if (contentLength > 0) {
|
||||
item.totalBytes = response.status === 206 ? existingBytes + contentLength : contentLength;
|
||||
}
|
||||
const completionPlan = planDownloadCompletion({
|
||||
} else if (contentLength > 0) {
|
||||
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({
|
||||
existingBytes,
|
||||
responseStatus: response.status,
|
||||
contentLength,
|
||||
@@ -11407,14 +11519,17 @@ export class DownloadManager extends EventEmitter {
|
||||
targetPath: effectiveTargetPath
|
||||
});
|
||||
return { resumable };
|
||||
} catch (error) {
|
||||
if (preAllocated && item.totalBytes && written < item.totalBytes) {
|
||||
try { await fs.promises.truncate(effectiveTargetPath, written); } catch { }
|
||||
}
|
||||
if (active.abortController.signal.aborted || String(error).includes("aborted:")) {
|
||||
throw error;
|
||||
}
|
||||
lastError = compactErrorText(error);
|
||||
} catch (error) {
|
||||
if (preAllocated && item.totalBytes && written < item.totalBytes) {
|
||||
try { await fs.promises.truncate(effectiveTargetPath, written); } catch { }
|
||||
}
|
||||
if (active.abortController.signal.aborted || String(error).includes("aborted:")) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof DiskCapacityError) {
|
||||
throw error;
|
||||
}
|
||||
lastError = compactErrorText(error);
|
||||
const normalizedLastError = lastError.replace(/^Error:\s*/i, "");
|
||||
const diskCause = classifyDiskError(error);
|
||||
logAttemptEvent("WARN", "HTTP-Download-Versuch fehlgeschlagen", {
|
||||
@@ -11537,11 +11652,18 @@ export class DownloadManager extends EventEmitter {
|
||||
continue;
|
||||
}
|
||||
|
||||
const is416Failure = item.status === "failed" && this.isHttp416Failure(item);
|
||||
const hasZeroByteArchive = await this.hasZeroByteArchiveArtifact(item);
|
||||
|
||||
if (item.status === "failed") {
|
||||
if (!is416Failure && !hasZeroByteArchive && item.retries >= maxAutoRetryFailures) {
|
||||
const is416Failure = item.status === "failed" && this.isHttp416Failure(item);
|
||||
const hasZeroByteArchive = await this.hasZeroByteArchiveArtifact(item);
|
||||
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
this.queueItemForRetry(item, {
|
||||
@@ -13334,17 +13456,7 @@ export class DownloadManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
}
|
||||
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();
|
||||
this.captureCompletedItemCleanup(pkg, item);
|
||||
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
|
||||
this.releaseTargetPath(itemId);
|
||||
this.dropItemContribution(itemId);
|
||||
@@ -13381,10 +13493,24 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
this.removePackageFromSession(packageId, [...pkg.itemIds], "completed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private finishRun(): void {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const runStartedAt = this.session.runStartedAt;
|
||||
this.session.running = false;
|
||||
this.session.paused = false;
|
||||
|
||||
@@ -229,12 +229,21 @@ export class RealDebridWebFallback {
|
||||
const primeFromWindow = (): void => {
|
||||
void this.primeTokenFromWindow(window);
|
||||
};
|
||||
window.webContents.on("did-finish-load", primeFromWindow);
|
||||
window.webContents.on("did-navigate", primeFromWindow);
|
||||
window.webContents.on("did-navigate-in-page", primeFromWindow);
|
||||
window.on("close", () => {
|
||||
void this.primeTokenFromWindow(window);
|
||||
});
|
||||
window.webContents.on("did-finish-load", primeFromWindow);
|
||||
window.webContents.on("did-navigate", 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", () => {
|
||||
void this.primeTokenFromWindow(window);
|
||||
});
|
||||
window.on("closed", () => {
|
||||
if (this.loginWindow === window) {
|
||||
this.loginWindow = null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus } from "./account-status-sanitizer";
|
||||
|
||||
@@ -78,7 +78,7 @@ export function createRendererAccounts(settings: AppSettings): RendererAccount[]
|
||||
dailyLimitBytes: settings.megaDebridAccountDailyLimitBytes[account.id] || 0,
|
||||
dailyUsageBytes: settings.megaDebridAccountDailyUsageBytes[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 { getMegaDebridAccountIds, mergeMegaDebridCredentialPools } from "../shared/mega-debrid-accounts";
|
||||
import { getMegaDebridAccountIds, getMegaDebridAccountStatusId, mergeMegaDebridCredentialPools } from "../shared/mega-debrid-accounts";
|
||||
import type { AppSettings } from "../shared/types";
|
||||
|
||||
export function overlayLiveUsageCounters(target: AppSettings, liveSettings: AppSettings, liveTotalRuntimeMs: number): void {
|
||||
const debridLinkKeyIds = new Set(getDebridLinkApiKeyIds(target.debridLinkApiKeys));
|
||||
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.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
|
||||
target.totalRuntimeAllTimeMs = Math.max(target.totalRuntimeAllTimeMs || 0, liveTotalRuntimeMs);
|
||||
|
||||
+31
-18
@@ -3,7 +3,7 @@ import fsp from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
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 { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
||||
import { defaultSettings } from "./constants";
|
||||
@@ -270,8 +270,15 @@ function normalizeDebridAccountStatuses(
|
||||
value: unknown,
|
||||
megaIds: string[],
|
||||
debridLinkIds: string[]
|
||||
): Record<string, DebridAccountStatus> {
|
||||
const allowed = new Set([...megaIds, ...debridLinkIds]);
|
||||
): Record<string, DebridAccountStatus> {
|
||||
const allowed = new Set([
|
||||
...megaIds,
|
||||
...megaIds.flatMap((accountId) => [
|
||||
getMegaDebridAccountStatusId(accountId, "api"),
|
||||
getMegaDebridAccountStatusId(accountId, "web")
|
||||
]),
|
||||
...debridLinkIds
|
||||
]);
|
||||
const result: Record<string, DebridAccountStatus> = {};
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
||||
@@ -1465,12 +1472,21 @@ export function addHistoryEntryForRetention(paths: StoragePaths, retentionMode:
|
||||
return addHistoryEntry(paths, entry, limits);
|
||||
}
|
||||
|
||||
export function resetHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): void {
|
||||
if (retentionMode === "permanent") {
|
||||
return;
|
||||
}
|
||||
clearHistory(paths);
|
||||
}
|
||||
export function resetHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): boolean {
|
||||
if (retentionMode === "permanent") {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeHistoryEntry(paths: StoragePaths, entryId: string): HistoryEntry[] {
|
||||
const existing = loadHistory(paths);
|
||||
@@ -1479,12 +1495,9 @@ export function removeHistoryEntry(paths: StoragePaths, entryId: string): Histor
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function clearHistory(paths: StoragePaths): void {
|
||||
ensureBaseDir(paths.baseDir);
|
||||
if (fs.existsSync(paths.historyFile)) {
|
||||
try {
|
||||
fs.unlinkSync(paths.historyFile);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
export function clearHistory(paths: StoragePaths): void {
|
||||
ensureBaseDir(paths.baseDir);
|
||||
if (fs.existsSync(paths.historyFile)) {
|
||||
fs.unlinkSync(paths.historyFile);
|
||||
}
|
||||
}
|
||||
|
||||
+252
-29
@@ -42,6 +42,7 @@ const MAX_PACKAGE_DTOS = 200;
|
||||
const MAX_ITEM_DTOS = 500;
|
||||
const MAX_HISTORY_FILE_BYTES = 1024 * 1024;
|
||||
const MAX_HISTORY_ENTRIES = 100;
|
||||
const MAX_RUNTIME_PRIVATE_NAMES = 64;
|
||||
|
||||
interface TextBudget {
|
||||
remainingBytes: number;
|
||||
@@ -100,6 +101,164 @@ function collectSensitiveValues(value: unknown, key = "", output = new Set<strin
|
||||
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 {
|
||||
const raw = String(value || "").replace(/\0/g, "");
|
||||
const findMarker = (source: string, offset: number): string => {
|
||||
@@ -205,7 +364,12 @@ function getSourcePathKey(sourcePath: string): string {
|
||||
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 bytesToRead = Math.min(stats.size, Math.max(0, maxBytes));
|
||||
if (bytesToRead <= 0) {
|
||||
@@ -214,14 +378,44 @@ async function readTextTail(filePath: string, maxBytes: number): Promise<string>
|
||||
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 text = buffer.subarray(0, bytesRead).toString("utf8");
|
||||
return stats.size > bytesRead ? `[gekürzt: letzte ${bytesRead} Bytes]\n${text}` : text;
|
||||
const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0);
|
||||
return buffer.subarray(0, bytesRead).toString("utf8");
|
||||
} finally {
|
||||
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(
|
||||
zip: AdmZip,
|
||||
sourcePath: string | null,
|
||||
@@ -231,7 +425,8 @@ async function addTextFileIfExists(
|
||||
budget: TextBudget,
|
||||
maxFileBytes: number,
|
||||
maxAgeMs?: number,
|
||||
redactArchiveFileName = false
|
||||
redactArchiveFileName = false,
|
||||
knownPrivateNames: readonly string[] = []
|
||||
): Promise<boolean> {
|
||||
if (!sourcePath || budget.remainingBytes <= 0) {
|
||||
return false;
|
||||
@@ -245,11 +440,21 @@ async function addTextFileIfExists(
|
||||
return false;
|
||||
}
|
||||
const allowedBytes = Math.min(maxFileBytes, budget.remainingBytes);
|
||||
const text = redactSupportText(await readTextTail(sourcePath, allowedBytes), sensitiveValues);
|
||||
let buffer = Buffer.from(text, "utf8");
|
||||
if (buffer.length > allowedBytes) {
|
||||
buffer = Buffer.from(buffer.subarray(buffer.length - allowedBytes).toString("utf8"), "utf8");
|
||||
}
|
||||
const tail = await readTextTail(sourcePath, allowedBytes);
|
||||
const normalizedZipPath = zipPath.replace(/\\/g, "/");
|
||||
const contextualRuntimeLog = /^(?:logs\/)?(?:package|item)-logs\//.test(normalizedZipPath);
|
||||
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();
|
||||
zip.addFile(sanitizeArchivePath(zipPath, sensitiveValues, redactArchiveFileName), buffer);
|
||||
includedSourcePaths.add(sourcePathKey);
|
||||
@@ -333,7 +538,8 @@ async function addRelevantLogFiles<T extends { id: string }>(
|
||||
maxFiles: number,
|
||||
includedSourcePaths: Set<string>,
|
||||
sensitiveValues: ReadonlySet<string>,
|
||||
budget: TextBudget
|
||||
budget: TextBudget,
|
||||
resolvePrivateNames: (entry: T) => readonly string[]
|
||||
): Promise<number> {
|
||||
let added = 0;
|
||||
for (const entry of entries.slice(0, maxFiles)) {
|
||||
@@ -353,7 +559,8 @@ async function addRelevantLogFiles<T extends { id: string }>(
|
||||
budget,
|
||||
MAX_TEXT_FILE_BYTES,
|
||||
undefined,
|
||||
true
|
||||
true,
|
||||
resolvePrivateNames(entry)
|
||||
)) {
|
||||
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[] {
|
||||
return entries.sort((a, b) => Number(isActiveStatus(b.status)) - Number(isActiveStatus(a.status)) || b.updatedAt - a.updatedAt).slice(0, limit);
|
||||
function diagnosticPriority(entry: { status: unknown; resumeResetPending?: unknown; retries?: unknown; lastError?: unknown }): number {
|
||||
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> {
|
||||
@@ -849,6 +1064,7 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
||||
const snapshot = manager.getSnapshot();
|
||||
const packageEntries = Object.values(snapshot.session.packages);
|
||||
const itemEntries = Object.values(snapshot.session.items);
|
||||
const snapshotPrivateNames = collectSnapshotPrivateNames(packageEntries, itemEntries);
|
||||
const selectedPackageEntries = selectRelevantEntries(packageEntries, MAX_PACKAGE_DTOS);
|
||||
const selectedItemEntries = selectRelevantEntries(itemEntries, MAX_ITEM_DTOS);
|
||||
const selectedPackages = selectedPackageEntries
|
||||
@@ -925,16 +1141,7 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
||||
textBudget,
|
||||
MAX_RUNTIME_FILE_BYTES
|
||||
);
|
||||
const addCurrentLog = (sourcePath: string | null, zipPath: string): Promise<boolean> => addTextFileIfExists(
|
||||
zip,
|
||||
sourcePath,
|
||||
zipPath,
|
||||
includedSourcePaths,
|
||||
sensitiveValues,
|
||||
textBudget,
|
||||
MAX_TEXT_FILE_BYTES
|
||||
);
|
||||
const addRotatedLog = (sourcePath: string | null, zipPath: string): Promise<boolean> => addTextFileIfExists(
|
||||
const addCurrentLog = (sourcePath: string | null, zipPath: string, privateNames: readonly string[] = []): Promise<boolean> => addTextFileIfExists(
|
||||
zip,
|
||||
sourcePath,
|
||||
zipPath,
|
||||
@@ -942,7 +1149,21 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
||||
sensitiveValues,
|
||||
textBudget,
|
||||
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}`);
|
||||
@@ -964,7 +1185,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
||||
MAX_PACKAGE_LOG_FILES,
|
||||
includedSourcePaths,
|
||||
sensitiveValues,
|
||||
textBudget
|
||||
textBudget,
|
||||
(entry) => [entry.name]
|
||||
);
|
||||
const relevantItemLogCount = await addRelevantLogFiles(
|
||||
zip,
|
||||
@@ -974,7 +1196,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
||||
MAX_ITEM_LOG_FILES,
|
||||
includedSourcePaths,
|
||||
sensitiveValues,
|
||||
textBudget
|
||||
textBudget,
|
||||
(entry) => [entry.fileName, snapshot.session.packages[entry.packageId]?.name || ""]
|
||||
);
|
||||
|
||||
const mainLogPath = getLogFilePath();
|
||||
@@ -983,8 +1206,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
||||
const traceLogPath = getTraceLogPath();
|
||||
const accountRotationLogPath = getAccountRotationLogPath();
|
||||
const conversionLogPath = getConversionLogPath();
|
||||
await addCurrentLog(mainLogPath, "logs/rd_downloader.log");
|
||||
await addRotatedLog(`${mainLogPath}.old`, "logs/rd_downloader.log.old");
|
||||
await addCurrentLog(mainLogPath, "logs/rd_downloader.log", snapshotPrivateNames);
|
||||
await addRotatedLog(`${mainLogPath}.old`, "logs/rd_downloader.log.old", snapshotPrivateNames);
|
||||
await addCurrentLog(auditLogPath, "logs/audit.log");
|
||||
await addRotatedLog(auditLogPath ? `${auditLogPath}.old` : null, "logs/audit.log.old");
|
||||
await addCurrentLog(renameLogPath, "logs/rename.log");
|
||||
|
||||
@@ -2,9 +2,13 @@ import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||
import { isNotifyUrlValid } from "./notify";
|
||||
import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types";
|
||||
|
||||
function hasText(value: unknown): boolean {
|
||||
return String(value || "").trim().length > 0;
|
||||
}
|
||||
function hasText(value: unknown): boolean {
|
||||
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> {
|
||||
const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys);
|
||||
@@ -112,8 +116,8 @@ export function buildRedactedSettingsPayload(settings: AppSettings): Record<stri
|
||||
autoSkipExtracted: settings.autoSkipExtracted,
|
||||
completedCleanupPolicy: settings.completedCleanupPolicy
|
||||
},
|
||||
ui: {
|
||||
packageName: settings.packageName,
|
||||
ui: {
|
||||
packageNameConfigured: hasText(settings.packageName),
|
||||
theme: settings.theme,
|
||||
collapseNewPackages: settings.collapseNewPackages,
|
||||
hideExtractedItems: settings.hideExtractedItems,
|
||||
@@ -146,10 +150,10 @@ export function buildRedactedSettingsPayload(settings: AppSettings): Record<stri
|
||||
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs,
|
||||
providerDailyLimitBytes: settings.providerDailyLimitBytes,
|
||||
providerDailyUsageBytes: settings.providerDailyUsageBytes,
|
||||
providerTotalUsageBytes: settings.providerTotalUsageBytes,
|
||||
debridLinkApiKeyDailyLimitBytes: settings.debridLinkApiKeyDailyLimitBytes,
|
||||
debridLinkApiKeyDailyUsageBytes: settings.debridLinkApiKeyDailyUsageBytes,
|
||||
debridLinkApiKeyTotalUsageBytes: settings.debridLinkApiKeyTotalUsageBytes,
|
||||
providerTotalUsageBytes: settings.providerTotalUsageBytes,
|
||||
debridLinkApiKeyDailyLimitBytes: sumUsage(settings.debridLinkApiKeyDailyLimitBytes),
|
||||
debridLinkApiKeyDailyUsageBytes: sumUsage(settings.debridLinkApiKeyDailyUsageBytes),
|
||||
debridLinkApiKeyTotalUsageBytes: sumUsage(settings.debridLinkApiKeyTotalUsageBytes),
|
||||
providerDailyUsageDay: settings.providerDailyUsageDay
|
||||
},
|
||||
accounts: buildAccountSummary(settings)
|
||||
|
||||
+31
-6
@@ -1,12 +1,14 @@
|
||||
import { DragEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
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 {
|
||||
AccountCreateCommand,
|
||||
AllDebridHostInfo,
|
||||
AppTheme,
|
||||
BandwidthScheduleEntry,
|
||||
DebugSetupCheckResult,
|
||||
BandwidthScheduleEntry,
|
||||
DebugSetupCheckResult,
|
||||
DebridAccountStatus,
|
||||
DebridFallbackProvider,
|
||||
DebridLinkHostLimitInfo,
|
||||
DebridProvider,
|
||||
@@ -878,6 +880,24 @@ export function getSnapshotRenderDelay(_itemCount: number, _running: boolean, _a
|
||||
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 {
|
||||
busy: boolean;
|
||||
}
|
||||
@@ -2513,7 +2533,9 @@ export function App(): ReactElement {
|
||||
), [configuredAccountServices]);
|
||||
const accountEditOption = accountEditDialog ? findAccountOption(accountEditDialog.target.kind) : 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 accountDialogOption = accountDialog?.kind ? findAccountOption(accountDialog.kind) : null;
|
||||
const accountDialogSelectableOptions = useMemo(() => {
|
||||
@@ -3041,7 +3063,10 @@ export function App(): ReactElement {
|
||||
const removeAccountTableRow = (row: AccountTableRow): void => {
|
||||
setAccountContextMenu(null);
|
||||
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({
|
||||
title: `${row.hosterLabel} entfernen`,
|
||||
message: `Soll ${row.hosterLabel}${username !== "—" ? ` (${username})` : ""} wirklich entfernt werden?`,
|
||||
@@ -4987,7 +5012,7 @@ export function App(): ReactElement {
|
||||
? accountRowBindings.get(accountContextMenu.rowId) ?? null
|
||||
: null;
|
||||
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
|
||||
? "disabled"
|
||||
: !checkedStatus
|
||||
|
||||
@@ -35,7 +35,7 @@ export type ContextMenuKeyboardAction =
|
||||
|
||||
export type ContextMenuSubmenuKeyboardAction = "open" | "close";
|
||||
|
||||
export function clampContextMenuPosition(
|
||||
export function clampContextMenuPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
@@ -46,8 +46,30 @@ export function clampContextMenuPosition(
|
||||
return {
|
||||
x: Math.max(0, Math.min(x, Math.max(0, viewportWidth - width))),
|
||||
y: Math.max(0, Math.min(y, Math.max(0, viewportHeight - height)))
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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(
|
||||
trigger: { left: number; right: number; top: number },
|
||||
@@ -223,13 +245,14 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(function
|
||||
if (!previousFocusRef.current) {
|
||||
previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
}
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
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
|
||||
? current
|
||||
: { ...next, sourceX: x, sourceY: y, ready: true });
|
||||
getTopLevelMenuItems(menuRef.current)[0]?.focus();
|
||||
}, [open, x, y]);
|
||||
const stopObserving = observeContextMenuPosition(menuRef.current, () => ({ x, y }), (next) => {
|
||||
setPosition((current) => current.x === next.x && current.y === next.y && current.sourceX === x && current.sourceY === y && current.ready
|
||||
? current
|
||||
: { ...next, sourceX: x, sourceY: y, ready: true });
|
||||
});
|
||||
getTopLevelMenuItems(menuRef.current)[0]?.focus();
|
||||
return stopObserving;
|
||||
}, [open, x, y]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
|
||||
@@ -147,6 +147,8 @@ export function compactDownloadStatus(value: string): string {
|
||||
}
|
||||
if (/^Entpack-Fehler\b/i.test(status)) return "Entpack-Fehler";
|
||||
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);
|
||||
if (extractionPending) return `${extractionPending[1]} - ${extractionPending[2]}`;
|
||||
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]))}%`;
|
||||
return finalizing[1];
|
||||
}
|
||||
if (/^Fehler(?:\s*:|$)/i.test(status)) return "Fehler";
|
||||
if (/^Error(?:\s*:|$)/i.test(status)) return "Error";
|
||||
return status;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DebridProvider, HistoryEntry } from "../../../shared/types";
|
||||
import { normalizeHosterHostname } from "../../../shared/hoster";
|
||||
|
||||
export type HistoryFilter = "all" | "today" | "week" | "older" | "completed" | "deleted" | "failed";
|
||||
export type HistoryViewStatus = HistoryEntry["status"] | "failed";
|
||||
@@ -168,11 +169,12 @@ export function deriveHistoryHoster(urls: string[] | undefined): string {
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
seen.add(hostname);
|
||||
hostnames.push(hostname);
|
||||
seen.add(hoster);
|
||||
hostnames.push(hoster);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export interface MegaDebridAccountEntry {
|
||||
id: string;
|
||||
login: string;
|
||||
password: string;
|
||||
index: number;
|
||||
label: string;
|
||||
maskedLogin: string;
|
||||
id: string;
|
||||
login: string;
|
||||
password: string;
|
||||
index: number;
|
||||
label: string;
|
||||
maskedLogin: string;
|
||||
mode?: MegaDebridAccountMode;
|
||||
}
|
||||
|
||||
export type MegaDebridAccountMode = "api" | "web";
|
||||
@@ -35,9 +36,13 @@ function fnv1a64(text: string): string {
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountId(login: string): string {
|
||||
return `mda_${fnv1a64(login.trim().toLowerCase())}`;
|
||||
}
|
||||
export function getMegaDebridAccountId(login: string): string {
|
||||
return `mda_${fnv1a64(login.trim().toLowerCase())}`;
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountStatusId(accountId: string, mode: MegaDebridAccountMode): string {
|
||||
return `${accountId}:${mode}`;
|
||||
}
|
||||
|
||||
export function maskMegaDebridLogin(login: string): string {
|
||||
const trimmed = login.trim();
|
||||
@@ -124,7 +129,8 @@ export function getMegaDebridCredentialsForMode(settings: MegaDebridModeSettings
|
||||
}
|
||||
|
||||
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[] {
|
||||
|
||||
Reference in New Issue
Block a user