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:
Sucukdeluxe
2026-08-24 07:37:55 +02:00
parent 06e5bf4340
commit 1b7caba2eb
83 changed files with 12769 additions and 1098 deletions
+59 -4
View File
@@ -2,7 +2,8 @@ import type { AccountCheckScope, AppSettings, DebridAccountStatus, DebridProvide
import { getMegaDebridAccountsForMode, getMegaDebridDisabledAccountIdsForMode, parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/debrid-link-keys";
import { getRealDebridAccounts, type RealDebridAccountEntry } from "../shared/real-debrid-accounts";
import { logger } from "./logger";
import { DEEPBRID_ACCOUNT_ID, DeepbridApiError, DeepbridClient } from "./deepbrid";
import { logger } from "./logger";
import { compactErrorText } from "./utils";
const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php";
@@ -175,6 +176,57 @@ export async function checkRealDebridAccount(
return { ...base, message: aborted ? "Prüfung abgebrochen" : `Prüfung fehlgeschlagen: ${errText}` };
}
}
export async function checkDeepbridAccount(
apiKey: string,
signal?: AbortSignal,
now = Date.now()
): Promise<DebridAccountStatus> {
const key = apiKey.trim();
const base: DebridAccountStatus = {
accountId: DEEPBRID_ACCOUNT_ID,
provider: "deepbrid",
label: "Deepbrid",
maskedLogin: maskSecret(key),
valid: false,
isPremium: false,
premiumUntilMs: null,
message: "",
checkedAt: now
};
if (!key) {
return { ...base, message: "Kein API-Key hinterlegt" };
}
try {
const user = await new DeepbridClient(key).getUser(signal);
const expiration = Date.parse(user.expiration);
const premiumUntilMs = Number.isFinite(expiration) ? expiration : null;
const isPremium = user.type.trim().toLowerCase() === "premium"
&& premiumUntilMs !== null
&& premiumUntilMs > now;
return {
...base,
valid: true,
isPremium,
premiumUntilMs,
username: user.username.trim() || undefined,
email: user.email.trim() || undefined,
message: isPremium
? formatRemaining(premiumUntilMs, now)
: user.type.trim().toLowerCase() === "premium" && premiumUntilMs !== null
? formatRemaining(premiumUntilMs, now)
: "Kein Premium (Free)"
};
} catch (error) {
if (signal?.aborted) {
return { ...base, message: "Prüfung abgebrochen" };
}
if (error instanceof DeepbridApiError && error.classification === "auth") {
return { ...base, message: error.status === 403 ? "Deepbrid API-Key gesperrt" : "Ungültiger Deepbrid API-Key" };
}
return { ...base, message: "Prüfung fehlgeschlagen" };
}
}
export async function checkMegaDebridAccount(
account: MegaDebridAccountEntry,
@@ -337,6 +389,8 @@ export async function checkAllDebridAccounts(
const realDebridAccounts = scope === "all"
? allRealDebridAccounts
: providerEnabled("realdebrid") ? allRealDebridAccounts.filter((account) => account.enabled) : [];
const deepbridApiKey = String(settings.deepbridApiKey || "").trim();
const checkDeepbrid = Boolean(deepbridApiKey) && (scope === "all" || providerEnabled("deepbrid"));
const taskFns: Array<() => Promise<DebridAccountStatus>> = [
...realDebridAccounts.map((account) => () => checkRealDebridAccount(
@@ -347,9 +401,10 @@ export async function checkAllDebridAccounts(
? (probeSignal) => probeRealDebridWebSession(account.id, probeSignal)
: undefined
)),
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now))
];
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now)),
...(checkDeepbrid ? [() => checkDeepbridAccount(deepbridApiKey, signal, now)] : [])
];
const results = await runWithConcurrency(taskFns, CHECK_CONCURRENCY);
logger.info(
+13 -2
View File
@@ -27,6 +27,7 @@ const ACCOUNT_KINDS = new Set<RendererAccountKind>([
"bestdebrid-web",
"alldebrid-api",
"alldebrid-web",
"deepbrid-api",
"ddownload-login",
"onefichier-api",
"debridlink-api",
@@ -127,10 +128,13 @@ export function validateAccountCredentialCheckInput(value: unknown): AccountCred
&& raw.kind !== "realdebrid-web"
&& raw.kind !== "megadebrid-api"
&& raw.kind !== "megadebrid-web"
&& raw.kind !== "debridlink-api") invalid();
&& raw.kind !== "debridlink-api"
&& raw.kind !== "deepbrid-api") invalid();
const accountId = optionalString(raw.accountId, 256);
if (raw.kind === "deepbrid-api" && accountId !== undefined && accountId !== "svc-deepbrid") invalid();
return {
kind: raw.kind,
accountId: optionalString(raw.accountId, 256),
accountId,
identity: optionalString(raw.identity, 512),
secret: optionalString(raw.secret, 100_000)
};
@@ -174,6 +178,7 @@ export function resolveStoredAccountSecret(settings: AppSettings, request: Accou
if (request.accountId !== `svc-${provider}` || !singleConfigured(settings, request.kind)) storedSecretMissing();
if (request.kind === "bestdebrid-api" && settings.bestToken) return settings.bestToken;
if (request.kind === "alldebrid-api" && settings.allDebridToken) return settings.allDebridToken;
if (request.kind === "deepbrid-api" && settings.deepbridApiKey) return settings.deepbridApiKey;
if (request.kind === "ddownload-login" && settings.ddownloadPassword) return settings.ddownloadPassword;
if (request.kind === "onefichier-api" && settings.oneFichierApiKey) return settings.oneFichierApiKey;
if (request.kind === "linksnappy-login" && settings.linkSnappyPassword) return settings.linkSnappyPassword;
@@ -522,6 +527,7 @@ function singleProvider(kind: RendererAccountKind): DebridProvider {
if (kind.startsWith("realdebrid")) return "realdebrid";
if (kind.startsWith("bestdebrid")) return "bestdebrid";
if (kind.startsWith("alldebrid")) return "alldebrid";
if (kind === "deepbrid-api") return "deepbrid";
if (kind === "ddownload-login") return "ddownload";
if (kind === "onefichier-api") return "onefichier";
if (kind === "linksnappy-login") return "linksnappy";
@@ -535,6 +541,7 @@ function singleConfigured(settings: AppSettings, kind: RendererAccountKind): boo
if (kind === "bestdebrid-web") return settings.bestDebridUseWebLogin;
if (kind === "alldebrid-api") return Boolean(settings.allDebridToken.trim());
if (kind === "alldebrid-web") return settings.allDebridUseWebLogin;
if (kind === "deepbrid-api") return Boolean(settings.deepbridApiKey.trim());
if (kind === "ddownload-login") return Boolean(settings.ddownloadLogin.trim() && settings.ddownloadPassword);
if (kind === "onefichier-api") return Boolean(settings.oneFichierApiKey.trim());
if (kind === "linksnappy-login") return Boolean(settings.linkSnappyLogin.trim() && settings.linkSnappyPassword);
@@ -548,6 +555,7 @@ function setSingle(settings: AppSettings, kind: RendererAccountKind, identity: s
if (kind === "bestdebrid-web") return { ...settings, bestToken: "", bestDebridUseWebLogin: true };
if (kind === "alldebrid-api") return { ...settings, allDebridToken: validateSecret(secret || ""), allDebridUseWebLogin: false };
if (kind === "alldebrid-web") return { ...settings, allDebridToken: "", allDebridUseWebLogin: true };
if (kind === "deepbrid-api") return { ...settings, deepbridApiKey: validateSecret(secret || "") };
if (kind === "ddownload-login") return { ...settings, ddownloadLogin: validateIdentity(identity || ""), ddownloadPassword: validateSecret(secret || "") };
if (kind === "onefichier-api") return { ...settings, oneFichierApiKey: validateSecret(secret || "") };
if (kind === "linksnappy-login") return { ...settings, linkSnappyLogin: validateIdentity(identity || ""), linkSnappyPassword: validateSecret(secret || "") };
@@ -563,6 +571,7 @@ function replaceSingle(settings: AppSettings, command: Extract<AccountCommand, {
if (command.kind === "realdebrid-api") secret ||= settings.token;
if (command.kind === "bestdebrid-api") secret ||= settings.bestToken;
if (command.kind === "alldebrid-api") secret ||= settings.allDebridToken;
if (command.kind === "deepbrid-api") secret ||= settings.deepbridApiKey;
if (command.kind === "ddownload-login") {
identity = identity?.trim() ? identity : settings.ddownloadLogin;
secret ||= settings.ddownloadPassword;
@@ -587,12 +596,14 @@ function deleteSingle(settings: AppSettings, command: Extract<AccountCommand, {
if (provider === "realdebrid") next = { ...next, token: "", realDebridUseWebLogin: false };
if (provider === "bestdebrid") next = { ...next, bestToken: "", bestDebridUseWebLogin: false };
if (provider === "alldebrid") next = { ...next, allDebridToken: "", allDebridUseWebLogin: false };
if (provider === "deepbrid") next = { ...next, deepbridApiKey: "" };
if (provider === "ddownload") next = { ...next, ddownloadLogin: "", ddownloadPassword: "" };
if (provider === "onefichier") next = { ...next, oneFichierApiKey: "" };
if (provider === "linksnappy") next = { ...next, linkSnappyLogin: "", linkSnappyPassword: "" };
next.providerDailyLimitBytes = withoutKeys(settings.providerDailyLimitBytes as Record<string, number>, provider);
next.providerDailyUsageBytes = withoutKeys(settings.providerDailyUsageBytes as Record<string, number>, provider);
next.providerTotalUsageBytes = withoutKeys(settings.providerTotalUsageBytes as Record<string, number>, provider);
next.debridAccountStatuses = withoutKeys(settings.debridAccountStatuses, command.accountId);
return { settings: next, response: { accountId: null } };
}
+1
View File
@@ -53,6 +53,7 @@ export function collectAccountStatusRedactionValues(settings?: AppSettings, inpu
addRedaction(values, settings.megaDebridWebCredentials);
addRedaction(values, settings.bestToken);
addRedaction(values, settings.allDebridToken);
addRedaction(values, settings.deepbridApiKey);
addRedaction(values, settings.ddownloadPassword);
addRedaction(values, settings.oneFichierApiKey);
addRedaction(values, settings.debridLinkApiKeys);
+151 -28
View File
@@ -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 {
+25 -11
View File
@@ -63,9 +63,10 @@ export function defaultSettings(): AppSettings {
megaDebridPreferApi: true,
bestToken: "",
bestDebridUseWebLogin: false,
allDebridToken: "",
allDebridUseWebLogin: false,
ddownloadLogin: "",
allDebridToken: "",
allDebridUseWebLogin: false,
deepbridApiKey: "",
ddownloadLogin: "",
ddownloadPassword: "",
oneFichierApiKey: "",
debridLinkApiKeys: "",
@@ -125,9 +126,16 @@ export function defaultSettings(): AppSettings {
backupIncludeRemoteDiagnostics: false,
notifyUrl: "",
notifyMention: "",
notifyOnPackageCompleted: false,
notifyOnPackageFailed: false,
notifyOnRunFinished: false,
notifyOnPackageCompleted: false,
notifyOnPackageFailed: false,
notifyOnRunFinished: false,
notifyPackageSuccessMode: "digest",
notifyOnRemainingBelow: false,
notifyRemainingThresholdGb: 50,
notifyOnDownloadStall: false,
notifyStallAfterSeconds: 90,
notifyStallCooldownMinutes: 10,
notifyOnDownloadRecovery: true,
totalDownloadedAllTime: 0,
totalCompletedFilesAllTime: 0,
totalRuntimeAllTimeMs: 0,
@@ -150,8 +158,14 @@ export function defaultSettings(): AppSettings {
megaDebridAccountDailyLimitBytes: {},
megaDebridAccountDailyUsageBytes: {},
megaDebridAccountTotalUsageBytes: {},
debridAccountStatuses: {},
providerDailyUsageDay: getProviderUsageDayKey(),
scheduledStartEpochMs: 0
};
}
debridAccountStatuses: {},
providerDailyUsageDay: getProviderUsageDayKey(),
dailyStartEnabled: false,
dailyStartMinuteOfDay: 0,
dailyStartFirstLocalDate: "",
dailyStartLastHandledLocalDate: "",
dailyStartPendingLocalDate: "",
dailyStartLastOutcome: "",
scheduledStartEpochMs: 0
};
}
+1
View File
@@ -18,6 +18,7 @@ const CREDENTIAL_KEYS = [
"megaDebridWebCredentials",
"bestToken",
"allDebridToken",
"deepbridApiKey",
"ddownloadLogin",
"ddownloadPassword",
"oneFichierApiKey",
+263
View File
@@ -0,0 +1,263 @@
import type { DailyStartOutcome, DailyStartSettings } from "../shared/types";
interface DailyStartSnapshot {
settings: DailyStartSettings;
session: {
running: boolean;
paused: boolean;
items: Record<string, { status: string; packageId: string }>;
packages: Record<string, { enabled: boolean; cancelled: boolean }>;
};
canStart: boolean;
}
interface DailyStartController {
getSnapshot(): DailyStartSnapshot;
updateSettings(partial: Partial<DailyStartSettings>): unknown;
start(): Promise<void>;
}
interface LocalDateParts {
year: number;
month: number;
day: number;
}
function parseLocalDate(value: string): LocalDateParts | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(year, month - 1, day, 12, 0, 0, 0);
if (year < 1000 || date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
return null;
}
return { year, month, day };
}
function formatLocalDate(date: Date): string {
return `${date.getFullYear().toString().padStart(4, "0")}-${(date.getMonth() + 1).toString().padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`;
}
function addLocalDays(value: string, days: number): string {
const parts = parseLocalDate(value);
if (!parts) {
return "";
}
return formatLocalDate(new Date(parts.year, parts.month - 1, parts.day + days, 12, 0, 0, 0));
}
function localTargetEpochMs(value: string, minuteOfDay: number): number {
const parts = parseLocalDate(value);
if (!parts) {
return 0;
}
const minute = Math.max(0, Math.min(1_439, Math.floor(minuteOfDay)));
return new Date(parts.year, parts.month - 1, parts.day, Math.floor(minute / 60), minute % 60, 0, 0).getTime();
}
export function isValidLocalDate(value: string): boolean {
return parseLocalDate(value) !== null;
}
export function nextDailyStartEpochMs(settings: DailyStartSettings, nowEpochMs = Date.now()): number {
if (!settings.dailyStartEnabled || !Number.isFinite(settings.dailyStartMinuteOfDay)) {
return 0;
}
const firstDate = parseLocalDate(settings.dailyStartFirstLocalDate);
if (!firstDate) {
return 0;
}
const now = new Date(nowEpochMs);
const today = formatLocalDate(now);
let candidate = settings.dailyStartFirstLocalDate > today ? settings.dailyStartFirstLocalDate : today;
const pending = isValidLocalDate(settings.dailyStartPendingLocalDate)
? settings.dailyStartPendingLocalDate
: "";
if (pending && candidate < pending) {
candidate = pending;
}
const handled = isValidLocalDate(settings.dailyStartLastHandledLocalDate)
? settings.dailyStartLastHandledLocalDate
: "";
if (handled && candidate <= handled) {
candidate = addLocalDays(handled, 1);
}
return localTargetEpochMs(candidate, settings.dailyStartMinuteOfDay);
}
export function hasDailyStartRulePatch(partial: object): boolean {
const value = partial as Record<string, unknown>;
return ["dailyStartEnabled", "dailyStartMinuteOfDay", "dailyStartFirstLocalDate"]
.some((key) => Object.prototype.hasOwnProperty.call(value, key));
}
export function prepareDailyStartSettingsPatch<T extends object>(
partial: T,
current: DailyStartSettings
): T & { scheduledStartEpochMs?: number } {
const value = partial as Record<string, unknown>;
const changed = (
Object.prototype.hasOwnProperty.call(value, "dailyStartEnabled")
&& value.dailyStartEnabled !== current.dailyStartEnabled
) || (
Object.prototype.hasOwnProperty.call(value, "dailyStartMinuteOfDay")
&& value.dailyStartMinuteOfDay !== current.dailyStartMinuteOfDay
) || (
Object.prototype.hasOwnProperty.call(value, "dailyStartFirstLocalDate")
&& value.dailyStartFirstLocalDate !== current.dailyStartFirstLocalDate
);
return changed
? { ...partial, scheduledStartEpochMs: 0 }
: { ...partial };
}
export function shouldDeferAutoResumeToDailyStart(
settings: DailyStartSettings,
wasRunning: boolean,
nowEpochMs = Date.now()
): boolean {
return !wasRunning && nextDailyStartEpochMs(settings, nowEpochMs) > nowEpochMs;
}
export class DailyStartScheduler {
private reconcileInFlight: Promise<DailyStartOutcome | null> | null = null;
private reconcileTimer: ReturnType<typeof setInterval> | null = null;
private lifecycleGeneration = 0;
public constructor(
private readonly controller: DailyStartController,
private readonly now: () => number = Date.now
) {}
public reconcile(): Promise<DailyStartOutcome | null> {
if (this.reconcileInFlight) {
return this.reconcileInFlight;
}
const operation = this.reconcileOnce(this.lifecycleGeneration);
this.reconcileInFlight = operation;
void operation.finally(() => {
if (this.reconcileInFlight === operation) {
this.reconcileInFlight = null;
}
}).catch(() => {});
return operation;
}
public begin(onError: (error: unknown) => void = () => {}): void {
this.end();
void this.reconcile().catch(onError);
this.reconcileTimer = setInterval(() => {
void this.reconcile().catch(onError);
}, 60_000);
this.reconcileTimer.unref?.();
}
public end(): void {
this.lifecycleGeneration += 1;
if (this.reconcileTimer !== null) {
clearInterval(this.reconcileTimer);
this.reconcileTimer = null;
}
}
private finish(localDate: string, outcome: DailyStartOutcome): DailyStartOutcome {
this.controller.updateSettings({
dailyStartLastHandledLocalDate: localDate,
dailyStartPendingLocalDate: "",
dailyStartLastOutcome: outcome
});
return outcome;
}
private async reconcileOnce(generation: number): Promise<DailyStartOutcome | null> {
const nowEpochMs = this.now();
let settings = this.controller.getSnapshot().settings;
if (!settings.dailyStartEnabled || !isValidLocalDate(settings.dailyStartFirstLocalDate)) {
return null;
}
const today = formatLocalDate(new Date(nowEpochMs));
const handledDate = isValidLocalDate(settings.dailyStartLastHandledLocalDate)
? settings.dailyStartLastHandledLocalDate
: "";
const pendingDate = isValidLocalDate(settings.dailyStartPendingLocalDate)
? settings.dailyStartPendingLocalDate
: "";
if ((handledDate && today <= handledDate) || (pendingDate && today < pendingDate)) {
return null;
}
let missed: DailyStartOutcome | null = null;
if (isValidLocalDate(settings.dailyStartPendingLocalDate) && settings.dailyStartPendingLocalDate < today) {
const missedDate = settings.dailyStartPendingLocalDate;
const lastHandledLocalDate = handledDate > missedDate ? handledDate : missedDate;
this.controller.updateSettings({
dailyStartLastHandledLocalDate: lastHandledLocalDate,
dailyStartPendingLocalDate: "",
dailyStartLastOutcome: "missed"
});
settings = {
...settings,
dailyStartLastHandledLocalDate: lastHandledLocalDate,
dailyStartPendingLocalDate: "",
dailyStartLastOutcome: "missed"
};
missed = "missed";
}
if (today < settings.dailyStartFirstLocalDate || settings.dailyStartLastHandledLocalDate === today) {
return missed;
}
const targetEpochMs = localTargetEpochMs(today, settings.dailyStartMinuteOfDay);
if (!targetEpochMs || targetEpochMs > nowEpochMs) {
return missed;
}
if (settings.dailyStartPendingLocalDate === today) {
return this.finish(today, settings.dailyStartLastOutcome || "start_failed");
}
const snapshot = this.controller.getSnapshot();
if (snapshot.session.running || snapshot.session.paused) {
return this.finish(today, "already_active");
}
const hasQueuedItems = Object.values(snapshot.session.items).some((item) => {
if (item.status !== "queued" && item.status !== "reconnect_wait") {
return false;
}
const pkg = snapshot.session.packages[item.packageId];
return Boolean(pkg && pkg.enabled && !pkg.cancelled);
});
if (!hasQueuedItems) {
return this.finish(today, "empty_queue");
}
if (!snapshot.canStart) {
this.controller.updateSettings({ dailyStartLastOutcome: "missing_account" });
return "missing_account";
}
this.controller.updateSettings({
dailyStartPendingLocalDate: today,
dailyStartLastOutcome: "start_failed"
});
try {
await this.controller.start();
} catch (error) {
if (generation !== this.lifecycleGeneration) {
return null;
}
this.controller.updateSettings({
dailyStartPendingLocalDate: "",
dailyStartLastOutcome: "start_failed"
});
throw error;
}
if (generation !== this.lifecycleGeneration) {
return null;
}
return this.finish(today, "started");
}
}
+24 -10
View File
@@ -6,6 +6,7 @@ import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostL
import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached, isRealDebridAccountDailyLimitReached } from "../shared/provider-daily-limits";
import { isMegaDebridResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors";
import { APP_VERSION, REQUEST_RETRIES } from "./constants";
import { DEEPBRID_ACCOUNT_ID, DeepbridClient } from "./deepbrid";
import { pruneAccountRuntimeSession, recordAccountRuntimeAttempt, recordAccountRuntimeFailure, recordAccountRuntimeSuccess, resetAccountRuntimeSessionForProvider } from "./account-runtime";
import { logger } from "./logger";
import { logAccountRotation } from "./account-rotation-log";
@@ -595,9 +596,10 @@ const PROVIDER_LABELS: Record<DebridProvider, string> = {
megadebrid: "Mega-Debrid",
"megadebrid-api": "Mega-Debrid API",
"megadebrid-web": "Mega-Debrid Web",
bestdebrid: "BestDebrid",
alldebrid: "AllDebrid",
ddownload: "DDownload",
bestdebrid: "BestDebrid",
alldebrid: "AllDebrid",
deepbrid: "Deepbrid",
ddownload: "DDownload",
onefichier: "1Fichier",
debridlink: "Debrid-Link",
linksnappy: "LinkSnappy"
@@ -4500,9 +4502,12 @@ export class DebridService {
if (effectiveProvider === "megadebrid-web") {
return Boolean(hasMegaDebridCredentials(settings) && isMegaDebridModeEnabled(settings, "web") && this.options.megaWebUnrestrict);
}
if (effectiveProvider === "alldebrid") {
return Boolean(this.shouldUseAllDebridWeb(settings) || settings.allDebridToken.trim());
}
if (effectiveProvider === "alldebrid") {
return Boolean(this.shouldUseAllDebridWeb(settings) || settings.allDebridToken.trim());
}
if (effectiveProvider === "deepbrid") {
return Boolean(settings.deepbridApiKey.trim());
}
if (effectiveProvider === "ddownload") {
return Boolean(settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim());
}
@@ -4652,7 +4657,7 @@ export class DebridService {
if (effectiveProvider === "megadebrid-web") {
return MegaDebridClient.unrestrictWithAccounts(settings, "web", false, link, this.options.megaWebUnrestrict, signal);
}
if (effectiveProvider === "alldebrid") {
if (effectiveProvider === "alldebrid") {
if (this.shouldUseAllDebridWeb(settings) && this.options.allDebridWebUnrestrict) {
const result = await this.options.allDebridWebUnrestrict(link, signal);
if (!result) {
@@ -4662,9 +4667,18 @@ export class DebridService {
return result;
}
const adResult = await new AllDebridClient(settings.allDebridToken).unrestrictLink(link, signal);
adResult.sourceLabel = "API";
return adResult;
}
adResult.sourceLabel = "API";
return adResult;
}
if (effectiveProvider === "deepbrid") {
const result = await new DeepbridClient(settings.deepbridApiKey).unrestrictLink(link, signal);
return {
...result,
sourceLabel: "API",
sourceAccountId: DEEPBRID_ACCOUNT_ID,
sourceAccountLabel: "Deepbrid API"
};
}
if (effectiveProvider === "ddownload") {
return this.getDdownloadClient(settings.ddownloadLogin, settings.ddownloadPassword).unrestrictLink(link, signal);
}
+33 -15
View File
@@ -12,7 +12,7 @@ import { getSessionLogPath } from "./session-log";
import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
import { getRenameLogPath } from "./rename-log";
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, normalizeNotificationSupportPayload, summarizeHistoryEntry, type NotificationSupportPayload } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, setTraceEnabled, updateTraceConfig } from "./trace-log";
import { getConversionLogPath } from "./conversion-trace";
@@ -56,7 +56,8 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
{ method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." },
{ method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." },
{ method: "GET", path: "/providers", description: "Live provider runtime state: per-account/key cooldowns (until/remaining/reason/category), in-flight depth, Mega rotation cursor, empty-response streaks. The 'why is it cooling down right now' view." },
{ method: "GET", path: "/stats", description: "Returns live session stats plus persisted all-time totals." },
{ method: "GET", path: "/stats", description: "Returns live session stats plus persisted all-time totals." },
{ method: "GET", path: "/notifications", description: "Returns safe notification delivery and incident aggregates." },
{ method: "GET", path: "/history", queryExample: "limit=50&status=completed", description: "Returns history entries with optional filters." },
{ method: "GET", path: "/status", description: "Returns a live high-level status overview." },
{ method: "GET", path: "/packages", queryExample: "package=Release&includeItems=1", description: "Lists packages and optional per-item detail." },
@@ -74,6 +75,7 @@ let bindPort = DEFAULT_PORT;
let runtimeBaseDir = "";
let allowlist: string[] = [];
let requestLimits = new Map<string, { startedAt: number; count: number }>();
let notificationStatusProvider: (() => NotificationSupportPayload) | null = null;
export interface DebugServerRuntimeStatus {
running: boolean;
@@ -92,9 +94,13 @@ function readSupportSettings() {
return loadSettings(getStoragePaths());
}
function readSupportHistory() {
return loadHistory(getStoragePaths());
}
function readSupportHistory() {
return loadHistory(getStoragePaths());
}
function readNotificationStatus(): NotificationSupportPayload {
return normalizeNotificationSupportPayload(notificationStatusProvider?.());
}
function extractDebugClientIp(req: http.IncomingMessage): string {
const forwarded = req.headers["x-forwarded-for"];
@@ -908,7 +914,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return;
}
if (pathname === "/stats") {
if (pathname === "/stats") {
if (!manager) {
jsonResponse(res, 503, { error: "Manager not initialized" });
return;
@@ -923,8 +929,13 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
}
});
return;
}
return;
}
if (pathname === "/notifications") {
jsonResponse(res, 200, readNotificationStatus());
return;
}
if (pathname === "/history") {
const entries = readSupportHistory();
@@ -1046,7 +1057,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return;
}
const fileName = getSupportBundleDefaultFileName();
buildSupportBundle(manager, runtimeBaseDir)
buildSupportBundle(manager, runtimeBaseDir, { notificationStatus: readNotificationStatus() })
.then((body) => {
logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", {
fileName,
@@ -1088,7 +1099,8 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
},
status: buildStatusPayload(snapshot),
settings: buildRedactedSettingsPayload(readSupportSettings()),
stats: buildStatsPayload(snapshot),
stats: buildStatsPayload(snapshot),
notifications: readNotificationStatus(),
accounts: buildAccountSummary(readSupportSettings()),
providers: getProviderRuntimeSnapshot(),
history: {
@@ -1184,11 +1196,16 @@ function openServerSocket(): Promise<void> {
});
}
export function startDebugServer(mgr: DownloadManager, baseDir: string): void {
runtimeBaseDir = baseDir;
manager = mgr;
void openServerSocket();
}
export function startDebugServer(
mgr: DownloadManager,
baseDir: string,
readCurrentNotificationStatus?: () => NotificationSupportPayload
): void {
runtimeBaseDir = baseDir;
manager = mgr;
notificationStatusProvider = readCurrentNotificationStatus || null;
void openServerSocket();
}
export async function restartDebugServer(): Promise<DebugServerRuntimeStatus> {
const old = server;
@@ -1256,6 +1273,7 @@ export function clearDebugToken(): void {
}
export function stopDebugServer(): void {
notificationStatusProvider = null;
if (server) {
server.close();
try {
+394
View File
@@ -0,0 +1,394 @@
import type { UnrestrictedLink } from "./realdebrid";
export const DEEPBRID_ACCOUNT_ID = "svc-deepbrid";
const API_BASE_URL = "https://www.deepbrid.com/api/v1";
const MAX_ATTEMPTS = 3;
const REQUEST_TIMEOUT_MS = 30000;
const MAX_RETRY_DELAY_MS = 30000;
export type DeepbridErrorClassification = "auth" | "rate_limit" | "temporary" | "link" | "malformed";
export class DeepbridApiError extends Error {
public readonly status: number;
public readonly code: number;
public readonly classification: DeepbridErrorClassification;
public constructor(status: number, code: number, classification: DeepbridErrorClassification) {
super(`Deepbrid-Anfrage fehlgeschlagen (${classification}, HTTP ${status}, Code ${code})`);
this.name = "DeepbridApiError";
this.status = status;
this.code = code;
this.classification = classification;
}
}
export interface DeepbridUserInfo {
username: string;
email: string;
type: string;
expiration: string;
maxDownloads: number;
maxConnections: number;
}
export interface DeepbridHostInfo {
domain: string;
status: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function malformedError(status = 200): DeepbridApiError {
return new DeepbridApiError(status, status || 0, "malformed");
}
function parseErrorCode(payload: unknown, status: number): number {
if (!isRecord(payload)) {
return status;
}
const value = Number(payload.error ?? payload.code ?? status);
return Number.isFinite(value) ? Math.trunc(value) : status;
}
function parseRetryAfterMs(value: string | null): number | null {
const text = String(value || "").trim();
if (!text) {
return null;
}
const seconds = Number(text);
if (Number.isFinite(seconds) && seconds >= 0) {
return Math.min(MAX_RETRY_DELAY_MS, Math.floor(seconds * 1000));
}
const timestamp = Date.parse(text);
if (!Number.isFinite(timestamp)) {
return null;
}
return Math.min(MAX_RETRY_DELAY_MS, Math.max(0, timestamp - Date.now()));
}
function defaultRetryDelayMs(attempt: number): number {
return Math.min(MAX_RETRY_DELAY_MS, 250 * 2 ** (attempt - 1));
}
function callerAbortReason(signal: AbortSignal): unknown {
return signal.reason ?? new DOMException("The operation was aborted", "AbortError");
}
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
throw callerAbortReason(signal);
}
await new Promise<void>((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | undefined;
const onAbort = (): void => {
if (timer !== undefined) {
clearTimeout(timer);
}
signal?.removeEventListener("abort", onAbort);
reject(signal ? callerAbortReason(signal) : new DOMException("The operation was aborted", "AbortError"));
};
timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
function requestSignal(signal?: AbortSignal): AbortSignal {
const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
}
async function parseJson(response: Response): Promise<unknown> {
const mediaType = String(response.headers.get("content-type") || "")
.split(";", 1)[0]
.trim()
.toLowerCase();
const isJson = mediaType === "application/json"
|| /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+\+json$/.test(mediaType);
if (!isJson) {
throw malformedError(response.status);
}
let payload: unknown;
try {
payload = await response.json();
} catch (error) {
if (error instanceof SyntaxError) {
throw malformedError(response.status);
}
throw error;
}
if (!isRecord(payload) && !Array.isArray(payload)) {
throw malformedError(response.status);
}
return payload;
}
function classifyHttpError(status: number, payload: unknown, linkRequest: boolean): DeepbridApiError {
const code = parseErrorCode(payload, status);
if (status === 401 || status === 403) {
return new DeepbridApiError(status, code, "auth");
}
if (status === 429) {
return new DeepbridApiError(status, code, "rate_limit");
}
if (status >= 500) {
return new DeepbridApiError(status, code, "temporary");
}
if (linkRequest && isRecord(payload)) {
const structured = Number.isFinite(Number(payload.error ?? payload.code)) || typeof payload.message === "string";
if (structured) {
return new DeepbridApiError(status, code, "link");
}
}
return malformedError(status);
}
function isRetryable(error: DeepbridApiError): boolean {
return error.classification === "rate_limit" || error.classification === "temporary";
}
function validateUser(payload: unknown): DeepbridUserInfo {
if (!isRecord(payload)
|| typeof payload.username !== "string"
|| typeof payload.email !== "string"
|| typeof payload.type !== "string"
|| typeof payload.expiration !== "string"
|| !Number.isFinite(payload.maxDownloads)
|| !Number.isFinite(payload.maxConnections)) {
throw malformedError();
}
return {
username: payload.username,
email: payload.email,
type: payload.type,
expiration: payload.expiration,
maxDownloads: Number(payload.maxDownloads),
maxConnections: Number(payload.maxConnections)
};
}
function validateHosts(payload: unknown): DeepbridHostInfo[] {
if (!Array.isArray(payload)) {
throw malformedError();
}
const hosts: DeepbridHostInfo[] = [];
for (const entry of payload) {
if (typeof entry === "string") {
const domain = entry.trim();
if (!domain) {
throw malformedError();
}
hosts.push({ domain, status: "unknown" });
continue;
}
if (!isRecord(entry)) {
throw malformedError();
}
if (typeof entry.domain === "string" && typeof entry.status === "string") {
const domain = entry.domain.trim();
const status = entry.status.trim();
if (!domain || !status) {
throw malformedError();
}
hosts.push({ domain, status });
continue;
}
const pairs = Object.entries(entry);
if (pairs.length === 0 || pairs.some(([domain, status]) => !domain.trim() || typeof status !== "string" || !status.trim())) {
throw malformedError();
}
hosts.push(...pairs.map(([domain, status]) => ({ domain: domain.trim(), status: String(status).trim() })));
}
return hosts;
}
function safeBaseName(value: string): string | null {
const baseName = value
.replace(/[\u0000-\u001f\u007f]/g, "")
.split(/[\\/]/)
.filter((part) => part && part !== "." && part !== "..")
.at(-1);
if (!baseName) {
return null;
}
const sanitized = baseName
.replace(/[<>:"/\\|?*]/g, "_")
.replace(/[. ]+$/g, "");
if (!sanitized) {
return null;
}
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(sanitized)
? `_${sanitized}`
: sanitized;
}
function fallbackFileName(url: URL): string {
const segment = url.pathname.split("/").filter(Boolean).at(-1) || "";
if (!segment) {
return "download.bin";
}
try {
return safeBaseName(decodeURIComponent(segment)) || "download.bin";
} catch {
return safeBaseName(segment) || "download.bin";
}
}
function validateUnrestrictedLink(payload: unknown, retriesUsed: number): UnrestrictedLink {
if (!isRecord(payload)) {
throw malformedError();
}
const apiCode = Number(payload.error ?? 0);
if (Number.isFinite(apiCode) && apiCode !== 0) {
throw new DeepbridApiError(200, Math.trunc(apiCode), "link");
}
if (typeof payload.link !== "string" || !payload.link.trim()) {
throw malformedError();
}
let parsedUrl: URL;
try {
parsedUrl = new URL(payload.link.trim());
} catch {
throw malformedError();
}
if ((parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") || parsedUrl.username || parsedUrl.password) {
throw malformedError();
}
const providedName = typeof payload.filename === "string" ? safeBaseName(payload.filename) : null;
return {
fileName: providedName || fallbackFileName(parsedUrl),
directUrl: parsedUrl.toString(),
fileSize: parseDeepbridSize(payload.size),
retriesUsed
};
}
export function parseDeepbridSize(value: unknown): number | null {
if (typeof value === "number") {
return Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? Math.floor(value) : null;
}
if (typeof value !== "string") {
return null;
}
const match = value.trim().match(/^(\d+(?:[.,]\d+)?)\s*(B|KB|MB|GB|TB|KIB|MIB|GIB|TIB)$/i);
if (!match) {
return null;
}
const amount = Number(match[1].replace(",", "."));
const unit = match[2].toUpperCase();
const exponents: Record<string, number> = { B: 0, KB: 1, KIB: 1, MB: 2, MIB: 2, GB: 3, GIB: 3, TB: 4, TIB: 4 };
const exponent = exponents[unit];
if (exponent === undefined) {
return null;
}
const bytes = amount * 1024 ** exponent;
return Number.isFinite(bytes) && bytes >= 0 && bytes <= Number.MAX_SAFE_INTEGER ? Math.floor(bytes) : null;
}
export class DeepbridClient {
private readonly apiKey: string;
public constructor(apiKey: string) {
this.apiKey = String(apiKey || "").trim();
}
public async getUser(signal?: AbortSignal): Promise<DeepbridUserInfo> {
return this.request("/user", { method: "GET" }, validateUser, signal, false, true);
}
public async getHosts(signal?: AbortSignal): Promise<DeepbridHostInfo[]> {
return this.request("/hosts", { method: "GET" }, validateHosts, signal, false, false);
}
public async unrestrictLink(link: string, signal?: AbortSignal, password?: string): Promise<UnrestrictedLink> {
const body = new URLSearchParams({ link });
if (password) {
body.set("pass", password);
}
return this.request(
"/generate/link",
{ method: "POST", body },
validateUnrestrictedLink,
signal,
true,
true
);
}
private async request<T>(
path: string,
init: { method: "GET" | "POST"; body?: URLSearchParams },
validate: (payload: unknown, retriesUsed: number) => T,
signal: AbortSignal | undefined,
linkRequest: boolean,
authenticated: boolean
): Promise<T> {
if (authenticated && !this.apiKey) {
throw new DeepbridApiError(401, 401, "auth");
}
if (signal?.aborted) {
throw callerAbortReason(signal);
}
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
try {
const headers: Record<string, string> = { Accept: "application/json" };
if (authenticated) {
headers.Authorization = `Bearer ${this.apiKey}`;
}
if (init.method === "POST") {
headers["Content-Type"] = "application/x-www-form-urlencoded";
}
const response = await fetch(`${API_BASE_URL}${path}`, {
method: init.method,
headers,
body: init.body,
signal: requestSignal(signal)
});
if (signal?.aborted) {
throw callerAbortReason(signal);
}
let payload: unknown = null;
try {
payload = await parseJson(response);
} catch (error) {
if (response.ok || (response.status < 500 && response.status !== 429 && response.status !== 401 && response.status !== 403)) {
throw error;
}
}
if (signal?.aborted) {
throw callerAbortReason(signal);
}
if (response.ok) {
return validate(payload, attempt - 1);
}
const error = classifyHttpError(response.status, payload, linkRequest);
if (isRetryable(error) && attempt < MAX_ATTEMPTS) {
const delay = response.status === 429
? parseRetryAfterMs(response.headers.get("retry-after")) ?? defaultRetryDelayMs(attempt)
: defaultRetryDelayMs(attempt);
await sleepWithSignal(delay, signal);
continue;
}
throw error;
} catch (error) {
if (signal?.aborted) {
throw callerAbortReason(signal);
}
if (error instanceof DeepbridApiError) {
throw error;
}
const transportError = new DeepbridApiError(0, 0, "temporary");
if (attempt >= MAX_ATTEMPTS) {
throw transportError;
}
await sleepWithSignal(defaultRetryDelayMs(attempt), signal);
}
}
throw new DeepbridApiError(0, 0, "temporary");
}
}
+532
View File
@@ -0,0 +1,532 @@
import fs from "node:fs";
import path from "node:path";
import type { NotificationEvent } from "./notification-outbox";
export type DownloadHealthStatus =
| "idle"
| "suspended"
| "expected_wait"
| "healthy"
| "suspect_scheduler"
| "suspect_no_data"
| "alerted"
| "recovering";
export type DownloadHealthIncidentType = "scheduler" | "no_data";
export interface DownloadHealthSnapshot {
runActive: boolean;
runFingerprint: string;
queueFingerprint: string;
openItems: number;
openPackages: number;
knownDownloadedBytes: number;
activeTasks: number;
startableItems: number;
lastSchedulerTickAt: number;
downloadProgressSequence: number;
itemCompletionSequence: number;
lastPositiveByteAt: number;
technicalRecoveryCount: number;
paused: boolean;
reconnectUntil: number;
nextRetryAt: number;
providerCooldownUntil: number;
blockedOnDisk: boolean;
blockedOnThrottleUntil: number;
activePhaseDeadlineAt: number;
terminalFailure: boolean;
manualStop: boolean;
shuttingDown: boolean;
currentSpeedBps: number;
}
export interface DownloadHealthState {
version: 1;
status: DownloadHealthStatus;
runFingerprint: string | null;
queueFingerprint: string | null;
suspiciousDurationMs: number;
suspiciousSamples: number;
incidentStartedAt: number;
incidentType: DownloadHealthIncidentType | null;
alertedAt: number;
lastAlertAt: number;
cooldownUntil: number;
lastDeliveredStallEventId: string | null;
recoverySamples: number;
lastSampleAt: number | null;
downloadProgressSequence: number;
itemCompletionSequence: number;
lastPositiveByteAt: number;
technicalRecoveryCount: number;
restartPending: boolean;
restartFreshSamples: number;
}
export interface DownloadHealthOptions {
stallAfterMs: number;
cooldownMs: number;
notifyOnStall: boolean;
notifyOnRecovery: boolean;
}
export interface DownloadHealthEvaluation {
state: DownloadHealthState;
events: NotificationEvent[];
}
const HEALTH_STATUSES = new Set<DownloadHealthStatus>([
"idle",
"suspended",
"expected_wait",
"healthy",
"suspect_scheduler",
"suspect_no_data",
"alerted",
"recovering"
]);
const INCIDENT_TYPES = new Set<DownloadHealthIncidentType>(["scheduler", "no_data"]);
const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
const STALL_EVENT_ID_PATTERN = /^health:stall:[a-f0-9]{16}:\d+$/;
const MIN_SUSPICIOUS_SAMPLES = 3;
const SCHEDULER_STALE_AFTER_MS = 30_000;
const ERROR_EVENT_TTL_MS = 24 * 60 * 60 * 1000;
const SUCCESS_EVENT_TTL_MS = 6 * 60 * 60 * 1000;
function finiteInteger(value: unknown, fallback = 0): number {
const numeric = Number(value);
return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : fallback;
}
function validFingerprint(value: unknown): string | null {
return typeof value === "string" && FINGERPRINT_PATTERN.test(value) ? value : null;
}
function validStallEventId(value: unknown): string | null {
return typeof value === "string" && STALL_EVENT_ID_PATTERN.test(value) ? value : null;
}
function durationText(durationMs: number): string {
const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
if (totalSeconds < 60) {
return `${totalSeconds} s`;
}
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return seconds > 0 ? `${minutes} min ${seconds} s` : `${minutes} min`;
}
function byteText(bytes: number): string {
const value = Math.max(0, finiteInteger(bytes));
if (value < 1024) {
return `${value} B`;
}
const units = ["KB", "MB", "GB", "TB"];
let amount = value / 1024;
let unit = units[0];
for (let index = 1; index < units.length && amount >= 1024; index += 1) {
amount /= 1024;
unit = units[index];
}
return `${amount >= 10 ? amount.toFixed(0) : amount.toFixed(1)} ${unit}`;
}
function incidentEvent(
state: DownloadHealthState,
snapshot: DownloadHealthSnapshot,
now: number
): NotificationEvent {
const durationMs = Math.max(state.suspiciousDurationMs, now - state.incidentStartedAt);
return {
id: `health:stall:${snapshot.runFingerprint.slice(0, 16)}:${state.incidentStartedAt}`,
type: "download_stalled",
priority: "error",
createdAt: now,
expiresAt: now + ERROR_EVENT_TTL_MS,
attempts: 0,
nextAttemptAt: now,
payload: {
title: "Downloadstillstand bestätigt",
description: `Seit ${durationText(durationMs)} wurde kein bestätigter Downloadfortschritt erkannt.`,
color: 0xe67e22,
fields: [
{ name: "Dauer", value: durationText(durationMs), inline: true },
{ name: "Offene Pakete", value: String(snapshot.openPackages), inline: true },
{ name: "Offene Dateien", value: String(snapshot.openItems), inline: true },
{ name: "Bekannte Mindestmenge", value: byteText(snapshot.knownDownloadedBytes), inline: true },
{ name: "Aktiv / startfähig", value: `${snapshot.activeTasks} / ${snapshot.startableItems}`, inline: true },
{ name: "Technische Wiederherstellungen", value: String(snapshot.technicalRecoveryCount), inline: true }
]
}
};
}
function recoveryEvent(
state: DownloadHealthState,
snapshot: DownloadHealthSnapshot,
now: number
): NotificationEvent {
return {
id: `health:recovery:${snapshot.runFingerprint.slice(0, 16)}:${state.alertedAt}`,
type: "download_recovered",
priority: "success",
createdAt: now,
expiresAt: now + SUCCESS_EVENT_TTL_MS,
attempts: 0,
nextAttemptAt: now,
payload: {
title: "Download läuft wieder",
description: "Nach dem bestätigten Stillstand wurde neuer Fortschritt erkannt.",
color: 0x2ecc71,
fields: [
{ name: "Incident-Dauer", value: durationText(Math.max(0, now - state.incidentStartedAt)), inline: true },
{ name: "Aktive Downloads", value: String(snapshot.activeTasks), inline: true },
{ name: "Aktuelle Geschwindigkeit", value: `${byteText(snapshot.currentSpeedBps)}/s`, inline: true }
]
}
};
}
export function createDownloadHealthState(overrides: Partial<DownloadHealthState> = {}): DownloadHealthState {
return {
version: 1,
status: "idle",
runFingerprint: null,
queueFingerprint: null,
suspiciousDurationMs: 0,
suspiciousSamples: 0,
incidentStartedAt: 0,
incidentType: null,
alertedAt: 0,
lastAlertAt: 0,
cooldownUntil: 0,
lastDeliveredStallEventId: null,
recoverySamples: 0,
lastSampleAt: null,
downloadProgressSequence: 0,
itemCompletionSequence: 0,
lastPositiveByteAt: 0,
technicalRecoveryCount: 0,
restartPending: false,
restartFreshSamples: 0,
...overrides
};
}
function resetForSnapshot(
state: DownloadHealthState,
snapshot: DownloadHealthSnapshot,
now: number
): DownloadHealthState {
return createDownloadHealthState({
runFingerprint: snapshot.runFingerprint,
queueFingerprint: snapshot.queueFingerprint,
lastAlertAt: state.lastAlertAt,
cooldownUntil: state.cooldownUntil,
lastDeliveredStallEventId: state.lastDeliveredStallEventId,
lastSampleAt: now,
downloadProgressSequence: finiteInteger(snapshot.downloadProgressSequence),
itemCompletionSequence: finiteInteger(snapshot.itemCompletionSequence),
lastPositiveByteAt: finiteInteger(snapshot.lastPositiveByteAt),
technicalRecoveryCount: finiteInteger(snapshot.technicalRecoveryCount)
});
}
function endIncident(state: DownloadHealthState): DownloadHealthState {
return createDownloadHealthState({
lastAlertAt: state.lastAlertAt,
cooldownUntil: state.cooldownUntil,
lastDeliveredStallEventId: state.lastDeliveredStallEventId,
downloadProgressSequence: state.downloadProgressSequence,
itemCompletionSequence: state.itemCompletionSequence,
lastPositiveByteAt: state.lastPositiveByteAt,
technicalRecoveryCount: state.technicalRecoveryCount
});
}
function expectedWait(snapshot: DownloadHealthSnapshot, now: number): boolean {
return snapshot.paused
|| snapshot.reconnectUntil > now
|| snapshot.nextRetryAt > now
|| snapshot.providerCooldownUntil > now
|| snapshot.blockedOnDisk
|| snapshot.blockedOnThrottleUntil > now
|| snapshot.activePhaseDeadlineAt > now;
}
function suspicionType(snapshot: DownloadHealthSnapshot, now: number): DownloadHealthIncidentType | null {
if (snapshot.activeTasks === 0 && snapshot.startableItems > 0) {
if (snapshot.lastSchedulerTickAt <= 0 || now - snapshot.lastSchedulerTickAt >= SCHEDULER_STALE_AFTER_MS) {
return "scheduler";
}
return null;
}
return "no_data";
}
export function evaluateDownloadHealth(
previous: DownloadHealthState,
snapshot: DownloadHealthSnapshot,
nowValue: number,
options: DownloadHealthOptions
): DownloadHealthEvaluation {
const now = finiteInteger(nowValue);
let state = createDownloadHealthState(previous);
const events: NotificationEvent[] = [];
const runFingerprint = validFingerprint(snapshot.runFingerprint);
const queueFingerprint = validFingerprint(snapshot.queueFingerprint);
if (state.restartPending && !snapshot.runActive && !snapshot.terminalFailure && !snapshot.manualStop && !snapshot.shuttingDown) {
state.status = "suspended";
state.lastSampleAt = null;
return { state, events };
}
if (!snapshot.runActive || snapshot.openItems <= 0 || snapshot.terminalFailure || snapshot.manualStop || snapshot.shuttingDown || !runFingerprint || !queueFingerprint) {
state.downloadProgressSequence = Math.max(state.downloadProgressSequence, finiteInteger(snapshot.downloadProgressSequence));
state.itemCompletionSequence = Math.max(state.itemCompletionSequence, finiteInteger(snapshot.itemCompletionSequence));
state.lastPositiveByteAt = Math.max(state.lastPositiveByteAt, finiteInteger(snapshot.lastPositiveByteAt));
state.technicalRecoveryCount = Math.max(state.technicalRecoveryCount, finiteInteger(snapshot.technicalRecoveryCount));
return { state: endIncident(state), events };
}
if (state.runFingerprint !== runFingerprint || state.queueFingerprint !== queueFingerprint) {
state = resetForSnapshot(state, snapshot, now);
}
state.runFingerprint = runFingerprint;
state.queueFingerprint = queueFingerprint;
if (state.restartPending) {
state.restartFreshSamples += 1;
state.lastSampleAt = now;
if (state.restartFreshSamples < 2) {
state.downloadProgressSequence = finiteInteger(snapshot.downloadProgressSequence);
state.itemCompletionSequence = finiteInteger(snapshot.itemCompletionSequence);
state.lastPositiveByteAt = finiteInteger(snapshot.lastPositiveByteAt);
state.technicalRecoveryCount = finiteInteger(snapshot.technicalRecoveryCount);
return { state, events };
}
state.restartPending = false;
}
const progressSequence = finiteInteger(snapshot.downloadProgressSequence);
const completionSequence = finiteInteger(snapshot.itemCompletionSequence);
const positiveByteProgress = progressSequence > state.downloadProgressSequence;
const completionProgress = completionSequence > state.itemCompletionSequence;
const progress = positiveByteProgress || completionProgress;
state.downloadProgressSequence = Math.max(state.downloadProgressSequence, progressSequence);
state.itemCompletionSequence = Math.max(state.itemCompletionSequence, completionSequence);
state.lastPositiveByteAt = Math.max(state.lastPositiveByteAt, finiteInteger(snapshot.lastPositiveByteAt));
state.technicalRecoveryCount = Math.max(state.technicalRecoveryCount, finiteInteger(snapshot.technicalRecoveryCount));
if (state.alertedAt > 0 && progress) {
state.recoverySamples = completionProgress ? 2 : state.recoverySamples + 1;
if (state.recoverySamples >= 2) {
if (options.notifyOnRecovery) {
events.push(recoveryEvent(state, snapshot, now));
}
const recovered = resetForSnapshot(state, snapshot, now);
recovered.status = "healthy";
recovered.lastAlertAt = state.lastAlertAt;
recovered.cooldownUntil = state.cooldownUntil;
return { state: recovered, events };
}
state.status = "recovering";
state.lastSampleAt = now;
return { state, events };
}
if (state.alertedAt > 0) {
if (expectedWait(snapshot, now)) {
state.status = "expected_wait";
} else {
state.status = "alerted";
}
state.lastSampleAt = now;
return { state, events };
}
if (expectedWait(snapshot, now)) {
state.status = "expected_wait";
state.lastSampleAt = now;
return { state, events };
}
if (progress) {
state.status = "healthy";
state.suspiciousDurationMs = 0;
state.suspiciousSamples = 0;
state.incidentStartedAt = 0;
state.incidentType = null;
state.recoverySamples = 0;
state.lastSampleAt = now;
return { state, events };
}
const incidentType = suspicionType(snapshot, now);
if (!incidentType) {
state.status = "healthy";
state.suspiciousDurationMs = 0;
state.suspiciousSamples = 0;
state.incidentStartedAt = 0;
state.incidentType = null;
state.lastSampleAt = now;
return { state, events };
}
const elapsed = state.lastSampleAt === null ? 0 : Math.max(0, now - state.lastSampleAt);
if (state.incidentStartedAt <= 0) {
state.incidentStartedAt = now;
}
state.suspiciousDurationMs += elapsed;
state.suspiciousSamples += 1;
state.incidentType = incidentType;
state.status = incidentType === "scheduler" ? "suspect_scheduler" : "suspect_no_data";
state.lastSampleAt = now;
const stallAfterMs = Math.max(0, finiteInteger(options.stallAfterMs, 90_000));
const confirmed = state.suspiciousDurationMs >= stallAfterMs
&& state.suspiciousSamples >= MIN_SUSPICIOUS_SAMPLES;
if (confirmed && options.notifyOnStall && now >= state.cooldownUntil) {
state.status = "alerted";
state.alertedAt = now;
state.recoverySamples = 0;
events.push(incidentEvent(state, snapshot, now));
}
return { state, events };
}
function persistedState(state: DownloadHealthState): Omit<DownloadHealthState, "restartPending" | "restartFreshSamples"> {
return {
version: 1,
status: state.status,
runFingerprint: state.runFingerprint,
queueFingerprint: state.queueFingerprint,
suspiciousDurationMs: finiteInteger(state.suspiciousDurationMs),
suspiciousSamples: finiteInteger(state.suspiciousSamples),
incidentStartedAt: finiteInteger(state.incidentStartedAt),
incidentType: state.incidentType,
alertedAt: finiteInteger(state.alertedAt),
lastAlertAt: finiteInteger(state.lastAlertAt),
cooldownUntil: finiteInteger(state.cooldownUntil),
lastDeliveredStallEventId: validStallEventId(state.lastDeliveredStallEventId),
recoverySamples: finiteInteger(state.recoverySamples),
lastSampleAt: state.lastSampleAt === null ? null : finiteInteger(state.lastSampleAt),
downloadProgressSequence: finiteInteger(state.downloadProgressSequence),
itemCompletionSequence: finiteInteger(state.itemCompletionSequence),
lastPositiveByteAt: finiteInteger(state.lastPositiveByteAt),
technicalRecoveryCount: finiteInteger(state.technicalRecoveryCount)
};
}
export function saveDownloadHealthState(filePath: string, state: DownloadHealthState): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.tmp`;
try {
fs.writeFileSync(tempPath, JSON.stringify(persistedState(state)), "utf8");
fs.renameSync(tempPath, filePath);
} catch (error) {
try {
fs.rmSync(tempPath, { force: true });
} catch {
}
throw error;
}
}
export function loadDownloadHealthState(filePath: string): DownloadHealthState {
try {
const raw = JSON.parse(fs.readFileSync(filePath, "utf8")) as Record<string, unknown>;
const runFingerprint = raw.runFingerprint === null ? null : validFingerprint(raw.runFingerprint);
const queueFingerprint = raw.queueFingerprint === null ? null : validFingerprint(raw.queueFingerprint);
if (raw.version !== 1 || !HEALTH_STATUSES.has(raw.status as DownloadHealthStatus)) {
return createDownloadHealthState();
}
if ((raw.runFingerprint !== null && !runFingerprint) || (raw.queueFingerprint !== null && !queueFingerprint)) {
return createDownloadHealthState();
}
const incidentType = INCIDENT_TYPES.has(raw.incidentType as DownloadHealthIncidentType)
? raw.incidentType as DownloadHealthIncidentType
: null;
return createDownloadHealthState({
status: raw.status as DownloadHealthStatus,
runFingerprint,
queueFingerprint,
suspiciousDurationMs: finiteInteger(raw.suspiciousDurationMs),
suspiciousSamples: finiteInteger(raw.suspiciousSamples),
incidentStartedAt: finiteInteger(raw.incidentStartedAt),
incidentType,
alertedAt: finiteInteger(raw.alertedAt),
lastAlertAt: finiteInteger(raw.lastAlertAt),
cooldownUntil: finiteInteger(raw.cooldownUntil),
lastDeliveredStallEventId: validStallEventId(raw.lastDeliveredStallEventId),
recoverySamples: finiteInteger(raw.recoverySamples),
lastSampleAt: null,
downloadProgressSequence: finiteInteger(raw.downloadProgressSequence),
itemCompletionSequence: finiteInteger(raw.itemCompletionSequence),
lastPositiveByteAt: finiteInteger(raw.lastPositiveByteAt),
technicalRecoveryCount: finiteInteger(raw.technicalRecoveryCount),
restartPending: Boolean(runFingerprint && queueFingerprint),
restartFreshSamples: 0
});
} catch {
return createDownloadHealthState();
}
}
export class DownloadHealthMonitor {
private state: DownloadHealthState;
private operationChain: Promise<void> = Promise.resolve();
public constructor(private readonly filePath: string, initialState?: DownloadHealthState) {
this.state = initialState ? createDownloadHealthState(initialState) : loadDownloadHealthState(filePath);
}
public getState(): DownloadHealthState {
return createDownloadHealthState(this.state);
}
public acknowledgeDelivery(event: NotificationEvent, deliveredAt: number, cooldownMs: number): Promise<void> {
return this.runExclusive(async () => {
const eventId = validStallEventId(event.id);
if (event.type !== "download_stalled" || !eventId || this.state.lastDeliveredStallEventId === eventId) {
return;
}
const acknowledgedAt = finiteInteger(deliveredAt);
this.state = createDownloadHealthState({
...this.state,
lastAlertAt: acknowledgedAt,
cooldownUntil: acknowledgedAt + finiteInteger(cooldownMs),
lastDeliveredStallEventId: eventId
});
saveDownloadHealthState(this.filePath, this.state);
});
}
public sample(
snapshot: DownloadHealthSnapshot,
now: number,
options: DownloadHealthOptions,
enqueue: (event: NotificationEvent) => Promise<void>
): Promise<DownloadHealthEvaluation> {
return this.runExclusive(async () => {
const evaluation = evaluateDownloadHealth(this.state, snapshot, now, options);
for (const event of evaluation.events) {
await enqueue(event);
}
saveDownloadHealthState(this.filePath, evaluation.state);
this.state = evaluation.state;
return evaluation;
});
}
private runExclusive<T>(operation: () => Promise<T>): Promise<T> {
const result = this.operationChain.then(operation, operation);
this.operationChain = result.then(() => undefined, () => undefined);
return result;
}
}
+1324 -481
View File
File diff suppressed because it is too large Load Diff
+121 -31
View File
@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, nativeTheme, safeStorage, shell, Tray, type IpcMainEvent, type IpcMainInvokeEvent } from "electron";
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, nativeTheme, powerMonitor, safeStorage, shell, Tray, type IpcMainEvent, type IpcMainInvokeEvent } from "electron";
import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, RendererSettingsUpdate, UpdateInstallProgress } from "../shared/types";
import { AppController } from "./app-controller";
import { IPC_CHANNELS } from "../shared/ipc";
@@ -25,6 +25,7 @@ import { validateRealDebridLoginRequest } from "../shared/preload-api";
import { migrateProductUserDataDirectory } from "./storage";
import { forceDarkNativeTheme } from "./native-theme";
import { validateClipboardWriteText } from "./clipboard-write";
import { DailyStartScheduler, hasDailyStartRulePatch, prepareDailyStartSettingsPatch } from "./daily-start-scheduler";
forceDarkNativeTheme(nativeTheme);
@@ -48,9 +49,10 @@ const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([
"realdebrid",
"megadebrid-api",
"megadebrid-web",
"bestdebrid",
"alldebrid",
"ddownload",
"bestdebrid",
"alldebrid",
"deepbrid",
"ddownload",
"onefichier",
"debridlink",
"linksnappy"
@@ -89,12 +91,74 @@ process.on("warning", (warning) => {
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let clipboardTimer: ReturnType<typeof setInterval> | null = null;
let updateQuitTimer: ReturnType<typeof setTimeout> | null = null;
let scheduledStartTimer: ReturnType<typeof setTimeout> | null = null;
let updateQuitTimer: ReturnType<typeof setTimeout> | null = null;
let scheduledStartTimer: ReturnType<typeof setTimeout> | null = null;
let dailyStartScheduler: DailyStartScheduler | null = null;
let lastClipboardText = "";
let controller: AppController;
let pendingBackupImport: Buffer | null = null;
const CLIPBOARD_MAX_TEXT_CHARS = 50_000;
const CLIPBOARD_MAX_TEXT_CHARS = 50_000;
function reconcileDailyStart(source: string): void {
void dailyStartScheduler?.reconcile().catch((error) => {
logger.warn(`Täglicher Start konnte nach ${source} nicht abgeglichen werden: ${String(error)}`);
});
}
function handlePowerSuspend(): void {
reconcileDailyStart("Suspend");
}
function handlePowerResume(): void {
reconcileDailyStart("Resume");
}
export function cleanupSchedulerLifecycle(
scheduler: Pick<DailyStartScheduler, "end"> | null,
legacyTimer: ReturnType<typeof setTimeout> | null
): void {
scheduler?.end();
if (legacyTimer !== null) {
clearTimeout(legacyTimer);
}
powerMonitor.removeListener("suspend", handlePowerSuspend);
powerMonitor.removeListener("resume", handlePowerResume);
}
export interface BeforeQuitHandlerOptions {
cleanup: () => void;
shutdown: () => Promise<void>;
continueQuit: () => void;
onError: (error: unknown) => void;
}
export function createBeforeQuitHandler(options: BeforeQuitHandlerOptions): (event: { preventDefault: () => void }) => void {
let shutdownStarted = false;
let quitAllowed = false;
return (event) => {
if (quitAllowed) {
return;
}
event.preventDefault();
if (shutdownStarted) {
return;
}
shutdownStarted = true;
let shutdown: Promise<void>;
try {
options.cleanup();
shutdown = options.shutdown();
} catch (error) {
shutdown = Promise.reject(error);
}
void shutdown.catch((error) => {
options.onError(error);
}).finally(() => {
quitAllowed = true;
options.continueQuit();
});
};
}
function isDevMode(): boolean {
return process.env.NODE_ENV === "development";
@@ -413,13 +477,19 @@ function registerIpcHandlers(): void {
handleTrusted(IPC_CHANNELS.OPEN_EXTERNAL, async (_event: IpcMainInvokeEvent, rawUrl: string) => {
return openAllowedExternalUrl(String(rawUrl || "").trim(), MAIN_WINDOW_EXTERNAL_HOSTS);
});
handleTrusted(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: RendererSettingsUpdate) => {
const validated = validateRendererSettingsUpdate(partial ?? {}, controller.getSettings());
const result = controller.updateSettings(validated as Partial<AppSettings>);
handleTrusted(IPC_CHANNELS.UPDATE_SETTINGS, async (_event: IpcMainInvokeEvent, partial: RendererSettingsUpdate) => {
const currentSettings = controller.getSettings();
const validated = validateRendererSettingsUpdate(partial ?? {}, currentSettings);
const result = controller.updateSettings(prepareDailyStartSettingsPatch(validated, currentSettings) as Partial<AppSettings>);
updateClipboardWatcher();
updateTray();
armScheduledStart(result.scheduledStartEpochMs || 0, { startOnPast: true });
return createRendererSettings(result);
if (hasDailyStartRulePatch(validated)) {
await dailyStartScheduler?.reconcile();
} else {
reconcileDailyStart("Einstellungsänderung");
}
return createRendererSettings(controller.getSettings());
});
handleTrusted(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => {
const validatedProvider = validateString(provider, "provider") as DebridProvider;
@@ -436,28 +506,36 @@ function registerIpcHandlers(): void {
return createRendererSettings(controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId));
});
handleTrusted(IPC_CHANNELS.CREATE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
handleTrusted(IPC_CHANNELS.CREATE_ACCOUNT, async (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
const command = validateAccountCommand(rawCommand);
if (command.action !== "create") throw new Error("Account-Payload ist ungültig");
return controller.executeAccountCommand(command);
const result = await controller.executeAccountCommand(command);
reconcileDailyStart("Accountänderung");
return result;
});
handleTrusted(IPC_CHANNELS.REPLACE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
handleTrusted(IPC_CHANNELS.REPLACE_ACCOUNT, async (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
const command = validateAccountCommand(rawCommand);
if (command.action !== "replace") throw new Error("Account-Payload ist ungültig");
return controller.executeAccountCommand(command);
const result = await controller.executeAccountCommand(command);
reconcileDailyStart("Accountänderung");
return result;
});
handleTrusted(IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
handleTrusted(IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, async (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
const command = validateAccountCommand(rawCommand);
if (command.action !== "update-secret") throw new Error("Account-Payload ist ungültig");
return controller.executeAccountCommand(command);
const result = await controller.executeAccountCommand(command);
reconcileDailyStart("Accountänderung");
return result;
});
handleTrusted(IPC_CHANNELS.DELETE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
handleTrusted(IPC_CHANNELS.DELETE_ACCOUNT, async (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
const command = validateAccountCommand(rawCommand);
if (command.action !== "delete") throw new Error("Account-Payload ist ungültig");
return controller.executeAccountCommand(command);
const result = await controller.executeAccountCommand(command);
reconcileDailyStart("Accountänderung");
return result;
});
handleTrusted(IPC_CHANNELS.REVEAL_ACCOUNT_SECRET, (_event: IpcMainInvokeEvent, rawRequest: unknown) => {
return controller.revealAccountSecret(validateAccountSecretRequest(rawRequest));
@@ -993,6 +1071,7 @@ app.on("second-instance", () => {
app.whenReady().then(() => {
configureCredentialProtector(safeStorage);
controller = new AppController();
dailyStartScheduler = new DailyStartScheduler(controller);
cleanupStaleSubstDrives();
registerIpcHandlers();
mainWindow = createWindow();
@@ -1002,7 +1081,12 @@ app.whenReady().then(() => {
// A scheduled start persists in the settings but its timer lived only in this
// process — without re-arming it here, any restart (auto-update, reboot,
// crash) silently swallowed the planned run.
armScheduledStart(controller.getSettings().scheduledStartEpochMs || 0, { startOnPast: false });
armScheduledStart(controller.getSettings().scheduledStartEpochMs || 0, { startOnPast: false });
dailyStartScheduler.begin((error) => {
logger.warn(`Täglicher Start konnte nicht abgeglichen werden: ${String(error)}`);
});
powerMonitor.on("suspend", handlePowerSuspend);
powerMonitor.on("resume", handlePowerResume);
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
@@ -1023,16 +1107,22 @@ app.on("window-all-closed", () => {
}
});
app.on("before-quit", () => {
if (updateQuitTimer) { clearTimeout(updateQuitTimer); updateQuitTimer = null; }
stopClipboardWatcher();
destroyTray();
shutdownDaemon();
if (controller) {
try {
controller.shutdown();
} catch (error) {
logger.error(`Fehler beim Shutdown: ${String(error)}`);
app.on("before-quit", createBeforeQuitHandler({
cleanup: () => {
if (updateQuitTimer) { clearTimeout(updateQuitTimer); updateQuitTimer = null; }
cleanupSchedulerLifecycle(dailyStartScheduler, scheduledStartTimer);
scheduledStartTimer = null;
stopClipboardWatcher();
destroyTray();
shutdownDaemon();
},
shutdown: async () => {
if (controller) {
await controller.shutdown();
}
},
continueQuit: () => app.quit(),
onError: (error) => {
logger.error(`Fehler beim Shutdown: ${String(error)}`);
}
});
}));
+393
View File
@@ -0,0 +1,393 @@
import { createHash } from "node:crypto";
import type {
DebridProvider,
HistoryEntry,
PackageResult,
PackageResultStatus
} from "../shared/types";
import type {
NotificationEvent,
NotificationEventType,
NotificationPriority
} from "./notification-outbox";
import { projectPackageFailureCategory } from "./package-telemetry";
export interface PackageResultEnvelope {
generation: number;
result: PackageResult;
}
export interface RunResult {
id: string;
stopped: boolean;
startedAt: number;
completedAt: number;
totalDurationSeconds: number;
totalPackages: number;
completedPackages: number;
partialPackages: number;
failedPackages: number;
cancelledPackages: number;
successfulFiles: number;
failedFiles: number;
cancelledFiles: number;
totalBytes: number;
downloadedBytes: number;
averageDownloadSpeedBps: number;
downloadDurationSeconds: number;
extractionDurationSeconds: number;
remuxDurationSeconds: number;
postProcessDurationSeconds: number;
extractionFailures: number;
remuxFailures: number;
downloadFailures: number;
offlineFailures: number;
cleanupFailures: number;
}
export interface RunResultInput {
id: string;
stopped: boolean;
startedAt: number;
completedAt: number;
packages: readonly PackageResult[];
totalPackages?: number;
successfulFiles?: number;
failedFiles?: number;
cancelledFiles?: number;
}
export interface HistoryEntryContext {
generation: number;
outputDir: string;
urls: string[];
provider: DebridProvider | null;
}
export interface RunRemainingSnapshot {
remainingBytes: number;
openItems: number;
openPackages: number;
unknownCount: number;
finalizingItems: number;
speedBps: number;
etaSeconds: number;
}
export interface RemainingThresholdDecision {
emit: boolean;
remainingBytes?: number;
}
export interface RemainingThresholdState {
snapshot: RunRemainingSnapshot | null;
crossings: number;
}
const SUCCESS_TTL_MS = 6 * 60 * 60 * 1000;
const IMPORTANT_TTL_MS = 24 * 60 * 60 * 1000;
const DIGEST_PACKAGE_LIMIT = 20;
const numberFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
function finiteNonNegative(value: unknown): number {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? numeric : 0;
}
export function evaluateRemainingThreshold(
previous: RunRemainingSnapshot | null,
current: RunRemainingSnapshot,
thresholdBytes: number
): RemainingThresholdDecision {
const threshold = finiteNonNegative(thresholdBytes);
if (!previous
|| threshold <= 0
|| previous.openItems <= 0
|| current.openItems <= 0
|| previous.unknownCount > 0
|| current.unknownCount > 0
|| previous.finalizingItems > 0
|| current.finalizingItems > 0
|| previous.remainingBytes <= threshold
|| current.remainingBytes > threshold) {
return { emit: false };
}
return { emit: true, remainingBytes: current.remainingBytes };
}
function formatBytes(bytes: number): string {
let value = finiteNonNegative(bytes);
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${numberFormatter.format(value)} ${units[index]}`;
}
function formatDuration(seconds: number): string {
const total = Math.max(0, Math.floor(finiteNonNegative(seconds)));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const remainder = total % 60;
return hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`
: `${minutes}:${String(remainder).padStart(2, "0")}`;
}
function statusLabel(status: PackageResultStatus): string {
if (status === "completed") return "Abgeschlossen";
if (status === "partial") return "Teilweise abgeschlossen";
if (status === "failed") return "Fehlgeschlagen";
return "Abgebrochen";
}
function failurePhaseLabel(result: PackageResult): string {
if (result.failurePhase === "download") return "Download";
if (result.failurePhase === "extract") return "Entpacken";
if (result.failurePhase === "remux") return "Remux";
if (result.failurePhase === "cleanup") return "Aufräumen";
return "—";
}
function event(
id: string,
type: NotificationEventType,
priority: NotificationPriority,
createdAt: number,
title: string,
description: string,
color: number,
fields: NotificationEvent["payload"]["fields"]
): NotificationEvent {
return {
id,
type,
priority,
createdAt,
expiresAt: createdAt + (type === "run_completed" || priority === "error" ? IMPORTANT_TTL_MS : SUCCESS_TTL_MS),
attempts: 0,
nextAttemptAt: createdAt,
payload: { title, description, color, fields }
};
}
function packageEventType(status: PackageResultStatus): NotificationEventType {
if (status === "completed") return "package_completed";
if (status === "partial") return "package_partial";
return "package_failed";
}
export function buildPackageNotificationEvent(
envelope: PackageResultEnvelope,
createdAt: number
): NotificationEvent {
const { generation, result } = envelope;
const type = packageEventType(result.status);
const priority: NotificationPriority = result.status === "completed" ? "success" : "error";
const title = result.status === "completed"
? "✅ Paket fertig"
: result.status === "partial"
? "⚠️ Paket teilweise fertig"
: result.status === "cancelled"
? "⏹️ Paket abgebrochen"
: "❌ Paket fehlgeschlagen";
const fields = [
{ name: "Paket", value: result.name || "—", inline: false },
{ name: "Ergebnis", value: statusLabel(result.status), inline: true },
{ name: "Dateien", value: `${result.successfulFiles} erfolgreich · ${result.failedFiles} fehlgeschlagen · ${result.cancelledFiles} abgebrochen`, inline: false },
{ name: "Downloadgröße", value: `${formatBytes(result.downloadedBytes)} / ${formatBytes(result.totalBytes)}`, inline: true },
{ name: "Zeiten", value: `Download ${formatDuration(result.downloadDurationSeconds)} · Entpacken ${formatDuration(result.extractionDurationSeconds)} · Remux ${formatDuration(result.remuxDurationSeconds)} · Gesamt ${formatDuration(result.totalDurationSeconds)}`, inline: false },
{ name: "Aktive Downloadgeschwindigkeit", value: result.averageDownloadSpeedBps > 0 ? `${formatBytes(result.averageDownloadSpeedBps)}/s` : "—", inline: true },
{ name: "Archive", value: `${result.archiveCount} Gruppen · ${result.partCount} Parts · ${result.outputCount} Ausgaben`, inline: true }
];
if (result.failurePhase) {
const errorCategory = projectPackageFailureCategory(result.failurePhase, result.errorCategory);
fields.push({
name: "Fehler",
value: `${failurePhaseLabel(result)} · ${errorCategory}`,
inline: false
});
}
return event(
`package:${result.packageId}:${generation}:${type}`,
type,
priority,
createdAt,
title,
result.name,
priority === "success" ? 0x2ecc71 : 0xe74c3c,
fields
);
}
export function buildPackageDigestEvents(
envelopes: readonly PackageResultEnvelope[],
createdAt: number
): NotificationEvent[] {
const sorted = [...envelopes].sort((left, right) => {
const byCompleted = left.result.completedAt - right.result.completedAt;
if (byCompleted !== 0) return byCompleted;
const byPackage = left.result.packageId.localeCompare(right.result.packageId);
return byPackage !== 0 ? byPackage : left.generation - right.generation;
});
const digestKey = createHash("sha256")
.update(sorted.map((entry) => `${entry.result.packageId}:${entry.generation}`).join("|"))
.digest("hex")
.slice(0, 16);
const events: NotificationEvent[] = [];
for (let offset = 0; offset < sorted.length; offset += DIGEST_PACKAGE_LIMIT) {
const chunk = sorted.slice(offset, offset + DIGEST_PACKAGE_LIMIT);
const page = Math.floor(offset / DIGEST_PACKAGE_LIMIT) + 1;
const totalPages = Math.ceil(sorted.length / DIGEST_PACKAGE_LIMIT);
const fields = chunk.map(({ result }) => ({
name: result.name || "Paket",
value: `${result.successfulFiles} Dateien · ${formatBytes(result.downloadedBytes)} · ${formatDuration(result.totalDurationSeconds)}`,
inline: false
}));
events.push(event(
`package-digest:${digestKey}:${page}`,
"package_completed",
"success",
createdAt,
totalPages > 1 ? `✅ Paket-Digest ${page}/${totalPages}` : "✅ Paket-Digest",
`${sorted.length} Pakete abgeschlossen`,
0x2ecc71,
fields
));
}
return events;
}
export function buildRunResult(input: RunResultInput): RunResult {
const packages = [...input.packages];
const sum = (select: (result: PackageResult) => number): number => packages.reduce((total, result) => total + finiteNonNegative(select(result)), 0);
const downloadedBytes = sum((result) => result.downloadedBytes);
const downloadDurationSeconds = sum((result) => result.downloadDurationSeconds);
const successfulFiles = input.successfulFiles ?? sum((result) => result.successfulFiles);
const failedFiles = input.failedFiles ?? sum((result) => result.failedFiles);
const cancelledFiles = input.cancelledFiles ?? sum((result) => result.cancelledFiles);
return {
id: input.id,
stopped: input.stopped,
startedAt: finiteNonNegative(input.startedAt),
completedAt: finiteNonNegative(input.completedAt),
totalDurationSeconds: Math.max(0, Math.floor((finiteNonNegative(input.completedAt) - finiteNonNegative(input.startedAt)) / 1000)),
totalPackages: Math.max(packages.length, Math.floor(finiteNonNegative(input.totalPackages))),
completedPackages: packages.filter((result) => result.status === "completed").length,
partialPackages: packages.filter((result) => result.status === "partial").length,
failedPackages: packages.filter((result) => result.status === "failed").length,
cancelledPackages: packages.filter((result) => result.status === "cancelled").length,
successfulFiles,
failedFiles,
cancelledFiles,
totalBytes: sum((result) => result.totalBytes),
downloadedBytes,
averageDownloadSpeedBps: downloadDurationSeconds > 0 ? Math.floor(downloadedBytes / downloadDurationSeconds) : 0,
downloadDurationSeconds,
extractionDurationSeconds: sum((result) => result.extractionDurationSeconds),
remuxDurationSeconds: sum((result) => result.remuxDurationSeconds),
postProcessDurationSeconds: sum((result) => result.postProcessDurationSeconds),
extractionFailures: sum((result) => result.extractionFailures),
remuxFailures: sum((result) => result.remuxFailures),
downloadFailures: sum((result) => result.downloadFailures),
offlineFailures: sum((result) => result.offlineFailures),
cleanupFailures: sum((result) => result.cleanupFailures)
};
}
export function buildRunNotificationEvent(result: RunResult): NotificationEvent {
const priority: NotificationPriority = result.stopped || result.failedFiles > 0 || result.partialPackages > 0 || result.failedPackages > 0
? "error"
: "success";
const type: NotificationEventType = result.stopped ? "run_stopped" : "run_completed";
const title = result.stopped
? "⏹️ Durchlauf gestoppt"
: priority === "success"
? "🏁 Durchlauf beendet"
: "⚠️ Durchlauf mit Fehlern beendet";
return event(
`run:${result.id}:${type}`,
type,
priority,
result.completedAt,
title,
result.stopped ? "Offene Dateien bleiben in der Warteschlange." : "Alle Paketresultate sind final.",
priority === "success" ? 0x2ecc71 : 0xe67e22,
[
{ name: "Pakete", value: `${result.completedPackages} fertig · ${result.partialPackages} teilweise · ${result.failedPackages} fehlgeschlagen · ${result.cancelledPackages} abgebrochen`, inline: false },
{ name: "Dateien", value: `${result.successfulFiles} erfolgreich · ${result.failedFiles} fehlgeschlagen · ${result.cancelledFiles} abgebrochen`, inline: false },
{ name: "Dauer", value: `Download ${formatDuration(result.downloadDurationSeconds)} · Entpacken ${formatDuration(result.extractionDurationSeconds)} · Remux ${formatDuration(result.remuxDurationSeconds)} · Nachbearbeitung ${formatDuration(result.postProcessDurationSeconds)} · Gesamt ${formatDuration(result.totalDurationSeconds)}`, inline: false },
{ name: "Downloadgröße", value: `${formatBytes(result.downloadedBytes)} / ${formatBytes(result.totalBytes)}`, inline: true },
{ name: "Aktive Downloadgeschwindigkeit", value: result.averageDownloadSpeedBps > 0 ? `${formatBytes(result.averageDownloadSpeedBps)}/s` : "—", inline: true },
{ name: "Entpackfehler", value: String(result.extractionFailures), inline: true },
{ name: "Remuxfehler", value: String(result.remuxFailures), inline: true },
{ name: "Downloadfehler", value: String(result.downloadFailures), inline: true },
{ name: "Offline", value: String(result.offlineFailures), inline: true },
{ name: "Cleanupfehler", value: String(result.cleanupFailures), inline: true }
]
);
}
export function buildRemainingThresholdNotificationEvent(
runId: string,
crossing: number,
snapshot: RunRemainingSnapshot,
thresholdBytes: number,
createdAt: number
): NotificationEvent {
return event(
`run:${runId}:remaining_threshold_crossed:${crossing}`,
"remaining_threshold_crossed",
"success",
createdAt,
"📉 Restmenge erreicht",
`Der aktive Durchlauf liegt bei oder unter ${formatBytes(thresholdBytes)}.`,
0x3498db,
[
{ name: "Restmenge", value: formatBytes(snapshot.remainingBytes), inline: true },
{ name: "Offene Pakete", value: String(snapshot.openPackages), inline: true },
{ name: "Offene Dateien", value: String(snapshot.openItems), inline: true },
{ name: "Geschwindigkeit", value: snapshot.speedBps > 0 ? `${formatBytes(snapshot.speedBps)}/s` : "—", inline: true },
{ name: "ETA", value: snapshot.etaSeconds >= 0 ? formatDuration(snapshot.etaSeconds) : "—", inline: true }
]
);
}
export function buildHistoryEntry(
result: PackageResult,
context: HistoryEntryContext
): HistoryEntry {
return {
id: `hist-${result.packageId}-${context.generation}`,
name: result.name,
totalBytes: result.totalBytes,
downloadedBytes: result.downloadedBytes,
fileCount: result.successfulFiles + result.failedFiles + result.cancelledFiles,
provider: context.provider,
completedAt: result.completedAt,
durationSeconds: result.downloadDurationSeconds,
status: result.status,
outputDir: context.outputDir,
urls: [...new Set(context.urls.filter(Boolean))],
startedAt: result.startedAt,
downloadEndedAt: result.downloadEndedAt,
postProcessStartedAt: result.postProcessStartedAt,
downloadDurationSeconds: result.downloadDurationSeconds,
extractionDurationSeconds: result.extractionDurationSeconds,
remuxDurationSeconds: result.remuxDurationSeconds,
postProcessDurationSeconds: result.postProcessDurationSeconds,
totalDurationSeconds: result.totalDurationSeconds,
successfulFiles: result.successfulFiles,
failedFiles: result.failedFiles,
cancelledFiles: result.cancelledFiles,
archiveCount: result.archiveCount,
partCount: result.partCount,
outputCount: result.outputCount,
failurePhase: result.failurePhase,
archiveOperations: result.archiveOperations.map((operation) => ({ ...operation, itemIds: [...operation.itemIds] })),
remuxOperations: result.remuxOperations.map((operation) => ({ ...operation }))
};
}
+444
View File
@@ -0,0 +1,444 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import type { DiscordEmbedFieldPayload } from "./notify";
import { projectPackageFailureCategory } from "./package-telemetry";
import type { FailurePhase } from "../shared/types";
export type NotificationEventType =
| "package_completed"
| "package_partial"
| "package_failed"
| "run_completed"
| "run_stopped"
| "remaining_threshold_crossed"
| "download_stalled"
| "download_recovered";
export type NotificationPriority = "success" | "error";
export interface NotificationEventPayload {
title: string;
description?: string;
color?: number;
fields: DiscordEmbedFieldPayload[];
}
export interface NotificationEvent {
id: string;
type: NotificationEventType;
priority: NotificationPriority;
createdAt: number;
expiresAt: number;
attempts: number;
nextAttemptAt: number;
payload: NotificationEventPayload;
}
export interface NotificationOutboxStatus {
queued: number;
lastSuccessAt: number;
lastFailureAt: number;
}
export interface NotificationOutboxOptions {
filePath: string;
send: (event: NotificationEvent) => Promise<boolean>;
onDelivered?: (event: NotificationEvent, deliveredAt: number) => void | Promise<void>;
now?: () => number;
autoDrain?: boolean;
}
interface PersistedNotificationOutbox {
version: 1;
events: NotificationEvent[];
lastSuccessAt: number;
lastFailureAt: number;
}
const EVENT_TYPES = new Set<NotificationEventType>([
"package_completed",
"package_partial",
"package_failed",
"run_completed",
"run_stopped",
"remaining_threshold_crossed",
"download_stalled",
"download_recovered"
]);
const MAX_EVENTS = 250;
const MAX_RETRY_DELAY_MS = 10 * 60 * 1000;
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 3000;
const PACKAGE_FAILURE_EVENT_TYPES = new Set<NotificationEventType>([
"package_partial",
"package_failed"
]);
const PACKAGE_FAILURE_PHASES = new Map<string, FailurePhase>([
["Download", "download"],
["Entpacken", "extract"],
["Remux", "remux"],
["Aufräumen", "cleanup"]
]);
function finiteInteger(value: unknown, fallback = 0): number {
const numeric = Number(value);
return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : fallback;
}
function sanitizePackageFailureFieldValue(value: string): string {
const match = /^(Download|Entpacken|Remux|Aufräumen)(?:\s*·\s*(.*))?$/s.exec(value.trim());
if (!match) {
return "Unbekannt";
}
const phase = PACKAGE_FAILURE_PHASES.get(match[1]) ?? null;
return `${match[1]} · ${projectPackageFailureCategory(phase, match[2])}`;
}
function sanitizeEvent(value: unknown): NotificationEvent | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const raw = value as Partial<NotificationEvent>;
const id = typeof raw.id === "string" ? raw.id.trim().slice(0, 256) : "";
const type = EVENT_TYPES.has(raw.type as NotificationEventType) ? raw.type as NotificationEventType : null;
const priority = raw.priority === "success" || raw.priority === "error" ? raw.priority : null;
const payload = raw.payload && typeof raw.payload === "object" && !Array.isArray(raw.payload)
? raw.payload as NotificationEventPayload
: null;
const title = typeof payload?.title === "string" ? payload.title.slice(0, 4096) : "";
if (!id || !type || !priority || !payload || !title) {
return null;
}
const fields = Array.isArray(payload.fields)
? payload.fields.slice(0, 25).flatMap((field) => {
if (!field || typeof field !== "object") {
return [];
}
const name = typeof field.name === "string" ? field.name.slice(0, 1024) : "";
const rawFieldValue = typeof field.value === "string" ? field.value.slice(0, 4096) : "";
const fieldValue = name === "Fehler" && PACKAGE_FAILURE_EVENT_TYPES.has(type)
? sanitizePackageFailureFieldValue(rawFieldValue)
: rawFieldValue;
return name && fieldValue ? [{ name, value: fieldValue, inline: Boolean(field.inline) }] : [];
})
: [];
const description = typeof payload.description === "string" ? payload.description.slice(0, 8192) : undefined;
const color = Number.isFinite(payload.color)
? Math.max(0, Math.min(0xffffff, Math.floor(payload.color as number)))
: undefined;
return {
id,
type,
priority,
createdAt: finiteInteger(raw.createdAt),
expiresAt: finiteInteger(raw.expiresAt),
attempts: finiteInteger(raw.attempts),
nextAttemptAt: finiteInteger(raw.nextAttemptAt),
payload: {
title,
...(description !== undefined ? { description } : {}),
...(color !== undefined ? { color } : {}),
fields
}
};
}
function oldestIndex(events: NotificationEvent[], predicate: (event: NotificationEvent) => boolean): number {
let selected = -1;
for (let index = 0; index < events.length; index += 1) {
if (!predicate(events[index])) {
continue;
}
if (selected < 0 || events[index].createdAt < events[selected].createdAt) {
selected = index;
}
}
return selected;
}
function retryDelayMs(attempts: number): number {
return Math.min(MAX_RETRY_DELAY_MS, 1000 * (2 ** Math.min(30, Math.max(0, attempts - 1))));
}
export class NotificationOutbox {
private events: NotificationEvent[] = [];
private lastSuccessAt = 0;
private lastFailureAt = 0;
private operationChain: Promise<void> = Promise.resolve();
private readonly filePath: string;
private readonly sendEvent: (event: NotificationEvent) => Promise<boolean>;
private readonly onDelivered: ((event: NotificationEvent, deliveredAt: number) => void | Promise<void>) | null;
private readonly clock: () => number;
private readonly autoDrain: boolean;
private retryTimer: NodeJS.Timeout | null = null;
private shutdownRequested = false;
private drainOperation: Promise<void> | null = null;
private inFlightEvent: NotificationEvent | null = null;
private persistenceRequired = false;
private persistenceRetryAttempts = 0;
public constructor(options: NotificationOutboxOptions) {
this.filePath = options.filePath;
this.sendEvent = options.send;
this.onDelivered = options.onDelivered || null;
this.clock = options.now || Date.now;
this.autoDrain = Boolean(options.autoDrain);
this.load();
if (this.autoDrain && this.events.length > 0) {
this.scheduleDrain(Math.max(0, this.events[0].nextAttemptAt - this.clock()));
}
}
public async enqueue(event: NotificationEvent): Promise<void> {
await this.runExclusive(async () => {
const normalized = sanitizeEvent(event);
if (normalized && !this.events.some((queuedEvent) => queuedEvent.id === normalized.id)) {
this.events.push(normalized);
}
await this.persist(this.clock());
});
if (this.autoDrain) {
this.scheduleDrain(0);
}
}
public drain(now?: number): Promise<void> {
if (this.drainOperation) {
return this.drainOperation;
}
const operation = this.performDrain(now).finally(() => {
if (this.drainOperation === operation) {
this.drainOperation = null;
}
});
this.drainOperation = operation;
return operation;
}
public async drainForShutdown(timeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS): Promise<void> {
this.shutdownRequested = true;
this.clearRetryTimer();
let timer: NodeJS.Timeout | null = null;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, Math.max(0, finiteInteger(timeoutMs, DEFAULT_SHUTDOWN_TIMEOUT_MS)));
});
const shutdownDrain = Promise.all([this.recoverPersistence(), this.drain()]).then(() => undefined);
await Promise.race([shutdownDrain, timeout]);
if (timer) {
clearTimeout(timer);
}
}
public getStatus(): NotificationOutboxStatus {
return {
queued: this.events.length,
lastSuccessAt: this.lastSuccessAt,
lastFailureAt: this.lastFailureAt
};
}
private runExclusive<T>(operation: () => Promise<T>): Promise<T> {
const result = this.operationChain.then(operation, operation);
this.operationChain = result.then(() => undefined, () => undefined);
return result;
}
private async performDrain(now?: number): Promise<void> {
let currentNow = finiteInteger(now ?? this.clock());
while (true) {
const current = await this.runExclusive(async () => {
if (this.persistenceRequired) {
await this.persist(currentNow);
}
this.enforceLimits(currentNow);
const next = this.events[0] || null;
if (!next) {
this.clearRetryTimer();
await this.persist(finiteInteger(this.clock(), currentNow));
return null;
}
if (next.nextAttemptAt > currentNow) {
await this.persist(currentNow);
if (this.autoDrain) {
this.scheduleDrain(Math.max(0, next.nextAttemptAt - this.clock()));
}
return null;
}
this.inFlightEvent = next;
return next;
});
if (!current) {
return;
}
let sent = false;
try {
sent = await this.sendEvent(current);
} catch {
sent = false;
}
const outcomeAt = finiteInteger(this.clock(), currentNow);
const delivered = await this.runExclusive(async () => {
const index = this.events.indexOf(current);
this.inFlightEvent = null;
if (index < 0) {
return false;
}
if (!sent) {
const queued = this.events[index];
queued.attempts += 1;
queued.nextAttemptAt = outcomeAt + retryDelayMs(queued.attempts);
this.lastFailureAt = outcomeAt;
await this.persist(outcomeAt);
if (this.autoDrain) {
this.scheduleDrain(Math.max(0, queued.nextAttemptAt - this.clock()));
}
return false;
}
this.events.splice(index, 1);
this.lastSuccessAt = outcomeAt;
await this.persist(outcomeAt);
return true;
});
if (!sent) {
return;
}
if (delivered && this.onDelivered) {
try {
await this.onDelivered(current, outcomeAt);
} catch {
}
}
currentNow = finiteInteger(this.clock(), outcomeAt);
}
}
private scheduleDrain(delayMs: number): void {
if (this.shutdownRequested) {
return;
}
this.clearRetryTimer();
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
void this.drain().catch(() => {});
}, delayMs);
this.retryTimer.unref?.();
}
private schedulePersistenceRetry(delayMs: number): void {
if (this.shutdownRequested) {
return;
}
this.clearRetryTimer();
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
void this.recoverPersistence()
.then(() => {
if (!this.shutdownRequested) {
void this.drain().catch(() => {});
}
})
.catch(() => {});
}, delayMs);
this.retryTimer.unref?.();
}
private recoverPersistence(): Promise<void> {
return this.runExclusive(async () => {
if (this.persistenceRequired) {
await this.persist(this.clock());
}
});
}
private clearRetryTimer(): void {
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
}
private load(): void {
if (!fs.existsSync(this.filePath)) {
return;
}
try {
const parsed = JSON.parse(fs.readFileSync(this.filePath, "utf8")) as Partial<PersistedNotificationOutbox>;
this.events = Array.isArray(parsed.events)
? parsed.events.flatMap((event) => {
const normalized = sanitizeEvent(event);
return normalized ? [normalized] : [];
})
: [];
this.lastSuccessAt = finiteInteger(parsed.lastSuccessAt);
this.lastFailureAt = finiteInteger(parsed.lastFailureAt);
this.enforceLimits(this.clock());
} catch {
this.events = [];
this.lastSuccessAt = 0;
this.lastFailureAt = 0;
}
this.persistSync(this.clock());
}
private enforceLimits(now: number): void {
this.events = this.events.filter((event) => event === this.inFlightEvent || event.expiresAt > now);
while (this.events.length > MAX_EVENTS) {
const successIndex = oldestIndex(this.events, (event) => event !== this.inFlightEvent && event.priority === "success");
const removeIndex = successIndex >= 0
? successIndex
: oldestIndex(this.events, (event) => event !== this.inFlightEvent);
if (removeIndex < 0) {
break;
}
this.events.splice(removeIndex, 1);
}
}
private async persist(now: number): Promise<void> {
this.persistenceRequired = true;
this.enforceLimits(now);
const tempPath = `${this.filePath}.tmp`;
const state: PersistedNotificationOutbox = {
version: 1,
events: this.events,
lastSuccessAt: this.lastSuccessAt,
lastFailureAt: this.lastFailureAt
};
try {
await fsp.mkdir(path.dirname(this.filePath), { recursive: true });
await fsp.writeFile(tempPath, JSON.stringify(state), "utf8");
await fsp.rename(tempPath, this.filePath);
this.persistenceRequired = false;
this.persistenceRetryAttempts = 0;
} catch (error) {
await fsp.rm(tempPath, { force: true }).catch(() => {});
this.persistenceRetryAttempts += 1;
if (this.autoDrain) {
this.schedulePersistenceRetry(retryDelayMs(this.persistenceRetryAttempts));
}
throw error;
}
}
private persistSync(now: number): void {
this.enforceLimits(now);
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
const tempPath = `${this.filePath}.tmp`;
const state: PersistedNotificationOutbox = {
version: 1,
events: this.events,
lastSuccessAt: this.lastSuccessAt,
lastFailureAt: this.lastFailureAt
};
try {
fs.writeFileSync(tempPath, JSON.stringify(state), "utf8");
fs.renameSync(tempPath, this.filePath);
} catch (error) {
try {
fs.rmSync(tempPath, { force: true });
} catch {
}
throw error;
}
}
}
+103 -23
View File
@@ -1,17 +1,45 @@
import { logger } from "./logger";
export interface NotifyPayload {
title: string;
message: string;
mention?: string;
}
export interface NotifyPayload {
title: string;
message: string;
mention?: string;
color?: number;
fields?: DiscordEmbedFieldPayload[];
timestamp?: number | string;
}
export interface DiscordEmbedFieldPayload {
name: string;
value: string;
inline?: boolean;
}
export interface DiscordEmbedPayload {
title: string;
description: string;
color: number;
fields: Array<{
name: string;
value: string;
inline: boolean;
}>;
timestamp?: string;
}
const NOTIFY_TIMEOUT_MS = 5000;
const WEBHOOK_USERNAME = "Real-Debrid Downloader";
const WEBHOOK_USERNAME = "Multi-Debrid Downloader";
const MIN_SEND_GAP_MS = 450;
const RETRY_DELAYS_MS = [1000, 2500];
const RATE_LIMIT_MAX_WAIT_MS = 15_000;
const CONTENT_MAX_CHARS = 2000;
const CONTENT_MAX_CHARS = 2000;
const EMBED_TITLE_MAX_CHARS = 256;
const EMBED_DESCRIPTION_MAX_CHARS = 4096;
const EMBED_FIELD_NAME_MAX_CHARS = 256;
const EMBED_FIELD_VALUE_MAX_CHARS = 1024;
const EMBED_FIELDS_MAX = 25;
const EMBED_TOTAL_MAX_CHARS = 6000;
const DEFAULT_EMBED_COLOR = 0x2f81f7;
export function isNotifyUrlValid(url: string): boolean {
return /^https?:\/\/\S+$/i.test(String(url || "").trim());
@@ -32,7 +60,7 @@ export function normalizeDiscordMention(raw: string): string {
// Discord counts the limit itself; slicing UTF-16 units can split a surrogate
// pair at the boundary, which Discord rejects as invalid content.
export function truncateContent(content: string, maxChars = CONTENT_MAX_CHARS): string {
export function truncateContent(content: string, maxChars = CONTENT_MAX_CHARS): string {
if (content.length <= maxChars) {
return content;
}
@@ -41,21 +69,73 @@ export function truncateContent(content: string, maxChars = CONTENT_MAX_CHARS):
if (last >= 0xd800 && last <= 0xdbff) {
cut = cut.slice(0, -1);
}
return cut;
}
export function buildNotifyRequest(url: string, payload: NotifyPayload): { url: string; init: RequestInit } {
const mention = normalizeDiscordMention(payload.mention || "");
const content = truncateContent(`${mention ? `${mention} ` : ""}**${payload.title}**\n${payload.message}`);
return {
url: String(url || "").trim(),
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: WEBHOOK_USERNAME, content })
}
};
}
return cut;
}
function normalizeTimestamp(value: number | string | undefined): string | undefined {
if (value === undefined) {
return undefined;
}
const date = new Date(value);
return Number.isFinite(date.getTime()) ? date.toISOString() : undefined;
}
function normalizeEmbedColor(value: number | undefined): number {
if (!Number.isFinite(value)) {
return DEFAULT_EMBED_COLOR;
}
return Math.max(0, Math.min(0xffffff, Math.floor(value as number)));
}
function buildDiscordEmbed(payload: NotifyPayload): DiscordEmbedPayload {
let remaining = EMBED_TOTAL_MAX_CHARS;
const title = truncateContent(String(payload.title || ""), Math.min(EMBED_TITLE_MAX_CHARS, remaining));
remaining -= title.length;
const description = truncateContent(String(payload.message || ""), Math.min(EMBED_DESCRIPTION_MAX_CHARS, remaining));
remaining -= description.length;
const fields: DiscordEmbedPayload["fields"] = [];
for (const field of (payload.fields || []).slice(0, EMBED_FIELDS_MAX)) {
if (remaining < 2) {
break;
}
const name = truncateContent(String(field.name || ""), Math.min(EMBED_FIELD_NAME_MAX_CHARS, remaining - 1));
if (!name) {
continue;
}
remaining -= name.length;
const value = truncateContent(String(field.value || ""), Math.min(EMBED_FIELD_VALUE_MAX_CHARS, remaining));
if (!value) {
remaining += name.length;
continue;
}
remaining -= value.length;
fields.push({ name, value, inline: Boolean(field.inline) });
}
const timestamp = normalizeTimestamp(payload.timestamp);
return {
title,
description,
color: normalizeEmbedColor(payload.color),
fields,
...(timestamp ? { timestamp } : {})
};
}
export function buildNotifyRequest(url: string, payload: NotifyPayload): { url: string; init: RequestInit } {
const mention = normalizeDiscordMention(payload.mention || "");
return {
url: String(url || "").trim(),
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: WEBHOOK_USERNAME,
content: truncateContent(mention),
embeds: [buildDiscordEmbed(payload)]
})
}
};
}
function delayMs(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
+201
View File
@@ -0,0 +1,201 @@
import type {
ArchiveOperationMetric,
FailurePhase,
PackageResult,
PackageResultStatus,
PackageTelemetry,
RemuxOperationMetric
} from "../shared/types";
export type PackageFailureCategory =
| "Netzwerk"
| "Timeout"
| "Offline"
| "Speicherplatz"
| "Berechtigung"
| "Download"
| "Entpacken"
| "Remux"
| "Cleanup"
| "Unbekannt";
const packageFailureCategories = new Map<string, PackageFailureCategory>([
["netzwerk", "Netzwerk"],
["timeout", "Timeout"],
["offline", "Offline"],
["speicherplatz", "Speicherplatz"],
["berechtigung", "Berechtigung"],
["download", "Download"],
["entpacken", "Entpacken"],
["remux", "Remux"],
["cleanup", "Cleanup"],
["unbekannt", "Unbekannt"]
]);
function finiteNonNegative(value: unknown): number {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : 0;
}
export function durationMsToSeconds(durationMs: number): number {
return Math.floor(finiteNonNegative(durationMs) / 1_000);
}
export function durationSecondsBetween(startedAt: number | undefined, completedAt: number | undefined): number {
const start = finiteNonNegative(startedAt);
const end = finiteNonNegative(completedAt);
return start > 0 && end > start ? durationMsToSeconds(end - start) : 0;
}
export function sumOperationDurationSeconds(operations: readonly { durationMs: number }[]): number {
return durationMsToSeconds(operations.reduce((total, operation) => total + finiteNonNegative(operation.durationMs), 0));
}
export function projectPackageFailureCategory(failurePhase: FailurePhase, detail: unknown): PackageFailureCategory {
const normalized = String(detail ?? "").trim().slice(0, 2048).toLowerCase();
const knownCategory = packageFailureCategories.get(normalized);
if (knownCategory) {
return knownCategory;
}
if (/\b(?:e?timed?[\s_-]*out|timeout)\b|zeit(?:ü|ue)berschreitung/.test(normalized)) {
return "Timeout";
}
if (/\boffline\b|not[\s_-]*found|nicht[\s_-]*gefunden|dead[\s_-]*link|http\s*404/.test(normalized)) {
return "Offline";
}
if (/\benospc\b|disk[\s_-]*full|no[\s_-]+space[\s_-]+left|not[\s_-]+enough[\s_-]+(?:disk[\s_-]+)?space|insufficient[\s_-]+(?:disk[\s_-]+)?space|speicherplatz|datenträger[^\n]*voll/.test(normalized)) {
return "Speicherplatz";
}
if (/\beacces\b|\beperm\b|permission|access[\s_-]*denied|zugriff[^\n]*verweigert|berechtigung/.test(normalized)) {
return "Berechtigung";
}
if (/network|netzwerk|\beconn|\benet|\behost|\beai_again\b|\bdns\b|socket|connection|verbindung|fetch[\s_-]*failed/.test(normalized)) {
return "Netzwerk";
}
if (failurePhase === "download") return "Download";
if (failurePhase === "extract") return "Entpacken";
if (failurePhase === "remux") return "Remux";
if (failurePhase === "cleanup") return "Cleanup";
return "Unbekannt";
}
function classifyStatus(successfulFiles: number, failedFiles: number, cancelledFiles: number, packageCancelled: boolean): PackageResultStatus {
if (successfulFiles > 0 && (failedFiles > 0 || cancelledFiles > 0 || packageCancelled)) {
return "partial";
}
if (failedFiles > 0) {
return "failed";
}
if (cancelledFiles > 0 || packageCancelled) {
return "cancelled";
}
return "completed";
}
function getFailure(
cleanupErrorCategory: string,
remuxOperations: readonly RemuxOperationMetric[],
remuxFallbackFailures: number,
archiveOperations: readonly ArchiveOperationMetric[],
downloadErrors: readonly string[]
): { failurePhase: FailurePhase; errorCategory: string } {
if (cleanupErrorCategory) {
return { failurePhase: "cleanup", errorCategory: projectPackageFailureCategory("cleanup", cleanupErrorCategory) };
}
const failedRemux = remuxOperations.find((operation) => operation.status === "failed");
if (failedRemux || remuxFallbackFailures > 0) {
return { failurePhase: "remux", errorCategory: projectPackageFailureCategory("remux", failedRemux?.errorCategory) };
}
const failedArchive = archiveOperations.find((operation) => operation.status === "failed");
if (failedArchive) {
return { failurePhase: "extract", errorCategory: projectPackageFailureCategory("extract", failedArchive.errorCategory) };
}
const downloadError = downloadErrors.find(Boolean);
if (downloadErrors.length > 0) {
return { failurePhase: "download", errorCategory: projectPackageFailureCategory("download", downloadError) };
}
return { failurePhase: null, errorCategory: "" };
}
export function finalizePackageResult(telemetry: PackageTelemetry): PackageResult {
const packageEntry = telemetry.package;
const archiveOperations = (telemetry.archiveOperations ?? packageEntry.archiveOperations ?? [])
.map((operation) => ({ ...operation, itemIds: [...operation.itemIds] }));
const remuxOperations = (telemetry.remuxOperations ?? packageEntry.remuxOperations ?? [])
.map((operation) => ({ ...operation }));
const cleanedCompletedDownloads = Math.max(0, Math.floor(finiteNonNegative(packageEntry.cleanedCompletedItemCount)));
const completedDownloads = cleanedCompletedDownloads + telemetry.items.filter((item) => item.status === "completed").length;
const failedDownloads = telemetry.items.filter((item) => item.status === "failed");
const cancelledDownloads = telemetry.items.filter((item) => item.status === "cancelled").length;
const failedArchives = archiveOperations.filter((operation) => operation.status === "failed").length;
const cancelledArchives = archiveOperations.filter((operation) => operation.status === "cancelled").length;
const failedRemuxOperations = remuxOperations.filter((operation) => operation.status === "failed").length;
const cancelledRemuxOperations = remuxOperations.filter((operation) => operation.status === "cancelled").length;
const audioStripFailures = Math.max(0, Math.floor(finiteNonNegative(packageEntry.audioStripSummary?.failed)));
const remuxFailures = Math.max(failedRemuxOperations, audioStripFailures);
const cleanupErrorCategory = String(telemetry.cleanupErrorCategory ?? packageEntry.cleanupErrorCategory ?? "").trim();
const downloadFailureCategories = failedDownloads.map((item) =>
projectPackageFailureCategory("download", item.lastError || item.fullStatus)
);
const offlineFailures = downloadFailureCategories.filter((category) => category === "Offline").length;
const cleanupFailures = cleanupErrorCategory ? 1 : 0;
const postProcessFailures = failedArchives + remuxFailures + cleanupFailures;
const postProcessCancellations = cancelledArchives + cancelledRemuxOperations;
const failedFiles = failedDownloads.length + postProcessFailures;
const cancelledFiles = cancelledDownloads + postProcessCancellations;
const successfulFiles = Math.max(0, completedDownloads - postProcessFailures - postProcessCancellations);
const startedAt = finiteNonNegative(packageEntry.downloadStartedAt);
const downloadEndedAt = finiteNonNegative(packageEntry.downloadEndedAt) || finiteNonNegative(packageEntry.downloadCompletedAt);
const postProcessStartedAt = finiteNonNegative(packageEntry.postProcessStartedAt);
const postProcessCompletedAt = finiteNonNegative(packageEntry.postProcessCompletedAt);
const completedAt = finiteNonNegative(packageEntry.terminalAt);
const downloadedBytes = finiteNonNegative(packageEntry.cleanedDownloadedBytes)
+ telemetry.items.reduce((total, item) => total + finiteNonNegative(item.downloadedBytes), 0);
const totalBytes = finiteNonNegative(packageEntry.cleanedTotalBytes)
+ telemetry.items.reduce((total, item) => total + finiteNonNegative(item.totalBytes ?? item.downloadedBytes), 0);
const downloadDurationSeconds = durationSecondsBetween(startedAt, downloadEndedAt);
const extractionDurationSeconds = sumOperationDurationSeconds(archiveOperations);
const remuxDurationSeconds = sumOperationDurationSeconds(remuxOperations);
const postProcessDurationSeconds = durationSecondsBetween(postProcessStartedAt, postProcessCompletedAt);
const totalDurationSeconds = durationSecondsBetween(startedAt, completedAt);
const failure = getFailure(
cleanupErrorCategory,
remuxOperations,
audioStripFailures,
archiveOperations,
failedDownloads.map((item) => item.lastError || item.fullStatus)
);
return {
packageId: packageEntry.id,
name: packageEntry.name,
status: classifyStatus(successfulFiles, failedFiles, cancelledFiles, packageEntry.cancelled),
startedAt,
downloadEndedAt,
postProcessStartedAt,
completedAt,
downloadDurationSeconds,
extractionDurationSeconds,
remuxDurationSeconds,
postProcessDurationSeconds,
totalDurationSeconds,
totalBytes,
downloadedBytes,
averageDownloadSpeedBps: downloadDurationSeconds > 0 ? Math.floor(downloadedBytes / downloadDurationSeconds) : 0,
successfulFiles,
failedFiles,
cancelledFiles,
downloadFailures: failedDownloads.length,
offlineFailures,
extractionFailures: failedArchives,
remuxFailures,
cleanupFailures,
archiveCount: archiveOperations.length,
partCount: archiveOperations.reduce((total, operation) => total + Math.max(0, Math.floor(finiteNonNegative(operation.partCount))), 0),
outputCount: Math.max(0, Math.floor(finiteNonNegative(telemetry.outputCount ?? packageEntry.outputCount))),
failurePhase: failure.failurePhase,
errorCategory: failure.errorCategory,
archiveOperations,
remuxOperations
};
}
+19 -1
View File
@@ -1,7 +1,16 @@
import type { AppSettings, RendererSettingsUpdate } from "../shared/types";
import { createRendererSettings } from "./renderer-state";
import { isValidLocalDate } from "./daily-start-scheduler";
const DERIVED_KEYS = new Set(["archivePasswordListConfigured", "notifyUrlConfigured", "configuredProviders"]);
const DERIVED_KEYS = new Set([
"archivePasswordListConfigured",
"notifyUrlConfigured",
"configuredProviders",
"dailyStartLastHandledLocalDate",
"dailyStartPendingLocalDate",
"dailyStartLastOutcome",
"nextDailyStartEpochMs"
]);
const WRITE_ONLY_KEYS = new Set(["archivePasswordList", "notifyUrl"]);
const MAX_SETTINGS_PAYLOAD_BYTES = 1_000_000;
@@ -82,6 +91,15 @@ export function validateRendererSettingsUpdate(value: unknown, current: AppSetti
if (!(key in safe)) {
invalid();
}
if (key === "notifyPackageSuccessMode" && entry !== "digest" && entry !== "individual") {
invalid();
}
if (key === "dailyStartMinuteOfDay" && (!Number.isInteger(entry) || (entry as number) < 0 || (entry as number) > 1_439)) {
invalid();
}
if (key === "dailyStartFirstLocalDate" && entry !== "" && (typeof entry !== "string" || !isValidLocalDate(entry))) {
invalid();
}
validateTopLevelType(entry, safe[key]);
validateJsonValue(entry);
output[key] = entry;
+23 -1
View File
@@ -3,6 +3,7 @@ import { getMegaDebridAccountsForMode, getMegaDebridDisabledAccountIdsForMode }
import { getRealDebridAccounts } from "../shared/real-debrid-accounts";
import type { AppSettings, DebridAccountStatus, DebridProvider, RendererAccount, RendererAccountKind, RendererSettings } from "../shared/types";
import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus } from "./account-status-sanitizer";
import { nextDailyStartEpochMs } from "./daily-start-scheduler";
function maskValue(value: string, keepStart = 3, keepEnd = 3): string {
const trimmed = value.trim();
@@ -120,6 +121,13 @@ export function createRendererAccounts(settings: AppSettings): RendererAccount[]
true
));
}
if (settings.deepbridApiKey.trim()) {
const status = safeStatus(settings.debridAccountStatuses["svc-deepbrid"], redactions);
accounts.push({
...singleAccount(settings, "deepbrid-api", "deepbrid", status?.username || "", maskValue(settings.deepbridApiKey), true),
status
});
}
if (settings.ddownloadLogin.trim() && settings.ddownloadPassword) {
accounts.push(singleAccount(settings, "ddownload-login", "ddownload", settings.ddownloadLogin.trim(), maskValue(settings.ddownloadLogin, 2, 4), true));
}
@@ -220,6 +228,13 @@ export function createRendererSettings(settings: AppSettings): RendererSettings
notifyOnPackageCompleted: settings.notifyOnPackageCompleted,
notifyOnPackageFailed: settings.notifyOnPackageFailed,
notifyOnRunFinished: settings.notifyOnRunFinished,
notifyPackageSuccessMode: settings.notifyPackageSuccessMode,
notifyOnRemainingBelow: settings.notifyOnRemainingBelow,
notifyRemainingThresholdGb: settings.notifyRemainingThresholdGb,
notifyOnDownloadStall: settings.notifyOnDownloadStall,
notifyStallAfterSeconds: settings.notifyStallAfterSeconds,
notifyStallCooldownMinutes: settings.notifyStallCooldownMinutes,
notifyOnDownloadRecovery: settings.notifyOnDownloadRecovery,
totalDownloadedAllTime: settings.totalDownloadedAllTime,
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs,
@@ -244,7 +259,14 @@ export function createRendererSettings(settings: AppSettings): RendererSettings
megaDebridAccountTotalUsageBytes: { ...settings.megaDebridAccountTotalUsageBytes },
debridAccountStatuses: Object.fromEntries(Object.entries(settings.debridAccountStatuses).map(([id, status]) => [id, safeStatus(status, redactions)])),
providerDailyUsageDay: settings.providerDailyUsageDay,
scheduledStartEpochMs: settings.scheduledStartEpochMs
dailyStartEnabled: settings.dailyStartEnabled,
dailyStartMinuteOfDay: settings.dailyStartMinuteOfDay,
dailyStartFirstLocalDate: settings.dailyStartFirstLocalDate,
dailyStartLastHandledLocalDate: settings.dailyStartLastHandledLocalDate,
dailyStartPendingLocalDate: settings.dailyStartPendingLocalDate,
dailyStartLastOutcome: settings.dailyStartLastOutcome,
scheduledStartEpochMs: settings.scheduledStartEpochMs,
nextDailyStartEpochMs: nextDailyStartEpochMs(settings)
} as RendererSettings;
}
+4 -1
View File
@@ -7,11 +7,14 @@ export function overlayLiveUsageCounters(target: AppSettings, liveSettings: AppS
const debridLinkKeyIds = new Set(getDebridLinkApiKeyIds(target.debridLinkApiKeys));
const megaAccountIds = new Set(getMegaDebridAccountIds(mergeMegaDebridCredentialPools(target.megaDebridApiCredentials || "", target.megaDebridWebCredentials || "") || target.megaCredentials || "", target.megaPassword || ""));
const realDebridAccountIds = new Set(getRealDebridAccountIds(target));
const targetDeepbridApiKey = target.deepbridApiKey.trim();
const liveDeepbridApiKey = liveSettings.deepbridApiKey.trim();
const validAccountIds = new Set([
...debridLinkKeyIds,
...megaAccountIds,
...realDebridAccountIds,
...(realDebridAccountIds.size === 0 && (target.realDebridUseWebLogin || target.token.trim()) ? ["svc-realdebrid"] : [])
...(realDebridAccountIds.size === 0 && (target.realDebridUseWebLogin || target.token.trim()) ? ["svc-realdebrid"] : []),
...(targetDeepbridApiKey && targetDeepbridApiKey === liveDeepbridApiKey ? ["svc-deepbrid"] : [])
]);
target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
+2
View File
@@ -21,6 +21,7 @@ const providers = new Set<DebridProvider>([
"megadebrid-web",
"bestdebrid",
"alldebrid",
"deepbrid",
"ddownload",
"onefichier",
"debridlink",
@@ -41,6 +42,7 @@ const providerLabels: Record<DebridProvider, string> = {
"megadebrid-web": "Mega-Debrid Web",
bestdebrid: "BestDebrid",
alldebrid: "AllDebrid",
deepbrid: "Deepbrid",
ddownload: "DDownload",
onefichier: "1Fichier",
debridlink: "Debrid-Link",
+229 -84
View File
@@ -5,12 +5,14 @@ import path from "node:path";
import { randomUUID } from "node:crypto";
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
import { AppSettings, AudioStripSummary, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PackageEntry, PackagePriority, SessionState } from "../shared/types";
import { AppSettings, ArchiveOperationMetric, AudioStripSummary, BandwidthScheduleEntry, DailyStartOutcome, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, FailurePhase, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PackageEntry, PackagePriority, RemuxOperationMetric, SessionState } from "../shared/types";
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
import { getRealDebridAccountIds, normalizeRealDebridWebAccountIds, parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../shared/real-debrid-accounts";
import { defaultSettings } from "./constants";
import { needsPersistedSettingsRewrite, protectPersistedSettings, restorePersistedSettings } from "./credential-protection";
import { logger } from "./logger";
import { isValidLocalDate } from "./daily-start-scheduler";
import { projectPackageFailureCategory } from "./package-telemetry";
export function migrateProductUserDataDirectory(appDataPath: string): string {
const legacyPath = path.join(appDataPath, "Real-Debrid-Downloader");
@@ -26,8 +28,8 @@ export function migrateProductUserDataDirectory(appDataPath: string): string {
}
}
const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"]);
const VALID_FALLBACK_PROVIDERS = new Set(["none", "realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"]);
const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "deepbrid", "ddownload", "onefichier", "debridlink", "linksnappy"]);
const VALID_FALLBACK_PROVIDERS = new Set(["none", "realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "deepbrid", "ddownload", "onefichier", "debridlink", "linksnappy"]);
const VALID_CLEANUP_MODES = new Set(["none", "trash", "delete"]);
const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]);
const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "package_done"]);
@@ -40,9 +42,12 @@ const VALID_PACKAGE_PRIORITIES = new Set<string>(["high", "normal", "low"]);
const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([
"queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled"
]);
const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]);
const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]);
const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "deepbrid", "ddownload", "onefichier", "debridlink"]);
const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]);
const VALID_OPERATION_STATUSES = new Set(["completed", "failed", "cancelled"]);
const VALID_HISTORY_STATUSES = new Set(["completed", "partial", "failed", "cancelled", "deleted"]);
const VALID_FAILURE_PHASES = new Set(["download", "extract", "remux", "cleanup"]);
const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
function asText(value: unknown): string {
return String(value ?? "").trim();
@@ -287,9 +292,10 @@ function normalizeDebridAccountStatuses(
megaIds: string[],
debridLinkIds: string[],
realDebridIds: string[],
legacyRealDebridTargetId: string | null
legacyRealDebridTargetId: string | null,
deepbridConfigured: boolean
): Record<string, DebridAccountStatus> {
const allowed = new Set([...megaIds, ...debridLinkIds, ...realDebridIds]);
const allowed = new Set([...megaIds, ...debridLinkIds, ...realDebridIds, ...(deepbridConfigured ? ["svc-deepbrid"] : [])]);
const result: Record<string, DebridAccountStatus> = {};
if (value && typeof value === "object" && !Array.isArray(value)) {
for (const [storedKey, raw] of Object.entries(value as Record<string, unknown>)) {
@@ -304,11 +310,13 @@ function normalizeDebridAccountStatuses(
if (typeof entry.accountId !== "string" || typeof entry.checkedAt !== "number") {
continue;
}
const provider = entry.provider === "debridlink"
? "debridlink"
: entry.provider === "realdebrid"
? "realdebrid"
: "megadebrid";
const provider = key === "svc-deepbrid"
? "deepbrid"
: entry.provider === "debridlink"
? "debridlink"
: entry.provider === "realdebrid"
? "realdebrid"
: "megadebrid";
let username = typeof entry.username === "string" ? entry.username : undefined;
let email = typeof entry.email === "string" ? entry.email : undefined;
if (provider === "debridlink" && !username && email && !email.includes("@")) {
@@ -413,10 +421,14 @@ function migrateUpdateRepo(raw: string, fallback: string): string {
return trimmed;
}
export function normalizeSettings(settings: AppSettings): AppSettings {
const defaults = defaultSettings();
const directorySettings = migrateLegacyDefaultDirectories(settings, defaults);
const currentUsageDay = getProviderUsageDayKey();
export function normalizeSettings(settings: AppSettings): AppSettings {
const defaults = defaultSettings();
const directorySettings = migrateLegacyDefaultDirectories(settings, defaults);
const legacySuccessMode = settings.notifyOnPackageCompleted === true ? "individual" : "digest";
const notifyPackageSuccessMode = settings.notifyPackageSuccessMode === "individual" || settings.notifyPackageSuccessMode === "digest"
? settings.notifyPackageSuccessMode
: legacySuccessMode;
const currentUsageDay = getProviderUsageDayKey();
const legacyMegaLogin = asText(settings.megaLogin);
const legacyMegaPassword = asText(settings.megaPassword);
let legacyMegaCredentials = String(settings.megaCredentials ?? "").replace(/\r\n|\r/g, "\n").trim();
@@ -487,7 +499,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
const providerDailyUsageDay = /^\d{4}-\d{2}-\d{2}$/.test(providerDailyUsageDayRaw)
? providerDailyUsageDayRaw
: currentUsageDay;
const debridLinkApiKeyIds = getDebridLinkApiKeyIds(String(settings.debridLinkApiKeys ?? ""));
const debridLinkApiKeyIds = getDebridLinkApiKeyIds(String(settings.debridLinkApiKeys ?? ""));
const deepbridApiKey = asText(settings.deepbridApiKey);
const providerDailyUsageBytes = normalizeProviderByteMap(
settings.providerDailyUsageBytes,
megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled,
@@ -510,8 +523,9 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
settings.debridLinkApiKeyTotalUsageBytes,
debridLinkApiKeyIds
);
const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds);
const normalized: AppSettings = {
const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds);
const validDailyStartOutcomes = new Set<DailyStartOutcome>(["", "started", "already_active", "empty_queue", "missing_account", "start_failed", "missed"]);
const normalized: AppSettings = {
language: settings.language === "de" ? "de" : "en",
token: asText(settings.token),
realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin),
@@ -533,9 +547,10 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
megaDebridPreferApi,
bestToken: asText(settings.bestToken),
bestDebridUseWebLogin: Boolean(settings.bestDebridUseWebLogin),
allDebridToken: asText(settings.allDebridToken),
allDebridUseWebLogin: Boolean(settings.allDebridUseWebLogin),
ddownloadLogin: asText(settings.ddownloadLogin),
allDebridToken: asText(settings.allDebridToken),
allDebridUseWebLogin: Boolean(settings.allDebridUseWebLogin),
deepbridApiKey,
ddownloadLogin: asText(settings.ddownloadLogin),
ddownloadPassword: asText(settings.ddownloadPassword),
oneFichierApiKey: asText(settings.oneFichierApiKey),
debridLinkApiKeys: String(settings.debridLinkApiKeys ?? "").replace(/\r\n|\r/g, "\n").trim(),
@@ -604,9 +619,16 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
backupIncludeRemoteDiagnostics: settings.backupIncludeRemoteDiagnostics !== undefined ? Boolean(settings.backupIncludeRemoteDiagnostics) : defaults.backupIncludeRemoteDiagnostics,
notifyUrl: asText(settings.notifyUrl) || defaults.notifyUrl,
notifyMention: asText(settings.notifyMention) || defaults.notifyMention,
notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted,
notifyOnPackageFailed: settings.notifyOnPackageFailed !== undefined ? Boolean(settings.notifyOnPackageFailed) : defaults.notifyOnPackageFailed,
notifyOnRunFinished: settings.notifyOnRunFinished !== undefined ? Boolean(settings.notifyOnRunFinished) : defaults.notifyOnRunFinished,
notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted,
notifyOnPackageFailed: settings.notifyOnPackageFailed !== undefined ? Boolean(settings.notifyOnPackageFailed) : defaults.notifyOnPackageFailed,
notifyOnRunFinished: settings.notifyOnRunFinished !== undefined ? Boolean(settings.notifyOnRunFinished) : defaults.notifyOnRunFinished,
notifyPackageSuccessMode,
notifyOnRemainingBelow: settings.notifyOnRemainingBelow !== undefined ? Boolean(settings.notifyOnRemainingBelow) : defaults.notifyOnRemainingBelow,
notifyRemainingThresholdGb: clampNumber(settings.notifyRemainingThresholdGb, 50, 1, 100000),
notifyOnDownloadStall: settings.notifyOnDownloadStall !== undefined ? Boolean(settings.notifyOnDownloadStall) : defaults.notifyOnDownloadStall,
notifyStallAfterSeconds: clampNumber(settings.notifyStallAfterSeconds, 90, 60, 3600),
notifyStallCooldownMinutes: clampNumber(settings.notifyStallCooldownMinutes, 10, 5, 1440),
notifyOnDownloadRecovery: settings.notifyOnDownloadRecovery !== undefined ? Boolean(settings.notifyOnDownloadRecovery) : defaults.notifyOnDownloadRecovery,
totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime,
totalCompletedFilesAllTime: typeof settings.totalCompletedFilesAllTime === "number" && settings.totalCompletedFilesAllTime >= 0 ? settings.totalCompletedFilesAllTime : defaults.totalCompletedFilesAllTime,
totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs,
@@ -641,11 +663,18 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
megaDebridAccountIds,
debridLinkApiKeyIds,
realDebridAccountIds,
legacyRealDebridTargetId
legacyRealDebridTargetId,
Boolean(deepbridApiKey)
),
providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay,
scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER)
};
providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay,
dailyStartEnabled: settings.dailyStartEnabled !== undefined ? Boolean(settings.dailyStartEnabled) : defaults.dailyStartEnabled,
dailyStartMinuteOfDay: clampNumber(settings.dailyStartMinuteOfDay, defaults.dailyStartMinuteOfDay, 0, 1_439),
dailyStartFirstLocalDate: isValidLocalDate(asText(settings.dailyStartFirstLocalDate)) ? asText(settings.dailyStartFirstLocalDate) : "",
dailyStartLastHandledLocalDate: isValidLocalDate(asText(settings.dailyStartLastHandledLocalDate)) ? asText(settings.dailyStartLastHandledLocalDate) : "",
dailyStartPendingLocalDate: isValidLocalDate(asText(settings.dailyStartPendingLocalDate)) ? asText(settings.dailyStartPendingLocalDate) : "",
dailyStartLastOutcome: validDailyStartOutcomes.has(settings.dailyStartLastOutcome) ? settings.dailyStartLastOutcome : "",
scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER)
};
if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) {
normalized.providerPrimary = defaults.providerPrimary;
@@ -682,22 +711,26 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
}
export interface StoragePaths {
baseDir: string;
configFile: string;
sessionFile: string;
baseDir: string;
configFile: string;
sessionFile: string;
historyFile: string;
statisticsFile: string;
}
notificationOutboxFile: string;
notificationHealthFile: string;
}
export function createStoragePaths(baseDir: string): StoragePaths {
return {
baseDir,
configFile: path.join(baseDir, "rd_downloader_config.json"),
sessionFile: path.join(baseDir, "rd_session_state.json"),
configFile: path.join(baseDir, "rd_downloader_config.json"),
sessionFile: path.join(baseDir, "rd_session_state.json"),
historyFile: path.join(baseDir, "rd_history.json"),
statisticsFile: path.join(baseDir, "rd_statistics.json")
};
}
statisticsFile: path.join(baseDir, "rd_statistics.json"),
notificationOutboxFile: path.join(baseDir, "rd_notification_outbox.json"),
notificationHealthFile: path.join(baseDir, "rd_notification_health.json")
};
}
function ensureBaseDir(baseDir: string): void {
try {
@@ -725,7 +758,7 @@ function asRecord(value: unknown): Record<string, unknown> | null {
return value as Record<string, unknown>;
}
function normalizeAudioStripSummary(raw: unknown): AudioStripSummary | undefined {
function normalizeAudioStripSummary(raw: unknown): AudioStripSummary | undefined {
const parsed = asRecord(raw);
if (!parsed) {
return undefined;
@@ -753,9 +786,76 @@ function normalizeAudioStripSummary(raw: unknown): AudioStripSummary | undefined
skippedNoTool: clampNumber(parsed.skippedNoTool, 0, 0, 1_000_000),
failed: clampNumber(parsed.failed, 0, 0, 1_000_000),
files
};
}
};
}
function normalizeFailureCategory(phase: FailurePhase, detail: unknown): string {
const value = asText(detail);
return value ? projectPackageFailureCategory(phase, value) : "";
}
function normalizeArchiveOperations(raw: unknown): ArchiveOperationMetric[] {
if (!Array.isArray(raw)) {
return [];
}
return raw.slice(0, 10_000).flatMap((value) => {
const operation = asRecord(value);
if (!operation) {
return [];
}
const id = normalizeSessionId(operation.id);
const status = asText(operation.status);
if (!id || !VALID_OPERATION_STATUSES.has(status)) {
return [];
}
return [{
id,
name: asText(operation.name),
itemIds: Array.isArray(operation.itemIds)
? operation.itemIds.map(normalizeSessionId).filter(Boolean).slice(0, 100_000)
: [],
partCount: clampNumber(operation.partCount, 0, 0, 100_000),
startedAt: clampNumber(operation.startedAt, 0, 0, Number.MAX_SAFE_INTEGER),
completedAt: clampNumber(operation.completedAt, 0, 0, Number.MAX_SAFE_INTEGER),
durationMs: clampNumber(operation.durationMs, 0, 0, Number.MAX_SAFE_INTEGER),
status: status as ArchiveOperationMetric["status"],
errorCategory: normalizeFailureCategory("extract", operation.errorCategory)
}];
});
}
function normalizeRemuxOperations(raw: unknown): RemuxOperationMetric[] {
if (!Array.isArray(raw)) {
return [];
}
return raw.slice(0, 10_000).flatMap((value) => {
const operation = asRecord(value);
if (!operation) {
return [];
}
const id = normalizeSessionId(operation.id);
const status = asText(operation.status);
if (!id || !VALID_OPERATION_STATUSES.has(status)) {
return [];
}
return [{
id,
fileName: asText(operation.fileName),
startedAt: clampNumber(operation.startedAt, 0, 0, Number.MAX_SAFE_INTEGER),
completedAt: clampNumber(operation.completedAt, 0, 0, Number.MAX_SAFE_INTEGER),
durationMs: clampNumber(operation.durationMs, 0, 0, Number.MAX_SAFE_INTEGER),
status: status as RemuxOperationMetric["status"],
errorCategory: normalizeFailureCategory("remux", operation.errorCategory)
}];
});
}
function optionalClampedNumber(record: Record<string, unknown>, key: string, max = Number.MAX_SAFE_INTEGER): number | undefined {
return Object.prototype.hasOwnProperty.call(record, key)
? clampNumber(record[key], 0, 0, max)
: undefined;
}
function migrateLegacyMegaEnableFlags(parsed: AppSettings): AppSettings {
if (parsed.megaDebridApiEnabled !== undefined || parsed.megaDebridWebEnabled !== undefined) {
return parsed;
@@ -801,6 +901,9 @@ function readSettingsFile(filePath: string): LoadedSettingsFile | null {
if (!Object.prototype.hasOwnProperty.call(parsed, "realDebridWebAccountIds")) {
delete (mergedInput as Partial<AppSettings>).realDebridWebAccountIds;
}
if (!Object.prototype.hasOwnProperty.call(parsed, "notifyPackageSuccessMode")) {
delete (mergedInput as Partial<AppSettings>).notifyPackageSuccessMode;
}
const merged = normalizeSettings(mergedInput);
return { settings: merged, needsCredentialRewrite };
} catch (error) {
@@ -907,8 +1010,18 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
? [...new Set(pkg.cleanedProviders.map((value) => asText(value) as DebridProvider).filter((value) => VALID_ITEM_PROVIDERS.has(value)))]
: [],
downloadStartedAt: clampNumber(pkg.downloadStartedAt, 0, 0, Number.MAX_SAFE_INTEGER),
downloadCompletedAt: clampNumber(pkg.downloadCompletedAt, 0, 0, Number.MAX_SAFE_INTEGER),
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
downloadCompletedAt: clampNumber(pkg.downloadCompletedAt, 0, 0, Number.MAX_SAFE_INTEGER),
downloadEndedAt: clampNumber(pkg.downloadEndedAt, 0, 0, Number.MAX_SAFE_INTEGER),
postProcessQueuedAt: clampNumber(pkg.postProcessQueuedAt, 0, 0, Number.MAX_SAFE_INTEGER),
postProcessStartedAt: clampNumber(pkg.postProcessStartedAt, 0, 0, Number.MAX_SAFE_INTEGER),
postProcessCompletedAt: clampNumber(pkg.postProcessCompletedAt, 0, 0, Number.MAX_SAFE_INTEGER),
terminalAt: clampNumber(pkg.terminalAt, 0, 0, Number.MAX_SAFE_INTEGER),
archiveOperations: normalizeArchiveOperations(pkg.archiveOperations),
remuxOperations: normalizeRemuxOperations(pkg.remuxOperations),
outputCount: clampNumber(pkg.outputCount, 0, 0, 1_000_000),
cleanupErrorCategory: normalizeFailureCategory("cleanup", pkg.cleanupErrorCategory),
resultGeneration: clampNumber(pkg.resultGeneration, 1, 1, Number.MAX_SAFE_INTEGER),
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
};
}
@@ -1118,7 +1231,12 @@ function sleepSyncMs(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function readSessionFile(filePath: string): SessionState | null {
interface LoadedSessionFile {
session: SessionState;
wasRunning: boolean;
}
function readSessionFile(filePath: string): LoadedSessionFile | null {
let raw: string | null = null;
const maxAttempts = 5;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
@@ -1144,13 +1262,15 @@ function readSessionFile(filePath: string): SessionState | null {
if (raw === null) {
return null;
}
try {
const parsed = JSON.parse(raw) as unknown;
const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed));
try {
const parsed = JSON.parse(raw) as unknown;
const normalized = normalizeLoadedSession(parsed);
const wasRunning = normalized.running;
const session = normalizeLoadedSessionTransientFields(normalized);
const pkgCount = Object.keys(session.packages).length;
const itemCount = Object.keys(session.items).length;
logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`);
return session;
return { session, wasRunning };
} catch (error) {
logger.error(`Session-Datei beschädigt (JSON ungültig): ${filePath}: ${String(error)}`);
return null;
@@ -1264,10 +1384,11 @@ export type SessionLoadStatus =
| "empty-fresh"
| "empty-unreadable";
export interface SessionLoadResult {
session: SessionState;
status: SessionLoadStatus;
}
export interface SessionLoadResult {
session: SessionState;
status: SessionLoadStatus;
wasRunning: boolean;
}
export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
ensureBaseDir(paths.baseDir);
@@ -1281,69 +1402,69 @@ export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
if (!primaryExists) {
if (!backupExists && !anyTempExists) {
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
return { session: emptySession(), status: "empty-fresh" };
return { session: emptySession(), status: "empty-fresh", wasRunning: false };
}
logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht");
}
const primary = primaryExists ? readSessionFile(paths.sessionFile) : null;
if (primary) {
const primaryPkgCount = Object.keys(primary.packages).length;
const primary = primaryExists ? readSessionFile(paths.sessionFile) : null;
if (primary) {
const primaryPkgCount = Object.keys(primary.session.packages).length;
if (primaryPkgCount === 0 && backupExists) {
const backup = readSessionFile(backupFile);
if (backup) {
const backupPkgCount = Object.keys(backup.packages).length;
const backup = readSessionFile(backupFile);
if (backup) {
const backupPkgCount = Object.keys(backup.session.packages).length;
if (backupPkgCount > 0) {
logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`);
try {
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
const payload = JSON.stringify({ ...backup.session, updatedAt: Date.now() }, safeJsonReplacer);
fs.writeFileSync(syncTempFile, payload, "utf8");
syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
} catch {
}
return { session: backup, status: "recovered-backup" };
return { session: backup.session, status: "recovered-backup", wasRunning: backup.wasRunning };
}
}
}
return { session: primary, status: "ok" };
return { session: primary.session, status: "ok", wasRunning: primary.wasRunning };
}
const backup = backupExists ? readSessionFile(backupFile) : null;
if (backup) {
logger.warn("Session defekt, Backup-Datei wird verwendet");
try {
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
const payload = JSON.stringify({ ...backup.session, updatedAt: Date.now() }, safeJsonReplacer);
fs.writeFileSync(syncTempFile, payload, "utf8");
syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
} catch {
}
return { session: backup, status: "recovered-backup" };
return { session: backup.session, status: "recovered-backup", wasRunning: backup.wasRunning };
}
for (const kind of ["sync", "async"] as const) {
const tmpPath = sessionTempPath(paths.sessionFile, kind);
if (fs.existsSync(tmpPath)) {
const tmpSession = readSessionFile(tmpPath);
if (tmpSession && Object.keys(tmpSession.packages).length > 0) {
logger.warn(`Session aus temporaerer Datei wiederhergestellt: ${tmpPath} (${Object.keys(tmpSession.packages).length} Pakete)`);
try {
const payload = JSON.stringify({ ...tmpSession, updatedAt: Date.now() }, safeJsonReplacer);
const tmpSession = readSessionFile(tmpPath);
if (tmpSession && Object.keys(tmpSession.session.packages).length > 0) {
logger.warn(`Session aus temporaerer Datei wiederhergestellt: ${tmpPath} (${Object.keys(tmpSession.session.packages).length} Pakete)`);
try {
const payload = JSON.stringify({ ...tmpSession.session, updatedAt: Date.now() }, safeJsonReplacer);
fs.writeFileSync(paths.sessionFile, payload, "utf8");
} catch {
}
return { session: tmpSession, status: "recovered-temp" };
return { session: tmpSession.session, status: "recovered-temp", wasRunning: tmpSession.wasRunning };
}
}
}
if (primaryExists || backupExists || anyTempExists) {
logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen) — Schutz gegen leeres Ueberschreiben aktiv");
return { session: emptySession(), status: "empty-unreadable" };
return { session: emptySession(), status: "empty-unreadable", wasRunning: false };
}
return { session: emptySession(), status: "empty-fresh" };
}
return { session: emptySession(), status: "empty-fresh", wasRunning: false };
}
export function loadSession(paths: StoragePaths): SessionState {
return loadSessionWithStatus(paths).session;
@@ -1467,9 +1588,27 @@ export function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry
const entry = asRecord(raw);
if (!entry) return null;
const id = asText(entry.id) || `hist-${Date.now().toString(36)}-${index}`;
const name = asText(entry.name) || "Unbenannt";
const providerRaw = asText(entry.provider);
const id = asText(entry.id) || `hist-${Date.now().toString(36)}-${index}`;
const name = asText(entry.name) || "Unbenannt";
const providerRaw = asText(entry.provider);
const statusRaw = asText(entry.status);
const failurePhaseRaw = entry.failurePhase === null ? null : asText(entry.failurePhase);
const optionalFields = {
startedAt: optionalClampedNumber(entry, "startedAt"),
downloadEndedAt: optionalClampedNumber(entry, "downloadEndedAt"),
postProcessStartedAt: optionalClampedNumber(entry, "postProcessStartedAt"),
downloadDurationSeconds: optionalClampedNumber(entry, "downloadDurationSeconds"),
extractionDurationSeconds: optionalClampedNumber(entry, "extractionDurationSeconds"),
remuxDurationSeconds: optionalClampedNumber(entry, "remuxDurationSeconds"),
postProcessDurationSeconds: optionalClampedNumber(entry, "postProcessDurationSeconds"),
totalDurationSeconds: optionalClampedNumber(entry, "totalDurationSeconds"),
successfulFiles: optionalClampedNumber(entry, "successfulFiles", 1_000_000),
failedFiles: optionalClampedNumber(entry, "failedFiles", 1_000_000),
cancelledFiles: optionalClampedNumber(entry, "cancelledFiles", 1_000_000),
archiveCount: optionalClampedNumber(entry, "archiveCount", 100_000),
partCount: optionalClampedNumber(entry, "partCount", 1_000_000),
outputCount: optionalClampedNumber(entry, "outputCount", 1_000_000)
};
return {
id,
@@ -1480,11 +1619,17 @@ export function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry
provider: VALID_ITEM_PROVIDERS.has(providerRaw as DebridProvider) ? providerRaw as DebridProvider : null,
completedAt: clampNumber(entry.completedAt, Date.now(), 0, Number.MAX_SAFE_INTEGER),
durationSeconds: clampNumber(entry.durationSeconds, 0, 0, Number.MAX_SAFE_INTEGER),
status: entry.status === "deleted" ? "deleted" : "completed",
outputDir: asText(entry.outputDir),
urls: Array.isArray(entry.urls) ? (entry.urls as unknown[]).map(String).filter(Boolean) : undefined
};
}
status: VALID_HISTORY_STATUSES.has(statusRaw) ? statusRaw as HistoryEntry["status"] : "completed",
outputDir: asText(entry.outputDir),
urls: Array.isArray(entry.urls) ? (entry.urls as unknown[]).map(String).filter(Boolean) : undefined,
...Object.fromEntries(Object.entries(optionalFields).filter(([, value]) => value !== undefined)),
...(failurePhaseRaw === null || VALID_FAILURE_PHASES.has(failurePhaseRaw)
? { failurePhase: failurePhaseRaw as FailurePhase }
: {}),
...(Array.isArray(entry.archiveOperations) ? { archiveOperations: normalizeArchiveOperations(entry.archiveOperations) } : {}),
...(Array.isArray(entry.remuxOperations) ? { remuxOperations: normalizeRemuxOperations(entry.remuxOperations) } : {})
};
}
export function loadHistory(paths: StoragePaths, limits?: HistoryLimits): HistoryEntry[] {
ensureBaseDir(paths.baseDir);
+10 -7
View File
@@ -13,7 +13,7 @@ import { getRenameLogPath } from "./rename-log";
import { getDesktopRenameLogPath } from "./desktop-rename-log";
import { getSessionLogPath } from "./session-log";
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, normalizeNotificationSupportPayload, summarizeHistoryEntry, type NotificationSupportPayload } from "./support-data";
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
import type { DownloadManager } from "./download-manager";
@@ -99,9 +99,10 @@ export function getSupportBundleDefaultFileName(): string {
type HostDiagnosticsMode = "full" | "cached" | "none";
interface BuildSupportBundleOptions {
hostDiagnosticsMode?: HostDiagnosticsMode;
}
interface BuildSupportBundleOptions {
hostDiagnosticsMode?: HostDiagnosticsMode;
notificationStatus?: NotificationSupportPayload;
}
function createDeferredHostDiagnostics(reason: string): unknown {
return {
@@ -144,7 +145,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
const snapshot = manager.getSnapshot();
const packageIds = Object.keys(snapshot.session.packages);
const itemIds = Object.keys(snapshot.session.items);
const debugSetup = getDebugSetupCheck(baseDir);
const debugSetup = getDebugSetupCheck(baseDir);
const notificationStatus = normalizeNotificationSupportPayload(options.notificationStatus);
addJson(zip, "overview/meta.json", {
appVersion: APP_VERSION,
@@ -156,14 +158,15 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
addJson(zip, "overview/status.json", snapshot.session);
addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings));
addJson(zip, "overview/accounts.json", buildAccountSummary(settings));
addJson(zip, "overview/stats.json", {
addJson(zip, "overview/stats.json", {
...buildStatsPayload(snapshot),
allTime: {
totalDownloadedAllTime: settings.totalDownloadedAllTime,
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
}
});
});
addJson(zip, "overview/notifications.json", notificationStatus);
addJson(zip, "overview/debug-setup.json", debugSetup);
addJson(zip, "overview/self-check.json", debugSetup);
addJson(zip, "overview/history.json", {
+78 -10
View File
@@ -1,17 +1,69 @@
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
import { getRealDebridAccounts } from "../shared/real-debrid-accounts";
import { isNotifyUrlValid } from "./notify";
import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types";
import { isNotifyUrlValid } from "./notify";
import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types";
import type { DownloadHealthState } from "./download-health-monitor";
import type { NotificationOutboxStatus } from "./notification-outbox";
function hasText(value: unknown): boolean {
return String(value || "").trim().length > 0;
}
function hasText(value: unknown): boolean {
return String(value || "").trim().length > 0;
}
export interface NotificationSupportPayload {
queued: number;
lastSuccessAt: number | null;
incidentType: DownloadHealthState["incidentType"];
incidentAgeMs: number | null;
}
export function normalizeNotificationSupportPayload(
value?: Partial<NotificationSupportPayload> | null
): NotificationSupportPayload {
const rawQueued = value?.queued;
const rawLastSuccessAt = value?.lastSuccessAt;
const rawIncidentAgeMs = value?.incidentAgeMs;
const queued = typeof rawQueued === "number" && Number.isFinite(rawQueued)
? Math.max(0, Math.floor(rawQueued))
: 0;
const lastSuccessAt = typeof rawLastSuccessAt === "number" && Number.isFinite(rawLastSuccessAt) && rawLastSuccessAt > 0
? Math.floor(rawLastSuccessAt)
: null;
const incidentType = value?.incidentType === "scheduler" || value?.incidentType === "no_data"
? value.incidentType
: null;
const incidentAgeMs = incidentType && typeof rawIncidentAgeMs === "number" && Number.isFinite(rawIncidentAgeMs) && rawIncidentAgeMs >= 0
? Math.floor(rawIncidentAgeMs)
: null;
return { queued, lastSuccessAt, incidentType, incidentAgeMs };
}
export function buildNotificationSupportPayload(
outbox: Pick<NotificationOutboxStatus, "queued" | "lastSuccessAt">,
health: Pick<DownloadHealthState, "incidentType" | "incidentStartedAt">,
now: number = Date.now()
): NotificationSupportPayload {
const queued = Number.isFinite(outbox.queued) ? Math.max(0, Math.floor(outbox.queued)) : 0;
const lastSuccessAt = Number.isFinite(outbox.lastSuccessAt) && outbox.lastSuccessAt > 0
? Math.floor(outbox.lastSuccessAt)
: null;
const incidentType = health.incidentType === "scheduler" || health.incidentType === "no_data"
? health.incidentType
: null;
const incidentStartedAt = Number.isFinite(health.incidentStartedAt) && health.incidentStartedAt > 0
? Math.floor(health.incidentStartedAt)
: 0;
const incidentAgeMs = incidentType && incidentStartedAt > 0
? Math.max(0, Math.floor(Number.isFinite(now) ? now : Date.now()) - incidentStartedAt)
: null;
return normalizeNotificationSupportPayload({ queued, lastSuccessAt, incidentType, incidentAgeMs });
}
export function buildAccountSummary(settings: AppSettings): Record<string, unknown> {
const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys);
const disabledDebridLinkIds = new Set(settings.debridLinkDisabledKeyIds || []);
const realDebridAccounts = getRealDebridAccounts(settings);
const enabledRealDebridAccounts = realDebridAccounts.filter((account) => account.enabled);
const deepbridStatus = settings.debridAccountStatuses?.["svc-deepbrid"];
return {
realDebrid: {
@@ -39,11 +91,27 @@ export function buildAccountSummary(settings: AppSettings): Record<string, unkno
tokenConfigured: hasText(settings.bestToken),
webLoginEnabled: settings.bestDebridUseWebLogin
},
allDebrid: {
configured: hasText(settings.allDebridToken) || settings.allDebridUseWebLogin,
tokenConfigured: hasText(settings.allDebridToken),
webLoginEnabled: settings.allDebridUseWebLogin
},
allDebrid: {
configured: hasText(settings.allDebridToken) || settings.allDebridUseWebLogin,
tokenConfigured: hasText(settings.allDebridToken),
webLoginEnabled: settings.allDebridUseWebLogin
},
deepbrid: {
configured: hasText(settings.deepbridApiKey),
apiKeyConfigured: hasText(settings.deepbridApiKey),
status: {
checked: Boolean(deepbridStatus),
valid: deepbridStatus?.valid ?? null,
premium: deepbridStatus?.isPremium ?? null,
premiumUntilMs: deepbridStatus?.premiumUntilMs ?? null,
checkedAt: deepbridStatus?.checkedAt ?? null
},
usage: {
dailyLimitBytes: settings.providerDailyLimitBytes.deepbrid || 0,
dailyUsageBytes: settings.providerDailyUsageBytes.deepbrid || 0,
totalUsageBytes: settings.providerTotalUsageBytes.deepbrid || 0
}
},
ddownload: {
configured: hasText(settings.ddownloadLogin) && hasText(settings.ddownloadPassword)
},
+226 -42
View File
@@ -106,6 +106,7 @@ import {
DownloadsSidebar,
DownloadsSidebarStatus,
DownloadsToolbar,
type DailyScheduleStartDay,
type DownloadsViewActions,
type DownloadsViewModel
} from "./views/downloads/DownloadsView";
@@ -115,6 +116,7 @@ import {
buildTargetedAccountCheck,
projectAccountRows,
resolveHistoryRetentionSelection,
normalizeNotificationNumberField,
sortAccountRows,
formatAccountContextHeading,
type AccountAddOption,
@@ -329,7 +331,7 @@ interface RendererSettingsDraft extends RendererSettings {
notifyUrl: string;
}
function createSettingsDraft(settings: RendererSettings, current?: RendererSettingsDraft): RendererSettingsDraft {
export function createSettingsDraft(settings: RendererSettings, current?: RendererSettingsDraft): RendererSettingsDraft {
return {
...settings,
archivePasswordList: current?.archivePasswordList || "",
@@ -503,14 +505,23 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
pickerDescription: "Direkter Zugriff über API-Key.",
needsToken: true
},
{
kind: "alldebrid-web",
{
kind: "alldebrid-web",
service: "alldebrid",
serviceLabel: "AllDebrid",
title: "AllDebrid Web-Login",
modeLabel: "Web-Login",
pickerDescription: "Login über Browserfenster für reCAPTCHA.",
},
pickerDescription: "Login über Browserfenster für reCAPTCHA.",
},
{
kind: "deepbrid-api",
service: "deepbrid",
serviceLabel: "Deepbrid",
title: "Deepbrid API",
modeLabel: "API",
pickerDescription: "Direkter Zugriff über API-Key.",
needsToken: true
},
{
kind: "ddownload-login",
service: "ddownload",
@@ -549,7 +560,7 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
}
];
const ACCOUNT_SERVICES: AccountService[] = ["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"];
const ACCOUNT_SERVICES: AccountService[] = ["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "deepbrid", "ddownload", "onefichier", "debridlink", "linksnappy"];
const ACCOUNT_LIMIT_BYTES_PER_GIB = 1024 * 1024 * 1024;
function findAccountOption(kind: AccountKind): AccountOption {
const option = ACCOUNT_OPTIONS.find((entry) => entry.kind === kind);
@@ -589,9 +600,10 @@ function getAccountPickerFunctionLabel(option: AccountOption): string {
return "Login:Passwort (Web)";
case "bestdebrid-web":
return "Cookies.txt-Import";
case "alldebrid-api":
case "onefichier-api":
return "API-Key";
case "alldebrid-api":
case "deepbrid-api":
case "onefichier-api":
return "API-Key";
case "ddownload-login":
return "Login + Passwort";
default:
@@ -613,6 +625,7 @@ function getAccountCredentialLabel(kind: AccountKind): string {
case "realdebrid-api":
case "bestdebrid-api":
case "alldebrid-api":
case "deepbrid-api":
case "onefichier-api":
case "debridlink-api":
return "API-Key gespeichert";
@@ -637,7 +650,7 @@ function normalizeProviderOrderForSettings(settings: RendererSettings): DebridPr
return buildConfiguredProviderOrder(settings.providerOrder || [], configured);
}
function normalizeProviderSelectionForSettings(
function normalizeProviderSelectionForSettings(
settings: RendererSettings
): Pick<RendererSettings, "providerOrder" | "providerPrimary" | "providerSecondary" | "providerTertiary"> {
const providerOrder = normalizeProviderOrderForSettings(settings);
@@ -646,9 +659,15 @@ function normalizeProviderSelectionForSettings(
providerPrimary: providerOrder[0] ?? settings.providerPrimary,
providerSecondary: (providerOrder[1] ?? "none") as DebridFallbackProvider,
providerTertiary: (providerOrder[2] ?? "none") as DebridFallbackProvider
};
}
};
}
export function buildAccountCreateProviderOrderUpdate(
settings: RendererSettings
): Pick<RendererSettings, "providerOrder" | "providerPrimary" | "providerSecondary" | "providerTertiary"> {
return normalizeProviderSelectionForSettings(settings);
}
function getConfiguredAccountKind(settings: RendererSettings, service: AccountService): AccountKind | null {
const configured = new Set(settings.configuredProviders);
switch (service) {
@@ -665,6 +684,8 @@ function getConfiguredAccountKind(settings: RendererSettings, service: AccountSe
case "alldebrid":
if (settings.allDebridUseWebLogin) return "alldebrid-web";
return configured.has("alldebrid") ? "alldebrid-api" : null;
case "deepbrid":
return configured.has("deepbrid") ? "deepbrid-api" : null;
case "ddownload":
return configured.has("ddownload") ? "ddownload-login" : null;
case "onefichier":
@@ -836,6 +857,8 @@ const emptySnapshot = (): UiSnapshot => ({
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
theme: "dark", logStorageLocation: "appdata", collapseNewPackages: true, animatePackageDisclosure: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false,
notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
notifyPackageSuccessMode: "digest", notifyOnRemainingBelow: false, notifyRemainingThresholdGb: 50,
notifyOnDownloadStall: false, notifyStallAfterSeconds: 90, notifyStallCooldownMinutes: 10, notifyOnDownloadRecovery: true,
accountListShowDetailedDebridLinkKeys: false,
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"],
@@ -854,10 +877,17 @@ const emptySnapshot = (): UiSnapshot => ({
megaDebridWebDisabledAccountIds: [],
megaDebridAccountDailyLimitBytes: {},
megaDebridAccountDailyUsageBytes: {},
megaDebridAccountTotalUsageBytes: {},
debridAccountStatuses: {},
providerDailyUsageDay: getProviderUsageDayKey(),
scheduledStartEpochMs: 0
megaDebridAccountTotalUsageBytes: {},
debridAccountStatuses: {},
providerDailyUsageDay: getProviderUsageDayKey(),
dailyStartEnabled: false,
dailyStartMinuteOfDay: 0,
dailyStartFirstLocalDate: "",
dailyStartLastHandledLocalDate: "",
dailyStartPendingLocalDate: "",
dailyStartLastOutcome: "",
scheduledStartEpochMs: 0,
nextDailyStartEpochMs: 0
},
accounts: [],
session: {
@@ -1532,6 +1562,100 @@ export async function runLatestUpdateCheck(
await apply(result, generation);
}
interface DailySchedulePersistenceDependencies {
updateSettings: (update: RendererSettingsUpdate) => Promise<RendererSettings>;
getSnapshot: () => Promise<UiSnapshot>;
applySettings: (settings: RendererSettings) => void;
applySnapshot: (snapshot: UiSnapshot) => void;
showError: (message: string) => void;
}
function formatDailyScheduleLocalDate(date: Date): string {
const year = String(date.getFullYear()).padStart(4, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function formatDailyScheduleTime(minuteOfDay: number): string {
const minute = Math.max(0, Math.min(1_439, Math.floor(minuteOfDay)));
return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`;
}
export function resolveDailyScheduleInitialTime(
settings: Pick<RendererSettings, "dailyStartMinuteOfDay" | "dailyStartFirstLocalDate">,
now = new Date()
): string {
const minuteOfDay = settings.dailyStartFirstLocalDate
? settings.dailyStartMinuteOfDay
: now.getHours() * 60 + now.getMinutes();
return formatDailyScheduleTime(minuteOfDay);
}
export function buildDailyScheduleSettingsUpdate(
time: string,
startDay: DailyScheduleStartDay,
now = new Date()
): RendererSettingsUpdate | null {
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(time);
if (!match) {
return null;
}
const firstDate = new Date(now);
if (startDay === "tomorrow") {
firstDate.setDate(firstDate.getDate() + 1);
}
return {
dailyStartEnabled: true,
dailyStartMinuteOfDay: Number(match[1]) * 60 + Number(match[2]),
dailyStartFirstLocalDate: formatDailyScheduleLocalDate(firstDate)
};
}
export function buildScheduleCancellationSettingsUpdate(
settings: Pick<RendererSettings, "dailyStartEnabled" | "scheduledStartEpochMs">
): RendererSettingsUpdate {
return settings.dailyStartEnabled
? { dailyStartEnabled: false }
: { scheduledStartEpochMs: 0 };
}
export async function activateDailyScheduleSettings(
time: string,
startDay: DailyScheduleStartDay,
persist: (update: RendererSettingsUpdate) => Promise<boolean>,
showError: (message: string) => void,
now = new Date()
): Promise<boolean> {
const update = buildDailyScheduleSettingsUpdate(time, startDay, now);
if (!update) {
showError("Bitte eine gültige Startzeit auswählen.");
return false;
}
return persist(update);
}
export async function persistDailyScheduleSettingsUpdate(
update: RendererSettingsUpdate,
operation: "activate" | "cancel",
dependencies: DailySchedulePersistenceDependencies
): Promise<boolean> {
try {
const settings = await dependencies.updateSettings(update);
dependencies.applySettings(settings);
return true;
} catch (error) {
const action = operation === "activate" ? "aktiviert" : "abgebrochen";
dependencies.showError(`Zeitplan konnte nicht ${action} werden: ${String(error)}`);
try {
dependencies.applySnapshot(await dependencies.getSnapshot());
} catch (snapshotError) {
dependencies.showError(`Zeitplan konnte nicht abgeglichen werden: ${String(snapshotError)}`);
}
return false;
}
}
export function App(): ReactElement {
const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot);
const [appVersion, setAppVersion] = useState("");
@@ -1546,9 +1670,10 @@ export function App(): ReactElement {
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
const [settingsDirty, setSettingsDirty] = useState(false);
const [settingsSaveState, setSettingsSaveState] = useState<SettingsSaveState>("clean");
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
const [scheduleTimeInput, setScheduleTimeInput] = useState("");
const [scheduleCountdown, setScheduleCountdown] = useState("");
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
const [scheduleTimeInput, setScheduleTimeInput] = useState("");
const [scheduleStartDay, setScheduleStartDay] = useState<DailyScheduleStartDay>("today");
const [scheduleCountdown, setScheduleCountdown] = useState("");
const [runtimeNow, setRuntimeNow] = useState(() => Date.now());
const updateCheckGenerationRef = useRef(0);
const dismissedUpdateTagRef = useRef("");
@@ -1771,7 +1896,9 @@ export function App(): ReactElement {
}, [settingsDraft.speedLimitKbps]);
useEffect(() => {
const schedMs = snapshot.settings.scheduledStartEpochMs || 0;
const schedMs = snapshot.settings.dailyStartEnabled
? snapshot.settings.nextDailyStartEpochMs
: snapshot.settings.scheduledStartEpochMs || 0;
if (schedMs <= 0) { setScheduleCountdown(""); return; }
const update = (): void => {
const remaining = schedMs - Date.now();
@@ -1785,7 +1912,7 @@ export function App(): ReactElement {
update();
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
}, [snapshot.settings.scheduledStartEpochMs]);
}, [snapshot.settings.dailyStartEnabled, snapshot.settings.nextDailyStartEpochMs, snapshot.settings.scheduledStartEpochMs]);
useEffect(() => {
const timer = setInterval(() => setRuntimeNow(Date.now()), 1000);
@@ -2514,7 +2641,9 @@ export function App(): ReactElement {
});
}
} else {
const serviceAccountId = null;
const serviceAccountId = entry.kind === "deepbrid-api"
? accountsOfKind(entry.kind, snapshot.accounts)[0]?.accountId || "svc-deepbrid"
: null;
rows.push({
rowKey: `svc-${entry.service}`,
entry,
@@ -2535,7 +2664,8 @@ export function App(): ReactElement {
rowKey: `svc-${entry.service}`,
kind: entry.kind as SingleAccountKind,
service: entry.service,
provider: entry.provider
provider: entry.provider,
accountId: serviceAccountId || undefined
}
});
}
@@ -2931,8 +3061,9 @@ export function App(): ReactElement {
const command = buildAccountCreateCommand(dialogSnapshot);
if (!command) throw new Error("Account-Payload ist ungültig");
const result = await window.rd.createAccount(command);
setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts }));
applyPersistedSettings(result.settings);
const persistedSettings = await window.rd.updateSettings(buildAccountCreateProviderOrderUpdate(result.settings));
setSnapshot((current) => ({ ...current, settings: persistedSettings, accounts: result.accounts }));
applyPersistedSettings(persistedSettings);
closeAccountDialog();
if (quickAction) {
await runAccountQuickAction(quickAction, result.accountId);
@@ -3091,7 +3222,13 @@ export function App(): ReactElement {
...settingsDraft,
disabledProviders: nextDisabledProviders
};
await persistAccountToggle(nextDraft);
const enabled = current.includes(provider);
await persistAccountToggle(
nextDraft,
enabled && entry.kind === "deepbrid-api"
? () => window.rd.checkAccountCredentials({ kind: "deepbrid-api", accountId: "svc-deepbrid" })
: undefined
);
showToast(
nextDisabledProviders.includes(provider)
? `${entry.serviceLabel} deaktiviert`
@@ -4809,16 +4946,46 @@ export function App(): ReactElement {
});
}, [setClipboardWatcherActive, showToast]);
const applyDailyScheduleSettings = useCallback((settings: RendererSettings): void => {
const apply = (current: UiSnapshot): UiSnapshot => ({ ...current, settings });
const next = apply(snapshotRef.current);
snapshotRef.current = next;
masterSnapshotRef.current = masterSnapshotRef.current ? apply(masterSnapshotRef.current) : next;
if (latestStateRef.current) {
latestStateRef.current = apply(latestStateRef.current);
}
setSnapshot(next);
}, []);
const applyAuthoritativeDailyScheduleSnapshot = useCallback((state: UiSnapshot): void => {
masterSnapshotRef.current = state;
latestStateRef.current = null;
snapshotRef.current = state;
setSnapshot(state);
}, []);
const persistDownloadSchedule = useCallback((update: RendererSettingsUpdate, operation: "activate" | "cancel"): Promise<boolean> => (
persistDailyScheduleSettingsUpdate(update, operation, {
updateSettings: (value) => window.rd.updateSettings(value),
getSnapshot: () => window.rd.getSnapshot(),
applySettings: applyDailyScheduleSettings,
applySnapshot: applyAuthoritativeDailyScheduleSnapshot,
showError: (message) => showToast(message, 3200)
})
), [applyAuthoritativeDailyScheduleSnapshot, applyDailyScheduleSettings, showToast]);
const activateDownloadSchedule = useCallback((): void => {
if (!scheduleTimeInput) return;
const [hours, minutes] = scheduleTimeInput.split(":").map(Number);
const now = new Date();
const target = new Date(now);
target.setHours(hours, minutes, 0, 0);
if (target.getTime() <= now.getTime()) target.setDate(target.getDate() + 1);
void window.rd.updateSettings({ scheduledStartEpochMs: target.getTime() }).catch(() => {});
setSchedulePickerOpen(false);
}, [scheduleTimeInput]);
void activateDailyScheduleSettings(
scheduleTimeInput,
scheduleStartDay,
(update) => persistDownloadSchedule(update, "activate"),
(message) => showToast(message, 2800)
).then((persisted) => {
if (persisted) {
setSchedulePickerOpen(false);
}
});
}, [persistDownloadSchedule, scheduleStartDay, scheduleTimeInput, showToast]);
const removeActionableDownloads = useCallback((): void => {
const ids = new Set(downloadsViewCore.actionableSelectedIds);
@@ -4849,10 +5016,16 @@ export function App(): ReactElement {
reconnectSeconds: snapshot.reconnectSeconds,
reconnectReason: snapshot.session.reconnectReason,
clipboardWatcher: snapshot.clipboardActive,
scheduleActive: snapshot.settings.scheduledStartEpochMs > 0,
scheduleActive: snapshot.settings.dailyStartEnabled || snapshot.settings.scheduledStartEpochMs > 0,
scheduleOpen: schedulePickerOpen,
scheduleTime: scheduleTimeInput,
scheduleLabel: scheduleCountdown || (snapshot.settings.scheduledStartEpochMs > 0 ? new Date(snapshot.settings.scheduledStartEpochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""),
scheduleTimeValid: buildDailyScheduleSettingsUpdate(scheduleTimeInput, scheduleStartDay) !== null,
scheduleStartDay,
scheduleLabel: scheduleCountdown || (snapshot.settings.dailyStartEnabled
? formatDailyScheduleTime(snapshot.settings.dailyStartMinuteOfDay)
: snapshot.settings.scheduledStartEpochMs > 0
? new Date(snapshot.settings.scheduledStartEpochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
: ""),
packageSpeedBps: downloadPackageSpeeds,
editingPackageId,
editingName,
@@ -4876,7 +5049,7 @@ export function App(): ReactElement {
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
eta: snapshot.etaText
}
}), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
}), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleStartDay, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.dailyStartEnabled, snapshot.settings.dailyStartMinuteOfDay, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
const resetColumnLayout = useCallback((): void => {
if (columnDragSettleTimerRef.current !== null) {
@@ -4917,12 +5090,17 @@ export function App(): ReactElement {
},
onStopDownloads: () => { void performQuickAction(() => window.rd.stop()); },
onToggleSchedule: () => {
setSchedulePickerOpen((current) => !current);
setScheduleTimeInput("");
if (!schedulePickerOpen) {
const now = new Date();
setScheduleTimeInput(resolveDailyScheduleInitialTime(snapshot.settings, now));
setScheduleStartDay("today");
}
setSchedulePickerOpen(!schedulePickerOpen);
},
onScheduleTimeChange: setScheduleTimeInput,
onScheduleStartDayChange: setScheduleStartDay,
onActivateSchedule: activateDownloadSchedule,
onCancelSchedule: () => { void window.rd.updateSettings({ scheduledStartEpochMs: 0 }).catch(() => {}); },
onCancelSchedule: () => { void persistDownloadSchedule(buildScheduleCancellationSettingsUpdate(snapshot.settings), "cancel"); },
onMoveSelectionUp: () => moveSelectedPackages("up", downloadsViewCore.actionableSelectedIds),
onMoveSelectionDown: () => moveSelectedPackages("down", downloadsViewCore.actionableSelectedIds),
onRenameSelection: () => {
@@ -5150,6 +5328,7 @@ export function App(): ReactElement {
},
dailyLimitBytes: row.dailyLimitBytes,
dailyUsageBytes: row.dailyUsedBytes,
totalUsageBytes: row.totalUsedBytes,
username: row.username,
credentialKind: row.credentialLabel.includes("API") ? "api-key" : row.credentialLabel.includes("•") ? "password" : "protected",
canCheck: row.checkable
@@ -5451,6 +5630,11 @@ export function App(): ReactElement {
setBool(fieldId as keyof RendererSettingsDraft, value);
return;
}
const notificationNumber = normalizeNotificationNumberField(fieldId, value);
if (notificationNumber !== undefined) {
setNum(fieldId as keyof RendererSettingsDraft, notificationNumber);
return;
}
const numericLimits: Partial<Record<keyof RendererSettingsDraft, [number, number, number]>> = {
maxParallel: [1, 50, 1],
retryLimit: [0, 99, 0],
+1 -1
View File
@@ -1,6 +1,6 @@
import type { AccountDeleteCommand, AccountReplaceCommand, AccountSecretRequest, DebridProvider, RendererAccount, RendererAccountKind } from "../shared/types";
export type AccountService = "realdebrid" | "megadebrid-api" | "megadebrid-web" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier" | "debridlink" | "linksnappy";
export type AccountService = "realdebrid" | "megadebrid-api" | "megadebrid-web" | "bestdebrid" | "alldebrid" | "deepbrid" | "ddownload" | "onefichier" | "debridlink" | "linksnappy";
export type AccountKind = RendererAccountKind;
export type SingleAccountKind = Exclude<AccountKind, "megadebrid-api" | "megadebrid-web" | "debridlink-api">;
+1
View File
@@ -6,6 +6,7 @@ export const ACCOUNT_SERVICE_ICONS = {
"megadebrid-web": "./provider-icons/mega-debrid.png",
bestdebrid: "./provider-icons/bestdebrid.ico",
alldebrid: "./provider-icons/alldebrid.png",
deepbrid: "./provider-icons/deepbrid.png",
ddownload: "./provider-icons/ddownload.ico",
onefichier: "./provider-icons/onefichier.png",
debridlink: "./provider-icons/debrid-link.ico",
+2 -1
View File
@@ -9,6 +9,7 @@ export const providerLabels: Record<DebridProvider, string> = {
"megadebrid-web": "Mega-Debrid Web",
bestdebrid: "BestDebrid",
alldebrid: "AllDebrid",
deepbrid: "Deepbrid",
ddownload: "DDownload",
onefichier: "1Fichier",
debridlink: "Debrid-Link",
@@ -56,7 +57,7 @@ export function formatHosterLabel(hoster: string): { compact: string; title: str
const normalized = hoster.trim().toLowerCase();
if (normalized === "rapidgator") return { compact: "RG", title: "RapidGator", iconSrc: hosterIconSources.rapidgator };
if (normalized === "ddownload") return { compact: "DD", title: "DDownload", iconSrc: hosterIconSources.ddownload };
if (normalized === "1fichier") return { compact: "1F", title: "1Fichier" };
if (normalized === "1fichier") return { compact: "1Fichier", title: "1Fichier", iconSrc: hosterIconSources.onefichier };
return { compact: hoster, title: hoster };
}
+1
View File
@@ -1,4 +1,5 @@
export const hosterIconSources: Readonly<Record<string, string>> = {
rapidgator: "data:image/x-icon;base64,AAABAAIAEBAAAAEAGABoAwAAJgAAACAgAAABAAgAqAgAAI4DAAAoAAAAEAAAACAAAAABABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHVV8LEFQNTU2NTU2NTU2NTU2NTU2NTU2NTU2NTU2MDtDIk9vAAAAAAAAAAAALj5KKFaKHHXZFoP9FoP9FoP9FoP9FoP9FoP9FoP9FoP9GXzrJlqUMzpCmsDaHFd/KFiNGIX/GIX/GIX/GIX/GIX/GIX/LUpqLUpqLUpqIWq7GIX/GIX/Jl+gLT9NLEFPH3jcGob/Gob/Gob/Gob/Gob/Gob/NDQ0NDQ0NDQ0NDQ0JGWuGob/Gob/NTU2MTpAHoLyHIf/HIf/HIf/HIf/HIf/HIf/HIf/HIf/InHJNDQ0NDQ0IXbWHIf/NTU2MTpAIILyHoj/Hoj/Hoj/Hoj/IX3kLVB4NDQ0NDQ0JHLJLkpqNDQ0JHLJHoj/NTU2MTpAIYPyIIn/IIn/IIn/IYPxMT9PNDQ0MEVdL0tqJHjWL0tqNDQ0JXLJIIn/NTU2MTpAI4TyL0tqNDQ0J3PJKmKgNDQ0LlF4Ior/Ior/Ior/L0tqNDQ0J3PJIor/NTU2MTpAJYXyMEtqNDQ0KHTJK2KgNDQ0LV2TJIv/JIv/JIv/MEtqNDQ0KHTJJIv/NTU2MTpAKIbyMUtqNDQ0KnXJKnrWNDQ0NDQ0Ll2TLWOgLWOgMkBPNDQ0KnXJJ4z/NTU2MTpAKojyMUxqNDQ0LHbJKY7/LWquMzpCNDQ0NDQ0NDQ0NDQ0NDQ0LHbJKY7/NTU2MTpALInyMkxqNDQ0MFiFK4//K4//LInxLXfJLXfJLXfJLXfJLXfJLInxK4//NTU2Lz1HLobpL3G7NDQ0NDQ0M0ZdMk1qMk1qMGuuLZD/LZD/LZD/LZD/LZD/LZD/NTU2IFFzMl+TLpD+MGutNDQ0NDQ0NDQ0NDQ0MWWgLpD+LpD+LpD+LpD+LpD+MW60MTpAAAAANDc6MWKYLofrLoHfMHG6MHG6MHG6L3zTLofrLofrLofrLofrMWywM0hgFGKYAAAAo8ffKUZbMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpALz1HE2OZAAAAwAMAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAgAEAACgAAAAgAAAAQAAAAAEACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1NTYAo8ffAClGWwAxOkAALz1HABNjmQAAfdIACnC1ADQ3OgAxYpgALofrAC6B3wAwcboAL3zTADFssAAzSGAAFGKYACBRcwAyX5MALpD+ADBrrQA0NDQAMWWgADFutAAuhukAL3G7ADNGXQAyTWoAMGuuAC2Q/wA1NTYALInyADJMagAwWIUAK4//ACyJ8QAtd8kAKojyADFMagAsdskAKY7/AC1qrgAzOkIAKIbyADFLagAqdckAKnrWAC5dkwAtY6AAMkBPACeM/wAlhfIAMEtqACh0yQArYqAALV2TACSL/wAjhPIAL0tqACdzyQAqYqAALlF4ACKK/wAhg/IAIIn/ACGD8QAxP08AMEVdACR41gAlcskAIILyAB6I/wAhfeQALVB4ACRyyQAuSmoAHoLyAByH/wAicckAIXbWACxBTwAfeNwAGob/ACRlrgAcV38AKFiNABiF/wAtSmoAIWq7ACZfoAAtP00AB3S+AC4+SgAoVooAHHXZABaD/QAZfOsAJlqUAJrA2gAFdsIAHVV8ACxBUAAwO0MAIk9vAAtusQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpZGUeHh4eHh4eHmZnaWlpaWlpaWlpaWlpaWlpaWlpaVxdXl9fX19fX19fYGEqYmlpaWlpaWlpaWlpaWlpaWlUVUBAQEBAQFdXV1hWVllaaWlpaWlpaWlpaWlpaWlpaVBRQEBAQEBAFRUVFVNSUh5paWlpaWlpaWlpaWlpaWlpBEZAQEBAQEBAQEoVFU84HmlpaWlpaWlpaWlpaWlpaWkERkBAQEBISRUVSjQVJzgeaWlpaWlpaWlpaWlpaWlpaQQlQEBAQUIVQzpENBUnOB5paWlpaWlpaWlpaWlpaWlpBCUgFSc2FT04ODg0FSc4HmlpaWlpaWlpaWlpaWlpaWkEJSAVJzYVNzg4ODQVJzgeaWlpaWlpaWlpaWlpaWlpaQQlIBUnLhUVLzAwMRUnEx5paWlpaWlpaWlpaWlpaWlpBCUgFSciKSoVFRUVFScTHmlpaWlpaWlpaWlpaWlpaWkEGCAVISIiIyQkJCQkIxMeaWlpaWlpaWlpaWlpaWlpaQQYGRUVGhsbHBMTExMTEx5paWlpaWlpaWlpaWlpaWlpERITFBUVFRUWExMTExMXA2lpaWlpaWlpaWlpaWlpaWlpCAkKCwwMDA0KCgoKDg8QaWlpaWlpaWlpaWlpaWlpaWkBAgMDAwMDAwMDAwMDBWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaf//////////////////////wAP//4AA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//+AAP//gAH/////////////////////////////////////////////////////////////////",
onefichier: "./provider-icons/onefichier.png",
ddownload: "./provider-icons/ddownload.ico"
};
+5 -2
View File
@@ -16,6 +16,9 @@ const pairs = [
["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"],
["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"],
["Melden, wenn ein Paket fertig ist", "Notify when a package completes"], ["Melden, wenn ein Paket fehlschlägt", "Notify when a package fails"], ["Melden, wenn alles fertig ist", "Notify when everything completes"],
["Erfolgsmeldungen senden", "Send success notifications"], ["Gesammelt (alle 2 Minuten)", "Grouped (every 2 minutes)"], ["Jedes Paket einzeln", "Each package individually"],
["Melden, wenn der gesamte Lauf fertig ist", "Notify when the entire run completes"], ["Melden, wenn die Restmenge unterschritten wird", "Notify when the remaining amount falls below the threshold"], ["Restmengenschwelle (GB)", "Remaining amount threshold (GB)"],
["Melden, wenn Downloads stillstehen", "Notify when downloads stall"], ["Stillstand bestätigen nach (Sek.)", "Confirm stall after (sec.)"], ["Frühestens erneut melden nach (Min.)", "Notify again after at least (min.)"], ["Melden, wenn Downloads wieder laufen", "Notify when downloads resume"],
["Quelle und Zeitpunkt der Update-Prüfung.", "Update source and check timing."], ["Aktualisierung", "Update"], ["Beim Start nach Updates suchen", "Check for updates on startup"], ["Update-Quelle", "Update source"],
["Jetzt nach einer neuen Version suchen", "Check for a new version now"], ["Nach Updates suchen", "Check for updates"], ["Quelle im Format Benutzer/Repository.", "Source in owner/repository format."],
["Update verfügbar", "Update available"], ["Eine neue Version ist bereit. Klicke hier, um sie zu installieren.", "A new version is ready. Click here to install it."], ["Update installieren", "Install update"],
@@ -60,7 +63,7 @@ const pairs = [
["Abgeschlossene und gelöschte Pakete erscheinen hier.", "Completed and deleted packages appear here."], ["Passe Filter oder Suche an.", "Adjust the filter or search."], ["Öffne die Ansicht erneut, um es noch einmal zu versuchen.", "Open the view again to retry."],
["Alle sichtbaren Einträge auswählen", "Select all visible entries"], ["Details anzeigen", "Show details"], ["Details ausblenden", "Hide details"],
["Sichtbar:", "Visible:"], ["pro Seite", "per page"],
["Verfügbarkeit", "Availability"], ["Hinzugefügt am", "Added on"], ["Ungeprüft", "Unchecked"], ["Paket gestoppt", "Package stopped"], ["Alle anzeigen", "Show all"], ["Planen", "Schedule"], ["Startzeit", "Start time"],
["Verfügbarkeit", "Availability"], ["Hinzugefügt am", "Added on"], ["Ungeprüft", "Unchecked"], ["Paket gestoppt", "Package stopped"], ["Alle anzeigen", "Show all"], ["Planen", "Schedule"], ["Startzeit", "Start time"], ["Starttag", "Start day"], ["Ab heute", "Starting today"], ["Ab morgen", "Starting tomorrow"], ["Bitte eine gültige Startzeit auswählen.", "Select a valid start time."],
["Keine Downloads", "No downloads"], ["Keine passenden Downloads", "No matching downloads"], ["Füge Links hinzu, um Downloads vorzubereiten.", "Add links to prepare downloads."], ["Passe Filter oder Suche an.", "Adjust the filter or search."],
["Keine Links gesammelt", "No links collected"], ["Keine passenden Links", "No matching links"], ["Füge Links oder Text ein, um sie zu sammeln.", "Paste links or text to collect them."], ["Links durchsuchen", "Search links"],
["Datenmenge", "Data volume"], ["Sitzungszähler", "Session counter"], ["Sieben Tage", "Seven days"], ["30 Tage", "30 days"], ["Zeitraum", "Period"], ["Erfolgreich", "Successful"],
@@ -208,7 +211,7 @@ const prefixedPairs = [
["Sicherung laden fehlgeschlagen: ", "Loading backup failed: "], ["Support-Bundle fehlgeschlagen: ", "Support bundle failed: "], ["Support-Trace fehlgeschlagen: ", "Support trace failed: "],
["Debug-Setup-Check fehlgeschlagen: ", "Debug setup check failed: "], ["Fehler-Ansicht fehlgeschlagen: ", "Error view failed: "], ["Token-Rotation fehlgeschlagen: ", "Token rotation failed: "],
["Ferndiagnose-Status fehlgeschlagen: ", "Remote diagnostics status failed: "], ["Aktivieren fehlgeschlagen: ", "Enabling failed: "], ["Deaktivieren fehlgeschlagen: ", "Disabling failed: "],
["Session-Reset fehlgeschlagen: ", "Session reset failed: "], ["Download-Reset fehlgeschlagen: ", "Download reset failed: "]
["Session-Reset fehlgeschlagen: ", "Session reset failed: "], ["Download-Reset fehlgeschlagen: ", "Download reset failed: "], ["Zeitplan konnte nicht aktiviert werden: ", "Schedule could not be activated: "], ["Zeitplan konnte nicht abgebrochen werden: ", "Schedule could not be cancelled: "], ["Zeitplan konnte nicht abgeglichen werden: ", "Schedule could not be reconciled: "]
] as const;
export function normalizeLanguage(value: unknown): AppLanguage {
+29 -17
View File
@@ -11,8 +11,10 @@ import { VirtualizedDownloadsBody } from "./VirtualizedDownloadsBody";
import "./downloads.css";
const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 });
export interface DownloadsStatusModel {
export type DailyScheduleStartDay = "today" | "tomorrow";
export interface DownloadsStatusModel {
packages: number;
links: number;
session: string;
@@ -37,10 +39,12 @@ export interface DownloadsViewModel extends DownloadsViewModelCore {
reconnectSeconds: number;
reconnectReason: string;
clipboardWatcher: boolean;
scheduleActive: boolean;
scheduleOpen: boolean;
scheduleTime: string;
scheduleLabel: string;
scheduleActive: boolean;
scheduleOpen: boolean;
scheduleTime: string;
scheduleTimeValid: boolean;
scheduleStartDay: DailyScheduleStartDay;
scheduleLabel: string;
packageSpeedBps: Record<string, number>;
editingPackageId: string | null;
editingName: string;
@@ -63,9 +67,10 @@ export interface DownloadsViewActions extends DownloadsTableActions {
onStartDownloads: () => void;
onPauseDownloads: () => void;
onStopDownloads: () => void;
onToggleSchedule: () => void;
onScheduleTimeChange: (value: string) => void;
onActivateSchedule: () => void;
onToggleSchedule: () => void;
onScheduleTimeChange: (value: string) => void;
onScheduleStartDayChange: (value: DailyScheduleStartDay) => void;
onActivateSchedule: () => void;
onCancelSchedule: () => void;
onMoveSelectionUp: () => void;
onMoveSelectionDown: () => void;
@@ -121,18 +126,25 @@ export function DownloadsSidebarStatus({ model }: { model: DownloadsViewModel })
})}<div><span>Geschwindigkeit</span><strong data-status-metric="speed">{speed}</strong></div><div><span>ETA</span><strong data-status-metric="eta">{eta}</strong></div></section>;
}
export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
const hasSelection = model.actionableSelectedIds.length > 0;
const hasSelectedPackage = model.actionableSelectedPackageIds.length > 0;
const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1;
return (
export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
const hasSelection = model.actionableSelectedIds.length > 0;
const hasSelectedPackage = model.actionableSelectedPackageIds.length > 0;
const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1;
const scheduleSlotOpen = model.scheduleActive || model.scheduleOpen;
const scheduleSlotClass = `downloads-schedule-slot ${scheduleSlotOpen ? "is-open" : "is-closed"}${model.animationsEnabled ? "" : " is-motion-disabled"}`;
return (
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
<button disabled={model.actionBusy || !model.canStart} onClick={actions.onStartDownloads} type="button">Start</button>
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
{model.scheduleActive
? <span className="downloads-schedule-controls"><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></span>
: <><button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button>{model.scheduleOpen ? <span className="downloads-schedule-controls"><input aria-label="Startzeit" onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><button onClick={actions.onActivateSchedule} type="button">Planen</button></span> : null}</>}
{!model.scheduleActive ? <button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button> : null}
<span className={scheduleSlotClass}>
<span {...(!scheduleSlotOpen ? { inert: "true" } : {})} aria-hidden={!scheduleSlotOpen} className="downloads-schedule-controls">
{model.scheduleActive
? <><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></>
: <><input aria-label="Startzeit" disabled={!scheduleSlotOpen} onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><select aria-label="Starttag" disabled={!scheduleSlotOpen} onChange={(event) => actions.onScheduleStartDayChange(event.target.value as DailyScheduleStartDay)} value={model.scheduleStartDay}><option value="today">Ab heute</option><option value="tomorrow">Ab morgen</option></select><button disabled={!scheduleSlotOpen || !model.scheduleTimeValid} onClick={actions.onActivateSchedule} type="button">Planen</button></>}
</span>
</span>
<span className="downloads-toolbar-divider" />
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionUp} type="button">Nach oben</button>
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionDown} type="button">Nach unten</button>
+33 -1
View File
@@ -237,13 +237,45 @@
background: var(--ui-border);
}
.downloads-schedule-slot {
display: grid;
grid-template-columns: 0fr;
min-width: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
transition: grid-template-columns 180ms ease, opacity 140ms ease;
}
.downloads-schedule-slot.is-open {
grid-template-columns: 1fr;
opacity: 1;
pointer-events: auto;
}
.downloads-schedule-controls {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
width: max-content;
overflow: hidden;
white-space: nowrap;
transform: translateX(-10px);
transition: transform 180ms ease;
}
.downloads-schedule-controls input {
.downloads-schedule-slot.is-open .downloads-schedule-controls {
transform: translateX(0);
}
.downloads-schedule-slot.is-motion-disabled,
.downloads-schedule-slot.is-motion-disabled .downloads-schedule-controls {
transition: none !important;
}
.downloads-schedule-controls input,
.downloads-schedule-controls select {
height: 36px;
}
+56 -1
View File
@@ -20,6 +20,7 @@ import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
import { SlidingSelection } from "../../ui/SlidingSelection";
import {
createHistoryTableColumnWidths,
formatHistoryDuration,
getHistoryTableGridTemplate,
getHistoryTableMinWidth,
HISTORY_TABLE_COLUMN_IDS,
@@ -69,6 +70,12 @@ const HISTORY_DISCLOSURE_DURATION_MS = 520;
const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
let historyTableResizeSession: { column: HistoryTableColumnId; startX: number; initial: HistoryTableColumnWidths } | null = null;
function operationStatusLabel(status: "completed" | "failed" | "cancelled"): string {
if (status === "completed") return "Abgeschlossen";
if (status === "failed") return "Fehlgeschlagen";
return "Abgebrochen";
}
function loadHistoryTableColumnWidths(): HistoryTableColumnWidths {
try {
const stored = typeof window === "undefined" ? null : window.localStorage.getItem(HISTORY_TABLE_COLUMN_STORAGE_KEY);
@@ -183,11 +190,59 @@ function HistoryRowDetails({
<dl className="history-details-grid">
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div>
<div><dt>Dateien</dt><dd>{row.fileCount}</dd></div>
<div><dt>Dauer</dt><dd>{row.durationLabel}</dd></div>
{row.hasStructuredLifecycle ? (
<>
<div><dt>Download gestartet</dt><dd>{row.startedLabel}</dd></div>
<div><dt>Download beendet</dt><dd>{row.downloadEndedLabel}</dd></div>
<div><dt>Nachbearbeitung gestartet</dt><dd>{row.postProcessStartedLabel}</dd></div>
<div><dt>Abgeschlossen</dt><dd>{row.completedLabel}</dd></div>
<div><dt>Downloaddauer</dt><dd>{row.downloadDurationLabel}</dd></div>
<div><dt>Entpackdauer</dt><dd>{row.extractionDurationLabel}</dd></div>
<div><dt>Remuxdauer</dt><dd>{row.remuxDurationLabel}</dd></div>
<div><dt>Nachbearbeitungsdauer</dt><dd>{row.postProcessDurationLabel}</dd></div>
<div><dt>Gesamtdauer</dt><dd>{row.totalDurationLabel}</dd></div>
<div><dt>Status</dt><dd>{row.statusLabel}</dd></div>
<div><dt>Erfolgreich / Fehlgeschlagen / Abgebrochen</dt><dd>{row.successfulFiles ?? 0} / {row.failedFiles ?? 0} / {row.cancelledFiles ?? 0}</dd></div>
<div><dt>Archive / Parts / Ausgaben</dt><dd>{row.archiveCount ?? 0} / {row.partCount ?? 0} / {row.outputCount ?? 0}</dd></div>
<div><dt>Fehlerphase</dt><dd>{row.failurePhaseLabel}</dd></div>
</>
) : (
<div><dt>Downloaddauer (Altbestand)</dt><dd>{row.durationLabel}</dd></div>
)}
<div><dt>Durchschnitt</dt><dd>{row.averageSpeedLabel}</dd></div>
<div className="history-detail-wide"><dt>Zielordner</dt><dd className="history-copyable">{row.outputDir || "—"}</dd></div>
<div className="history-detail-wide"><dt>URLs</dt><dd className="history-copyable">{row.urls?.length ? row.urls.join("\n") : "—"}</dd></div>
</dl>
{row.hasStructuredLifecycle ? (
<div className="history-operation-groups">
<section className="history-operation-group">
<h3>Archivvorgänge</h3>
{row.archiveOperations?.length ? (
<ul>
{row.archiveOperations.map((operation) => (
<li key={operation.id}>
<strong>{operation.name}</strong>
<span>{operation.partCount} Parts · {formatHistoryDuration(operation.durationMs / 1000)} · {operationStatusLabel(operation.status)}{operation.errorCategory ? ` · ${operation.errorCategory}` : ""}</span>
</li>
))}
</ul>
) : <p>Keine Archivvorgänge</p>}
</section>
<section className="history-operation-group">
<h3>Remuxvorgänge</h3>
{row.remuxOperations?.length ? (
<ul>
{row.remuxOperations.map((operation) => (
<li key={operation.id}>
<strong>{operation.fileName}</strong>
<span>{formatHistoryDuration(operation.durationMs / 1000)} · {operationStatusLabel(operation.status)}{operation.errorCategory ? ` · ${operation.errorCategory}` : ""}</span>
</li>
))}
</ul>
) : <p>Keine Remuxvorgänge</p>}
</section>
</div>
) : null}
</div>
</div>
</div>
+51 -9
View File
@@ -14,6 +14,15 @@ export interface HistoryRow extends HistoryViewEntry {
durationLabel: string;
averageSpeedLabel: string;
statusLabel: string;
hasStructuredLifecycle: boolean;
downloadEndedLabel: string;
postProcessStartedLabel: string;
downloadDurationLabel: string;
extractionDurationLabel: string;
remuxDurationLabel: string;
postProcessDurationLabel: string;
totalDurationLabel: string;
failurePhaseLabel: string;
}
export interface HistoryFilterCounts {
@@ -108,6 +117,7 @@ const providerLabels: Record<DebridProvider, string> = {
"megadebrid-web": "Mega-Debrid Web",
bestdebrid: "BestDebrid",
alldebrid: "AllDebrid",
deepbrid: "Deepbrid",
ddownload: "DDownload",
onefichier: "1Fichier",
debridlink: "Debrid-Link",
@@ -116,6 +126,8 @@ const providerLabels: Record<DebridProvider, string> = {
const statusLabels: Record<HistoryViewStatus, string> = {
completed: "Abgeschlossen",
partial: "Teilweise",
cancelled: "Abgebrochen",
deleted: "Gelöscht",
failed: "Fehlgeschlagen"
};
@@ -145,7 +157,7 @@ function formatBytes(bytes: number): string {
return `${numberFormatter.format(value)} ${units[unitIndex]}`;
}
function formatDuration(durationSeconds: number): string {
export function formatHistoryDuration(durationSeconds: number): string {
const total = Math.max(0, Math.floor(Number.isFinite(durationSeconds) ? durationSeconds : 0));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
@@ -156,6 +168,19 @@ function formatDuration(durationSeconds: number): string {
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
function formatTimestamp(timestamp: number | undefined): string {
const safe = Math.max(0, Number.isFinite(timestamp) ? Number(timestamp) : 0);
return safe > 0 ? dateFormatter.format(new Date(safe)) : "—";
}
function failurePhaseLabel(entry: HistoryViewEntry): string {
if (entry.failurePhase === "download") return "Download";
if (entry.failurePhase === "extract") return "Entpacken";
if (entry.failurePhase === "remux") return "Remux";
if (entry.failurePhase === "cleanup") return "Aufräumen";
return "—";
}
export function paginateHistoryRows(rows: HistoryRow[], requestedPage: number): HistoryPage {
const totalItems = rows.length;
const totalPages = Math.max(1, Math.ceil(totalItems / HISTORY_PAGE_SIZE));
@@ -232,7 +257,11 @@ export function deriveHistoryHoster(urls: string[] | undefined): string {
return hostnames.length > 0 ? hostnames.join(", ") : "—";
}
export function deriveHistoryStartAt(entry: Pick<HistoryViewEntry, "completedAt" | "durationSeconds">): number {
export function deriveHistoryStartAt(entry: Pick<HistoryViewEntry, "completedAt" | "durationSeconds" | "startedAt">): number {
const startedAt = Math.max(0, Number.isFinite(entry.startedAt) ? Number(entry.startedAt) : 0);
if (startedAt > 0) {
return startedAt;
}
const completedAt = Math.max(0, Number.isFinite(entry.completedAt) ? entry.completedAt : 0);
const durationMs = Math.max(0, Number.isFinite(entry.durationSeconds) ? entry.durationSeconds : 0) * 1000;
return Math.max(0, completedAt - durationMs);
@@ -242,19 +271,32 @@ function toHistoryRow(entry: HistoryViewEntry): HistoryRow {
const hoster = deriveHistoryHoster(entry.urls);
const providerLabel = entry.provider ? providerLabels[entry.provider] : "—";
const startAt = deriveHistoryStartAt(entry);
const durationSeconds = Math.max(0, entry.durationSeconds || 0);
const averageBytesPerSecond = durationSeconds > 0 ? entry.downloadedBytes / durationSeconds : 0;
const downloadDurationSeconds = Math.max(0, entry.downloadDurationSeconds ?? entry.durationSeconds ?? 0);
const averageBytesPerSecond = downloadDurationSeconds > 0 ? entry.downloadedBytes / downloadDurationSeconds : 0;
const hasStructuredLifecycle = entry.startedAt !== undefined
|| entry.downloadEndedAt !== undefined
|| entry.postProcessStartedAt !== undefined
|| entry.totalDurationSeconds !== undefined;
return {
...entry,
hoster,
providerLabel,
startAt,
sizeLabel: `${formatBytes(entry.downloadedBytes)} / ${formatBytes(entry.totalBytes)}`,
startedLabel: dateFormatter.format(new Date(startAt)),
completedLabel: dateFormatter.format(new Date(Math.max(0, entry.completedAt))),
durationLabel: formatDuration(durationSeconds),
averageSpeedLabel: durationSeconds > 0 ? `${formatBytes(averageBytesPerSecond)}/s` : "—",
statusLabel: statusLabels[entry.status]
startedLabel: formatTimestamp(startAt),
completedLabel: formatTimestamp(entry.completedAt),
durationLabel: formatHistoryDuration(downloadDurationSeconds),
averageSpeedLabel: downloadDurationSeconds > 0 ? `${formatBytes(averageBytesPerSecond)}/s` : "—",
statusLabel: statusLabels[entry.status],
hasStructuredLifecycle,
downloadEndedLabel: formatTimestamp(entry.downloadEndedAt),
postProcessStartedLabel: formatTimestamp(entry.postProcessStartedAt),
downloadDurationLabel: formatHistoryDuration(entry.downloadDurationSeconds ?? 0),
extractionDurationLabel: formatHistoryDuration(entry.extractionDurationSeconds ?? 0),
remuxDurationLabel: formatHistoryDuration(entry.remuxDurationSeconds ?? 0),
postProcessDurationLabel: formatHistoryDuration(entry.postProcessDurationSeconds ?? 0),
totalDurationLabel: formatHistoryDuration(entry.totalDurationSeconds ?? 0),
failurePhaseLabel: failurePhaseLabel(entry)
};
}
+72 -10
View File
@@ -339,11 +339,23 @@
color: var(--ui-danger-text);
}
.history-status-failed {
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
.history-status-failed {
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
color: var(--ui-danger-text);
}
}
.history-status-partial {
background: color-mix(in srgb, var(--ui-warning) 16%, transparent);
border-color: color-mix(in srgb, var(--ui-warning) 60%, var(--ui-border));
color: var(--ui-warning-text);
}
.history-status-cancelled {
background: color-mix(in srgb, var(--ui-text-muted) 14%, transparent);
border-color: color-mix(in srgb, var(--ui-text-muted) 48%, var(--ui-border));
color: var(--ui-text-secondary);
}
.history-row-size,
.history-row-hoster,
@@ -422,9 +434,55 @@
min-width: 0;
}
.history-details-grid .history-detail-wide {
grid-column: span 2;
}
.history-details-grid .history-detail-wide {
grid-column: span 2;
}
.history-operation-groups {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 12px;
}
.history-operation-group {
background: color-mix(in srgb, var(--ui-panel) 72%, transparent);
border: 1px solid color-mix(in srgb, var(--ui-border) 82%, transparent);
border-radius: 6px;
min-width: 0;
padding: 10px 11px;
}
.history-operation-group h3 {
color: var(--ui-text-muted);
font-size: 11px;
margin: 0 0 8px;
text-transform: uppercase;
}
.history-operation-group p,
.history-operation-group ul {
color: var(--ui-text-secondary);
margin: 0;
}
.history-operation-group ul {
display: grid;
gap: 8px;
list-style: none;
padding: 0;
}
.history-operation-group li {
display: grid;
gap: 3px;
min-width: 0;
}
.history-operation-group strong,
.history-operation-group span {
overflow-wrap: anywhere;
}
.history-copyable {
overflow-wrap: anywhere;
@@ -494,9 +552,13 @@
overflow: hidden;
}
.history-action {
padding: 0 9px;
}
.history-action {
padding: 0 9px;
}
.history-operation-groups {
grid-template-columns: 1fr;
}
}
+42 -5
View File
@@ -146,6 +146,7 @@ export interface AccountRowSource {
};
dailyLimitBytes?: number;
dailyUsageBytes?: number;
totalUsageBytes?: number;
username: string;
credentialKind: "password" | "api-key" | "protected";
canCheck: boolean;
@@ -270,6 +271,22 @@ export interface SettingsFormProjectionInput {
themeChoice?: "light" | "dark" | "system";
}
const NOTIFICATION_NUMBER_LIMITS = {
notifyRemainingThresholdGb: { min: 1, max: 100000, fallback: 50 },
notifyStallAfterSeconds: { min: 60, max: 3600, fallback: 90 },
notifyStallCooldownMinutes: { min: 5, max: 1440, fallback: 10 }
} as const;
export function normalizeNotificationNumberField(fieldId: string, value: unknown): number | undefined {
const limits = NOTIFICATION_NUMBER_LIMITS[fieldId as keyof typeof NOTIFICATION_NUMBER_LIMITS];
if (!limits) {
return undefined;
}
const parsed = Number(value);
const normalized = Number.isFinite(parsed) ? Math.floor(parsed) : limits.fallback;
return Math.max(limits.min, Math.min(limits.max, normalized));
}
export function buildSettingsFormViewModel({
settings,
section,
@@ -600,7 +617,24 @@ export function buildSettingsFormViewModel({
{ id: "notifyMention", kind: "text", label: "Discord-Erwähnung (optional)", value: settings.notifyMention },
{ id: "notifyOnPackageCompleted", kind: "switch", label: "Melden, wenn ein Paket fertig ist", value: settings.notifyOnPackageCompleted },
{ id: "notifyOnPackageFailed", kind: "switch", label: "Melden, wenn ein Paket fehlschlägt", value: settings.notifyOnPackageFailed },
{ id: "notifyOnRunFinished", kind: "switch", label: "Melden, wenn alles fertig ist", value: settings.notifyOnRunFinished }
{
id: "notifyPackageSuccessMode",
kind: "select",
label: "Erfolgsmeldungen senden",
value: settings.notifyPackageSuccessMode,
disabled: !settings.notifyOnPackageCompleted,
options: [
{ value: "digest", label: "Gesammelt (alle 2 Minuten)" },
{ value: "individual", label: "Jedes Paket einzeln" }
]
},
{ id: "notifyOnRunFinished", kind: "switch", label: "Melden, wenn der gesamte Lauf fertig ist", value: settings.notifyOnRunFinished },
{ id: "notifyOnRemainingBelow", kind: "switch", label: "Melden, wenn die Restmenge unterschritten wird", value: settings.notifyOnRemainingBelow },
{ id: "notifyRemainingThresholdGb", kind: "number", label: "Restmengenschwelle (GB)", value: String(settings.notifyRemainingThresholdGb), min: 1, max: 100000, disabled: !settings.notifyOnRemainingBelow },
{ id: "notifyOnDownloadStall", kind: "switch", label: "Melden, wenn Downloads stillstehen", value: settings.notifyOnDownloadStall },
{ id: "notifyStallAfterSeconds", kind: "number", label: "Stillstand bestätigen nach (Sek.)", value: String(settings.notifyStallAfterSeconds), min: 60, max: 3600, disabled: !settings.notifyOnDownloadStall },
{ id: "notifyStallCooldownMinutes", kind: "number", label: "Frühestens erneut melden nach (Min.)", value: String(settings.notifyStallCooldownMinutes), min: 5, max: 1440, disabled: !settings.notifyOnDownloadStall },
{ id: "notifyOnDownloadRecovery", kind: "switch", label: "Melden, wenn Downloads wieder laufen", value: settings.notifyOnDownloadRecovery, disabled: !settings.notifyOnDownloadStall }
]
}
]
@@ -621,12 +655,15 @@ function formatBytes(bytes: number): string {
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: value >= 100 ? 0 : value >= 10 ? 1 : 2 }).format(value)} ${units[unitIndex]}`;
}
function formatTraffic(limitBytes?: number, usageBytes?: number): string {
function formatTraffic(limitBytes?: number, usageBytes?: number, totalUsageBytes?: number): string {
const total = Number.isFinite(totalUsageBytes) && totalUsageBytes && totalUsageBytes > 0
? ` · Gesamt ${formatBytes(totalUsageBytes)}`
: "";
if (!Number.isFinite(limitBytes) || !limitBytes || limitBytes <= 0) {
return "Unbeschränkt";
return `Unbeschränkt${total}`;
}
const safeUsage = Number.isFinite(usageBytes) && usageBytes && usageBytes > 0 ? usageBytes : 0;
return `${formatBytes(Math.max(0, limitBytes - safeUsage))} von ${formatBytes(limitBytes)} übrig`;
return `${formatBytes(Math.max(0, limitBytes - safeUsage))} von ${formatBytes(limitBytes)} übrig${total}`;
}
function formatExpiry(premiumUntilMs: number | null): string {
@@ -714,7 +751,7 @@ export function projectAccountRows(
enabled: source.enabled,
selected: selected.has(id),
status: { ...status, checkedAgo: formatCheckedAgo(source.status.checkedAt, nowMs) },
traffic: formatTraffic(source.dailyLimitBytes, source.dailyUsageBytes),
traffic: formatTraffic(source.dailyLimitBytes, source.dailyUsageBytes, source.totalUsageBytes),
username: identity.username,
email: identity.email,
expires: formatExpiry(source.status.premiumUntilMs),
@@ -50,6 +50,7 @@ const providerLabels: Record<DebridProvider, string> = {
"megadebrid-web": "Mega-Debrid Web",
bestdebrid: "BestDebrid",
alldebrid: "AllDebrid",
deepbrid: "Deepbrid",
ddownload: "DDownload",
onefichier: "1Fichier",
debridlink: "Debrid-Link",
+156 -26
View File
@@ -19,9 +19,10 @@ export type DebridProvider =
| "megadebrid"
| "megadebrid-api"
| "megadebrid-web"
| "bestdebrid"
| "alldebrid"
| "ddownload"
| "bestdebrid"
| "alldebrid"
| "deepbrid"
| "ddownload"
| "onefichier"
| "debridlink"
| "linksnappy";
@@ -120,7 +121,20 @@ export interface DebridAccountStatus {
checkedAt: number;
}
export interface AppSettings {
export type NotifyPackageSuccessMode = "digest" | "individual";
export type DailyStartOutcome = "" | "started" | "already_active" | "empty_queue" | "missing_account" | "start_failed" | "missed";
export interface DailyStartSettings {
dailyStartEnabled: boolean;
dailyStartMinuteOfDay: number;
dailyStartFirstLocalDate: string;
dailyStartLastHandledLocalDate: string;
dailyStartPendingLocalDate: string;
dailyStartLastOutcome: DailyStartOutcome;
}
export interface AppSettings extends DailyStartSettings {
language: AppLanguage;
token: string;
realDebridUseWebLogin: boolean;
@@ -140,9 +154,10 @@ export interface AppSettings {
megaDebridPreferApi: boolean;
bestToken: string;
bestDebridUseWebLogin: boolean;
allDebridToken: string;
allDebridUseWebLogin: boolean;
ddownloadLogin: string;
allDebridToken: string;
allDebridUseWebLogin: boolean;
deepbridApiKey: string;
ddownloadLogin: string;
ddownloadPassword: string;
oneFichierApiKey: string;
debridLinkApiKeys: string;
@@ -200,11 +215,18 @@ export interface AppSettings {
confirmDeleteSelection: boolean;
backupIncludeDownloads: boolean;
backupIncludeRemoteDiagnostics: boolean;
notifyUrl: string;
notifyMention: string;
notifyOnPackageCompleted: boolean;
notifyOnPackageFailed: boolean;
notifyOnRunFinished: boolean;
notifyUrl: string;
notifyMention: string;
notifyOnPackageCompleted: boolean;
notifyOnPackageFailed: boolean;
notifyOnRunFinished: boolean;
notifyPackageSuccessMode: NotifyPackageSuccessMode;
notifyOnRemainingBelow: boolean;
notifyRemainingThresholdGb: number;
notifyOnDownloadStall: boolean;
notifyStallAfterSeconds: number;
notifyStallCooldownMinutes: number;
notifyOnDownloadRecovery: boolean;
totalDownloadedAllTime: number;
totalCompletedFilesAllTime: number;
totalRuntimeAllTimeMs: number;
@@ -243,6 +265,7 @@ export type RendererAccountKind =
| "bestdebrid-web"
| "alldebrid-api"
| "alldebrid-web"
| "deepbrid-api"
| "ddownload-login"
| "onefichier-api"
| "debridlink-api"
@@ -262,7 +285,7 @@ export interface RendererAccount {
status: DebridAccountStatus | null;
}
export interface RendererSettings {
export interface RendererSettings extends DailyStartSettings {
language: AppLanguage;
realDebridUseWebLogin: boolean;
realDebridDisabledAccountIds: string[];
@@ -332,6 +355,13 @@ export interface RendererSettings {
notifyOnPackageCompleted: boolean;
notifyOnPackageFailed: boolean;
notifyOnRunFinished: boolean;
notifyPackageSuccessMode: NotifyPackageSuccessMode;
notifyOnRemainingBelow: boolean;
notifyRemainingThresholdGb: number;
notifyOnDownloadStall: boolean;
notifyStallAfterSeconds: number;
notifyStallCooldownMinutes: number;
notifyOnDownloadRecovery: boolean;
totalDownloadedAllTime: number;
totalCompletedFilesAllTime: number;
totalRuntimeAllTimeMs: number;
@@ -357,9 +387,15 @@ export interface RendererSettings {
debridAccountStatuses: Record<string, DebridAccountStatus>;
providerDailyUsageDay: string;
scheduledStartEpochMs: number;
nextDailyStartEpochMs: number;
}
export type RendererSettingsUpdate = Partial<RendererSettings> & {
export type RendererSettingsUpdate = Partial<Omit<RendererSettings,
"dailyStartLastHandledLocalDate"
| "dailyStartPendingLocalDate"
| "dailyStartLastOutcome"
| "nextDailyStartEpochMs"
>> & {
archivePasswordList?: string;
notifyUrl?: string;
};
@@ -403,7 +439,7 @@ export interface AccountCommandResult {
}
export interface AccountCredentialCheckInput {
kind: "realdebrid-api" | "realdebrid-web" | "megadebrid-api" | "megadebrid-web" | "debridlink-api";
kind: "realdebrid-api" | "realdebrid-web" | "megadebrid-api" | "megadebrid-web" | "debridlink-api" | "deepbrid-api";
accountId?: string;
identity?: string;
secret?: string;
@@ -455,7 +491,7 @@ export interface AudioStripFileResult {
languages?: string;
}
export interface AudioStripSummary {
export interface AudioStripSummary {
at: number;
candidates: number;
remuxed: number;
@@ -463,10 +499,77 @@ export interface AudioStripSummary {
skippedNoGerman: number;
skippedNoTool: number;
failed: number;
files: AudioStripFileResult[];
}
export interface PackageEntry {
files: AudioStripFileResult[];
}
export type PackageResultStatus = "completed" | "partial" | "failed" | "cancelled";
export type FailurePhase = "download" | "extract" | "remux" | "cleanup" | null;
export interface ArchiveOperationMetric {
id: string;
name: string;
itemIds: string[];
partCount: number;
startedAt: number;
completedAt: number;
durationMs: number;
status: "completed" | "failed" | "cancelled";
errorCategory: string;
}
export interface RemuxOperationMetric {
id: string;
fileName: string;
startedAt: number;
completedAt: number;
durationMs: number;
status: "completed" | "failed" | "cancelled";
errorCategory: string;
}
export interface PackageTelemetry {
package: PackageEntry;
items: DownloadItem[];
archiveOperations?: ArchiveOperationMetric[];
remuxOperations?: RemuxOperationMetric[];
outputCount?: number;
cleanupErrorCategory?: string;
}
export interface PackageResult {
packageId: string;
name: string;
status: PackageResultStatus;
startedAt: number;
downloadEndedAt: number;
postProcessStartedAt: number;
completedAt: number;
downloadDurationSeconds: number;
extractionDurationSeconds: number;
remuxDurationSeconds: number;
postProcessDurationSeconds: number;
totalDurationSeconds: number;
totalBytes: number;
downloadedBytes: number;
averageDownloadSpeedBps: number;
successfulFiles: number;
failedFiles: number;
cancelledFiles: number;
downloadFailures: number;
offlineFailures: number;
extractionFailures: number;
remuxFailures: number;
cleanupFailures: number;
archiveCount: number;
partCount: number;
outputCount: number;
failurePhase: FailurePhase;
errorCategory: string;
archiveOperations: ArchiveOperationMetric[];
remuxOperations: RemuxOperationMetric[];
}
export interface PackageEntry {
id: string;
name: string;
outputDir: string;
@@ -485,8 +588,18 @@ export interface PackageEntry {
cleanedUrls?: string[];
cleanedProviders?: DebridProvider[];
downloadStartedAt?: number;
downloadCompletedAt?: number;
createdAt: number;
downloadCompletedAt?: number;
downloadEndedAt?: number;
postProcessQueuedAt?: number;
postProcessStartedAt?: number;
postProcessCompletedAt?: number;
terminalAt?: number;
archiveOperations?: ArchiveOperationMetric[];
remuxOperations?: RemuxOperationMetric[];
outputCount?: number;
cleanupErrorCategory?: string;
resultGeneration?: number;
createdAt: number;
updatedAt: number;
}
@@ -814,10 +927,27 @@ export interface HistoryEntry {
provider: DebridProvider | null;
completedAt: number;
durationSeconds: number;
status: "completed" | "deleted";
outputDir: string;
urls?: string[];
}
status: PackageResultStatus | "deleted";
outputDir: string;
urls?: string[];
startedAt?: number;
downloadEndedAt?: number;
postProcessStartedAt?: number;
downloadDurationSeconds?: number;
extractionDurationSeconds?: number;
remuxDurationSeconds?: number;
postProcessDurationSeconds?: number;
totalDurationSeconds?: number;
successfulFiles?: number;
failedFiles?: number;
cancelledFiles?: number;
archiveCount?: number;
partCount?: number;
outputCount?: number;
failurePhase?: FailurePhase;
archiveOperations?: ArchiveOperationMetric[];
remuxOperations?: RemuxOperationMetric[];
}
export interface HistoryState {
entries: HistoryEntry[];