feat: Add Cloudflare Tunnel settings and service

This commit is contained in:
Peifan Li
2025-12-25 17:32:29 -05:00
parent 18dda72280
commit 508daaef7b
19 changed files with 575 additions and 43 deletions

View File

@@ -4,14 +4,29 @@ FROM node:22-alpine AS builder
WORKDIR /app
# Install dependencies
COPY package*.json ./
# Install dependencies
COPY backend/package*.json ./
# Skip Puppeteer download during build as we only need to compile TS
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
# Install build dependencies for native modules (python3, make, g++)
RUN apk add --no-cache python3 make g++
RUN npm ci
COPY . .
# Copy backend source
COPY backend/ .
# Copy frontend source for building
COPY frontend/ /app/frontend/
# Build frontend
WORKDIR /app/frontend
# Install frontend dependencies
RUN npm ci
# Build frontend with relative paths
ENV VITE_API_URL=/api
ENV VITE_BACKEND_URL=
RUN npm run build
WORKDIR /app
# Build bgutil-ytdlp-pot-provider
WORKDIR /app/bgutil-ytdlp-pot-provider/server
@@ -42,6 +57,11 @@ RUN apk add --no-cache \
RUN curl -fsSL https://deno.land/install.sh | sh -s -- -y && \
ln -sf /root/.deno/bin/deno /usr/local/bin/deno
# Install cloudflared (Binary download)
ARG TARGETARCH
RUN curl -L --output /usr/local/bin/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${TARGETARCH:-amd64} && \
chmod +x /usr/local/bin/cloudflared
# Install yt-dlp, bgutil-ytdlp-pot-provider, and yt-dlp-ejs for YouTube n challenge solving
RUN pip3 install yt-dlp bgutil-ytdlp-pot-provider yt-dlp-ejs --break-system-packages
@@ -52,11 +72,13 @@ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
# Install production dependencies only
COPY package*.json ./
COPY backend/package*.json ./
RUN npm ci --only=production
# Copy built artifacts from builder
COPY --from=builder /app/dist ./dist
# Copy frontend build
COPY --from=builder /app/frontend/dist ./frontend/dist
# Copy drizzle migrations
COPY --from=builder /app/drizzle ./drizzle
# Copy bgutil-ytdlp-pot-provider

View File

@@ -3,11 +3,12 @@ import { Request, Response } from "express";
import fs from "fs-extra";
import path from "path";
import {
COLLECTIONS_DATA_PATH,
STATUS_DATA_PATH,
VIDEOS_DATA_PATH,
COLLECTIONS_DATA_PATH,
STATUS_DATA_PATH,
VIDEOS_DATA_PATH,
} from "../config/paths";
import { NotFoundError, ValidationError } from "../errors/DownloadErrors";
import { cloudflaredService } from "../services/cloudflaredService";
import downloadManager from "../services/downloadManager";
import * as loginAttemptService from "../services/loginAttemptService";
import * as storageService from "../services/storageService";
@@ -41,6 +42,8 @@ interface Settings {
visitorMode?: boolean;
infiniteScroll?: boolean;
videoColumns?: number;
cloudflaredTunnelEnabled?: boolean;
cloudflaredToken?: string;
}
const defaultSettings: Settings = {
@@ -310,6 +313,40 @@ export const updateSettings = async (
}
}
// Handle Cloudflare Tunnel settings changes
if (
newSettings.cloudflaredTunnelEnabled !==
existingSettings.cloudflaredTunnelEnabled ||
newSettings.cloudflaredToken !== existingSettings.cloudflaredToken
) {
// If we are enabling it (or it was enabled and config changed)
if (newSettings.cloudflaredTunnelEnabled) {
// Determine port
const port = process.env.PORT ? parseInt(process.env.PORT) : 5551;
const shouldRestart = existingSettings.cloudflaredTunnelEnabled;
if (shouldRestart) {
// If it was already enabled, we need to restart to apply changes (Token -> No Token, or vice versa)
if (newSettings.cloudflaredToken) {
cloudflaredService.restart(newSettings.cloudflaredToken);
} else {
cloudflaredService.restart(undefined, port);
}
} else {
// It was disabled, now enabling -> just start
if (newSettings.cloudflaredToken) {
cloudflaredService.start(newSettings.cloudflaredToken);
} else {
cloudflaredService.start(undefined, port);
}
}
} else {
// If disabled, stop
cloudflaredService.stop();
}
}
// Apply settings immediately where possible
downloadManager.setMaxConcurrentDownloads(newSettings.maxConcurrentDownloads);
@@ -873,3 +910,16 @@ export const restoreFromLastBackup = async (
throw error;
}
};
/**
* Get Cloudflare Tunnel status
* Errors are automatically handled by asyncHandler middleware
*/
export const getCloudflaredStatus = async (
_req: Request,
res: Response
): Promise<void> => {
const status = cloudflaredService.getStatus();
res.json(status);
};

View File

@@ -2,22 +2,23 @@ import express from "express";
import multer from "multer";
import os from "os";
import {
checkCookies,
cleanupBackupDatabases,
deleteCookies,
deleteLegacyData,
exportDatabase,
formatFilenames,
getLastBackupInfo,
getPasswordEnabled,
getSettings,
importDatabase,
migrateData,
restoreFromLastBackup,
resetPassword,
updateSettings,
uploadCookies,
verifyPassword,
checkCookies,
cleanupBackupDatabases,
deleteCookies,
deleteLegacyData,
exportDatabase,
formatFilenames,
getCloudflaredStatus,
getLastBackupInfo,
getPasswordEnabled,
getSettings,
importDatabase,
migrateData,
resetPassword,
restoreFromLastBackup,
updateSettings,
uploadCookies,
verifyPassword,
} from "../controllers/settingsController";
import { asyncHandler } from "../middleware/errorHandler";
@@ -48,5 +49,6 @@ router.post(
router.post("/cleanup-backup-databases", asyncHandler(cleanupBackupDatabases));
router.get("/last-backup-info", asyncHandler(getLastBackupInfo));
router.post("/restore-from-last-backup", asyncHandler(restoreFromLastBackup));
router.get("/cloudflared/status", asyncHandler(getCloudflaredStatus));
export default router;

View File

@@ -5,17 +5,19 @@ dotenv.config();
import axios from "axios";
import cors from "cors";
import express from "express";
import path from "path";
import {
CLOUD_THUMBNAIL_CACHE_DIR,
IMAGES_DIR,
SUBTITLES_DIR,
VIDEOS_DIR,
CLOUD_THUMBNAIL_CACHE_DIR,
IMAGES_DIR,
SUBTITLES_DIR,
VIDEOS_DIR,
} from "./config/paths";
import { runMigrations } from "./db/migrate";
import { visitorModeMiddleware } from "./middleware/visitorModeMiddleware";
import { visitorModeSettingsMiddleware } from "./middleware/visitorModeSettingsMiddleware";
import apiRoutes from "./routes/api";
import settingsRoutes from "./routes/settingsRoutes";
import { cloudflaredService } from "./services/cloudflaredService";
import downloadManager from "./services/downloadManager";
import * as storageService from "./services/storageService";
import { logger } from "./utils/logger";
@@ -80,6 +82,10 @@ const startServer = async () => {
},
})
);
// Serve Frontend Static Files
const frontendDist = path.join(__dirname, "../../frontend/dist");
app.use(express.static(frontendDist));
// Cloud storage proxy endpoints
// Proxy /cloud/videos/* and /cloud/images/* to Alist API
@@ -206,6 +212,16 @@ const startServer = async () => {
// Use separate middleware for settings that allows disabling visitor mode
app.use("/api/settings", visitorModeSettingsMiddleware, settingsRoutes);
// SPA Fallback for Frontend
app.get("*", (req, res) => {
// Don't serve index.html for API calls that 404
if (req.path.startsWith('/api') || req.path.startsWith('/cloud')) {
res.status(404).send('Not Found');
return;
}
res.sendFile(path.join(frontendDist, "index.html"));
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
@@ -226,6 +242,18 @@ const startServer = async () => {
.catch((err) =>
console.error("Failed to start metadata service:", err)
);
// Start Cloudflared tunnel if enabled
const settings = storageService.getSettings();
if (settings.cloudflaredTunnelEnabled) {
if (settings.cloudflaredToken) {
cloudflaredService.start(settings.cloudflaredToken);
} else {
// Quick Tunnel
const port = typeof PORT === 'string' ? parseInt(PORT) : PORT;
cloudflaredService.start(undefined, port);
}
}
});
} catch (error) {
console.error("Failed to start server:", error);

View File

@@ -0,0 +1,121 @@
import { ChildProcess, spawn } from 'child_process';
import { logger } from '../utils/logger';
class CloudflaredService {
private process: ChildProcess | null = null;
private isRunning: boolean = false;
private tunnelId: string | null = null;
private accountTag: string | null = null;
private publicUrl: string | null = null;
private parseToken(token: string) {
try {
const buffer = Buffer.from(token, 'base64');
const decoded = JSON.parse(buffer.toString());
// Token format usually contains: a (account tag), t (tunnel id), s (secret)
this.accountTag = decoded.a || null;
this.tunnelId = decoded.t || null;
} catch (error) {
logger.error('Failed to parse Cloudflare token', error);
this.tunnelId = null;
this.accountTag = null;
}
}
public start(token?: string, port: number = 5551) {
if (this.isRunning) {
logger.info('Cloudflared service is already running.');
if (token) this.parseToken(token);
return;
}
this.publicUrl = null; // Reset URL
let args: string[] = [];
if (token) {
// Named Tunnel
this.parseToken(token);
logger.info(`Starting Cloudflared Named Tunnel (ID: ${this.tunnelId})...`);
args = ['tunnel', 'run', '--token', token];
} else {
// Quick Tunnel
this.tunnelId = null;
this.accountTag = null;
logger.info(`Starting Cloudflared Quick Tunnel on port ${port}...`);
args = ['tunnel', '--url', `http://localhost:${port}`];
}
try {
this.process = spawn('cloudflared', args);
const handleOutput = (data: Buffer) => {
const message = data.toString();
// Simple logging
logger.debug(`Cloudflared: ${message}`);
// Capture Quick Tunnel URL
// Example line: 2023-10-27T10:00:00Z INF | https://random-name.trycloudflare.com |
const urlMatch = message.match(/https?:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
if (urlMatch) {
this.publicUrl = urlMatch[0];
logger.info(`Cloudflared Quick Tunnel URL: ${this.publicUrl}`);
}
};
this.process.stdout?.on('data', handleOutput);
this.process.stderr?.on('data', handleOutput); // Cloudflared often logs to stderr
this.process.on('close', (code) => {
logger.info(`Cloudflared exited with code ${code}`);
this.isRunning = false;
this.process = null;
this.publicUrl = null;
});
this.process.on('error', (err) => {
logger.error('Failed to start Cloudflared process:', err);
this.isRunning = false;
this.process = null;
this.publicUrl = null;
});
this.isRunning = true;
logger.info('Cloudflared process spawned.');
} catch (error) {
logger.error('Error spawning cloudflared:', error);
}
}
public stop() {
if (this.process) {
logger.info('Stopping Cloudflared tunnel...');
this.process.kill();
this.process = null;
this.isRunning = false;
this.publicUrl = null;
logger.info('Cloudflared tunnel stopped.');
} else {
logger.info('No Cloudflared process is running to stop.');
}
}
public restart(token?: string, port: number = 5551) {
logger.info('Restarting Cloudflared tunnel...');
this.stop();
setTimeout(() => {
this.start(token, port);
}, 1000); // Wait a second before restarting
}
public getStatus() {
return {
isRunning: this.isRunning,
tunnelId: this.tunnelId,
accountTag: this.accountTag,
publicUrl: this.publicUrl
};
}
}
export const cloudflaredService = new CloudflaredService();

176
build-and-push-test.sh Executable file
View File

@@ -0,0 +1,176 @@
#!/bin/bash
set -e
DOCKER_PATH="/Applications/Docker.app/Contents/Resources/bin/docker"
USERNAME="franklioxygen"
# Default build arguments (can be overridden by environment variables)
VITE_API_URL=${VITE_API_URL:-"http://localhost:5551/api"}
VITE_BACKEND_URL=${VITE_BACKEND_URL:-"http://localhost:5551"}
# Define platforms to build
# Commented out arm64 as requested
PLATFORMS=("linux/amd64") # "linux/arm64")
# Tag definitions for TEST
BACKEND_TEST_AMD64="$USERNAME/mytube:backend-test-amd64"
# BACKEND_TEST_ARM64="$USERNAME/mytube:backend-test-arm64"
FRONTEND_TEST_AMD64="$USERNAME/mytube:frontend-test-amd64"
# FRONTEND_TEST_ARM64="$USERNAME/mytube:frontend-test-arm64"
# Ensure Docker is running
echo "🔍 Checking if Docker is running..."
$DOCKER_PATH ps > /dev/null 2>&1 || { echo "❌ Docker is not running. Please start Docker and try again."; exit 1; }
echo "✅ Docker is running!"
# Function to build backend for a specific platform
build_backend() {
local platform=$1
local tag=$2
echo "🏗️ Building backend for $platform..."
# Run build from root context to allow copying frontend files
# Use -f backend/Dockerfile to specify the Dockerfile path
$DOCKER_PATH build --no-cache --platform $platform -f backend/Dockerfile -t $tag .
echo "🚀 Pushing backend image: $tag"
$DOCKER_PATH push $tag
echo "🧹 Cleaning up local backend image: $tag"
$DOCKER_PATH rmi $tag
}
# Function to build frontend for a specific platform
build_frontend() {
local platform=$1
local tag=$2
echo "🏗️ Building frontend for $platform..."
cd frontend
$DOCKER_PATH build --no-cache --platform $platform \
--build-arg VITE_API_URL="$VITE_API_URL" \
--build-arg VITE_BACKEND_URL="$VITE_BACKEND_URL" \
-t $tag .
echo "🚀 Pushing frontend image: $tag"
$DOCKER_PATH push $tag
echo "🧹 Cleaning up local frontend image: $tag"
$DOCKER_PATH rmi $tag
cd ..
}
# Function to create and push manifest list
# create_and_push_manifest() {
# local manifest_tag=$1
# local image_amd64=$2
# local image_arm64=$3
#
# echo "📜 Creating manifest list: $manifest_tag"
# # Try to remove existing manifest first to avoid errors
# $DOCKER_PATH manifest rm $manifest_tag 2>/dev/null || true
#
# if ! $DOCKER_PATH manifest create $manifest_tag \
# --amend $image_amd64 \
# --amend $image_arm64 2>/dev/null; then
# echo "⚠️ Failed to create manifest list: $manifest_tag"
# echo " This might happen if the images are not yet available in the registry."
# echo " The platform-specific images are still available individually."
# return 1
# fi
#
# echo "🚀 Pushing manifest list: $manifest_tag"
# if ! $DOCKER_PATH manifest push $manifest_tag; then
# echo "⚠️ Failed to push manifest list: $manifest_tag"
# $DOCKER_PATH manifest rm $manifest_tag 2>/dev/null || true
# return 1
# fi
#
# echo "🧹 Cleaning up local manifest: $manifest_tag"
# $DOCKER_PATH manifest rm $manifest_tag 2>/dev/null || true
# return 0
# }
# Build for each platform
echo "🏗️ Building TEST images for multiple platforms with separate tags..."
echo "Platforms: ${PLATFORMS[*]}"
echo ""
# Build backend for all platforms
for platform in "${PLATFORMS[@]}"; do
if [ "$platform" = "linux/amd64" ]; then
build_backend "$platform" "$BACKEND_TEST_AMD64"
elif [ "$platform" = "linux/arm64" ]; then
# build_backend "$platform" "$BACKEND_TEST_ARM64"
echo "Skipping arm64"
fi
done
echo ""
# Build frontend for all platforms
for platform in "${PLATFORMS[@]}"; do
if [ "$platform" = "linux/amd64" ]; then
build_frontend "$platform" "$FRONTEND_TEST_AMD64"
elif [ "$platform" = "linux/arm64" ]; then
# build_frontend "$platform" "$FRONTEND_TEST_ARM64"
echo "Skipping arm64"
fi
done
echo ""
# Create and push manifests
echo "📦 Tagging and pushing main test tags (amd64)..."
# MANIFEST_ERRORS=0
# Backend
# if ! create_and_push_manifest "$USERNAME/mytube:backend-test" "$BACKEND_TEST_AMD64" "$BACKEND_TEST_ARM64"; then
# MANIFEST_ERRORS=$((MANIFEST_ERRORS + 1))
# fi
echo "🚀 Tagging and pushing backend test tag..."
$DOCKER_PATH pull "$BACKEND_TEST_AMD64" # Ensure we have it locally if it was cleaned up (though build cleaned it up, so we need to re-tag BEFORE cleanup or pull it again. Wait, build func cleans up.
# Ah, the build function cleans up: `$DOCKER_PATH rmi $tag`
# So $BACKEND_TEST_AMD64 is GONE locally.
# We need to re-pull it or NOT clean it up in the build function if we want to re-tag it.
# OR, we just tag it remotely? No, docker tag is local.
# We must pull it again or change the build function.
# Changing the build function is cleaner but risky if I don't want to change the structure too much.
# But calling pull is safe.
$DOCKER_PATH pull "$BACKEND_TEST_AMD64"
$DOCKER_PATH tag "$BACKEND_TEST_AMD64" "$USERNAME/mytube:backend-test"
$DOCKER_PATH push "$USERNAME/mytube:backend-test"
$DOCKER_PATH rmi "$USERNAME/mytube:backend-test"
# Frontend
# if ! create_and_push_manifest "$USERNAME/mytube:frontend-test" "$FRONTEND_TEST_AMD64" "$FRONTEND_TEST_ARM64"; then
# MANIFEST_ERRORS=$((MANIFEST_ERRORS + 1))
# fi
echo "🚀 Tagging and pushing frontend test tag..."
$DOCKER_PATH pull "$FRONTEND_TEST_AMD64"
$DOCKER_PATH tag "$FRONTEND_TEST_AMD64" "$USERNAME/mytube:frontend-test"
$DOCKER_PATH push "$USERNAME/mytube:frontend-test"
$DOCKER_PATH rmi "$USERNAME/mytube:frontend-test"
# if [ $MANIFEST_ERRORS -gt 0 ]; then
# echo ""
# echo "⚠️ Some manifest lists failed to create/push ($MANIFEST_ERRORS error(s))"
# echo " Platform-specific images are still available and can be used directly."
# fi
echo ""
echo "✅ Successfully built and pushed TEST images (amd64 only) to Docker Hub!"
echo ""
echo "Images:"
echo " - $USERNAME/mytube:backend-test (amd64)"
echo " - $USERNAME/mytube:frontend-test (amd64)"
echo ""
echo "Platform-specific images:"
echo " Backend:"
echo " - $BACKEND_TEST_AMD64"
# echo " - $BACKEND_TEST_ARM64"
echo " Frontend:"
echo " - $FRONTEND_TEST_AMD64"
# echo " - $FRONTEND_TEST_ARM64"

View File

@@ -38,8 +38,9 @@ build_backend() {
local version_tag=$3
echo "🏗️ Building backend for $platform..."
cd backend
$DOCKER_PATH build --no-cache --platform $platform -t $tag .
# Run build from root context to allow copying frontend files
# Use -f backend/Dockerfile to specify the Dockerfile path
$DOCKER_PATH build --no-cache --platform $platform -f backend/Dockerfile -t $tag .
if [ -n "$VERSION" ] && [ -n "$version_tag" ]; then
$DOCKER_PATH tag $tag $version_tag
@@ -58,8 +59,6 @@ build_backend() {
if [ -n "$VERSION" ] && [ -n "$version_tag" ]; then
$DOCKER_PATH rmi $version_tag
fi
cd ..
}
# Function to build frontend for a specific platform

View File

@@ -1,2 +1,2 @@
VITE_API_URL=http://localhost:5551/api
VITE_BACKEND_URL=http://localhost:5551
VITE_API_URL=/api
VITE_BACKEND_URL=

View File

@@ -105,8 +105,8 @@ const CollectionThumbnail: React.FC<{ video: Video; index: number }> = ({ video,
const isVideoInCloud = video.videoPath?.startsWith('cloud:') ?? false;
const thumbnailPathForCloud = isVideoInCloud ? video.thumbnailPath : null;
const thumbnailUrl = useCloudStorageUrl(thumbnailPathForCloud, 'thumbnail');
const localThumbnailUrl = !isVideoInCloud && video.thumbnailPath
? `${import.meta.env.VITE_BACKEND_URL || 'http://localhost:5551'}${video.thumbnailPath}`
const localThumbnailUrl = !isVideoInCloud && video.thumbnailPath
? `${import.meta.env.VITE_BACKEND_URL ?? 'http://localhost:5551'}${video.thumbnailPath}`
: undefined;
const src = thumbnailUrl || localThumbnailUrl || video.thumbnailUrl || 'https://via.placeholder.com/240x180?text=No+Thumbnail';

View File

@@ -1,5 +1,5 @@
import { Box, FormControl, FormControlLabel, InputLabel, MenuItem, Select, Switch, TextField, Typography } from '@mui/material';
import { useQueryClient } from '@tanstack/react-query';
import { Alert, Box, FormControl, FormControlLabel, InputLabel, MenuItem, Select, Switch, TextField, Typography } from '@mui/material';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import React, { useEffect, useState } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
@@ -16,11 +16,13 @@ interface GeneralSettingsProps {
savedVisitorMode?: boolean;
infiniteScroll?: boolean;
videoColumns?: number;
cloudflaredTunnelEnabled?: boolean;
cloudflaredToken?: string;
onChange: (field: string, value: string | number | boolean) => void;
}
const GeneralSettings: React.FC<GeneralSettingsProps> = (props) => {
const { language, websiteName, showYoutubeSearch, visitorMode, savedVisitorMode, infiniteScroll, videoColumns, onChange } = props;
const { language, websiteName, showYoutubeSearch, visitorMode, savedVisitorMode, infiniteScroll, videoColumns, cloudflaredTunnelEnabled, cloudflaredToken, onChange } = props;
const { t } = useLanguage();
const queryClient = useQueryClient();
@@ -31,6 +33,18 @@ const GeneralSettings: React.FC<GeneralSettingsProps> = (props) => {
const [remainingWaitTime, setRemainingWaitTime] = useState(0);
const [baseError, setBaseError] = useState('');
// Poll for Cloudflare Tunnel status
const { data: cloudflaredStatus } = useQuery({
queryKey: ['cloudflaredStatus'],
queryFn: async () => {
if (!cloudflaredTunnelEnabled) return null;
const res = await axios.get(`${API_URL}/settings/cloudflared/status`);
return res.data;
},
enabled: !!cloudflaredTunnelEnabled,
refetchInterval: 5000 // Poll every 5 seconds
});
// Use saved value for visibility, current value for toggle state
const isVisitorMode = savedVisitorMode ?? visitorMode ?? false;
@@ -231,6 +245,83 @@ const GeneralSettings: React.FC<GeneralSettingsProps> = (props) => {
</>
)}
<Box>
<Typography variant="subtitle1" gutterBottom sx={{ mt: 2 }}>
{t('cloudflaredTunnel')}
</Typography>
<FormControlLabel
control={
<Switch
checked={cloudflaredTunnelEnabled ?? false}
onChange={(e) => onChange('cloudflaredTunnelEnabled', e.target.checked)}
/>
}
label={t('enableCloudflaredTunnel')}
/>
{(cloudflaredTunnelEnabled) && (
<TextField
fullWidth
label={t('cloudflaredToken')}
type="password"
value={cloudflaredToken || ''}
onChange={(e) => onChange('cloudflaredToken', e.target.value)}
margin="normal"
helperText={t('cloudflaredTokenHelper') || "Paste your tunnel token here, or leave empty to use a random Quick Tunnel."}
/>
)}
{cloudflaredTunnelEnabled && cloudflaredStatus && (
<Box sx={{ mt: 2, p: 2, bgcolor: 'background.paper', borderRadius: 1, border: 1, borderColor: 'divider' }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1, gap: 1 }}>
<Typography variant="subtitle2">Status:</Typography>
<Typography variant="body2" color={cloudflaredStatus.isRunning ? 'success.main' : 'error.main'} fontWeight="bold">
{cloudflaredStatus.isRunning ? 'Running' : 'Stopped'}
</Typography>
</Box>
{cloudflaredStatus.tunnelId && (
<Box sx={{ mb: 1 }}>
<Typography variant="subtitle2">Tunnel ID:</Typography>
<Typography variant="body2" fontFamily="monospace">
{cloudflaredStatus.tunnelId}
</Typography>
</Box>
)}
{cloudflaredStatus.accountTag && (
<Box sx={{ mb: 1 }}>
<Typography variant="subtitle2">Account Tag:</Typography>
<Typography variant="body2" fontFamily="monospace">
{cloudflaredStatus.accountTag}
</Typography>
</Box>
)}
{cloudflaredStatus.publicUrl && (
<Box sx={{ mb: 1 }}>
<Typography variant="subtitle2">Public URL:</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" fontFamily="monospace" sx={{ wordBreak: 'break-all' }}>
{cloudflaredStatus.publicUrl}
</Typography>
</Box>
<Alert severity="warning" sx={{ mt: 1, py: 0 }}>
Quick Tunnel URLs change every time the tunnel restarts.
</Alert>
</Box>
)}
{!cloudflaredStatus.publicUrl && (
<Alert severity="info" sx={{ mt: 1 }}>
Public hostname is managed in your Cloudflare Zero Trust Dashboard.
</Alert>
)}
</Box>
)}
</Box>
<Box>
<FormControlLabel
control={
@@ -256,7 +347,7 @@ const GeneralSettings: React.FC<GeneralSettingsProps> = (props) => {
error={passwordError}
isLoading={isVerifyingPassword}
/>
</Box>
</Box >
);
};

View File

@@ -98,7 +98,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
const thumbnailPathForCloud = isVideoInCloud ? video.thumbnailPath : null;
const thumbnailUrl = useCloudStorageUrl(thumbnailPathForCloud, 'thumbnail');
const localThumbnailUrl = !isVideoInCloud && video.thumbnailPath
? `${import.meta.env.VITE_BACKEND_URL || 'http://localhost:5551'}${video.thumbnailPath}`
? `${import.meta.env.VITE_BACKEND_URL ?? 'http://localhost:5551'}${video.thumbnailPath}`
: undefined;
const thumbnailSrc = thumbnailUrl || localThumbnailUrl || video.thumbnailUrl;

View File

@@ -38,8 +38,8 @@ const SidebarThumbnail: React.FC<{ video: Video }> = ({ video }) => {
const isVideoInCloud = video.videoPath?.startsWith('cloud:') ?? false;
const thumbnailPathForCloud = isVideoInCloud ? video.thumbnailPath : null;
const thumbnailUrl = useCloudStorageUrl(thumbnailPathForCloud, 'thumbnail');
const localThumbnailUrl = !isVideoInCloud && video.thumbnailPath
? `${import.meta.env.VITE_BACKEND_URL || 'http://localhost:5551'}${video.thumbnailPath}`
const localThumbnailUrl = !isVideoInCloud && video.thumbnailPath
? `${import.meta.env.VITE_BACKEND_URL ?? 'http://localhost:5551'}${video.thumbnailPath}`
: undefined;
return (

View File

@@ -30,7 +30,7 @@ export const useCloudStorageUrl = (
});
} else {
// Regular path, construct URL synchronously
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:5551';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? 'http://localhost:5551';
setUrl(`${BACKEND_URL}${path}`);
}
}, [path, type]);

View File

@@ -481,6 +481,8 @@ const SettingsPage: React.FC = () => {
savedVisitorMode={settingsData?.visitorMode}
infiniteScroll={settings.infiniteScroll}
videoColumns={settings.videoColumns}
cloudflaredTunnelEnabled={settings.cloudflaredTunnelEnabled}
cloudflaredToken={settings.cloudflaredToken}
onChange={(field, value) => handleChange(field as keyof Settings, value)}
/>
</Grid>

View File

@@ -82,4 +82,6 @@ export interface Settings {
visitorMode?: boolean;
infiniteScroll?: boolean;
videoColumns?: number;
cloudflaredTunnelEnabled?: boolean;
cloudflaredToken?: string;
}

View File

@@ -1,6 +1,6 @@
import axios from 'axios';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:5551';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? 'http://localhost:5551';
/**
* Check if a path is a cloud storage path (starts with "cloud:")

View File

@@ -561,6 +561,12 @@ export const en = {
moveThumbnailsToVideoFolderDescription:
"When enabled, thumbnail files will be moved to the same folder as the video file. When disabled, they will be moved to the isolated images folder.",
// Cloudflare Tunnel
cloudflaredTunnel: "Cloudflare Tunnel",
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
cloudflaredToken: "Tunnel Token",
cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
// Database Export/Import
exportImportDatabase: "Export/Import Database",
exportImportDatabaseDescription:

View File

@@ -543,6 +543,12 @@ export const zh = {
moveThumbnailsToVideoFolderDescription:
"启用后,封面文件将被移动到与视频文件相同的文件夹中。禁用后,它们将被移动到独立的图片文件夹中。",
// Cloudflare Tunnel
cloudflaredTunnel: "Cloudflare 穿透",
enableCloudflaredTunnel: "启用 Cloudflare 穿透",
cloudflaredToken: "Token",
cloudflaredTokenHelper: "在此粘贴您的 Token或留空以使用随机 Quick Tunnel。",
// Database Export/Import
exportImportDatabase: "导出/导入数据库",
exportImportDatabaseDescription:

View File

@@ -12,6 +12,33 @@ export default defineConfig({
interval: 2000,
ignored: ['/node_modules/']
},
proxy: {
'/api': {
target: 'http://localhost:5551',
changeOrigin: true,
secure: false,
},
'/cloud': {
target: 'http://localhost:5551',
changeOrigin: true,
secure: false,
},
'/images': {
target: 'http://localhost:5551',
changeOrigin: true,
secure: false,
},
'/videos': {
target: 'http://localhost:5551',
changeOrigin: true,
secure: false,
},
'/subtitles': {
target: 'http://localhost:5551',
changeOrigin: true,
secure: false,
},
},
},
define: {
'import.meta.env.VITE_APP_VERSION': JSON.stringify(packageJson.version)