mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat: harden Bunny upload flow, migrate Bunny playback to hls.js, and add Bunny storage admin stats
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
@@ -21,8 +21,6 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { VideoCard } from '@/components/video-card';
|
||||
|
||||
type SortOrder = 'desc' | 'asc';
|
||||
|
||||
interface SerializedVideo {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -57,13 +55,17 @@ export function ProjectContentClient({
|
||||
videos,
|
||||
canEdit,
|
||||
isOwner,
|
||||
workspaceRole,
|
||||
totalPages,
|
||||
currentPage
|
||||
}: ProjectContentClientProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const sortOrder = searchParams.get('sort') || 'desc';
|
||||
const [localVideos, setLocalVideos] = useState<SerializedVideo[]>(videos);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalVideos(videos);
|
||||
}, [videos]);
|
||||
|
||||
const createQueryString = useCallback(
|
||||
(name: string, value: string) => {
|
||||
@@ -79,12 +81,16 @@ export function ProjectContentClient({
|
||||
[searchParams]
|
||||
);
|
||||
|
||||
const sortedVideos = [...videos].sort((a, b) => {
|
||||
const sortedVideos = [...localVideos].sort((a, b) => {
|
||||
const dateA = new Date(a.updatedAt).getTime();
|
||||
const dateB = new Date(b.updatedAt).getTime();
|
||||
return sortOrder === 'desc' ? dateB - dateA : dateA - dateB;
|
||||
});
|
||||
|
||||
const handleVideoDeleted = useCallback((videoId: string) => {
|
||||
setLocalVideos((prev) => prev.filter((video) => video.id !== videoId));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Project Header */}
|
||||
@@ -171,10 +177,10 @@ export function ProjectContentClient({
|
||||
</div>
|
||||
|
||||
{/* Videos Grid */}
|
||||
{videos.length > 0 ? (
|
||||
{localVideos.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{sortedVideos.map((video) => (
|
||||
<VideoCard key={video.id} video={video} projectId={projectId} />
|
||||
<VideoCard key={video.id} video={video} projectId={projectId} onDeleted={handleVideoDeleted} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
@@ -98,6 +99,8 @@ const isSafeUrl = (url: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
|
||||
|
||||
export default function CompareVersionsPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -117,6 +120,8 @@ export default function CompareVersionsPage() {
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [cursorIdle, setCursorIdle] = useState(false);
|
||||
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Map of versionId -> YT.Player or Custom Adapter
|
||||
@@ -307,6 +312,57 @@ export default function CompareVersionsPage() {
|
||||
handleSeek(currentTime);
|
||||
}, [isDragging, currentTime, handleSeek]);
|
||||
|
||||
const handleVideoMouseMove = useCallback(() => {
|
||||
setCursorIdle(false);
|
||||
if (cursorIdleTimerRef.current) {
|
||||
clearTimeout(cursorIdleTimerRef.current);
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
cursorIdleTimerRef.current = setTimeout(() => {
|
||||
setCursorIdle(true);
|
||||
}, 1000);
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
const handleVideoMouseLeave = useCallback(() => {
|
||||
if (cursorIdleTimerRef.current) {
|
||||
clearTimeout(cursorIdleTimerRef.current);
|
||||
}
|
||||
setCursorIdle(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cursorIdleTimerRef.current) {
|
||||
clearTimeout(cursorIdleTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (cursorIdleTimerRef.current) {
|
||||
clearTimeout(cursorIdleTimerRef.current);
|
||||
cursorIdleTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (!isPlaying) {
|
||||
setCursorIdle(false);
|
||||
return;
|
||||
}
|
||||
|
||||
cursorIdleTimerRef.current = setTimeout(() => {
|
||||
setCursorIdle(true);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (cursorIdleTimerRef.current) {
|
||||
clearTimeout(cursorIdleTimerRef.current);
|
||||
cursorIdleTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isPlaying]);
|
||||
|
||||
// Keyboard shortcuts (matching video page)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -563,9 +619,12 @@ export default function CompareVersionsPage() {
|
||||
<div
|
||||
className={cn(
|
||||
'bg-black flex items-center justify-center relative cursor-pointer group',
|
||||
cursorIdle && isPlaying && 'cursor-none',
|
||||
isCommentsOpen ? 'h-[55%]' : 'flex-1'
|
||||
)}
|
||||
onClick={handlePlayPause}
|
||||
onMouseMove={handleVideoMouseMove}
|
||||
onMouseLeave={handleVideoMouseLeave}
|
||||
>
|
||||
{version.providerId === 'youtube' ? (
|
||||
<YouTubePanel
|
||||
@@ -595,7 +654,7 @@ export default function CompareVersionsPage() {
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity duration-300 pointer-events-none',
|
||||
isPlaying ? 'opacity-0 group-hover:opacity-100' : 'opacity-100'
|
||||
isPlaying ? (cursorIdle ? 'opacity-0' : 'opacity-0 group-hover:opacity-100') : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full bg-black/60 flex items-center justify-center">
|
||||
@@ -798,7 +857,7 @@ function YouTubePanel({
|
||||
return <div ref={containerRef} className="w-full h-full pointer-events-none" />;
|
||||
}
|
||||
|
||||
// Isolated Bunny Steam player component per panel filtering Player.js to YouTube wrapper interface
|
||||
// Isolated Bunny Stream player component per panel mapped to the shared adapter interface
|
||||
function BunnyPanel({
|
||||
version,
|
||||
onRegister,
|
||||
@@ -808,86 +867,218 @@ function BunnyPanel({
|
||||
onRegister: (versionId: string, player: YT.Player | PlayerAdapter) => void;
|
||||
onUnregister: (versionId: string) => void;
|
||||
}) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const [portraitFrameWidth, setPortraitFrameWidth] = useState<number>(0);
|
||||
const [isPortraitSource, setIsPortraitSource] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iframeRef.current) return;
|
||||
const playerjs = require('player.js');
|
||||
const player = new playerjs.Player(iframeRef.current);
|
||||
const panelEl = panelRef.current;
|
||||
if (!panelEl || typeof ResizeObserver === 'undefined') return;
|
||||
|
||||
const updateFrameWidth = () => {
|
||||
const panelWidth = panelEl.clientWidth;
|
||||
const panelHeight = panelEl.clientHeight;
|
||||
if (panelWidth <= 0 || panelHeight <= 0) return;
|
||||
setPortraitFrameWidth(Math.min(panelWidth, panelHeight * (9 / 16)));
|
||||
};
|
||||
|
||||
updateFrameWidth();
|
||||
const observer = new ResizeObserver(updateFrameWidth);
|
||||
observer.observe(panelEl);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const videoEl = videoRef.current;
|
||||
if (!videoEl) return;
|
||||
|
||||
let cachedTime = 0;
|
||||
let cachedDuration = 0;
|
||||
let isPlaying = false;
|
||||
let isMuted = false;
|
||||
let destroyed = false;
|
||||
let retryAttempt = 0;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
player.on('ready', () => {
|
||||
player.getDuration((d: number) => { cachedDuration = d; });
|
||||
|
||||
const adapter = {
|
||||
playVideo: () => player.play(),
|
||||
pauseVideo: () => player.pause(),
|
||||
seekTo: (time: number) => { cachedTime = time; player.setCurrentTime(time); },
|
||||
mute: () => { isMuted = true; player.mute(); },
|
||||
unMute: () => { isMuted = false; player.unmute(); },
|
||||
isMuted: () => isMuted,
|
||||
getCurrentTime: () => cachedTime,
|
||||
getDuration: () => cachedDuration,
|
||||
getPlayerState: () => isPlaying ? window.YT?.PlayerState?.PLAYING : window.YT?.PlayerState?.PAUSED,
|
||||
setPlaybackRate: (rate: number) => {
|
||||
try {
|
||||
if (player && typeof player.setPlaybackRate === 'function') {
|
||||
player.setPlaybackRate(rate);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to set playback rate', e);
|
||||
}
|
||||
},
|
||||
destroy: () => {
|
||||
try {
|
||||
player.off('ready');
|
||||
player.off('timeupdate');
|
||||
player.off('play');
|
||||
player.off('pause');
|
||||
player.off('ended');
|
||||
} catch { }
|
||||
const clearRetryTimer = () => {
|
||||
if (!retryTimer) return;
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
};
|
||||
const getRetryUrl = (baseUrl: string) => {
|
||||
retryAttempt += 1;
|
||||
const separator = baseUrl.includes('?') ? '&' : '?';
|
||||
return `${baseUrl}${separator}retry=${Date.now()}-${retryAttempt}`;
|
||||
};
|
||||
const scheduleRetry = (retryFn: () => void) => {
|
||||
clearRetryTimer();
|
||||
retryTimer = setTimeout(() => {
|
||||
if (!destroyed) {
|
||||
retryFn();
|
||||
}
|
||||
};
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
onRegister(version.id, adapter);
|
||||
});
|
||||
const adapter: PlayerAdapter = {
|
||||
playVideo: () => {
|
||||
videoEl.play().catch((err) => console.error('Error playing Bunny panel video:', err));
|
||||
},
|
||||
pauseVideo: () => videoEl.pause(),
|
||||
seekTo: (time: number) => {
|
||||
cachedTime = time;
|
||||
videoEl.currentTime = time;
|
||||
},
|
||||
mute: () => {
|
||||
videoEl.muted = true;
|
||||
},
|
||||
unMute: () => {
|
||||
videoEl.muted = false;
|
||||
},
|
||||
isMuted: () => videoEl.muted,
|
||||
getCurrentTime: () => videoEl.currentTime || cachedTime,
|
||||
getDuration: () => {
|
||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
|
||||
cachedDuration = videoEl.duration;
|
||||
}
|
||||
return cachedDuration;
|
||||
},
|
||||
getPlayerState: () => (
|
||||
isPlaying
|
||||
? (window.YT?.PlayerState?.PLAYING ?? 1)
|
||||
: (window.YT?.PlayerState?.PAUSED ?? 2)
|
||||
),
|
||||
setPlaybackRate: (rate: number) => {
|
||||
videoEl.playbackRate = rate;
|
||||
},
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
clearRetryTimer();
|
||||
videoEl.removeEventListener('timeupdate', onTimeUpdate);
|
||||
videoEl.removeEventListener('play', onPlay);
|
||||
videoEl.removeEventListener('pause', onPause);
|
||||
videoEl.removeEventListener('ended', onEnded);
|
||||
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
if (hlsRef.current) {
|
||||
try { hlsRef.current.destroy(); } catch { /* ignore */ }
|
||||
hlsRef.current = null;
|
||||
}
|
||||
videoEl.removeAttribute('src');
|
||||
videoEl.load();
|
||||
},
|
||||
};
|
||||
|
||||
player.on('timeupdate', (data: { seconds: number }) => { cachedTime = data.seconds; });
|
||||
player.on('play', () => { isPlaying = true; });
|
||||
player.on('pause', () => { isPlaying = false; });
|
||||
player.on('ended', () => { isPlaying = false; });
|
||||
const onLoadedMetadata = () => {
|
||||
clearRetryTimer();
|
||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
|
||||
cachedDuration = videoEl.duration;
|
||||
}
|
||||
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
|
||||
setIsPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
|
||||
}
|
||||
};
|
||||
const onTimeUpdate = () => { cachedTime = videoEl.currentTime || 0; };
|
||||
const onPlay = () => { isPlaying = true; };
|
||||
const onPause = () => { isPlaying = false; };
|
||||
const onEnded = () => { isPlaying = false; };
|
||||
|
||||
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoEl.addEventListener('timeupdate', onTimeUpdate);
|
||||
videoEl.addEventListener('play', onPlay);
|
||||
videoEl.addEventListener('pause', onPause);
|
||||
videoEl.addEventListener('ended', onEnded);
|
||||
|
||||
const hlsUrl = `https://${BUNNY_PULL_ZONE_HOSTNAME}/${version.videoId}/playlist.m3u8`;
|
||||
if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
videoEl.src = hlsUrl;
|
||||
videoEl.load();
|
||||
} else if (Hls.isSupported()) {
|
||||
const hls = new Hls();
|
||||
hlsRef.current = hls;
|
||||
hls.attachMedia(videoEl);
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
if (!destroyed) {
|
||||
hls.loadSource(hlsUrl);
|
||||
}
|
||||
});
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (destroyed) return;
|
||||
clearRetryTimer();
|
||||
});
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (destroyed) return;
|
||||
const responseCode = (data as { response?: { code?: number } }).response?.code;
|
||||
const isManifestLoadFailure = data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR
|
||||
|| data.details === Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT;
|
||||
const hasProcessingLikeStatus = responseCode === undefined
|
||||
|| responseCode === 0
|
||||
|| responseCode === 403
|
||||
|| responseCode === 404
|
||||
|| responseCode === 423
|
||||
|| responseCode === 429
|
||||
|| responseCode === 503;
|
||||
const isLikelyProcessing = isManifestLoadFailure && hasProcessingLikeStatus;
|
||||
const isNetworkPreMetadataProcessing = data.type === Hls.ErrorTypes.NETWORK_ERROR
|
||||
&& hasProcessingLikeStatus
|
||||
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
||||
const isUnknownPreMetadataProcessing = !data.details
|
||||
&& !data.type
|
||||
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
||||
|
||||
if (isLikelyProcessing || isNetworkPreMetadataProcessing || isUnknownPreMetadataProcessing) {
|
||||
scheduleRetry(() => {
|
||||
const retryUrl = getRetryUrl(hlsUrl);
|
||||
try {
|
||||
hls.stopLoad();
|
||||
} catch {
|
||||
// ignore stop-load failures; loadSource is the important part
|
||||
}
|
||||
hls.loadSource(retryUrl);
|
||||
hls.startLoad(-1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.fatal) {
|
||||
console.error('Fatal HLS error in compare panel:', data);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('HLS is not supported in this browser.');
|
||||
}
|
||||
|
||||
onRegister(version.id, adapter);
|
||||
|
||||
return () => {
|
||||
onUnregister(version.id);
|
||||
try {
|
||||
player.off('ready');
|
||||
player.off('timeupdate');
|
||||
player.off('play');
|
||||
player.off('pause');
|
||||
player.off('ended');
|
||||
} catch { }
|
||||
adapter.destroy();
|
||||
};
|
||||
}, [version.id, onRegister, onUnregister]);
|
||||
|
||||
const src = version.originalUrl.replace('/play/', '/embed/');
|
||||
const embedSrc = `${src}${src.includes('?') ? '&' : '?'}autoplay=false&controls=false`;
|
||||
}, [version.id, version.videoId, onRegister, onUnregister]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full group">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={embedSrc}
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="w-full h-full pointer-events-none border-0"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope;"
|
||||
allowFullScreen
|
||||
/>
|
||||
<div ref={panelRef} className="relative w-full h-full group flex items-center justify-center bg-black">
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-center bg-black',
|
||||
isPortraitSource ? 'h-full overflow-hidden' : 'w-full h-full'
|
||||
)}
|
||||
style={isPortraitSource && portraitFrameWidth > 0 ? { width: `${portraitFrameWidth}px` } : undefined}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain pointer-events-none border-0 bg-black"
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'center',
|
||||
backgroundColor: 'black',
|
||||
}}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
@@ -10,7 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { parseVideoUrl, fetchVideoMetadata, getThumbnailUrl, type VideoSource } from '@/lib/video-providers';
|
||||
import * as tus from 'tus-js-client';
|
||||
|
||||
@@ -32,12 +32,100 @@ export default function NewVideoPage() {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadStatus, setUploadStatus] = useState('');
|
||||
const [pendingBunnyVideoId, setPendingBunnyVideoId] = useState<string | null>(null);
|
||||
const [pendingBunnyUploadToken, setPendingBunnyUploadToken] = useState<string | null>(null);
|
||||
const pendingBunnyVideoIdRef = useRef<string | null>(null);
|
||||
const pendingBunnyUploadTokenRef = useRef<string | null>(null);
|
||||
const activeTusUploadRef = useRef<tus.Upload | null>(null);
|
||||
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
});
|
||||
const isUploadingFile = isLoading && uploadMode === 'file';
|
||||
const leaveWarningMessage = 'A video upload is in progress. Leaving this page will interrupt it. Do you want to leave?';
|
||||
|
||||
useEffect(() => {
|
||||
pendingBunnyVideoIdRef.current = pendingBunnyVideoId;
|
||||
}, [pendingBunnyVideoId]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingBunnyUploadTokenRef.current = pendingBunnyUploadToken;
|
||||
}, [pendingBunnyUploadToken]);
|
||||
|
||||
const cleanupPendingBunnyVideo = useCallback(async (videoId: string, uploadToken: string, keepalive = false) => {
|
||||
try {
|
||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videoId, uploadToken }),
|
||||
keepalive,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup pending Bunny upload:', error);
|
||||
} finally {
|
||||
if (pendingBunnyVideoIdRef.current === videoId) {
|
||||
pendingBunnyVideoIdRef.current = null;
|
||||
setPendingBunnyVideoId(null);
|
||||
}
|
||||
if (pendingBunnyUploadTokenRef.current === uploadToken) {
|
||||
pendingBunnyUploadTokenRef.current = null;
|
||||
setPendingBunnyUploadToken(null);
|
||||
}
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const abortAndCleanupPendingUpload = useCallback((keepalive = false) => {
|
||||
const pendingVideoId = pendingBunnyVideoIdRef.current;
|
||||
const pendingUploadToken = pendingBunnyUploadTokenRef.current;
|
||||
if (!pendingVideoId || !pendingUploadToken) return;
|
||||
|
||||
if (activeTusUploadRef.current) {
|
||||
try {
|
||||
activeTusUploadRef.current.abort(true);
|
||||
} catch {
|
||||
// Ignore abort failures; we'll still attempt cleanup.
|
||||
} finally {
|
||||
activeTusUploadRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
void cleanupPendingBunnyVideo(pendingVideoId, pendingUploadToken, keepalive);
|
||||
}, [cleanupPendingBunnyVideo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUploadingFile) return;
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
};
|
||||
|
||||
const handlePageHide = () => {
|
||||
abortAndCleanupPendingUpload(true);
|
||||
};
|
||||
|
||||
const handlePopState = () => {
|
||||
const shouldLeave = window.confirm(leaveWarningMessage);
|
||||
if (!shouldLeave) {
|
||||
window.history.pushState(null, '', window.location.href);
|
||||
return;
|
||||
}
|
||||
abortAndCleanupPendingUpload(true);
|
||||
};
|
||||
|
||||
window.history.pushState(null, '', window.location.href);
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
window.addEventListener('pagehide', handlePageHide);
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
window.removeEventListener('pagehide', handlePageHide);
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
};
|
||||
}, [abortAndCleanupPendingUpload, isUploadingFile]);
|
||||
|
||||
// Auto-fetch metadata when a valid video source is detected
|
||||
useEffect(() => {
|
||||
@@ -102,7 +190,9 @@ export default function NewVideoPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const uploadToBunny = async (file: File): Promise<{ videoId: string; libraryId: string; providerId: string; url: string }> => {
|
||||
const uploadToBunny = async (
|
||||
file: File
|
||||
): Promise<{ videoId: string; libraryId: string; providerId: string; url: string; uploadToken: string }> => {
|
||||
// 1. Initialize Bunny Stream upload (creates video & gets signature)
|
||||
setUploadStatus('Initializing upload...');
|
||||
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
@@ -116,7 +206,11 @@ export default function NewVideoPage() {
|
||||
throw new Error(data.error || 'Failed to initialize upload');
|
||||
}
|
||||
|
||||
const { data: { videoId, libraryId, signature, expirationTime } } = await initRes.json();
|
||||
const { data: { videoId, libraryId, signature, expirationTime, uploadToken } } = await initRes.json();
|
||||
setPendingBunnyVideoId(videoId);
|
||||
setPendingBunnyUploadToken(uploadToken);
|
||||
pendingBunnyVideoIdRef.current = videoId;
|
||||
pendingBunnyUploadTokenRef.current = uploadToken;
|
||||
|
||||
// 2. Upload via TUS
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -135,6 +229,7 @@ export default function NewVideoPage() {
|
||||
title: formData.title || file.name,
|
||||
},
|
||||
onError: (error) => {
|
||||
activeTusUploadRef.current = null;
|
||||
reject(new Error('Upload failed: ' + error.message));
|
||||
},
|
||||
onProgress: (bytesUploaded, bytesTotal) => {
|
||||
@@ -143,15 +238,18 @@ export default function NewVideoPage() {
|
||||
setUploadStatus(`Uploading... ${percentage}%`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
activeTusUploadRef.current = null;
|
||||
setUploadStatus('Processing video...');
|
||||
resolve({
|
||||
videoId,
|
||||
libraryId,
|
||||
providerId: 'bunny',
|
||||
url: `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}`
|
||||
url: `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}`,
|
||||
uploadToken,
|
||||
});
|
||||
},
|
||||
});
|
||||
activeTusUploadRef.current = upload;
|
||||
upload.start();
|
||||
});
|
||||
};
|
||||
@@ -165,8 +263,10 @@ export default function NewVideoPage() {
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
let uploadedBunnyVideoId: string | null = null;
|
||||
let uploadedBunnyUploadToken: string | null = null;
|
||||
let finalTitle = formData.title.trim();
|
||||
let finalDescription = formData.description.trim() || null;
|
||||
const finalDescription = formData.description.trim() || null;
|
||||
let finalVideoUrl = '';
|
||||
let finalProviderId = '';
|
||||
let finalVideoId = '';
|
||||
@@ -195,6 +295,8 @@ export default function NewVideoPage() {
|
||||
|
||||
// Handle TUS Upload
|
||||
const bunnyData = await uploadToBunny(selectedFile);
|
||||
uploadedBunnyVideoId = bunnyData.videoId;
|
||||
uploadedBunnyUploadToken = bunnyData.uploadToken;
|
||||
|
||||
finalVideoUrl = bunnyData.url;
|
||||
finalProviderId = bunnyData.providerId;
|
||||
@@ -216,20 +318,32 @@ export default function NewVideoPage() {
|
||||
videoId: finalVideoId,
|
||||
thumbnailUrl: finalThumbnailUrl,
|
||||
duration: finalDuration,
|
||||
uploadToken: uploadedBunnyUploadToken,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
setSubmitError(data.error || 'Failed to add video');
|
||||
if (uploadedBunnyVideoId && uploadedBunnyUploadToken) {
|
||||
await cleanupPendingBunnyVideo(uploadedBunnyVideoId, uploadedBunnyUploadToken);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pendingBunnyVideoIdRef.current = null;
|
||||
pendingBunnyUploadTokenRef.current = null;
|
||||
setPendingBunnyVideoId(null);
|
||||
setPendingBunnyUploadToken(null);
|
||||
router.push(`/projects/${projectId}`);
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to add video:', error);
|
||||
setSubmitError(error.message || 'An unexpected error occurred');
|
||||
setSubmitError(error instanceof Error ? error.message : 'An unexpected error occurred');
|
||||
if (pendingBunnyVideoIdRef.current && pendingBunnyUploadTokenRef.current) {
|
||||
await cleanupPendingBunnyVideo(pendingBunnyVideoIdRef.current, pendingBunnyUploadTokenRef.current);
|
||||
}
|
||||
} finally {
|
||||
activeTusUploadRef.current = null;
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -242,6 +356,15 @@ export default function NewVideoPage() {
|
||||
<Link
|
||||
href={`/projects/${projectId}`}
|
||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={(event) => {
|
||||
if (!isUploadingFile) return;
|
||||
const shouldLeave = window.confirm(leaveWarningMessage);
|
||||
if (!shouldLeave) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
abortAndCleanupPendingUpload(true);
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back to Project
|
||||
@@ -256,10 +379,10 @@ export default function NewVideoPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={uploadMode} onValueChange={(v) => setUploadMode(v as 'url' | 'file')} className="mb-6">
|
||||
<Tabs value={uploadMode} onValueChange={(v) => !isLoading && setUploadMode(v as 'url' | 'file')} className="mb-6">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="url">Paste URL</TabsTrigger>
|
||||
<TabsTrigger value="file">Direct Upload</TabsTrigger>
|
||||
<TabsTrigger value="url" disabled={isLoading}>Paste URL</TabsTrigger>
|
||||
<TabsTrigger value="file" disabled={isLoading}>Direct Upload</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
@@ -385,6 +508,11 @@ export default function NewVideoPage() {
|
||||
<div className="bg-primary h-2 rounded-full transition-all" style={{ width: `${uploadProgress}%` }}></div>
|
||||
</div>
|
||||
)}
|
||||
{isUploadingFile && (
|
||||
<p className="text-xs text-amber-500">
|
||||
Do not close, refresh, or navigate away while the upload is in progress.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+15
-3
@@ -2,9 +2,9 @@ import { Metadata } from 'next';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCachedTotalStorage } from '@/lib/admin-stats';
|
||||
import { getCachedBunnyStorageStats, getCachedTotalStorage } from '@/lib/admin-stats';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Users, Folder, Video, MessageSquare, Mic, HardDrive, Image as ImageIcon } from 'lucide-react';
|
||||
import { Users, Folder, Video, MessageSquare, Mic, HardDrive, Image as ImageIcon, Film } from 'lucide-react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Admin Dashboard | OpenFrame',
|
||||
@@ -49,7 +49,10 @@ export default async function AdminDashboardPage() {
|
||||
]);
|
||||
|
||||
// 2. Storage Stats (Cached)
|
||||
const totalStorageBytes = await getCachedTotalStorage();
|
||||
const [totalStorageBytes, bunnyStorageStats] = await Promise.all([
|
||||
getCachedTotalStorage(),
|
||||
getCachedBunnyStorageStats(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 px-4 md:px-8">
|
||||
@@ -123,6 +126,15 @@ export default async function AdminDashboardPage() {
|
||||
<div className="text-2xl font-bold">{formatBytes(totalStorageBytes)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Bunny Stream Storage</CardTitle>
|
||||
<Film className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatBytes(bunnyStorageStats.totalBytes)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,8 +2,12 @@ import { Metadata } from 'next';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCachedUserMediaStorage } from '@/lib/admin-stats';
|
||||
import { HardDrive } from 'lucide-react';
|
||||
import {
|
||||
getCachedBunnyStorageStats,
|
||||
getCachedUserBunnyStorage,
|
||||
getCachedUserMediaStorage
|
||||
} from '@/lib/admin-stats';
|
||||
import { Film, HardDrive } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -70,8 +74,12 @@ export default async function AdminUsersPage({
|
||||
|
||||
const totalPages = Math.ceil(totalUsers / pageSize);
|
||||
|
||||
// Determine media storage per user (Cached)
|
||||
const userStorage = await getCachedUserMediaStorage();
|
||||
// Determine per-user storage usage (cached)
|
||||
const [userStorage, userBunnyStorage, bunnyStorageStats] = await Promise.all([
|
||||
getCachedUserMediaStorage(),
|
||||
getCachedUserBunnyStorage(),
|
||||
getCachedBunnyStorageStats(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 px-4 md:px-8">
|
||||
@@ -79,6 +87,29 @@ export default async function AdminUsersPage({
|
||||
<h2 className="text-3xl font-bold tracking-tight">Users</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Bunny Stream Storage</CardTitle>
|
||||
<Film className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatBytes(bunnyStorageStats.totalBytes)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Cloudflare R2 Media Storage</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatBytes(Object.values(userStorage).reduce((sum, item) => sum + item.total, 0))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Users</CardTitle>
|
||||
@@ -96,13 +127,14 @@ export default async function AdminUsersPage({
|
||||
<TableHead className="text-center">Workspaces Owned</TableHead>
|
||||
<TableHead className="text-center">Projects Owned</TableHead>
|
||||
<TableHead className="text-center">Total Comments</TableHead>
|
||||
<TableHead className="text-right">Bunny Upload</TableHead>
|
||||
<TableHead className="text-right">Media Storage</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
<TableCell colSpan={7} className="h-24 text-center">
|
||||
No users found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -121,6 +153,9 @@ export default async function AdminUsersPage({
|
||||
<TableCell className="text-center">{user._count.ownedWorkspaces}</TableCell>
|
||||
<TableCell className="text-center">{user._count.projects}</TableCell>
|
||||
<TableCell className="text-center">{user._count.comments}</TableCell>
|
||||
<TableCell className="text-right text-sm font-medium">
|
||||
{formatBytes(userBunnyStorage[user.id] || 0)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-medium text-foreground">{formatBytes(userStorage[user.id]?.total || 0)}</span>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { cleanupVideoMediaFiles } from '@/lib/r2-cleanup';
|
||||
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
|
||||
@@ -200,6 +201,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
const video = await db.video.findFirst({
|
||||
where: { id: videoId, projectId },
|
||||
include: {
|
||||
versions: {
|
||||
select: {
|
||||
providerId: true,
|
||||
videoId: true,
|
||||
},
|
||||
},
|
||||
project: {
|
||||
include: {
|
||||
members: { where: { userId: session.user.id } },
|
||||
@@ -229,6 +236,9 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.forbidden('Only project owner or admin can delete videos');
|
||||
}
|
||||
|
||||
// Delete Bunny provider videos first to avoid orphaned assets.
|
||||
await cleanupBunnyStreamVideos(video.versions);
|
||||
|
||||
// Clean up voice files from R2 before cascade delete removes comment rows
|
||||
await cleanupVideoMediaFiles(videoId);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string; videoId: string; versionId: string }> };
|
||||
@@ -125,6 +126,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const wasActive = result.version.isActive;
|
||||
|
||||
// Delete Bunny provider asset for this version before DB deletion.
|
||||
await cleanupBunnyStreamVideos([{
|
||||
providerId: result.version.providerId,
|
||||
videoId: result.version.videoId,
|
||||
}]);
|
||||
|
||||
// Delete the version (cascades to comments)
|
||||
await db.videoVersion.delete({ where: { id: versionId } });
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { validateUrl, validateOptionalUrl } from '@/lib/validation';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { notifyProjectOwner } from '@/lib/notifications';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
|
||||
|
||||
@@ -98,7 +99,16 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { videoUrl, providerId, providerVideoId, versionLabel, thumbnailUrl, duration, setActive } = body;
|
||||
const {
|
||||
videoUrl,
|
||||
providerId,
|
||||
providerVideoId,
|
||||
versionLabel,
|
||||
thumbnailUrl,
|
||||
duration,
|
||||
setActive,
|
||||
uploadToken
|
||||
} = body;
|
||||
|
||||
if (!videoUrl) {
|
||||
return apiErrors.badRequest('Video URL is required');
|
||||
@@ -115,6 +125,27 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.badRequest(thumbnailUrlError);
|
||||
}
|
||||
|
||||
const normalizedProviderId = typeof providerId === 'string' && providerId.trim()
|
||||
? providerId.trim().toLowerCase()
|
||||
: 'youtube';
|
||||
const normalizedProviderVideoId = typeof providerVideoId === 'string' ? providerVideoId.trim() : '';
|
||||
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
|
||||
|
||||
if (normalizedProviderId === 'bunny') {
|
||||
if (!normalizedProviderVideoId || !normalizedUploadToken) {
|
||||
return apiErrors.badRequest('Bunny uploads must include providerVideoId and uploadToken');
|
||||
}
|
||||
|
||||
const isValidUploadToken = verifyBunnyUploadToken(normalizedUploadToken, {
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
videoId: normalizedProviderVideoId,
|
||||
});
|
||||
if (!isValidUploadToken) {
|
||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
||||
}
|
||||
}
|
||||
|
||||
const nextVersionNumber = (video.versions[0]?.versionNumber || 0) + 1;
|
||||
|
||||
// Use transaction to handle active flag
|
||||
@@ -131,8 +162,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
data: {
|
||||
versionNumber: nextVersionNumber,
|
||||
versionLabel: versionLabel?.trim() || null,
|
||||
providerId: providerId || 'youtube',
|
||||
videoId: providerVideoId || '',
|
||||
providerId: normalizedProviderId,
|
||||
videoId: normalizedProviderVideoId,
|
||||
originalUrl: videoUrl,
|
||||
title: versionLabel?.trim() || `Version ${nextVersionNumber}`,
|
||||
thumbnailUrl: thumbnailUrl || null,
|
||||
|
||||
@@ -3,12 +3,46 @@ import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import crypto from 'crypto';
|
||||
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
||||
import { createBunnyUploadToken, verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||
|
||||
async function getProjectWithEditAccess(projectId: string, userId: string) {
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
members: { where: { userId } },
|
||||
workspace: {
|
||||
include: {
|
||||
members: { where: { userId } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
const isOwner = project.ownerId === userId;
|
||||
const membership = project.members[0];
|
||||
const workspaceMembership = project.workspace.members[0];
|
||||
const canEdit = isOwner ||
|
||||
membership?.role === ProjectMemberRole.ADMIN ||
|
||||
workspaceMembership?.role === WorkspaceMemberRole.ADMIN;
|
||||
|
||||
if (!canEdit) return null;
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
// POST /api/projects/[projectId]/videos/bunny-init
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const limited = await rateLimit(request, 'mutate');
|
||||
if (limited) return limited;
|
||||
|
||||
const session = await auth();
|
||||
const { projectId } = await params;
|
||||
|
||||
@@ -16,36 +50,13 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
// Check project access (must be owner, project admin, or workspace admin)
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
members: { where: { userId: session.user.id } },
|
||||
workspace: {
|
||||
include: {
|
||||
members: { where: { userId: session.user.id } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const project = await getProjectWithEditAccess(projectId, session.user.id);
|
||||
if (!project) {
|
||||
return apiErrors.notFound('Project');
|
||||
}
|
||||
|
||||
const isOwner = project.ownerId === session.user.id;
|
||||
const membership = project.members[0];
|
||||
const workspaceMembership = project.workspace.members[0];
|
||||
const canEdit = isOwner ||
|
||||
membership?.role === ProjectMemberRole.ADMIN ||
|
||||
workspaceMembership?.role === WorkspaceMemberRole.ADMIN;
|
||||
|
||||
if (!canEdit) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { title } = body;
|
||||
const body = await request.json().catch(() => null);
|
||||
const title = typeof body?.title === 'string' ? body.title.trim() : '';
|
||||
|
||||
if (!title) {
|
||||
return apiErrors.badRequest('Title is required');
|
||||
@@ -76,6 +87,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const bunnyVideo = await bunnyRes.json();
|
||||
const videoId = bunnyVideo.guid;
|
||||
if (typeof videoId !== 'string' || videoId.length === 0) {
|
||||
return apiErrors.internalError('Upload provider did not return a valid video identifier');
|
||||
}
|
||||
|
||||
// 2. Generate TUS upload signature
|
||||
const expirationTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour validity
|
||||
@@ -84,12 +98,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(libraryId + apiKey + expirationTime + videoId);
|
||||
const signature = hash.digest('hex');
|
||||
const uploadToken = createBunnyUploadToken({
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
videoId,
|
||||
}, 3600);
|
||||
|
||||
const response = successResponse({
|
||||
videoId,
|
||||
libraryId,
|
||||
signature,
|
||||
expirationTime
|
||||
expirationTime,
|
||||
uploadToken,
|
||||
});
|
||||
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
@@ -98,3 +118,49 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.internalError('Failed to initialize upload');
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/projects/[projectId]/videos/bunny-init
|
||||
// Best-effort cleanup for interrupted uploads before a DB row is created.
|
||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const limited = await rateLimit(request, 'mutate');
|
||||
if (limited) return limited;
|
||||
|
||||
const session = await auth();
|
||||
const { projectId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
const project = await getProjectWithEditAccess(projectId, session.user.id);
|
||||
if (!project) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const videoId = typeof body?.videoId === 'string' ? body.videoId.trim() : '';
|
||||
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
|
||||
|
||||
if (!videoId || !uploadToken) {
|
||||
return apiErrors.badRequest('videoId and uploadToken are required');
|
||||
}
|
||||
|
||||
const isValidUploadToken = verifyBunnyUploadToken(uploadToken, {
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
videoId,
|
||||
});
|
||||
if (!isValidUploadToken) {
|
||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
||||
}
|
||||
|
||||
await cleanupBunnyStreamVideos([{ providerId: 'bunny', videoId }]);
|
||||
|
||||
const response = successResponse({ message: 'Pending upload cleaned up' });
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up pending Bunny upload:', error);
|
||||
return apiErrors.internalError('Failed to cleanup pending upload');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { validateUrl, validateOptionalUrl } from '@/lib/validation';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { notifyProjectOwner } from '@/lib/notifications';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||
|
||||
@@ -103,7 +104,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { title, description, videoUrl, providerId, videoId, thumbnailUrl, duration } = body;
|
||||
const { title, description, videoUrl, providerId, videoId, thumbnailUrl, duration, uploadToken } = body;
|
||||
|
||||
if (!title || !videoUrl) {
|
||||
return apiErrors.badRequest('Title and video URL are required');
|
||||
@@ -120,6 +121,27 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.badRequest(thumbnailUrlError);
|
||||
}
|
||||
|
||||
const normalizedProviderId = typeof providerId === 'string' && providerId.trim()
|
||||
? providerId.trim().toLowerCase()
|
||||
: 'youtube';
|
||||
const normalizedVideoId = typeof videoId === 'string' ? videoId.trim() : '';
|
||||
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
|
||||
|
||||
if (normalizedProviderId === 'bunny') {
|
||||
if (!normalizedVideoId || !normalizedUploadToken) {
|
||||
return apiErrors.badRequest('Bunny uploads must include videoId and uploadToken');
|
||||
}
|
||||
|
||||
const isValidUploadToken = verifyBunnyUploadToken(normalizedUploadToken, {
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
videoId: normalizedVideoId,
|
||||
});
|
||||
if (!isValidUploadToken) {
|
||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
||||
}
|
||||
}
|
||||
|
||||
// Get the next position
|
||||
const lastVideo = await db.video.findFirst({
|
||||
where: { projectId },
|
||||
@@ -137,8 +159,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
versions: {
|
||||
create: {
|
||||
versionNumber: 1,
|
||||
providerId: providerId || 'youtube',
|
||||
videoId: videoId || '',
|
||||
providerId: normalizedProviderId,
|
||||
videoId: normalizedVideoId,
|
||||
originalUrl: videoUrl,
|
||||
title: title.trim(),
|
||||
thumbnailUrl: thumbnailUrl || null,
|
||||
|
||||
Reference in New Issue
Block a user