feat: add Deepbrid, daily scheduling, and notification center
Add encrypted Deepbrid API accounts with account validation, provider routing, fallback, usage tracking, safe error handling, and verified 1Fichier downloads. Restore persistent recurring daily starts with local-calendar deduplication and legacy schedule compatibility. Add durable Discord package, run, remaining-volume, stall, and recovery notifications with privacy-safe telemetry and disk-failure recovery.
This commit is contained in:
+151
-28
@@ -35,7 +35,7 @@ import { importDlcContainers } from "./container";
|
||||
import { APP_VERSION, ONLINE_BACKUP_API_URL } from "./constants";
|
||||
import { DownloadManager } from "./download-manager";
|
||||
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
|
||||
import { checkAllDebridAccounts, checkDebridLinkKey, checkMegaDebridAccount, checkRealDebridAccount, retainConfiguredRealDebridStatuses } from "./account-check";
|
||||
import { checkAllDebridAccounts, checkDebridLinkKey, checkDeepbridAccount, checkMegaDebridAccount, checkRealDebridAccount, retainConfiguredRealDebridStatuses } from "./account-check";
|
||||
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||
import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts";
|
||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
@@ -68,7 +68,7 @@ import { getDebugSetupCheck } from "./debug-setup";
|
||||
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
|
||||
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log";
|
||||
import { getDesktopRenameLogPath, initDesktopRenameLogAt, shutdownDesktopRenameLog } from "./desktop-rename-log";
|
||||
import { buildAccountSummary, diffAccountSummary } from "./support-data";
|
||||
import { buildAccountSummary, buildNotificationSupportPayload, diffAccountSummary, type NotificationSupportPayload } from "./support-data";
|
||||
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
||||
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
|
||||
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
|
||||
@@ -76,6 +76,10 @@ import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./
|
||||
import { overlayLiveUsageCounters } from "./settings-live-overlay";
|
||||
import { getLegacyDesktopLogDirectory, migrateLogDirectories, prepareLogDirectory, resolveLogDirectory } from "./log-storage";
|
||||
import { normalizeStatisticsLedger, saveStatisticsLedger } from "./statistics-ledger";
|
||||
import { NotificationOutbox } from "./notification-outbox";
|
||||
import { sendNotification } from "./notify";
|
||||
import { DownloadHealthMonitor } from "./download-health-monitor";
|
||||
import { shouldDeferAutoResumeToDailyStart } from "./daily-start-scheduler";
|
||||
|
||||
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
|
||||
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
|
||||
@@ -116,6 +120,14 @@ export class AppController {
|
||||
|
||||
private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime"));
|
||||
|
||||
private notificationOutbox: NotificationOutbox;
|
||||
|
||||
private downloadHealthMonitor: DownloadHealthMonitor;
|
||||
|
||||
private downloadHealthTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
private downloadHealthEvaluation: Promise<void> | null = null;
|
||||
|
||||
private logDirectory = this.storagePaths.baseDir;
|
||||
|
||||
private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null;
|
||||
@@ -148,9 +160,32 @@ export class AppController {
|
||||
}
|
||||
this.initializeLogStorage();
|
||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
||||
const loadResult = loadSessionWithStatus(this.storagePaths);
|
||||
const session = loadResult.session;
|
||||
this.megaWebFallback = new MegaWebFallback(() => ({
|
||||
const loadResult = loadSessionWithStatus(this.storagePaths);
|
||||
const session = loadResult.session;
|
||||
this.notificationOutbox = new NotificationOutbox({
|
||||
filePath: this.storagePaths.notificationOutboxFile,
|
||||
autoDrain: true,
|
||||
send: (event) => sendNotification(this.settings.notifyUrl, {
|
||||
title: event.payload.title,
|
||||
message: event.payload.description || "",
|
||||
mention: this.settings.notifyMention,
|
||||
color: event.payload.color ?? (event.priority === "error" ? 0xe74c3c : 0x2ecc71),
|
||||
fields: event.payload.fields,
|
||||
timestamp: event.createdAt
|
||||
}),
|
||||
onDelivered: (event, deliveredAt) => {
|
||||
return this.downloadHealthMonitor?.acknowledgeDelivery(
|
||||
event,
|
||||
deliveredAt,
|
||||
this.settings.notifyStallCooldownMinutes * 60_000
|
||||
);
|
||||
}
|
||||
});
|
||||
this.downloadHealthMonitor = new DownloadHealthMonitor(this.storagePaths.notificationHealthFile);
|
||||
void this.notificationOutbox.drain().catch((error) => {
|
||||
logger.warn(`Notification-Outbox konnte nicht gestartet werden: ${String(error)}`);
|
||||
});
|
||||
this.megaWebFallback = new MegaWebFallback(() => ({
|
||||
login: this.settings.megaLogin,
|
||||
password: this.settings.megaPassword
|
||||
}));
|
||||
@@ -161,15 +196,21 @@ export class AppController {
|
||||
allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal),
|
||||
realDebridWebUnrestrict: (accountId: string, link: string, signal?: AbortSignal) => this.unrestrictRealDebridWebAccount(accountId, link, signal),
|
||||
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
|
||||
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
|
||||
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
|
||||
protectEmptyClobber: loadResult.status === "empty-unreadable",
|
||||
enqueueNotification: (event) => this.notificationOutbox.enqueue(event),
|
||||
onHistoryEntry: (entry: HistoryEntry) => {
|
||||
this.recordHistoryEntry(entry);
|
||||
}
|
||||
});
|
||||
this.manager.on("state", (snapshot: UiSnapshot) => {
|
||||
this.onStateHandler?.(snapshot);
|
||||
});
|
||||
this.manager.on("state", (snapshot: UiSnapshot) => {
|
||||
this.onStateHandler?.(snapshot);
|
||||
});
|
||||
void this.evaluateDownloadHealth();
|
||||
this.downloadHealthTimer = setInterval(() => {
|
||||
void this.evaluateDownloadHealth();
|
||||
}, 15_000);
|
||||
this.downloadHealthTimer.unref?.();
|
||||
logger.info(`App gestartet v${APP_VERSION}`);
|
||||
logger.info(`Log-Datei: ${getLogFilePath()}`);
|
||||
logAuditEvent("INFO", "App gestartet", {
|
||||
@@ -204,7 +245,7 @@ export class AppController {
|
||||
} catch (err) {
|
||||
logger.warn(`Health-Check uebersprungen (Fehler): ${String((err as Error).message || err)}`);
|
||||
}
|
||||
startDebugServer(this.manager, this.storagePaths.baseDir);
|
||||
startDebugServer(this.manager, this.storagePaths.baseDir, () => this.getNotificationSupportPayload());
|
||||
this.runtimeStatsTimer = setInterval(() => {
|
||||
this.manager.persistRuntimeStats();
|
||||
this.settings = this.manager.getSettings();
|
||||
@@ -212,7 +253,7 @@ export class AppController {
|
||||
}, 60_000);
|
||||
this.runtimeStatsTimer.unref?.();
|
||||
|
||||
if (this.settings.autoResumeOnStart) {
|
||||
if (this.settings.autoResumeOnStart && !shouldDeferAutoResumeToDailyStart(this.settings, loadResult.wasRunning)) {
|
||||
const snapshot = this.manager.getSnapshot();
|
||||
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
|
||||
if (hasPending && this.hasAnyProviderToken(this.settings)) {
|
||||
@@ -550,6 +591,9 @@ export class AppController {
|
||||
if (!account) throw new Error("Account-Payload ist ungültig");
|
||||
checkedStatus = await checkRealDebridAccount(account);
|
||||
}
|
||||
if (command.action !== "delete" && applied.response.accountId && command.kind === "deepbrid-api") {
|
||||
checkedStatus = await checkDeepbridAccount(applied.settings.deepbridApiKey);
|
||||
}
|
||||
if (checkedStatus) {
|
||||
checkedStatus = sanitizeDebridAccountStatus(checkedStatus, redactions);
|
||||
}
|
||||
@@ -578,6 +622,15 @@ export class AppController {
|
||||
|
||||
public async checkAccountCredentials(input: AccountCredentialCheckInput): Promise<DebridAccountStatus> {
|
||||
const redactions = collectAccountStatusRedactionValues(this.settings, input);
|
||||
if (input.kind === "deepbrid-api") {
|
||||
const key = input.secret?.trim() || this.settings.deepbridApiKey.trim();
|
||||
if (!key) throw new Error("Account-Payload ist ungültig");
|
||||
const status = sanitizeDebridAccountStatus(await checkDeepbridAccount(key), redactions);
|
||||
if (!input.secret && input.accountId === "svc-deepbrid" && this.settings.deepbridApiKey.trim()) {
|
||||
this.manager.applyDebridAccountStatuses([status]);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
if (input.kind === "realdebrid-api" || input.kind === "realdebrid-web") {
|
||||
const useWebLogin = input.kind === "realdebrid-web";
|
||||
const account = input.secret?.trim() && !useWebLogin
|
||||
@@ -1130,21 +1183,31 @@ export class AppController {
|
||||
return { restored: true, relaunch: false, message: "Einstellungen aus Online-Sicherung wiederhergestellt" };
|
||||
}
|
||||
|
||||
public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> {
|
||||
public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> {
|
||||
this.audit("INFO", "Support-Bundle exportiert");
|
||||
logTraceEvent("INFO", "support", "Support-Bundle erstellt", {
|
||||
packageCount: Object.keys(this.manager.getSnapshot().session.packages).length,
|
||||
itemCount: Object.keys(this.manager.getSnapshot().session.items).length
|
||||
});
|
||||
return {
|
||||
buffer: await buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }),
|
||||
buffer: await buildSupportBundle(this.manager, this.storagePaths.baseDir, {
|
||||
hostDiagnosticsMode: "cached",
|
||||
notificationStatus: this.getNotificationSupportPayload()
|
||||
}),
|
||||
defaultFileName: getSupportBundleDefaultFileName()
|
||||
};
|
||||
}
|
||||
|
||||
public getSupportBundleDefaultFileName(): string {
|
||||
return getSupportBundleDefaultFileName();
|
||||
}
|
||||
public getSupportBundleDefaultFileName(): string {
|
||||
return getSupportBundleDefaultFileName();
|
||||
}
|
||||
|
||||
public getNotificationSupportPayload(): NotificationSupportPayload {
|
||||
return buildNotificationSupportPayload(
|
||||
this.notificationOutbox.getStatus(),
|
||||
this.downloadHealthMonitor.getState()
|
||||
);
|
||||
}
|
||||
|
||||
public importBackup(data: Buffer, passphrase?: string): { restored: boolean; relaunch: boolean; message: string } {
|
||||
let parsed: Record<string, unknown>;
|
||||
@@ -1169,8 +1232,8 @@ export class AppController {
|
||||
const importedSettingsRecord = importedSettings as unknown as Record<string, unknown>;
|
||||
const currentSettingsRecord = this.settings as unknown as Record<string, unknown>;
|
||||
const SENSITIVE_KEYS: (keyof AppSettings)[] = [
|
||||
"token", "megaLogin", "megaPassword", "bestToken", "allDebridToken",
|
||||
"ddownloadLogin", "ddownloadPassword", "oneFichierApiKey",
|
||||
"token", "megaLogin", "megaPassword", "bestToken", "allDebridToken",
|
||||
"deepbridApiKey", "ddownloadLogin", "ddownloadPassword", "oneFichierApiKey",
|
||||
"debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword",
|
||||
"notifyUrl"
|
||||
];
|
||||
@@ -1261,19 +1324,64 @@ export class AppController {
|
||||
return this.manager.getPackageLogPath(packageId) || getPackageLogPath(packageId);
|
||||
}
|
||||
|
||||
public getItemLogPath(itemId: string): string | null {
|
||||
return this.manager.getItemLogPath(itemId) || getItemLogPath(itemId);
|
||||
}
|
||||
|
||||
public shutdown(): void {
|
||||
if (this.runtimeStatsTimer) {
|
||||
public getItemLogPath(itemId: string): string | null {
|
||||
return this.manager.getItemLogPath(itemId) || getItemLogPath(itemId);
|
||||
}
|
||||
|
||||
private evaluateDownloadHealth(): Promise<void> {
|
||||
if (this.downloadHealthEvaluation) {
|
||||
return this.downloadHealthEvaluation;
|
||||
}
|
||||
const now = Date.now();
|
||||
const task = this.downloadHealthMonitor.sample(
|
||||
this.manager.getDownloadHealthSnapshot(now),
|
||||
now,
|
||||
{
|
||||
stallAfterMs: this.settings.notifyStallAfterSeconds * 1000,
|
||||
cooldownMs: this.settings.notifyStallCooldownMinutes * 60_000,
|
||||
notifyOnStall: this.settings.notifyOnDownloadStall && Boolean(String(this.settings.notifyUrl || "").trim()),
|
||||
notifyOnRecovery: this.settings.notifyOnDownloadRecovery && Boolean(String(this.settings.notifyUrl || "").trim())
|
||||
},
|
||||
(event) => this.notificationOutbox.enqueue(event)
|
||||
).then(() => undefined).catch((error) => {
|
||||
logger.warn(`Download-Health-Monitor konnte nicht ausgewertet werden: ${String(error)}`);
|
||||
}).finally(() => {
|
||||
if (this.downloadHealthEvaluation === task) {
|
||||
this.downloadHealthEvaluation = null;
|
||||
}
|
||||
});
|
||||
this.downloadHealthEvaluation = task;
|
||||
return task;
|
||||
}
|
||||
|
||||
public async shutdown(): Promise<void> {
|
||||
const deadlineAt = Date.now() + 3000;
|
||||
if (this.downloadHealthTimer) {
|
||||
clearInterval(this.downloadHealthTimer);
|
||||
this.downloadHealthTimer = null;
|
||||
}
|
||||
this.manager.suspendDownloadHealthMonitoring?.();
|
||||
if (this.runtimeStatsTimer) {
|
||||
clearInterval(this.runtimeStatsTimer);
|
||||
this.runtimeStatsTimer = null;
|
||||
}
|
||||
stopDebugServer();
|
||||
abortActiveUpdateDownload();
|
||||
cancelPendingAsyncSaves();
|
||||
stopDebugServer();
|
||||
abortActiveUpdateDownload();
|
||||
cancelPendingAsyncSaves();
|
||||
this.manager.prepareForShutdown();
|
||||
if (this.downloadHealthEvaluation) {
|
||||
await this.waitForShutdownTask(this.downloadHealthEvaluation, deadlineAt);
|
||||
}
|
||||
if (this.downloadHealthMonitor && Date.now() < deadlineAt) {
|
||||
await this.waitForShutdownTask(this.evaluateDownloadHealth(), deadlineAt);
|
||||
}
|
||||
const notificationFlush = this.manager.flushNotificationsForShutdown?.();
|
||||
if (notificationFlush && Date.now() < deadlineAt) {
|
||||
await this.waitForShutdownTask(notificationFlush, deadlineAt);
|
||||
}
|
||||
await this.notificationOutbox.drainForShutdown(Math.max(0, deadlineAt - Date.now())).catch((error) => {
|
||||
logger.warn(`Notification-Outbox konnte beim Beenden nicht geleert werden: ${String(error)}`);
|
||||
});
|
||||
this.megaWebFallback.dispose();
|
||||
for (const fallback of this.realDebridWebFallbacks.values()) {
|
||||
fallback.dispose();
|
||||
@@ -1291,7 +1399,22 @@ export class AppController {
|
||||
if (this.settings.historyRetentionMode === "session") {
|
||||
clearHistory(this.storagePaths);
|
||||
}
|
||||
logger.info("App beendet");
|
||||
logger.info("App beendet");
|
||||
}
|
||||
|
||||
private async waitForShutdownTask(task: Promise<unknown>, deadlineAt: number): Promise<void> {
|
||||
const remainingMs = Math.max(0, deadlineAt - Date.now());
|
||||
if (remainingMs <= 0) {
|
||||
return;
|
||||
}
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
const timeout = new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, remainingMs);
|
||||
});
|
||||
await Promise.race([task.then(() => undefined, () => undefined), timeout]);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private getDesktopDirectory(): string | null {
|
||||
|
||||
Reference in New Issue
Block a user