diff --git a/dev/payload.config.ts b/dev/payload.config.ts index dbbc67e..41c3380 100644 --- a/dev/payload.config.ts +++ b/dev/payload.config.ts @@ -50,6 +50,7 @@ const buildConfigWithMemoryDB = async () => { // Return the URL from your custom `externalUrl` field return ((doc?.stream as { thumbnailUrl?: string })?.thumbnailUrl as string) || null }, + disableLocalStorage: true, staticDir: path.resolve(dirname, 'media'), }, }, diff --git a/src/adapters/cloudflareStream.ts b/src/adapters/cloudflareStream.ts index 5428f82..df49bef 100644 --- a/src/adapters/cloudflareStream.ts +++ b/src/adapters/cloudflareStream.ts @@ -6,6 +6,11 @@ type CloudflareStreamAPIResponse = { messages?: string[] result: { created?: string + duration?: number + input?: { + height: number + width: number + } meta?: { [key: string]: boolean | null | number | string } @@ -15,6 +20,7 @@ type CloudflareStreamAPIResponse = { hls?: string } readyToStream: boolean + readyToStreamAt?: string requireSignedURLs?: boolean size?: number status?: { @@ -39,6 +45,8 @@ const streamResponseFromCloudflareStreamAPI = ( result: response.result ? { ...response.result, + durationInSeconds: response.result.duration, + height: response.result.input?.height, meta: response.result.meta ? { name: @@ -46,7 +54,9 @@ const streamResponseFromCloudflareStreamAPI = ( ...response.result.meta, } : undefined, + readyToStreamAt: response.result.readyToStreamAt, videoId: response.result.uid, + width: response.result.input?.width, } : undefined, success: response.success, @@ -58,6 +68,7 @@ class CloudflareStreamAdapter implements StreamAdapter { private readonly apiToken: string private readonly baseUrl: string private readonly customerSubdomain: string + private readonly generateDownloads: { audio?: boolean; video?: boolean } | boolean private readonly requireSignedURLs: boolean readonly providerName = 'cloudflare_stream' @@ -67,14 +78,55 @@ class CloudflareStreamAdapter implements StreamAdapter { accountId: string, customerSubdomain: string, requireSignedURLs = false, + generateDownloads: { audio?: boolean; video?: boolean } | boolean = true, ) { this.apiToken = apiToken this.accountId = accountId this.baseUrl = 'https://api.cloudflare.com/client/v4' this.requireSignedURLs = requireSignedURLs + this.generateDownloads = generateDownloads this.customerSubdomain = customerSubdomain } + /** + * Generate download links for a video or audio + * + * @param videoId + */ + private async generateDownloadLinks(videoId: string): Promise { + const url = `${this.baseUrl}/accounts/${this.accountId}/stream/${videoId}/downloads` + + const urls = [] + + if (this.isGenerateAudioDownloadEnabled) { + urls.push(`${url}/audio`) + } + + if (this.isGenerateVideoDownloadEnabled) { + urls.push(`${url}`) + } + + try { + await Promise.all( + urls.map(async (endpoint) => { + const response = await fetch(endpoint, { + headers: { + Authorization: `Bearer ${this.apiToken}`, + 'Content-Type': 'application/json', + }, + method: 'POST', + }) + + if (!response.ok) { + throw new Error(`Failed to generate download link: ${response.statusText}`) + } + }), + ) + } catch (error) { + console.error('Error generating download links:', error) + } + } + /** * Copies a video within Cloudflare Stream. * @@ -207,7 +259,17 @@ class CloudflareStreamAdapter implements StreamAdapter { const body = await response.json() - return streamResponseFromCloudflareStreamAPI(body) + const result = streamResponseFromCloudflareStreamAPI(body) + + // Generate download links if enabled + if ( + (this.isGenerateAudioDownloadEnabled || this.isGenerateVideoDownloadEnabled) && + result.result?.readyToStream + ) { + await this.generateDownloadLinks(videoId) + } + + return result } /** @@ -241,18 +303,40 @@ class CloudflareStreamAdapter implements StreamAdapter { return result } + + private get isGenerateAudioDownloadEnabled(): boolean { + if (typeof this.generateDownloads === 'boolean') { + return this.generateDownloads + } + return this.generateDownloads.audio === true + } + + private get isGenerateVideoDownloadEnabled(): boolean { + if (typeof this.generateDownloads === 'boolean') { + return this.generateDownloads + } + return this.generateDownloads.video === true + } } export const cloudflareStreamAdapter = ({ accountId, apiToken, customerSubdomain, + generateDownloads, requireSignedURLs, }: { accountId: string apiToken: string customerSubdomain: string + generateDownloads?: { audio?: boolean; video?: boolean } | boolean requireSignedURLs?: boolean }): StreamAdapter => { - return new CloudflareStreamAdapter(apiToken, accountId, customerSubdomain, requireSignedURLs) + return new CloudflareStreamAdapter( + apiToken, + accountId, + customerSubdomain, + requireSignedURLs, + generateDownloads ?? true, + ) } diff --git a/src/adapters/types.ts b/src/adapters/types.ts index cd59a53..95f29f1 100644 --- a/src/adapters/types.ts +++ b/src/adapters/types.ts @@ -13,6 +13,8 @@ export type StreamResponse = { messages?: string[] result?: { created?: string + durationInSeconds?: number + height?: number meta?: StreamVideoMeta modified?: string playback?: { @@ -20,6 +22,7 @@ export type StreamResponse = { hls?: string } readyToStream: boolean + readyToStreamAt?: string requireSignedURLs?: boolean size?: number status?: { @@ -30,15 +33,22 @@ export type StreamResponse = { } thumbnail?: string videoId: string + width?: number } success: boolean } export type StreamFieldData = { + downloadable?: boolean + durationInSeconds?: number error: string + height?: number provider: string readyToStream: boolean + readyToStreamAt?: string requireSignedURLs: boolean + size?: number thumbnailUrl: string videoId: string + width?: number } diff --git a/src/fields/stream.ts b/src/fields/stream.ts index 861bfef..e83bb62 100644 --- a/src/fields/stream.ts +++ b/src/fields/stream.ts @@ -21,6 +21,7 @@ export const streamField = ({ adapter }: { adapter: StreamAdapter }): Field => { name: 'videoId', type: 'text', admin: { + hidden: true, readOnly: true, }, }, @@ -28,6 +29,7 @@ export const streamField = ({ adapter }: { adapter: StreamAdapter }): Field => { name: 'thumbnailUrl', type: 'text', admin: { + hidden: true, readOnly: true, }, }, @@ -40,10 +42,51 @@ export const streamField = ({ adapter }: { adapter: StreamAdapter }): Field => { }, defaultValue: false, }, + { + name: 'readyToStreamAt', + type: 'date', + admin: { + hidden: true, + readOnly: true, + }, + }, + { + name: 'durationInSeconds', + type: 'number', + admin: { + hidden: true, + readOnly: true, + }, + }, + { + name: 'width', + type: 'number', + admin: { + hidden: true, + readOnly: true, + }, + }, + { + name: 'height', + type: 'number', + admin: { + hidden: true, + readOnly: true, + }, + }, + { + name: 'size', + type: 'number', + admin: { + hidden: true, + readOnly: true, + }, + }, { name: 'provider', type: 'text', admin: { + hidden: true, readOnly: true, }, }, @@ -61,7 +104,6 @@ export const streamField = ({ adapter }: { adapter: StreamAdapter }): Field => { admin: { description: 'If enabled, the video stream URLs will require signed URLs for access, enhancing security.', - // hidden: true, readOnly: true, }, defaultValue: false, @@ -70,5 +112,3 @@ export const streamField = ({ adapter }: { adapter: StreamAdapter }): Field => { ], } } - -// generate types from field diff --git a/src/hooks/afterOperation.ts b/src/hooks/afterOperation.ts index 3a8fee9..91e29b0 100644 --- a/src/hooks/afterOperation.ts +++ b/src/hooks/afterOperation.ts @@ -13,7 +13,7 @@ export const updateStatusHook = ( result.stream.videoId ) { // if there is a stream uid but not ready to stream, fetch latest status - const status = await adapter.getStatus(result.stream.videoId) + const response = await adapter.getStatus(result.stream.videoId) // update video document with latest stream status await req.payload.update({ @@ -21,11 +21,16 @@ export const updateStatusHook = ( collection: collectionSlug, data: { stream: { - error: status.result?.status?.errorReasonText || '', + durationInSeconds: response.result?.durationInSeconds, + error: response.result?.status?.errorReasonText || '', + height: response.result?.height, provider: adapter.providerName || '', - readyToStream: status.result?.readyToStream, - requireSignedURLs: status.result?.requireSignedURLs || false, - thumbnailUrl: status.result?.thumbnail || '', + readyToStream: response.result?.readyToStream, + readyToStreamAt: response.result?.readyToStreamAt, + requireSignedURLs: response.result?.requireSignedURLs || false, + size: response.result?.size, + thumbnailUrl: response.result?.thumbnail || '', + width: response.result?.width, }, }, req,