feat: Add Cloudflare Tunnel integration

This commit is contained in:
Peifan Li
2025-12-25 18:00:18 -05:00
parent 508daaef7b
commit 431e7163c1
17 changed files with 334 additions and 93 deletions

View File

@@ -41,6 +41,7 @@
- **yt-dlp 配置**: 通过用户界面自定义全局 `yt-dlp` 参数、网络代理及其他高级设置。
- **访客模式**:启用只读模式,允许查看视频但无法进行修改。非常适合与他人分享您的视频库。
- **云存储集成**下载后自动将视频和缩略图上传到云存储OpenList/Alist
- **Cloudflare Tunnel 集成**: 内置 Cloudflare Tunnel 支持,无需端口转发即可轻松将本地 MyTube 实例暴露到互联网。
## 目录结构

View File

@@ -41,6 +41,7 @@ A YouTube/Bilibili/MissAV video downloader and player that supports channel subs
- **yt-dlp Configuration**: Customize global `yt-dlp` arguments, network proxy, and other advanced settings via settings page.
- **Visitor Mode**: Enable read-only mode to allow viewing videos without modification capabilities. Perfect for sharing your library with others.
- **Cloud Storage Integration**: Automatically upload videos and thumbnails to cloud storage (OpenList/Alist) after download.
- **Cloudflare Tunnel Integration**: Built-in Cloudflare Tunnel support to easily expose your local MyTube instance to the internet without port forwarding.
## Directory Structure

View File

@@ -1,6 +1,7 @@
import { Box, Typography } from '@mui/material';
import { Link } from 'react-router-dom';
import logo from '../../assets/logo.svg';
import { useCloudflareStatus } from '../../hooks/useCloudflareStatus';
interface LogoProps {
websiteName: string;
@@ -8,9 +9,32 @@ interface LogoProps {
}
const Logo: React.FC<LogoProps> = ({ websiteName, onResetSearch }) => {
// Only check status if we think it might be enabled, or just check always (it handles enabled=false internally somewhat, but better to query only if needed)
// Since we don't have easy access to settings here without adding another context, we'll check status always for now or ideally use a context.
// However, the hook defaults to enabled=true. Let's rely on the hook handling null if not running.
// Actually, checking status constantly might be overkill if disabled. But without global settings context readily available in Logo without refactor, we'll assume we want to check.
// Better strategy: The user explicitly asked for "When cloudflare Status is Running".
const { data: cloudflaredStatus } = useCloudflareStatus(true);
return (
<Link to="/" onClick={onResetSearch} style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', color: 'inherit' }}>
<img src={logo} alt="MyTube Logo" height={40} />
<Box sx={{ position: 'relative' }}>
<img src={logo} alt="MyTube Logo" height={40} />
{cloudflaredStatus?.isRunning && (
<Box
sx={{
position: 'absolute',
top: 2,
right: -2,
width: 8,
height: 8,
bgcolor: '#4caf50', // Green
borderRadius: '50%',
boxShadow: '0 0 4px #4caf50'
}}
/>
)}
</Box>
<Box sx={{ ml: 1, display: 'flex', flexDirection: 'column' }}>
<Typography variant="h5" sx={{ fontWeight: 'bold', lineHeight: 1 }}>
{websiteName}

View File

@@ -0,0 +1,127 @@
import { Alert, Box, CircularProgress, FormControlLabel, Switch, TextField, Tooltip, Typography } from '@mui/material';
import React, { useState } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import { useCloudflareStatus } from '../../hooks/useCloudflareStatus';
interface CloudflareSettingsProps {
enabled?: boolean;
token?: string;
onChange: (field: string, value: string | number | boolean) => void;
}
const CloudflareSettings: React.FC<CloudflareSettingsProps> = ({ enabled, token, onChange }) => {
const { t } = useLanguage();
const [showCopied, setShowCopied] = useState(false);
const handleCopyUrl = (url: string) => {
navigator.clipboard.writeText(url);
setShowCopied(true);
setTimeout(() => setShowCopied(false), 2000);
};
// Poll for Cloudflare Tunnel status
const { data: cloudflaredStatus, isLoading } = useCloudflareStatus(enabled ?? false);
return (
<Box>
<Typography variant="h6" >
{t('cloudflaredTunnel')}
</Typography>
<FormControlLabel
control={
<Switch
checked={enabled ?? false}
onChange={(e) => onChange('cloudflaredTunnelEnabled', e.target.checked)}
/>
}
label={t('enableCloudflaredTunnel')}
/>
{(enabled) && (
<TextField
fullWidth
label={t('cloudflaredToken')}
type="password"
value={token || ''}
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."}
/>
)}
{enabled && (isLoading || (!cloudflaredStatus && enabled) || (cloudflaredStatus?.isRunning && !token && !cloudflaredStatus.publicUrl)) ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
<CircularProgress />
{cloudflaredStatus?.isRunning && !token && !cloudflaredStatus.publicUrl && (
<Typography variant="body2" sx={{ ml: 2, mt: 0.5 }}>
{t('waitingForUrl')}
</Typography>
)}
</Box>
) : (enabled && 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">{t('status') || 'Status'}:</Typography>
<Typography variant="body2" color={cloudflaredStatus.isRunning ? 'success.main' : 'error.main'} fontWeight="bold">
{cloudflaredStatus.isRunning ? t('running') : t('stopped')}
</Typography>
</Box>
{cloudflaredStatus.tunnelId && (
<Box sx={{ mb: 1 }}>
<Typography variant="subtitle2">{t('tunnelId')}:</Typography>
<Typography variant="body2" fontFamily="monospace">
{cloudflaredStatus.tunnelId}
</Typography>
</Box>
)}
{cloudflaredStatus.accountTag && (
<Box sx={{ mb: 1 }}>
<Typography variant="subtitle2">{t('accountTag')}:</Typography>
<Typography variant="body2" fontFamily="monospace">
{cloudflaredStatus.accountTag}
</Typography>
</Box>
)}
{cloudflaredStatus.publicUrl && (
<Box sx={{ mb: 1 }}>
<Typography variant="subtitle2">{t('publicUrl')}:</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip title={showCopied ? t('copied') : t('clickToCopy')} arrow>
<Typography
variant="body2"
fontFamily="monospace"
sx={{
wordBreak: 'break-all',
cursor: 'pointer',
'&:hover': { textDecoration: 'underline', color: 'primary.main' }
}}
onClick={() => handleCopyUrl(cloudflaredStatus.publicUrl!)}
>
{cloudflaredStatus.publicUrl}
</Typography>
</Tooltip>
</Box>
<Alert severity="warning" sx={{ mt: 1, py: 0 }}>
{t('quickTunnelWarning')}
</Alert>
</Box>
)}
{!cloudflaredStatus.publicUrl && (
<Alert severity="info" sx={{ mt: 1 }}>
{t('managedInDashboard')}
</Alert>
)}
</Box>
))}
</Box>
);
};
export default CloudflareSettings;

View File

@@ -1,5 +1,5 @@
import { Alert, Box, FormControl, FormControlLabel, InputLabel, MenuItem, Select, Switch, TextField, Typography } from '@mui/material';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Box, FormControl, FormControlLabel, InputLabel, MenuItem, Select, Switch, TextField, Typography } from '@mui/material';
import { useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import React, { useEffect, useState } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
@@ -16,13 +16,11 @@ 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, cloudflaredTunnelEnabled, cloudflaredToken, onChange } = props;
const { language, websiteName, showYoutubeSearch, visitorMode, savedVisitorMode, infiniteScroll, videoColumns, onChange } = props;
const { t } = useLanguage();
const queryClient = useQueryClient();
@@ -34,16 +32,7 @@ const GeneralSettings: React.FC<GeneralSettingsProps> = (props) => {
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;
@@ -245,81 +234,7 @@ 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>

View File

@@ -0,0 +1,24 @@
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
const API_URL = import.meta.env.VITE_API_URL;
interface CloudflareStatus {
isRunning: boolean;
tunnelId: string | null;
accountTag: string | null;
publicUrl: string | null;
}
export const useCloudflareStatus = (enabled: boolean = true) => {
return useQuery<CloudflareStatus>({
queryKey: ['cloudflaredStatus'],
queryFn: async () => {
if (!enabled) return { isRunning: false, tunnelId: null, accountTag: null, publicUrl: null };
const res = await axios.get(`${API_URL}/settings/cloudflared/status`);
return res.data;
},
enabled: !!enabled,
refetchInterval: 5000 // Poll every 5 seconds
});
};

View File

@@ -17,6 +17,7 @@ import React, { useEffect, useRef, useState } from 'react';
import ConfirmationModal from '../components/ConfirmationModal';
import AdvancedSettings from '../components/Settings/AdvancedSettings';
import CloudDriveSettings from '../components/Settings/CloudDriveSettings';
import CloudflareSettings from '../components/Settings/CloudflareSettings';
import CookieSettings from '../components/Settings/CookieSettings';
import DatabaseSettings from '../components/Settings/DatabaseSettings';
import DownloadSettings from '../components/Settings/DownloadSettings';
@@ -481,8 +482,18 @@ 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>
<Grid size={12}><Divider /></Grid>
{/* Cloudflare Settings */}
<Grid size={12}>
<CloudflareSettings
enabled={settings.cloudflaredTunnelEnabled}
token={settings.cloudflaredToken}
onChange={(field, value) => handleChange(field as keyof Settings, value)}
/>
</Grid>

View File

@@ -571,4 +571,19 @@ export const ar = {
deleteAuthorConfirmation: "هل أنت متأكد أنك تريد حذف المؤلف {author}؟ سيؤدي هذا إلى حذف جميع مقاطع الفيديو المرتبطة بهذا المؤلف.",
authorDeletedSuccessfully: "تم حذف المؤلف بنجاح",
failedToDeleteAuthor: "فشل حذف المؤلف",
// Cloudflare Tunnel
cloudflaredTunnel: "Cloudflare Tunnel",
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
cloudflaredToken: "Tunnel Token (Optional)",
cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
waitingForUrl: "Waiting for Quick Tunnel URL...",
running: "Running",
stopped: "Stopped",
tunnelId: "Tunnel ID",
accountTag: "Account Tag",
copied: "Copied!",
clickToCopy: "Click to copy",
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
};

View File

@@ -552,4 +552,19 @@ export const de = {
restoreFromLastBackupFailed: "Fehler beim Wiederherstellen aus Backup",
lastBackupDate: "Datum des letzten Backups",
noBackupAvailable: "Kein Backup verfügbar",
// Cloudflare Tunnel
cloudflaredTunnel: "Cloudflare Tunnel",
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
cloudflaredToken: "Tunnel Token (Optional)",
cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
waitingForUrl: "Waiting for Quick Tunnel URL...",
running: "Running",
stopped: "Stopped",
tunnelId: "Tunnel ID",
accountTag: "Account Tag",
copied: "Copied!",
clickToCopy: "Click to copy",
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
};

View File

@@ -564,8 +564,17 @@ export const en = {
// Cloudflare Tunnel
cloudflaredTunnel: "Cloudflare Tunnel",
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
cloudflaredToken: "Tunnel Token",
cloudflaredToken: "Tunnel Token (Optional)",
cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
waitingForUrl: "Waiting for Quick Tunnel URL...",
running: "Running",
stopped: "Stopped",
tunnelId: "Tunnel ID",
accountTag: "Account Tag",
copied: "Copied!",
clickToCopy: "Click to copy",
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
// Database Export/Import
exportImportDatabase: "Export/Import Database",

View File

@@ -558,4 +558,19 @@ export const es = {
restoreFromLastBackupFailed: "Error al restaurar desde la copia de respaldo",
lastBackupDate: "Fecha de última copia de respaldo",
noBackupAvailable: "No hay copia de respaldo disponible",
// Cloudflare Tunnel
cloudflaredTunnel: "Cloudflare Tunnel",
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
cloudflaredToken: "Tunnel Token (Optional)",
cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
waitingForUrl: "Waiting for Quick Tunnel URL...",
running: "Running",
stopped: "Stopped",
tunnelId: "Tunnel ID",
accountTag: "Account Tag",
copied: "Copied!",
clickToCopy: "Click to copy",
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
};

View File

@@ -605,4 +605,19 @@ export const fr = {
restoreFromLastBackupFailed: "Échec de la restauration depuis la sauvegarde",
lastBackupDate: "Date de la dernière sauvegarde",
noBackupAvailable: "Aucune sauvegarde disponible",
// Cloudflare Tunnel
cloudflaredTunnel: "Cloudflare Tunnel",
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
cloudflaredToken: "Tunnel Token (Optional)",
cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
waitingForUrl: "Waiting for Quick Tunnel URL...",
running: "Running",
stopped: "Stopped",
tunnelId: "Tunnel ID",
accountTag: "Account Tag",
copied: "Copied!",
clickToCopy: "Click to copy",
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
};

View File

@@ -580,4 +580,19 @@ export const ja = {
deleteAuthorConfirmation: "著者 {author} を削除してもよろしいですか?これにより、この著者に関連するすべての動画が削除されます。",
authorDeletedSuccessfully: "著者が正常に削除されました",
failedToDeleteAuthor: "著者の削除に失敗しました",
// Cloudflare Tunnel
cloudflaredTunnel: "Cloudflare Tunnel",
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
cloudflaredToken: "Tunnel Token (Optional)",
cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
waitingForUrl: "Waiting for Quick Tunnel URL...",
running: "Running",
stopped: "Stopped",
tunnelId: "Tunnel ID",
accountTag: "Account Tag",
copied: "Copied!",
clickToCopy: "Click to copy",
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
};

View File

@@ -571,4 +571,19 @@ export const ko = {
deleteAuthorConfirmation: "작성자 {author}님을 삭제하시겠습니까? 이 작성자와 관련된 모든 동영상이 삭제됩니다.",
authorDeletedSuccessfully: "작성자가 성공적으로 삭제되었습니다",
failedToDeleteAuthor: "작성자 삭제 실패",
// Cloudflare Tunnel
cloudflaredTunnel: "Cloudflare Tunnel",
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
cloudflaredToken: "Tunnel Token (Optional)",
cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
waitingForUrl: "Waiting for Quick Tunnel URL...",
running: "Running",
stopped: "Stopped",
tunnelId: "Tunnel ID",
accountTag: "Account Tag",
copied: "Copied!",
clickToCopy: "Click to copy",
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
};

View File

@@ -583,4 +583,19 @@ export const pt = {
restoreFromLastBackupFailed: "Falha ao restaurar do backup",
lastBackupDate: "Data do último backup",
noBackupAvailable: "Nenhum backup disponível",
// Cloudflare Tunnel
cloudflaredTunnel: "Cloudflare Tunnel",
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
cloudflaredToken: "Tunnel Token (Optional)",
cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
waitingForUrl: "Waiting for Quick Tunnel URL...",
running: "Running",
stopped: "Stopped",
tunnelId: "Tunnel ID",
accountTag: "Account Tag",
copied: "Copied!",
clickToCopy: "Click to copy",
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
};

View File

@@ -577,4 +577,19 @@ export const ru = {
restoreFromLastBackupFailed: "Не удалось восстановить из резервной копии",
lastBackupDate: "Дата последней резервной копии",
noBackupAvailable: "Резервная копия недоступна",
// Cloudflare Tunnel
cloudflaredTunnel: "Cloudflare Tunnel",
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
cloudflaredToken: "Tunnel Token (Optional)",
cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
waitingForUrl: "Waiting for Quick Tunnel URL...",
running: "Running",
stopped: "Stopped",
tunnelId: "Tunnel ID",
accountTag: "Account Tag",
copied: "Copied!",
clickToCopy: "Click to copy",
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
};

View File

@@ -548,6 +548,15 @@ export const zh = {
enableCloudflaredTunnel: "启用 Cloudflare 穿透",
cloudflaredToken: "Token",
cloudflaredTokenHelper: "在此粘贴您的 Token或留空以使用随机 Quick Tunnel。",
waitingForUrl: "等待 Quick Tunnel URL...",
running: "运行中",
stopped: "已停止",
tunnelId: "Tunnel ID",
accountTag: "Account Tag",
copied: "已复制!",
clickToCopy: "点击复制",
quickTunnelWarning: "每次重启隧道时Quick Tunnel URL 都会更改。",
managedInDashboard: "公开主机名在您的 Cloudflare Zero Trust 仪表板中管理。",
// Database Export/Import
exportImportDatabase: "导出/导入数据库",