feat: Add cloud storage settings and connection test feature

This commit is contained in:
Peifan Li
2025-12-17 15:36:33 -05:00
parent fc49b319a4
commit 7585e745d8
14 changed files with 599 additions and 131 deletions

View File

@@ -26,6 +26,8 @@ describe("CloudStorageService", () => {
vi.clearAllMocks();
console.log = vi.fn();
console.error = vi.fn();
// Ensure axios.put is properly mocked
(axios.put as any) = vi.fn();
});
describe("uploadVideo", () => {
@@ -77,9 +79,12 @@ describe("CloudStorageService", () => {
});
(fs.existsSync as any).mockReturnValue(true);
(fs.statSync as any).mockReturnValue({ size: 1024 });
(fs.statSync as any).mockReturnValue({ size: 1024, mtime: { getTime: () => Date.now() } });
(fs.createReadStream as any).mockReturnValue({});
(axios.put as any).mockResolvedValue({ status: 200 });
(axios.put as any).mockResolvedValue({
status: 200,
data: { code: 200, message: "Success" }
});
// Mock resolveAbsolutePath by making fs.existsSync return true for data dir
(fs.existsSync as any).mockImplementation((p: string) => {
@@ -99,9 +104,11 @@ describe("CloudStorageService", () => {
await CloudStorageService.uploadVideo(mockVideoData);
expect(axios.put).toHaveBeenCalled();
expect(console.log).toHaveBeenCalledWith(
"[CloudStorage] Starting upload for video: Test Video"
expect(console.log).toHaveBeenCalled();
const logCall = (console.log as any).mock.calls.find((call: any[]) =>
call[0]?.includes("[CloudStorage] Starting upload for video: Test Video")
);
expect(logCall).toBeDefined();
});
it("should upload thumbnail when path exists", async () => {
@@ -118,9 +125,12 @@ describe("CloudStorageService", () => {
});
(fs.existsSync as any).mockReturnValue(true);
(fs.statSync as any).mockReturnValue({ size: 512 });
(fs.statSync as any).mockReturnValue({ size: 512, mtime: { getTime: () => Date.now() } });
(fs.createReadStream as any).mockReturnValue({});
(axios.put as any).mockResolvedValue({ status: 200 });
(axios.put as any).mockResolvedValue({
status: 200,
data: { code: 200, message: "Success" }
});
(fs.existsSync as any).mockImplementation((p: string) => {
if (
@@ -158,13 +168,22 @@ describe("CloudStorageService", () => {
cloudDrivePath: "/uploads",
});
(fs.existsSync as any).mockReturnValue(true);
(fs.existsSync as any).mockImplementation((p: string) => {
// Return true for temp_metadata files and their directory
if (p.includes("temp_metadata")) {
return true;
}
return true;
});
(fs.ensureDirSync as any).mockReturnValue(undefined);
(fs.writeFileSync as any).mockReturnValue(undefined);
(fs.statSync as any).mockReturnValue({ size: 256 });
(fs.statSync as any).mockReturnValue({ size: 256, mtime: { getTime: () => Date.now() } });
(fs.createReadStream as any).mockReturnValue({});
(fs.unlinkSync as any).mockReturnValue(undefined);
(axios.put as any).mockResolvedValue({ status: 200 });
(axios.put as any).mockResolvedValue({
status: 200,
data: { code: 200, message: "Success" }
});
await CloudStorageService.uploadVideo(mockVideoData);
@@ -207,9 +226,11 @@ describe("CloudStorageService", () => {
await CloudStorageService.uploadVideo(mockVideoData);
expect(console.error).toHaveBeenCalledWith(
"[CloudStorage] Video file not found: /videos/missing.mp4"
expect(console.error).toHaveBeenCalled();
const errorCall = (console.error as any).mock.calls.find((call: any[]) =>
call[0]?.includes("[CloudStorage] Video file not found: /videos/missing.mp4")
);
expect(errorCall).toBeDefined();
// Metadata will still be uploaded even if video is missing
// So we check that video upload was not attempted
const putCalls = (axios.put as any).mock.calls;
@@ -233,7 +254,7 @@ describe("CloudStorageService", () => {
});
(fs.existsSync as any).mockReturnValue(true);
(fs.statSync as any).mockReturnValue({ size: 1024 });
(fs.statSync as any).mockReturnValue({ size: 1024, mtime: { getTime: () => Date.now() } });
(fs.createReadStream as any).mockReturnValue({});
(axios.put as any).mockRejectedValue(new Error("Upload failed"));
@@ -253,10 +274,12 @@ describe("CloudStorageService", () => {
await CloudStorageService.uploadVideo(mockVideoData);
expect(console.error).toHaveBeenCalledWith(
"[CloudStorage] Upload failed for Test Video:",
expect.any(Error)
expect(console.error).toHaveBeenCalled();
const errorCall = (console.error as any).mock.calls.find((call: any[]) =>
call[0]?.includes("[CloudStorage] Upload failed for Test Video:")
);
expect(errorCall).toBeDefined();
expect(errorCall[1]).toBeInstanceOf(Error);
});
it("should sanitize filename for metadata", async () => {
@@ -275,10 +298,13 @@ describe("CloudStorageService", () => {
(fs.existsSync as any).mockReturnValue(true);
(fs.ensureDirSync as any).mockReturnValue(undefined);
(fs.writeFileSync as any).mockReturnValue(undefined);
(fs.statSync as any).mockReturnValue({ size: 256 });
(fs.statSync as any).mockReturnValue({ size: 256, mtime: { getTime: () => Date.now() } });
(fs.createReadStream as any).mockReturnValue({});
(fs.unlinkSync as any).mockReturnValue(undefined);
(axios.put as any).mockResolvedValue({ status: 200 });
(axios.put as any).mockResolvedValue({
status: 200,
data: { code: 200, message: "Success" }
});
await CloudStorageService.uploadVideo(mockVideoData);
@@ -304,7 +330,7 @@ describe("CloudStorageService", () => {
});
(fs.existsSync as any).mockReturnValue(true);
(fs.statSync as any).mockReturnValue({ size: 1024 });
(fs.statSync as any).mockReturnValue({ size: 1024, mtime: { getTime: () => Date.now() } });
(fs.createReadStream as any).mockReturnValue({});
const axiosError = {
@@ -348,7 +374,7 @@ describe("CloudStorageService", () => {
});
(fs.existsSync as any).mockReturnValue(true);
(fs.statSync as any).mockReturnValue({ size: 1024 });
(fs.statSync as any).mockReturnValue({ size: 1024, mtime: { getTime: () => Date.now() } });
(fs.createReadStream as any).mockReturnValue({});
const axiosError = {
@@ -390,7 +416,7 @@ describe("CloudStorageService", () => {
});
(fs.existsSync as any).mockReturnValue(true);
(fs.statSync as any).mockReturnValue({ size: 1024 });
(fs.statSync as any).mockReturnValue({ size: 1024, mtime: { getTime: () => Date.now() } });
(fs.createReadStream as any).mockReturnValue({});
const axiosError = {

View File

@@ -2,6 +2,7 @@
import dotenv from "dotenv";
dotenv.config();
import axios from "axios";
import cors from "cors";
import express from "express";
import { IMAGES_DIR, SUBTITLES_DIR, VIDEOS_DIR } from "./config/paths";
@@ -10,6 +11,7 @@ import apiRoutes from "./routes/api";
import settingsRoutes from "./routes/settingsRoutes";
import downloadManager from "./services/downloadManager";
import * as storageService from "./services/storageService";
import { logger } from "./utils/logger";
import { VERSION } from "./version";
// Display version information
@@ -64,6 +66,125 @@ const startServer = async () => {
})
);
// Cloud storage proxy endpoints
// Proxy /cloud/videos/* and /cloud/images/* to Alist API
const proxyCloudFile = async (
req: express.Request,
res: express.Response,
fileType: "video" | "image"
) => {
try {
const { filename } = req.params;
const settings = storageService.getSettings();
if (
!settings.cloudDriveEnabled ||
!settings.openListApiUrl ||
!settings.openListToken
) {
return res.status(404).send("Cloud storage not configured");
}
// Construct Alist API URL for file download
const apiBaseUrl = settings.openListApiUrl.replace("/api/fs/put", "");
const uploadPath = (settings.cloudDrivePath || "/").replace(/\\/g, "/");
const normalizedPath = uploadPath.endsWith("/")
? `${uploadPath}${filename}`
: `${uploadPath}/${filename}`;
const filePath = normalizedPath.startsWith("/")
? normalizedPath
: `/${normalizedPath}`;
// Alist API endpoint for getting file: /api/fs/get (POST with JSON body)
const alistUrl = `${apiBaseUrl}/api/fs/get`;
// Handle range requests for video streaming
const range = req.headers.range;
const headers: any = {
Authorization: settings.openListToken,
};
if (range) {
headers.Range = range;
}
// Make request to Alist API (POST method with path in body)
const response = await axios.post(
alistUrl,
{ path: filePath },
{
headers: headers,
responseType: "stream",
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 400,
}
);
// Set appropriate content type
const ext = filename.split(".").pop()?.toLowerCase();
if (fileType === "video") {
if (ext === "mp4") {
res.setHeader("Content-Type", "video/mp4");
} else if (ext === "webm") {
res.setHeader("Content-Type", "video/webm");
} else if (ext === "mkv") {
res.setHeader("Content-Type", "video/x-matroska");
} else {
res.setHeader("Content-Type", "application/octet-stream");
}
// Support range requests for video streaming
if (range && response.headers["content-range"]) {
res.setHeader("Content-Range", response.headers["content-range"]);
res.status(206); // Partial Content
}
if (response.headers["accept-ranges"]) {
res.setHeader("Accept-Ranges", response.headers["accept-ranges"]);
}
} else {
// Image
if (ext === "jpg" || ext === "jpeg") {
res.setHeader("Content-Type", "image/jpeg");
} else if (ext === "png") {
res.setHeader("Content-Type", "image/png");
} else if (ext === "gif") {
res.setHeader("Content-Type", "image/gif");
} else {
res.setHeader("Content-Type", "image/jpeg");
}
}
// Set content length if available
if (response.headers["content-length"]) {
res.setHeader("Content-Length", response.headers["content-length"]);
}
// Stream the file to client
response.data.pipe(res);
response.data.on("error", (err: Error) => {
logger.error("Error streaming cloud file:", err);
if (!res.headersSent) {
res.status(500).send("Error streaming file from cloud storage");
}
});
} catch (error: any) {
logger.error(
`Error proxying cloud ${fileType}:`,
error instanceof Error ? error : new Error(String(error))
);
if (!res.headersSent) {
res.status(500).send(`Error fetching ${fileType} from cloud storage`);
}
}
};
app.get("/cloud/videos/:filename", (req, res) =>
proxyCloudFile(req, res, "video")
);
app.get("/cloud/images/:filename", (req, res) =>
proxyCloudFile(req, res, "image")
);
// API Routes
app.use("/api", apiRoutes);
app.use("/api/settings", settingsRoutes);

View File

@@ -1,11 +1,9 @@
import axios from 'axios';
import fs from 'fs-extra';
import path from 'path';
import {
FileError,
NetworkError,
} from '../errors/DownloadErrors';
import { getSettings } from './storageService';
import axios from "axios";
import fs from "fs-extra";
import path from "path";
import { FileError, NetworkError } from "../errors/DownloadErrors";
import { logger } from "../utils/logger";
import { getSettings } from "./storageService";
interface CloudDriveConfig {
enabled: boolean;
@@ -19,9 +17,9 @@ export class CloudStorageService {
const settings = getSettings();
return {
enabled: settings.cloudDriveEnabled || false,
apiUrl: settings.openListApiUrl || '',
token: settings.openListToken || '',
uploadPath: settings.cloudDrivePath || '/'
apiUrl: settings.openListApiUrl || "",
token: settings.openListToken || "",
uploadPath: settings.cloudDrivePath || "/",
};
}
@@ -31,38 +29,33 @@ export class CloudStorageService {
return;
}
console.log(`[CloudStorage] Starting upload for video: ${videoData.title}`);
logger.info(`[CloudStorage] Starting upload for video: ${videoData.title}`);
const uploadedFiles: string[] = []; // Track successfully uploaded files for deletion
try {
// Upload Video File
if (videoData.videoPath) {
// videoPath is relative, e.g. /videos/filename.mp4
// We need absolute path. Assuming backend runs in project root or we can resolve it.
// Based on storageService, VIDEOS_DIR is likely imported from config/paths.
// But here we might need to resolve it.
// Let's try to resolve relative to process.cwd() or use absolute path if available.
// Actually, storageService stores relative paths for frontend usage.
// We should probably look up the file using the same logic as storageService or just assume standard location.
// For now, let's try to construct the path.
// Better approach: Use the absolute path if we can get it, or resolve from common dirs.
// Since I don't have direct access to config/paths here easily without importing,
// I'll assume the videoData might have enough info or I'll import paths.
const absoluteVideoPath = this.resolveAbsolutePath(videoData.videoPath);
if (absoluteVideoPath && fs.existsSync(absoluteVideoPath)) {
await this.uploadFile(absoluteVideoPath, config);
await this.uploadFile(absoluteVideoPath, config);
uploadedFiles.push(absoluteVideoPath);
} else {
console.error(`[CloudStorage] Video file not found: ${videoData.videoPath}`);
// Don't throw - continue with other files
logger.error(
`[CloudStorage] Video file not found: ${videoData.videoPath}`
);
// Don't throw - continue with other files
}
}
// Upload Thumbnail
if (videoData.thumbnailPath) {
const absoluteThumbPath = this.resolveAbsolutePath(videoData.thumbnailPath);
const absoluteThumbPath = this.resolveAbsolutePath(
videoData.thumbnailPath
);
if (absoluteThumbPath && fs.existsSync(absoluteThumbPath)) {
await this.uploadFile(absoluteThumbPath, config);
await this.uploadFile(absoluteThumbPath, config);
uploadedFiles.push(absoluteThumbPath);
}
}
@@ -74,112 +67,266 @@ export class CloudStorageService {
sourceUrl: videoData.sourceUrl,
tags: videoData.tags,
createdAt: videoData.createdAt,
...videoData
...videoData,
};
const metadataFileName = `${this.sanitizeFilename(videoData.title)}.json`;
const metadataPath = path.join(process.cwd(), 'temp_metadata', metadataFileName);
// Keep metadata filename consistent with thumbnail and video filename
const metadataFileName = videoData.thumbnailFilename
? videoData.thumbnailFilename
.replace(".jpg", ".json")
.replace(".png", ".json")
: `${this.sanitizeFilename(videoData.title)}.json`;
const metadataPath = path.join(
process.cwd(),
"temp_metadata",
metadataFileName
);
fs.ensureDirSync(path.dirname(metadataPath));
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
await this.uploadFile(metadataPath, config);
// Cleanup temp metadata
// Cleanup temp metadata (always delete temp file)
fs.unlinkSync(metadataPath);
console.log(`[CloudStorage] Upload completed for: ${videoData.title}`);
logger.info(`[CloudStorage] Upload completed for: ${videoData.title}`);
// Delete local files after successful upload and update video record to point to cloud storage
if (uploadedFiles.length > 0) {
logger.info(
`[CloudStorage] Deleting ${uploadedFiles.length} local file(s) after successful upload...`
);
// Track which files were successfully deleted
const deletedFiles: string[] = [];
for (const filePath of uploadedFiles) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
deletedFiles.push(filePath);
logger.info(`[CloudStorage] Deleted local file: ${filePath}`);
}
} catch (deleteError: any) {
logger.error(
`[CloudStorage] Failed to delete local file ${filePath}:`,
deleteError instanceof Error
? deleteError
: new Error(deleteError.message)
);
// Don't throw - continue with other files
}
}
logger.info(`[CloudStorage] Local file cleanup completed`);
// Update video record to point to cloud storage URLs
if (videoData.id && deletedFiles.length > 0) {
try {
const storageService = await import("./storageService");
const updates: any = {};
// Update video path if video was deleted
if (videoData.videoFilename) {
updates.videoPath = `/cloud/videos/${videoData.videoFilename}`;
}
// Update thumbnail path if thumbnail was deleted
if (videoData.thumbnailFilename) {
updates.thumbnailPath = `/cloud/images/${videoData.thumbnailFilename}`;
}
if (Object.keys(updates).length > 0) {
storageService.updateVideo(videoData.id, updates);
logger.info(
`[CloudStorage] Updated video record ${videoData.id} with cloud storage paths`
);
}
} catch (updateError: any) {
logger.error(
`[CloudStorage] Failed to update video record with cloud paths:`,
updateError instanceof Error
? updateError
: new Error(updateError.message)
);
// Don't throw - file deletion was successful
}
}
}
} catch (error) {
console.error(`[CloudStorage] Upload failed for ${videoData.title}:`, error);
logger.error(
`[CloudStorage] Upload failed for ${videoData.title}:`,
error instanceof Error ? error : new Error(String(error))
);
// If upload failed, don't delete local files
}
}
private static resolveAbsolutePath(relativePath: string): string | null {
// This is a heuristic. In a real app we should import the constants.
// Assuming the app runs from 'backend' or root.
// relativePath starts with /videos or /images
// Try to find the 'data' directory.
// If we are in backend/src/services, data is likely ../../../data
// Let's try to use the absolute path if we can find the data dir.
// Or just check common locations.
const possibleRoots = [
path.join(process.cwd(), 'data'),
path.join(process.cwd(), '..', 'data'), // if running from backend
path.join(__dirname, '..', '..', '..', 'data') // if compiled
];
logger.debug("resolveAbsolutePath input:", relativePath);
for (const root of possibleRoots) {
if (fs.existsSync(root)) {
// Remove leading slash from relative path
const cleanRelative = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
const fullPath = path.join(root, cleanRelative);
if (fs.existsSync(fullPath)) {
return fullPath;
}
}
const cleanRelative = relativePath.startsWith("/")
? relativePath.slice(1)
: relativePath;
logger.debug("cleanRelative:", cleanRelative);
// Key fix: uploadsBase should not add 'backend'
const uploadsBase = path.join(process.cwd(), "uploads");
logger.debug("uploadsBase:", uploadsBase);
if (cleanRelative.startsWith("videos/")) {
const fullPath = path.join(uploadsBase, cleanRelative);
logger.debug("Trying uploads videos path:", fullPath);
if (fs.existsSync(fullPath)) {
logger.debug("Found video file at:", fullPath);
return fullPath;
}
logger.debug("Video path does not exist:", fullPath);
}
if (cleanRelative.startsWith("images/")) {
const fullPath = path.join(uploadsBase, cleanRelative);
logger.debug("Trying uploads images path:", fullPath);
if (fs.existsSync(fullPath)) {
logger.debug("Found image file at:", fullPath);
return fullPath;
}
logger.debug("Image path does not exist:", fullPath);
}
if (cleanRelative.startsWith("subtitles/")) {
const fullPath = path.join(uploadsBase, cleanRelative);
logger.debug("Trying uploads subtitles path:", fullPath);
if (fs.existsSync(fullPath)) {
logger.debug("Found subtitle file at:", fullPath);
return fullPath;
}
logger.debug("Subtitle path does not exist:", fullPath);
}
// Old data directory logic (backward compatibility)
const possibleRoots = [
path.join(process.cwd(), "data"),
path.join(process.cwd(), "..", "data"),
path.join(__dirname, "..", "..", "..", "data"),
];
for (const root of possibleRoots) {
logger.debug("Checking data root:", root);
if (fs.existsSync(root)) {
const fullPath = path.join(root, cleanRelative);
logger.debug("Found data root directory, trying file:", fullPath);
if (fs.existsSync(fullPath)) {
logger.debug("Found file in data root:", fullPath);
return fullPath;
}
logger.debug("File not found in data root:", fullPath);
} else {
logger.debug("Data root does not exist:", root);
}
}
logger.debug("No matching absolute path found for:", relativePath);
return null;
}
private static async uploadFile(filePath: string, config: CloudDriveConfig): Promise<void> {
private static async uploadFile(
filePath: string,
config: CloudDriveConfig
): Promise<void> {
// 1. Get basic file information
const fileName = path.basename(filePath);
const fileSize = fs.statSync(filePath).size;
const fileStat = fs.statSync(filePath);
const fileSize = fileStat.size;
const lastModified = fileStat.mtime.getTime().toString(); // Get millisecond timestamp
const fileStream = fs.createReadStream(filePath);
console.log(`[CloudStorage] Uploading ${fileName} (${fileSize} bytes)...`);
logger.info(`[CloudStorage] Uploading ${fileName} (${fileSize} bytes)...`);
// Generic upload implementation
// Assuming a simple PUT or POST with file content
// Many cloud drives (like Alist/WebDAV) use PUT with the path.
// Construct URL: apiUrl + uploadPath + fileName
// Ensure slashes are handled correctly
const baseUrl = config.apiUrl.endsWith('/') ? config.apiUrl.slice(0, -1) : config.apiUrl;
const uploadDir = config.uploadPath.startsWith('/') ? config.uploadPath : '/' + config.uploadPath;
const finalDir = uploadDir.endsWith('/') ? uploadDir : uploadDir + '/';
// Encode filename for URL
const encodedFileName = encodeURIComponent(fileName);
const url = `${baseUrl}${finalDir}${encodedFileName}`;
// 2. Prepare request URL and path
// URL is always a fixed PUT endpoint
const url = config.apiUrl; // Assume apiUrl is http://127.0.0.1:5244/api/fs/put
// Destination path is the combination of uploadPath and fileName
// Normalize path separators to forward slashes for Alist (works on all platforms)
const normalizedUploadPath = config.uploadPath.replace(/\\/g, "/");
const normalizedPath = normalizedUploadPath.endsWith("/")
? `${normalizedUploadPath}${fileName}`
: `${normalizedUploadPath}/${fileName}`;
const destinationPath = normalizedPath.startsWith("/")
? normalizedPath
: `/${normalizedPath}`;
logger.debug(
`[CloudStorage] Destination path in header: ${destinationPath}`
);
// 3. Prepare Headers
const headers = {
// Key fix #1: Destination path is passed in Header
"file-path": encodeURI(destinationPath), // Alist expects this header, needs encoding
// Key fix #2: Authorization Header does not have 'Bearer ' prefix
Authorization: config.token,
// Key fix #3: Include Last-Modified Header
"Last-Modified": lastModified,
// Other Headers
"Content-Type": "application/octet-stream", // Use generic stream type
"Content-Length": fileSize.toString(),
};
try {
await axios.put(url, fileStream, {
headers: {
'Authorization': `Bearer ${config.token}`,
'Content-Type': 'application/octet-stream',
'Content-Length': fileSize
},
maxContentLength: Infinity,
maxBodyLength: Infinity
});
console.log(`[CloudStorage] Successfully uploaded ${fileName}`);
// 4. Send PUT request, note that URL is fixed
const response = await axios.put(url, fileStream, {
headers: headers,
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
// 5. Check if the returned JSON Body indicates real success
if (response.data && response.data.code === 200) {
logger.info(
`[CloudStorage] Successfully uploaded ${fileName}. Server message: ${response.data.message}`
);
} else {
// Even if HTTP status code is 200, server may return business errors
const errorMessage = response.data
? response.data.message
: "Unknown server error after upload";
throw NetworkError.withStatus(
`Upload failed on server: ${errorMessage} (Code: ${response.data?.code})`,
response.status || 500
);
}
} catch (error: any) {
// Determine if it's a network error or file error
if (error.response) {
// HTTP error response
const statusCode = error.response.status;
throw NetworkError.withStatus(
`Upload failed: ${error.message}`,
statusCode
);
} else if (error.request) {
// Request made but no response (network issue)
throw NetworkError.timeout();
} else if (error.code === 'ENOENT') {
// File not found
throw FileError.notFound(filePath);
} else {
// Other file/system errors
throw FileError.writeError(filePath, error.message);
}
// Error handling logic
if (error.response) {
// HTTP error response
const statusCode = error.response.status;
logger.error(
`[CloudStorage] HTTP Error: ${statusCode}`,
new Error(JSON.stringify(error.response.data))
);
throw NetworkError.withStatus(
`Upload failed: ${error.message}`,
statusCode
);
} else if (error.request) {
// Request was made but no response received
logger.error("[CloudStorage] Network Error: No response received.");
throw NetworkError.timeout();
} else if (error.code === "ENOENT") {
// File not found
throw FileError.notFound(filePath);
} else {
// Other errors
logger.error(
"[CloudStorage] Upload Error:",
error instanceof Error ? error : new Error(error.message)
);
throw FileError.writeError(filePath, error.message);
}
}
}
private static sanitizeFilename(filename: string): string {
return filename.replace(/[^a-z0-9]/gi, '_').toLowerCase();
return filename.replace(/[^a-z0-9]/gi, "_").toLowerCase();
}
}

View File

@@ -1,5 +1,6 @@
import { Box, FormControlLabel, Switch, TextField, Typography } from '@mui/material';
import React from 'react';
import { Alert, Box, Button, CircularProgress, FormControlLabel, Switch, TextField, Typography } from '@mui/material';
import axios from 'axios';
import React, { useState } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import { Settings } from '../../types';
@@ -10,10 +11,113 @@ interface CloudDriveSettingsProps {
const CloudDriveSettings: React.FC<CloudDriveSettingsProps> = ({ settings, onChange }) => {
const { t } = useLanguage();
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
// Validate API URL format
const validateApiUrl = (url: string): string | null => {
if (!url.trim()) {
return 'This field is required';
}
try {
const urlObj = new URL(url);
if (!urlObj.protocol.startsWith('http')) {
return 'URL must start with http:// or https://';
}
if (!url.includes('/api/fs/put')) {
return 'URL should end with /api/fs/put';
}
} catch {
return 'Invalid URL format';
}
return null;
};
// Validate upload path
const validateUploadPath = (path: string): string | null => {
if (!path.trim()) {
return null; // Optional field, but recommend starting with /
}
if (!path.startsWith('/')) {
return 'Path should start with / (e.g., /mytube-uploads)';
}
return null;
};
const apiUrlError = settings.cloudDriveEnabled && settings.openListApiUrl
? validateApiUrl(settings.openListApiUrl)
: null;
const uploadPathError = settings.cloudDriveEnabled && settings.cloudDrivePath
? validateUploadPath(settings.cloudDrivePath)
: null;
const handleTestConnection = async () => {
if (!settings.openListApiUrl || !settings.openListToken) {
setTestResult({
type: 'error',
message: 'Please fill in API URL and Token first'
});
return;
}
setTesting(true);
setTestResult(null);
try {
// Test connection by attempting to upload a small test file
// Or we could use a different Alist API endpoint to test
const testUrl = settings.openListApiUrl;
// Try to make a HEAD request or use a test endpoint
// For now, we'll just validate the URL format and token presence
const response = await axios.head(testUrl, {
headers: {
Authorization: settings.openListToken,
},
timeout: 5000,
validateStatus: () => true, // Accept any status for testing
});
if (response.status < 500) {
setTestResult({
type: 'success',
message: 'Connection test successful! Settings are valid.'
});
} else {
setTestResult({
type: 'error',
message: `Connection failed: Server returned status ${response.status}`
});
}
} catch (error: any) {
if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') {
setTestResult({
type: 'error',
message: 'Cannot connect to server. Please check the API URL.'
});
} else if (error.response?.status === 401 || error.response?.status === 403) {
setTestResult({
type: 'error',
message: 'Authentication failed. Please check your token.'
});
} else {
setTestResult({
type: 'error',
message: `Connection test failed: ${error.message || 'Unknown error'}`
});
}
} finally {
setTesting(false);
}
};
return (
<Box>
<Typography variant="h6" gutterBottom>{t('cloudDriveSettings')} (beta)</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Automatically upload videos to cloud storage (Alist) and delete local files after successful upload.
</Typography>
<FormControlLabel
control={
<Switch
@@ -31,22 +135,62 @@ const CloudDriveSettings: React.FC<CloudDriveSettingsProps> = ({ settings, onCha
value={settings.openListApiUrl || ''}
onChange={(e) => onChange('openListApiUrl', e.target.value)}
helperText={t('apiUrlHelper')}
error={!!apiUrlError}
required
fullWidth
/>
{apiUrlError && (
<Typography variant="caption" color="error" sx={{ mt: -1.5 }}>
{apiUrlError}
</Typography>
)}
<TextField
label={t('token')}
value={settings.openListToken || ''}
onChange={(e) => onChange('openListToken', e.target.value)}
type="password"
helperText="Alist API token for authentication"
required
fullWidth
/>
<TextField
label={t('uploadPath')}
value={settings.cloudDrivePath || ''}
onChange={(e) => onChange('cloudDrivePath', e.target.value)}
helperText={t('cloudDrivePathHelper')}
error={!!uploadPathError}
placeholder="/mytube-uploads"
fullWidth
/>
{uploadPathError && (
<Typography variant="caption" color="error" sx={{ mt: -1.5 }}>
{uploadPathError}
</Typography>
)}
<Button
variant="outlined"
onClick={handleTestConnection}
disabled={testing || !settings.openListApiUrl || !settings.openListToken}
startIcon={testing ? <CircularProgress size={16} /> : null}
sx={{ alignSelf: 'flex-start' }}
>
{testing ? 'Testing...' : 'Test Connection'}
</Button>
{testResult && (
<Alert severity={testResult.type} onClose={() => setTestResult(null)}>
{testResult.message}
</Alert>
)}
<Alert severity="info" sx={{ mt: 1 }}>
<Typography variant="body2">
<strong>{t('note')}:</strong> {t('cloudDriveNote')}
</Typography>
</Alert>
</Box>
)}
</Box>

View File

@@ -134,6 +134,8 @@ export const ar = {
token: "الرمز المميز (Token)",
uploadPath: "مسار التحميل",
cloudDrivePathHelper: "مسار الدليل في التخزين السحابي، مثال: /mytube-uploads",
cloudDriveNote:
"بعد تفعيل هذه الميزة، سيتم تحميل مقاطع الفيديو التي تم تنزيلها حديثًا تلقائيًا إلى التخزين السحابي وسيتم حذف الملفات المحلية. سيتم تشغيل مقاطع الفيديو من التخزين السحابي عبر الوكيل.",
// Manage
manageContent: "إدارة المحتوى",
@@ -285,6 +287,7 @@ export const ar = {
cancel: "إلغاء",
confirm: "تأكيد",
save: "حفظ",
note: "ملاحظة",
on: "تشغيل",
off: "إيقاف",
continue: "متابعة",

View File

@@ -130,6 +130,8 @@ export const de = {
uploadPath: "Upload-Pfad",
cloudDrivePathHelper:
"Verzeichnispfad im Cloud-Speicher, z.B. /mytube-uploads",
cloudDriveNote:
"Nach Aktivierung dieser Funktion werden neu heruntergeladene Videos automatisch in den Cloud-Speicher hochgeladen und lokale Dateien werden gelöscht. Videos werden über einen Proxy aus dem Cloud-Speicher abgespielt.",
manageContent: "Inhalte Verwalten",
videos: "Videos",
@@ -252,6 +254,7 @@ export const de = {
cancel: "Abbrechen",
confirm: "Bestätigen",
save: "Speichern",
note: "Hinweis",
on: "Ein",
off: "Aus",
continue: "Weiter",

View File

@@ -132,6 +132,8 @@ export const en = {
token: "Token",
uploadPath: "Upload Path",
cloudDrivePathHelper: "Directory path in cloud drive, e.g. /mytube-uploads",
cloudDriveNote:
"After enabling this feature, newly downloaded videos will be automatically uploaded to cloud storage and local files will be deleted. Videos will be played from cloud storage via proxy.",
// Manage
manageContent: "Manage Content",
@@ -267,6 +269,7 @@ export const en = {
cancel: "Cancel",
confirm: "Confirm",
save: "Save",
note: "Note",
on: "On",
off: "Off",
continue: "Continue",

View File

@@ -143,6 +143,8 @@ export const es = {
token: "Token",
uploadPath: "Ruta de carga",
cloudDrivePathHelper: "Ruta del directorio en la nube, ej. /mytube-uploads",
cloudDriveNote:
"Después de habilitar esta función, los videos recién descargados se subirán automáticamente al almacenamiento en la nube y se eliminarán los archivos locales. Los videos se reproducirán desde el almacenamiento en la nube a través de un proxy.",
manageContent: "Gestionar Contenido",
videos: "Videos",
@@ -276,6 +278,7 @@ export const es = {
cancel: "Cancelar",
confirm: "Confirmar",
save: "Guardar",
note: "Nota",
on: "Activado",
off: "Desactivado",
expand: "Expandir",

View File

@@ -145,6 +145,8 @@ export const fr = {
uploadPath: "Chemin de téléchargement",
cloudDrivePathHelper:
"Chemin du répertoire dans le cloud, ex. /mytube-uploads",
cloudDriveNote:
"Après avoir activé cette fonctionnalité, les vidéos nouvellement téléchargées seront automatiquement téléchargées vers le stockage cloud et les fichiers locaux seront supprimés. Les vidéos seront lues depuis le stockage cloud via un proxy.",
// Manage
manageContent: "Gérer le contenu",
@@ -287,6 +289,7 @@ export const fr = {
cancel: "Annuler",
confirm: "Confirmer",
save: "Enregistrer",
note: "Note",
on: "On",
off: "Off",
continue: "Continuer",

View File

@@ -140,6 +140,8 @@ export const ja = {
uploadPath: "アップロードパス",
cloudDrivePathHelper:
"クラウドドライブ内のディレクトリパス、例: /mytube-uploads",
cloudDriveNote:
"この機能を有効にすると、新しくダウンロードされた動画は自動的にクラウドストレージにアップロードされ、ローカルファイルは削除されます。動画はプロキシ経由でクラウドストレージから再生されます。",
// Manage
manageContent: "コンテンツの管理",
@@ -280,6 +282,7 @@ export const ja = {
cancel: "キャンセル",
confirm: "確認",
save: "保存",
note: "注意",
on: "オン",
off: "オフ",
continue: "続行",

View File

@@ -137,6 +137,8 @@ export const ko = {
uploadPath: "업로드 경로",
cloudDrivePathHelper:
"클라우드 드라이브 내 디렉토리 경로, 예: /mytube-uploads",
cloudDriveNote:
"이 기능을 활성화한 후 새로 다운로드된 비디오는 자동으로 클라우드 스토리지에 업로드되고 로컬 파일은 삭제됩니다. 비디오는 프록시를 통해 클라우드 스토리지에서 재생됩니다.",
// Manage
manageContent: "콘텐츠 관리",
@@ -277,6 +279,7 @@ export const ko = {
cancel: "취소",
confirm: "확인",
save: "저장",
note: "참고",
on: "켜기",
off: "끄기",
continue: "계속",

View File

@@ -140,6 +140,8 @@ export const pt = {
token: "Token",
uploadPath: "Caminho de upload",
cloudDrivePathHelper: "Caminho do diretório na nuvem, ex. /mytube-uploads",
cloudDriveNote:
"Após habilitar este recurso, os vídeos recém-baixados serão automaticamente enviados para o armazenamento em nuvem e os arquivos locais serão excluídos. Os vídeos serão reproduzidos do armazenamento em nuvem via proxy.",
// Manage
manageContent: "Gerenciar Conteúdo",
@@ -281,6 +283,7 @@ export const pt = {
cancel: "Cancelar",
confirm: "Confirmar",
save: "Salvar",
note: "Nota",
on: "Ligado",
off: "Desligado",
continue: "Continuar",

View File

@@ -146,6 +146,8 @@ export const ru = {
token: "Токен",
uploadPath: "Путь загрузки",
cloudDrivePathHelper: "Путь к каталогу в облаке, напр. /mytube-uploads",
cloudDriveNote:
"После включения этой функции недавно загруженные видео будут автоматически загружены в облачное хранилище, а локальные файлы будут удалены. Видео будут воспроизводиться из облачного хранилища через прокси.",
// Manage
manageContent: "Управление контентом",
@@ -289,6 +291,7 @@ export const ru = {
cancel: "Отмена",
confirm: "Подтвердить",
save: "Сохранить",
note: "Примечание",
on: "Вкл.",
off: "Выкл",
continue: "Продолжить",

View File

@@ -132,6 +132,8 @@ export const zh = {
token: "Token",
uploadPath: "上传路径",
cloudDrivePathHelper: "云端存储中的目录路径,例如:/mytube-uploads",
cloudDriveNote:
"启用此功能后,新下载的视频将自动上传到云端存储,本地文件将被删除。视频将通过代理从云端存储播放。",
// Manage
manageContent: "内容管理",
@@ -270,6 +272,7 @@ export const zh = {
cancel: "取消",
confirm: "确认",
save: "保存",
note: "注意",
on: "开启",
off: "关",
continue: "继续",