Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
881a159777 | ||
|
|
26fd63eada | ||
|
|
f20ecd42e1 | ||
|
|
ae8507a609 | ||
|
|
7969412091 | ||
|
|
c88909b658 | ||
|
|
618d905e6d | ||
|
|
88e452fc61 | ||
|
|
cffe2319c2 | ||
|
|
19383ad582 |
@@ -17,6 +17,7 @@
|
||||
- **视频上传**:直接上传本地视频文件到您的库,并自动生成缩略图。
|
||||
- **Bilibili 支持**:支持下载单个视频、多P视频以及整个合集/系列。
|
||||
- **并行下载**:支持队列下载,可同时追踪多个下载任务的进度。
|
||||
- **批量下载**:一次性添加多个视频链接到下载队列。
|
||||
- **并发下载限制**:设置同时下载的数量限制以管理带宽。
|
||||
- **本地库**:自动保存视频缩略图和元数据,提供丰富的浏览体验。
|
||||
- **视频播放器**:自定义播放器,支持播放/暂停、循环、快进/快退、全屏和调光控制。
|
||||
|
||||
@@ -17,6 +17,7 @@ A YouTube/Bilibili/MissAV video downloader and player application that allows yo
|
||||
- **Video Upload**: Upload local video files directly to your library with automatic thumbnail generation.
|
||||
- **Bilibili Support**: Support for downloading single videos, multi-part videos, and entire collections/series.
|
||||
- **Parallel Downloads**: Queue multiple downloads and track their progress simultaneously.
|
||||
- **Batch Download**: Add multiple video URLs at once to the download queue.
|
||||
- **Concurrent Download Limit**: Set a limit on the number of simultaneous downloads to manage bandwidth.
|
||||
- **Local Library**: Automatically save video thumbnails and metadata for a rich browsing experience.
|
||||
- **Video Player**: Custom player with Play/Pause, Loop, Seek, Full-screen, and Dimming controls.
|
||||
|
||||
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "backend",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.2",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.3",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "ts-node src/server.ts",
|
||||
|
||||
@@ -14,6 +14,11 @@ interface Settings {
|
||||
maxConcurrentDownloads: number;
|
||||
language: string;
|
||||
tags?: string[];
|
||||
cloudDriveEnabled?: boolean;
|
||||
openListApiUrl?: string;
|
||||
openListToken?: string;
|
||||
cloudDrivePath?: string;
|
||||
homeSidebarOpen?: boolean;
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
@@ -22,7 +27,12 @@ const defaultSettings: Settings = {
|
||||
defaultAutoPlay: false,
|
||||
defaultAutoLoop: false,
|
||||
maxConcurrentDownloads: 3,
|
||||
language: 'en'
|
||||
language: 'en',
|
||||
cloudDriveEnabled: false,
|
||||
openListApiUrl: '',
|
||||
openListToken: '',
|
||||
cloudDrivePath: '',
|
||||
homeSidebarOpen: true
|
||||
};
|
||||
|
||||
export const getSettings = async (_req: Request, res: Response) => {
|
||||
|
||||
173
backend/src/services/CloudStorageService.ts
Normal file
173
backend/src/services/CloudStorageService.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.2",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
71
frontend/src/components/BatchDownloadModal.tsx
Normal file
71
frontend/src/components/BatchDownloadModal.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
TextField
|
||||
} from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
interface BatchDownloadModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (urls: string[]) => void;
|
||||
}
|
||||
|
||||
const BatchDownloadModal: React.FC<BatchDownloadModalProps> = ({ open, onClose, onConfirm }) => {
|
||||
const { t } = useLanguage();
|
||||
const [text, setText] = useState('');
|
||||
|
||||
const handleConfirm = () => {
|
||||
const urls = text
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
|
||||
onConfirm(urls);
|
||||
setText('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setText('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{t('batchDownload') || 'Batch Download'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText sx={{ mb: 2 }}>
|
||||
{t('batchDownloadDescription') || 'Paste multiple URLs below, one per line.'}
|
||||
</DialogContentText>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
id="urls"
|
||||
label={t('urls') || 'URLs'}
|
||||
type="text"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={10}
|
||||
variant="outlined"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="https://www.youtube.com/watch?v=...\nhttps://www.bilibili.com/video/..."
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>{t('cancel') || 'Cancel'}</Button>
|
||||
<Button onClick={handleConfirm} variant="contained" disabled={!text.trim()}>
|
||||
{t('addToQueue') || 'Add to Queue'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default BatchDownloadModal;
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
Brightness4,
|
||||
Brightness7,
|
||||
Clear,
|
||||
CloudUpload,
|
||||
Download,
|
||||
Menu as MenuIcon,
|
||||
Search,
|
||||
@@ -17,7 +16,7 @@ import {
|
||||
CircularProgress,
|
||||
ClickAwayListener,
|
||||
Collapse,
|
||||
Divider,
|
||||
Fade,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
Menu,
|
||||
@@ -39,7 +38,7 @@ import { Collection, Video } from '../types';
|
||||
import AuthorsList from './AuthorsList';
|
||||
import Collections from './Collections';
|
||||
import TagsList from './TagsList';
|
||||
import UploadModal from './UploadModal';
|
||||
|
||||
|
||||
interface DownloadInfo {
|
||||
id: string;
|
||||
@@ -84,7 +83,6 @@ const Header: React.FC<HeaderProps> = ({
|
||||
const [error, setError] = useState<string>('');
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [manageAnchorEl, setManageAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [uploadModalOpen, setUploadModalOpen] = useState<boolean>(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState<boolean>(false);
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
@@ -166,21 +164,11 @@ const Header: React.FC<HeaderProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadSuccess = () => {
|
||||
if (window.location.pathname === '/') {
|
||||
window.location.reload();
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const renderActionButtons = () => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tooltip title={t('uploadVideo')}>
|
||||
<IconButton color="inherit" onClick={() => setUploadModalOpen(true)}>
|
||||
<CloudUpload />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
{(
|
||||
<>
|
||||
@@ -201,6 +189,8 @@ const Header: React.FC<HeaderProps> = ({
|
||||
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
|
||||
mt: 1.5,
|
||||
width: 320,
|
||||
maxHeight: '50vh',
|
||||
overflowY: 'auto',
|
||||
'& .MuiAvatar-root': {
|
||||
width: 32,
|
||||
height: 32,
|
||||
@@ -224,7 +214,12 @@ const Header: React.FC<HeaderProps> = ({
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
slots={{ transition: Fade }}
|
||||
>
|
||||
<MenuItem onClick={() => { handleDownloadsClose(); navigate('/downloads'); }}>
|
||||
<Download sx={{ mr: 2 }} /> {t('manageDownloads') || 'Manage Downloads'}
|
||||
</MenuItem>
|
||||
|
||||
{activeDownloads.map((download) => (
|
||||
<MenuItem key={download.id} sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 1, py: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||
@@ -284,12 +279,6 @@ const Header: React.FC<HeaderProps> = ({
|
||||
</MenuItem>
|
||||
))
|
||||
]}
|
||||
<Divider />
|
||||
<MenuItem onClick={() => { handleDownloadsClose(); navigate('/downloads'); }}>
|
||||
<Typography variant="body2" color="primary" sx={{ fontWeight: 'bold', width: '100%', textAlign: 'center' }}>
|
||||
{t('manageDownloads') || 'Manage Downloads'}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
@@ -318,6 +307,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
overflow: 'visible',
|
||||
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
|
||||
mt: 1.5,
|
||||
width: 320,
|
||||
'&:before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
@@ -335,6 +325,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
slots={{ transition: Fade }}
|
||||
>
|
||||
<MenuItem onClick={() => { handleManageClose(); navigate('/manage'); }}>
|
||||
<VideoLibrary sx={{ mr: 2 }} /> {t('manageContent')}
|
||||
@@ -484,11 +475,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
)}
|
||||
</Toolbar>
|
||||
|
||||
<UploadModal
|
||||
open={uploadModalOpen}
|
||||
onClose={() => setUploadModalOpen(false)}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
/>
|
||||
|
||||
|
||||
</AppBar>
|
||||
</ClickAwayListener>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
useMediaQuery,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Collection, Video } from '../types';
|
||||
@@ -43,6 +43,36 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Helper to parse duration to seconds
|
||||
const parseDuration = (duration: string | number | undefined): number => {
|
||||
if (!duration) return 0;
|
||||
if (typeof duration === 'number') return duration;
|
||||
|
||||
if (duration.includes(':')) {
|
||||
const parts = duration.split(':').map(part => parseInt(part, 10));
|
||||
if (parts.length === 3) {
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
} else if (parts.length === 2) {
|
||||
return parts[0] * 60 + parts[1];
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseInt(duration, 10);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isMobile && video.videoPath) {
|
||||
setIsHovered(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsHovered(false);
|
||||
};
|
||||
|
||||
// Format the date (assuming format YYYYMMDD from youtube-dl)
|
||||
const formatDate = (dateString: string) => {
|
||||
@@ -158,7 +188,12 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
||||
border: isFirstInAnyCollection ? `1px solid ${theme.palette.primary.main}` : 'none'
|
||||
}}
|
||||
>
|
||||
<CardActionArea onClick={handleVideoNavigation} sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}>
|
||||
<CardActionArea
|
||||
onClick={handleVideoNavigation}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
|
||||
>
|
||||
<Box sx={{ position: 'relative', paddingTop: '56.25%' /* 16:9 aspect ratio */ }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
@@ -170,7 +205,9 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover'
|
||||
objectFit: 'cover',
|
||||
opacity: isHovered ? 0 : 1,
|
||||
transition: 'opacity 0.2s'
|
||||
}}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
@@ -179,6 +216,44 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{isHovered && video.videoPath && (
|
||||
<Box
|
||||
component="video"
|
||||
ref={videoRef}
|
||||
src={`${BACKEND_URL}${video.videoPath}`}
|
||||
muted
|
||||
autoPlay
|
||||
playsInline
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
bgcolor: 'black'
|
||||
}}
|
||||
onLoadedMetadata={(e) => {
|
||||
const videoEl = e.target as HTMLVideoElement;
|
||||
const duration = parseDuration(video.duration);
|
||||
if (duration > 5) {
|
||||
videoEl.currentTime = Math.max(0, (duration / 2) - 2.5);
|
||||
}
|
||||
}}
|
||||
onTimeUpdate={(e) => {
|
||||
const videoEl = e.target as HTMLVideoElement;
|
||||
const duration = parseDuration(video.duration);
|
||||
const startTime = Math.max(0, (duration / 2) - 2.5);
|
||||
const endTime = startTime + 5;
|
||||
|
||||
if (videoEl.currentTime >= endTime) {
|
||||
videoEl.currentTime = startTime;
|
||||
videoEl.play();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{video.partNumber && video.totalParts && video.totalParts > 1 && (
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
Forward10,
|
||||
Fullscreen,
|
||||
FullscreenExit,
|
||||
KeyboardDoubleArrowLeft,
|
||||
KeyboardDoubleArrowRight,
|
||||
Loop,
|
||||
Pause,
|
||||
PlayArrow,
|
||||
@@ -90,6 +92,26 @@ const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ignore if typing in an input or textarea
|
||||
if (document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowLeft') {
|
||||
handleSeek(-10);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
handleSeek(10);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (videoRef.current) {
|
||||
if (isPlaying) {
|
||||
@@ -139,7 +161,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' }}
|
||||
@@ -215,7 +237,12 @@ const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
</Stack>
|
||||
|
||||
{/* Row 2 on Mobile: Seek Controls */}
|
||||
<Stack direction="row" spacing={1} justifyContent="center" width={{ xs: '100%', sm: 'auto' }}>
|
||||
<Stack direction="row" spacing={0.5} 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 +263,11 @@ const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
<FastForward />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="+10m">
|
||||
<Button variant="outlined" onClick={() => handleSeek(600)}>
|
||||
<KeyboardDoubleArrowRight />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -167,6 +167,33 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Tags Section */}
|
||||
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
|
||||
<LocalOffer color="action" fontSize="small" />
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={availableTags}
|
||||
value={video.tags || []}
|
||||
isOptionEqualToValue={(option, value) => option === value}
|
||||
onChange={(_, newValue) => onTagsUpdate(newValue)}
|
||||
slotProps={{
|
||||
chip: { variant: 'outlined', size: 'small' }
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
variant="standard"
|
||||
placeholder={!video.tags || video.tags.length === 0 ? (t('tags') || 'Tags') : ''}
|
||||
sx={{ minWidth: 200 }}
|
||||
slotProps={{
|
||||
input: { ...params.InputProps, disableUnderline: true }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
justifyContent="space-between"
|
||||
@@ -223,7 +250,7 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Box sx={{ bgcolor: 'background.paper', p: 2, borderRadius: 2 }}>
|
||||
<Stack direction="row" spacing={3} alignItems="center" flexWrap="wrap">
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={{ xs: 1, sm: 3 }} alignItems={{ xs: 'flex-start', sm: 'center' }} flexWrap="wrap">
|
||||
{video.sourceUrl && (
|
||||
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<a href={video.sourceUrl} target="_blank" rel="noopener noreferrer" style={{ color: theme.palette.primary.main, textDecoration: 'none', display: 'flex', alignItems: 'center' }}>
|
||||
@@ -256,8 +283,8 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
||||
|
||||
{videoCollections.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>{t('collections')}:</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
|
||||
<Typography variant="subtitle2" sx={{ mr: 1 }}>{t('collections')}:</Typography>
|
||||
{videoCollections.map(c => (
|
||||
<Chip
|
||||
key={c.id}
|
||||
@@ -267,40 +294,14 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
clickable
|
||||
sx={{ mb: 1 }}
|
||||
size="small"
|
||||
sx={{ my: 0.5 }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Tags Section */}
|
||||
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
|
||||
<LocalOffer color="action" fontSize="small" />
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={availableTags}
|
||||
value={video.tags || []}
|
||||
isOptionEqualToValue={(option, value) => option === value}
|
||||
onChange={(_, newValue) => onTagsUpdate(newValue)}
|
||||
slotProps={{
|
||||
chip: { variant: 'outlined', size: 'small' }
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
variant="standard"
|
||||
placeholder={!video.tags || video.tags.length === 0 ? (t('tags') || 'Tags') : ''}
|
||||
sx={{ minWidth: 200 }}
|
||||
slotProps={{
|
||||
input: { ...params.InputProps, disableUnderline: true }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -2,8 +2,11 @@ import {
|
||||
Cancel as CancelIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
ClearAll as ClearAllIcon,
|
||||
CloudUpload,
|
||||
Delete as DeleteIcon,
|
||||
Error as ErrorIcon
|
||||
Error as ErrorIcon,
|
||||
FindInPage,
|
||||
PlaylistAdd as PlaylistAddIcon
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
Box,
|
||||
@@ -14,6 +17,7 @@ import {
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Pagination,
|
||||
Paper,
|
||||
Tab,
|
||||
Tabs,
|
||||
@@ -22,11 +26,15 @@ import {
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { useState } from 'react';
|
||||
import BatchDownloadModal from '../components/BatchDownloadModal';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import UploadModal from '../components/UploadModal';
|
||||
import { useDownload } from '../contexts/DownloadContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useSnackbar } from '../contexts/SnackbarContext';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
interface DownloadHistoryItem {
|
||||
id: string;
|
||||
@@ -70,9 +78,48 @@ function CustomTabPanel(props: TabPanelProps) {
|
||||
const DownloadPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const { activeDownloads, queuedDownloads } = useDownload();
|
||||
const { activeDownloads, queuedDownloads, handleVideoSubmit } = useDownload();
|
||||
const queryClient = useQueryClient();
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [showBatchModal, setShowBatchModal] = useState(false);
|
||||
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
||||
const [showScanConfirmModal, setShowScanConfirmModal] = useState(false);
|
||||
const [queuePage, setQueuePage] = useState(1);
|
||||
const [historyPage, setHistoryPage] = useState(1);
|
||||
|
||||
// Scan files mutation
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await axios.post(`${API_URL}/scan-files`);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
showSnackbar(t('scanFilesSuccess').replace('{count}', data.addedCount.toString()) || `Scan complete. ${data.addedCount} files added.`);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
showSnackbar(`${t('scanFilesFailed') || 'Scan failed'}: ${error.response?.data?.details || error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const handleUploadSuccess = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleBatchSubmit = async (urls: string[]) => {
|
||||
// We'll process them sequentially to be safe, or just fire them all.
|
||||
// Let's fire them all but with a small delay or just let the context handle it.
|
||||
// Since handleVideoSubmit is async, we can await them.
|
||||
let addedCount = 0;
|
||||
for (const url of urls) {
|
||||
if (url.trim()) {
|
||||
await handleVideoSubmit(url.trim());
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
if (addedCount > 0) {
|
||||
showSnackbar(t('batchTasksAdded', { count: addedCount }) || `${addedCount} tasks added`);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch history with polling
|
||||
const { data: history = [] } = useQuery({
|
||||
@@ -211,9 +258,46 @@ const DownloadPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', p: 2 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
{t('downloads') || 'Downloads'}
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
justifyContent: 'space-between',
|
||||
alignItems: { xs: 'flex-start', sm: 'center' },
|
||||
mb: 2,
|
||||
gap: { xs: 2, sm: 0 }
|
||||
}}>
|
||||
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
|
||||
{t('downloads') || 'Downloads'}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', width: { xs: '100%', sm: 'auto' } }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<FindInPage />}
|
||||
onClick={() => setShowScanConfirmModal(true)}
|
||||
disabled={scanMutation.isPending}
|
||||
>
|
||||
{scanMutation.isPending ? (t('scanning') || 'Scanning...') : (t('scanFiles') || 'Scan Files')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<PlaylistAddIcon />}
|
||||
onClick={() => setShowBatchModal(true)}
|
||||
>
|
||||
{t('addBatchTasks') || 'Add batch tasks'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<CloudUpload />}
|
||||
onClick={() => setUploadModalOpen(true)}
|
||||
>
|
||||
{t('uploadVideo') || 'Upload Video'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tabValue} onChange={handleTabChange} aria-label="download tabs">
|
||||
<Tab label={t('activeDownloads') || 'Active Downloads'} />
|
||||
@@ -286,25 +370,39 @@ const DownloadPage: React.FC = () => {
|
||||
{queuedDownloads.length === 0 ? (
|
||||
<Typography color="textSecondary">{t('noQueuedDownloads') || 'No queued downloads'}</Typography>
|
||||
) : (
|
||||
<List>
|
||||
{queuedDownloads.map((download) => (
|
||||
<Paper key={download.id} sx={{ mb: 2, p: 2 }}>
|
||||
<ListItem
|
||||
disableGutters
|
||||
secondaryAction={
|
||||
<IconButton edge="end" aria-label="remove" onClick={() => handleRemoveFromQueue(download.id)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={download.title}
|
||||
secondary={t('queued') || 'Queued'}
|
||||
/>
|
||||
</ListItem>
|
||||
</Paper>
|
||||
))}
|
||||
</List>
|
||||
<>
|
||||
<List>
|
||||
{queuedDownloads
|
||||
.slice((queuePage - 1) * ITEMS_PER_PAGE, queuePage * ITEMS_PER_PAGE)
|
||||
.map((download) => (
|
||||
<Paper key={download.id} sx={{ mb: 2, p: 2 }}>
|
||||
<ListItem
|
||||
disableGutters
|
||||
secondaryAction={
|
||||
<IconButton edge="end" aria-label="remove" onClick={() => handleRemoveFromQueue(download.id)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={download.title}
|
||||
secondary={t('queued') || 'Queued'}
|
||||
/>
|
||||
</ListItem>
|
||||
</Paper>
|
||||
))}
|
||||
</List>
|
||||
{queuedDownloads.length > ITEMS_PER_PAGE && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
|
||||
<Pagination
|
||||
count={Math.ceil(queuedDownloads.length / ITEMS_PER_PAGE)}
|
||||
page={queuePage}
|
||||
onChange={(_: React.ChangeEvent<unknown>, page: number) => setQueuePage(page)}
|
||||
color="primary"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CustomTabPanel>
|
||||
|
||||
@@ -323,46 +421,88 @@ const DownloadPage: React.FC = () => {
|
||||
{history.length === 0 ? (
|
||||
<Typography color="textSecondary">{t('noDownloadHistory') || 'No download history'}</Typography>
|
||||
) : (
|
||||
<List>
|
||||
{history.map((item: DownloadHistoryItem) => (
|
||||
<Paper key={item.id} sx={{ mb: 2, p: 2 }}>
|
||||
<ListItem
|
||||
disableGutters
|
||||
secondaryAction={
|
||||
<IconButton edge="end" aria-label="remove" onClick={() => handleRemoveFromHistory(item.id)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={item.title}
|
||||
secondaryTypographyProps={{ component: 'div' }}
|
||||
secondary={
|
||||
<Box component="div" sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Typography variant="caption" component="span">
|
||||
{formatDate(item.finishedAt)}
|
||||
</Typography>
|
||||
{item.error && (
|
||||
<Typography variant="caption" color="error" component="span">
|
||||
{item.error}
|
||||
</Typography>
|
||||
<>
|
||||
<List>
|
||||
{history
|
||||
.slice((historyPage - 1) * ITEMS_PER_PAGE, historyPage * ITEMS_PER_PAGE)
|
||||
.map((item: DownloadHistoryItem) => (
|
||||
<Paper key={item.id} sx={{ mb: 2, p: 2 }}>
|
||||
<ListItem
|
||||
disableGutters
|
||||
secondaryAction={
|
||||
<IconButton edge="end" aria-label="remove" onClick={() => handleRemoveFromHistory(item.id)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={item.title}
|
||||
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>
|
||||
{item.error && (
|
||||
<Typography variant="caption" color="error" component="span">
|
||||
{item.error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Box sx={{ mr: 8 }}>
|
||||
{item.status === 'success' ? (
|
||||
<Chip icon={<CheckCircleIcon />} label={t('success') || 'Success'} color="success" size="small" />
|
||||
) : (
|
||||
<Chip icon={<ErrorIcon />} label={t('failed') || 'Failed'} color="error" size="small" />
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Box sx={{ mr: 8 }}>
|
||||
{item.status === 'success' ? (
|
||||
<Chip icon={<CheckCircleIcon />} label={t('success') || 'Success'} color="success" size="small" />
|
||||
) : (
|
||||
<Chip icon={<ErrorIcon />} label={t('failed') || 'Failed'} color="error" size="small" />
|
||||
)}
|
||||
</Box>
|
||||
</ListItem>
|
||||
</Paper>
|
||||
))}
|
||||
</List>
|
||||
</ListItem>
|
||||
</Paper>
|
||||
))}
|
||||
</List>
|
||||
{history.length > ITEMS_PER_PAGE && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
|
||||
<Pagination
|
||||
count={Math.ceil(history.length / ITEMS_PER_PAGE)}
|
||||
page={historyPage}
|
||||
onChange={(_: React.ChangeEvent<unknown>, page: number) => setHistoryPage(page)}
|
||||
color="primary"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CustomTabPanel>
|
||||
|
||||
<BatchDownloadModal
|
||||
open={showBatchModal}
|
||||
onClose={() => setShowBatchModal(false)}
|
||||
onConfirm={handleBatchSubmit}
|
||||
/>
|
||||
<UploadModal
|
||||
open={uploadModalOpen}
|
||||
onClose={() => setUploadModalOpen(false)}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
isOpen={showScanConfirmModal}
|
||||
onClose={() => setShowScanConfirmModal(false)}
|
||||
onConfirm={() => {
|
||||
setShowScanConfirmModal(false);
|
||||
scanMutation.mutate();
|
||||
}}
|
||||
title={t('scanFiles') || 'Scan Files'}
|
||||
message={t('scanFilesConfirmMessage') || 'The system will scan the root folder of the video path to find undocumented video files.'}
|
||||
confirmText={t('continue') || 'Continue'}
|
||||
cancelText={t('cancel') || 'Cancel'}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowBack, Collections as CollectionsIcon, Download, GridView, OndemandVideo, YouTube } from '@mui/icons-material';
|
||||
import { ArrowBack, Collections as CollectionsIcon, Download, GridView, OndemandVideo, ViewSidebar, YouTube } from '@mui/icons-material';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Chip,
|
||||
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
Container,
|
||||
Grid,
|
||||
Pagination,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
ToggleButtonGroup,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import AuthorsList from '../components/AuthorsList';
|
||||
import CollectionCard from '../components/CollectionCard';
|
||||
@@ -27,6 +30,8 @@ import { useDownload } from '../contexts/DownloadContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useVideo } from '../contexts/VideoContext';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const {
|
||||
@@ -54,6 +59,50 @@ const Home: React.FC = () => {
|
||||
const saved = localStorage.getItem('homeViewMode');
|
||||
return (saved as 'collections' | 'all-videos') || 'collections';
|
||||
});
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
|
||||
// Fetch settings on mount
|
||||
useEffect(() => {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
if (response.data && typeof response.data.homeSidebarOpen !== 'undefined') {
|
||||
setIsSidebarOpen(response.data.homeSidebarOpen);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
}
|
||||
};
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const handleSidebarToggle = async () => {
|
||||
const newState = !isSidebarOpen;
|
||||
setIsSidebarOpen(newState);
|
||||
try {
|
||||
// We need to fetch current settings first to not overwrite other settings
|
||||
// Or better, the backend should support partial updates, but the current controller
|
||||
// implementation replaces the whole object or merges with defaults.
|
||||
// Let's fetch first to be safe, similar to how SettingsPage does it,
|
||||
// but for a simple toggle, we might want a lighter endpoint.
|
||||
// However, given the current backend structure, we'll fetch then save.
|
||||
// Actually, the backend `updateSettings` merges with `defaultSettings` but expects the full object
|
||||
// in `req.body` to be the new state.
|
||||
// Wait, looking at `settingsController.ts`: `const newSettings: Settings = req.body;`
|
||||
// and `storageService.saveSettings(newSettings);`
|
||||
// It seems it REPLACES the settings with what's sent.
|
||||
// So we MUST fetch existing settings first.
|
||||
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
const currentSettings = response.data;
|
||||
await axios.post(`${API_URL}/settings`, {
|
||||
...currentSettings,
|
||||
homeSidebarOpen: newState
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save sidebar state:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset page when filters change
|
||||
useEffect(() => {
|
||||
@@ -289,29 +338,65 @@ const Home: React.FC = () => {
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Grid container spacing={4}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'stretch' }}>
|
||||
{/* Sidebar container for Collections, Authors, and Tags */}
|
||||
<Grid size={{ xs: 12, md: 3 }} sx={{ display: { xs: 'none', md: 'block' } }}>
|
||||
<Box sx={{ position: 'sticky', top: 80 }}>
|
||||
<Collections collections={collections} />
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<TagsList
|
||||
availableTags={availableTags}
|
||||
selectedTags={selectedTags}
|
||||
onTagToggle={handleTagToggle}
|
||||
/>
|
||||
<Box sx={{ display: { xs: 'none', md: 'block' } }}>
|
||||
<Collapse in={isSidebarOpen} orientation="horizontal" timeout={300} sx={{ height: '100%', '& .MuiCollapse-wrapper': { height: '100%' }, '& .MuiCollapse-wrapperInner': { height: '100%' } }}>
|
||||
<Box sx={{ width: 280, mr: 4, flexShrink: 0, height: '100%', position: 'relative' }}>
|
||||
<Box sx={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
|
||||
<Box sx={{
|
||||
position: 'sticky',
|
||||
maxHeight: 'calc(100% - 80px)',
|
||||
overflowY: 'auto',
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '6px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: 'transparent',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: 'rgba(0,0,0,0.1)',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
'&:hover::-webkit-scrollbar-thumb': {
|
||||
background: 'rgba(0,0,0,0.2)',
|
||||
},
|
||||
}}>
|
||||
<Collections collections={collections} />
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<TagsList
|
||||
availableTags={availableTags}
|
||||
selectedTags={selectedTags}
|
||||
onTagToggle={handleTagToggle}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<AuthorsList videos={videoArray} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<AuthorsList videos={videoArray} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* Videos grid */}
|
||||
<Grid size={{ xs: 12, md: 9 }}>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
{/* View mode toggle */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h5" fontWeight="bold">
|
||||
<Typography variant="h5" fontWeight="bold" sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
onClick={handleSidebarToggle}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
p: 1,
|
||||
display: { xs: 'none', md: 'inline-flex' },
|
||||
color: 'text.primary',
|
||||
borderColor: 'text.primary',
|
||||
}}
|
||||
>
|
||||
<ViewSidebar sx={{ transform: 'rotate(180deg)' }} />
|
||||
</Button>
|
||||
{t('videos')}
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
@@ -332,10 +417,14 @@ const Home: React.FC = () => {
|
||||
</Box>
|
||||
<Grid container spacing={3}>
|
||||
{displayedVideos.map(video => {
|
||||
const gridProps = isSidebarOpen
|
||||
? { xs: 12, sm: 6, lg: 4, xl: 3 }
|
||||
: { xs: 12, sm: 6, md: 4, lg: 3, xl: 2 };
|
||||
|
||||
// In all-videos mode, ALWAYS render as VideoCard
|
||||
if (viewMode === 'all-videos') {
|
||||
return (
|
||||
<Grid size={{ xs: 12, sm: 6, lg: 4, xl: 3 }} key={video.id}>
|
||||
<Grid size={gridProps} key={video.id}>
|
||||
<VideoCard
|
||||
video={video}
|
||||
collections={collections}
|
||||
@@ -351,7 +440,7 @@ const Home: React.FC = () => {
|
||||
// If it is, render CollectionCard
|
||||
if (collection) {
|
||||
return (
|
||||
<Grid size={{ xs: 12, sm: 6, lg: 4, xl: 3 }} key={`collection-${collection.id}`}>
|
||||
<Grid size={gridProps} key={`collection-${collection.id}`}>
|
||||
<CollectionCard
|
||||
collection={collection}
|
||||
videos={videoArray}
|
||||
@@ -362,7 +451,7 @@ const Home: React.FC = () => {
|
||||
|
||||
// Otherwise render VideoCard for non-collection videos
|
||||
return (
|
||||
<Grid size={{ xs: 12, sm: 6, lg: 4, xl: 3 }} key={video.id}>
|
||||
<Grid size={gridProps} key={video.id}>
|
||||
<VideoCard
|
||||
video={video}
|
||||
collections={collections}
|
||||
@@ -372,6 +461,8 @@ const Home: React.FC = () => {
|
||||
})}
|
||||
</Grid>
|
||||
|
||||
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
|
||||
<Pagination
|
||||
@@ -385,10 +476,10 @@ const Home: React.FC = () => {
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box >
|
||||
)}
|
||||
</Container>
|
||||
</Container >
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ArrowBack,
|
||||
Check,
|
||||
Close,
|
||||
|
||||
Delete,
|
||||
Edit,
|
||||
Folder,
|
||||
@@ -34,6 +35,7 @@ import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import DeleteCollectionModal from '../components/DeleteCollectionModal';
|
||||
|
||||
import { useCollection } from '../contexts/CollectionContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useVideo } from '../contexts/VideoContext';
|
||||
@@ -53,6 +55,7 @@ const ManagePage: React.FC = () => {
|
||||
const [videoToDelete, setVideoToDelete] = useState<string | null>(null);
|
||||
const [showVideoDeleteModal, setShowVideoDeleteModal] = useState<boolean>(false);
|
||||
|
||||
|
||||
// Editing state
|
||||
const [editingVideoId, setEditingVideoId] = useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = useState<string>('');
|
||||
@@ -227,22 +230,29 @@ const ManagePage: React.FC = () => {
|
||||
return video.thumbnailUrl || 'https://via.placeholder.com/120x90?text=No+Thumbnail';
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||
<Typography variant="h4" component="h1" fontWeight="bold">
|
||||
{t('manageContent')}
|
||||
</Typography>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/"
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBack />}
|
||||
>
|
||||
{t('backToHome')}
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
|
||||
<Button
|
||||
component={Link}
|
||||
to="/"
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBack />}
|
||||
>
|
||||
{t('backToHome')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
|
||||
<DeleteCollectionModal
|
||||
isOpen={!!collectionToDelete}
|
||||
onClose={() => !isDeletingCollection && setCollectionToDelete(null)}
|
||||
|
||||
@@ -44,6 +44,11 @@ interface Settings {
|
||||
maxConcurrentDownloads: number;
|
||||
language: string;
|
||||
tags: string[];
|
||||
cloudDriveEnabled: boolean;
|
||||
openListApiUrl: string;
|
||||
openListToken: string;
|
||||
cloudDrivePath: string;
|
||||
homeSidebarOpen?: boolean;
|
||||
}
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
@@ -58,7 +63,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);
|
||||
@@ -119,29 +128,7 @@ const SettingsPage: React.FC = () => {
|
||||
saveMutation.mutate(settings);
|
||||
};
|
||||
|
||||
// Scan files mutation
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await axios.post(`${API_URL}/scan-files`);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: t('success'),
|
||||
message: t('scanFilesSuccess').replace('{count}', data.addedCount.toString()),
|
||||
type: 'success'
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: t('error'),
|
||||
message: `${t('scanFilesFailed')}: ${error.response?.data?.details || error.message}`,
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Migrate data mutation
|
||||
const migrateMutation = useMutation({
|
||||
@@ -281,7 +268,7 @@ const SettingsPage: React.FC = () => {
|
||||
setSettings(prev => ({ ...prev, tags: updatedTags }));
|
||||
};
|
||||
|
||||
const isSaving = saveMutation.isPending || scanMutation.isPending || migrateMutation.isPending || cleanupMutation.isPending || deleteLegacyMutation.isPending;
|
||||
const isSaving = saveMutation.isPending || migrateMutation.isPending || cleanupMutation.isPending || deleteLegacyMutation.isPending;
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
@@ -469,6 +456,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>
|
||||
@@ -484,15 +513,7 @@ const SettingsPage: React.FC = () => {
|
||||
{t('migrateDataButton')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => scanMutation.mutate()}
|
||||
disabled={isSaving}
|
||||
sx={{ ml: 2 }}
|
||||
>
|
||||
{t('scanFiles')}
|
||||
</Button>
|
||||
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>{t('removeLegacyData')}</Typography>
|
||||
|
||||
@@ -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,31 +372,35 @@ const VideoPlayer: React.FC = () => {
|
||||
startTime={video.progress || 0}
|
||||
/>
|
||||
|
||||
<VideoInfo
|
||||
video={video}
|
||||
onTitleSave={handleSaveTitle}
|
||||
onRatingChange={handleRatingChange}
|
||||
onAuthorClick={handleAuthorClick}
|
||||
onAddToCollection={handleAddToCollection}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
deleteError={deleteMutation.error ? (deleteMutation.error as any).message || t('deleteFailed') : null}
|
||||
videoCollections={videoCollections}
|
||||
onCollectionClick={handleCollectionClick}
|
||||
availableTags={availableTags}
|
||||
onTagsUpdate={handleUpdateTags}
|
||||
/>
|
||||
<Box sx={{ px: { xs: 2, md: 0 } }}>
|
||||
<VideoInfo
|
||||
video={video}
|
||||
onTitleSave={handleSaveTitle}
|
||||
onRatingChange={handleRatingChange}
|
||||
onAuthorClick={handleAuthorClick}
|
||||
onAddToCollection={handleAddToCollection}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
deleteError={deleteMutation.error ? (deleteMutation.error as any).message || t('deleteFailed') : null}
|
||||
videoCollections={videoCollections}
|
||||
onCollectionClick={handleCollectionClick}
|
||||
availableTags={availableTags}
|
||||
onTagsUpdate={handleUpdateTags}
|
||||
/>
|
||||
|
||||
<CommentsSection
|
||||
comments={comments}
|
||||
loading={loadingComments}
|
||||
showComments={showComments}
|
||||
onToggleComments={handleToggleComments}
|
||||
/>
|
||||
{(video.source === 'youtube' || video.source === 'bilibili') && (
|
||||
<CommentsSection
|
||||
comments={comments}
|
||||
loading={loadingComments}
|
||||
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 => (
|
||||
@@ -428,8 +437,8 @@ const VideoPlayer: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<CardContent sx={{ flex: '1 0 auto', p: 1, '&:last-child': { pb: 1 } }}>
|
||||
<Typography variant="body2" fontWeight="bold" sx={{ lineHeight: 1.2, mb: 0.5, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
<CardContent sx={{ flex: '1 1 auto', minWidth: 0, p: 1, '&:last-child': { pb: 1 } }}>
|
||||
<Typography variant="body2" fontWeight="bold" sx={{ lineHeight: 1.2, mb: 0.5, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'block' }}>
|
||||
{relatedVideo.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
@@ -438,11 +447,9 @@ const VideoPlayer: React.FC = () => {
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
{formatDate(relatedVideo.date)}
|
||||
</Typography>
|
||||
{relatedVideo.viewCount !== undefined && (
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
{relatedVideo.viewCount} {t('views')}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
{relatedVideo.viewCount || 0} {t('views')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -59,8 +59,10 @@ export const ar = {
|
||||
migrateDataButton: "نقل البيانات من JSON",
|
||||
scanFiles: "فحص الملفات",
|
||||
scanFilesSuccess: "اكتمل الفحص. تمت إضافة {count} فيديوهات جديدة.",
|
||||
scanFilesFailed: "فشل الفحص",
|
||||
migrateConfirmation: "هل أنت متأكد أنك تريد نقل البيانات؟ قد يستغرق هذا بضع لحظات.",
|
||||
scanFilesFailed: "فشل المسح",
|
||||
scanFilesConfirmMessage: "سيقوم النظام بفحص المجلد الجذر لمسار الفيديو للعثور على ملفات الفيديو غير الموثقة.",
|
||||
scanning: "جارٍ المسح...",
|
||||
migrateConfirmation: "هل أنت متأكد أنك تريد ترحيل البيانات؟ قد يستغرق هذا بضع لحظات.",
|
||||
migrationResults: "نتائج النقل",
|
||||
migrationReport: "تقرير النقل",
|
||||
migrationSuccess: "اكتمل النقل. انظر التفاصيل في التنبيه.",
|
||||
@@ -84,6 +86,15 @@ export const ar = {
|
||||
cleanupTempFilesActiveDownloads: "لا يمكن التنظيف أثناء وجود تنزيلات نشطة. يرجى الانتظار حتى تكتمل جميع التنزيلات أو إلغائها أولاً.",
|
||||
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: "إدارة المحتوى",
|
||||
@@ -203,6 +214,7 @@ export const ar = {
|
||||
save: "حفظ",
|
||||
on: "تشغيل",
|
||||
off: "إيقاف",
|
||||
continue: "متابعة",
|
||||
|
||||
// Video Card
|
||||
unknownDate: "تاريخ غير معروف",
|
||||
@@ -256,4 +268,12 @@ export const ar = {
|
||||
speed: "السرعة",
|
||||
finishedAt: "انتهى في",
|
||||
failed: "فشل",
|
||||
|
||||
// Batch Download
|
||||
batchDownload: "تحميل مجمع",
|
||||
batchDownloadDescription: "الصق روابط متعددة أدناه، واحد في كل سطر.",
|
||||
urls: "الروابط",
|
||||
addToQueue: "إضافة إلى قائمة الانتظار",
|
||||
batchTasksAdded: "تمت إضافة {count} مهام",
|
||||
addBatchTasks: "إضافة مهام مجمعة",
|
||||
};
|
||||
|
||||
@@ -23,6 +23,8 @@ export const de = {
|
||||
database: "Datenbank", migrateDataDescription: "Daten von Legacy-JSON-Dateien zur neuen SQLite-Datenbank migrieren. Diese Aktion kann sicher mehrmals ausgeführt werden (Duplikate werden übersprungen).",
|
||||
migrateDataButton: "Daten aus JSON migrieren", scanFiles: "Dateien Scannen",
|
||||
scanFilesSuccess: "Scan abgeschlossen. {count} neue Videos hinzugefügt.", scanFilesFailed: "Scan fehlgeschlagen",
|
||||
scanFilesConfirmMessage: "Das System scannt den Stammordner des Videopfads, um nicht dokumentierte Videodateien zu finden.",
|
||||
scanning: "Scannen...",
|
||||
migrateConfirmation: "Sind Sie sicher, dass Sie Daten migrieren möchten? Dies kann einige Momente dauern.",
|
||||
migrationResults: "Migrationsergebnisse", migrationReport: "Migrationsbericht",
|
||||
migrationSuccess: "Migration abgeschlossen. Details in der Warnung anzeigen.", migrationNoData: "Migration abgeschlossen, aber keine Daten gefunden.",
|
||||
@@ -42,6 +44,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",
|
||||
@@ -83,7 +95,8 @@ export const de = {
|
||||
deleteCollectionTitle: "Sammlung Löschen", deleteCollectionConfirmation: "Sind Sie sicher, dass Sie die Sammlung löschen möchten",
|
||||
collectionContains: "Diese Sammlung enthält", deleteCollectionOnly: "Nur Sammlung Löschen",
|
||||
deleteCollectionAndVideos: "Sammlung und Alle Videos Löschen", loading: "Laden...", error: "Fehler",
|
||||
success: "Erfolg", cancel: "Abbrechen", confirm: "Bestätigen", save: "Speichern", on: "Ein", off: "Aus",
|
||||
success: "Erfolg", cancel: "Abbrechen", confirm: "Bestätigen", save: "Speichern", on: "Ein", off: "Aus",
|
||||
continue: "Weiter",
|
||||
unknownDate: "Unbekanntes Datum", part: "Teil", collection: "Sammlung", selectVideoFile: "Videodatei Auswählen",
|
||||
pleaseSelectVideo: "Bitte wählen Sie eine Videodatei aus", uploadFailed: "Upload fehlgeschlagen",
|
||||
failedToUpload: "Fehler beim Hochladen des Videos", uploading: "Hochladen...", upload: "Hochladen",
|
||||
@@ -126,4 +139,12 @@ export const de = {
|
||||
speed: "Geschwindigkeit",
|
||||
finishedAt: "Beendet am",
|
||||
failed: "Fehlgeschlagen",
|
||||
|
||||
// Batch Download
|
||||
batchDownload: "Stapel-Download",
|
||||
batchDownloadDescription: "Fügen Sie unten mehrere URLs ein, eine pro Zeile.",
|
||||
urls: "URLs",
|
||||
addToQueue: "Zur Warteschlange hinzufügen",
|
||||
batchTasksAdded: "{count} Aufgaben hinzugefügt",
|
||||
addBatchTasks: "Stapelaufgaben hinzufügen",
|
||||
};
|
||||
|
||||
@@ -60,6 +60,8 @@ export const en = {
|
||||
scanFiles: "Scan Files",
|
||||
scanFilesSuccess: "Scan complete. Added {count} new videos.",
|
||||
scanFilesFailed: "Scan failed",
|
||||
scanFilesConfirmMessage: "The system will scan the root folder of the video path to find undocumented video files.",
|
||||
scanning: "Scanning...",
|
||||
migrateConfirmation: "Are you sure you want to migrate data? This may take a few moments.",
|
||||
migrationResults: "Migration Results",
|
||||
migrationReport: "Migration Report",
|
||||
@@ -84,6 +86,15 @@ export const en = {
|
||||
cleanupTempFilesActiveDownloads: "Cannot clean up while downloads are active. Please wait for all downloads to complete or cancel them first.",
|
||||
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",
|
||||
@@ -194,6 +205,7 @@ export const en = {
|
||||
save: "Save",
|
||||
on: "On",
|
||||
off: "Off",
|
||||
continue: "Continue",
|
||||
|
||||
// Video Card
|
||||
unknownDate: "Unknown date",
|
||||
@@ -257,4 +269,12 @@ export const en = {
|
||||
videoRemovedFromCollection: "Video removed from collection",
|
||||
collectionDeletedSuccessfully: "Collection deleted successfully",
|
||||
failedToDeleteCollection: "Failed to delete collection",
|
||||
|
||||
// Batch Download
|
||||
batchDownload: "Batch Download",
|
||||
batchDownloadDescription: "Paste multiple URLs below, one per line.",
|
||||
urls: "URLs",
|
||||
addToQueue: "Add to Queue",
|
||||
batchTasksAdded: "{count} tasks added",
|
||||
addBatchTasks: "Add batch tasks",
|
||||
};
|
||||
|
||||
@@ -22,7 +22,9 @@ export const es = {
|
||||
tagsManagementNote: "Recuerde hacer clic en \"Guardar Configuración\" después de agregar o eliminar etiquetas para aplicar los cambios.",
|
||||
database: "Base de Datos", migrateDataDescription: "Migrar datos de archivos JSON heredados a la nueva base de datos SQLite. Esta acción es segura para ejecutar varias veces (se omitirán duplicados).",
|
||||
migrateDataButton: "Migrar Datos desde JSON", scanFiles: "Escanear Archivos",
|
||||
scanFilesSuccess: "Escaneo completo. Se agregaron {count} nuevos videos.", scanFilesFailed: "Escaneo fallido",
|
||||
scanFilesSuccess: "Escaneo completo. Se agregaron {count} nuevos videos.", scanFilesFailed: "Escaneo fallido",
|
||||
scanFilesConfirmMessage: "El sistema escaneará la carpeta raíz de la ruta de video para encontrar archivos de video no documentados.",
|
||||
scanning: "Escaneando...",
|
||||
migrateConfirmation: "¿Está seguro de que desea migrar los datos? Esto puede tardar unos momentos.",
|
||||
migrationResults: "Resultados de Migración", migrationReport: "Informe de Migración",
|
||||
migrationSuccess: "Migración completada. Ver detalles en la alerta.", migrationNoData: "Migración finalizada pero no se encontraron datos.",
|
||||
@@ -40,6 +42,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",
|
||||
@@ -124,4 +136,12 @@ export const es = {
|
||||
speed: "Velocidad",
|
||||
finishedAt: "Finalizado en",
|
||||
failed: "Fallido",
|
||||
|
||||
// Batch Download
|
||||
batchDownload: "Descarga por lotes",
|
||||
batchDownloadDescription: "Pegue varias URL a continuación, una por línea.",
|
||||
urls: "URLs",
|
||||
addToQueue: "Añadir a la cola",
|
||||
batchTasksAdded: "{count} tareas añadidas",
|
||||
addBatchTasks: "Añadir tareas por lotes",
|
||||
};
|
||||
|
||||
@@ -60,6 +60,8 @@ export const fr = {
|
||||
scanFiles: "Scanner les fichiers",
|
||||
scanFilesSuccess: "Scan terminé. {count} nouvelles vidéos ajoutées.",
|
||||
scanFilesFailed: "Échec du scan",
|
||||
scanFilesConfirmMessage: "Le système analysera le dossier racine du chemin vidéo pour trouver des fichiers vidéo non documentés.",
|
||||
scanning: "Analyse en cours...",
|
||||
migrateConfirmation: "Êtes-vous sûr de vouloir migrer les données ? Cela peut prendre quelques instants.",
|
||||
migrationResults: "Résultats de la migration",
|
||||
migrationReport: "Rapport de migration",
|
||||
@@ -84,6 +86,15 @@ export const fr = {
|
||||
cleanupTempFilesActiveDownloads: "Impossible de nettoyer pendant que des téléchargements sont actifs. Veuillez attendre la fin de tous les téléchargements ou les annuler d'abord.",
|
||||
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",
|
||||
@@ -255,4 +266,12 @@ export const fr = {
|
||||
speed: "Vitesse",
|
||||
finishedAt: "Terminé à",
|
||||
failed: "Échoué",
|
||||
|
||||
// Batch Download
|
||||
batchDownload: "Téléchargement par lot",
|
||||
batchDownloadDescription: "Collez plusieurs URL ci-dessous, une par ligne.",
|
||||
urls: "URLs",
|
||||
addToQueue: "Ajouter à la file d'attente",
|
||||
batchTasksAdded: "{count} tâches ajoutées",
|
||||
addBatchTasks: "Ajouter des tâches par lot",
|
||||
};
|
||||
|
||||
@@ -60,6 +60,8 @@ export const ja = {
|
||||
scanFiles: "ファイルをスキャン",
|
||||
scanFilesSuccess: "スキャンが完了しました。{count}個の新しい動画を追加しました。",
|
||||
scanFilesFailed: "スキャンに失敗しました",
|
||||
scanFilesConfirmMessage: "システムはビデオパスのルートフォルダをスキャンして、未登録のビデオファイルを検索します。",
|
||||
scanning: "スキャン中...",
|
||||
migrateConfirmation: "データを移行してもよろしいですか?これには時間がかかる場合があります。",
|
||||
migrationResults: "移行結果",
|
||||
migrationReport: "移行レポート",
|
||||
@@ -84,6 +86,15 @@ export const ja = {
|
||||
cleanupTempFilesActiveDownloads: "ダウンロードがアクティブな間はクリーンアップできません。すべてのダウンロードが完了するまで待つか、キャンセルしてください。",
|
||||
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: "コンテンツの管理",
|
||||
@@ -203,6 +214,7 @@ export const ja = {
|
||||
save: "保存",
|
||||
on: "オン",
|
||||
off: "オフ",
|
||||
continue: "続行",
|
||||
|
||||
// Video Card
|
||||
unknownDate: "不明な日付",
|
||||
@@ -256,4 +268,12 @@ export const ja = {
|
||||
speed: "速度",
|
||||
finishedAt: "完了日時",
|
||||
failed: "失敗",
|
||||
|
||||
// Batch Download
|
||||
batchDownload: "一括ダウンロード",
|
||||
batchDownloadDescription: "以下に複数のURLを1行に1つずつ貼り付けてください。",
|
||||
urls: "URL",
|
||||
addToQueue: "キューに追加",
|
||||
batchTasksAdded: "{count} 件のタスクを追加しました",
|
||||
addBatchTasks: "一括タスクを追加",
|
||||
};
|
||||
|
||||
@@ -60,6 +60,8 @@ export const ko = {
|
||||
scanFiles: "파일 스캔",
|
||||
scanFilesSuccess: "스캔 완료. {count}개의 새 동영상이 추가되었습니다.",
|
||||
scanFilesFailed: "스캔 실패",
|
||||
scanFilesConfirmMessage: "시스템이 비디오 경로의 루트 폴더를 스캔하여 문서화되지 않은 비디오 파일을 찾습니다.",
|
||||
scanning: "스캔 중...",
|
||||
migrateConfirmation: "데이터를 마이그레이션하시겠습니까? 잠시 시간이 걸릴 수 있습니다.",
|
||||
migrationResults: "마이그레이션 결과",
|
||||
migrationReport: "마이그레이션 보고서",
|
||||
@@ -84,6 +86,15 @@ export const ko = {
|
||||
cleanupTempFilesActiveDownloads: "다운로드가 활성화된 동안에는 정리할 수 없습니다. 모든 다운로드가 완료될 때까지 기다리거나 먼저 취소하세요.",
|
||||
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: "콘텐츠 관리",
|
||||
@@ -203,6 +214,7 @@ export const ko = {
|
||||
save: "저장",
|
||||
on: "켜기",
|
||||
off: "끄기",
|
||||
continue: "계속",
|
||||
|
||||
// Video Card
|
||||
unknownDate: "알 수 없는 날짜",
|
||||
@@ -256,4 +268,12 @@ export const ko = {
|
||||
speed: "속도",
|
||||
finishedAt: "완료 시간",
|
||||
failed: "실패",
|
||||
|
||||
// Batch Download
|
||||
batchDownload: "일괄 다운로드",
|
||||
batchDownloadDescription: "아래에 여러 URL을 한 줄에 하나씩 붙여넣으세요.",
|
||||
urls: "URL",
|
||||
addToQueue: "대기열에 추가",
|
||||
batchTasksAdded: "{count}개의 작업이 추가되었습니다",
|
||||
addBatchTasks: "일괄 작업 추가",
|
||||
};
|
||||
|
||||
@@ -59,7 +59,9 @@ export const pt = {
|
||||
migrateDataButton: "Migrar Dados do JSON",
|
||||
scanFiles: "Escanear Arquivos",
|
||||
scanFilesSuccess: "Escaneamento completo. {count} novos vídeos adicionados.",
|
||||
scanFilesFailed: "Falha no escaneamento",
|
||||
scanFilesFailed: "A verificação falhou",
|
||||
scanFilesConfirmMessage: "O sistema verificará a pasta raiz do caminho do vídeo para encontrar arquivos de vídeo não documentados.",
|
||||
scanning: "Verificando...",
|
||||
migrateConfirmation: "Tem certeza de que deseja migrar os dados? Isso pode levar alguns instantes.",
|
||||
migrationResults: "Resultados da Migração",
|
||||
migrationReport: "Relatório de Migração",
|
||||
@@ -84,6 +86,15 @@ export const pt = {
|
||||
cleanupTempFilesActiveDownloads: "Não é possível limpar enquanto houver downloads ativos. Aguarde a conclusão de todos os downloads ou cancele-os primeiro.",
|
||||
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",
|
||||
@@ -150,6 +161,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",
|
||||
@@ -196,6 +213,7 @@ export const pt = {
|
||||
save: "Salvar",
|
||||
on: "Ligado",
|
||||
off: "Desligado",
|
||||
continue: "Continuar",
|
||||
|
||||
// Video Card
|
||||
unknownDate: "Data desconhecida",
|
||||
@@ -249,4 +267,12 @@ export const pt = {
|
||||
speed: "Velocidade",
|
||||
finishedAt: "Terminado em",
|
||||
failed: "Falhou",
|
||||
|
||||
// Batch Download
|
||||
batchDownload: "Download em lote",
|
||||
batchDownloadDescription: "Cole vários URLs abaixo, um por linha.",
|
||||
urls: "URLs",
|
||||
addToQueue: "Adicionar à fila",
|
||||
batchTasksAdded: "{count} tarefas adicionadas",
|
||||
addBatchTasks: "Adicionar tarefas em lote",
|
||||
};
|
||||
|
||||
@@ -59,7 +59,9 @@ export const ru = {
|
||||
migrateDataButton: "Перенести данные из JSON",
|
||||
scanFiles: "Сканировать файлы",
|
||||
scanFilesSuccess: "Сканирование завершено. Добавлено {count} новых видео.",
|
||||
scanFilesFailed: "Ошибка сканирования",
|
||||
scanFilesFailed: "Сканирование не удалось",
|
||||
scanFilesConfirmMessage: "Система просканирует корневую папку с видео, чтобы найти недовкументированные видеофайлы.",
|
||||
scanning: "Сканирование...",
|
||||
migrateConfirmation: "Вы уверены, что хотите перенести данные? Это может занять некоторое время.",
|
||||
migrationResults: "Результаты миграции",
|
||||
migrationReport: "Отчет о миграции",
|
||||
@@ -84,6 +86,15 @@ export const ru = {
|
||||
cleanupTempFilesActiveDownloads: "Невозможно очистить, пока активны загрузки. Пожалуйста, дождитесь завершения всех загрузок или сначала отмените их.",
|
||||
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: "Управление контентом",
|
||||
@@ -202,7 +213,8 @@ export const ru = {
|
||||
confirm: "Подтвердить",
|
||||
save: "Сохранить",
|
||||
on: "Вкл.",
|
||||
off: "Выкл.",
|
||||
off: "Выкл",
|
||||
continue: "Продолжить",
|
||||
|
||||
// Video Card
|
||||
unknownDate: "Неизвестная дата",
|
||||
@@ -256,4 +268,12 @@ export const ru = {
|
||||
speed: "Скорость",
|
||||
finishedAt: "Завершено в",
|
||||
failed: "Ошибка",
|
||||
|
||||
// Batch Download
|
||||
batchDownload: "Пакетная загрузка",
|
||||
batchDownloadDescription: "Вставьте несколько URL ниже, по одному в строке.",
|
||||
urls: "URL",
|
||||
addToQueue: "Добавить в очередь",
|
||||
batchTasksAdded: "Добавлено {count} задач",
|
||||
addBatchTasks: "Добавить пакетные задачи",
|
||||
};
|
||||
|
||||
@@ -60,7 +60,9 @@ export const zh = {
|
||||
scanFiles: "扫描文件",
|
||||
scanFilesSuccess: "扫描完成。添加了 {count} 个新视频。",
|
||||
scanFilesFailed: "扫描失败",
|
||||
migrateConfirmation: "确定要迁移数据吗?这可能需要一些时间。",
|
||||
scanFilesConfirmMessage: "系统将扫描视频路径的根文件夹以查找未记录的视频文件。",
|
||||
scanning: "扫描中...",
|
||||
migrateConfirmation: "您确定要迁移数据吗?这可能需要一些时间。",
|
||||
migrationResults: "迁移结果",
|
||||
migrationReport: "迁移报告",
|
||||
migrationSuccess: "迁移完成。请查看警报中的详细信息。",
|
||||
@@ -84,6 +86,15 @@ export const zh = {
|
||||
cleanupTempFilesActiveDownloads: "有活动下载时无法清理。请等待所有下载完成或取消它们。",
|
||||
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: "内容管理",
|
||||
@@ -202,7 +213,8 @@ export const zh = {
|
||||
confirm: "确认",
|
||||
save: "保存",
|
||||
on: "开启",
|
||||
off: "关闭",
|
||||
off: "关",
|
||||
continue: "继续",
|
||||
|
||||
// Video Card
|
||||
unknownDate: "未知日期",
|
||||
@@ -257,4 +269,12 @@ export const zh = {
|
||||
speed: "速度",
|
||||
finishedAt: "完成时间",
|
||||
failed: "失败",
|
||||
|
||||
// Batch Download
|
||||
batchDownload: "批量下载",
|
||||
batchDownloadDescription: "在下方粘贴多个链接,每行一个。",
|
||||
urls: "链接",
|
||||
addToQueue: "添加到队列",
|
||||
batchTasksAdded: "已添加 {count} 个任务",
|
||||
addBatchTasks: "添加批量任务",
|
||||
};
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mytube",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mytube",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mytube",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.3",
|
||||
"description": "YouTube video downloader and player application",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user