Skip to content

Commit 091e0a4

Browse files
committed
fix: publish recording replacements without overwriting originals
1 parent 45369c5 commit 091e0a4

9 files changed

Lines changed: 1578 additions & 171 deletions

File tree

apps/desktop-gpui/src/upload.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1347,7 +1347,8 @@ async fn upload_video(
13471347
cancel: &AtomicBool,
13481348
replace_existing: bool,
13491349
) -> Result<(String, Option<String>), AuthApiError> {
1350-
let initiate = checked_upload_step(cancel, || multipart_initiate(video_id)).await?;
1350+
let initiate =
1351+
checked_upload_step(cancel, || multipart_initiate(video_id, replace_existing)).await?;
13511352
let is_drive = is_google_drive_upload(initiate.provider.as_deref(), &initiate.upload_id);
13521353
let parts = upload_parts(
13531354
video_id,
@@ -1394,13 +1395,17 @@ struct InitiateResponse {
13941395
provider: Option<String>,
13951396
}
13961397

1397-
async fn multipart_initiate(video_id: &str) -> Result<InitiateResponse, AuthApiError> {
1398+
async fn multipart_initiate(
1399+
video_id: &str,
1400+
replace_existing: bool,
1401+
) -> Result<InitiateResponse, AuthApiError> {
13981402
let response = auth::authed_request(
13991403
reqwest::Method::POST,
14001404
"/api/upload/multipart/initiate",
14011405
Some(json!({
14021406
"videoId": video_id,
1403-
"contentType": "video/mp4"
1407+
"contentType": "video/mp4",
1408+
"replaceExisting": replace_existing
14041409
})),
14051410
)
14061411
.await

apps/desktop/src-tauri/src/api.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,16 @@ pub struct MultipartUploadInitiateResponse {
2121
pub async fn upload_multipart_initiate(
2222
app: &AppHandle,
2323
video_id: &str,
24+
replace_existing: bool,
2425
) -> Result<MultipartUploadInitiateResponse, AuthedApiError> {
2526
let resp = app
2627
.authed_api_request("/api/upload/multipart/initiate", |c, url| {
2728
c.post(url)
2829
.header("Content-Type", "application/json")
2930
.json(&serde_json::json!({
3031
"videoId": video_id,
31-
"contentType": "video/mp4"
32+
"contentType": "video/mp4",
33+
"replaceExisting": replace_existing
3234
}))
3335
})
3436
.await

apps/desktop/src-tauri/src/upload.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ pub async fn upload_video(
255255
info!("Uploading video {video_id}...");
256256

257257
let start = Instant::now();
258-
let upload = api::upload_multipart_initiate(app, &video_id).await?;
258+
let upload = api::upload_multipart_initiate(app, &video_id, replace_existing).await?;
259259
let is_drive_upload = is_google_drive_upload(upload.provider.as_deref(), &upload.upload_id);
260260
let upload_id = upload.upload_id;
261261

@@ -726,7 +726,7 @@ impl InstantMultipartUpload {
726726
recording_dir: recording_dir.clone(),
727727
})?;
728728

729-
let upload = api::upload_multipart_initiate(&app, &video_id).await?;
729+
let upload = api::upload_multipart_initiate(&app, &video_id, false).await?;
730730
let is_drive_upload = is_google_drive_upload(upload.provider.as_deref(), &upload.upload_id);
731731
let upload_id = upload.upload_id;
732732

@@ -4273,7 +4273,7 @@ pub(crate) mod strict_instant {
42734273
required_audio: bool,
42744274
) -> Result<(), AuthedApiError> {
42754275
let upload = control
4276-
.step(|| api::upload_multipart_initiate(app, &video.id))
4276+
.step(|| api::upload_multipart_initiate(app, &video.id, false))
42774277
.await?;
42784278
let concurrency = if is_google_drive_upload(upload.provider.as_deref(), &upload.upload_id) {
42794279
1

apps/web/__tests__/unit/desktop-recording-output-replacement.test.ts

Lines changed: 231 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { VideoEditSpec } from "@cap/database/types";
22
import { User, Video } from "@cap/web-domain";
3-
import { Effect, Option } from "effect";
3+
import { Effect, Option, Schema } from "effect";
44
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
55

66
const mocks = vi.hoisted(() => ({
@@ -76,6 +76,11 @@ import {
7676
invalidateVideoCache,
7777
} from "@/actions/admin/replace-video";
7878
import { prepareDesktopReupload } from "@/lib/desktop-reupload";
79+
import {
80+
createDesktopReuploadKey,
81+
createDesktopReuploadToken,
82+
decodeDesktopReuploadToken,
83+
} from "@/lib/desktop-reupload-token";
7984
import { saveMetadataAndComplete } from "@/workflows/admin-reprocess-video";
8085
import {
8186
saveEditResultAndComplete,
@@ -128,7 +133,13 @@ function createClient() {
128133
: [video];
129134
return Object.assign(Promise.resolve(rows), {
130135
for: async () => {
131-
events.push(table.table === "jobs" ? "lock-job" : "lock-video");
136+
events.push(
137+
table.table === "jobs"
138+
? "lock-job"
139+
: table.table === "uploads"
140+
? "lock-upload"
141+
: "lock-video",
142+
);
132143
return rows;
133144
},
134145
});
@@ -145,9 +156,10 @@ function createClient() {
145156
updates.push(values);
146157
Object.assign(video, values);
147158
if (values.source && reorderSourceKeys) {
148-
video.source = Object.fromEntries(
149-
Object.entries(video.source).reverse(),
150-
) as typeof video.source;
159+
video.source = Object.assign(
160+
{ type: video.source.type },
161+
Object.fromEntries(Object.entries(video.source).reverse()),
162+
);
151163
}
152164
}
153165
return [{ affectedRows: 1 }];
@@ -236,6 +248,7 @@ beforeEach(() => {
236248
events.push("retire-job");
237249
});
238250
mocks.env.mockReturnValue({
251+
NEXTAUTH_SECRET: "test-only-desktop-reupload-signing-key",
239252
MEDIA_SERVER_URL: "https://media.test",
240253
MEDIA_SERVER_WEBHOOK_SECRET: "secret",
241254
WEB_URL: "https://cap.test",
@@ -253,35 +266,234 @@ describe("desktop reupload publication", () => {
253266
ownerId: User.UserId.make(video.ownerId),
254267
bucketId: Option.none(),
255268
storageIntegrationId: Option.none(),
269+
source: Schema.decodeUnknownSync(Video.Video.fields.source)(video.source),
256270
});
271+
const tokenFor = (snapshot = original()) => {
272+
const token = decodeDesktopReuploadToken(
273+
createDesktopReuploadToken(snapshot, {
274+
uploadId: "provider-upload-id",
275+
provider: "s3",
276+
outputKey: createDesktopReuploadKey(snapshot),
277+
}),
278+
);
279+
if (!token) throw new Error("Expected a replacement token");
280+
upload.rawFileKey = token.outputKey;
281+
return token;
282+
};
283+
const transaction = () =>
284+
createClient() as unknown as Parameters<typeof prepareDesktopReupload>[0];
257285

258286
it("replaces processed playback and stale AI while preserving the link's other metadata", async () => {
259287
video.source.audioLevelOutputKey =
260288
"user/video/.recording/outputs/old-audio.mp4";
261289
video.metadata.editProcessing = { token: "old-edit" };
262-
const tx = createClient() as unknown as Parameters<
263-
typeof prepareDesktopReupload
264-
>[0];
265-
const replacement = await prepareDesktopReupload(tx, original());
290+
video.metadata.completedVideoEdit = { token: "completed-edit" };
291+
video.metadata.chapters = [{ title: "old chapter" }];
292+
video.metadata.aiGenerationStatus = "complete";
293+
const before = structuredClone(video);
294+
const snapshot = original();
295+
const token = tokenFor(snapshot);
296+
const tx = transaction();
297+
const replacement = await prepareDesktopReupload(tx, snapshot, token);
266298
expect(replacement).toEqual({
267-
source: { type: "desktopMP4" },
299+
source: { type: "desktopMP4", outputKey: token.outputKey },
268300
metadata: { customCreatedAt: "2020-01-01T00:00:00Z" },
269301
transcriptionStatus: null,
270302
});
271-
expect(video.source.outputKey).toBeDefined();
272-
expect(events).toEqual(["retire-job", "lock-video"]);
303+
expect(video).toEqual(before);
304+
expect(events).toEqual([
305+
"lock-job",
306+
"lock-video",
307+
"lock-upload",
308+
"retire-job",
309+
]);
310+
expect(mocks.retire).toHaveBeenCalledWith(tx, {
311+
videoId: "video",
312+
userId: "user",
313+
});
314+
expect(mocks.head).not.toHaveBeenCalled();
315+
expect(mocks.access).not.toHaveBeenCalled();
273316
});
274317

275-
it("rejects publication if storage changed during upload", async () => {
318+
it.each(["desktopSegments", "webMP4"])(
319+
"publishes a %s replacement with its immutable MP4 key",
320+
async (type) => {
321+
video.source = { type };
322+
const replacement = await prepareDesktopReupload(
323+
transaction(),
324+
original(),
325+
tokenFor(),
326+
);
327+
expect(replacement?.source.type).toBe(
328+
type === "webMP4" ? "webMP4" : "desktopMP4",
329+
);
330+
expect(replacement?.source.outputKey).toContain(
331+
"user/video/.recording/outputs/reupload-",
332+
);
333+
},
334+
);
335+
336+
it.each(["bucket", "storageIntegrationId", "ownerId"] as const)(
337+
"rejects publication if %s changed during upload before retiring any job",
338+
async (field) => {
339+
const snapshot = original();
340+
const token = tokenFor(snapshot);
341+
video[field] = "different-identity";
342+
const before = structuredClone(video);
343+
await expect(
344+
prepareDesktopReupload(transaction(), snapshot, token),
345+
).rejects.toThrow("storage changed");
346+
expect(video).toEqual(before);
347+
expect(updates).toEqual([]);
348+
expect(events).toEqual(["lock-job", "lock-video"]);
349+
expect(mocks.retire).not.toHaveBeenCalled();
350+
},
351+
);
352+
353+
it.each([null, "newer-upload"])(
354+
"rejects canceled or superseded upload %s under the lock",
355+
async (rawFileKey) => {
356+
const snapshot = original();
357+
const token = tokenFor(snapshot);
358+
upload.rawFileKey = rawFileKey;
359+
const before = structuredClone(video);
360+
await expect(
361+
prepareDesktopReupload(transaction(), snapshot, token),
362+
).rejects.toThrow("canceled or superseded");
363+
expect(video).toEqual(before);
364+
expect(mocks.retire).not.toHaveBeenCalled();
365+
expect(events).toEqual(["lock-job", "lock-video", "lock-upload"]);
366+
},
367+
);
368+
it("rejects an older upload completion after another publication", async () => {
276369
const snapshot = original();
277-
video.bucket = "different-bucket";
278-
const tx = createClient() as unknown as Parameters<
279-
typeof prepareDesktopReupload
280-
>[0];
281-
await expect(prepareDesktopReupload(tx, snapshot)).rejects.toThrow(
282-
"storage changed",
370+
const token = tokenFor(snapshot);
371+
video.source.outputKey = "user/video/.recording/outputs/newer/result.mp4";
372+
const before = structuredClone(video);
373+
await expect(
374+
prepareDesktopReupload(transaction(), snapshot, token),
375+
).rejects.toThrow("source changed");
376+
expect(video).toEqual(before);
377+
expect(mocks.retire).not.toHaveBeenCalled();
378+
});
379+
380+
it("compares normalized source fields under the lock", async () => {
381+
const snapshot = original();
382+
const token = tokenFor(snapshot);
383+
video.source = Object.assign(
384+
{ type: video.source.type },
385+
Object.fromEntries(Object.entries(video.source).reverse()),
386+
{ legacyExtra: "ignored by Video.source" },
283387
);
388+
await expect(
389+
prepareDesktopReupload(transaction(), snapshot, token),
390+
).resolves.toMatchObject({ source: { outputKey: token.outputKey } });
391+
expect(events).toEqual([
392+
"lock-job",
393+
"lock-video",
394+
"lock-upload",
395+
"retire-job",
396+
]);
397+
});
398+
399+
it("returns no update for the exact already-published attempt without retiring a new job", async () => {
400+
const snapshot = original();
401+
const token = tokenFor(snapshot);
402+
video.source = { type: "desktopMP4", outputKey: token.outputKey };
403+
video.metadata.summary = "new summary";
404+
const before = structuredClone(video);
405+
await expect(
406+
prepareDesktopReupload(transaction(), snapshot, token),
407+
).resolves.toBeNull();
408+
expect(video).toEqual(before);
409+
expect(events).toEqual(["lock-job", "lock-video"]);
410+
expect(mocks.retire).not.toHaveBeenCalled();
411+
expect(updates).toEqual([]);
412+
});
413+
414+
it("still validates storage on an already-published retry", async () => {
415+
const snapshot = original();
416+
const token = tokenFor(snapshot);
417+
video.source = { type: "desktopMP4", outputKey: token.outputKey };
418+
video.bucket = "changed-bucket";
419+
await expect(
420+
prepareDesktopReupload(transaction(), snapshot, token),
421+
).rejects.toThrow("storage changed");
422+
expect(mocks.retire).not.toHaveBeenCalled();
423+
});
424+
425+
it("leaves the publication and metadata intact if retiring the job fails", async () => {
426+
const before = structuredClone(video);
427+
mocks.retire.mockImplementationOnce(async () => {
428+
events.push("retire-job");
429+
throw new Error("Job retirement failed");
430+
});
431+
await expect(
432+
prepareDesktopReupload(transaction(), original(), tokenFor()),
433+
).rejects.toThrow("Job retirement failed");
434+
expect(video).toEqual(before);
284435
expect(updates).toEqual([]);
436+
expect(events).toEqual([
437+
"lock-job",
438+
"lock-video",
439+
"lock-upload",
440+
"retire-job",
441+
]);
442+
});
443+
444+
it("keeps job retirement inside the caller's publication transaction", async () => {
445+
const before = structuredClone(video);
446+
const retainedJob = {
447+
generation: "original-generation",
448+
state: "complete",
449+
sourceKey: "user/video/original-source.mp4",
450+
outputKey: video.source.outputKey,
451+
};
452+
let job = { ...retainedJob };
453+
const tx = transaction();
454+
mocks.retire.mockImplementationOnce(async (owner) => {
455+
expect(owner).toBe(tx);
456+
events.push("retire-job");
457+
job = {
458+
...job,
459+
generation: "retired-generation",
460+
state: "source-blocked",
461+
};
462+
});
463+
const publishTransaction = async () => {
464+
events.push("transaction");
465+
const savedVideo = structuredClone(video);
466+
const savedJob = { ...job };
467+
try {
468+
const replacement = await prepareDesktopReupload(
469+
tx,
470+
original(),
471+
tokenFor(),
472+
);
473+
expect(replacement).not.toBeNull();
474+
expect(job.state).toBe("source-blocked");
475+
Object.assign(video, replacement);
476+
events.push("publication-failed");
477+
throw new Error("Publication failed");
478+
} catch (error) {
479+
video = savedVideo;
480+
job = savedJob;
481+
events.push("rollback");
482+
throw error;
483+
}
484+
};
485+
await expect(publishTransaction()).rejects.toThrow("Publication failed");
486+
expect(video).toEqual(before);
487+
expect(job).toEqual(retainedJob);
488+
expect(events).toEqual([
489+
"transaction",
490+
"lock-job",
491+
"lock-video",
492+
"lock-upload",
493+
"retire-job",
494+
"publication-failed",
495+
"rollback",
496+
]);
285497
});
286498
});
287499

0 commit comments

Comments
 (0)