feat: Add batch download feature

This commit is contained in:
Peifan Li
2025-12-01 13:26:40 -05:00
parent 618d905e6d
commit c88909b658
19 changed files with 309 additions and 85 deletions

View File

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

View File

@@ -17,6 +17,7 @@ A YouTube/Bilibili/MissAV video downloader and player application that allows yo
- **Video Upload**: Upload local video files directly to your library with automatic thumbnail generation.
- **Bilibili Support**: Support for downloading single videos, multi-part videos, and entire collections/series.
- **Parallel Downloads**: Queue multiple downloads and track their progress simultaneously.
- **Batch Download**: Add multiple video URLs at once to the download queue.
- **Concurrent Download Limit**: Set a limit on the number of simultaneous downloads to manage bandwidth.
- **Local Library**: Automatically save video thumbnails and metadata for a rich browsing experience.
- **Video Player**: Custom player with Play/Pause, Loop, Seek, Full-screen, and Dimming controls.

View File

@@ -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,17 @@ const Header: React.FC<HeaderProps> = ({
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
slots={{ transition: Fade }}
>
<MenuItem onClick={() => { handleDownloadsClose(); navigate('/downloads'); }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', color: 'primary.main' }}>
<Download sx={{ mr: 1, fontSize: 20 }} />
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
{t('manageDownloads') || 'Manage Downloads'}
</Typography>
</Box>
</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 +284,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 +312,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 +330,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 +480,7 @@ const Header: React.FC<HeaderProps> = ({
)}
</Toolbar>
<UploadModal
open={uploadModalOpen}
onClose={() => setUploadModalOpen(false)}
onUploadSuccess={handleUploadSuccess}
/>
</AppBar>
</ClickAwayListener>

View File

@@ -92,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) {
@@ -217,7 +237,7 @@ 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 />

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

@@ -3,7 +3,8 @@ import {
CheckCircle as CheckCircleIcon,
ClearAll as ClearAllIcon,
Delete as DeleteIcon,
Error as ErrorIcon
Error as ErrorIcon,
PlaylistAdd as PlaylistAddIcon
} from '@mui/icons-material';
import {
Box,
@@ -22,6 +23,7 @@ import {
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import React, { useState } from 'react';
import BatchDownloadModal from '../components/BatchDownloadModal';
import { useDownload } from '../contexts/DownloadContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useSnackbar } from '../contexts/SnackbarContext';
@@ -70,9 +72,26 @@ 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 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 +230,20 @@ const DownloadPage: React.FC = () => {
return (
<Box sx={{ width: '100%', p: 2 }}>
<Typography variant="h4" gutterBottom>
{t('downloads') || 'Downloads'}
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
{t('downloads') || 'Downloads'}
</Typography>
<Button
variant="contained"
size="small"
startIcon={<PlaylistAddIcon />}
onClick={() => setShowBatchModal(true)}
>
{t('addBatchTasks') || 'Add batch tasks'}
</Button>
</Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={tabValue} onChange={handleTabChange} aria-label="download tabs">
<Tab label={t('activeDownloads') || 'Active Downloads'} />
@@ -368,6 +398,12 @@ const DownloadPage: React.FC = () => {
</List>
)}
</CustomTabPanel>
<BatchDownloadModal
open={showBatchModal}
onClose={() => setShowBatchModal(false)}
onConfirm={handleBatchSubmit}
/>
</Box>
);
};

View File

@@ -2,6 +2,7 @@ import {
ArrowBack,
Check,
Close,
CloudUpload,
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 UploadModal from '../components/UploadModal';
import { useCollection } from '../contexts/CollectionContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useVideo } from '../contexts/VideoContext';
@@ -52,6 +54,7 @@ const ManagePage: React.FC = () => {
const [isDeletingCollection, setIsDeletingCollection] = useState<boolean>(false);
const [videoToDelete, setVideoToDelete] = useState<string | null>(null);
const [showVideoDeleteModal, setShowVideoDeleteModal] = useState<boolean>(false);
const [uploadModalOpen, setUploadModalOpen] = useState<boolean>(false);
// Editing state
const [editingVideoId, setEditingVideoId] = useState<string | null>(null);
@@ -227,22 +230,41 @@ const ManagePage: React.FC = () => {
return video.thumbnailUrl || 'https://via.placeholder.com/120x90?text=No+Thumbnail';
};
const handleUploadSuccess = () => {
window.location.reload();
};
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
variant="contained"
startIcon={<CloudUpload />}
onClick={() => setUploadModalOpen(true)}
>
{t('uploadVideo')}
</Button>
<Button
component={Link}
to="/"
variant="outlined"
startIcon={<ArrowBack />}
>
{t('backToHome')}
</Button>
</Box>
</Box>
<UploadModal
open={uploadModalOpen}
onClose={() => setUploadModalOpen(false)}
onUploadSuccess={handleUploadSuccess}
/>
<DeleteCollectionModal
isOpen={!!collectionToDelete}
onClose={() => !isDeletingCollection && setCollectionToDelete(null)}

View File

@@ -388,12 +388,14 @@ const VideoPlayer: React.FC = () => {
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>
@@ -435,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">
@@ -445,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

@@ -265,4 +265,12 @@ export const ar = {
speed: "السرعة",
finishedAt: "انتهى في",
failed: "فشل",
// Batch Download
batchDownload: "تحميل مجمع",
batchDownloadDescription: "الصق روابط متعددة أدناه، واحد في كل سطر.",
urls: "الروابط",
addToQueue: "إضافة إلى قائمة الانتظار",
batchTasksAdded: "تمت إضافة {count} مهام",
addBatchTasks: "إضافة مهام مجمعة",
};

View File

@@ -136,4 +136,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

@@ -266,4 +266,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

@@ -134,4 +134,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

@@ -264,4 +264,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

@@ -265,4 +265,12 @@ export const ja = {
speed: "速度",
finishedAt: "完了日時",
failed: "失敗",
// Batch Download
batchDownload: "一括ダウンロード",
batchDownloadDescription: "以下に複数のURLを1行に1つずつ貼り付けてください。",
urls: "URL",
addToQueue: "キューに追加",
batchTasksAdded: "{count} 件のタスクを追加しました",
addBatchTasks: "一括タスクを追加",
};

View File

@@ -265,4 +265,12 @@ export const ko = {
speed: "속도",
finishedAt: "완료 시간",
failed: "실패",
// Batch Download
batchDownload: "일괄 다운로드",
batchDownloadDescription: "아래에 여러 URL을 한 줄에 하나씩 붙여넣으세요.",
urls: "URL",
addToQueue: "대기열에 추가",
batchTasksAdded: "{count}개의 작업이 추가되었습니다",
addBatchTasks: "일괄 작업 추가",
};

View File

@@ -264,4 +264,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

@@ -265,4 +265,12 @@ export const ru = {
speed: "Скорость",
finishedAt: "Завершено в",
failed: "Ошибка",
// Batch Download
batchDownload: "Пакетная загрузка",
batchDownloadDescription: "Вставьте несколько URL ниже, по одному в строке.",
urls: "URL",
addToQueue: "Добавить в очередь",
batchTasksAdded: "Добавлено {count} задач",
addBatchTasks: "Добавить пакетные задачи",
};

View File

@@ -266,4 +266,12 @@ export const zh = {
speed: "速度",
finishedAt: "完成时间",
failed: "失败",
// Batch Download
batchDownload: "批量下载",
batchDownloadDescription: "在下方粘贴多个链接,每行一个。",
urls: "链接",
addToQueue: "添加到队列",
batchTasksAdded: "已添加 {count} 个任务",
addBatchTasks: "添加批量任务",
};