From bf2b685e8356168d7a4daeea3cb624fcb3319a1c Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 1 Mar 2026 20:13:16 +0100 Subject: [PATCH] Add session backup restore and release v1.4.68 --- CHANGELOG.md | 21 ++++++++++++ package-lock.json | 4 +-- package.json | 2 +- src/main/storage.ts | 77 ++++++++++++++++++++++++++++++++---------- tests/storage.test.ts | 78 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 161 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29fac2c..dac185f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ Alle nennenswerten Aenderungen werden in dieser Datei dokumentiert. +## 1.4.68 - 2026-03-01 + +Stabilitaets-Hotfix fuer Session-Verlust nach Update/Neustart: Session-Dateien haben jetzt ein robustes Backup-/Restore-Fallback. + +### Fixes + +- Session-Backup fuer Queue-Zustand eingefuehrt: + - Vor jedem Session-Save wird die vorherige Session als `.bak` gesichert (sync + async Pfad). + - Schuetzt gegen defekte/trunkierte Session-Datei beim Start. +- Session-Autorestore beim Laden: + - Wenn `rd_session_state.json` defekt ist, wird automatisch `rd_session_state.json.bak` geladen. + - Das Backup wird danach best-effort wieder als primäre Session-Datei hergestellt. +- Klarere Fehlersignale im Log: + - Eindeutige Meldung, ob primäre Session defekt war und Backup verwendet wurde. + +### Tests + +- Neue Tests in `tests/storage.test.ts`: + - Laden aus Session-Backup bei defekter primärer Session. + - Backup-Erstellung vor sync- und async-Session-Overwrite. + ## 1.4.67 - 2026-03-01 Hotfix fuer einen kritischen Start-Konflikt-Datenverlust und zusaetzliche Renamer-Haertung fuer reale Scene-Muster. diff --git a/package-lock.json b/package-lock.json index 25abb6b..4c8d47c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.4.67", + "version": "1.4.68", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.4.67", + "version": "1.4.68", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index b6beb4c..6ec347f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.67", + "version": "1.4.68", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/storage.ts b/src/main/storage.ts index 2bb3487..024e612 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -365,6 +365,34 @@ function sessionTempPath(sessionFile: string, kind: "sync" | "async"): string { return `${sessionFile}.${kind}.tmp`; } +function sessionBackupPath(sessionFile: string): string { + return `${sessionFile}.bak`; +} + +function normalizeLoadedSessionTransientFields(session: SessionState): SessionState { + // Reset transient fields that may be stale from a previous crash + const ACTIVE_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]); + for (const item of Object.values(session.items)) { + if (ACTIVE_STATUSES.has(item.status)) { + item.status = "queued"; + item.lastError = ""; + } + // Always clear stale speed values + item.speedBps = 0; + } + + return session; +} + +function readSessionFile(filePath: string): SessionState | null { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; + return normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed)); + } catch { + return null; + } +} + export function saveSettings(paths: StoragePaths, settings: AppSettings): void { ensureBaseDir(paths.baseDir); // Create a backup of the existing config before overwriting @@ -404,30 +432,40 @@ export function loadSession(paths: StoragePaths): SessionState { if (!fs.existsSync(paths.sessionFile)) { return emptySession(); } - try { - const parsed = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as unknown; - const session = normalizeLoadedSession(parsed); - // Reset transient fields that may be stale from a previous crash - const ACTIVE_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]); - for (const item of Object.values(session.items)) { - if (ACTIVE_STATUSES.has(item.status)) { - item.status = "queued"; - item.lastError = ""; - } - // Always clear stale speed values - item.speedBps = 0; - } - - return session; - } catch (error) { - logger.error(`Session konnte nicht geladen werden: ${String(error)}`); - return emptySession(); + const primary = readSessionFile(paths.sessionFile); + if (primary) { + return primary; } + + const backupFile = sessionBackupPath(paths.sessionFile); + const backup = fs.existsSync(backupFile) ? readSessionFile(backupFile) : null; + if (backup) { + logger.warn("Session defekt, Backup-Datei wird verwendet"); + try { + const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }); + const tempPath = sessionTempPath(paths.sessionFile, "sync"); + fs.writeFileSync(tempPath, payload, "utf8"); + syncRenameWithExdevFallback(tempPath, paths.sessionFile); + } catch { + // ignore restore write failure + } + return backup; + } + + logger.error("Session konnte nicht geladen werden (auch Backup fehlgeschlagen)"); + return emptySession(); } export function saveSession(paths: StoragePaths, session: SessionState): void { ensureBaseDir(paths.baseDir); + if (fs.existsSync(paths.sessionFile)) { + try { + fs.copyFileSync(paths.sessionFile, sessionBackupPath(paths.sessionFile)); + } catch { + // Best-effort backup; proceed even if it fails + } + } const payload = JSON.stringify({ ...session, updatedAt: Date.now() }); const tempPath = sessionTempPath(paths.sessionFile, "sync"); fs.writeFileSync(tempPath, payload, "utf8"); @@ -439,6 +477,9 @@ let asyncSaveQueued: { paths: StoragePaths; payload: string } | null = null; async function writeSessionPayload(paths: StoragePaths, payload: string): Promise { await fs.promises.mkdir(paths.baseDir, { recursive: true }); + if (fs.existsSync(paths.sessionFile)) { + await fsp.copyFile(paths.sessionFile, sessionBackupPath(paths.sessionFile)).catch(() => {}); + } const tempPath = sessionTempPath(paths.sessionFile, "async"); await fsp.writeFile(tempPath, payload, "utf8"); try { diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 237fb02..a817757 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -304,6 +304,58 @@ describe("settings storage", () => { expect(loaded.packageOrder).toEqual(empty.packageOrder); }); + it("loads backup session when primary session is corrupted", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + + const backupSession = emptySession(); + backupSession.packageOrder = ["pkg-backup"]; + backupSession.packages["pkg-backup"] = { + id: "pkg-backup", + name: "Backup Package", + outputDir: path.join(dir, "out"), + extractDir: path.join(dir, "extract"), + status: "queued", + itemIds: ["item-backup"], + cancelled: false, + enabled: true, + createdAt: Date.now(), + updatedAt: Date.now() + }; + backupSession.items["item-backup"] = { + id: "item-backup", + packageId: "pkg-backup", + url: "https://example.com/backup-file", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fileName: "backup-file.rar", + targetPath: path.join(dir, "out", "backup-file.rar"), + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt: Date.now(), + updatedAt: Date.now() + }; + + fs.writeFileSync(`${paths.sessionFile}.bak`, JSON.stringify(backupSession), "utf8"); + fs.writeFileSync(paths.sessionFile, "{broken-session-json", "utf8"); + + const loaded = loadSession(paths); + expect(loaded.packageOrder).toEqual(["pkg-backup"]); + expect(loaded.packages["pkg-backup"]?.name).toBe("Backup Package"); + expect(loaded.items["item-backup"]?.fileName).toBe("backup-file.rar"); + + const restoredPrimary = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as { packages?: Record }; + expect(restoredPrimary.packages && "pkg-backup" in restoredPrimary.packages).toBe(true); + }); + it("returns defaults when config file contains invalid JSON", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); tempDirs.push(dir); @@ -395,6 +447,32 @@ describe("settings storage", () => { expect(persisted.summaryText).toBe("before-mutation"); }); + it("creates session backup before sync and async session overwrites", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + + const first = emptySession(); + first.summaryText = "first"; + saveSession(paths, first); + + const second = emptySession(); + second.summaryText = "second"; + saveSession(paths, second); + + const backupAfterSync = JSON.parse(fs.readFileSync(`${paths.sessionFile}.bak`, "utf8")) as { summaryText?: string }; + expect(backupAfterSync.summaryText).toBe("first"); + + const third = emptySession(); + third.summaryText = "third"; + await saveSessionAsync(paths, third); + + const backupAfterAsync = JSON.parse(fs.readFileSync(`${paths.sessionFile}.bak`, "utf8")) as { summaryText?: string }; + const primaryAfterAsync = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as { summaryText?: string }; + expect(backupAfterAsync.summaryText).toBe("second"); + expect(primaryAfterAsync.summaryText).toBe("third"); + }); + it("applies defaults for missing fields when loading old config", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); tempDirs.push(dir);