1+ import { randomUUID } from "node:crypto" ;
12import { db } from "@cap/database" ;
23import { videos , videoUploads } from "@cap/database/schema" ;
34import { 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
1966interface VideoProcessingResult {
@@ -48,7 +95,7 @@ export async function importLoomVideoWorkflow(
4895async 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