Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
881a159777 | ||
|
|
26fd63eada | ||
|
|
f20ecd42e1 | ||
|
|
ae8507a609 | ||
|
|
7969412091 | ||
|
|
c88909b658 | ||
|
|
618d905e6d | ||
|
|
88e452fc61 | ||
|
|
cffe2319c2 | ||
|
|
19383ad582 | ||
|
|
c2d6215b44 | ||
|
|
f2b5af0912 | ||
|
|
56557da2cf |
@@ -1,6 +1,6 @@
|
||||
# MyTube
|
||||
|
||||
一个 YouTube/Bilibili/MissAV 视频下载和播放应用,允许您将视频及其缩略图本地保存。将您的视频整理到收藏夹中,以便轻松访问和管理。
|
||||
一个 YouTube/Bilibili/MissAV 视频下载和播放应用,允许您将视频及其缩略图本地保存。将您的视频整理到收藏夹中,以便轻松访问和管理。现已支持[yt-dlp所有网址](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md##),包括微博,小红书,x.com等。
|
||||
|
||||
[English](README.md)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
- **视频上传**:直接上传本地视频文件到您的库,并自动生成缩略图。
|
||||
- **Bilibili 支持**:支持下载单个视频、多P视频以及整个合集/系列。
|
||||
- **并行下载**:支持队列下载,可同时追踪多个下载任务的进度。
|
||||
- **批量下载**:一次性添加多个视频链接到下载队列。
|
||||
- **并发下载限制**:设置同时下载的数量限制以管理带宽。
|
||||
- **本地库**:自动保存视频缩略图和元数据,提供丰富的浏览体验。
|
||||
- **视频播放器**:自定义播放器,支持播放/暂停、循环、快进/快退、全屏和调光控制。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MyTube
|
||||
|
||||
A YouTube/Bilibili/MissAV video downloader and player application that allows you to download and save videos locally, along with their thumbnails. Organize your videos into collections for easy access and management.
|
||||
A YouTube/Bilibili/MissAV video downloader and player application that allows you to download and save videos locally, along with their thumbnails. Organize your videos into collections for easy access and management. Now supports [yt-dlp sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md##), including Weibo, Xiaohongshu, X.com, etc.
|
||||
|
||||
[中文](README-zh.md)
|
||||
|
||||
@@ -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.2.5",
|
||||
"version": "1.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "backend",
|
||||
"version": "1.2.5",
|
||||
"version": "1.3.2",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.3.0",
|
||||
"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);
|
||||
|
||||
@@ -52,7 +52,6 @@ export class YtDlpDownloader {
|
||||
const searchResults = await youtubedl(`ytsearch5:${query}`, {
|
||||
dumpSingleJson: true,
|
||||
noWarnings: true,
|
||||
noCallHome: true,
|
||||
skipDownload: true,
|
||||
playlistEnd: 5, // Limit to 5 results
|
||||
} as any);
|
||||
@@ -86,7 +85,6 @@ export class YtDlpDownloader {
|
||||
const info = await youtubedl(url, {
|
||||
dumpSingleJson: true,
|
||||
noWarnings: true,
|
||||
callHome: false,
|
||||
preferFreeFormats: true,
|
||||
// youtubeSkipDashManifest: true, // Specific to YT, might want to keep or make conditional
|
||||
} as any);
|
||||
@@ -129,7 +127,6 @@ export class YtDlpDownloader {
|
||||
const info = await youtubedl(videoUrl, {
|
||||
dumpSingleJson: true,
|
||||
noWarnings: true,
|
||||
callHome: false,
|
||||
preferFreeFormats: true,
|
||||
} as any);
|
||||
|
||||
|
||||
@@ -64,9 +64,10 @@ export const backfillDurations = async () => {
|
||||
const duration = await getVideoDuration(fsPath);
|
||||
|
||||
if (duration !== null) {
|
||||
await db.update(videos)
|
||||
db.update(videos)
|
||||
.set({ duration: duration.toString() })
|
||||
.where(eq(videos.id, video.id));
|
||||
.where(eq(videos.id, video.id))
|
||||
.run();
|
||||
console.log(`Updated duration for ${video.title}: ${duration}s`);
|
||||
updatedCount++;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function runMigration() {
|
||||
description: video.description,
|
||||
viewCount: video.viewCount,
|
||||
duration: video.duration,
|
||||
}).onConflictDoNothing();
|
||||
}).onConflictDoNothing().run();
|
||||
results.videos.count++;
|
||||
} catch (error: any) {
|
||||
console.error(`Error migrating video ${video.id}:`, error);
|
||||
@@ -96,7 +96,7 @@ export async function runMigration() {
|
||||
title: collection.title,
|
||||
createdAt: collection.createdAt || new Date().toISOString(),
|
||||
updatedAt: collection.updatedAt,
|
||||
}).onConflictDoNothing();
|
||||
}).onConflictDoNothing().run();
|
||||
results.collections.count++;
|
||||
|
||||
// Insert Collection Videos
|
||||
@@ -106,7 +106,7 @@ export async function runMigration() {
|
||||
await db.insert(collectionVideos).values({
|
||||
collectionId: collection.id,
|
||||
videoId: videoId,
|
||||
}).onConflictDoNothing();
|
||||
}).onConflictDoNothing().run();
|
||||
} catch (err: any) {
|
||||
console.error(`Error linking video ${videoId} to collection ${collection.id}:`, err);
|
||||
results.errors.push(`Link ${videoId}->${collection.id}: ${err.message}`);
|
||||
@@ -137,7 +137,7 @@ export async function runMigration() {
|
||||
}).onConflictDoUpdate({
|
||||
target: settings.key,
|
||||
set: { value: JSON.stringify(value) },
|
||||
});
|
||||
}).run();
|
||||
results.settings.count++;
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -178,7 +178,7 @@ export async function runMigration() {
|
||||
speed: download.speed,
|
||||
status: 'active',
|
||||
}
|
||||
});
|
||||
}).run();
|
||||
results.downloads.count++;
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,7 @@ export async function runMigration() {
|
||||
timestamp: download.timestamp,
|
||||
status: 'queued',
|
||||
}
|
||||
});
|
||||
}).run();
|
||||
results.downloads.count++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,9 +108,13 @@ export function sanitizeFilename(filename: string): string {
|
||||
|
||||
// Replace only unsafe characters for filesystems
|
||||
// This preserves non-Latin characters like Chinese, Japanese, Korean, etc.
|
||||
return withoutHashtags
|
||||
const sanitized = withoutHashtags
|
||||
.replace(/[\/\\:*?"<>|]/g, "_") // Replace unsafe filesystem characters
|
||||
.replace(/\s+/g, "_"); // Replace spaces with underscores
|
||||
|
||||
// Truncate to 200 characters to avoid ENAMETOOLONG errors (filesystem limit is usually 255 bytes)
|
||||
// We use 200 to leave room for timestamp suffix and extension
|
||||
return sanitized.slice(0, 200);
|
||||
}
|
||||
|
||||
// Helper function to extract user mid from Bilibili URL
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.2.5",
|
||||
"version": "1.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.2.5",
|
||||
"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.0",
|
||||
"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.2.5",
|
||||
"version": "1.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mytube",
|
||||
"version": "1.2.5",
|
||||
"version": "1.3.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mytube",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.3",
|
||||
"description": "YouTube video downloader and player application",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user