mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(bunny-cdn): refactor CDN hostname resolution and update asset URLs for improved flexibility
This commit is contained in:
+9
-6
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import Hls from 'hls.js';
|
import Hls from 'hls.js';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface Version {
|
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 }) {
|
export default function CompareVersionsPageClient({ projectId, videoId }: { projectId: string; videoId: string }) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
@@ -867,6 +866,7 @@ function BunnyPanel({
|
|||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const hlsRef = useRef<Hls | null>(null);
|
const hlsRef = useRef<Hls | null>(null);
|
||||||
|
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
|
||||||
const [portraitFrameWidth, setPortraitFrameWidth] = useState<number>(0);
|
const [portraitFrameWidth, setPortraitFrameWidth] = useState<number>(0);
|
||||||
const [isPortraitSource, setIsPortraitSource] = useState(false);
|
const [isPortraitSource, setIsPortraitSource] = useState(false);
|
||||||
|
|
||||||
@@ -980,8 +980,11 @@ function BunnyPanel({
|
|||||||
const onPlay = () => { isPlaying = true; };
|
const onPlay = () => { isPlaying = true; };
|
||||||
const onPause = () => { isPlaying = false; };
|
const onPause = () => { isPlaying = false; };
|
||||||
const onEnded = () => { isPlaying = false; };
|
const onEnded = () => { isPlaying = false; };
|
||||||
const hlsUrl = `https://${BUNNY_PULL_ZONE_HOSTNAME}/${version.videoId}/playlist.m3u8`;
|
if (!bunnyCdnHostname) {
|
||||||
const originalUrl = `https://${BUNNY_PULL_ZONE_HOSTNAME}/${version.videoId}/original`;
|
return;
|
||||||
|
}
|
||||||
|
const hlsUrl = `https://${bunnyCdnHostname}/${version.videoId}/playlist.m3u8`;
|
||||||
|
const originalUrl = `https://${bunnyCdnHostname}/${version.videoId}/original`;
|
||||||
const activateOriginalFallback = (): void => {
|
const activateOriginalFallback = (): void => {
|
||||||
sourceMode = 'original';
|
sourceMode = 'original';
|
||||||
clearRetryTimer();
|
clearRetryTimer();
|
||||||
@@ -1088,7 +1091,7 @@ function BunnyPanel({
|
|||||||
onUnregister(version.id);
|
onUnregister(version.id);
|
||||||
adapter.destroy();
|
adapter.destroy();
|
||||||
};
|
};
|
||||||
}, [version.id, version.videoId, onRegister, onUnregister]);
|
}, [version.id, version.videoId, onRegister, onUnregister, bunnyCdnHostname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={panelRef} className="relative w-full h-full group flex items-center justify-center bg-black">
|
<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 { Textarea } from '@/components/ui/textarea';
|
||||||
import { Tabs, 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 { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
import * as tus from 'tus-js-client';
|
import * as tus from 'tus-js-client';
|
||||||
|
|
||||||
export default function NewVideoPageClient({ projectId }: { projectId: string }) {
|
export default function NewVideoPageClient({ projectId }: { projectId: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isFetchingMeta, setIsFetchingMeta] = useState(false);
|
const [isFetchingMeta, setIsFetchingMeta] = useState(false);
|
||||||
@@ -301,7 +303,9 @@ export default function NewVideoPageClient({ projectId }: { projectId: string })
|
|||||||
finalVideoId = bunnyData.videoId;
|
finalVideoId = bunnyData.videoId;
|
||||||
// Bunny will generate thumbnails automatically after processing.
|
// Bunny will generate thumbnails automatically after processing.
|
||||||
// We'll just provide the standard CDN thumbnail URL format as fallback.
|
// 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
|
// Final POST to our database
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response
|
|||||||
import { rateLimit } from '@/lib/rate-limit';
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||||
|
import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { DownloadEgressSource } from '@prisma/client';
|
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_DOWNLOAD_FALLBACK_HEIGHTS = [2160, 1440, 1080, 720, 480, 360, 240];
|
||||||
const BUNNY_ALLOWED_QUALITIES = new Set(BUNNY_DOWNLOAD_FALLBACK_HEIGHTS);
|
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_MAX_PROBE_CANDIDATES = 4;
|
||||||
const BUNNY_SOURCE_RESOLUTION_CACHE_TTL_MS = 60 * 1000;
|
const BUNNY_SOURCE_RESOLUTION_CACHE_TTL_MS = 60 * 1000;
|
||||||
const BUNNY_REMOTE_FETCH_TIMEOUT_MS = 8 * 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}`;
|
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBunnyCdnHostname(): string {
|
function resolveBunnyCdnHostname(): string | null {
|
||||||
const raw = process.env.BUNNY_CDN_URL || process.env.NEXT_PUBLIC_BUNNY_CDN_URL;
|
return resolveServerBunnyCdnHostname();
|
||||||
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 buildBunnyOriginalUrl(videoId: string): string {
|
function buildBunnyOriginalUrl(videoId: string): string {
|
||||||
return `https://${resolveBunnyCdnHostname()}/${videoId}/original`;
|
const hostname = resolveBunnyCdnHostname();
|
||||||
|
if (!hostname) return '';
|
||||||
|
return `https://${hostname}/${videoId}/original`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBunnySourceCacheKey(
|
function buildBunnySourceCacheKey(
|
||||||
@@ -140,6 +134,7 @@ async function isRemoteFileAvailable(url: string): Promise<boolean> {
|
|||||||
|
|
||||||
async function resolveHighestBunnyMp4Url(videoId: string): Promise<string> {
|
async function resolveHighestBunnyMp4Url(videoId: string): Promise<string> {
|
||||||
const hostname = resolveBunnyCdnHostname();
|
const hostname = resolveBunnyCdnHostname();
|
||||||
|
if (!hostname) return '';
|
||||||
const playlistUrl = `https://${hostname}/${videoId}/playlist.m3u8`;
|
const playlistUrl = `https://${hostname}/${videoId}/playlist.m3u8`;
|
||||||
|
|
||||||
let playlistHeights: number[] = [];
|
let playlistHeights: number[] = [];
|
||||||
@@ -172,6 +167,7 @@ async function resolveHighestBunnyMp4Url(videoId: string): Promise<string> {
|
|||||||
|
|
||||||
async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloadSource | null> {
|
async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloadSource | null> {
|
||||||
const originalUrl = buildBunnyOriginalUrl(videoId);
|
const originalUrl = buildBunnyOriginalUrl(videoId);
|
||||||
|
if (!originalUrl) return null;
|
||||||
if (await isRemoteFileAvailable(originalUrl)) {
|
if (await isRemoteFileAvailable(originalUrl)) {
|
||||||
return {
|
return {
|
||||||
sourceType: 'original',
|
sourceType: 'original',
|
||||||
@@ -184,8 +180,17 @@ async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloa
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveBunnyCompressedSource(videoId: string, requestedQuality: number | null): Promise<BunnyDownloadSource> {
|
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) {
|
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)) {
|
if (await isRemoteFileAvailable(requestedUrl)) {
|
||||||
return {
|
return {
|
||||||
sourceType: 'compressed',
|
sourceType: 'compressed',
|
||||||
@@ -196,6 +201,13 @@ async function resolveBunnyCompressedSource(videoId: string, requestedQuality: n
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fallbackUrl = await resolveHighestBunnyMp4Url(videoId);
|
const fallbackUrl = await resolveHighestBunnyMp4Url(videoId);
|
||||||
|
if (!fallbackUrl) {
|
||||||
|
return {
|
||||||
|
sourceType: 'compressed',
|
||||||
|
quality: null,
|
||||||
|
url: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sourceType: 'compressed',
|
sourceType: 'compressed',
|
||||||
@@ -209,6 +221,8 @@ async function resolveBunnyDownloadSource(
|
|||||||
requestedQuality: number | null,
|
requestedQuality: number | null,
|
||||||
sourcePreference: BunnyDownloadSourcePreference
|
sourcePreference: BunnyDownloadSourcePreference
|
||||||
): Promise<BunnyDownloadSource | null> {
|
): Promise<BunnyDownloadSource | null> {
|
||||||
|
if (!resolveBunnyCdnHostname()) return null;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const cacheKey = buildBunnySourceCacheKey(videoId, requestedQuality, sourcePreference);
|
const cacheKey = buildBunnySourceCacheKey(videoId, requestedQuality, sourcePreference);
|
||||||
const cached = getCachedBunnyDownloadSource(cacheKey, now);
|
const cached = getCachedBunnyDownloadSource(cacheKey, now);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { deriveGuestUploadContext, verifyGuestUploadToken } from '@/lib/guest-up
|
|||||||
import { ensureGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity';
|
import { ensureGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity';
|
||||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||||
import { validateUrl, validateOptionalUrl } from '@/lib/validation';
|
import { validateUrl, validateOptionalUrl } from '@/lib/validation';
|
||||||
|
import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
import {
|
import {
|
||||||
SAFE_BUNNY_VIDEO_ID,
|
SAFE_BUNNY_VIDEO_ID,
|
||||||
SAFE_IMAGE_PROXY_PATH,
|
SAFE_IMAGE_PROXY_PATH,
|
||||||
@@ -26,11 +27,6 @@ type RouteParams = { params: Promise<{ videoId: string }> };
|
|||||||
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
||||||
const ASSET_LIST_DEFAULT_LIMIT = 40;
|
const ASSET_LIST_DEFAULT_LIMIT = 40;
|
||||||
const ASSET_LIST_MAX_LIMIT = 100;
|
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;
|
const YOUTUBE_TITLE_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
type AssetWithViewerFields = {
|
type AssetWithViewerFields = {
|
||||||
@@ -62,10 +58,19 @@ type YouTubeTitleCacheRecord = {
|
|||||||
const youtubeTitleCache = new Map<string, YouTubeTitleCacheRecord>();
|
const youtubeTitleCache = new Map<string, YouTubeTitleCacheRecord>();
|
||||||
|
|
||||||
function isAllowedBunnyMediaUrl(url: string): boolean {
|
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 {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
if (parsed.protocol !== 'https:') return false;
|
if (parsed.protocol !== 'https:') return false;
|
||||||
return BUNNY_ALLOWED_THUMBNAIL_HOSTS.has(parsed.hostname);
|
return allowedHosts.has(parsed.hostname);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -330,7 +335,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
displayName = sanitizeAssetDisplayName(requestedDisplayName, `Bunny ${providerVideoId}`);
|
displayName = sanitizeAssetDisplayName(requestedDisplayName, `Bunny ${providerVideoId}`);
|
||||||
if (!thumbnailUrl) {
|
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';
|
kind = 'VIDEO';
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-15
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { parseVideoUrl, fetchVideoMetadata, getThumbnailUrl, type VideoSource } from '@/lib/video-providers';
|
import { parseVideoUrl, fetchVideoMetadata, getThumbnailUrl, type VideoSource } from '@/lib/video-providers';
|
||||||
|
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
|
|
||||||
interface VideoCardProps {
|
interface VideoCardProps {
|
||||||
video: {
|
video: {
|
||||||
@@ -86,6 +87,20 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
|
|||||||
// Delete dialog
|
// Delete dialog
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = 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 () => {
|
const handleEdit = async () => {
|
||||||
setIsSaving(true);
|
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>
|
<span className="text-[11px] text-muted-foreground/90">Video may already be playable</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
resolvedThumbnailUrl ? (
|
||||||
<img
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
src={`${video.thumbnailUrl?.replace('vz-thumbnail.b-cdn.net', 'vz-965f4f4a-fc1.b-cdn.net')}${retryKey ? `?t=${retryKey}` : ''}`}
|
<img
|
||||||
alt={video.title}
|
src={`${resolvedThumbnailUrl}${retryKey ? `?t=${retryKey}` : ''}`}
|
||||||
className="absolute inset-0 w-full h-full object-cover transition-transform group-hover:scale-105"
|
alt={video.title}
|
||||||
onError={() => {
|
className="absolute inset-0 w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||||
setImgError(true);
|
onError={() => {
|
||||||
// Check again after 10 seconds in case Bunny is still processing
|
setImgError(true);
|
||||||
setTimeout(() => {
|
// Check again after 10 seconds in case Bunny is still processing
|
||||||
setRetryKey(Date.now());
|
setTimeout(() => {
|
||||||
setImgError(false);
|
setRetryKey(Date.now());
|
||||||
}, 10000);
|
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 && (
|
{!imgError && (
|
||||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
|
|
||||||
type ProjectOption = {
|
type ProjectOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -89,6 +90,7 @@ export function VideoDragDropUploader({
|
|||||||
const hasLoadedProjectsRef = useRef(false);
|
const hasLoadedProjectsRef = useRef(false);
|
||||||
|
|
||||||
const needsProjectSelection = !fixedProjectId;
|
const needsProjectSelection = !fixedProjectId;
|
||||||
|
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
|
||||||
|
|
||||||
const projectsById = useMemo(() => {
|
const projectsById = useMemo(() => {
|
||||||
return new Map(projects.map((project) => [project.id, project.name]));
|
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}`,
|
videoUrl: `https://iframe.mediadelivery.net/embed/${initPayload.data.libraryId}/${initPayload.data.videoId}`,
|
||||||
providerId: 'bunny',
|
providerId: 'bunny',
|
||||||
videoId: initPayload.data.videoId,
|
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,
|
duration: null,
|
||||||
uploadToken,
|
uploadToken,
|
||||||
}),
|
}),
|
||||||
@@ -341,7 +345,7 @@ export function VideoDragDropUploader({
|
|||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to upload video');
|
toast.error(error instanceof Error ? error.message : 'Failed to upload video');
|
||||||
}
|
}
|
||||||
}, [cleanupUploadState, projectsById, router]);
|
}, [bunnyCdnHostname, cleanupUploadState, projectsById, router]);
|
||||||
|
|
||||||
const handleDropFile = useCallback((file: File) => {
|
const handleDropFile = useCallback((file: File) => {
|
||||||
if (!canUpload) {
|
if (!canUpload) {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import type {
|
|||||||
} from '@/components/video-page/types';
|
} from '@/components/video-page/types';
|
||||||
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
|
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
|
||||||
import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets';
|
import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets';
|
||||||
|
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
const totalSeconds = Math.floor(seconds);
|
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 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';
|
export type VideoPageMode = 'dashboard' | 'watch';
|
||||||
|
|
||||||
@@ -255,6 +255,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
}, [video?.versions, activeVersionId]);
|
}, [video?.versions, activeVersionId]);
|
||||||
const activeProviderId = activeVersion?.providerId;
|
const activeProviderId = activeVersion?.providerId;
|
||||||
const activeVersionDuration = activeVersion?.duration;
|
const activeVersionDuration = activeVersion?.duration;
|
||||||
|
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
|
||||||
const embedUrl = useMemo(() => {
|
const embedUrl = useMemo(() => {
|
||||||
if (!activeVersion) return '';
|
if (!activeVersion) return '';
|
||||||
if (activeVersion.providerId === 'youtube') {
|
if (activeVersion.providerId === 'youtube') {
|
||||||
@@ -264,7 +265,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
return `${base}&origin=${encodeURIComponent(origin)}`;
|
return `${base}&origin=${encodeURIComponent(origin)}`;
|
||||||
}
|
}
|
||||||
if (activeVersion.providerId === 'bunny') {
|
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 {
|
try {
|
||||||
const url = new URL(activeVersion.originalUrl);
|
const url = new URL(activeVersion.originalUrl);
|
||||||
@@ -275,7 +277,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}, [activeVersion]);
|
}, [activeVersion, bunnyCdnHostname]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isReady,
|
isReady,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { BunnyPreviewPlayer, type BunnyPreviewPlayerHandle } from '@/components/
|
|||||||
import { AssetListSection } from '@/components/video-page/asset-list-section';
|
import { AssetListSection } from '@/components/video-page/asset-list-section';
|
||||||
import type { VideoAsset } from '@/components/video-page/types';
|
import type { VideoAsset } from '@/components/video-page/types';
|
||||||
import { extractPastedImageFile, validateImageFile } from '@/components/video-page/image-upload-utils';
|
import { extractPastedImageFile, validateImageFile } from '@/components/video-page/image-upload-utils';
|
||||||
|
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
|
|
||||||
interface AssetsPaneProps {
|
interface AssetsPaneProps {
|
||||||
videoId: string;
|
videoId: string;
|
||||||
@@ -82,6 +83,7 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
const [previewImageTitle, setPreviewImageTitle] = useState<string | null>(null);
|
const [previewImageTitle, setPreviewImageTitle] = useState<string | null>(null);
|
||||||
const [selectedAsset, setSelectedAsset] = useState<VideoAsset | null>(null);
|
const [selectedAsset, setSelectedAsset] = useState<VideoAsset | null>(null);
|
||||||
|
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
|
||||||
const [focusedAssetId, setFocusedAssetId] = useState<string | null>(null);
|
const [focusedAssetId, setFocusedAssetId] = useState<string | null>(null);
|
||||||
const bunnyPreviewPlayerRef = useRef<BunnyPreviewPlayerHandle | null>(null);
|
const bunnyPreviewPlayerRef = useRef<BunnyPreviewPlayerHandle | null>(null);
|
||||||
const youtubeIframeRef = useRef<HTMLIFrameElement | 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 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({
|
const createdAsset = await createAsset({
|
||||||
provider: 'BUNNY',
|
provider: 'BUNNY',
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { BunnyPlaybackState, BunnyQualityOption } from '@/components/video-page/types';
|
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];
|
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 {
|
function formatTime(value: number): string {
|
||||||
if (!Number.isFinite(value) || value < 0) return '0:00';
|
if (!Number.isFinite(value) || value < 0) return '0:00';
|
||||||
const total = Math.floor(value);
|
const total = Math.floor(value);
|
||||||
@@ -78,7 +68,7 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
|
|||||||
const [selectedQualityLevel, setSelectedQualityLevel] = useState<number>(-1);
|
const [selectedQualityLevel, setSelectedQualityLevel] = useState<number>(-1);
|
||||||
const [bunnySourcePreference, setBunnySourcePreference] = useState<'auto' | 'original'>('auto');
|
const [bunnySourcePreference, setBunnySourcePreference] = useState<'auto' | 'original'>('auto');
|
||||||
const [bunnyPlaybackState, setBunnyPlaybackState] = useState<BunnyPlaybackState>('none');
|
const [bunnyPlaybackState, setBunnyPlaybackState] = useState<BunnyPlaybackState>('none');
|
||||||
const bunnyCdnHostname = useMemo(() => resolveBunnyCdnHostname(), []);
|
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
|
||||||
|
|
||||||
const playlistUrl = useMemo(() => {
|
const playlistUrl = useMemo(() => {
|
||||||
if (!providerVideoId || !bunnyCdnHostname) return null;
|
if (!providerVideoId || !bunnyCdnHostname) return null;
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { BunnyDownloadPreference, Comment, DownloadTarget, Version, VideoData } from '@/components/video-page/types';
|
import type { BunnyDownloadPreference, Comment, DownloadTarget, Version, VideoData } from '@/components/video-page/types';
|
||||||
|
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
const BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
|
|
||||||
|
|
||||||
function sanitizeDownloadFileName(value: string): string {
|
function sanitizeDownloadFileName(value: string): string {
|
||||||
return value
|
return value
|
||||||
@@ -14,17 +13,9 @@ function sanitizeDownloadFileName(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getAllowedHosts() {
|
function getAllowedHosts() {
|
||||||
|
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
|
||||||
return [
|
return [
|
||||||
BUNNY_PULL_ZONE_HOSTNAME,
|
...(bunnyCdnHostname ? [bunnyCdnHostname] : []),
|
||||||
...(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(/\/+$/, '')];
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
: []),
|
|
||||||
...(process.env.NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS ?? '').split(','),
|
...(process.env.NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS ?? '').split(','),
|
||||||
]
|
]
|
||||||
.map((host) => host.trim().toLowerCase())
|
.map((host) => host.trim().toLowerCase())
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { toast } from 'sonner';
|
|||||||
import * as tus from 'tus-js-client';
|
import * as tus from 'tus-js-client';
|
||||||
import { parseVideoUrl, getThumbnailUrl, fetchVideoMetadata, type VideoSource } from '@/lib/video-providers';
|
import { parseVideoUrl, getThumbnailUrl, fetchVideoMetadata, type VideoSource } from '@/lib/video-providers';
|
||||||
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
|
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
|
||||||
|
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
|
|
||||||
interface UseVersionActionsParams extends VersionActionsConfig {
|
interface UseVersionActionsParams extends VersionActionsConfig {
|
||||||
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
||||||
@@ -33,6 +34,7 @@ export function useVersionActions({
|
|||||||
const [showDeleteVersionDialog, setShowDeleteVersionDialog] = useState(false);
|
const [showDeleteVersionDialog, setShowDeleteVersionDialog] = useState(false);
|
||||||
const [versionToDelete, setVersionToDelete] = useState<string | null>(null);
|
const [versionToDelete, setVersionToDelete] = useState<string | null>(null);
|
||||||
const [isDeletingVersion, setIsDeletingVersion] = useState(false);
|
const [isDeletingVersion, setIsDeletingVersion] = useState(false);
|
||||||
|
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
|
||||||
|
|
||||||
const handleNewVersionUrlChange = (url: string) => {
|
const handleNewVersionUrlChange = (url: string) => {
|
||||||
setNewVersionUrl(url);
|
setNewVersionUrl(url);
|
||||||
@@ -126,7 +128,9 @@ export function useVersionActions({
|
|||||||
finalVideoUrl = `https://iframe.mediadelivery.net/embed/${libraryId}/${bunnyVideoId}`;
|
finalVideoUrl = `https://iframe.mediadelivery.net/embed/${libraryId}/${bunnyVideoId}`;
|
||||||
finalProviderId = 'bunny';
|
finalProviderId = 'bunny';
|
||||||
finalProviderVideoId = bunnyVideoId;
|
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`, {
|
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}/versions`, {
|
||||||
|
|||||||
@@ -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
@@ -1,3 +1,5 @@
|
|||||||
|
import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
|
|
||||||
type BunnyDownloadSourcePreference = 'auto' | 'original' | 'compressed';
|
type BunnyDownloadSourcePreference = 'auto' | 'original' | 'compressed';
|
||||||
|
|
||||||
export type BunnyDownloadSource = {
|
export type BunnyDownloadSource = {
|
||||||
@@ -6,7 +8,6 @@ export type BunnyDownloadSource = {
|
|||||||
url: string;
|
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_DOWNLOAD_FALLBACK_HEIGHTS = [2160, 1440, 1080, 720, 480, 360, 240];
|
||||||
const BUNNY_ALLOWED_QUALITIES = new Set(BUNNY_DOWNLOAD_FALLBACK_HEIGHTS);
|
const BUNNY_ALLOWED_QUALITIES = new Set(BUNNY_DOWNLOAD_FALLBACK_HEIGHTS);
|
||||||
const BUNNY_MAX_PROBE_CANDIDATES = 4;
|
const BUNNY_MAX_PROBE_CANDIDATES = 4;
|
||||||
@@ -20,16 +21,8 @@ type BunnyDownloadSourceCacheRecord = {
|
|||||||
|
|
||||||
const bunnyDownloadSourceCache = new Map<string, BunnyDownloadSourceCacheRecord>();
|
const bunnyDownloadSourceCache = new Map<string, BunnyDownloadSourceCacheRecord>();
|
||||||
|
|
||||||
export function resolveBunnyCdnHostname(): string {
|
export function resolveBunnyCdnHostname(): string | null {
|
||||||
const raw = process.env.BUNNY_CDN_URL || process.env.NEXT_PUBLIC_BUNNY_CDN_URL;
|
return resolveServerBunnyCdnHostname();
|
||||||
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 async function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
|
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 {
|
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 {
|
function extractHeightFromBunnyMp4Url(url: string): number | null {
|
||||||
@@ -75,6 +70,7 @@ function extractHeightFromBunnyMp4Url(url: string): number | null {
|
|||||||
|
|
||||||
async function resolveHighestBunnyMp4Url(videoId: string): Promise<string> {
|
async function resolveHighestBunnyMp4Url(videoId: string): Promise<string> {
|
||||||
const hostname = resolveBunnyCdnHostname();
|
const hostname = resolveBunnyCdnHostname();
|
||||||
|
if (!hostname) return '';
|
||||||
const playlistUrl = `https://${hostname}/${videoId}/playlist.m3u8`;
|
const playlistUrl = `https://${hostname}/${videoId}/playlist.m3u8`;
|
||||||
|
|
||||||
let playlistHeights: number[] = [];
|
let playlistHeights: number[] = [];
|
||||||
@@ -106,6 +102,7 @@ async function resolveHighestBunnyMp4Url(videoId: string): Promise<string> {
|
|||||||
|
|
||||||
async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloadSource | null> {
|
async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloadSource | null> {
|
||||||
const originalUrl = buildBunnyOriginalUrl(videoId);
|
const originalUrl = buildBunnyOriginalUrl(videoId);
|
||||||
|
if (!originalUrl) return null;
|
||||||
if (await isRemoteFileAvailable(originalUrl)) {
|
if (await isRemoteFileAvailable(originalUrl)) {
|
||||||
return {
|
return {
|
||||||
sourceType: 'original',
|
sourceType: 'original',
|
||||||
@@ -118,8 +115,17 @@ async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloa
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveBunnyCompressedSource(videoId: string, requestedQuality: number | null): Promise<BunnyDownloadSource> {
|
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) {
|
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)) {
|
if (await isRemoteFileAvailable(requestedUrl)) {
|
||||||
return {
|
return {
|
||||||
sourceType: 'compressed',
|
sourceType: 'compressed',
|
||||||
@@ -130,6 +136,13 @@ async function resolveBunnyCompressedSource(videoId: string, requestedQuality: n
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fallbackUrl = await resolveHighestBunnyMp4Url(videoId);
|
const fallbackUrl = await resolveHighestBunnyMp4Url(videoId);
|
||||||
|
if (!fallbackUrl) {
|
||||||
|
return {
|
||||||
|
sourceType: 'compressed',
|
||||||
|
quality: null,
|
||||||
|
url: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
sourceType: 'compressed',
|
sourceType: 'compressed',
|
||||||
quality: extractHeightFromBunnyMp4Url(fallbackUrl),
|
quality: extractHeightFromBunnyMp4Url(fallbackUrl),
|
||||||
@@ -163,6 +176,8 @@ export async function resolveBunnyDownloadSource(
|
|||||||
requestedQuality: number | null,
|
requestedQuality: number | null,
|
||||||
preference: BunnyDownloadSourcePreference
|
preference: BunnyDownloadSourcePreference
|
||||||
): Promise<BunnyDownloadSource | null> {
|
): Promise<BunnyDownloadSource | null> {
|
||||||
|
if (!resolveBunnyCdnHostname()) return null;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const cacheKey = buildSourceCacheKey(videoId, requestedQuality, preference);
|
const cacheKey = buildSourceCacheKey(videoId, requestedQuality, preference);
|
||||||
const cached = getCachedSource(cacheKey, now);
|
const cached = getCachedSource(cacheKey, now);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { VideoProvider, VideoMetadata, EmbedOptions } from './types';
|
import type { VideoProvider, VideoMetadata, EmbedOptions } from './types';
|
||||||
import { getCachedMetadata, setCachedMetadata } from './metadata-cache';
|
import { getCachedMetadata, setCachedMetadata } from './metadata-cache';
|
||||||
|
import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
|
|
||||||
// Bunny Stream URL patterns
|
// Bunny Stream URL patterns
|
||||||
// e.g. https://iframe.mediadelivery.net/play/libraryId/videoId
|
// e.g. https://iframe.mediadelivery.net/play/libraryId/videoId
|
||||||
@@ -44,11 +45,9 @@ export const bunnyProvider: VideoProvider = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getThumbnailUrl(videoId: string): string {
|
getThumbnailUrl(videoId: string): string {
|
||||||
// Bunny stream thumbnails: https://vz-uuid.b-cdn.net/{videoId}/thumbnail.jpg
|
const bunnyCdnHostname = resolveServerBunnyCdnHostname();
|
||||||
// Since we don't have the b-cdn pull zone readily available in pure abstract,
|
if (!bunnyCdnHostname) return '';
|
||||||
// we should rely on fetching metadata for actual thumbnails, OR construct via API
|
return `https://${bunnyCdnHostname}/${videoId}/thumbnail.jpg`;
|
||||||
// Actually, Bunny's public thumbnail format is:
|
|
||||||
return `https://vz-965f4f4a-fc1.b-cdn.net/${videoId}/thumbnail.jpg`; // Fallback approximate
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getMetadata(videoId: string): Promise<VideoMetadata> {
|
async getMetadata(videoId: string): Promise<VideoMetadata> {
|
||||||
|
|||||||
+22
-7
@@ -1,14 +1,29 @@
|
|||||||
import type { NextConfig } from "next";
|
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 = {
|
const nextConfig: NextConfig = {
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
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' },
|
|
||||||
],
|
|
||||||
formats: ['image/avif', 'image/webp'],
|
formats: ['image/avif', 'image/webp'],
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
|
|||||||
Reference in New Issue
Block a user