feat: Add Cloudflare Tunnel settings and service
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
121
backend/src/services/cloudflaredService.ts
Normal file
121
backend/src/services/cloudflaredService.ts
Normal 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
176
build-and-push-test.sh
Executable 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"
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
VITE_API_URL=http://localhost:5551/api
|
||||
VITE_BACKEND_URL=http://localhost:5551
|
||||
VITE_API_URL=/api
|
||||
VITE_BACKEND_URL=
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 >
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -82,4 +82,6 @@ export interface Settings {
|
||||
visitorMode?: boolean;
|
||||
infiniteScroll?: boolean;
|
||||
videoColumns?: number;
|
||||
cloudflaredTunnelEnabled?: boolean;
|
||||
cloudflaredToken?: string;
|
||||
}
|
||||
|
||||
@@ -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:")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user