mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 07:16:02 +00:00
Merge pull request #282 from meiiie/fix/protect-project-recordings-from-prune
fix(recordings): preserve media referenced by saved projects
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
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 < 23; 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 protectedVideoRecordingPath = recordingPaths.at(-3);
|
||||
const protectedWebcamRecordingPath = recordingPaths.at(-2);
|
||||
const prunableRecordingPath = recordingPaths.at(-1);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(projectsDir, `saved-project-video.${PROJECT_FILE_EXTENSION}`),
|
||||
JSON.stringify(
|
||||
{
|
||||
videoPath: protectedVideoRecordingPath,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(projectsDir, `saved-project-webcam.${PROJECT_FILE_EXTENSION}`),
|
||||
JSON.stringify(
|
||||
{
|
||||
editor: {
|
||||
webcam: {
|
||||
sourcePath: protectedWebcamRecordingPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await pruneAutoRecordings();
|
||||
|
||||
await expect(fs.access(protectedVideoRecordingPath!)).resolves.toBeUndefined();
|
||||
await expect(fs.access(protectedWebcamRecordingPath!)).resolves.toBeUndefined();
|
||||
await expect(fs.access(prunableRecordingPath!)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("aborts pruning when a saved project cannot be parsed", 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 recordingPath = path.join(recordingsDir, "recording-stale.mp4");
|
||||
await fs.writeFile(recordingPath, "video");
|
||||
const staleTimestamp = new Date(Date.now() - 48 * 60 * 60 * 1_000);
|
||||
await fs.utimes(recordingPath, staleTimestamp, staleTimestamp);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(projectsDir, `broken-project.${PROJECT_FILE_EXTENSION}`),
|
||||
"{ invalid json",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await expect(pruneAutoRecordings()).rejects.toThrow();
|
||||
await expect(fs.access(recordingPath)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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,74 @@ 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,
|
||||
]);
|
||||
|
||||
let projectEntries: Array<{ isFile(): boolean; name: string }>;
|
||||
try {
|
||||
projectEntries = await fs.readdir(projectsDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
const code =
|
||||
typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
|
||||
if (code === "ENOENT") {
|
||||
return protectedPaths;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
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);
|
||||
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.
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
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 +137,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 +158,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) {
|
||||
|
||||
Reference in New Issue
Block a user