mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat: harden Bunny upload flow, migrate Bunny playback to hls.js, and add Bunny storage admin stats
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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
@@ -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'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
@@ -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 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user