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
@@ -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<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
const [portraitFrameWidth, setPortraitFrameWidth] = useState<number>(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 (
<div ref={panelRef} className="relative w-full h-full group flex items-center justify-center bg-black">
@@ -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
+27 -13
View File
@@ -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<boolean> {
async function resolveHighestBunnyMp4Url(videoId: string): Promise<string> {
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<string> {
async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloadSource | null> {
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<BunnyDownloa
}
async function resolveBunnyCompressedSource(videoId: string, requestedQuality: number | null): Promise<BunnyDownloadSource> {
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<BunnyDownloadSource | null> {
if (!resolveBunnyCdnHostname()) return null;
const now = Date.now();
const cacheKey = buildBunnySourceCacheKey(videoId, requestedQuality, sourcePreference);
const cached = getCachedBunnyDownloadSource(cacheKey, now);
+15 -7
View File
@@ -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<string, YouTubeTitleCacheRecord>();
function isAllowedBunnyMediaUrl(url: string): boolean {
const allowedHosts = new Set<string>([
'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';
}
+23 -2
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,9 +214,10 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
<span className="text-[11px] text-muted-foreground/90">Video may already be playable</span>
</div>
) : (
resolvedThumbnailUrl ? (
// 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}` : ''}`}
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={() => {
@@ -213,6 +229,11 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
}, 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`, {
+20
View File
@@ -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);
}
+28 -13
View File
@@ -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<string, BunnyDownloadSourceCacheRecord>();
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<Response> {
@@ -63,7 +56,9 @@ async function isRemoteFileAvailable(url: string): Promise<boolean> {
}
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<string> {
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<string> {
async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloadSource | null> {
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<BunnyDownloa
}
async function resolveBunnyCompressedSource(videoId: string, requestedQuality: number | null): Promise<BunnyDownloadSource> {
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<BunnyDownloadSource | null> {
if (!resolveBunnyCdnHostname()) return null;
const now = Date.now();
const cacheKey = buildSourceCacheKey(videoId, requestedQuality, preference);
const cached = getCachedSource(cacheKey, now);
+4 -5
View File
@@ -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<VideoMetadata> {
+20 -5
View File
@@ -1,14 +1,29 @@
import type { NextConfig } from "next";
import type { RemotePattern } from "next/dist/shared/lib/image-config";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
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' },
{ protocol: 'https', hostname: 'vz-965f4f4a-fc1.b-cdn.net' },
],
...(bunnyCdnHostname ? [{ protocol: 'https' as const, hostname: bunnyCdnHostname }] : []),
];
const nextConfig: NextConfig = {
images: {
remotePatterns,
formats: ['image/avif', 'image/webp'],
},
experimental: {