feat: Add Cloud Storage Service and settings for OpenList

This commit is contained in:
Peifan Li
2025-11-30 17:07:10 -05:00
parent 19383ad582
commit cffe2319c2
18 changed files with 393 additions and 28 deletions

View File

@@ -14,6 +14,10 @@ interface Settings {
maxConcurrentDownloads: number;
language: string;
tags?: string[];
cloudDriveEnabled?: boolean;
openListApiUrl?: string;
openListToken?: string;
cloudDrivePath?: string;
}
const defaultSettings: Settings = {
@@ -22,7 +26,11 @@ const defaultSettings: Settings = {
defaultAutoPlay: false,
defaultAutoLoop: false,
maxConcurrentDownloads: 3,
language: 'en'
language: 'en',
cloudDriveEnabled: false,
openListApiUrl: '',
openListToken: '',
cloudDrivePath: ''
};
export const getSettings = async (_req: Request, res: Response) => {

View File

@@ -0,0 +1,173 @@
import axios from 'axios';
import fs from 'fs-extra';
import path from 'path';
import { getSettings } from './storageService';
interface CloudDriveConfig {
enabled: boolean;
apiUrl: string;
token: string;
uploadPath: string;
}
export class CloudStorageService {
private static getConfig(): CloudDriveConfig {
const settings = getSettings();
return {
enabled: settings.cloudDriveEnabled || false,
apiUrl: settings.openListApiUrl || '',
token: settings.openListToken || '',
uploadPath: settings.cloudDrivePath || '/'
};
}
static async uploadVideo(videoData: any): Promise<void> {
const config = this.getConfig();
if (!config.enabled || !config.apiUrl || !config.token) {
return;
}
console.log(`[CloudStorage] Starting upload for video: ${videoData.title}`);
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);
} else {
console.error(`[CloudStorage] Video file not found: ${videoData.videoPath}`);
}
}
// Upload Thumbnail
if (videoData.thumbnailPath) {
const absoluteThumbPath = this.resolveAbsolutePath(videoData.thumbnailPath);
if (absoluteThumbPath && fs.existsSync(absoluteThumbPath)) {
await this.uploadFile(absoluteThumbPath, config);
}
}
// Upload Metadata (JSON)
const metadata = {
title: videoData.title,
description: videoData.description,
author: videoData.author,
sourceUrl: videoData.sourceUrl,
tags: videoData.tags,
createdAt: videoData.createdAt,
...videoData
};
const metadataFileName = `${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
fs.unlinkSync(metadataPath);
console.log(`[CloudStorage] Upload completed for: ${videoData.title}`);
} catch (error) {
console.error(`[CloudStorage] Upload failed for ${videoData.title}:`, error);
}
}
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
];
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;
}
}
}
return null;
}
private static async uploadFile(filePath: string, config: CloudDriveConfig): Promise<void> {
const fileName = path.basename(filePath);
const fileSize = fs.statSync(filePath).size;
const fileStream = fs.createReadStream(filePath);
console.log(`[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}`;
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}`);
} catch (error: any) {
// Try POST if PUT fails, some APIs might differ
console.warn(`[CloudStorage] PUT failed, trying POST... Error: ${error.message}`);
try {
// For POST, we might need FormData, but let's try raw body first or check if it's a specific API.
// If it's Alist/WebDAV, PUT is standard.
// If it's a custom API, it might expect FormData.
// Let's stick to PUT for now as it's common for "Save to Cloud" generic interfaces.
throw error;
} catch (retryError) {
throw retryError;
}
}
}
private static sanitizeFilename(filename: string): string {
return filename.replace(/[^a-z0-9]/gi, '_').toLowerCase();
}
}

View File

@@ -1,3 +1,4 @@
import { CloudStorageService } from "./CloudStorageService";
import { createDownloadTask } from "./downloadService";
import * as storageService from "./storageService";
@@ -279,6 +280,16 @@ class DownloadManager {
author: videoData.author,
});
// Trigger Cloud Upload (Async, don't await to block queue processing?)
// Actually, we might want to await it if we want to ensure it's done before resolving,
// but that would block the download queue.
// Let's run it in background but log it.
CloudStorageService.uploadVideo({
...videoData,
title: finalTitle || task.title,
sourceUrl: task.sourceUrl
}).catch(err => console.error("Background cloud upload failed:", err));
task.resolve(result);
} catch (error) {
console.error(`Error downloading ${task.title}:`, error);

View File

@@ -4,6 +4,8 @@ import {
Forward10,
Fullscreen,
FullscreenExit,
KeyboardDoubleArrowLeft,
KeyboardDoubleArrowRight,
Loop,
Pause,
PlayArrow,
@@ -139,7 +141,7 @@ const VideoControls: React.FC<VideoControlsProps> = ({
};
return (
<Box sx={{ width: '100%', bgcolor: 'black', borderRadius: 2, overflow: 'hidden', boxShadow: 4, position: 'relative' }}>
<Box sx={{ width: '100%', bgcolor: 'black', borderRadius: { xs: 0, sm: 2 }, overflow: 'hidden', boxShadow: 4, position: 'relative' }}>
<video
ref={videoRef}
style={{ width: '100%', aspectRatio: '16/9', display: 'block' }}
@@ -216,6 +218,11 @@ const VideoControls: React.FC<VideoControlsProps> = ({
{/* Row 2 on Mobile: Seek Controls */}
<Stack direction="row" spacing={1} justifyContent="center" width={{ xs: '100%', sm: 'auto' }}>
<Tooltip title="-10m">
<Button variant="outlined" onClick={() => handleSeek(-600)}>
<KeyboardDoubleArrowLeft />
</Button>
</Tooltip>
<Tooltip title="-1m">
<Button variant="outlined" onClick={() => handleSeek(-60)}>
<FastRewind />
@@ -236,6 +243,11 @@ const VideoControls: React.FC<VideoControlsProps> = ({
<FastForward />
</Button>
</Tooltip>
<Tooltip title="+10m">
<Button variant="outlined" onClick={() => handleSeek(600)}>
<KeyboardDoubleArrowRight />
</Button>
</Tooltip>
</Stack>
</Stack>
</Box>

View File

@@ -19,7 +19,8 @@ import { Video } from '../types';
const AuthorVideos: React.FC = () => {
const { t } = useLanguage();
const { author } = useParams<{ author: string }>();
const { authorName } = useParams<{ authorName: string }>();
const author = authorName;
const navigate = useNavigate();
const { videos, loading, deleteVideo } = useVideo();
const { collections } = useCollection();
@@ -79,7 +80,7 @@ const AuthorVideos: React.FC = () => {
</Avatar>
<Box>
<Typography variant="h4" component="h1" fontWeight="bold">
{author ? decodeURIComponent(author) : t('unknownAuthor')}
{author || t('unknownAuthor')}
</Typography>
<Typography variant="subtitle1" color="text.secondary">
{authorVideos.length} {t('videos')}

View File

@@ -339,6 +339,11 @@ const DownloadPage: React.FC = () => {
secondaryTypographyProps={{ component: 'div' }}
secondary={
<Box component="div" sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{item.sourceUrl && (
<Typography variant="caption" color="primary" component="a" href={item.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
{item.sourceUrl}
</Typography>
)}
<Typography variant="caption" component="span">
{formatDate(item.finishedAt)}
</Typography>

View File

@@ -44,6 +44,10 @@ interface Settings {
maxConcurrentDownloads: number;
language: string;
tags: string[];
cloudDriveEnabled: boolean;
openListApiUrl: string;
openListToken: string;
cloudDrivePath: string;
}
const SettingsPage: React.FC = () => {
@@ -58,7 +62,11 @@ const SettingsPage: React.FC = () => {
defaultAutoLoop: false,
maxConcurrentDownloads: 3,
language: 'en',
tags: []
tags: [],
cloudDriveEnabled: false,
openListApiUrl: '',
openListToken: '',
cloudDrivePath: ''
});
const [newTag, setNewTag] = useState('');
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null);
@@ -469,6 +477,48 @@ const SettingsPage: React.FC = () => {
<Grid size={12}><Divider /></Grid>
{/* Cloud Drive Settings */}
<Grid size={12}>
<Typography variant="h6" gutterBottom>{t('cloudDriveSettings')}</Typography>
<FormControlLabel
control={
<Switch
checked={settings.cloudDriveEnabled || false}
onChange={(e) => handleChange('cloudDriveEnabled', e.target.checked)}
/>
}
label={t('enableAutoSave')}
/>
{settings.cloudDriveEnabled && (
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 600 }}>
<TextField
label={t('apiUrl')}
value={settings.openListApiUrl || ''}
onChange={(e) => handleChange('openListApiUrl', e.target.value)}
helperText={t('apiUrlHelper')}
fullWidth
/>
<TextField
label={t('token')}
value={settings.openListToken || ''}
onChange={(e) => handleChange('openListToken', e.target.value)}
type="password"
fullWidth
/>
<TextField
label={t('uploadPath')}
value={settings.cloudDrivePath || ''}
onChange={(e) => handleChange('cloudDrivePath', e.target.value)}
helperText={t('cloudDrivePathHelper')}
fullWidth
/>
</Box>
)}
</Grid>
<Grid size={12}><Divider /></Grid>
{/* Database Settings */}
<Grid size={12}>
<Typography variant="h6" gutterBottom>{t('database')}</Typography>

View File

@@ -354,9 +354,14 @@ const VideoPlayer: React.FC = () => {
}).slice(0, 10);
}, [video, videos, collections]);
// Scroll to top when video ID changes
useEffect(() => {
window.scrollTo(0, 0);
}, [id]);
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Grid container spacing={4}>
<Container maxWidth={false} disableGutters sx={{ py: { xs: 0, md: 4 }, px: { xs: 0, md: 2 } }}>
<Grid container spacing={{ xs: 0, md: 4 }}>
{/* Main Content Column */}
<Grid size={{ xs: 12, lg: 8 }}>
<VideoControls
@@ -367,6 +372,7 @@ const VideoPlayer: React.FC = () => {
startTime={video.progress || 0}
/>
<Box sx={{ px: { xs: 2, md: 0 } }}>
<VideoInfo
video={video}
onTitleSave={handleSaveTitle}
@@ -388,10 +394,11 @@ const VideoPlayer: React.FC = () => {
showComments={showComments}
onToggleComments={handleToggleComments}
/>
</Box>
</Grid>
{/* Sidebar Column - Up Next */}
<Grid size={{ xs: 12, lg: 4 }}>
<Grid size={{ xs: 12, lg: 4 }} sx={{ p: { xs: 2, md: 0 }, pt: { xs: 2, md: 0 } }}>
<Typography variant="h6" gutterBottom fontWeight="bold">{t('upNext')}</Typography>
<Stack spacing={2}>
{relatedVideos.map(relatedVideo => (

View File

@@ -85,6 +85,15 @@ export const ar = {
cleanupTempFilesSuccess: "تم حذف {count} ملف (ملفات) مؤقت بنجاح.",
cleanupTempFilesFailed: "فشل تنظيف الملفات المؤقتة",
// Cloud Drive
cloudDriveSettings: "التخزين السحابي (OpenList)",
enableAutoSave: "تمكين الحفظ التلقائي في السحابة",
apiUrl: "رابط API",
apiUrlHelper: "مثال: https://your-alist-instance.com/api/fs/put",
token: "الرمز المميز (Token)",
uploadPath: "مسار التحميل",
cloudDrivePathHelper: "مسار الدليل في التخزين السحابي، مثال: /mytube-uploads",
// Manage
manageContent: "إدارة المحتوى",
videos: "فيديوهات",

View File

@@ -42,6 +42,16 @@ export const de = {
cleanupTempFilesActiveDownloads: "Bereinigung nicht möglich, während Downloads aktiv sind. Bitte warten Sie, bis alle Downloads abgeschlossen sind, oder brechen Sie sie ab.",
cleanupTempFilesSuccess: "Erfolgreich {count} temporäre Datei(en) gelöscht.",
cleanupTempFilesFailed: "Fehler beim Bereinigen temporärer Dateien",
// Cloud Drive
cloudDriveSettings: "Cloud-Speicher (OpenList)",
enableAutoSave: "Automatisches Speichern in der Cloud aktivieren",
apiUrl: "API-URL",
apiUrlHelper: "z.B. https://your-alist-instance.com/api/fs/put",
token: "Token",
uploadPath: "Upload-Pfad",
cloudDrivePathHelper: "Verzeichnispfad im Cloud-Speicher, z.B. /mytube-uploads",
manageContent: "Inhalte Verwalten", videos: "Videos", collections: "Sammlungen", allVideos: "Alle Videos",
delete: "Löschen", backToHome: "Zurück zur Startseite", confirmDelete: "Sind Sie sicher, dass Sie dies löschen möchten?",
deleteSuccess: "Erfolgreich gelöscht", deleteFailed: "Löschen fehlgeschlagen", noVideos: "Keine Videos gefunden",

View File

@@ -85,6 +85,15 @@ export const en = {
cleanupTempFilesSuccess: "Successfully deleted {count} temporary file(s).",
cleanupTempFilesFailed: "Failed to clean up temporary files",
// Cloud Drive
cloudDriveSettings: "Cloud Drive (OpenList)",
enableAutoSave: "Enable Auto Save to Cloud",
apiUrl: "API URL",
apiUrlHelper: "e.g. https://your-alist-instance.com/api/fs/put",
token: "Token",
uploadPath: "Upload Path",
cloudDrivePathHelper: "Directory path in cloud drive, e.g. /mytube-uploads",
// Manage
manageContent: "Manage Content",
videos: "Videos",

View File

@@ -40,6 +40,16 @@ export const es = {
cleanupTempFilesActiveDownloads: "No se puede limpiar mientras hay descargas activas. Espera a que todas las descargas terminen o cancélalas primero.",
cleanupTempFilesSuccess: "Se eliminaron exitosamente {count} archivo(s) temporal(es).",
cleanupTempFilesFailed: "Error al limpiar archivos temporales",
// Cloud Drive
cloudDriveSettings: "Almacenamiento en la Nube (OpenList)",
enableAutoSave: "Habilitar guardado automático en la nube",
apiUrl: "URL de la API",
apiUrlHelper: "ej. https://your-alist-instance.com/api/fs/put",
token: "Token",
uploadPath: "Ruta de carga",
cloudDrivePathHelper: "Ruta del directorio en la nube, ej. /mytube-uploads",
manageContent: "Gestionar Contenido", videos: "Videos", collections: "Colecciones", allVideos: "Todos los Videos",
delete: "Eliminar", backToHome: "Volver a Inicio", confirmDelete: "¿Está seguro de que desea eliminar esto?",
deleteSuccess: "Eliminado exitosamente", deleteFailed: "Error al eliminar", noVideos: "No se encontraron videos",

View File

@@ -85,6 +85,15 @@ export const fr = {
cleanupTempFilesSuccess: "{count} fichier(s) temporaire(s) supprimé(s) avec succès.",
cleanupTempFilesFailed: "Échec du nettoyage des fichiers temporaires",
// Cloud Drive
cloudDriveSettings: "Stockage Cloud (OpenList)",
enableAutoSave: "Activer la sauvegarde automatique sur le Cloud",
apiUrl: "URL de l'API",
apiUrlHelper: "ex. https://your-alist-instance.com/api/fs/put",
token: "Jeton (Token)",
uploadPath: "Chemin de téléchargement",
cloudDrivePathHelper: "Chemin du répertoire dans le cloud, ex. /mytube-uploads",
// Manage
manageContent: "Gérer le contenu",
videos: "Vidéos",

View File

@@ -85,6 +85,15 @@ export const ja = {
cleanupTempFilesSuccess: "{count}個の一時ファイルを正常に削除しました。",
cleanupTempFilesFailed: "一時ファイルのクリーンアップに失敗しました",
// Cloud Drive
cloudDriveSettings: "クラウドストレージ (OpenList)",
enableAutoSave: "クラウドへの自動保存を有効にする",
apiUrl: "API URL",
apiUrlHelper: "例: https://your-alist-instance.com/api/fs/put",
token: "トークン",
uploadPath: "アップロードパス",
cloudDrivePathHelper: "クラウドドライブ内のディレクトリパス、例: /mytube-uploads",
// Manage
manageContent: "コンテンツの管理",
videos: "動画",

View File

@@ -85,6 +85,15 @@ export const ko = {
cleanupTempFilesSuccess: "{count}개의 임시 파일을 성공적으로 삭제했습니다.",
cleanupTempFilesFailed: "임시 파일 정리 실패",
// Cloud Drive
cloudDriveSettings: "클라우드 드라이브 (OpenList)",
enableAutoSave: "클라우드 자동 저장 활성화",
apiUrl: "API URL",
apiUrlHelper: "예: https://your-alist-instance.com/api/fs/put",
token: "토큰",
uploadPath: "업로드 경로",
cloudDrivePathHelper: "클라우드 드라이브 내 디렉토리 경로, 예: /mytube-uploads",
// Manage
manageContent: "콘텐츠 관리",
videos: "동영상",

View File

@@ -85,6 +85,15 @@ export const pt = {
cleanupTempFilesSuccess: "{count} arquivo(s) temporário(s) excluído(s) com sucesso.",
cleanupTempFilesFailed: "Falha ao limpar arquivos temporários",
// Cloud Drive
cloudDriveSettings: "Armazenamento em Nuvem (OpenList)",
enableAutoSave: "Ativar salvamento automático na nuvem",
apiUrl: "URL da API",
apiUrlHelper: "ex. https://your-alist-instance.com/api/fs/put",
token: "Token",
uploadPath: "Caminho de upload",
cloudDrivePathHelper: "Caminho do diretório na nuvem, ex. /mytube-uploads",
// Manage
manageContent: "Gerenciar Conteúdo",
videos: "Vídeos",
@@ -150,6 +159,12 @@ export const pt = {
titleUpdateFailed: "Falha ao atualizar título",
refreshThumbnail: "Atualizar miniatura",
thumbnailRefreshed: "Miniatura atualizada com sucesso",
thumbnailRefreshFailed: "Falha ao atualizar miniatura",
videoUpdated: "Vídeo atualizado com sucesso",
videoUpdateFailed: "Falha ao atualizar vídeo",
failedToLoadVideos: "Falha ao carregar vídeos. Por favor, tente novamente mais tarde.",
videoRemovedSuccessfully: "Vídeo removido com sucesso",
failedToDeleteVideo: "Falha ao excluir vídeo",
// Snackbar Messages
videoDownloading: "Baixando vídeo",
downloadStartedSuccessfully: "Download iniciado com sucesso",

View File

@@ -85,6 +85,15 @@ export const ru = {
cleanupTempFilesSuccess: "Успешно удалено {count} временных файлов.",
cleanupTempFilesFailed: "Не удалось очистить временные файлы",
// Cloud Drive
cloudDriveSettings: "Облачное хранилище (OpenList)",
enableAutoSave: "Включить автосохранение в облако",
apiUrl: "URL API",
apiUrlHelper: "напр. https://your-alist-instance.com/api/fs/put",
token: "Токен",
uploadPath: "Путь загрузки",
cloudDrivePathHelper: "Путь к каталогу в облаке, напр. /mytube-uploads",
// Manage
manageContent: "Управление контентом",
videos: "Видео",

View File

@@ -85,6 +85,15 @@ export const zh = {
cleanupTempFilesSuccess: "成功删除了 {count} 个临时文件。",
cleanupTempFilesFailed: "清理临时文件失败",
// Cloud Drive
cloudDriveSettings: "云端存储 (OpenList)",
enableAutoSave: "启用自动保存到云端",
apiUrl: "API 地址",
apiUrlHelper: "例如https://your-alist-instance.com/api/fs/put",
token: "Token",
uploadPath: "上传路径",
cloudDrivePathHelper: "云端存储中的目录路径,例如:/mytube-uploads",
// Manage
manageContent: "内容管理",
videos: "视频",