diff --git a/apps/web/__tests__/unit/segment-playlist-route.test.ts b/apps/web/__tests__/unit/segment-playlist-route.test.ts index 3de9040482..d079178e3d 100644 --- a/apps/web/__tests__/unit/segment-playlist-route.test.ts +++ b/apps/web/__tests__/unit/segment-playlist-route.test.ts @@ -7,6 +7,11 @@ const mocks = vi.hoisted(() => ({ audioInitExists: false, storageUnavailable: false, denied: false, + defaultBucket: false, + source: { type: "desktopSegments" } as { + type: "desktopSegments" | "desktopMP4"; + outputKey?: string; + }, sign: vi.fn(), read: vi.fn(), dispose: async () => {}, @@ -23,6 +28,7 @@ vi.mock("@cap/web-backend", async (importOriginal) => { const { Context, Effect, Option } = await import("effect"); const { Policy, Storage: StorageDomain } = await import("@cap/web-domain"); const bucket = { + provider: "s3", getObject: () => Effect.sync(() => { mocks.read(); @@ -48,7 +54,11 @@ vi.mock("@cap/web-backend", async (importOriginal) => { effect: import("effect").Effect.Effect, ) => effect, Storage: Object.assign(Context.GenericTag("PlaylistTestStorage"), { - getAccessForVideo: () => Effect.succeed([bucket, false] as const), + getAccessForVideo: () => + Effect.succeed([ + bucket, + mocks.defaultBucket ? Option.none() : Option.some({}), + ] as const), }), Videos: Object.assign(Context.GenericTag("PlaylistTestVideos"), { testService: { @@ -60,7 +70,7 @@ vi.mock("@cap/web-backend", async (importOriginal) => { { id: "recording", ownerId: "owner", - source: { type: "desktopSegments" }, + source: mocks.source, }, ]), ), @@ -121,6 +131,8 @@ describe("Instant playlist readiness API", () => { mocks.audioInitExists = false; mocks.storageUnavailable = false; mocks.denied = false; + mocks.defaultBucket = false; + mocks.source = { type: "desktopSegments" }; }); afterAll(() => mocks.dispose()); @@ -170,4 +182,20 @@ describe("Instant playlist readiness API", () => { expect((await request()).status).toBe(401); expect(mocks.read).not.toHaveBeenCalled(); }); + + it.each(["custom", "default"] as const)( + "redirects a published MP4 in a %s bucket to its retained storage key", + async (bucket) => { + mocks.defaultBucket = bucket === "default"; + mocks.source = { + type: "desktopMP4", + outputKey: "owner/recording/.recording/outputs/generation/result.mp4", + }; + const response = await request("mp4", ""); + expect(response.status).toBe(302); + expect(mocks.sign).toHaveBeenCalledExactlyOnceWith( + mocks.source.outputKey, + ); + }, + ); }); diff --git a/apps/web/__tests__/unit/share-playback.test.ts b/apps/web/__tests__/unit/share-playback.test.ts index 1b6c9e87d3..900b503f8c 100644 --- a/apps/web/__tests__/unit/share-playback.test.ts +++ b/apps/web/__tests__/unit/share-playback.test.ts @@ -79,6 +79,31 @@ describe("share page playback URL", () => { source: publishedVideo.source, storageIntegrationId: Option.some("drive-integration"), }); + expect(mocks.sign).toHaveBeenCalledExactlyOnceWith( + publishedVideo.source.outputKey, + ); + }); + + it("signs the audio leveled output when it is the published source", async () => { + mocks.sign.mockReturnValue( + Effect.succeed("https://media.example.com/audio.mp4"), + ); + mocks.access.mockReturnValue( + Effect.succeed([{ getSignedObjectUrl: mocks.sign }, Option.none()]), + ); + const outputKey = "owner/video/.recording/outputs/generation/result.mp4"; + const audioLevelOutputKey = + "owner/video/.recording/outputs/audio-quality-v3/result.mp4"; + await getSharePlaybackUrl({ + ...video, + source: { + type: "desktopMP4", + outputKey, + audioLevelSourceKey: outputKey, + audioLevelOutputKey, + }, + }); + expect(mocks.sign).toHaveBeenCalledExactlyOnceWith(audioLevelOutputKey); }); it("allows the player to use its existing route when storage resolution fails", async () => { diff --git a/apps/web/__tests__/unit/video-download-key.test.ts b/apps/web/__tests__/unit/video-download-key.test.ts new file mode 100644 index 0000000000..4776c63211 --- /dev/null +++ b/apps/web/__tests__/unit/video-download-key.test.ts @@ -0,0 +1,88 @@ +import { Video } from "@cap/web-domain"; +import { Effect } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + rows: [] as unknown[][], + sign: vi.fn(), + access: vi.fn(), +})); + +vi.mock("@cap/database", () => ({ + db: () => ({ + select: () => ({ + from: () => ({ where: () => Promise.resolve(mocks.rows.shift() ?? []) }), + }), + }), +})); +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: () => Promise.resolve({ id: "owner" }), +})); +vi.mock("@cap/web-backend", () => ({ + Storage: { getAccessForVideo: mocks.access }, +})); +vi.mock("@/lib/video-download-permissions", () => ({ + canUserDownloadVideo: () => Promise.resolve(true), +})); +vi.mock("@/lib/server", () => ({ runPromise: Effect.runPromise })); + +import { getVideoDownloadInfo } from "@/actions/videos/download"; + +describe("current video download", () => { + beforeEach(() => { + mocks.rows = []; + mocks.sign.mockReset(); + mocks.access.mockReset(); + }); + + it.each(["current", "original"] as const)( + "signs the %s MP4 key", + async (variant) => { + const outputKey = "owner/video/.recording/outputs/generation/result.mp4"; + const originalKey = "owner/video/.recording/sources/original.mp4"; + mocks.rows = [ + [ + { + id: "video", + ownerId: "owner", + orgId: "organization", + name: "Recording", + public: true, + source: { type: "desktopMP4", outputKey }, + metadata: null, + bucket: "custom-bucket", + storageIntegrationId: null, + folderId: null, + transcriptionStatus: null, + width: null, + height: null, + duration: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + variant === "current" ? [] : [{ sourceKey: originalKey }], + ]; + mocks.sign.mockReturnValue( + Effect.succeed("https://media.example.com/video.mp4"), + ); + mocks.access.mockReturnValue( + Effect.succeed([ + { + headObject: () => Effect.succeed({}), + getSignedObjectUrl: mocks.sign, + }, + ]), + ); + + expect( + await getVideoDownloadInfo(Video.VideoId.make("video"), variant), + ).toMatchObject({ + success: true, + }); + expect(mocks.sign).toHaveBeenCalledExactlyOnceWith( + variant === "current" ? outputKey : originalKey, + ); + }, + ); +}); diff --git a/apps/web/actions/videos/download.ts b/apps/web/actions/videos/download.ts index 5ced7ae8cf..6352ea7a92 100644 --- a/apps/web/actions/videos/download.ts +++ b/apps/web/actions/videos/download.ts @@ -4,7 +4,7 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { videoEdits, videos, videoUploads } from "@cap/database/schema"; import { Storage } from "@cap/web-backend"; -import type { Video } from "@cap/web-domain"; +import { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { Effect } from "effect"; import { runPromise } from "@/lib/server"; @@ -141,10 +141,15 @@ export async function getVideoDownloadInfo( } try { + const decodedVideo = decodeStorageVideo(video); + if (variant === "current") { + const source = Video.Video.getSource(decodedVideo); + if (source instanceof Video.Mp4Source) { + downloadKey = source.getFileKey(); + } + } const downloadUrl = await Effect.gen(function* () { - const [bucket] = yield* Storage.getAccessForVideo( - decodeStorageVideo(video), - ); + const [bucket] = yield* Storage.getAccessForVideo(decodedVideo); const exists = yield* bucket.headObject(downloadKey).pipe( Effect.as(true), Effect.catchAll(() => Effect.succeed(false)), diff --git a/apps/web/app/api/playlist/route.ts b/apps/web/app/api/playlist/route.ts index 692038d5c1..4a8c3c7f31 100644 --- a/apps/web/app/api/playlist/route.ts +++ b/apps/web/app/api/playlist/route.ts @@ -170,8 +170,11 @@ const getPlaylistResponse = ( ) => Effect.gen(function* () { const [bucket, customBucket] = yield* Storage.getAccessForVideo(video); - const isMp4Source = - video.source.type === "desktopMP4" || video.source.type === "webMP4"; + const source = Video.Video.getSource(video); + const isMp4Source = source instanceof Video.Mp4Source; + const mp4Key = isMp4Source + ? source.getFileKey() + : `${video.ownerId}/${video.id}/result.mp4`; if (urlParams.videoType === "raw-preview") { const rawFileKey = yield* resolveRawPreviewKey(video); @@ -364,8 +367,7 @@ const getPlaylistResponse = ( if (bucket.provider === "s3" && Option.isNone(customBucket)) { let redirect = `${video.ownerId}/${video.id}/combined-source/stream.m3u8`; - if (isMp4Source || urlParams.videoType === "mp4") - redirect = `${video.ownerId}/${video.id}/result.mp4`; + if (isMp4Source || urlParams.videoType === "mp4") redirect = mp4Key; else if (video.source.type === "MediaConvert") redirect = `${video.ownerId}/${video.id}/output/video_recording_000.m3u8`; @@ -443,11 +445,9 @@ const getPlaylistResponse = ( }, }); } else if (isMp4Source) { - yield* Effect.log( - `Returning path ${`${video.ownerId}/${video.id}/result.mp4`}`, - ); + yield* Effect.log(`Returning path ${mp4Key}`); return yield* bucket - .getSignedObjectUrl(`${video.ownerId}/${video.id}/result.mp4`) + .getSignedObjectUrl(mp4Key) .pipe(Effect.map(HttpServerResponse.redirect)); } diff --git a/apps/web/lib/share-playback.ts b/apps/web/lib/share-playback.ts index 00da540280..bb8625a2c4 100644 --- a/apps/web/lib/share-playback.ts +++ b/apps/web/lib/share-playback.ts @@ -20,9 +20,9 @@ export const getSharePlaybackUrl = (video: SharePlaybackVideo) => updatedAt: video.updatedAt.toISOString(), }); const [bucket] = yield* Storage.getAccessForVideo(loadedVideo); - return yield* bucket.getSignedObjectUrl( - `${video.owner.id}/${video.id}/result.mp4`, - ); + const source = Video.Video.getSource(loadedVideo); + if (!(source instanceof Video.Mp4Source)) return null; + return yield* bucket.getSignedObjectUrl(source.getFileKey()); }) .pipe(runPromise) .catch(() => null);