优化视频下载
This commit is contained in:
@@ -1,13 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { M3U8DownloadTask } from '@/lib/m3u8-downloader';
|
||||
import { M3U8DownloadTask, M3U8SegmentLogStatus } from '@/lib/m3u8-downloader';
|
||||
|
||||
import { useDownload } from '@/contexts/DownloadContext';
|
||||
|
||||
export function DownloadPanel() {
|
||||
const { tasks, showDownloadPanel, setShowDownloadPanel, startTask, pauseTask, cancelTask, retryFailedSegments, getProgress } = useDownload();
|
||||
const [logTaskId, setLogTaskId] = useState<string | null>(null);
|
||||
const [logFilter, setLogFilter] = useState<'all' | M3U8SegmentLogStatus>('all');
|
||||
|
||||
const logTask = useMemo(
|
||||
() => tasks.find((task) => task.id === logTaskId) || null,
|
||||
[logTaskId, tasks]
|
||||
);
|
||||
|
||||
const filteredLogs = useMemo(() => {
|
||||
if (!logTask) return [];
|
||||
const logs = logFilter === 'all'
|
||||
? logTask.segmentLogs
|
||||
: logTask.segmentLogs.filter((log) => log.status === logFilter);
|
||||
return [...logs].reverse();
|
||||
}, [logFilter, logTask]);
|
||||
|
||||
if (!showDownloadPanel) {
|
||||
return null;
|
||||
@@ -33,31 +48,83 @@ export function DownloadPanel() {
|
||||
const getStatusColor = (status: M3U8DownloadTask['status']) => {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
return 'text-gray-500';
|
||||
return 'text-gray-500 dark:text-slate-400';
|
||||
case 'downloading':
|
||||
return 'text-blue-500';
|
||||
return 'text-blue-500 dark:text-sky-400';
|
||||
case 'pause':
|
||||
return 'text-yellow-500';
|
||||
return 'text-yellow-600 dark:text-amber-400';
|
||||
case 'done':
|
||||
return 'text-green-500';
|
||||
return 'text-green-600 dark:text-emerald-400';
|
||||
case 'error':
|
||||
return 'text-red-500';
|
||||
return 'text-red-600 dark:text-rose-400';
|
||||
default:
|
||||
return 'text-gray-500';
|
||||
return 'text-gray-500 dark:text-slate-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getLogBadgeClass = (status: M3U8SegmentLogStatus) => {
|
||||
switch (status) {
|
||||
case 'downloading':
|
||||
return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-400/30 dark:bg-sky-400/10 dark:text-sky-300';
|
||||
case 'success':
|
||||
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-400/30 dark:bg-emerald-400/10 dark:text-emerald-300';
|
||||
case 'retry':
|
||||
return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-400/30 dark:bg-amber-400/10 dark:text-amber-300';
|
||||
case 'timeout':
|
||||
case 'error':
|
||||
return 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-400/30 dark:bg-rose-400/10 dark:text-rose-300';
|
||||
case 'aborted':
|
||||
return 'border-slate-200 bg-slate-50 text-slate-700 dark:border-slate-500/30 dark:bg-slate-500/10 dark:text-slate-300';
|
||||
default:
|
||||
return 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-500/30 dark:bg-gray-500/10 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const getLogStatusText = (status: M3U8SegmentLogStatus) => {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return '排队';
|
||||
case 'downloading':
|
||||
return '下载中';
|
||||
case 'success':
|
||||
return '成功';
|
||||
case 'retry':
|
||||
return '重试';
|
||||
case 'error':
|
||||
return '失败';
|
||||
case 'timeout':
|
||||
return '超时';
|
||||
case 'aborted':
|
||||
return '中止';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: number) => new Date(timestamp).toLocaleTimeString();
|
||||
|
||||
const logStats = logTask ? {
|
||||
total: logTask.segmentLogs.length,
|
||||
success: logTask.finishList.filter((item) => item.status === 'is-success').length,
|
||||
downloading: logTask.finishList.filter((item) => item.status === 'is-downloading').length,
|
||||
error: logTask.finishList.filter((item) => item.status === 'is-error').length,
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm'>
|
||||
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-2xl w-full max-w-4xl max-h-[80vh] flex flex-col'>
|
||||
<div className='fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 p-3 backdrop-blur-sm sm:p-6'>
|
||||
<div className='flex max-h-[86vh] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-slate-700/80 dark:bg-slate-950'>
|
||||
{/* 标题栏 */}
|
||||
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
|
||||
<h2 className='text-xl font-bold text-gray-900 dark:text-white'>下载任务列表</h2>
|
||||
<div className='flex items-center justify-between border-b border-gray-200 bg-white/90 p-4 dark:border-slate-800 dark:bg-slate-950/90'>
|
||||
<div>
|
||||
<h2 className='text-xl font-bold text-gray-900 dark:text-slate-50'>下载任务列表</h2>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-slate-400'>支持查看每个分片的下载、重试、超时和失败日志</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowDownloadPanel(false)}
|
||||
className='text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors'
|
||||
className='cursor-pointer rounded-lg p-2 text-gray-500 transition-colors duration-200 hover:bg-gray-100 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-sky-500 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200'
|
||||
aria-label='关闭下载任务列表'
|
||||
>
|
||||
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<svg className='h-6 w-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M6 18L18 6M6 6l12 12' />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -66,8 +133,8 @@ export function DownloadPanel() {
|
||||
{/* 任务列表 */}
|
||||
<div className='flex-1 overflow-y-auto p-4 space-y-3'>
|
||||
{tasks.length === 0 ? (
|
||||
<div className='flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400'>
|
||||
<svg className='w-16 h-16 mb-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<div className='flex min-h-[320px] flex-col items-center justify-center text-gray-500 dark:text-slate-400'>
|
||||
<svg className='mb-4 h-16 w-16' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
@@ -83,21 +150,21 @@ export function DownloadPanel() {
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className='bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600'
|
||||
className='rounded-xl border border-gray-200 bg-gray-50 p-4 transition-colors duration-200 dark:border-slate-700 dark:bg-slate-900/70'
|
||||
>
|
||||
{/* 任务信息 */}
|
||||
<div className='flex items-start justify-between mb-3'>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<h3 className='text-sm font-medium text-gray-900 dark:text-white truncate mb-1'>
|
||||
<div className='mb-3 flex items-start justify-between gap-4'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h3 className='mb-1 truncate text-sm font-semibold text-gray-900 dark:text-slate-50'>
|
||||
{task.title}
|
||||
</h3>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 truncate'>{task.url}</p>
|
||||
<p className='truncate text-xs text-gray-500 dark:text-slate-400'>{task.url}</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 ml-4'>
|
||||
<div className='flex shrink-0 items-center gap-2'>
|
||||
<span className={`text-xs font-medium ${getStatusColor(task.status)}`}>
|
||||
{getStatusText(task.status)}
|
||||
</span>
|
||||
<span className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
<span className='rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-500 dark:border-slate-700 dark:text-slate-400'>
|
||||
{task.type}
|
||||
</span>
|
||||
</div>
|
||||
@@ -105,22 +172,22 @@ export function DownloadPanel() {
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className='mb-3'>
|
||||
<div className='flex items-center justify-between text-xs text-gray-600 dark:text-gray-300 mb-1'>
|
||||
<div className='mb-1 flex items-center justify-between text-xs text-gray-600 dark:text-slate-300'>
|
||||
<span>
|
||||
{task.finishNum} / {task.rangeDownload.targetSegment} 片段
|
||||
</span>
|
||||
<span>{progress.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className='w-full bg-gray-200 dark:bg-gray-600 rounded-full h-2 overflow-hidden'>
|
||||
<div className='h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-slate-800'>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${
|
||||
task.status === 'downloading'
|
||||
? 'bg-gradient-to-r from-blue-500 to-purple-600 animate-pulse'
|
||||
? 'bg-gradient-to-r from-sky-500 to-emerald-500 motion-safe:animate-pulse'
|
||||
: task.status === 'done'
|
||||
? 'bg-green-500'
|
||||
? 'bg-emerald-500'
|
||||
: task.status === 'error'
|
||||
? 'bg-red-500'
|
||||
: 'bg-gray-400'
|
||||
? 'bg-rose-500'
|
||||
: 'bg-slate-400'
|
||||
}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
@@ -129,13 +196,13 @@ export function DownloadPanel() {
|
||||
|
||||
{/* 错误信息 */}
|
||||
{task.errorNum > 0 && (
|
||||
<div className='mb-3 flex items-center justify-between'>
|
||||
<div className='text-xs text-red-500 dark:text-red-400'>
|
||||
<div className='mb-3 flex items-center justify-between rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 dark:border-rose-400/30 dark:bg-rose-400/10'>
|
||||
<div className='text-xs text-rose-600 dark:text-rose-300' role='alert'>
|
||||
{task.errorNum} 个片段下载失败
|
||||
</div>
|
||||
<button
|
||||
onClick={() => retryFailedSegments(task.id)}
|
||||
className='text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 underline'
|
||||
className='cursor-pointer text-xs text-sky-600 underline transition-colors hover:text-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-500 dark:text-sky-300 dark:hover:text-sky-200'
|
||||
>
|
||||
重试失败片段
|
||||
</button>
|
||||
@@ -143,13 +210,31 @@ export function DownloadPanel() {
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLogTaskId(task.id);
|
||||
setLogFilter('all');
|
||||
}}
|
||||
className='flex cursor-pointer items-center gap-1 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition-colors duration-200 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-sky-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700'
|
||||
>
|
||||
<svg className='h-4 w-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M9 17v-6m4 6V7m4 10v-3M5 19h14M5 5h14' />
|
||||
</svg>
|
||||
查看日志
|
||||
{task.segmentLogs.length > 0 && (
|
||||
<span className='rounded-full bg-slate-200 px-1.5 py-0.5 text-[10px] text-slate-700 dark:bg-slate-700 dark:text-slate-200'>
|
||||
{task.segmentLogs.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{task.status === 'downloading' && (
|
||||
<button
|
||||
onClick={() => pauseTask(task.id)}
|
||||
className='flex items-center gap-1 px-3 py-1.5 bg-yellow-500 hover:bg-yellow-600 text-white text-xs font-medium rounded transition-colors'
|
||||
className='flex cursor-pointer items-center gap-1 rounded-lg bg-amber-500 px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200 hover:bg-amber-600 focus:outline-none focus:ring-2 focus:ring-amber-400'
|
||||
>
|
||||
<svg className='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<svg className='h-4 w-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M10 9v6m4-6v6' />
|
||||
</svg>
|
||||
暂停
|
||||
@@ -159,9 +244,9 @@ export function DownloadPanel() {
|
||||
{(task.status === 'pause' || task.status === 'ready' || task.status === 'error') && (
|
||||
<button
|
||||
onClick={() => startTask(task.id)}
|
||||
className='flex items-center gap-1 px-3 py-1.5 bg-blue-500 hover:bg-blue-600 text-white text-xs font-medium rounded transition-colors'
|
||||
className='flex cursor-pointer items-center gap-1 rounded-lg bg-sky-500 px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200 hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-400'
|
||||
>
|
||||
<svg className='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<svg className='h-4 w-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z' />
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M21 12a9 9 0 11-18 0 9 9 0 0118 0z' />
|
||||
</svg>
|
||||
@@ -171,9 +256,9 @@ export function DownloadPanel() {
|
||||
|
||||
<button
|
||||
onClick={() => cancelTask(task.id)}
|
||||
className='flex items-center gap-1 px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white text-xs font-medium rounded transition-colors'
|
||||
className='flex cursor-pointer items-center gap-1 rounded-lg bg-rose-500 px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200 hover:bg-rose-600 focus:outline-none focus:ring-2 focus:ring-rose-400'
|
||||
>
|
||||
<svg className='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<svg className='h-4 w-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16' />
|
||||
</svg>
|
||||
删除
|
||||
@@ -187,8 +272,8 @@ export function DownloadPanel() {
|
||||
|
||||
{/* 底部统计 */}
|
||||
{tasks.length > 0 && (
|
||||
<div className='p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/30'>
|
||||
<div className='flex items-center justify-between text-sm text-gray-600 dark:text-gray-300'>
|
||||
<div className='border-t border-gray-200 bg-gray-50 p-4 dark:border-slate-800 dark:bg-slate-900/80'>
|
||||
<div className='grid grid-cols-2 gap-2 text-sm text-gray-600 dark:text-slate-300 sm:grid-cols-4'>
|
||||
<span>总任务数: {tasks.length}</span>
|
||||
<span>下载中: {tasks.filter(t => t.status === 'downloading').length}</span>
|
||||
<span>已完成: {tasks.filter(t => t.status === 'done').length}</span>
|
||||
@@ -197,6 +282,105 @@ export function DownloadPanel() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{logTask && (
|
||||
<div className='fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 p-3 backdrop-blur-sm sm:p-6'>
|
||||
<div className='flex max-h-[82vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-950'>
|
||||
<div className='border-b border-gray-200 bg-white/95 p-4 dark:border-slate-800 dark:bg-slate-950/95'>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div className='min-w-0'>
|
||||
<h3 className='truncate text-lg font-bold text-gray-900 dark:text-slate-50'>分片下载日志</h3>
|
||||
<p className='mt-1 truncate text-xs text-gray-500 dark:text-slate-400'>{logTask.title}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setLogTaskId(null)}
|
||||
className='cursor-pointer rounded-lg p-2 text-gray-500 transition-colors duration-200 hover:bg-gray-100 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-sky-500 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200'
|
||||
aria-label='关闭分片下载日志'
|
||||
>
|
||||
<svg className='h-5 w-5' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M6 18L18 6M6 6l12 12' />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{logStats && (
|
||||
<div className='mt-4 grid grid-cols-2 gap-2 sm:grid-cols-4'>
|
||||
<div className='rounded-xl border border-slate-200 bg-slate-50 p-3 dark:border-slate-700 dark:bg-slate-900'>
|
||||
<div className='text-xs text-slate-500 dark:text-slate-400'>日志数</div>
|
||||
<div className='mt-1 text-lg font-semibold text-slate-900 dark:text-slate-50'>{logStats.total}</div>
|
||||
</div>
|
||||
<div className='rounded-xl border border-emerald-200 bg-emerald-50 p-3 dark:border-emerald-400/30 dark:bg-emerald-400/10'>
|
||||
<div className='text-xs text-emerald-700 dark:text-emerald-300'>成功分片</div>
|
||||
<div className='mt-1 text-lg font-semibold text-emerald-700 dark:text-emerald-200'>{logStats.success}</div>
|
||||
</div>
|
||||
<div className='rounded-xl border border-sky-200 bg-sky-50 p-3 dark:border-sky-400/30 dark:bg-sky-400/10'>
|
||||
<div className='text-xs text-sky-700 dark:text-sky-300'>下载中</div>
|
||||
<div className='mt-1 text-lg font-semibold text-sky-700 dark:text-sky-200'>{logStats.downloading}</div>
|
||||
</div>
|
||||
<div className='rounded-xl border border-rose-200 bg-rose-50 p-3 dark:border-rose-400/30 dark:bg-rose-400/10'>
|
||||
<div className='text-xs text-rose-700 dark:text-rose-300'>失败分片</div>
|
||||
<div className='mt-1 text-lg font-semibold text-rose-700 dark:text-rose-200'>{logStats.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mt-4 flex flex-wrap gap-2'>
|
||||
{(['all', 'downloading', 'success', 'retry', 'timeout', 'error', 'aborted'] as Array<'all' | M3U8SegmentLogStatus>).map((filter) => (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setLogFilter(filter)}
|
||||
className={`cursor-pointer rounded-full border px-3 py-1 text-xs font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-sky-500 ${
|
||||
logFilter === filter
|
||||
? 'border-sky-500 bg-sky-500 text-white'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
{filter === 'all' ? '全部' : getLogStatusText(filter)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 overflow-y-auto p-4'>
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className='flex min-h-[220px] flex-col items-center justify-center rounded-xl border border-dashed border-gray-300 text-gray-500 dark:border-slate-700 dark:text-slate-400'>
|
||||
<svg className='mb-3 h-10 w-10' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M9 12h6m-6 4h6M7 4h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6a2 2 0 012-2z' />
|
||||
</svg>
|
||||
<p className='text-sm'>暂无匹配的分片日志</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
{filteredLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className='grid gap-3 rounded-xl border border-gray-200 bg-white p-3 text-sm dark:border-slate-800 dark:bg-slate-900 md:grid-cols-[120px_110px_1fr_160px]'
|
||||
>
|
||||
<div className='font-mono text-xs text-slate-500 dark:text-slate-400'>
|
||||
{formatTime(log.timestamp)}
|
||||
</div>
|
||||
<div>
|
||||
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${getLogBadgeClass(log.status)}`}>
|
||||
{getLogStatusText(log.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<div className='truncate text-slate-900 dark:text-slate-100'>{log.message}</div>
|
||||
<div className='mt-1 truncate font-mono text-xs text-slate-500 dark:text-slate-500'>segment #{log.index + 1}</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-2 text-xs text-slate-500 dark:text-slate-400 md:justify-end'>
|
||||
{typeof log.retryCount === 'number' && <span>重试 {log.retryCount}</span>}
|
||||
{typeof log.durationMs === 'number' && <span>{log.durationMs}ms</span>}
|
||||
{typeof log.httpStatus === 'number' && <span>HTTP {log.httpStatus}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -208,6 +208,7 @@ export const UserMenu: React.FC = () => {
|
||||
const [exactSearch, setExactSearch] = useState(true);
|
||||
const [maxConcurrentDownloads, setMaxConcurrentDownloads] = useState(6);
|
||||
const [downloadThreadsPerTask, setDownloadThreadsPerTask] = useState(6);
|
||||
const [downloadSegmentTimeout, setDownloadSegmentTimeout] = useState(30000);
|
||||
const [downloadMode, setDownloadMode] = useState<'browser' | 'filesystem'>(
|
||||
'browser'
|
||||
);
|
||||
@@ -824,6 +825,17 @@ export const UserMenu: React.FC = () => {
|
||||
setDownloadThreadsPerTask(Number(savedDownloadThreadsPerTask));
|
||||
}
|
||||
|
||||
// 加载分片下载超时设置
|
||||
const savedDownloadSegmentTimeout = localStorage.getItem(
|
||||
'downloadSegmentTimeout'
|
||||
);
|
||||
if (savedDownloadSegmentTimeout !== null) {
|
||||
const timeout = Number(savedDownloadSegmentTimeout);
|
||||
if (Number.isFinite(timeout)) {
|
||||
setDownloadSegmentTimeout(Math.min(Math.max(timeout, 30000), 300000));
|
||||
}
|
||||
}
|
||||
|
||||
// 加载下载模式设置
|
||||
const savedDownloadMode = localStorage.getItem('downloadMode');
|
||||
if (
|
||||
@@ -1591,6 +1603,24 @@ export const UserMenu: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadSegmentTimeoutChange = (value: number) => {
|
||||
const normalizedValue = Math.min(Math.max(value, 30000), 300000);
|
||||
setDownloadSegmentTimeout(normalizedValue);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('downloadSegmentTimeout', String(normalizedValue));
|
||||
}
|
||||
};
|
||||
|
||||
const formatDownloadSegmentTimeout = (value: number) => {
|
||||
if (value < 60000) {
|
||||
return `${Math.round(value / 1000)}秒`;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(value / 60000);
|
||||
const seconds = Math.round((value % 60000) / 1000);
|
||||
return seconds > 0 ? `${minutes}分${seconds}秒` : `${minutes}分钟`;
|
||||
};
|
||||
|
||||
const handleDownloadModeChange = (mode: 'browser' | 'filesystem') => {
|
||||
// 如果选择 filesystem 模式,先检测浏览器是否支持
|
||||
if (
|
||||
@@ -3470,6 +3500,80 @@ export const UserMenu: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分片下载超时 */}
|
||||
<div className='space-y-2'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
分片下载超时
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
单个分片超过该时间仍未完成时会自动判定超时并按原分片重试
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-xs text-gray-600 dark:text-gray-400'>
|
||||
超时时间
|
||||
</span>
|
||||
<span className='text-xs font-medium text-gray-700 dark:text-gray-300'>
|
||||
{formatDownloadSegmentTimeout(downloadSegmentTimeout)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<input
|
||||
type='range'
|
||||
min='30000'
|
||||
max='300000'
|
||||
step='10000'
|
||||
value={downloadSegmentTimeout}
|
||||
onChange={(e) =>
|
||||
handleDownloadSegmentTimeoutChange(
|
||||
Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className='flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700'
|
||||
style={{
|
||||
background: `linear-gradient(to right, #10b981 0%, #10b981 ${
|
||||
((downloadSegmentTimeout - 30000) / (300000 - 30000)) * 100
|
||||
}%, #e5e7eb ${
|
||||
((downloadSegmentTimeout - 30000) / (300000 - 30000)) * 100
|
||||
}%, #e5e7eb 100%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-between text-xs text-gray-500 dark:text-gray-400'>
|
||||
<button
|
||||
onClick={() => handleDownloadSegmentTimeoutChange(30000)}
|
||||
className={`px-2 py-0.5 rounded cursor-pointer ${
|
||||
downloadSegmentTimeout === 30000
|
||||
? 'bg-green-500 text-white'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
30秒
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownloadSegmentTimeoutChange(120000)}
|
||||
className={`px-2 py-0.5 rounded cursor-pointer ${
|
||||
downloadSegmentTimeout === 120000
|
||||
? 'bg-green-500 text-white'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
2分钟
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownloadSegmentTimeoutChange(300000)}
|
||||
className={`px-2 py-0.5 rounded cursor-pointer ${
|
||||
downloadSegmentTimeout === 300000
|
||||
? 'bg-green-500 text-white'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
5分钟
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 下载模式 */}
|
||||
<div className='space-y-2'>
|
||||
<div>
|
||||
|
||||
@@ -154,6 +154,7 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
||||
rangeDownload: task.rangeDownload,
|
||||
m3u8Content: task.m3u8Content,
|
||||
createdAt: task.createdAt || Date.now(),
|
||||
segmentLogs: task.segmentLogs,
|
||||
}));
|
||||
|
||||
await downloadDB.saveActiveTasks(tasksToSave);
|
||||
@@ -255,6 +256,7 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
||||
task.errorNum = savedTask.errorNum;
|
||||
task.downloadMode = savedTask.downloadMode;
|
||||
task.rangeDownload = savedTask.rangeDownload;
|
||||
task.segmentLogs = savedTask.segmentLogs || [];
|
||||
|
||||
if (dirHandle) {
|
||||
task.filesystemDirHandle = dirHandle;
|
||||
|
||||
@@ -29,6 +29,16 @@ export interface SavedTask {
|
||||
m3u8Content?: string;
|
||||
createdAt: number;
|
||||
completedAt?: number;
|
||||
segmentLogs?: Array<{
|
||||
id: string;
|
||||
index: number;
|
||||
status: 'queued' | 'downloading' | 'success' | 'retry' | 'error' | 'timeout' | 'aborted';
|
||||
message: string;
|
||||
timestamp: number;
|
||||
retryCount?: number;
|
||||
durationMs?: number;
|
||||
httpStatus?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CompletedTask {
|
||||
|
||||
+179
-56
@@ -8,6 +8,26 @@ import * as muxjs from 'mux.js';
|
||||
|
||||
import { AESDecryptor } from './aes-decryptor';
|
||||
|
||||
export type M3U8SegmentLogStatus =
|
||||
| 'queued'
|
||||
| 'downloading'
|
||||
| 'success'
|
||||
| 'retry'
|
||||
| 'error'
|
||||
| 'timeout'
|
||||
| 'aborted';
|
||||
|
||||
export interface M3U8SegmentLog {
|
||||
id: string;
|
||||
index: number;
|
||||
status: M3U8SegmentLogStatus;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
retryCount?: number;
|
||||
durationMs?: number;
|
||||
httpStatus?: number;
|
||||
}
|
||||
|
||||
export interface M3U8DownloadTask {
|
||||
id: string;
|
||||
url: string;
|
||||
@@ -53,6 +73,7 @@ export interface M3U8DownloadTask {
|
||||
videoId?: string;
|
||||
episodeIndex?: number;
|
||||
createdAt?: number; // 创建时间戳
|
||||
segmentLogs: M3U8SegmentLog[]; // 分片下载日志
|
||||
}
|
||||
|
||||
export interface M3U8DownloaderOptions {
|
||||
@@ -70,6 +91,24 @@ export class M3U8Downloader {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
private addSegmentLog(
|
||||
task: M3U8DownloadTask,
|
||||
log: Omit<M3U8SegmentLog, 'id' | 'timestamp'>
|
||||
): void {
|
||||
task.segmentLogs.push({
|
||||
...log,
|
||||
id: `log_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// 控制内存占用,保留最近 1000 条日志
|
||||
if (task.segmentLogs.length > 1000) {
|
||||
task.segmentLogs = task.segmentLogs.slice(-1000);
|
||||
}
|
||||
|
||||
this.options.onProgress?.(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建下载任务
|
||||
*/
|
||||
@@ -161,6 +200,7 @@ export class M3U8Downloader {
|
||||
videoId: metadata?.videoId,
|
||||
episodeIndex: metadata?.episodeIndex,
|
||||
createdAt: Date.now(),
|
||||
segmentLogs: [],
|
||||
};
|
||||
|
||||
// 解析 TS 片段
|
||||
@@ -346,6 +386,141 @@ export class M3U8Downloader {
|
||||
* 下载 TS 片段
|
||||
*/
|
||||
private downloadTS(task: M3U8DownloadTask): void {
|
||||
const maxRetries = 3;
|
||||
// 单个分片超时时间,默认 30 秒;可通过 localStorage.downloadSegmentTimeout 调整(单位:毫秒)
|
||||
const segmentTimeout = typeof window !== 'undefined'
|
||||
? Number(localStorage.getItem('downloadSegmentTimeout') || 30000)
|
||||
: 30000;
|
||||
|
||||
const cleanupRequest = (xhr: XMLHttpRequest) => {
|
||||
const requestIndex = task.requests.indexOf(xhr);
|
||||
if (requestIndex >= 0) {
|
||||
task.requests.splice(requestIndex, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const checkAllSegmentsHandled = () => {
|
||||
if (task.finishNum + task.errorNum === task.rangeDownload.targetSegment && task.errorNum > 0) {
|
||||
task.status = 'pause';
|
||||
this.options.onError?.(task, `下载完成,但有 ${task.errorNum} 个片段失败`);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadSegment = (index: number, onSettled: () => void) => {
|
||||
if (task.status === 'pause') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!task.finishList[index] || task.finishList[index].status !== '') {
|
||||
onSettled();
|
||||
return;
|
||||
}
|
||||
|
||||
task.finishList[index].status = 'is-downloading';
|
||||
if (!task.finishList[index].retryCount) {
|
||||
task.finishList[index].retryCount = 0;
|
||||
}
|
||||
const startTime = Date.now();
|
||||
this.addSegmentLog(task, {
|
||||
index,
|
||||
status: 'downloading',
|
||||
message: `开始下载分片 ${index + 1}`,
|
||||
retryCount: task.finishList[index].retryCount,
|
||||
});
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
let settled = false;
|
||||
|
||||
const handleFailure = (
|
||||
reason: string,
|
||||
status: M3U8SegmentLogStatus = 'error',
|
||||
httpStatus?: number
|
||||
) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanupRequest(xhr);
|
||||
|
||||
// 暂停/取消时 abort 请求,不应计为失败或触发重试
|
||||
if (task.status === 'pause') {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRetry = task.finishList[index].retryCount || 0;
|
||||
|
||||
if (currentRetry < maxRetries) {
|
||||
task.finishList[index].retryCount = currentRetry + 1;
|
||||
task.finishList[index].status = '';
|
||||
this.addSegmentLog(task, {
|
||||
index,
|
||||
status: 'retry',
|
||||
message: `${reason},准备第 ${currentRetry + 1}/${maxRetries} 次重试`,
|
||||
retryCount: currentRetry + 1,
|
||||
durationMs: Date.now() - startTime,
|
||||
httpStatus,
|
||||
});
|
||||
console.log(`片段 ${index} ${reason},正在重试 (${currentRetry + 1}/${maxRetries})...`);
|
||||
|
||||
// 延迟后按原 index 重试,避免失败分片被全局 downloadIndex 跳过后遗留到末尾
|
||||
setTimeout(() => {
|
||||
if (task.status !== 'pause') {
|
||||
downloadSegment(index, onSettled);
|
||||
}
|
||||
}, 1000 * (currentRetry + 1));
|
||||
} else {
|
||||
task.errorNum++;
|
||||
task.finishList[index].status = 'is-error';
|
||||
this.addSegmentLog(task, {
|
||||
index,
|
||||
status,
|
||||
message: `${reason},重试次数已用尽`,
|
||||
retryCount: currentRetry,
|
||||
durationMs: Date.now() - startTime,
|
||||
httpStatus,
|
||||
});
|
||||
this.options.onError?.(task, `片段 ${index} ${reason}(已重试 ${maxRetries} 次)`);
|
||||
checkAllSegmentsHandled();
|
||||
onSettled();
|
||||
}
|
||||
};
|
||||
|
||||
xhr.responseType = 'arraybuffer';
|
||||
xhr.timeout = Number.isFinite(segmentTimeout) && segmentTimeout > 0 ? segmentTimeout : 30000;
|
||||
xhr.onload = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
settled = true;
|
||||
cleanupRequest(xhr);
|
||||
this.dealTS(task, xhr.response, index, () => {
|
||||
if (task.finishList[index]?.status === 'is-success') {
|
||||
this.addSegmentLog(task, {
|
||||
index,
|
||||
status: 'success',
|
||||
message: `分片 ${index + 1} 下载完成`,
|
||||
retryCount: task.finishList[index].retryCount || 0,
|
||||
durationMs: Date.now() - startTime,
|
||||
httpStatus: xhr.status,
|
||||
});
|
||||
}
|
||||
onSettled();
|
||||
});
|
||||
} else {
|
||||
handleFailure(`下载失败 HTTP ${xhr.status}`, 'error', xhr.status);
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => handleFailure('网络错误', 'error');
|
||||
xhr.ontimeout = () => handleFailure(`下载超时(${xhr.timeout}ms)`, 'timeout');
|
||||
xhr.onabort = () => handleFailure('请求中止', 'aborted');
|
||||
|
||||
xhr.open('GET', task.tsUrlList[index], true);
|
||||
xhr.send();
|
||||
task.requests.push(xhr);
|
||||
};
|
||||
|
||||
const download = () => {
|
||||
const isPause = task.status === 'pause';
|
||||
const index = task.downloadIndex;
|
||||
@@ -357,63 +532,11 @@ export class M3U8Downloader {
|
||||
task.downloadIndex++;
|
||||
|
||||
if (task.finishList[index] && task.finishList[index].status === '') {
|
||||
task.finishList[index].status = 'is-downloading';
|
||||
if (!task.finishList[index].retryCount) {
|
||||
task.finishList[index].retryCount = 0;
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.responseType = 'arraybuffer';
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState === 4) {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
this.dealTS(task, xhr.response, index, () => {
|
||||
if (task.downloadIndex < task.rangeDownload.endSegment && !isPause) {
|
||||
download();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 下载失败,检查是否需要重试
|
||||
const maxRetries = 3;
|
||||
const currentRetry = task.finishList[index].retryCount || 0;
|
||||
|
||||
if (currentRetry < maxRetries) {
|
||||
// 重试
|
||||
task.finishList[index].retryCount = currentRetry + 1;
|
||||
task.finishList[index].status = '';
|
||||
console.log(`片段 ${index} 下载失败,正在重试 (${currentRetry + 1}/${maxRetries})...`);
|
||||
|
||||
// 延迟重试,避免立即重试
|
||||
setTimeout(() => {
|
||||
if (task.status !== 'pause') {
|
||||
download();
|
||||
}
|
||||
}, 1000 * (currentRetry + 1)); // 递增延迟
|
||||
} else {
|
||||
// 重试次数用完,标记为最终失败
|
||||
task.errorNum++;
|
||||
task.finishList[index].status = 'is-error';
|
||||
this.options.onError?.(task, `片段 ${index} 下载失败(已重试 ${maxRetries} 次)`);
|
||||
|
||||
// 检查是否所有片段都已处理完成
|
||||
if (task.finishNum + task.errorNum === task.rangeDownload.targetSegment) {
|
||||
if (task.errorNum > 0) {
|
||||
task.status = 'pause';
|
||||
this.options.onError?.(task, `下载完成,但有 ${task.errorNum} 个片段失败`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (task.downloadIndex < task.rangeDownload.endSegment) {
|
||||
!isPause && download();
|
||||
}
|
||||
}
|
||||
downloadSegment(index, () => {
|
||||
if (task.downloadIndex < task.rangeDownload.endSegment && task.status !== 'pause') {
|
||||
download();
|
||||
}
|
||||
};
|
||||
|
||||
xhr.open('GET', task.tsUrlList[index], true);
|
||||
xhr.send();
|
||||
task.requests.push(xhr);
|
||||
});
|
||||
} else if (task.downloadIndex < task.rangeDownload.endSegment) {
|
||||
!isPause && download();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user