Skip to content

Commit a23182e

Browse files
committed
Refetch Loom download URLs and validate downloads
1 parent a002258 commit a23182e

3 files changed

Lines changed: 71 additions & 7 deletions

File tree

apps/web/actions/loom.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,18 +133,14 @@ function isStreamingUrl(url: string): boolean {
133133
async function getLoomDownloadUrl(loomVideoId: string): Promise<string | null> {
134134
const endpoints = ["transcoded-url", "raw-url"] as const;
135135

136-
let fallbackStreamingUrl: string | null = null;
137-
138136
for (const endpoint of endpoints) {
139137
const url = await fetchLoomEndpoint(loomVideoId, endpoint);
140138
if (!url) continue;
141139

142140
if (!isStreamingUrl(url)) return url;
143-
144-
if (!fallbackStreamingUrl) fallbackStreamingUrl = url;
145141
}
146142

147-
return fallbackStreamingUrl;
143+
return null;
148144
}
149145

150146
async function fetchLoomOEmbed(
@@ -328,6 +324,7 @@ export async function importFromLoom({
328324
rawFileKey,
329325
bucketId: customBucket?.id ?? null,
330326
loomDownloadUrl: downloadUrl,
327+
loomVideoId,
331328
},
332329
]);
333330

apps/web/app/s/[videoId]/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ async function AuthorizedContent({
358358
owner: InferSelectModel<typeof users>;
359359
sharedOrganization: { organizationId: Organisation.OrganisationId } | null;
360360
hasPassword: boolean;
361+
hasActiveUpload: boolean;
361362
orgSettings?: OrganizationSettings | null;
362363
videoSettings?: OrganizationSettings | null;
363364
};
@@ -451,6 +452,7 @@ async function AuthorizedContent({
451452
}
452453

453454
if (
455+
!video.hasActiveUpload &&
454456
video.transcriptionStatus !== "COMPLETE" &&
455457
video.transcriptionStatus !== "PROCESSING" &&
456458
video.transcriptionStatus !== "SKIPPED" &&

apps/web/workflows/import-loom-video.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { randomUUID } from "node:crypto";
12
import { db } from "@cap/database";
23
import { videos, videoUploads } from "@cap/database/schema";
34
import { serverEnv } from "@cap/env";
@@ -14,6 +15,52 @@ interface ImportLoomPayload {
1415
rawFileKey: string;
1516
bucketId: string | null;
1617
loomDownloadUrl: string;
18+
loomVideoId: string;
19+
}
20+
21+
const MINIMUM_VIDEO_SIZE = 1024;
22+
23+
async function fetchFreshLoomDownloadUrl(loomVideoId: string): Promise<string> {
24+
const endpoints = ["transcoded-url", "raw-url"] as const;
25+
26+
for (const endpoint of endpoints) {
27+
try {
28+
const response = await fetch(
29+
`https://www.loom.com/api/campaigns/sessions/${loomVideoId}/${endpoint}`,
30+
{
31+
method: "POST",
32+
headers: {
33+
"Content-Type": "application/json",
34+
Accept: "application/json",
35+
},
36+
body: JSON.stringify({
37+
anonID: randomUUID(),
38+
deviceID: null,
39+
force_original: false,
40+
password: null,
41+
}),
42+
},
43+
);
44+
45+
if (!response.ok || response.status === 204) continue;
46+
47+
const text = await response.text();
48+
if (!text.trim()) continue;
49+
50+
const data = JSON.parse(text) as { url?: string };
51+
const url = data.url;
52+
if (!url) continue;
53+
54+
const path = (url.split("?")[0] ?? "").toLowerCase();
55+
if (path.endsWith(".m3u8") || path.endsWith(".mpd")) continue;
56+
57+
return url;
58+
} catch {}
59+
}
60+
61+
throw new FatalError(
62+
"Could not retrieve a direct download URL from Loom. The video may only be available as a stream.",
63+
);
1764
}
1865

1966
interface VideoProcessingResult {
@@ -48,7 +95,7 @@ export async function importLoomVideoWorkflow(
4895
async function downloadLoomToS3(payload: ImportLoomPayload): Promise<void> {
4996
"use step";
5097

51-
const { videoId, loomDownloadUrl, rawFileKey, bucketId } = payload;
98+
const { videoId, loomVideoId, rawFileKey, bucketId } = payload;
5299

53100
await db()
54101
.update(videoUploads)
@@ -61,6 +108,8 @@ async function downloadLoomToS3(payload: ImportLoomPayload): Promise<void> {
61108
})
62109
.where(eq(videoUploads.videoId, videoId as Video.VideoId));
63110

111+
const freshDownloadUrl = await fetchFreshLoomDownloadUrl(loomVideoId);
112+
64113
const bucketIdOption = Option.fromNullable(bucketId).pipe(
65114
Option.map((id) => S3Bucket.S3BucketId.make(id)),
66115
);
@@ -72,15 +121,31 @@ async function downloadLoomToS3(payload: ImportLoomPayload): Promise<void> {
72121
});
73122
}).pipe(runPromise);
74123

75-
const loomResponse = await fetch(loomDownloadUrl);
124+
const loomResponse = await fetch(freshDownloadUrl);
76125
if (!loomResponse.ok) {
77126
throw new FatalError(
78127
`Failed to download from Loom: ${loomResponse.status} ${loomResponse.statusText}`,
79128
);
80129
}
81130

131+
const contentType = loomResponse.headers.get("content-type") ?? "";
132+
if (
133+
contentType.includes("text/html") ||
134+
contentType.includes("application/json")
135+
) {
136+
throw new FatalError(
137+
`Loom returned non-video content (${contentType}). The download URL may have expired.`,
138+
);
139+
}
140+
82141
const videoBuffer = Buffer.from(await loomResponse.arrayBuffer());
83142

143+
if (videoBuffer.length < MINIMUM_VIDEO_SIZE) {
144+
throw new FatalError(
145+
`Downloaded file is too small (${videoBuffer.length} bytes). The video may not be available for download.`,
146+
);
147+
}
148+
84149
const uploadResponse = await fetch(presignedPutUrl, {
85150
method: "PUT",
86151
body: videoBuffer,

0 commit comments

Comments
 (0)