feat: Add useCloudStorageUrl hook for cloud storage paths

This commit is contained in:
Peifan Li
2025-12-19 14:22:12 -05:00
parent b3ae1310a2
commit ace6793cdf
14 changed files with 332 additions and 114 deletions

View File

@@ -0,0 +1,42 @@
import { Request, Response } from "express";
import { ValidationError } from "../errors/DownloadErrors";
import { CloudStorageService } from "../services/CloudStorageService";
/**
* Get signed URL for a cloud storage file
* GET /api/cloud/signed-url?filename=xxx&type=video|thumbnail
*/
export const getSignedUrl = async (
req: Request,
res: Response
): Promise<void> => {
const { filename, type } = req.query;
if (!filename || typeof filename !== "string") {
throw new ValidationError("filename is required", "filename");
}
if (type && type !== "video" && type !== "thumbnail") {
throw new ValidationError(
"type must be 'video' or 'thumbnail'",
"type"
);
}
const fileType = (type as "video" | "thumbnail") || "video";
const signedUrl = await CloudStorageService.getSignedUrl(filename, fileType);
if (!signedUrl) {
res.status(404).json({
success: false,
message: "File not found in cloud storage or cloud storage not configured",
});
return;
}
res.status(200).json({
success: true,
url: signedUrl,
});
};

View File

@@ -1,5 +1,6 @@
import express from "express";
import * as cleanupController from "../controllers/cleanupController";
import * as cloudStorageController from "../controllers/cloudStorageController";
import * as collectionController from "../controllers/collectionController";
import * as downloadController from "../controllers/downloadController";
import * as scanController from "../controllers/scanController";
@@ -123,4 +124,10 @@ router.delete(
asyncHandler(subscriptionController.deleteSubscription)
);
// Cloud storage routes
router.get(
"/cloud/signed-url",
asyncHandler(cloudStorageController.getSignedUrl)
);
export default router;

View File

@@ -120,38 +120,27 @@ export class CloudStorageService {
}
logger.info(`[CloudStorage] Local file cleanup completed`);
// Update video record to point to cloud storage URLs with sign information
// Update video record to point to cloud storage (store only filename, not full URL with sign)
// Sign will be retrieved dynamically when needed
if (videoData.id && deletedFiles.length > 0) {
try {
const storageService = await import("./storageService");
const updates: any = {};
// Get file list from Openlist to retrieve sign information
const fileUrls = await this.getFileUrlsWithSign(
config,
videoData.videoFilename,
videoData.thumbnailFilename
);
// Update video path if video was deleted
if (videoData.videoFilename && fileUrls.videoUrl) {
updates.videoPath = fileUrls.videoUrl;
// Store cloud storage indicator in path format: "cloud:filename"
// This allows us to identify cloud storage files and retrieve sign dynamically
if (videoData.videoFilename) {
updates.videoPath = `cloud:${videoData.videoFilename}`;
}
// Update thumbnail path if thumbnail was deleted
if (videoData.thumbnailFilename) {
if (fileUrls.thumbnailUrl) {
updates.thumbnailPath = fileUrls.thumbnailUrl;
} else if (fileUrls.thumbnailThumbUrl) {
// Use thumb URL if file doesn't exist but thumb is available
updates.thumbnailPath = fileUrls.thumbnailThumbUrl;
}
updates.thumbnailPath = `cloud:${videoData.thumbnailFilename}`;
}
if (Object.keys(updates).length > 0) {
storageService.updateVideo(videoData.id, updates);
logger.info(
`[CloudStorage] Updated video record ${videoData.id} with cloud storage paths`
`[CloudStorage] Updated video record ${videoData.id} with cloud storage indicators`
);
}
} catch (updateError: any) {
@@ -344,9 +333,45 @@ export class CloudStorageService {
return filename.replace(/[^a-z0-9]/gi, "_").toLowerCase();
}
/**
* Get signed URL for a cloud storage file
* Returns URL in format: https://domain/d/path/filename?sign=xxx
* @param filename - The filename to get signed URL for
* @param fileType - 'video' or 'thumbnail'
*/
static async getSignedUrl(
filename: string,
fileType: "video" | "thumbnail" = "video"
): Promise<string | null> {
const config = this.getConfig();
if (!config.enabled || !config.apiUrl || !config.token) {
return null;
}
try {
const result = await this.getFileUrlsWithSign(
config,
fileType === "video" ? filename : undefined,
fileType === "thumbnail" ? filename : undefined
);
if (fileType === "video") {
return result.videoUrl || null;
} else {
return result.thumbnailUrl || result.thumbnailThumbUrl || null;
}
} catch (error) {
logger.error(
`[CloudStorage] Failed to get signed URL for ${filename}:`,
error instanceof Error ? error : new Error(String(error))
);
return null;
}
}
/**
* Get file URLs with sign information from Openlist
* Returns URLs in format: https://域名/d/上传路径/文件名?sign=xxx
* Returns URLs in format: https://domain/d/path/filename?sign=xxx
*/
private static async getFileUrlsWithSign(
config: CloudDriveConfig,
@@ -412,7 +437,7 @@ export class CloudStorageService {
(file: any) => file.name === videoFilename
);
if (videoFile && videoFile.sign) {
// Build URL: https://域名/d/上传路径/文件名?sign=xxx
// Build URL: https://domain/d/path/filename?sign=xxx
// Only encode the filename, not the path
const encodedFilename = encodeURIComponent(videoFilename);
result.videoUrl = `${domain}/d${uploadPath}/${encodedFilename}?sign=${encodeURIComponent(
@@ -429,7 +454,7 @@ export class CloudStorageService {
if (thumbnailFile) {
// Prefer file URL with sign if available
if (thumbnailFile.sign) {
// Build URL: https://域名/d/上传路径/文件名?sign=xxx
// Build URL: https://domain/d/path/filename?sign=xxx
const encodedFilename = encodeURIComponent(thumbnailFilename);
result.thumbnailUrl = `${domain}/d${uploadPath}/${encodedFilename}?sign=${encodeURIComponent(
thumbnailFile.sign

View File

@@ -11,10 +11,9 @@ import {
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import { useCloudStorageUrl } from '../hooks/useCloudStorageUrl';
import { Collection, Video } from '../types';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
interface CollectionCardProps {
collection: Collection;
videos: Video[];
@@ -35,14 +34,6 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection, videos }) =
navigate(`/collection/${collection.id}`);
};
const getThumbnailSrc = (video: Video) => {
return video.thumbnailPath
? (video.thumbnailPath.startsWith("http://") || video.thumbnailPath.startsWith("https://")
? video.thumbnailPath
: `${BACKEND_URL}${video.thumbnailPath}`)
: video.thumbnailUrl;
};
return (
<Card
sx={{
@@ -74,33 +65,7 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection, videos }) =
>
{collectionVideos.length > 0 ? (
collectionVideos.map((video, index) => (
<Box
key={video.id}
sx={{
width: '50%',
height: '50%',
position: 'relative',
borderRight: index % 2 === 0 ? '1px solid rgba(255,255,255,0.1)' : 'none',
borderBottom: index < 2 ? '1px solid rgba(255,255,255,0.1)' : 'none',
overflow: 'hidden'
}}
>
<CardMedia
component="img"
image={getThumbnailSrc(video) || 'https://via.placeholder.com/240x180?text=No+Thumbnail'}
alt={video.title}
sx={{
width: '100%',
height: '100%',
objectFit: 'cover'
}}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.onerror = null;
target.src = 'https://via.placeholder.com/240x180?text=No+Thumbnail';
}}
/>
</Box>
<CollectionThumbnail key={video.id} video={video} index={index} />
))
) : (
<Box sx={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
@@ -134,4 +99,41 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection, videos }) =
);
};
// Component for individual thumbnail with cloud storage support
const CollectionThumbnail: React.FC<{ video: Video; index: number }> = ({ video, index }) => {
const thumbnailUrl = useCloudStorageUrl(video.thumbnailPath, 'thumbnail');
const src = thumbnailUrl || video.thumbnailUrl || 'https://via.placeholder.com/240x180?text=No+Thumbnail';
return (
<Box
sx={{
width: '50%',
height: '50%',
position: 'relative',
borderRight: index % 2 === 0 ? '1px solid rgba(255,255,255,0.1)' : 'none',
borderBottom: index < 2 ? '1px solid rgba(255,255,255,0.1)' : 'none',
overflow: 'hidden'
}}
>
<CardMedia
component="img"
image={src}
alt={video.title}
sx={{
width: '100%',
height: '100%',
objectFit: 'cover'
}}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.onerror = null;
target.src = 'https://via.placeholder.com/240x180?text=No+Thumbnail';
}}
/>
</Box>
);
};
export default CollectionCard;

View File

@@ -30,11 +30,27 @@ import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useLanguage } from '../../contexts/LanguageContext';
import { useVisitorMode } from '../../contexts/VisitorModeContext';
import { useCloudStorageUrl } from '../../hooks/useCloudStorageUrl';
import { Video } from '../../types';
import { formatDuration, formatSize } from '../../utils/formatUtils';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
// Component for thumbnail with cloud storage support
const ThumbnailImage: React.FC<{ video: Video }> = ({ video }) => {
const thumbnailUrl = useCloudStorageUrl(video.thumbnailPath, 'thumbnail');
const src = thumbnailUrl || video.thumbnailUrl || 'https://via.placeholder.com/120x90?text=No+Thumbnail';
return (
<Box
component="img"
src={src}
alt={video.title}
sx={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 1 }}
/>
);
};
interface VideosTableProps {
displayedVideos: Video[];
totalVideosCount: number;
@@ -100,14 +116,6 @@ const VideosTable: React.FC<VideosTableProps> = ({
setEditTitle('');
};
const getThumbnailSrc = (video: Video) => {
if (video.thumbnailPath) {
return video.thumbnailPath.startsWith("http://") || video.thumbnailPath.startsWith("https://")
? video.thumbnailPath
: `${BACKEND_URL}${video.thumbnailPath}`;
}
return video.thumbnailUrl || 'https://via.placeholder.com/120x90?text=No+Thumbnail';
};
return (
<Box>
@@ -177,12 +185,7 @@ const VideosTable: React.FC<VideosTableProps> = ({
<TableCell sx={{ width: 140 }}>
<Box sx={{ position: 'relative', width: 120, height: 68 }}>
<Link to={`/video/${video.id}`} style={{ display: 'block', width: '100%', height: '100%' }}>
<Box
component="img"
src={getThumbnailSrc(video)}
alt={video.title}
sx={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 1 }}
/>
<ThumbnailImage video={video} />
</Link>
{!visitorMode && (
<Tooltip title={t('refreshThumbnail') || "Refresh Thumbnail"}>

View File

@@ -20,14 +20,13 @@ import { useCollection } from '../contexts/CollectionContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useSnackbar } from '../contexts/SnackbarContext'; // Added
import { useShareVideo } from '../hooks/useShareVideo'; // Added
import { useCloudStorageUrl } from '../hooks/useCloudStorageUrl';
import { Collection, Video } from '../types';
import { formatDuration, parseDuration } from '../utils/formatUtils';
import CollectionModal from './CollectionModal';
import ConfirmationModal from './ConfirmationModal';
import VideoKebabMenuButtons from './VideoPlayer/VideoInfo/VideoKebabMenuButtons'; // Added
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
interface VideoCardProps {
video: Video;
collections?: Collection[];
@@ -90,12 +89,12 @@ const VideoCard: React.FC<VideoCardProps> = ({
// Use local thumbnail if available, otherwise fall back to the original URL
const thumbnailSrc = video.thumbnailPath
? (video.thumbnailPath.startsWith("http://") || video.thumbnailPath.startsWith("https://")
? video.thumbnailPath
: `${BACKEND_URL}${video.thumbnailPath}`)
: video.thumbnailUrl;
// Use cloud storage hook for thumbnail URL
const thumbnailUrl = useCloudStorageUrl(video.thumbnailPath, 'thumbnail');
const thumbnailSrc = thumbnailUrl || video.thumbnailUrl;
// Use cloud storage hook for video URL
const videoUrl = useCloudStorageUrl(video.videoPath, 'video');
// Handle author click
const handleAuthorClick = (e: React.MouseEvent) => {
@@ -295,13 +294,11 @@ const VideoCard: React.FC<VideoCardProps> = ({
>
<Box sx={{ position: 'relative', paddingTop: '56.25%' /* 16:9 aspect ratio */ }}>
{/* Video Element (only shown on hover) */}
{isHovered && video.videoPath && (
{isHovered && videoUrl && (
<Box
component="video"
ref={videoRef}
src={video.videoPath.startsWith("http://") || video.videoPath.startsWith("https://")
? video.videoPath
: `${BACKEND_URL}${video.videoPath}`}
src={videoUrl}
muted
autoPlay
playsInline

View File

@@ -20,11 +20,10 @@ import {
import React, { useState } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import { useVisitorMode } from '../../contexts/VisitorModeContext';
import { useCloudStorageUrl } from '../../hooks/useCloudStorageUrl';
import { Video } from '../../types';
import { formatDate, formatDuration } from '../../utils/formatUtils';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
interface UpNextSidebarProps {
relatedVideos: Video[];
autoPlayNext: boolean;
@@ -35,6 +34,7 @@ interface UpNextSidebarProps {
const SidebarThumbnail: React.FC<{ video: Video }> = ({ video }) => {
const [isImageLoaded, setIsImageLoaded] = useState(false);
const thumbnailUrl = useCloudStorageUrl(video.thumbnailPath, 'thumbnail');
return (
<Box sx={{ width: 168, minWidth: 168, position: 'relative' }}>
@@ -63,9 +63,7 @@ const SidebarThumbnail: React.FC<{ video: Video }> = ({ video }) => {
// The image is always rendered but hidden until loaded
}}
onLoad={() => setIsImageLoaded(true)}
image={video.thumbnailPath && (video.thumbnailPath.startsWith("http://") || video.thumbnailPath.startsWith("https://"))
? video.thumbnailPath
: `${BACKEND_URL}${video.thumbnailPath}`}
image={thumbnailUrl || video.thumbnailUrl || 'https://via.placeholder.com/168x94?text=No+Thumbnail'}
alt={video.title}
onError={(e) => {
setIsImageLoaded(true);

View File

@@ -1,6 +1,7 @@
import { Alert, Box, Divider, Stack } from '@mui/material';
import React from 'react';
import { useVideoResolution } from '../../hooks/useVideoResolution';
import { useCloudStorageUrl } from '../../hooks/useCloudStorageUrl';
import { Collection, Video } from '../../types';
import EditableTitle from './VideoInfo/EditableTitle';
import VideoActionButtons from './VideoInfo/VideoActionButtons';
@@ -48,18 +49,15 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
onUnsubscribe
}) => {
const { videoRef, videoResolution } = useVideoResolution(video);
const videoUrl = useCloudStorageUrl(video.videoPath, 'video');
return (
<Box sx={{ mt: 2 }}>
{/* Hidden video element to get resolution */}
{(video.videoPath || video.sourceUrl) && (
{(videoUrl || video.sourceUrl) && (
<video
ref={videoRef}
src={video.videoPath
? (video.videoPath.startsWith("http://") || video.videoPath.startsWith("https://")
? video.videoPath
: `${BACKEND_URL}${video.videoPath}`)
: video.sourceUrl}
src={videoUrl || video.sourceUrl}
style={{
position: 'absolute',
width: '1px',

View File

@@ -5,6 +5,7 @@ import { useLanguage } from '../../../contexts/LanguageContext';
import { useSnackbar } from '../../../contexts/SnackbarContext';
import { useVisitorMode } from '../../../contexts/VisitorModeContext';
import { useShareVideo } from '../../../hooks/useShareVideo';
import { useCloudStorageUrl } from '../../../hooks/useCloudStorageUrl';
import { Video } from '../../../types';
import VideoKebabMenuButtons from './VideoKebabMenuButtons';
@@ -28,8 +29,15 @@ const VideoActionButtons: React.FC<VideoActionButtonsProps> = ({
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [playerMenuAnchor, setPlayerMenuAnchor] = useState<null | HTMLElement>(null);
const videoUrl = useCloudStorageUrl(video.videoPath, 'video');
const getVideoUrl = (): string => {
// If we have a cloud storage URL, use it directly
if (videoUrl) {
return videoUrl;
}
// Otherwise, construct URL from videoPath
if (video.videoPath) {
const videoPath = video.videoPath.startsWith('/') ? video.videoPath : `/${video.videoPath}`;
@@ -38,7 +46,7 @@ const VideoActionButtons: React.FC<VideoActionButtonsProps> = ({
// when accessed remotely, so window.location.origin is the correct base URL
return `${window.location.origin}${videoPath}`;
}
return video.sourceUrl;
return video.sourceUrl || '';
};
const handlePlayerMenuOpen = (event: React.MouseEvent<HTMLElement>) => {

View File

@@ -2,6 +2,7 @@ import { CalendarToday, Download, Folder, HighQuality, Link as LinkIcon, VideoLi
import { Box, Typography, useTheme } from '@mui/material';
import React from 'react';
import { useLanguage } from '../../../contexts/LanguageContext';
import { useCloudStorageUrl } from '../../../hooks/useCloudStorageUrl';
import { Collection, Video } from '../../../types';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
@@ -21,6 +22,7 @@ const VideoMetadata: React.FC<VideoMetadataProps> = ({
}) => {
const theme = useTheme();
const { t } = useLanguage();
const videoUrl = useCloudStorageUrl(video.videoPath, 'video');
return (
<Box sx={{ bgcolor: 'background.paper', p: 2, borderRadius: 2 }}>
@@ -40,7 +42,7 @@ const VideoMetadata: React.FC<VideoMetadataProps> = ({
</a>
</Typography>
)}
{video.videoPath && (
{(videoUrl || video.videoPath) && (
<Typography
variant="body2"
sx={{
@@ -49,9 +51,9 @@ const VideoMetadata: React.FC<VideoMetadataProps> = ({
fontSize: { xs: '0.75rem', sm: '0.875rem' }
}}
>
<a href={video.videoPath && (video.videoPath.startsWith("http://") || video.videoPath.startsWith("https://"))
<a href={videoUrl || (video.videoPath && (video.videoPath.startsWith("http://") || video.videoPath.startsWith("https://"))
? video.videoPath
: `${BACKEND_URL}${video.videoPath}`} download style={{ color: theme.palette.primary.main, textDecoration: 'none', display: 'flex', alignItems: 'center' }}>
: `${BACKEND_URL}${video.videoPath}`)} download style={{ color: theme.palette.primary.main, textDecoration: 'none', display: 'flex', alignItems: 'center' }}>
<Download sx={{ mr: 0.5, fontSize: { xs: '0.875rem', sm: '1rem' } }} />
<strong>{t('download')}</strong>
</a>

View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from 'react';
import { getFileUrl, isCloudStoragePath } from '../utils/cloudStorage';
/**
* Hook to get file URL, handling cloud storage paths dynamically
* Returns the URL string, or undefined if not available
*/
export const useCloudStorageUrl = (
path: string | null | undefined,
type: 'video' | 'thumbnail' = 'video'
): string | undefined => {
const [url, setUrl] = useState<string | undefined>(undefined);
useEffect(() => {
if (!path) {
setUrl(undefined);
return;
}
// If already a full URL, use it directly
if (path.startsWith('http://') || path.startsWith('https://')) {
setUrl(path);
return;
}
// If cloud storage path, fetch signed URL
if (isCloudStoragePath(path)) {
getFileUrl(path, type).then((signedUrl) => {
setUrl(signedUrl);
});
} else {
// Regular path, construct URL synchronously
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:5551';
setUrl(`${BACKEND_URL}${path}`);
}
}, [path, type]);
return url;
};

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { Video } from '../types';
import { useCloudStorageUrl } from './useCloudStorageUrl';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
@@ -51,23 +52,19 @@ export const formatResolution = (video: Video): string | null => {
export const useVideoResolution = (video: Video) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [detectedResolution, setDetectedResolution] = useState<string | null>(null);
const videoUrl = useCloudStorageUrl(video.videoPath, 'video');
useEffect(() => {
const videoElement = videoRef.current;
const videoSrc = video.videoPath || video.sourceUrl;
const videoSrc = videoUrl || video.sourceUrl;
if (!videoElement || !videoSrc) {
setDetectedResolution(null);
return;
}
// Set the video source
const fullVideoUrl = video.videoPath
? (video.videoPath.startsWith("http://") || video.videoPath.startsWith("https://")
? video.videoPath
: `${BACKEND_URL}${video.videoPath}`)
: video.sourceUrl;
if (videoElement.src !== fullVideoUrl) {
videoElement.src = fullVideoUrl;
if (videoElement.src !== videoSrc) {
videoElement.src = videoSrc;
videoElement.load(); // Force reload
}
@@ -104,7 +101,7 @@ export const useVideoResolution = (video: Video) => {
videoElement.removeEventListener('loadedmetadata', handleLoadedMetadata);
videoElement.removeEventListener('error', handleError);
};
}, [video.videoPath, video.sourceUrl, video.id]);
}, [videoUrl, video.sourceUrl, video.id]);
// Try to get resolution from video object first, fallback to detected resolution
const resolutionFromObject = formatResolution(video);

View File

@@ -21,6 +21,7 @@ import { useCollection } from '../contexts/CollectionContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useSnackbar } from '../contexts/SnackbarContext';
import { useVideo } from '../contexts/VideoContext';
import { useCloudStorageUrl } from '../hooks/useCloudStorageUrl';
import { Collection, Video } from '../types';
import { getRecommendations } from '../utils/recommendations';
const API_URL = import.meta.env.VITE_API_URL;
@@ -105,6 +106,9 @@ const VideoPlayer: React.FC = () => {
const availableTags = Array.isArray(settings?.tags) ? settings.tags : [];
const subtitlesEnabled = settings?.subtitlesEnabled ?? true;
// Get cloud storage URLs
const videoUrl = useCloudStorageUrl(video?.videoPath, 'video');
// Fetch comments
const { data: comments = [], isLoading: loadingComments } = useQuery({
queryKey: ['comments', id],
@@ -539,11 +543,7 @@ const VideoPlayer: React.FC = () => {
{/* Main Content Column */}
<Grid size={{ xs: 12, lg: 8 }}>
<VideoControls
src={video.videoPath
? (video.videoPath.startsWith("http://") || video.videoPath.startsWith("https://")
? video.videoPath
: `${BACKEND_URL}${video.videoPath}`)
: video.sourceUrl}
src={videoUrl || video?.sourceUrl}
autoPlay={autoPlay}
autoLoop={autoLoop}
onTimeUpdate={handleTimeUpdate}

View File

@@ -0,0 +1,99 @@
import axios from 'axios';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:5551';
/**
* Check if a path is a cloud storage path (starts with "cloud:")
*/
export const isCloudStoragePath = (path: string | null | undefined): boolean => {
return path?.startsWith('cloud:') ?? false;
};
/**
* Extract filename from cloud storage path (removes "cloud:" prefix)
*/
export const extractCloudFilename = (path: string): string => {
if (!path.startsWith('cloud:')) {
return path;
}
return path.substring(6); // Remove "cloud:" prefix
};
/**
* Get signed URL for a cloud storage file
* This fetches the dynamic sign from the backend
*/
export const getCloudStorageSignedUrl = async (
filename: string,
type: 'video' | 'thumbnail' = 'video'
): Promise<string | null> => {
try {
const response = await axios.get(`${BACKEND_URL}/api/cloud/signed-url`, {
params: {
filename,
type,
},
});
if (response.data?.success && response.data?.url) {
return response.data.url;
}
return null;
} catch (error) {
console.error('Failed to get cloud storage signed URL:', error);
return null;
}
};
/**
* Get file URL, handling both local files and cloud storage
* For cloud storage paths (starting with "cloud:"), fetches signed URL dynamically
* For regular paths, returns the full URL with backend prefix
* For already full URLs (http:// or https://), returns as is
*/
export const getFileUrl = async (
path: string | null | undefined,
type: 'video' | 'thumbnail' = 'video'
): Promise<string | undefined> => {
if (!path) return undefined;
// If already a full URL, return as is
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
// If cloud storage path, fetch signed URL
if (isCloudStoragePath(path)) {
const filename = extractCloudFilename(path);
const signedUrl = await getCloudStorageSignedUrl(filename, type);
return signedUrl || undefined;
}
// Otherwise, prepend backend URL
return `${BACKEND_URL}${path}`;
};
/**
* Synchronous version that returns a URL string (for cases where async is not possible)
* For cloud storage, returns a placeholder that will need to be handled separately
*/
export const getFileUrlSync = (
path: string | null | undefined
): string | undefined => {
if (!path) return undefined;
// If already a full URL, return as is
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
// If cloud storage path, return a special marker that components can detect
// Components should use getFileUrl() async version for cloud storage
if (isCloudStoragePath(path)) {
return `cloud:${extractCloudFilename(path)}`;
}
// Otherwise, prepend backend URL
return `${BACKEND_URL}${path}`;
};