feat: harden Bunny upload flow, migrate Bunny playback to hls.js, and add Bunny storage admin stats

This commit is contained in:
Yusuf İpek
2026-02-22 13:03:45 +03:00
parent 10164069dd
commit e30b4a5b19
17 changed files with 1445 additions and 285 deletions
@@ -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
View File
@@ -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>
);
+40 -5
View File
@@ -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');
}
}
+25 -3
View File
@@ -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,