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:
+229
-84
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user