13 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
Peifan Li
c2d6215b44 chore(release): v1.3.1 2025-11-29 10:52:04 -05:00
Peifan Li
f2b5af0912 refactor: Remove unnecessary youtubedl call arguments 2025-11-29 10:52:00 -05:00
Peifan Li
56557da2cf feat: Update versions and add support for more sites 2025-11-28 21:05:18 -05:00
36 changed files with 1094 additions and 254 deletions

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.3.0",
"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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.3.0",
"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.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"

View File

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