mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
- Added support for self-hosted S3 video uploads with new environment variables: OPENFRAME_ENABLE_S3_VIDEO_UPLOADS and OPENFRAME_MAX_VIDEO_UPLOAD_BYTES. - Updated .env.example and .env.docker.example to reflect new configuration options. - Enhanced Content Security Policy to include origins for S3-compatible storage. - Updated dependencies for AWS SDK to support new features. - Refactored upload logic to accommodate both Bunny and S3 upload providers. - Updated documentation to clarify the usage of direct uploads and S3 configurations. - Closes #11
335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useState, type Dispatch, type SetStateAction } from 'react';
|
|
import { toast } from 'sonner';
|
|
import * as tus from 'tus-js-client';
|
|
import {
|
|
parseVideoUrl,
|
|
getThumbnailUrl,
|
|
fetchVideoMetadata,
|
|
type VideoSource,
|
|
} from '@/lib/video-providers';
|
|
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
|
|
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
|
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
|
|
|
|
interface UseVersionActionsParams extends VersionActionsConfig {
|
|
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
|
activeVersionId: string | null;
|
|
setActiveVersionId: Dispatch<SetStateAction<string | null>>;
|
|
}
|
|
|
|
export function useVersionActions({
|
|
projectId,
|
|
videoId,
|
|
directUploadsEnabled = false,
|
|
directUploadProvider = 'bunny',
|
|
setVideo,
|
|
activeVersionId,
|
|
setActiveVersionId,
|
|
}: UseVersionActionsParams) {
|
|
const [showVersionDialog, setShowVersionDialog] = useState(false);
|
|
const [newVersionUrl, setNewVersionUrl] = useState('');
|
|
const [newVersionLabel, setNewVersionLabel] = useState('');
|
|
const [newVersionSource, setNewVersionSource] = useState<VideoSource | null>(null);
|
|
const [newVersionUrlError, setNewVersionUrlError] = useState('');
|
|
const [isCreatingVersion, setIsCreatingVersion] = useState(false);
|
|
const [newVersionMode, setNewVersionMode] = useState<'url' | 'file'>('url');
|
|
const [newVersionFile, setNewVersionFile] = useState<File | null>(null);
|
|
const [newVersionUploadProgress, setNewVersionUploadProgress] = useState(0);
|
|
const [newVersionUploadStatus, setNewVersionUploadStatus] = useState('');
|
|
|
|
const [showDeleteVersionDialog, setShowDeleteVersionDialog] = useState(false);
|
|
const [versionToDelete, setVersionToDelete] = useState<string | null>(null);
|
|
const [isDeletingVersion, setIsDeletingVersion] = useState(false);
|
|
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
|
|
|
|
const handleNewVersionUrlChange = (url: string) => {
|
|
setNewVersionUrl(url);
|
|
setNewVersionUrlError('');
|
|
if (!url.trim()) {
|
|
setNewVersionSource(null);
|
|
return;
|
|
}
|
|
const source = parseVideoUrl(url);
|
|
if (source) {
|
|
setNewVersionSource(source);
|
|
} else {
|
|
setNewVersionSource(null);
|
|
if (url.length > 10) setNewVersionUrlError('Unsupported URL');
|
|
}
|
|
};
|
|
|
|
const uploadNewVersionFile = async (file: File, title: string) => {
|
|
if (!projectId) throw new Error('Missing project');
|
|
|
|
if (directUploadProvider === 'r2') {
|
|
setNewVersionUploadStatus('Initializing upload...');
|
|
const uploaded = await uploadVideoToR2(projectId, file, {
|
|
onProgress: (progress) => {
|
|
setNewVersionUploadProgress(progress);
|
|
setNewVersionUploadStatus(`Uploading... ${progress}%`);
|
|
},
|
|
});
|
|
|
|
return {
|
|
finalVideoUrl: uploaded.proxyUrl,
|
|
finalProviderId: 'r2',
|
|
finalProviderVideoId: uploaded.objectKey,
|
|
finalThumbnailUrl: uploaded.thumbnailUrl || '/placeholder-video-thumbnail.png',
|
|
finalDuration: uploaded.duration,
|
|
uploadToken: uploaded.uploadToken,
|
|
objectKey: uploaded.objectKey,
|
|
reservationId: uploaded.reservationId,
|
|
pendingCleanup: {
|
|
objectKey: uploaded.objectKey,
|
|
uploadToken: uploaded.uploadToken,
|
|
reservationId: uploaded.reservationId,
|
|
thumbnailObjectKey: uploaded.thumbnailObjectKey,
|
|
},
|
|
};
|
|
}
|
|
|
|
setNewVersionUploadStatus('Initializing upload...');
|
|
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title }),
|
|
});
|
|
|
|
if (!initRes.ok) throw new Error('Failed to initialize upload');
|
|
const {
|
|
data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken },
|
|
} = await initRes.json();
|
|
|
|
await new Promise((resolve, reject) => {
|
|
setNewVersionUploadStatus('Uploading video...');
|
|
const upload = new tus.Upload(file, {
|
|
endpoint: 'https://video.bunnycdn.com/tusupload',
|
|
retryDelays: [0, 3000, 5000, 10000, 20000],
|
|
headers: {
|
|
AuthorizationSignature: signature,
|
|
AuthorizationExpire: expirationTime.toString(),
|
|
VideoId: bunnyVideoId,
|
|
LibraryId: libraryId,
|
|
},
|
|
metadata: {
|
|
filetype: file.type,
|
|
title,
|
|
},
|
|
onError: (error) => reject(new Error(`Upload failed: ${error.message}`)),
|
|
onProgress: (bytesUploaded, bytesTotal) => {
|
|
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1);
|
|
setNewVersionUploadProgress(Number(percentage));
|
|
setNewVersionUploadStatus(`Uploading... ${percentage}%`);
|
|
},
|
|
onSuccess: () => {
|
|
setNewVersionUploadStatus('Processing video...');
|
|
resolve(true);
|
|
},
|
|
});
|
|
upload.start();
|
|
});
|
|
|
|
return {
|
|
finalVideoUrl: `https://iframe.mediadelivery.net/embed/${libraryId}/${bunnyVideoId}`,
|
|
finalProviderId: 'bunny',
|
|
finalProviderVideoId: bunnyVideoId,
|
|
finalThumbnailUrl: bunnyCdnHostname
|
|
? `https://${bunnyCdnHostname}/${bunnyVideoId}/thumbnail.jpg`
|
|
: null,
|
|
finalDuration: null as number | null,
|
|
uploadToken,
|
|
objectKey: null as string | null,
|
|
reservationId: null as string | null,
|
|
pendingCleanup: {
|
|
bunnyVideoId,
|
|
uploadToken,
|
|
},
|
|
};
|
|
};
|
|
|
|
const handleCreateVersion = async () => {
|
|
if (!projectId) return;
|
|
setIsCreatingVersion(true);
|
|
setNewVersionUploadStatus('');
|
|
setNewVersionUploadProgress(0);
|
|
let pendingCleanup:
|
|
| {
|
|
objectKey: string;
|
|
uploadToken: string;
|
|
reservationId: string | null;
|
|
}
|
|
| {
|
|
bunnyVideoId: string;
|
|
uploadToken: string;
|
|
}
|
|
| null = null;
|
|
|
|
try {
|
|
let finalVideoUrl = '';
|
|
let finalProviderId = '';
|
|
let finalProviderVideoId = '';
|
|
let finalThumbnailUrl: string | null = null;
|
|
let finalDuration: number | null = null;
|
|
let uploadToken: string | null = null;
|
|
let objectKey: string | null = null;
|
|
let reservationId: string | null = null;
|
|
|
|
if (newVersionMode === 'url') {
|
|
if (!newVersionSource) throw new Error('Invalid URL');
|
|
const meta = await fetchVideoMetadata(newVersionSource);
|
|
finalVideoUrl = newVersionSource.originalUrl;
|
|
finalProviderId = newVersionSource.providerId;
|
|
finalProviderVideoId = newVersionSource.videoId;
|
|
finalThumbnailUrl = getThumbnailUrl(newVersionSource, 'large');
|
|
finalDuration = meta?.duration || null;
|
|
} else {
|
|
if (!directUploadsEnabled) throw new Error('Direct uploads are disabled by this host');
|
|
if (!newVersionFile) throw new Error('No file selected');
|
|
let title = newVersionFile.name;
|
|
if (newVersionLabel.trim()) {
|
|
title = newVersionLabel.trim();
|
|
} else {
|
|
title = title.replace(/\.[^/.]+$/, '');
|
|
}
|
|
|
|
const uploaded = await uploadNewVersionFile(newVersionFile, title);
|
|
finalVideoUrl = uploaded.finalVideoUrl;
|
|
finalProviderId = uploaded.finalProviderId;
|
|
finalProviderVideoId = uploaded.finalProviderVideoId;
|
|
finalThumbnailUrl = uploaded.finalThumbnailUrl;
|
|
finalDuration = uploaded.finalDuration;
|
|
uploadToken = uploaded.uploadToken;
|
|
objectKey = uploaded.objectKey;
|
|
reservationId = uploaded.reservationId;
|
|
pendingCleanup = uploaded.pendingCleanup;
|
|
}
|
|
|
|
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}/versions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
videoUrl: finalVideoUrl,
|
|
providerId: finalProviderId,
|
|
providerVideoId: finalProviderVideoId,
|
|
uploadToken,
|
|
objectKey,
|
|
reservationId,
|
|
versionLabel: newVersionLabel.trim() || null,
|
|
thumbnailUrl: finalThumbnailUrl,
|
|
duration: finalDuration,
|
|
setActive: true,
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => null);
|
|
throw new Error(data?.error || 'Failed to create version');
|
|
}
|
|
|
|
const versionData = await res.json();
|
|
const newVersion = versionData.data;
|
|
setVideo((prev) => {
|
|
if (!prev) return prev;
|
|
const updatedVersions = prev.versions.map((v) => ({ ...v, isActive: false }));
|
|
updatedVersions.unshift({
|
|
...newVersion,
|
|
comments: [],
|
|
});
|
|
return { ...prev, versions: updatedVersions };
|
|
});
|
|
setActiveVersionId(newVersion.id);
|
|
setShowVersionDialog(false);
|
|
setNewVersionUrl('');
|
|
setNewVersionLabel('');
|
|
setNewVersionSource(null);
|
|
setNewVersionFile(null);
|
|
setNewVersionUploadStatus('');
|
|
pendingCleanup = null;
|
|
} catch (err) {
|
|
const errorObj = err as Error;
|
|
if (pendingCleanup && projectId) {
|
|
if ('objectKey' in pendingCleanup) {
|
|
await cleanupPendingR2VideoUpload(projectId, pendingCleanup);
|
|
} else {
|
|
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
videoId: pendingCleanup.bunnyVideoId,
|
|
uploadToken: pendingCleanup.uploadToken,
|
|
}),
|
|
}).catch((cleanupError) => {
|
|
console.error('Failed to cleanup pending Bunny version upload:', cleanupError);
|
|
});
|
|
}
|
|
}
|
|
console.error('Failed to create version:', errorObj);
|
|
toast.error(errorObj.message || 'Failed to create version');
|
|
} finally {
|
|
setIsCreatingVersion(false);
|
|
setNewVersionUploadProgress(0);
|
|
}
|
|
};
|
|
|
|
const handleDeleteVersion = async () => {
|
|
if (!versionToDelete || !projectId) return;
|
|
setIsDeletingVersion(true);
|
|
try {
|
|
const res = await fetch(
|
|
`/api/projects/${projectId}/videos/${videoId}/versions/${versionToDelete}`,
|
|
{ method: 'DELETE' }
|
|
);
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => null);
|
|
throw new Error(data?.error || 'Failed to delete version');
|
|
}
|
|
|
|
setVideo((prev) => {
|
|
if (!prev) return prev;
|
|
const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
|
|
if (activeVersionId === versionToDelete && remaining.length > 0) {
|
|
const nextActive = remaining.find((v) => v.isActive) ?? remaining[0];
|
|
setActiveVersionId(nextActive.id);
|
|
}
|
|
return { ...prev, versions: remaining };
|
|
});
|
|
|
|
setShowDeleteVersionDialog(false);
|
|
setVersionToDelete(null);
|
|
toast.success('Version deleted');
|
|
} catch (err) {
|
|
const errorObj = err as Error;
|
|
console.error('Failed to delete version:', errorObj);
|
|
toast.error(errorObj.message || 'Failed to delete version');
|
|
} finally {
|
|
setIsDeletingVersion(false);
|
|
}
|
|
};
|
|
|
|
return {
|
|
showVersionDialog,
|
|
setShowVersionDialog,
|
|
newVersionUrl,
|
|
newVersionLabel,
|
|
setNewVersionLabel,
|
|
newVersionSource,
|
|
newVersionUrlError,
|
|
isCreatingVersion,
|
|
newVersionMode,
|
|
setNewVersionMode,
|
|
newVersionFile,
|
|
setNewVersionFile,
|
|
newVersionUploadProgress,
|
|
newVersionUploadStatus,
|
|
handleNewVersionUrlChange,
|
|
handleCreateVersion,
|
|
showDeleteVersionDialog,
|
|
setShowDeleteVersionDialog,
|
|
setVersionToDelete,
|
|
isDeletingVersion,
|
|
handleDeleteVersion,
|
|
};
|
|
}
|