diff --git a/electron/ipc/recording/prune.test.ts b/electron/ipc/recording/prune.test.ts new file mode 100644 index 00000000..d546be62 --- /dev/null +++ b/electron/ipc/recording/prune.test.ts @@ -0,0 +1,88 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("pruneAutoRecordings", () => { + let tempRoot: string; + let appDataPath: string; + let userDataPath: string; + let tempPath: string; + let appPath: string; + + beforeEach(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-prune-")); + appDataPath = path.join(tempRoot, "AppData"); + userDataPath = path.join(tempRoot, "UserData"); + tempPath = path.join(tempRoot, "Temp"); + appPath = path.join(tempRoot, "App"); + + await Promise.all( + [appDataPath, userDataPath, tempPath, appPath].map((dirPath) => + fs.mkdir(dirPath, { recursive: true }), + ), + ); + + vi.resetModules(); + vi.doMock("electron", () => ({ + app: { + isPackaged: false, + getAppPath: () => appPath, + getPath: (name: string) => { + if (name === "appData") return appDataPath; + if (name === "userData") return userDataPath; + if (name === "temp") return tempPath; + return tempRoot; + }, + setPath: () => undefined, + }, + })); + }); + + afterEach(async () => { + vi.resetModules(); + vi.doUnmock("electron"); + if (tempRoot) { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + + it("preserves recordings referenced by saved projects in the Projects directory", async () => { + const { getRecordingsDir } = await import("../utils"); + const { PROJECTS_DIRECTORY_NAME, PROJECT_FILE_EXTENSION } = await import("../constants"); + const { pruneAutoRecordings } = await import("./prune"); + + const recordingsDir = await getRecordingsDir(); + const projectsDir = path.join(recordingsDir, PROJECTS_DIRECTORY_NAME); + await fs.mkdir(projectsDir, { recursive: true }); + + const recordingPaths: string[] = []; + for (let index = 0; index < 22; index += 1) { + const recordingPath = path.join(recordingsDir, `recording-${index}.mp4`); + recordingPaths.push(recordingPath); + await fs.writeFile(recordingPath, `video-${index}`); + const timestamp = new Date(Date.now() - index * 60_000); + await fs.utimes(recordingPath, timestamp, timestamp); + } + + const protectedRecordingPath = recordingPaths.at(-2); + const prunableRecordingPath = recordingPaths.at(-1); + + await fs.writeFile( + path.join(projectsDir, `saved-project.${PROJECT_FILE_EXTENSION}`), + JSON.stringify( + { + videoPath: protectedRecordingPath, + }, + null, + 2, + ), + "utf-8", + ); + + await pruneAutoRecordings(); + + await expect(fs.access(protectedRecordingPath!)).resolves.toBeUndefined(); + await expect(fs.access(prunableRecordingPath!)).rejects.toThrow(); + }); +}); diff --git a/electron/ipc/recording/prune.ts b/electron/ipc/recording/prune.ts index 1f1bda14..29023dff 100644 --- a/electron/ipc/recording/prune.ts +++ b/electron/ipc/recording/prune.ts @@ -1,14 +1,21 @@ import fs from "node:fs/promises"; import path from "node:path"; import { - AUTO_RECORDING_RETENTION_COUNT, AUTO_RECORDING_MAX_AGE_MS, - PROJECT_FILE_EXTENSION, - LEGACY_PROJECT_FILE_EXTENSIONS, + AUTO_RECORDING_RETENTION_COUNT, COMPANION_AUDIO_LAYOUTS, + LEGACY_PROJECT_FILE_EXTENSIONS, + PROJECT_FILE_EXTENSION, + PROJECTS_DIRECTORY_NAME, } from "../constants"; import { currentVideoPath } from "../state"; -import { normalizePath, getTelemetryPathForVideo, isAutoRecordingPath, getRecordingsDir } from "../utils"; +import { + getRecordingsDir, + getTelemetryPathForVideo, + isAutoRecordingPath, + normalizePath, + normalizeVideoSourcePath, +} from "../utils"; export async function hasSiblingProjectFile(videoPath: string) { const baseName = path.basename(videoPath, path.extname(videoPath)); @@ -30,9 +37,71 @@ export async function hasSiblingProjectFile(videoPath: string) { export { isAutoRecordingPath }; +async function loadSavedProjectMediaPaths() { + const recordingsDir = await getRecordingsDir(); + const projectsDir = path.join(recordingsDir, PROJECTS_DIRECTORY_NAME); + const protectedPaths = new Set(); + const candidateExtensions = new Set([ + PROJECT_FILE_EXTENSION, + ...LEGACY_PROJECT_FILE_EXTENSIONS, + ]); + + const projectEntries = await fs.readdir(projectsDir, { withFileTypes: true }).catch(() => []); + + await Promise.all( + projectEntries + .filter((entry) => { + if (!entry.isFile()) { + return false; + } + + const extension = path.extname(entry.name).replace(/^\./, "").toLowerCase(); + return candidateExtensions.has(extension); + }) + .map(async (entry) => { + const projectPath = path.join(projectsDir, entry.name); + + try { + const rawProject = JSON.parse(await fs.readFile(projectPath, "utf-8")) as { + videoPath?: unknown; + editor?: { webcam?: { sourcePath?: unknown } }; + }; + const candidatePaths = [ + rawProject.videoPath, + rawProject.editor?.webcam?.sourcePath, + ]; + + for (const candidatePath of candidatePaths) { + if ( + typeof candidatePath !== "string" || + candidatePath.trim().length === 0 + ) { + continue; + } + + const normalizedCandidatePath = normalizePath( + normalizeVideoSourcePath(candidatePath) ?? candidatePath, + ); + protectedPaths.add(normalizedCandidatePath); + try { + protectedPaths.add(await fs.realpath(normalizedCandidatePath)); + } catch { + // Ignore missing project media; project loading already surfaces that error. + } + } + } catch { + // Ignore malformed project files during retention pruning. + } + }), + ); + + return protectedPaths; +} + export async function pruneAutoRecordings(exemptPaths: string[] = []) { const recordingsDir = await getRecordingsDir(); await fs.mkdir(recordingsDir, { recursive: true }); + const protectedProjectMediaPaths = await loadSavedProjectMediaPaths(); const exempt = new Set( [currentVideoPath, ...exemptPaths] .filter((value): value is string => Boolean(value)) @@ -65,6 +134,14 @@ export async function pruneAutoRecordings(exemptPaths: string[] = []) { continue; } + const resolvedFilePath = await fs.realpath(entry.filePath).catch(() => normalizedFilePath); + if ( + protectedProjectMediaPaths.has(normalizedFilePath) || + protectedProjectMediaPaths.has(resolvedFilePath) + ) { + continue; + } + const tooOld = now - entry.stats.mtimeMs > AUTO_RECORDING_MAX_AGE_MS; const overLimit = index >= AUTO_RECORDING_RETENTION_COUNT; if (!tooOld && !overLimit) { @@ -78,7 +155,10 @@ export async function pruneAutoRecordings(exemptPaths: string[] = []) { const base = entry.filePath.replace(/\.(mp4|mov|webm)$/i, ""); const companionSuffixes = Array.from( new Set( - COMPANION_AUDIO_LAYOUTS.flatMap((layout) => [layout.systemSuffix, layout.micSuffix]), + COMPANION_AUDIO_LAYOUTS.flatMap((layout) => [ + layout.systemSuffix, + layout.micSuffix, + ]), ), ); for (const suffix of companionSuffixes) {