10 Commits

Author SHA1 Message Date
Peifan Li
881a159777 chore(release): v1.3.3 2025-12-01 17:15:59 -05:00
Peifan Li
26fd63eada feat: Add hover functionality to VideoCard 2025-12-01 16:53:04 -05:00
Peifan Li
f20ecd42e1 feat: Add pagination and toggle for sidebar in Home page 2025-12-01 16:46:56 -05:00
Peifan Li
ae8507a609 style: Update Header component UI for manageDownloads 2025-12-01 14:30:08 -05:00
Peifan Li
7969412091 feat: Add upload and scan modals on DownloadPage 2025-12-01 14:16:47 -05:00
Peifan Li
c88909b658 feat: Add batch download feature 2025-12-01 13:26:40 -05:00
Peifan Li
618d905e6d fix: Update package versions to 1.3.2 in lock files 2025-11-30 17:17:49 -05:00
Peifan Li
88e452fc61 chore(release): v1.3.2 2025-11-30 17:07:22 -05:00
Peifan Li
cffe2319c2 feat: Add Cloud Storage Service and settings for OpenList 2025-11-30 17:07:10 -05:00
Peifan Li
19383ad582 fix: Update package versions to 1.3.1 2025-11-29 10:55:20 -05:00
32 changed files with 1078 additions and 240 deletions

View File

@@ -17,6 +17,7 @@
- **视频上传**:直接上传本地视频文件到您的库,并自动生成缩略图。
- **Bilibili 支持**支持下载单个视频、多P视频以及整个合集/系列。
- **并行下载**:支持队列下载,可同时追踪多个下载任务的进度。
- **批量下载**:一次性添加多个视频链接到下载队列。
- **并发下载限制**:设置同时下载的数量限制以管理带宽。
- **本地库**:自动保存视频缩略图和元数据,提供丰富的浏览体验。
- **视频播放器**:自定义播放器,支持播放/暂停、循环、快进/快退、全屏和调光控制。

View File

@@ -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.

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.3.1",
"version": "1.3.3",
"main": "server.js",
"scripts": {
"start": "ts-node src/server.ts",

View File

@@ -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) => {

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

@@ -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",

View File

@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.3.1",
"version": "1.3.3",
"type": "module",
"scripts": {
"dev": "vite",

View 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;

View File

@@ -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>

View File

@@ -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 && (

View File

@@ -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>

View File

@@ -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>
);
};

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

@@ -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>
);
};

View File

@@ -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 >
);
};

View File

@@ -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)}

View File

@@ -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>

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,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>
))}

View File

@@ -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: "إضافة مهام مجمعة",
};

View File

@@ -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",
};

View File

@@ -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",
};

View File

@@ -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",
};

View File

@@ -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",
};

View File

@@ -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: "一括タスクを追加",
};

View File

@@ -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: "일괄 작업 추가",
};

View File

@@ -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",
};

View File

@@ -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: "Добавить пакетные задачи",
};

View File

@@ -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
View File

@@ -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"

View File

@@ -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": {