From 4ea6099508037815ce33ddeb9b6446f2e2b33373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Thu, 26 Feb 2026 11:53:03 +0300 Subject: [PATCH] feat(bunny-cdn): refactor CDN hostname resolution and update asset URLs for improved flexibility --- .../compare/compare-versions-page-client.tsx | 15 +++--- .../videos/new/new-video-page-client.tsx | 6 ++- .../versions/[versionId]/download/route.ts | 40 ++++++++++----- app/api/videos/[videoId]/assets/route.ts | 22 +++++--- components/video-card.tsx | 51 +++++++++++++------ components/video-drag-drop-uploader.tsx | 8 ++- components/video-page-content.tsx | 8 +-- components/video-page/assets-pane.tsx | 6 ++- .../video-page/bunny-preview-player.tsx | 14 +---- .../video-page/hooks/use-download-actions.ts | 15 ++---- .../video-page/hooks/use-version-actions.ts | 6 ++- lib/bunny-cdn.ts | 20 ++++++++ lib/bunny-download.ts | 41 ++++++++++----- lib/video-providers/bunny.ts | 9 ++-- next.config.ts | 29 ++++++++--- 15 files changed, 192 insertions(+), 98 deletions(-) create mode 100644 lib/bunny-cdn.ts diff --git a/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/compare-versions-page-client.tsx b/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/compare-versions-page-client.tsx index f8f868c..c9a4def 100644 --- a/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/compare-versions-page-client.tsx +++ b/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/compare-versions-page-client.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import Hls from 'hls.js'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; @@ -28,6 +28,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; import { cn } from '@/lib/utils'; interface Version { @@ -99,8 +100,6 @@ const isSafeUrl = (url: string) => { } }; -const BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net'; - export default function CompareVersionsPageClient({ projectId, videoId }: { projectId: string; videoId: string }) { const searchParams = useSearchParams(); @@ -867,6 +866,7 @@ function BunnyPanel({ const panelRef = useRef(null); const videoRef = useRef(null); const hlsRef = useRef(null); + const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []); const [portraitFrameWidth, setPortraitFrameWidth] = useState(0); const [isPortraitSource, setIsPortraitSource] = useState(false); @@ -980,8 +980,11 @@ function BunnyPanel({ const onPlay = () => { isPlaying = true; }; const onPause = () => { isPlaying = false; }; const onEnded = () => { isPlaying = false; }; - const hlsUrl = `https://${BUNNY_PULL_ZONE_HOSTNAME}/${version.videoId}/playlist.m3u8`; - const originalUrl = `https://${BUNNY_PULL_ZONE_HOSTNAME}/${version.videoId}/original`; + if (!bunnyCdnHostname) { + return; + } + const hlsUrl = `https://${bunnyCdnHostname}/${version.videoId}/playlist.m3u8`; + const originalUrl = `https://${bunnyCdnHostname}/${version.videoId}/original`; const activateOriginalFallback = (): void => { sourceMode = 'original'; clearRetryTimer(); @@ -1088,7 +1091,7 @@ function BunnyPanel({ onUnregister(version.id); adapter.destroy(); }; - }, [version.id, version.videoId, onRegister, onUnregister]); + }, [version.id, version.videoId, onRegister, onUnregister, bunnyCdnHostname]); return (
diff --git a/app/(dashboard)/projects/[projectId]/videos/new/new-video-page-client.tsx b/app/(dashboard)/projects/[projectId]/videos/new/new-video-page-client.tsx index 80b8918..934c246 100644 --- a/app/(dashboard)/projects/[projectId]/videos/new/new-video-page-client.tsx +++ b/app/(dashboard)/projects/[projectId]/videos/new/new-video-page-client.tsx @@ -12,10 +12,12 @@ import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { parseVideoUrl, fetchVideoMetadata, getThumbnailUrl, type VideoSource } from '@/lib/video-providers'; +import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; import * as tus from 'tus-js-client'; export default function NewVideoPageClient({ projectId }: { projectId: string }) { const router = useRouter(); + const bunnyCdnHostname = resolvePublicBunnyCdnHostname(); const [isLoading, setIsLoading] = useState(false); const [isFetchingMeta, setIsFetchingMeta] = useState(false); @@ -301,7 +303,9 @@ export default function NewVideoPageClient({ projectId }: { projectId: string }) finalVideoId = bunnyData.videoId; // Bunny will generate thumbnails automatically after processing. // We'll just provide the standard CDN thumbnail URL format as fallback. - finalThumbnailUrl = `https://vz-thumbnail.b-cdn.net/${bunnyData.videoId}/thumbnail.jpg`; + finalThumbnailUrl = bunnyCdnHostname + ? `https://${bunnyCdnHostname}/${bunnyData.videoId}/thumbnail.jpg` + : null; } // Final POST to our database diff --git a/app/api/versions/[versionId]/download/route.ts b/app/api/versions/[versionId]/download/route.ts index c478d37..d89c6dc 100644 --- a/app/api/versions/[versionId]/download/route.ts +++ b/app/api/versions/[versionId]/download/route.ts @@ -4,6 +4,7 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response import { rateLimit } from '@/lib/rate-limit'; import { validateShareLinkAccess } from '@/lib/share-links'; import { getShareSessionFromRequest } from '@/lib/share-session'; +import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn'; import { NextRequest } from 'next/server'; import { DownloadEgressSource } from '@prisma/client'; @@ -17,7 +18,6 @@ type BunnyDownloadSource = { const BUNNY_DOWNLOAD_FALLBACK_HEIGHTS = [2160, 1440, 1080, 720, 480, 360, 240]; const BUNNY_ALLOWED_QUALITIES = new Set(BUNNY_DOWNLOAD_FALLBACK_HEIGHTS); -const DEFAULT_BUNNY_CDN_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net'; const BUNNY_MAX_PROBE_CANDIDATES = 4; const BUNNY_SOURCE_RESOLUTION_CACHE_TTL_MS = 60 * 1000; const BUNNY_REMOTE_FETCH_TIMEOUT_MS = 8 * 1000; @@ -65,20 +65,14 @@ function buildContentDisposition(fileNameWithExt: string): string { return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`; } -function resolveBunnyCdnHostname(): string { - const raw = process.env.BUNNY_CDN_URL || process.env.NEXT_PUBLIC_BUNNY_CDN_URL; - if (!raw) return DEFAULT_BUNNY_CDN_HOSTNAME; - - try { - const url = new URL(raw); - return url.hostname || DEFAULT_BUNNY_CDN_HOSTNAME; - } catch { - return raw.replace(/^https?:\/\//, '').replace(/\/+$/, '') || DEFAULT_BUNNY_CDN_HOSTNAME; - } +function resolveBunnyCdnHostname(): string | null { + return resolveServerBunnyCdnHostname(); } function buildBunnyOriginalUrl(videoId: string): string { - return `https://${resolveBunnyCdnHostname()}/${videoId}/original`; + const hostname = resolveBunnyCdnHostname(); + if (!hostname) return ''; + return `https://${hostname}/${videoId}/original`; } function buildBunnySourceCacheKey( @@ -140,6 +134,7 @@ async function isRemoteFileAvailable(url: string): Promise { async function resolveHighestBunnyMp4Url(videoId: string): Promise { const hostname = resolveBunnyCdnHostname(); + if (!hostname) return ''; const playlistUrl = `https://${hostname}/${videoId}/playlist.m3u8`; let playlistHeights: number[] = []; @@ -172,6 +167,7 @@ async function resolveHighestBunnyMp4Url(videoId: string): Promise { async function resolveBunnyOriginalSource(videoId: string): Promise { const originalUrl = buildBunnyOriginalUrl(videoId); + if (!originalUrl) return null; if (await isRemoteFileAvailable(originalUrl)) { return { sourceType: 'original', @@ -184,8 +180,17 @@ async function resolveBunnyOriginalSource(videoId: string): Promise { + const hostname = resolveBunnyCdnHostname(); + if (!hostname) { + return { + sourceType: 'compressed', + quality: null, + url: '', + }; + } + if (typeof requestedQuality === 'number' && Number.isFinite(requestedQuality) && requestedQuality > 0) { - const requestedUrl = `https://${resolveBunnyCdnHostname()}/${videoId}/play_${requestedQuality}p.mp4`; + const requestedUrl = `https://${hostname}/${videoId}/play_${requestedQuality}p.mp4`; if (await isRemoteFileAvailable(requestedUrl)) { return { sourceType: 'compressed', @@ -196,6 +201,13 @@ async function resolveBunnyCompressedSource(videoId: string, requestedQuality: n } const fallbackUrl = await resolveHighestBunnyMp4Url(videoId); + if (!fallbackUrl) { + return { + sourceType: 'compressed', + quality: null, + url: '', + }; + } return { sourceType: 'compressed', @@ -209,6 +221,8 @@ async function resolveBunnyDownloadSource( requestedQuality: number | null, sourcePreference: BunnyDownloadSourcePreference ): Promise { + if (!resolveBunnyCdnHostname()) return null; + const now = Date.now(); const cacheKey = buildBunnySourceCacheKey(videoId, requestedQuality, sourcePreference); const cached = getCachedBunnyDownloadSource(cacheKey, now); diff --git a/app/api/videos/[videoId]/assets/route.ts b/app/api/videos/[videoId]/assets/route.ts index 7804801..67974b6 100644 --- a/app/api/videos/[videoId]/assets/route.ts +++ b/app/api/videos/[videoId]/assets/route.ts @@ -11,6 +11,7 @@ import { deriveGuestUploadContext, verifyGuestUploadToken } from '@/lib/guest-up import { ensureGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity'; import { getShareSessionFromRequest } from '@/lib/share-session'; import { validateUrl, validateOptionalUrl } from '@/lib/validation'; +import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn'; import { SAFE_BUNNY_VIDEO_ID, SAFE_IMAGE_PROXY_PATH, @@ -26,11 +27,6 @@ type RouteParams = { params: Promise<{ videoId: string }> }; const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000; const ASSET_LIST_DEFAULT_LIMIT = 40; const ASSET_LIST_MAX_LIMIT = 100; -const BUNNY_ALLOWED_THUMBNAIL_HOSTS = new Set([ - 'iframe.mediadelivery.net', - 'video.bunnycdn.com', - 'vz-965f4f4a-fc1.b-cdn.net', -]); const YOUTUBE_TITLE_CACHE_TTL_MS = 5 * 60 * 1000; type AssetWithViewerFields = { @@ -62,10 +58,19 @@ type YouTubeTitleCacheRecord = { const youtubeTitleCache = new Map(); function isAllowedBunnyMediaUrl(url: string): boolean { + const allowedHosts = new Set([ + 'iframe.mediadelivery.net', + 'video.bunnycdn.com', + ]); + const bunnyCdnHostname = resolveServerBunnyCdnHostname(); + if (bunnyCdnHostname) { + allowedHosts.add(bunnyCdnHostname); + } + try { const parsed = new URL(url); if (parsed.protocol !== 'https:') return false; - return BUNNY_ALLOWED_THUMBNAIL_HOSTS.has(parsed.hostname); + return allowedHosts.has(parsed.hostname); } catch { return false; } @@ -330,7 +335,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) { displayName = sanitizeAssetDisplayName(requestedDisplayName, `Bunny ${providerVideoId}`); if (!thumbnailUrl) { - thumbnailUrl = `https://vz-965f4f4a-fc1.b-cdn.net/${providerVideoId}/thumbnail.jpg`; + const bunnyCdnHostname = resolveServerBunnyCdnHostname(); + if (bunnyCdnHostname) { + thumbnailUrl = `https://${bunnyCdnHostname}/${providerVideoId}/thumbnail.jpg`; + } } kind = 'VIDEO'; } diff --git a/components/video-card.tsx b/components/video-card.tsx index e45e13b..33572ee 100644 --- a/components/video-card.tsx +++ b/components/video-card.tsx @@ -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 Video may already be playable
) : ( - // eslint-disable-next-line @next/next/no-img-element - {video.title} { - 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 + {video.title} { + setImgError(true); + // Check again after 10 seconds in case Bunny is still processing + setTimeout(() => { + setRetryKey(Date.now()); + setImgError(false); + }, 10000); + }} + /> + ) : ( +
+ Thumbnail unavailable +
+ ) )} {!imgError && (
diff --git a/components/video-drag-drop-uploader.tsx b/components/video-drag-drop-uploader.tsx index eb2a03b..3552887 100644 --- a/components/video-drag-drop-uploader.tsx +++ b/components/video-drag-drop-uploader.tsx @@ -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) { diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index d4a67c4..70ebbb2 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -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, diff --git a/components/video-page/assets-pane.tsx b/components/video-page/assets-pane.tsx index d094836..fa6b2bc 100644 --- a/components/video-page/assets-pane.tsx +++ b/components/video-page/assets-pane.tsx @@ -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(null); const [previewImageTitle, setPreviewImageTitle] = useState(null); const [selectedAsset, setSelectedAsset] = useState(null); + const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []); const [focusedAssetId, setFocusedAssetId] = useState(null); const bunnyPreviewPlayerRef = useRef(null); const youtubeIframeRef = useRef(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, diff --git a/components/video-page/bunny-preview-player.tsx b/components/video-page/bunny-preview-player.tsx index 139c2ba..6f581d4 100644 --- a/components/video-page/bunny-preview-player.tsx +++ b/components/video-page/bunny-preview-player.tsx @@ -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(-1); const [bunnySourcePreference, setBunnySourcePreference] = useState<'auto' | 'original'>('auto'); const [bunnyPlaybackState, setBunnyPlaybackState] = useState('none'); - const bunnyCdnHostname = useMemo(() => resolveBunnyCdnHostname(), []); + const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []); const playlistUrl = useMemo(() => { if (!providerVideoId || !bunnyCdnHostname) return null; diff --git a/components/video-page/hooks/use-download-actions.ts b/components/video-page/hooks/use-download-actions.ts index 6719d23..2ed0e9c 100644 --- a/components/video-page/hooks/use-download-actions.ts +++ b/components/video-page/hooks/use-download-actions.ts @@ -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()) diff --git a/components/video-page/hooks/use-version-actions.ts b/components/video-page/hooks/use-version-actions.ts index 8e7ee72..e5560bb 100644 --- a/components/video-page/hooks/use-version-actions.ts +++ b/components/video-page/hooks/use-version-actions.ts @@ -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>; @@ -33,6 +34,7 @@ export function useVersionActions({ const [showDeleteVersionDialog, setShowDeleteVersionDialog] = useState(false); const [versionToDelete, setVersionToDelete] = useState(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`, { diff --git a/lib/bunny-cdn.ts b/lib/bunny-cdn.ts new file mode 100644 index 0000000..0191973 --- /dev/null +++ b/lib/bunny-cdn.ts @@ -0,0 +1,20 @@ +function normalizeBunnyCdnHostname(raw: string | null | undefined): string | null { + if (!raw) return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + + try { + const parsed = new URL(trimmed); + return parsed.hostname || null; + } catch { + return trimmed.replace(/^https?:\/\//, '').replace(/\/+$/, '') || null; + } +} + +export function resolveServerBunnyCdnHostname(): string | null { + return normalizeBunnyCdnHostname(process.env.BUNNY_CDN_URL || process.env.NEXT_PUBLIC_BUNNY_CDN_URL); +} + +export function resolvePublicBunnyCdnHostname(): string | null { + return normalizeBunnyCdnHostname(process.env.NEXT_PUBLIC_BUNNY_CDN_URL); +} diff --git a/lib/bunny-download.ts b/lib/bunny-download.ts index 73609a4..73e98d8 100644 --- a/lib/bunny-download.ts +++ b/lib/bunny-download.ts @@ -1,3 +1,5 @@ +import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn'; + type BunnyDownloadSourcePreference = 'auto' | 'original' | 'compressed'; export type BunnyDownloadSource = { @@ -6,7 +8,6 @@ export type BunnyDownloadSource = { url: string; }; -const DEFAULT_BUNNY_CDN_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net'; const BUNNY_DOWNLOAD_FALLBACK_HEIGHTS = [2160, 1440, 1080, 720, 480, 360, 240]; const BUNNY_ALLOWED_QUALITIES = new Set(BUNNY_DOWNLOAD_FALLBACK_HEIGHTS); const BUNNY_MAX_PROBE_CANDIDATES = 4; @@ -20,16 +21,8 @@ type BunnyDownloadSourceCacheRecord = { const bunnyDownloadSourceCache = new Map(); -export function resolveBunnyCdnHostname(): string { - const raw = process.env.BUNNY_CDN_URL || process.env.NEXT_PUBLIC_BUNNY_CDN_URL; - if (!raw) return DEFAULT_BUNNY_CDN_HOSTNAME; - - try { - const parsed = new URL(raw); - return parsed.hostname || DEFAULT_BUNNY_CDN_HOSTNAME; - } catch { - return raw.replace(/^https?:\/\//, '').replace(/\/+$/, '') || DEFAULT_BUNNY_CDN_HOSTNAME; - } +export function resolveBunnyCdnHostname(): string | null { + return resolveServerBunnyCdnHostname(); } export async function fetchWithTimeout(url: string, init: RequestInit): Promise { @@ -63,7 +56,9 @@ async function isRemoteFileAvailable(url: string): Promise { } function buildBunnyOriginalUrl(videoId: string): string { - return `https://${resolveBunnyCdnHostname()}/${videoId}/original`; + const hostname = resolveBunnyCdnHostname(); + if (!hostname) return ''; + return `https://${hostname}/${videoId}/original`; } function extractHeightFromBunnyMp4Url(url: string): number | null { @@ -75,6 +70,7 @@ function extractHeightFromBunnyMp4Url(url: string): number | null { async function resolveHighestBunnyMp4Url(videoId: string): Promise { const hostname = resolveBunnyCdnHostname(); + if (!hostname) return ''; const playlistUrl = `https://${hostname}/${videoId}/playlist.m3u8`; let playlistHeights: number[] = []; @@ -106,6 +102,7 @@ async function resolveHighestBunnyMp4Url(videoId: string): Promise { async function resolveBunnyOriginalSource(videoId: string): Promise { const originalUrl = buildBunnyOriginalUrl(videoId); + if (!originalUrl) return null; if (await isRemoteFileAvailable(originalUrl)) { return { sourceType: 'original', @@ -118,8 +115,17 @@ async function resolveBunnyOriginalSource(videoId: string): Promise { + const hostname = resolveBunnyCdnHostname(); + if (!hostname) { + return { + sourceType: 'compressed', + quality: null, + url: '', + }; + } + if (typeof requestedQuality === 'number' && Number.isFinite(requestedQuality) && requestedQuality > 0) { - const requestedUrl = `https://${resolveBunnyCdnHostname()}/${videoId}/play_${requestedQuality}p.mp4`; + const requestedUrl = `https://${hostname}/${videoId}/play_${requestedQuality}p.mp4`; if (await isRemoteFileAvailable(requestedUrl)) { return { sourceType: 'compressed', @@ -130,6 +136,13 @@ async function resolveBunnyCompressedSource(videoId: string, requestedQuality: n } const fallbackUrl = await resolveHighestBunnyMp4Url(videoId); + if (!fallbackUrl) { + return { + sourceType: 'compressed', + quality: null, + url: '', + }; + } return { sourceType: 'compressed', quality: extractHeightFromBunnyMp4Url(fallbackUrl), @@ -163,6 +176,8 @@ export async function resolveBunnyDownloadSource( requestedQuality: number | null, preference: BunnyDownloadSourcePreference ): Promise { + if (!resolveBunnyCdnHostname()) return null; + const now = Date.now(); const cacheKey = buildSourceCacheKey(videoId, requestedQuality, preference); const cached = getCachedSource(cacheKey, now); diff --git a/lib/video-providers/bunny.ts b/lib/video-providers/bunny.ts index e879938..045fae5 100644 --- a/lib/video-providers/bunny.ts +++ b/lib/video-providers/bunny.ts @@ -1,5 +1,6 @@ import type { VideoProvider, VideoMetadata, EmbedOptions } from './types'; import { getCachedMetadata, setCachedMetadata } from './metadata-cache'; +import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn'; // Bunny Stream URL patterns // e.g. https://iframe.mediadelivery.net/play/libraryId/videoId @@ -44,11 +45,9 @@ export const bunnyProvider: VideoProvider = { }, getThumbnailUrl(videoId: string): string { - // Bunny stream thumbnails: https://vz-uuid.b-cdn.net/{videoId}/thumbnail.jpg - // Since we don't have the b-cdn pull zone readily available in pure abstract, - // we should rely on fetching metadata for actual thumbnails, OR construct via API - // Actually, Bunny's public thumbnail format is: - return `https://vz-965f4f4a-fc1.b-cdn.net/${videoId}/thumbnail.jpg`; // Fallback approximate + const bunnyCdnHostname = resolveServerBunnyCdnHostname(); + if (!bunnyCdnHostname) return ''; + return `https://${bunnyCdnHostname}/${videoId}/thumbnail.jpg`; }, async getMetadata(videoId: string): Promise { diff --git a/next.config.ts b/next.config.ts index c2fbde1..bcd6c5e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,14 +1,29 @@ import type { NextConfig } from "next"; +import type { RemotePattern } from "next/dist/shared/lib/image-config"; + +function resolveBunnyCdnHostname(): string | null { + const raw = process.env.BUNNY_CDN_URL || process.env.NEXT_PUBLIC_BUNNY_CDN_URL; + if (!raw) return null; + try { + const parsed = new URL(raw); + return parsed.hostname || null; + } catch { + return raw.replace(/^https?:\/\//, '').replace(/\/+$/, '') || null; + } +} + +const bunnyCdnHostname = resolveBunnyCdnHostname(); +const remotePatterns: RemotePattern[] = [ + { protocol: 'https', hostname: 'img.youtube.com' }, + { protocol: 'https', hostname: 'i.ytimg.com' }, + { protocol: 'https', hostname: 'images.unsplash.com' }, + { protocol: 'https', hostname: 'vz-thumbnail.b-cdn.net' }, + ...(bunnyCdnHostname ? [{ protocol: 'https' as const, hostname: bunnyCdnHostname }] : []), +]; const nextConfig: NextConfig = { images: { - remotePatterns: [ - { protocol: 'https', hostname: 'img.youtube.com' }, - { protocol: 'https', hostname: 'i.ytimg.com' }, - { protocol: 'https', hostname: 'images.unsplash.com' }, - { protocol: 'https', hostname: 'vz-thumbnail.b-cdn.net' }, - { protocol: 'https', hostname: 'vz-965f4f4a-fc1.b-cdn.net' }, - ], + remotePatterns, formats: ['image/avif', 'image/webp'], }, experimental: {