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'; 'use client';
import { useCallback } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { import {
@@ -21,8 +21,6 @@ import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { VideoCard } from '@/components/video-card'; import { VideoCard } from '@/components/video-card';
type SortOrder = 'desc' | 'asc';
interface SerializedVideo { interface SerializedVideo {
id: string; id: string;
title: string; title: string;
@@ -57,13 +55,17 @@ export function ProjectContentClient({
videos, videos,
canEdit, canEdit,
isOwner, isOwner,
workspaceRole,
totalPages, totalPages,
currentPage currentPage
}: ProjectContentClientProps) { }: ProjectContentClientProps) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const sortOrder = searchParams.get('sort') || 'desc'; const sortOrder = searchParams.get('sort') || 'desc';
const [localVideos, setLocalVideos] = useState<SerializedVideo[]>(videos);
useEffect(() => {
setLocalVideos(videos);
}, [videos]);
const createQueryString = useCallback( const createQueryString = useCallback(
(name: string, value: string) => { (name: string, value: string) => {
@@ -79,12 +81,16 @@ export function ProjectContentClient({
[searchParams] [searchParams]
); );
const sortedVideos = [...videos].sort((a, b) => { const sortedVideos = [...localVideos].sort((a, b) => {
const dateA = new Date(a.updatedAt).getTime(); const dateA = new Date(a.updatedAt).getTime();
const dateB = new Date(b.updatedAt).getTime(); const dateB = new Date(b.updatedAt).getTime();
return sortOrder === 'desc' ? dateB - dateA : dateA - dateB; return sortOrder === 'desc' ? dateB - dateA : dateA - dateB;
}); });
const handleVideoDeleted = useCallback((videoId: string) => {
setLocalVideos((prev) => prev.filter((video) => video.id !== videoId));
}, []);
return ( return (
<> <>
{/* Project Header */} {/* Project Header */}
@@ -171,10 +177,10 @@ export function ProjectContentClient({
</div> </div>
{/* Videos Grid */} {/* Videos Grid */}
{videos.length > 0 ? ( {localVideos.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sortedVideos.map((video) => ( {sortedVideos.map((video) => (
<VideoCard key={video.id} video={video} projectId={projectId} /> <VideoCard key={video.id} video={video} projectId={projectId} onDeleted={handleVideoDeleted} />
))} ))}
</div> </div>
) : ( ) : (
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import Hls from 'hls.js';
import Link from 'next/link'; import Link from 'next/link';
import { useParams, useSearchParams } from 'next/navigation'; import { useParams, useSearchParams } from 'next/navigation';
import { import {
@@ -98,6 +99,8 @@ const isSafeUrl = (url: string) => {
} }
}; };
const BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
export default function CompareVersionsPage() { export default function CompareVersionsPage() {
const params = useParams(); const params = useParams();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -117,6 +120,8 @@ export default function CompareVersionsPage() {
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [cursorIdle, setCursorIdle] = useState(false);
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLDivElement>(null);
// Map of versionId -> YT.Player or Custom Adapter // Map of versionId -> YT.Player or Custom Adapter
@@ -307,6 +312,57 @@ export default function CompareVersionsPage() {
handleSeek(currentTime); handleSeek(currentTime);
}, [isDragging, currentTime, handleSeek]); }, [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) // Keyboard shortcuts (matching video page)
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@@ -563,9 +619,12 @@ export default function CompareVersionsPage() {
<div <div
className={cn( className={cn(
'bg-black flex items-center justify-center relative cursor-pointer group', 'bg-black flex items-center justify-center relative cursor-pointer group',
cursorIdle && isPlaying && 'cursor-none',
isCommentsOpen ? 'h-[55%]' : 'flex-1' isCommentsOpen ? 'h-[55%]' : 'flex-1'
)} )}
onClick={handlePlayPause} onClick={handlePlayPause}
onMouseMove={handleVideoMouseMove}
onMouseLeave={handleVideoMouseLeave}
> >
{version.providerId === 'youtube' ? ( {version.providerId === 'youtube' ? (
<YouTubePanel <YouTubePanel
@@ -595,7 +654,7 @@ export default function CompareVersionsPage() {
<div <div
className={cn( className={cn(
'absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity duration-300 pointer-events-none', '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"> <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" />; 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({ function BunnyPanel({
version, version,
onRegister, onRegister,
@@ -808,86 +867,218 @@ function BunnyPanel({
onRegister: (versionId: string, player: YT.Player | PlayerAdapter) => void; onRegister: (versionId: string, player: YT.Player | PlayerAdapter) => void;
onUnregister: (versionId: string) => 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(() => { useEffect(() => {
if (!iframeRef.current) return; const panelEl = panelRef.current;
const playerjs = require('player.js'); if (!panelEl || typeof ResizeObserver === 'undefined') return;
const player = new playerjs.Player(iframeRef.current);
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 cachedTime = 0;
let cachedDuration = 0; let cachedDuration = 0;
let isPlaying = false; let isPlaying = false;
let isMuted = false; let destroyed = false;
let retryAttempt = 0;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
player.on('ready', () => { const clearRetryTimer = () => {
player.getDuration((d: number) => { cachedDuration = d; }); if (!retryTimer) return;
clearTimeout(retryTimer);
const adapter = { retryTimer = null;
playVideo: () => player.play(), };
pauseVideo: () => player.pause(), const getRetryUrl = (baseUrl: string) => {
seekTo: (time: number) => { cachedTime = time; player.setCurrentTime(time); }, retryAttempt += 1;
mute: () => { isMuted = true; player.mute(); }, const separator = baseUrl.includes('?') ? '&' : '?';
unMute: () => { isMuted = false; player.unmute(); }, return `${baseUrl}${separator}retry=${Date.now()}-${retryAttempt}`;
isMuted: () => isMuted, };
getCurrentTime: () => cachedTime, const scheduleRetry = (retryFn: () => void) => {
getDuration: () => cachedDuration, clearRetryTimer();
getPlayerState: () => isPlaying ? window.YT?.PlayerState?.PLAYING : window.YT?.PlayerState?.PAUSED, retryTimer = setTimeout(() => {
setPlaybackRate: (rate: number) => { if (!destroyed) {
try { retryFn();
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 { }
} }
}; }, 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; }); const onLoadedMetadata = () => {
player.on('play', () => { isPlaying = true; }); clearRetryTimer();
player.on('pause', () => { isPlaying = false; }); if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
player.on('ended', () => { isPlaying = false; }); 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 () => { return () => {
onUnregister(version.id); onUnregister(version.id);
try { adapter.destroy();
player.off('ready');
player.off('timeupdate');
player.off('play');
player.off('pause');
player.off('ended');
} catch { }
}; };
}, [version.id, onRegister, onUnregister]); }, [version.id, version.videoId, onRegister, onUnregister]);
const src = version.originalUrl.replace('/play/', '/embed/');
const embedSrc = `${src}${src.includes('?') ? '&' : '?'}autoplay=false&controls=false`;
return ( return (
<div className="relative w-full h-full group"> <div ref={panelRef} className="relative w-full h-full group flex items-center justify-center bg-black">
<iframe <div
ref={iframeRef} className={cn(
src={embedSrc} 'relative flex items-center justify-center bg-black',
width="100%" isPortraitSource ? 'h-full overflow-hidden' : 'w-full h-full'
height="100%" )}
className="w-full h-full pointer-events-none border-0" style={isPortraitSource && portraitFrameWidth > 0 ? { width: `${portraitFrameWidth}px` } : undefined}
style={{ pointerEvents: 'none' }} >
allow="accelerometer; autoplay; encrypted-media; gyroscope;" <video
allowFullScreen 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> </div>
) )
} }
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
@@ -10,7 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; 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 { parseVideoUrl, fetchVideoMetadata, getThumbnailUrl, type VideoSource } from '@/lib/video-providers';
import * as tus from 'tus-js-client'; import * as tus from 'tus-js-client';
@@ -32,12 +32,100 @@ export default function NewVideoPage() {
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [uploadStatus, setUploadStatus] = useState(''); 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 [submitError, setSubmitError] = useState('');
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
title: '', title: '',
description: '', 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 // Auto-fetch metadata when a valid video source is detected
useEffect(() => { 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) // 1. Initialize Bunny Stream upload (creates video & gets signature)
setUploadStatus('Initializing upload...'); setUploadStatus('Initializing upload...');
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, { 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'); 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 // 2. Upload via TUS
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -135,6 +229,7 @@ export default function NewVideoPage() {
title: formData.title || file.name, title: formData.title || file.name,
}, },
onError: (error) => { onError: (error) => {
activeTusUploadRef.current = null;
reject(new Error('Upload failed: ' + error.message)); reject(new Error('Upload failed: ' + error.message));
}, },
onProgress: (bytesUploaded, bytesTotal) => { onProgress: (bytesUploaded, bytesTotal) => {
@@ -143,15 +238,18 @@ export default function NewVideoPage() {
setUploadStatus(`Uploading... ${percentage}%`); setUploadStatus(`Uploading... ${percentage}%`);
}, },
onSuccess: () => { onSuccess: () => {
activeTusUploadRef.current = null;
setUploadStatus('Processing video...'); setUploadStatus('Processing video...');
resolve({ resolve({
videoId, videoId,
libraryId, libraryId,
providerId: 'bunny', providerId: 'bunny',
url: `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}` url: `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}`,
uploadToken,
}); });
}, },
}); });
activeTusUploadRef.current = upload;
upload.start(); upload.start();
}); });
}; };
@@ -165,8 +263,10 @@ export default function NewVideoPage() {
setUploadProgress(0); setUploadProgress(0);
try { try {
let uploadedBunnyVideoId: string | null = null;
let uploadedBunnyUploadToken: string | null = null;
let finalTitle = formData.title.trim(); let finalTitle = formData.title.trim();
let finalDescription = formData.description.trim() || null; const finalDescription = formData.description.trim() || null;
let finalVideoUrl = ''; let finalVideoUrl = '';
let finalProviderId = ''; let finalProviderId = '';
let finalVideoId = ''; let finalVideoId = '';
@@ -195,6 +295,8 @@ export default function NewVideoPage() {
// Handle TUS Upload // Handle TUS Upload
const bunnyData = await uploadToBunny(selectedFile); const bunnyData = await uploadToBunny(selectedFile);
uploadedBunnyVideoId = bunnyData.videoId;
uploadedBunnyUploadToken = bunnyData.uploadToken;
finalVideoUrl = bunnyData.url; finalVideoUrl = bunnyData.url;
finalProviderId = bunnyData.providerId; finalProviderId = bunnyData.providerId;
@@ -216,20 +318,32 @@ export default function NewVideoPage() {
videoId: finalVideoId, videoId: finalVideoId,
thumbnailUrl: finalThumbnailUrl, thumbnailUrl: finalThumbnailUrl,
duration: finalDuration, duration: finalDuration,
uploadToken: uploadedBunnyUploadToken,
}), }),
}); });
if (!response.ok) { if (!response.ok) {
const data = await response.json(); const data = await response.json();
setSubmitError(data.error || 'Failed to add video'); setSubmitError(data.error || 'Failed to add video');
if (uploadedBunnyVideoId && uploadedBunnyUploadToken) {
await cleanupPendingBunnyVideo(uploadedBunnyVideoId, uploadedBunnyUploadToken);
}
return; return;
} }
pendingBunnyVideoIdRef.current = null;
pendingBunnyUploadTokenRef.current = null;
setPendingBunnyVideoId(null);
setPendingBunnyUploadToken(null);
router.push(`/projects/${projectId}`); router.push(`/projects/${projectId}`);
} catch (error: any) { } catch (error: unknown) {
console.error('Failed to add video:', error); 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 { } finally {
activeTusUploadRef.current = null;
setIsLoading(false); setIsLoading(false);
} }
}; };
@@ -242,6 +356,15 @@ export default function NewVideoPage() {
<Link <Link
href={`/projects/${projectId}`} href={`/projects/${projectId}`}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors" 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" /> <ArrowLeft className="h-4 w-4 mr-1" />
Back to Project Back to Project
@@ -256,10 +379,10 @@ export default function NewVideoPage() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <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"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="url">Paste URL</TabsTrigger> <TabsTrigger value="url" disabled={isLoading}>Paste URL</TabsTrigger>
<TabsTrigger value="file">Direct Upload</TabsTrigger> <TabsTrigger value="file" disabled={isLoading}>Direct Upload</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
@@ -385,6 +508,11 @@ export default function NewVideoPage() {
<div className="bg-primary h-2 rounded-full transition-all" style={{ width: `${uploadProgress}%` }}></div> <div className="bg-primary h-2 rounded-full transition-all" style={{ width: `${uploadProgress}%` }}></div>
</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> </div>
)} )}
+15 -3
View File
@@ -2,9 +2,9 @@ import { Metadata } from 'next';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation'; 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 { 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 = { export const metadata: Metadata = {
title: 'Admin Dashboard | OpenFrame', title: 'Admin Dashboard | OpenFrame',
@@ -49,7 +49,10 @@ export default async function AdminDashboardPage() {
]); ]);
// 2. Storage Stats (Cached) // 2. Storage Stats (Cached)
const totalStorageBytes = await getCachedTotalStorage(); const [totalStorageBytes, bunnyStorageStats] = await Promise.all([
getCachedTotalStorage(),
getCachedBunnyStorageStats(),
]);
return ( return (
<div className="flex-1 space-y-4 px-4 md:px-8"> <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> <div className="text-2xl font-bold">{formatBytes(totalStorageBytes)}</div>
</CardContent> </CardContent>
</Card> </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>
</div> </div>
); );
+40 -5
View File
@@ -2,8 +2,12 @@ import { Metadata } from 'next';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { getCachedUserMediaStorage } from '@/lib/admin-stats'; import {
import { HardDrive } from 'lucide-react'; getCachedBunnyStorageStats,
getCachedUserBunnyStorage,
getCachedUserMediaStorage
} from '@/lib/admin-stats';
import { Film, HardDrive } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -70,8 +74,12 @@ export default async function AdminUsersPage({
const totalPages = Math.ceil(totalUsers / pageSize); const totalPages = Math.ceil(totalUsers / pageSize);
// Determine media storage per user (Cached) // Determine per-user storage usage (cached)
const userStorage = await getCachedUserMediaStorage(); const [userStorage, userBunnyStorage, bunnyStorageStats] = await Promise.all([
getCachedUserMediaStorage(),
getCachedUserBunnyStorage(),
getCachedBunnyStorageStats(),
]);
return ( return (
<div className="flex-1 space-y-4 px-4 md:px-8"> <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> <h2 className="text-3xl font-bold tracking-tight">Users</h2>
</div> </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> <Card>
<CardHeader> <CardHeader>
<CardTitle>All Users</CardTitle> <CardTitle>All Users</CardTitle>
@@ -96,13 +127,14 @@ export default async function AdminUsersPage({
<TableHead className="text-center">Workspaces Owned</TableHead> <TableHead className="text-center">Workspaces Owned</TableHead>
<TableHead className="text-center">Projects Owned</TableHead> <TableHead className="text-center">Projects Owned</TableHead>
<TableHead className="text-center">Total Comments</TableHead> <TableHead className="text-center">Total Comments</TableHead>
<TableHead className="text-right">Bunny Upload</TableHead>
<TableHead className="text-right">Media Storage</TableHead> <TableHead className="text-right">Media Storage</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{users.length === 0 ? ( {users.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="h-24 text-center"> <TableCell colSpan={7} className="h-24 text-center">
No users found. No users found.
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -121,6 +153,9 @@ export default async function AdminUsersPage({
<TableCell className="text-center">{user._count.ownedWorkspaces}</TableCell> <TableCell className="text-center">{user._count.ownedWorkspaces}</TableCell>
<TableCell className="text-center">{user._count.projects}</TableCell> <TableCell className="text-center">{user._count.projects}</TableCell>
<TableCell className="text-center">{user._count.comments}</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"> <TableCell className="text-right text-sm">
<div className="flex flex-col items-end"> <div className="flex flex-col items-end">
<span className="font-medium text-foreground">{formatBytes(userStorage[user.id]?.total || 0)}</span> <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 { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { cleanupVideoMediaFiles } from '@/lib/r2-cleanup'; import { cleanupVideoMediaFiles } from '@/lib/r2-cleanup';
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> }; 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({ const video = await db.video.findFirst({
where: { id: videoId, projectId }, where: { id: videoId, projectId },
include: { include: {
versions: {
select: {
providerId: true,
videoId: true,
},
},
project: { project: {
include: { include: {
members: { where: { userId: session.user.id } }, 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'); 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 // Clean up voice files from R2 before cascade delete removes comment rows
await cleanupVideoMediaFiles(videoId); await cleanupVideoMediaFiles(videoId);
@@ -3,6 +3,7 @@ import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client'; import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
type RouteParams = { params: Promise<{ projectId: string; videoId: string; versionId: string }> }; 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; 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) // Delete the version (cascades to comments)
await db.videoVersion.delete({ where: { id: versionId } }); await db.videoVersion.delete({ where: { id: versionId } });
@@ -6,6 +6,7 @@ import { validateUrl, validateOptionalUrl } from '@/lib/validation';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { notifyProjectOwner } from '@/lib/notifications'; import { notifyProjectOwner } from '@/lib/notifications';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> }; 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 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) { if (!videoUrl) {
return apiErrors.badRequest('Video URL is required'); return apiErrors.badRequest('Video URL is required');
@@ -115,6 +125,27 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest(thumbnailUrlError); 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; const nextVersionNumber = (video.versions[0]?.versionNumber || 0) + 1;
// Use transaction to handle active flag // Use transaction to handle active flag
@@ -131,8 +162,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
data: { data: {
versionNumber: nextVersionNumber, versionNumber: nextVersionNumber,
versionLabel: versionLabel?.trim() || null, versionLabel: versionLabel?.trim() || null,
providerId: providerId || 'youtube', providerId: normalizedProviderId,
videoId: providerVideoId || '', videoId: normalizedProviderVideoId,
originalUrl: videoUrl, originalUrl: videoUrl,
title: versionLabel?.trim() || `Version ${nextVersionNumber}`, title: versionLabel?.trim() || `Version ${nextVersionNumber}`,
thumbnailUrl: thumbnailUrl || null, thumbnailUrl: thumbnailUrl || null,
@@ -3,12 +3,46 @@ import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client'; import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { rateLimit } from '@/lib/rate-limit';
import crypto from 'crypto'; 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 }> }; 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) { export async function POST(request: NextRequest, { params }: RouteParams) {
try { try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth(); const session = await auth();
const { projectId } = await params; const { projectId } = await params;
@@ -16,36 +50,13 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.unauthorized(); return apiErrors.unauthorized();
} }
// Check project access (must be owner, project admin, or workspace admin) const project = await getProjectWithEditAccess(projectId, session.user.id);
const project = await db.project.findUnique({
where: { id: projectId },
include: {
members: { where: { userId: session.user.id } },
workspace: {
include: {
members: { where: { userId: session.user.id } },
},
},
},
});
if (!project) { 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'); return apiErrors.forbidden('Access denied');
} }
const body = await request.json(); const body = await request.json().catch(() => null);
const { title } = body; const title = typeof body?.title === 'string' ? body.title.trim() : '';
if (!title) { if (!title) {
return apiErrors.badRequest('Title is required'); return apiErrors.badRequest('Title is required');
@@ -76,6 +87,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const bunnyVideo = await bunnyRes.json(); const bunnyVideo = await bunnyRes.json();
const videoId = bunnyVideo.guid; 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 // 2. Generate TUS upload signature
const expirationTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour validity 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'); const hash = crypto.createHash('sha256');
hash.update(libraryId + apiKey + expirationTime + videoId); hash.update(libraryId + apiKey + expirationTime + videoId);
const signature = hash.digest('hex'); const signature = hash.digest('hex');
const uploadToken = createBunnyUploadToken({
userId: session.user.id,
projectId,
videoId,
}, 3600);
const response = successResponse({ const response = successResponse({
videoId, videoId,
libraryId, libraryId,
signature, signature,
expirationTime expirationTime,
uploadToken,
}); });
return withCacheControl(response, 'private, no-store'); 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'); 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 { rateLimit } from '@/lib/rate-limit';
import { notifyProjectOwner } from '@/lib/notifications'; import { notifyProjectOwner } from '@/lib/notifications';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
type RouteParams = { params: Promise<{ projectId: string }> }; type RouteParams = { params: Promise<{ projectId: string }> };
@@ -103,7 +104,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
} }
const body = await request.json(); 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) { if (!title || !videoUrl) {
return apiErrors.badRequest('Title and video URL are required'); 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); 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 // Get the next position
const lastVideo = await db.video.findFirst({ const lastVideo = await db.video.findFirst({
where: { projectId }, where: { projectId },
@@ -137,8 +159,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
versions: { versions: {
create: { create: {
versionNumber: 1, versionNumber: 1,
providerId: providerId || 'youtube', providerId: normalizedProviderId,
videoId: videoId || '', videoId: normalizedVideoId,
originalUrl: videoUrl, originalUrl: videoUrl,
title: title.trim(), title: title.trim(),
thumbnailUrl: thumbnailUrl || null, thumbnailUrl: thumbnailUrl || null,
+3 -3
View File
@@ -15,6 +15,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"hls.js": "^1.6.15",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"next": "16.1.6", "next": "16.1.6",
@@ -22,7 +23,6 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.1",
"pg": "^8.18.0", "pg": "^8.18.0",
"player.js": "^0.1.0",
"prisma": "^7.3.0", "prisma": "^7.3.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.3", "react": "19.2.3",
@@ -1175,6 +1175,8 @@
"hermes-parser": ["[email protected]", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], "hermes-parser": ["[email protected]", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"hls.js": ["[email protected]", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="],
"hono": ["[email protected]", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], "hono": ["[email protected]", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
"http-errors": ["[email protected]", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "http-errors": ["[email protected]", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
@@ -1543,8 +1545,6 @@
"pkg-types": ["[email protected]", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], "pkg-types": ["[email protected]", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
"player.js": ["[email protected]", "", {}, "sha512-pzWPiqw5b4kQCYhXyoLOZWidWcSySVJTqciDOtoH/MzY97piKnUme494dfczCAC7FLkMSPMH2HfhNJU9D/lVFw=="],
"possible-typed-array-names": ["[email protected]", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "possible-typed-array-names": ["[email protected]", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
+4 -5
View File
@@ -55,9 +55,10 @@ interface VideoCardProps {
lastUpdated: string; lastUpdated: string;
}; };
projectId: string; projectId: string;
onDeleted?: (videoId: string) => void;
} }
export function VideoCard({ video, projectId }: VideoCardProps) { export function VideoCard({ video, projectId, onDeleted }: VideoCardProps) {
const router = useRouter(); const router = useRouter();
const [imgError, setImgError] = useState(false); const [imgError, setImgError] = useState(false);
const [retryKey, setRetryKey] = useState(0); const [retryKey, setRetryKey] = useState(0);
@@ -165,8 +166,7 @@ export function VideoCard({ video, projectId }: VideoCardProps) {
}); });
if (res.ok) { if (res.ok) {
setShowDeleteDialog(false); setShowDeleteDialog(false);
// Give revalidatePath time to invalidate cache before refreshing onDeleted?.(video.id);
await new Promise((r) => setTimeout(r, 300));
router.refresh(); router.refresh();
} }
} catch (err) { } catch (err) {
@@ -193,8 +193,7 @@ export function VideoCard({ video, projectId }: VideoCardProps) {
src={`${video.thumbnailUrl?.replace('vz-thumbnail.b-cdn.net', 'vz-965f4f4a-fc1.b-cdn.net')}${retryKey ? `?t=${retryKey}` : ''}`} src={`${video.thumbnailUrl?.replace('vz-thumbnail.b-cdn.net', 'vz-965f4f4a-fc1.b-cdn.net')}${retryKey ? `?t=${retryKey}` : ''}`}
alt={video.title} alt={video.title}
className="absolute inset-0 w-full h-full object-cover transition-transform group-hover:scale-105" className="absolute inset-0 w-full h-full object-cover transition-transform group-hover:scale-105"
onError={(e) => { onError={() => {
console.error('Thumbnail failed to load. Tried:', (e.target as HTMLImageElement).currentSrc);
setImgError(true); setImgError(true);
// Check again after 10 seconds in case Bunny is still processing // Check again after 10 seconds in case Bunny is still processing
setTimeout(() => { setTimeout(() => {
+477 -116
View File
@@ -2,6 +2,7 @@
import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { List } from 'react-window'; import { List } from 'react-window';
import Hls, { type Level } from 'hls.js';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -173,7 +174,25 @@ function formatTime(seconds: number): string {
return `${mins}:${secs.toString().padStart(2, '0')}`; return `${mins}:${secs.toString().padStart(2, '0')}`;
} }
function formatBunnyQualityLabel(level: { height?: number; bitrate?: number }, index: number): string {
if (typeof level.height === 'number' && level.height > 0) {
return `${level.height}p`;
}
if (typeof level.bitrate === 'number' && level.bitrate > 0) {
return `${Math.round(level.bitrate / 1000)} kbps`;
}
return `Level ${index + 1}`;
}
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
const BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
interface BunnyQualityOption {
level: number;
label: string;
}
type BunnyPlaybackState = 'none' | 'processing' | 'error';
export type VideoPageMode = 'dashboard' | 'watch'; export type VideoPageMode = 'dashboard' | 'watch';
@@ -185,6 +204,9 @@ interface VideoPageContentProps {
export function VideoPageContent({ mode, videoId, projectId: propProjectId }: VideoPageContentProps) { export function VideoPageContent({ mode, videoId, projectId: propProjectId }: VideoPageContentProps) {
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const bunnyViewportRef = useRef<HTMLDivElement>(null);
const hlsRef = useRef<Hls | null>(null);
const playerRef = useRef<YT.Player | PlayerAdapter | null>(null); const playerRef = useRef<YT.Player | PlayerAdapter | null>(null);
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLDivElement>(null);
const videoContainerRef = useRef<HTMLDivElement>(null); const videoContainerRef = useRef<HTMLDivElement>(null);
@@ -194,12 +216,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const [error, setError] = useState(''); const [error, setError] = useState('');
const [activeVersionId, setActiveVersionId] = useState<string | null>(null); const [activeVersionId, setActiveVersionId] = useState<string | null>(null);
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [bunnyPlaybackState, setBunnyPlaybackState] = useState<BunnyPlaybackState>('none');
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [videoDuration, setVideoDuration] = useState(0); const [videoDuration, setVideoDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const isDraggingRef = useRef(false);
const [playbackSpeed, setPlaybackSpeed] = useState(1); const [playbackSpeed, setPlaybackSpeed] = useState(1);
const [qualityOptions, setQualityOptions] = useState<BunnyQualityOption[]>([]);
const [selectedQualityLevel, setSelectedQualityLevel] = useState<number>(-1);
const [isBunnyPortraitSource, setIsBunnyPortraitSource] = useState(false);
const [bunnyPortraitFrameWidth, setBunnyPortraitFrameWidth] = useState<number>(0);
const [cursorIdle, setCursorIdle] = useState(false); const [cursorIdle, setCursorIdle] = useState(false);
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastPathnameRef = useRef<string>(pathname); const lastPathnameRef = useRef<string>(pathname);
@@ -231,6 +259,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const [showResumePrompt, setShowResumePrompt] = useState(false); const [showResumePrompt, setShowResumePrompt] = useState(false);
const progressSaveTimerRef = useRef<ReturnType<typeof setInterval> | null>(null); const progressSaveTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const lastSavedProgressRef = useRef<number>(0); const lastSavedProgressRef = useRef<number>(0);
const bunnyRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Fullscreen state // Fullscreen state
const [isFullscreenMode, setIsFullscreenMode] = useState(false); const [isFullscreenMode, setIsFullscreenMode] = useState(false);
@@ -279,6 +308,27 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const [selectedCompareVersions, setSelectedCompareVersions] = useState<Set<string>>(new Set()); const [selectedCompareVersions, setSelectedCompareVersions] = useState<Set<string>>(new Set());
const router = useRouter(); const router = useRouter();
useEffect(() => {
isDraggingRef.current = isDragging;
}, [isDragging]);
useEffect(() => {
const viewportEl = bunnyViewportRef.current;
if (!viewportEl || typeof ResizeObserver === 'undefined') return;
const updateFrameWidth = () => {
const viewportWidth = viewportEl.clientWidth;
const viewportHeight = viewportEl.clientHeight;
if (viewportWidth <= 0 || viewportHeight <= 0) return;
setBunnyPortraitFrameWidth(Math.min(viewportWidth, viewportHeight * (9 / 16)));
};
updateFrameWidth();
const observer = new ResizeObserver(updateFrameWidth);
observer.observe(viewportEl);
return () => observer.disconnect();
}, [activeVersionId]);
useEffect(() => { useEffect(() => {
const saved = localStorage.getItem('openframe_guest_name'); const saved = localStorage.getItem('openframe_guest_name');
if (saved) { if (saved) {
@@ -413,9 +463,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
return `https://www.youtube.com/embed/${activeVersion.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`; return `https://www.youtube.com/embed/${activeVersion.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`;
} }
if (activeVersion.providerId === 'bunny') { if (activeVersion.providerId === 'bunny') {
// Force /embed/ endpoint to ensure player.js integration works correctly and hide native controls return `https://${BUNNY_PULL_ZONE_HOSTNAME}/${activeVersion.videoId}/playlist.m3u8`;
const url = activeVersion.originalUrl.replace('/play/', '/embed/');
return `${url}${url.includes('?') ? '&' : '?'}autoplay=false&controls=false`;
} }
try { try {
const url = new URL(activeVersion.originalUrl); const url = new URL(activeVersion.originalUrl);
@@ -428,6 +476,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
} }
}, [activeVersion]); }, [activeVersion]);
const selectedQualityLabel = useMemo(() => {
if (selectedQualityLevel === -1) return 'Auto';
return qualityOptions.find((option) => option.level === selectedQualityLevel)?.label ?? 'Auto';
}, [qualityOptions, selectedQualityLevel]);
useEffect(() => { useEffect(() => {
if (!projectId) return; if (!projectId) return;
async function fetchTags() { async function fetchTags() {
@@ -477,29 +530,32 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
if (!isYoutube && !isBunny) return; if (!isYoutube && !isBunny) return;
setIsReady(false); setIsReady(false);
setBunnyPlaybackState('none');
setCurrentTime(0); setCurrentTime(0);
setVideoDuration(0); setVideoDuration(0);
setIsPlaying(false); setIsPlaying(false);
setIsMuted(false);
setPlaybackSpeed(1); setPlaybackSpeed(1);
setQualityOptions([]);
setSelectedQualityLevel(-1);
setIsBunnyPortraitSource(false);
if (playerRef.current) { if (playerRef.current) {
if (isYoutube) { try { playerRef.current.destroy(); } catch { /* ignore */ }
try { playerRef.current.destroy(); } catch { /* ignore */ }
} else if (isBunny && 'off' in playerRef.current) {
try {
(playerRef.current as PlayerAdapter).off?.('ready');
(playerRef.current as PlayerAdapter).off?.('play');
(playerRef.current as PlayerAdapter).off?.('pause');
(playerRef.current as PlayerAdapter).off?.('timeupdate');
} catch { /* ignore */ }
}
playerRef.current = null; playerRef.current = null;
} }
if (hlsRef.current) {
try { hlsRef.current.destroy(); } catch { /* ignore */ }
hlsRef.current = null;
}
if (bunnyRetryTimerRef.current) {
clearTimeout(bunnyRetryTimerRef.current);
bunnyRetryTimerRef.current = null;
}
const initPlayer = () => { const initPlayer = () => {
if (!iframeRef.current) return;
if (isYoutube) { if (isYoutube) {
if (!iframeRef.current) return;
playerRef.current = new YT.Player(iframeRef.current, { playerRef.current = new YT.Player(iframeRef.current, {
events: { events: {
onReady: (event: YT.PlayerEvent) => { onReady: (event: YT.PlayerEvent) => {
@@ -535,84 +591,241 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}, },
}); });
} else if (isBunny) { } else if (isBunny) {
// dynamically require player.js to avoid SSR window errors const videoEl = videoRef.current;
const playerjs = require('player.js'); if (!videoEl) return;
const player = new playerjs.Player(iframeRef.current);
playerRef.current = player;
player.on('ready', () => { let cachedDuration = 0;
let destroyed = false;
let retryAttempt = 0;
let usingHlsJs = false;
let hlsInstance: Hls | null = null;
const clearRetryTimer = () => {
if (bunnyRetryTimerRef.current) {
clearTimeout(bunnyRetryTimerRef.current);
bunnyRetryTimerRef.current = null;
}
};
const scheduleRetry = (retryFn: () => void) => {
clearRetryTimer();
bunnyRetryTimerRef.current = setTimeout(() => {
if (!destroyed) {
retryFn();
}
}, 3000);
};
const getRetryUrl = () => {
retryAttempt += 1;
const separator = embedUrl.includes('?') ? '&' : '?';
return `${embedUrl}${separator}retry=${Date.now()}-${retryAttempt}`;
};
const retryNativeLoad = () => {
videoEl.src = getRetryUrl();
videoEl.load();
};
const retryHlsLoad = () => {
if (destroyed || !hlsInstance) return;
const retryUrl = getRetryUrl();
try {
hlsInstance.stopLoad();
} catch {
// ignore stop-load failures and continue with a fresh loadSource
}
hlsInstance.loadSource(retryUrl);
hlsInstance.startLoad(-1);
};
const syncDuration = () => {
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
cachedDuration = videoEl.duration;
setVideoDuration(videoEl.duration);
}
};
const saveProgress = () => {
const current = videoEl.currentTime || 0;
const duration = Number.isFinite(videoEl.duration) && videoEl.duration > 0 ? videoEl.duration : cachedDuration;
if (video?.isAuthenticated && current > 0 && activeVersionId) {
fetch(`/api/watch/${videoId}/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
progress: current,
duration,
versionId: activeVersionId,
}),
}).catch((err) => console.error('Error saving watch progress on pause:', err));
}
};
const onLoadedMetadata = () => {
if (destroyed) return;
clearRetryTimer();
setBunnyPlaybackState('none');
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
setIsBunnyPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
}
setIsReady(true); setIsReady(true);
player.getDuration((duration: number) => { syncDuration();
if (duration > 0) setVideoDuration(duration); };
});
});
player.on('play', () => { const onPlay = () => {
setIsPlaying(true); setIsPlaying(true);
player.getDuration((duration: number) => { setBunnyPlaybackState('none');
if (duration > 0) setVideoDuration(duration); syncDuration();
}); };
});
player.on('pause', () => { const onPause = () => {
setIsPlaying(false); setIsPlaying(false);
player.getCurrentTime((currentTime: number) => { saveProgress();
player.getDuration((duration: number) => { };
if (video?.isAuthenticated && currentTime > 0 && activeVersionId) {
fetch(`/api/watch/${videoId}/progress`, { const onEnded = () => {
method: 'POST', setIsPlaying(false);
headers: { 'Content-Type': 'application/json' }, saveProgress();
body: JSON.stringify({ };
progress: currentTime,
duration: duration, const onTimeUpdate = () => {
versionId: activeVersionId, if (!isDraggingRef.current) {
}), setCurrentTime(videoEl.currentTime || 0);
}).catch(console.error); }
} if (Number.isFinite(videoEl.duration) && videoEl.duration > 0 && videoEl.duration !== cachedDuration) {
}); cachedDuration = videoEl.duration;
setVideoDuration(videoEl.duration);
}
};
const onVideoError = () => {
if (destroyed) return;
if (usingHlsJs) return;
if (videoEl.readyState >= HTMLMediaElement.HAVE_METADATA) {
setBunnyPlaybackState('error');
return;
}
setIsReady(false);
setBunnyPlaybackState('processing');
scheduleRetry(retryNativeLoad);
};
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
videoEl.addEventListener('play', onPlay);
videoEl.addEventListener('pause', onPause);
videoEl.addEventListener('ended', onEnded);
videoEl.addEventListener('timeupdate', onTimeUpdate);
videoEl.addEventListener('error', onVideoError);
const configureHlsLevels = (levels: Level[]) => {
setQualityOptions(levels.map((level, index) => ({
level: index,
label: formatBunnyQualityLabel(level, index),
})));
setSelectedQualityLevel(-1);
};
if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
videoEl.src = embedUrl;
videoEl.load();
} else if (Hls.isSupported()) {
usingHlsJs = true;
const hls = new Hls();
hlsInstance = hls;
hlsRef.current = hls;
hls.attachMedia(videoEl);
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
if (!destroyed) {
hls.loadSource(embedUrl);
}
}); });
});
let cachedTime = 0; hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
let cachedDuration = videoDuration || 0; if (destroyed) return;
clearRetryTimer();
setBunnyPlaybackState('none');
configureHlsLevels(data.levels);
setIsReady(true);
syncDuration();
});
player.on('timeupdate', (data: { seconds: number, duration: number }) => { hls.on(Hls.Events.ERROR, (_, data) => {
cachedTime = data.seconds; if (destroyed) return;
if (data.duration > 0 && data.duration !== cachedDuration) { const responseCode = (data as { response?: { code?: number } }).response?.code;
cachedDuration = data.duration; const isManifestLoadFailure = data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR
setVideoDuration(data.duration); || data.details === Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT;
} const hasProcessingLikeStatus = responseCode === undefined
if (!isDragging) { || responseCode === 0
setCurrentTime(data.seconds); || 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) {
setIsReady(false);
setBunnyPlaybackState('processing');
scheduleRetry(retryHlsLoad);
return;
}
if (data.fatal) {
setBunnyPlaybackState('error');
console.error('Fatal HLS error:', data);
}
});
} else {
setBunnyPlaybackState('error');
console.error('HLS is not supported in this browser.');
}
playerRef.current = { playerRef.current = {
playVideo: () => player.play(), playVideo: () => {
pauseVideo: () => player.pause(), videoEl.play().catch((err) => console.error('Error playing Bunny video:', err));
seekTo: (time: number) => { player.setCurrentTime(time); }, },
mute: () => player.mute(), pauseVideo: () => videoEl.pause(),
unMute: () => player.unmute(), seekTo: (time: number) => {
isMuted: () => false, videoEl.currentTime = time;
getCurrentTime: () => cachedTime, },
getDuration: () => cachedDuration, mute: () => {
getPlayerState: () => window.YT?.PlayerState?.PLAYING || 1, videoEl.muted = true;
},
unMute: () => {
videoEl.muted = false;
},
isMuted: () => videoEl.muted,
getCurrentTime: () => videoEl.currentTime || 0,
getDuration: () => {
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) return videoEl.duration;
return cachedDuration;
},
getPlayerState: () => (
videoEl.paused
? (window.YT?.PlayerState?.PAUSED ?? 2)
: (window.YT?.PlayerState?.PLAYING ?? 1)
),
setPlaybackRate: (rate: number) => { setPlaybackRate: (rate: number) => {
try { videoEl.playbackRate = rate;
if (player && typeof player.setPlaybackRate === 'function') {
player.setPlaybackRate(rate);
}
} catch (e) { console.error('Error setting playback rate on Bunny Stream', e); }
}, },
destroy: () => { destroy: () => {
try { destroyed = true;
player.off('ready'); clearRetryTimer();
player.off('play'); videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
player.off('pause'); videoEl.removeEventListener('play', onPlay);
player.off('timeupdate'); videoEl.removeEventListener('pause', onPause);
} catch { } videoEl.removeEventListener('ended', onEnded);
videoEl.removeEventListener('timeupdate', onTimeUpdate);
videoEl.removeEventListener('error', onVideoError);
if (hlsRef.current) {
try { hlsRef.current.destroy(); } catch { /* ignore */ }
hlsRef.current = null;
}
videoEl.removeAttribute('src');
videoEl.load();
}, },
off: (event: string) => player.off(event)
}; };
} }
}; };
@@ -634,8 +847,20 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
if (isYoutube) { if (isYoutube) {
window.onYouTubeIframeAPIReady = undefined; window.onYouTubeIframeAPIReady = undefined;
} }
if (playerRef.current) {
try { playerRef.current.destroy(); } catch { /* ignore */ }
playerRef.current = null;
}
if (hlsRef.current) {
try { hlsRef.current.destroy(); } catch { /* ignore */ }
hlsRef.current = null;
}
if (bunnyRetryTimerRef.current) {
clearTimeout(bunnyRetryTimerRef.current);
bunnyRetryTimerRef.current = null;
}
}; };
}, [activeVersionId, isApiLoaded, video?.isAuthenticated, videoId]); }, [activeVersionId, embedUrl, isApiLoaded, video?.isAuthenticated, videoId]);
// Save detected duration to DB if the version doesn't have one stored // Save detected duration to DB if the version doesn't have one stored
useEffect(() => { useEffect(() => {
@@ -713,9 +938,6 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
// Save progress every 5 seconds while playing // Save progress every 5 seconds while playing
progressSaveTimerRef.current = setInterval(() => { progressSaveTimerRef.current = setInterval(() => {
const isYoutube = activeVersion?.providerId === 'youtube';
const isBunny = activeVersion?.providerId === 'bunny';
const save = (playerCurrentTime: number, playerDuration: number) => { const save = (playerCurrentTime: number, playerDuration: number) => {
if (playerCurrentTime > 0 && Math.abs(playerCurrentTime - lastSavedProgressRef.current) >= 2) { if (playerCurrentTime > 0 && Math.abs(playerCurrentTime - lastSavedProgressRef.current) >= 2) {
fetch(`/api/watch/${videoId}/progress`, { fetch(`/api/watch/${videoId}/progress`, {
@@ -861,6 +1083,24 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return; return;
} }
const isBunnyBlocked = activeVersion?.providerId === 'bunny' && bunnyPlaybackState !== 'none';
const isPlaybackControlKey = [
'Space',
'KeyK',
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
'Comma',
'Period',
'KeyM',
'KeyJ',
'KeyL',
].includes(e.code);
if (isBunnyBlocked && isPlaybackControlKey) {
e.preventDefault();
return;
}
switch (e.code) { switch (e.code) {
case 'Space': case 'Space':
@@ -978,16 +1218,17 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [isPlaying, currentTime, duration, isMuted, playbackSpeed, toggleFullscreen]); }, [activeVersion?.providerId, bunnyPlaybackState, isPlaying, currentTime, duration, isMuted, playbackSpeed, toggleFullscreen]);
const handlePlayPause = useCallback(() => { const handlePlayPause = useCallback(() => {
if (activeVersion?.providerId === 'bunny' && bunnyPlaybackState !== 'none') return;
if (!playerRef.current) return; if (!playerRef.current) return;
if (isPlaying) { if (isPlaying) {
playerRef.current.pauseVideo(); playerRef.current.pauseVideo();
} else { } else {
playerRef.current.playVideo(); playerRef.current.playVideo();
} }
}, [isPlaying]); }, [activeVersion?.providerId, bunnyPlaybackState, isPlaying]);
const handleSeekToTimestamp = useCallback((timestamp: number, annotation?: string | null) => { const handleSeekToTimestamp = useCallback((timestamp: number, annotation?: string | null) => {
setCurrentTime(timestamp); setCurrentTime(timestamp);
@@ -1034,6 +1275,22 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
[] []
); );
const handleQualityChange = useCallback((level: number) => {
const hls = hlsRef.current;
if (!hls) return;
if (level === -1) {
hls.currentLevel = -1;
hls.nextLevel = -1;
setSelectedQualityLevel(-1);
return;
}
hls.currentLevel = level;
hls.nextLevel = level;
setSelectedQualityLevel(level);
}, []);
const handleTimelineClick = useCallback( const handleTimelineClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => { (e: React.MouseEvent<HTMLDivElement>) => {
if (!timelineRef.current) return; if (!timelineRef.current) return;
@@ -1963,6 +2220,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setIsCreatingVersion(true); setIsCreatingVersion(true);
setNewVersionUploadStatus(''); setNewVersionUploadStatus('');
setNewVersionUploadProgress(0); setNewVersionUploadProgress(0);
let uploadedBunnyVideoId: string | null = null;
let uploadedBunnyUploadToken: string | null = null;
try { try {
let finalVideoUrl = ''; let finalVideoUrl = '';
@@ -1996,7 +2255,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}); });
if (!initRes.ok) throw new Error('Failed to initialize upload'); if (!initRes.ok) throw new Error('Failed to initialize upload');
const { data: { videoId, libraryId, signature, expirationTime } } = await initRes.json(); const { data: { videoId, libraryId, signature, expirationTime, uploadToken } } = await initRes.json();
uploadedBunnyVideoId = videoId;
uploadedBunnyUploadToken = uploadToken;
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
setNewVersionUploadStatus('Uploading video...'); setNewVersionUploadStatus('Uploading video...');
@@ -2040,6 +2301,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
videoUrl: finalVideoUrl, videoUrl: finalVideoUrl,
providerId: finalProviderId, providerId: finalProviderId,
providerVideoId: finalProviderVideoId, providerVideoId: finalProviderVideoId,
uploadToken: uploadedBunnyUploadToken,
versionLabel: newVersionLabel.trim() || null, versionLabel: newVersionLabel.trim() || null,
thumbnailUrl: finalThumbnailUrl, thumbnailUrl: finalThumbnailUrl,
duration: finalDuration, duration: finalDuration,
@@ -2047,30 +2309,42 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}), }),
}); });
if (res.ok) { if (!res.ok) {
const versionData = await res.json(); const data = await res.json().catch(() => null);
const newVersion = versionData.data; throw new Error(data?.error || 'Failed to create version');
// Optimistically add the new version to local state instead of refetching
setVideo((prev) => {
if (!prev) return prev;
const updatedVersions = prev.versions.map(v => ({ ...v, isActive: false }));
const createdVersion = {
...newVersion,
comments: [],
};
updatedVersions.unshift(createdVersion);
return { ...prev, versions: updatedVersions };
});
setActiveVersionId(newVersion.id);
setShowVersionDialog(false);
setNewVersionUrl('');
setNewVersionLabel('');
setNewVersionSource(null);
setNewVersionFile(null);
setNewVersionUploadStatus('');
} }
const versionData = await res.json();
const newVersion = versionData.data;
// Optimistically add the new version to local state instead of refetching
setVideo((prev) => {
if (!prev) return prev;
const updatedVersions = prev.versions.map(v => ({ ...v, isActive: false }));
const createdVersion = {
...newVersion,
comments: [],
};
updatedVersions.unshift(createdVersion);
return { ...prev, versions: updatedVersions };
});
setActiveVersionId(newVersion.id);
setShowVersionDialog(false);
setNewVersionUrl('');
setNewVersionLabel('');
setNewVersionSource(null);
setNewVersionFile(null);
setNewVersionUploadStatus('');
} catch (err) { } catch (err) {
const errorObj = err as Error; const errorObj = err as Error;
if (uploadedBunnyVideoId && uploadedBunnyUploadToken) {
await fetch(`/api/projects/${propProjectId}/videos/bunny-init`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId: uploadedBunnyVideoId, uploadToken: uploadedBunnyUploadToken }),
}).catch((cleanupError) => {
console.error('Failed to cleanup pending Bunny version upload:', cleanupError);
});
}
console.error('Failed to create version:', errorObj); console.error('Failed to create version:', errorObj);
toast.error(errorObj.message || 'Failed to create version'); toast.error(errorObj.message || 'Failed to create version');
} finally { } finally {
@@ -2120,6 +2394,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const backHref = mode === 'dashboard' const backHref = mode === 'dashboard'
? `/projects/${propProjectId}` ? `/projects/${propProjectId}`
: (video?.projectId ? `/projects/${video.projectId}` : '/'); : (video?.projectId ? `/projects/${video.projectId}` : '/');
const isBunnyVersion = activeVersion?.providerId === 'bunny';
const showBunnyProcessingOverlay = isBunnyVersion && bunnyPlaybackState === 'processing';
const showBunnyErrorOverlay = isBunnyVersion && bunnyPlaybackState === 'error';
if (loading) { if (loading) {
return ( return (
@@ -2530,20 +2807,48 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
onMouseLeave={handleVideoMouseLeave} onMouseLeave={handleVideoMouseLeave}
> >
<div className={cn("relative w-full h-full", isFullscreenMode && "absolute inset-0")}> <div className={cn("relative w-full h-full", isFullscreenMode && "absolute inset-0")}>
<iframe {activeVersion?.providerId === 'bunny' ? (
key={activeVersionId} <div ref={bunnyViewportRef} className="absolute inset-0 flex items-center justify-center bg-black">
ref={iframeRef} <div
src={embedUrl} className={cn(
width="100%" 'relative flex items-center justify-center bg-black',
height="100%" isBunnyPortraitSource ? 'h-full overflow-hidden' : 'w-full h-full'
className="absolute inset-0 w-full h-full border-0" )}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" style={isBunnyPortraitSource && bunnyPortraitFrameWidth > 0 ? { width: `${bunnyPortraitFrameWidth}px` } : undefined}
allowFullScreen >
/> <video
key={activeVersionId}
ref={videoRef}
className="w-full h-full object-contain border-0 bg-black"
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
objectPosition: 'center',
backgroundColor: 'black',
}}
preload="metadata"
playsInline
/>
</div>
</div>
) : (
<iframe
key={activeVersionId}
ref={iframeRef}
src={embedUrl}
width="100%"
height="100%"
className="absolute inset-0 w-full h-full border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
)}
<div <div
className={cn( className={cn(
'absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity duration-300', 'absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity duration-300',
(showBunnyProcessingOverlay || showBunnyErrorOverlay) && 'opacity-0 pointer-events-none',
isPlaying isPlaying
? cursorIdle ? 'opacity-0' : 'opacity-0 group-hover:opacity-100' ? cursorIdle ? 'opacity-0' : 'opacity-0 group-hover:opacity-100'
: 'opacity-100' : 'opacity-100'
@@ -2558,6 +2863,34 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
</div> </div>
</div> </div>
{showBunnyProcessingOverlay && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/65">
<div className="max-w-sm rounded-md border bg-background/95 px-4 py-3 text-center shadow-lg">
<div className="mb-2 flex items-center justify-center gap-2 text-sm font-medium">
<Loader2 className="h-4 w-4 animate-spin" />
Video Is Processing
</div>
<p className="text-xs text-muted-foreground">
This video is still processing. We&apos;ll keep retrying every few seconds.
</p>
</div>
</div>
)}
{showBunnyErrorOverlay && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/65">
<div className="max-w-sm rounded-md border bg-background/95 px-4 py-3 text-center shadow-lg">
<div className="mb-2 flex items-center justify-center gap-2 text-sm font-medium">
<AlertCircle className="h-4 w-4 text-destructive" />
Unable To Load Video
</div>
<p className="text-xs text-muted-foreground">
The Bunny stream is unavailable right now. Please refresh this page in a moment.
</p>
</div>
</div>
)}
{/* Resume playback prompt */} {/* Resume playback prompt */}
{showResumePrompt && savedProgress !== null && ( {showResumePrompt && savedProgress !== null && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 z-10"> <div className="absolute inset-0 flex items-center justify-center bg-black/40 z-10">
@@ -2683,6 +3016,34 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
</span> </span>
<div className="ml-auto flex items-center"> <div className="ml-auto flex items-center">
{activeVersion?.providerId === 'bunny' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs">
Quality {selectedQualityLabel}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[120px]">
<DropdownMenuItem
onClick={() => handleQualityChange(-1)}
className={cn(selectedQualityLevel === -1 && 'font-bold text-primary')}
>
Auto
</DropdownMenuItem>
{qualityOptions.length > 0 && <DropdownMenuSeparator />}
{qualityOptions.map((option) => (
<DropdownMenuItem
key={option.level}
onClick={() => handleQualityChange(option.level)}
className={cn(option.level === selectedQualityLevel && 'font-bold text-primary')}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs"> <Button variant="ghost" size="sm" className="h-8 gap-1 text-xs">
+177 -35
View File
@@ -1,28 +1,133 @@
import { unstable_cache } from 'next/cache'; import { unstable_cache } from 'next/cache';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { ListObjectsV2Command } from '@aws-sdk/client-s3'; import { ListObjectsV2Command, type ListObjectsV2CommandInput } from '@aws-sdk/client-s3';
const BUNNY_API_BASE = 'https://video.bunnycdn.com';
const STORAGE_CACHE_SECONDS = 600;
interface BunnyStorageStats {
totalBytes: number;
byVideoId: Record<string, number>;
}
function getBunnyConfig(): { apiKey: string; libraryId: string } {
const apiKey = process.env.BUNNY_STREAM_API_KEY;
const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
if (!apiKey || !libraryId) {
throw new Error('Missing Bunny Stream credentials.');
}
return { apiKey, libraryId };
}
function toRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object') return null;
return value as Record<string, unknown>;
}
function parseBunnyVideoStorageBytes(item: unknown): number {
const record = toRecord(item);
if (!record) return 0;
const candidates = ['storageSize', 'storage', 'size'];
for (const key of candidates) {
const value = record[key];
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return value;
}
}
return 0;
}
function parseBunnyVideoGuid(item: unknown): string | null {
const record = toRecord(item);
if (!record) return null;
const value = record.guid;
return typeof value === 'string' && value.length > 0 ? value : null;
}
async function listAllR2FileSizes(): Promise<Map<string, number>> {
const fileSizes = new Map<string, number>();
let isTruncated = true;
let continuationToken: string | undefined;
while (isTruncated) {
const commandParams: ListObjectsV2CommandInput = { Bucket: R2_BUCKET_NAME };
if (continuationToken) {
commandParams.ContinuationToken = continuationToken;
}
const data = await r2Client.send(new ListObjectsV2Command(commandParams));
if (data.Contents) {
for (const item of data.Contents) {
if (item.Key) fileSizes.set(item.Key, item.Size || 0);
}
}
isTruncated = data.IsTruncated ?? false;
continuationToken = data.NextContinuationToken;
}
return fileSizes;
}
async function fetchBunnyStorageStats(): Promise<BunnyStorageStats> {
const { apiKey, libraryId } = getBunnyConfig();
const byVideoId: Record<string, number> = {};
let totalBytes = 0;
let page = 1;
const itemsPerPage = 100;
while (page <= 200) {
const response = await fetch(
`${BUNNY_API_BASE}/library/${libraryId}/videos?page=${page}&itemsPerPage=${itemsPerPage}`,
{ headers: { AccessKey: apiKey }, cache: 'no-store' }
);
if (!response.ok) {
throw new Error(`Bunny API failed (${response.status})`);
}
const json = await response.json();
const record = toRecord(json);
if (!record) break;
const rawItems = Array.isArray(record.items)
? record.items
: (Array.isArray(record.Items) ? record.Items : []);
if (rawItems.length === 0) break;
for (const rawItem of rawItems) {
const guid = parseBunnyVideoGuid(rawItem);
if (!guid) continue;
const storageBytes = parseBunnyVideoStorageBytes(rawItem);
byVideoId[guid] = storageBytes;
totalBytes += storageBytes;
}
const totalItems = typeof record.totalItems === 'number'
? record.totalItems
: (typeof record.TotalItems === 'number' ? record.TotalItems : null);
if (totalItems !== null && page * itemsPerPage >= totalItems) {
break;
}
page += 1;
}
return { totalBytes, byVideoId };
}
// Cache for 10 minutes (600 seconds) // Cache for 10 minutes (600 seconds)
export const getCachedTotalStorage = unstable_cache( export const getCachedTotalStorage = unstable_cache(
async () => { async () => {
let totalStorageBytes = 0; let totalStorageBytes = 0;
try { try {
let isTruncated = true; const fileSizes = await listAllR2FileSizes();
let continuationToken: string | undefined = undefined; for (const size of fileSizes.values()) {
totalStorageBytes += size;
while (isTruncated) {
const commandParams: any = { Bucket: R2_BUCKET_NAME };
if (continuationToken) commandParams.ContinuationToken = continuationToken;
const data = await r2Client.send(new ListObjectsV2Command(commandParams));
if (data.Contents) {
for (const item of data.Contents) {
totalStorageBytes += item.Size || 0;
}
}
isTruncated = data.IsTruncated ?? false;
continuationToken = data.NextContinuationToken;
} }
} catch (err) { } catch (err) {
console.error('Failed to fetch total storage stats:', err); console.error('Failed to fetch total storage stats:', err);
@@ -31,7 +136,60 @@ export const getCachedTotalStorage = unstable_cache(
return totalStorageBytes; return totalStorageBytes;
}, },
['admin-total-storage'], ['admin-total-storage'],
{ revalidate: 600 } { revalidate: STORAGE_CACHE_SECONDS }
);
export const getCachedBunnyStorageStats = unstable_cache(
async () => {
try {
return await fetchBunnyStorageStats();
} catch (err) {
console.error('Failed to fetch Bunny storage stats:', err);
return { totalBytes: -1, byVideoId: {} } as BunnyStorageStats;
}
},
['admin-bunny-storage'],
{ revalidate: STORAGE_CACHE_SECONDS }
);
export const getCachedUserBunnyStorage = unstable_cache(
async () => {
const perUserStorage: Record<string, number> = {};
try {
const bunnyStats = await getCachedBunnyStorageStats();
if (bunnyStats.totalBytes < 0) return perUserStorage;
const bunnyVersions = await db.videoVersion.findMany({
where: { providerId: 'bunny' },
select: {
videoId: true,
video: {
select: {
project: {
select: { ownerId: true },
},
},
},
},
});
const seenVideoIds = new Set<string>();
for (const version of bunnyVersions) {
const ownerId = version.video.project.ownerId;
const dedupeKey = `${ownerId}:${version.videoId}`;
if (seenVideoIds.has(dedupeKey)) continue;
seenVideoIds.add(dedupeKey);
const size = bunnyStats.byVideoId[version.videoId] || 0;
perUserStorage[ownerId] = (perUserStorage[ownerId] || 0) + size;
}
} catch (err) {
console.error('Failed to calculate per-user Bunny storage:', err);
}
return perUserStorage;
},
['admin-user-bunny-storage'],
{ revalidate: STORAGE_CACHE_SECONDS }
); );
export const getCachedUserMediaStorage = unstable_cache( export const getCachedUserMediaStorage = unstable_cache(
@@ -39,23 +197,7 @@ export const getCachedUserMediaStorage = unstable_cache(
// Return a plain object so it maps cleanly out of unstable_cache across requests // Return a plain object so it maps cleanly out of unstable_cache across requests
const userStorage: Record<string, { total: number, voice: number, image: number }> = {}; const userStorage: Record<string, { total: number, voice: number, image: number }> = {};
try { try {
const fileSizes = new Map<string, number>(); const fileSizes = await listAllR2FileSizes();
let isTruncated = true;
let continuationToken: string | undefined = undefined;
while (isTruncated) {
const commandParams: any = { Bucket: R2_BUCKET_NAME };
if (continuationToken) commandParams.ContinuationToken = continuationToken;
const data = await r2Client.send(new ListObjectsV2Command(commandParams));
if (data.Contents) {
for (const item of data.Contents) {
if (item.Key) fileSizes.set(item.Key, item.Size || 0);
}
}
isTruncated = data.IsTruncated ?? false;
continuationToken = data.NextContinuationToken;
}
const mediaComments = await db.comment.findMany({ const mediaComments = await db.comment.findMany({
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }], authorId: { not: null } }, where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }], authorId: { not: null } },
@@ -93,5 +235,5 @@ export const getCachedUserMediaStorage = unstable_cache(
return userStorage; return userStorage;
}, },
['admin-user-media-storage'], ['admin-user-media-storage'],
{ revalidate: 600 } { revalidate: STORAGE_CACHE_SECONDS }
); );
+55
View File
@@ -0,0 +1,55 @@
interface BunnyVideoRef {
providerId: string;
videoId: string;
}
const BUNNY_API_BASE = 'https://video.bunnycdn.com';
const BUNNY_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
function getBunnyConfig(): { apiKey: string; libraryId: string } {
const apiKey = process.env.BUNNY_STREAM_API_KEY;
const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
if (!apiKey || !libraryId) {
throw new Error('Bunny cleanup failed: missing BUNNY_STREAM_API_KEY or BUNNY_STREAM_LIBRARY_ID.');
}
return { apiKey, libraryId };
}
export async function cleanupBunnyStreamVideos(videoRefs: BunnyVideoRef[]): Promise<void> {
const normalizeVideoId = (value: string): string | null => {
const trimmed = value.trim();
return BUNNY_VIDEO_ID_PATTERN.test(trimmed) ? trimmed : null;
};
const bunnyVideoIds = [...new Set(
videoRefs
.filter((ref) => ref.providerId === 'bunny' && Boolean(ref.videoId))
.map((ref) => normalizeVideoId(ref.videoId))
.filter((videoId): videoId is string => Boolean(videoId))
)];
if (bunnyVideoIds.length === 0) return;
const { apiKey, libraryId } = getBunnyConfig();
for (const bunnyVideoId of bunnyVideoIds) {
const response = await fetch(`${BUNNY_API_BASE}/library/${libraryId}/videos/${encodeURIComponent(bunnyVideoId)}`, {
method: 'DELETE',
headers: {
AccessKey: apiKey,
},
});
// Treat not-found as already deleted.
if (response.status === 404) continue;
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(
`Bunny cleanup failed for video ${bunnyVideoId}: ${response.status} ${body.slice(0, 300)}`
);
}
}
}
+95
View File
@@ -0,0 +1,95 @@
import crypto from 'crypto';
const BUNNY_UPLOAD_TOKEN_TYPE = 'bunny-upload';
const DEFAULT_TOKEN_TTL_SECONDS = 60 * 60;
interface BunnyUploadTokenPayload {
typ: typeof BUNNY_UPLOAD_TOKEN_TYPE;
uid: string;
pid: string;
vid: string;
iat: number;
exp: number;
}
interface BunnyUploadTokenSubject {
userId: string;
projectId: string;
videoId: string;
}
function getBunnyUploadTokenSecret(): string {
const secret = process.env.BUNNY_UPLOAD_TOKEN_SECRET || process.env.NEXTAUTH_SECRET;
if (!secret) {
throw new Error('Missing BUNNY_UPLOAD_TOKEN_SECRET or NEXTAUTH_SECRET.');
}
return secret;
}
function signPayload(payload: string, secret: string): string {
return crypto.createHmac('sha256', secret).update(payload).digest('base64url');
}
function isValidPayload(value: unknown): value is BunnyUploadTokenPayload {
if (!value || typeof value !== 'object') return false;
const payload = value as Partial<BunnyUploadTokenPayload>;
return payload.typ === BUNNY_UPLOAD_TOKEN_TYPE
&& typeof payload.uid === 'string'
&& typeof payload.pid === 'string'
&& typeof payload.vid === 'string'
&& typeof payload.iat === 'number'
&& Number.isFinite(payload.iat)
&& typeof payload.exp === 'number'
&& Number.isFinite(payload.exp);
}
export function createBunnyUploadToken(
subject: BunnyUploadTokenSubject,
ttlSeconds = DEFAULT_TOKEN_TTL_SECONDS
): string {
const now = Math.floor(Date.now() / 1000);
const payload: BunnyUploadTokenPayload = {
typ: BUNNY_UPLOAD_TOKEN_TYPE,
uid: subject.userId,
pid: subject.projectId,
vid: subject.videoId,
iat: now,
exp: now + ttlSeconds,
};
const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
const signature = signPayload(encodedPayload, getBunnyUploadTokenSecret());
return `${encodedPayload}.${signature}`;
}
export function verifyBunnyUploadToken(token: string, subject: BunnyUploadTokenSubject): boolean {
try {
const parts = token.split('.');
if (parts.length !== 2) return false;
const [encodedPayload, providedSignature] = parts;
if (!encodedPayload || !providedSignature) return false;
const expectedSignature = signPayload(encodedPayload, getBunnyUploadTokenSecret());
const providedBuffer = Buffer.from(providedSignature, 'utf8');
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
if (providedBuffer.length !== expectedBuffer.length) return false;
if (!crypto.timingSafeEqual(providedBuffer, expectedBuffer)) return false;
const payloadJson = Buffer.from(encodedPayload, 'base64url').toString('utf8');
const payloadUnknown: unknown = JSON.parse(payloadJson);
if (!isValidPayload(payloadUnknown)) return false;
const payload = payloadUnknown;
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return false;
return payload.uid === subject.userId
&& payload.pid === subject.projectId
&& payload.vid === subject.videoId;
} catch {
return false;
}
}
+1 -1
View File
@@ -28,6 +28,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"hls.js": "^1.6.15",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"next": "16.1.6", "next": "16.1.6",
@@ -35,7 +36,6 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.1",
"pg": "^8.18.0", "pg": "^8.18.0",
"player.js": "^0.1.0",
"prisma": "^7.3.0", "prisma": "^7.3.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.3", "react": "19.2.3",