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 WORKDIR /app
# Install dependencies # Install dependencies
COPY package*.json ./ # Install dependencies
COPY backend/package*.json ./
# Skip Puppeteer download during build as we only need to compile TS # Skip Puppeteer download during build as we only need to compile TS
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
# Install build dependencies for native modules (python3, make, g++) # Install build dependencies for native modules (python3, make, g++)
RUN apk add --no-cache python3 make g++ RUN apk add --no-cache python3 make g++
RUN npm ci 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 # Build bgutil-ytdlp-pot-provider
WORKDIR /app/bgutil-ytdlp-pot-provider/server 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 && \ RUN curl -fsSL https://deno.land/install.sh | sh -s -- -y && \
ln -sf /root/.deno/bin/deno /usr/local/bin/deno 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 # 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 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 ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
# Install production dependencies only # Install production dependencies only
COPY package*.json ./ COPY backend/package*.json ./
RUN npm ci --only=production RUN npm ci --only=production
# Copy built artifacts from builder # Copy built artifacts from builder
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
# Copy frontend build
COPY --from=builder /app/frontend/dist ./frontend/dist
# Copy drizzle migrations # Copy drizzle migrations
COPY --from=builder /app/drizzle ./drizzle COPY --from=builder /app/drizzle ./drizzle
# Copy bgutil-ytdlp-pot-provider # Copy bgutil-ytdlp-pot-provider

View File

@@ -3,11 +3,12 @@ import { Request, Response } from "express";
import fs from "fs-extra"; import fs from "fs-extra";
import path from "path"; import path from "path";
import { import {
COLLECTIONS_DATA_PATH, COLLECTIONS_DATA_PATH,
STATUS_DATA_PATH, STATUS_DATA_PATH,
VIDEOS_DATA_PATH, VIDEOS_DATA_PATH,
} from "../config/paths"; } from "../config/paths";
import { NotFoundError, ValidationError } from "../errors/DownloadErrors"; import { NotFoundError, ValidationError } from "../errors/DownloadErrors";
import { cloudflaredService } from "../services/cloudflaredService";
import downloadManager from "../services/downloadManager"; import downloadManager from "../services/downloadManager";
import * as loginAttemptService from "../services/loginAttemptService"; import * as loginAttemptService from "../services/loginAttemptService";
import * as storageService from "../services/storageService"; import * as storageService from "../services/storageService";
@@ -41,6 +42,8 @@ interface Settings {
visitorMode?: boolean; visitorMode?: boolean;
infiniteScroll?: boolean; infiniteScroll?: boolean;
videoColumns?: number; videoColumns?: number;
cloudflaredTunnelEnabled?: boolean;
cloudflaredToken?: string;
} }
const defaultSettings: Settings = { 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 // Apply settings immediately where possible
downloadManager.setMaxConcurrentDownloads(newSettings.maxConcurrentDownloads); downloadManager.setMaxConcurrentDownloads(newSettings.maxConcurrentDownloads);
@@ -873,3 +910,16 @@ export const restoreFromLastBackup = async (
throw error; 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 multer from "multer";
import os from "os"; import os from "os";
import { import {
checkCookies, checkCookies,
cleanupBackupDatabases, cleanupBackupDatabases,
deleteCookies, deleteCookies,
deleteLegacyData, deleteLegacyData,
exportDatabase, exportDatabase,
formatFilenames, formatFilenames,
getLastBackupInfo, getCloudflaredStatus,
getPasswordEnabled, getLastBackupInfo,
getSettings, getPasswordEnabled,
importDatabase, getSettings,
migrateData, importDatabase,
restoreFromLastBackup, migrateData,
resetPassword, resetPassword,
updateSettings, restoreFromLastBackup,
uploadCookies, updateSettings,
verifyPassword, uploadCookies,
verifyPassword,
} from "../controllers/settingsController"; } from "../controllers/settingsController";
import { asyncHandler } from "../middleware/errorHandler"; import { asyncHandler } from "../middleware/errorHandler";
@@ -48,5 +49,6 @@ router.post(
router.post("/cleanup-backup-databases", asyncHandler(cleanupBackupDatabases)); router.post("/cleanup-backup-databases", asyncHandler(cleanupBackupDatabases));
router.get("/last-backup-info", asyncHandler(getLastBackupInfo)); router.get("/last-backup-info", asyncHandler(getLastBackupInfo));
router.post("/restore-from-last-backup", asyncHandler(restoreFromLastBackup)); router.post("/restore-from-last-backup", asyncHandler(restoreFromLastBackup));
router.get("/cloudflared/status", asyncHandler(getCloudflaredStatus));
export default router; export default router;

View File

@@ -5,17 +5,19 @@ dotenv.config();
import axios from "axios"; import axios from "axios";
import cors from "cors"; import cors from "cors";
import express from "express"; import express from "express";
import path from "path";
import { import {
CLOUD_THUMBNAIL_CACHE_DIR, CLOUD_THUMBNAIL_CACHE_DIR,
IMAGES_DIR, IMAGES_DIR,
SUBTITLES_DIR, SUBTITLES_DIR,
VIDEOS_DIR, VIDEOS_DIR,
} from "./config/paths"; } from "./config/paths";
import { runMigrations } from "./db/migrate"; import { runMigrations } from "./db/migrate";
import { visitorModeMiddleware } from "./middleware/visitorModeMiddleware"; import { visitorModeMiddleware } from "./middleware/visitorModeMiddleware";
import { visitorModeSettingsMiddleware } from "./middleware/visitorModeSettingsMiddleware"; import { visitorModeSettingsMiddleware } from "./middleware/visitorModeSettingsMiddleware";
import apiRoutes from "./routes/api"; import apiRoutes from "./routes/api";
import settingsRoutes from "./routes/settingsRoutes"; import settingsRoutes from "./routes/settingsRoutes";
import { cloudflaredService } from "./services/cloudflaredService";
import downloadManager from "./services/downloadManager"; import downloadManager from "./services/downloadManager";
import * as storageService from "./services/storageService"; import * as storageService from "./services/storageService";
import { logger } from "./utils/logger"; 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 // Cloud storage proxy endpoints
// Proxy /cloud/videos/* and /cloud/images/* to Alist API // 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 // Use separate middleware for settings that allows disabling visitor mode
app.use("/api/settings", visitorModeSettingsMiddleware, settingsRoutes); 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, () => { app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`); console.log(`Server running on port ${PORT}`);
@@ -226,6 +242,18 @@ const startServer = async () => {
.catch((err) => .catch((err) =>
console.error("Failed to start metadata service:", 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) { } catch (error) {
console.error("Failed to start server:", 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 local version_tag=$3
echo "🏗️ Building backend for $platform..." echo "🏗️ Building backend for $platform..."
cd backend # Run build from root context to allow copying frontend files
$DOCKER_PATH build --no-cache --platform $platform -t $tag . # 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 if [ -n "$VERSION" ] && [ -n "$version_tag" ]; then
$DOCKER_PATH tag $tag $version_tag $DOCKER_PATH tag $tag $version_tag
@@ -58,8 +59,6 @@ build_backend() {
if [ -n "$VERSION" ] && [ -n "$version_tag" ]; then if [ -n "$VERSION" ] && [ -n "$version_tag" ]; then
$DOCKER_PATH rmi $version_tag $DOCKER_PATH rmi $version_tag
fi fi
cd ..
} }
# Function to build frontend for a specific platform # Function to build frontend for a specific platform

View File

@@ -1,2 +1,2 @@
VITE_API_URL=http://localhost:5551/api VITE_API_URL=/api
VITE_BACKEND_URL=http://localhost:5551 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 isVideoInCloud = video.videoPath?.startsWith('cloud:') ?? false;
const thumbnailPathForCloud = isVideoInCloud ? video.thumbnailPath : null; const thumbnailPathForCloud = isVideoInCloud ? video.thumbnailPath : null;
const thumbnailUrl = useCloudStorageUrl(thumbnailPathForCloud, 'thumbnail'); const thumbnailUrl = useCloudStorageUrl(thumbnailPathForCloud, 'thumbnail');
const localThumbnailUrl = !isVideoInCloud && video.thumbnailPath 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; : undefined;
const src = thumbnailUrl || localThumbnailUrl || video.thumbnailUrl || 'https://via.placeholder.com/240x180?text=No+Thumbnail'; 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 { Alert, Box, FormControl, FormControlLabel, InputLabel, MenuItem, Select, Switch, TextField, Typography } from '@mui/material';
import { useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios'; import axios from 'axios';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useLanguage } from '../../contexts/LanguageContext'; import { useLanguage } from '../../contexts/LanguageContext';
@@ -16,11 +16,13 @@ interface GeneralSettingsProps {
savedVisitorMode?: boolean; savedVisitorMode?: boolean;
infiniteScroll?: boolean; infiniteScroll?: boolean;
videoColumns?: number; videoColumns?: number;
cloudflaredTunnelEnabled?: boolean;
cloudflaredToken?: string;
onChange: (field: string, value: string | number | boolean) => void; onChange: (field: string, value: string | number | boolean) => void;
} }
const GeneralSettings: React.FC<GeneralSettingsProps> = (props) => { 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 { t } = useLanguage();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -31,6 +33,18 @@ const GeneralSettings: React.FC<GeneralSettingsProps> = (props) => {
const [remainingWaitTime, setRemainingWaitTime] = useState(0); const [remainingWaitTime, setRemainingWaitTime] = useState(0);
const [baseError, setBaseError] = useState(''); 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 // Use saved value for visibility, current value for toggle state
const isVisitorMode = savedVisitorMode ?? visitorMode ?? false; 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> <Box>
<FormControlLabel <FormControlLabel
control={ control={
@@ -256,7 +347,7 @@ const GeneralSettings: React.FC<GeneralSettingsProps> = (props) => {
error={passwordError} error={passwordError}
isLoading={isVerifyingPassword} isLoading={isVerifyingPassword}
/> />
</Box> </Box >
); );
}; };

View File

@@ -98,7 +98,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
const thumbnailPathForCloud = isVideoInCloud ? video.thumbnailPath : null; const thumbnailPathForCloud = isVideoInCloud ? video.thumbnailPath : null;
const thumbnailUrl = useCloudStorageUrl(thumbnailPathForCloud, 'thumbnail'); const thumbnailUrl = useCloudStorageUrl(thumbnailPathForCloud, 'thumbnail');
const localThumbnailUrl = !isVideoInCloud && video.thumbnailPath 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; : undefined;
const thumbnailSrc = thumbnailUrl || localThumbnailUrl || video.thumbnailUrl; 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 isVideoInCloud = video.videoPath?.startsWith('cloud:') ?? false;
const thumbnailPathForCloud = isVideoInCloud ? video.thumbnailPath : null; const thumbnailPathForCloud = isVideoInCloud ? video.thumbnailPath : null;
const thumbnailUrl = useCloudStorageUrl(thumbnailPathForCloud, 'thumbnail'); const thumbnailUrl = useCloudStorageUrl(thumbnailPathForCloud, 'thumbnail');
const localThumbnailUrl = !isVideoInCloud && video.thumbnailPath 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; : undefined;
return ( return (

View File

@@ -30,7 +30,7 @@ export const useCloudStorageUrl = (
}); });
} else { } else {
// Regular path, construct URL synchronously // 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}`); setUrl(`${BACKEND_URL}${path}`);
} }
}, [path, type]); }, [path, type]);

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import axios from 'axios'; 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:") * Check if a path is a cloud storage path (starts with "cloud:")

View File

@@ -561,6 +561,12 @@ export const en = {
moveThumbnailsToVideoFolderDescription: 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.", "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 // Database Export/Import
exportImportDatabase: "Export/Import Database", exportImportDatabase: "Export/Import Database",
exportImportDatabaseDescription: exportImportDatabaseDescription:

View File

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

View File

@@ -12,6 +12,33 @@ export default defineConfig({
interval: 2000, interval: 2000,
ignored: ['/node_modules/'] 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: { define: {
'import.meta.env.VITE_APP_VERSION': JSON.stringify(packageJson.version) 'import.meta.env.VITE_APP_VERSION': JSON.stringify(packageJson.version)