feat(bunny-cdn): refactor CDN hostname resolution and update asset URLs for improved flexibility

This commit is contained in:
Yusuf İpek
2026-02-26 11:53:03 +03:00
parent 3522c3da30
commit 4ea6099508
15 changed files with 192 additions and 98 deletions
+36 -15
View File
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
@@ -47,6 +47,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { parseVideoUrl, fetchVideoMetadata, getThumbnailUrl, type VideoSource } from '@/lib/video-providers';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
interface VideoCardProps {
video: {
@@ -86,6 +87,20 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
// Delete dialog
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
const resolvedThumbnailUrl = useMemo(() => {
if (!video.thumbnailUrl) return '';
try {
const parsed = new URL(video.thumbnailUrl);
if (parsed.hostname === 'vz-thumbnail.b-cdn.net' && bunnyCdnHostname) {
parsed.hostname = bunnyCdnHostname;
return parsed.toString();
}
return parsed.toString();
} catch {
return video.thumbnailUrl;
}
}, [video.thumbnailUrl, bunnyCdnHostname]);
const handleEdit = async () => {
setIsSaving(true);
@@ -199,20 +214,26 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
<span className="text-[11px] text-muted-foreground/90">Video may already be playable</span>
</div>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`${video.thumbnailUrl?.replace('vz-thumbnail.b-cdn.net', 'vz-965f4f4a-fc1.b-cdn.net')}${retryKey ? `?t=${retryKey}` : ''}`}
alt={video.title}
className="absolute inset-0 w-full h-full object-cover transition-transform group-hover:scale-105"
onError={() => {
setImgError(true);
// Check again after 10 seconds in case Bunny is still processing
setTimeout(() => {
setRetryKey(Date.now());
setImgError(false);
}, 10000);
}}
/>
resolvedThumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`${resolvedThumbnailUrl}${retryKey ? `?t=${retryKey}` : ''}`}
alt={video.title}
className="absolute inset-0 w-full h-full object-cover transition-transform group-hover:scale-105"
onError={() => {
setImgError(true);
// Check again after 10 seconds in case Bunny is still processing
setTimeout(() => {
setRetryKey(Date.now());
setImgError(false);
}, 10000);
}}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-muted/80 text-xs text-muted-foreground font-medium">
Thumbnail unavailable
</div>
)
)}
{!imgError && (
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
+6 -2
View File
@@ -21,6 +21,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
type ProjectOption = {
id: string;
@@ -89,6 +90,7 @@ export function VideoDragDropUploader({
const hasLoadedProjectsRef = useRef(false);
const needsProjectSelection = !fixedProjectId;
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
const projectsById = useMemo(() => {
return new Map(projects.map((project) => [project.id, project.name]));
@@ -296,7 +298,9 @@ export function VideoDragDropUploader({
videoUrl: `https://iframe.mediadelivery.net/embed/${initPayload.data.libraryId}/${initPayload.data.videoId}`,
providerId: 'bunny',
videoId: initPayload.data.videoId,
thumbnailUrl: `https://vz-thumbnail.b-cdn.net/${initPayload.data.videoId}/thumbnail.jpg`,
thumbnailUrl: bunnyCdnHostname
? `https://${bunnyCdnHostname}/${initPayload.data.videoId}/thumbnail.jpg`
: null,
duration: null,
uploadToken,
}),
@@ -341,7 +345,7 @@ export function VideoDragDropUploader({
setIsUploading(false);
toast.error(error instanceof Error ? error.message : 'Failed to upload video');
}
}, [cleanupUploadState, projectsById, router]);
}, [bunnyCdnHostname, cleanupUploadState, projectsById, router]);
const handleDropFile = useCallback((file: File) => {
if (!canUpload) {
+5 -3
View File
@@ -36,6 +36,7 @@ import type {
} from '@/components/video-page/types';
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
function formatTime(seconds: number): string {
const totalSeconds = Math.floor(seconds);
@@ -59,7 +60,6 @@ function formatBunnyQualityLabel(level: { height?: number; bitrate?: number }, i
}
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';
export type VideoPageMode = 'dashboard' | 'watch';
@@ -255,6 +255,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}, [video?.versions, activeVersionId]);
const activeProviderId = activeVersion?.providerId;
const activeVersionDuration = activeVersion?.duration;
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
const embedUrl = useMemo(() => {
if (!activeVersion) return '';
if (activeVersion.providerId === 'youtube') {
@@ -264,7 +265,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
return `${base}&origin=${encodeURIComponent(origin)}`;
}
if (activeVersion.providerId === 'bunny') {
return `https://${BUNNY_PULL_ZONE_HOSTNAME}/${activeVersion.videoId}/playlist.m3u8`;
if (!bunnyCdnHostname) return '';
return `https://${bunnyCdnHostname}/${activeVersion.videoId}/playlist.m3u8`;
}
try {
const url = new URL(activeVersion.originalUrl);
@@ -275,7 +277,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
} catch {
return '';
}
}, [activeVersion]);
}, [activeVersion, bunnyCdnHostname]);
const {
isReady,
+5 -1
View File
@@ -20,6 +20,7 @@ import { BunnyPreviewPlayer, type BunnyPreviewPlayerHandle } from '@/components/
import { AssetListSection } from '@/components/video-page/asset-list-section';
import type { VideoAsset } from '@/components/video-page/types';
import { extractPastedImageFile, validateImageFile } from '@/components/video-page/image-upload-utils';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
interface AssetsPaneProps {
videoId: string;
@@ -82,6 +83,7 @@ export const AssetsPane = memo(function AssetsPane({
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewImageTitle, setPreviewImageTitle] = useState<string | null>(null);
const [selectedAsset, setSelectedAsset] = useState<VideoAsset | null>(null);
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
const [focusedAssetId, setFocusedAssetId] = useState<string | null>(null);
const bunnyPreviewPlayerRef = useRef<BunnyPreviewPlayerHandle | null>(null);
const youtubeIframeRef = useRef<HTMLIFrameElement | null>(null);
@@ -369,7 +371,9 @@ export const AssetsPane = memo(function AssetsPane({
});
const sourceUrl = `https://iframe.mediadelivery.net/embed/${initData.libraryId}/${initData.videoId}`;
const thumbnailUrl = `https://vz-965f4f4a-fc1.b-cdn.net/${initData.videoId}/thumbnail.jpg`;
const thumbnailUrl = bunnyCdnHostname
? `https://${bunnyCdnHostname}/${initData.videoId}/thumbnail.jpg`
: undefined;
const createdAsset = await createAsset({
provider: 'BUNNY',
sourceUrl,
+2 -12
View File
@@ -11,6 +11,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { cn } from '@/lib/utils';
import type { BunnyPlaybackState, BunnyQualityOption } from '@/components/video-page/types';
@@ -28,17 +29,6 @@ export interface BunnyPreviewPlayerHandle {
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
function resolveBunnyCdnHostname(): string | null {
const configured = process.env.NEXT_PUBLIC_BUNNY_CDN_URL;
if (!configured) return null;
try {
const parsed = new URL(configured);
return parsed.hostname || null;
} catch {
return configured.replace(/^https?:\/\//, '').replace(/\/+$/, '') || null;
}
}
function formatTime(value: number): string {
if (!Number.isFinite(value) || value < 0) return '0:00';
const total = Math.floor(value);
@@ -78,7 +68,7 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
const [selectedQualityLevel, setSelectedQualityLevel] = useState<number>(-1);
const [bunnySourcePreference, setBunnySourcePreference] = useState<'auto' | 'original'>('auto');
const [bunnyPlaybackState, setBunnyPlaybackState] = useState<BunnyPlaybackState>('none');
const bunnyCdnHostname = useMemo(() => resolveBunnyCdnHostname(), []);
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
const playlistUrl = useMemo(() => {
if (!providerVideoId || !bunnyCdnHostname) return null;
@@ -3,8 +3,7 @@
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import type { BunnyDownloadPreference, Comment, DownloadTarget, Version, VideoData } from '@/components/video-page/types';
const BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
function sanitizeDownloadFileName(value: string): string {
return value
@@ -14,17 +13,9 @@ function sanitizeDownloadFileName(value: string): string {
}
function getAllowedHosts() {
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
return [
BUNNY_PULL_ZONE_HOSTNAME,
...(process.env.NEXT_PUBLIC_BUNNY_CDN_URL
? (() => {
try {
return [new URL(process.env.NEXT_PUBLIC_BUNNY_CDN_URL).hostname];
} catch {
return [process.env.NEXT_PUBLIC_BUNNY_CDN_URL.replace(/^https?:\/\//, '').replace(/\/+$/, '')];
}
})()
: []),
...(bunnyCdnHostname ? [bunnyCdnHostname] : []),
...(process.env.NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS ?? '').split(','),
]
.map((host) => host.trim().toLowerCase())
@@ -5,6 +5,7 @@ import { toast } from 'sonner';
import * as tus from 'tus-js-client';
import { parseVideoUrl, getThumbnailUrl, fetchVideoMetadata, type VideoSource } from '@/lib/video-providers';
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
interface UseVersionActionsParams extends VersionActionsConfig {
setVideo: Dispatch<SetStateAction<VideoData | null>>;
@@ -33,6 +34,7 @@ export function useVersionActions({
const [showDeleteVersionDialog, setShowDeleteVersionDialog] = useState(false);
const [versionToDelete, setVersionToDelete] = useState<string | null>(null);
const [isDeletingVersion, setIsDeletingVersion] = useState(false);
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
const handleNewVersionUrlChange = (url: string) => {
setNewVersionUrl(url);
@@ -126,7 +128,9 @@ export function useVersionActions({
finalVideoUrl = `https://iframe.mediadelivery.net/embed/${libraryId}/${bunnyVideoId}`;
finalProviderId = 'bunny';
finalProviderVideoId = bunnyVideoId;
finalThumbnailUrl = `https://vz-965f4f4a-fc1.b-cdn.net/${bunnyVideoId}/thumbnail.jpg`;
finalThumbnailUrl = bunnyCdnHostname
? `https://${bunnyCdnHostname}/${bunnyVideoId}/thumbnail.jpg`
: null;
}
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}/versions`, {