feat: Add useCloudStorageUrl hook for cloud storage paths
This commit is contained in:
42
backend/src/controllers/cloudStorageController.ts
Normal file
42
backend/src/controllers/cloudStorageController.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
40
frontend/src/hooks/useCloudStorageUrl.ts
Normal file
40
frontend/src/hooks/useCloudStorageUrl.ts
Normal 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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
99
frontend/src/utils/cloudStorage.ts
Normal file
99
frontend/src/utils/cloudStorage.ts
Normal 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}`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user