fix(recordings): preserve media referenced by saved projects

This commit is contained in:
wiiiii123
2026-04-20 01:49:40 +07:00
parent daf99c0387
commit f7e3494ee3
2 changed files with 173 additions and 5 deletions
+88
View File
@@ -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();
});
});
+85 -5
View File
@@ -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<string>();
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) {