From 63fb51ce99bc8a4ecc48635869c76d7c401e035f Mon Sep 17 00:00:00 2001 From: mtvpls Date: Thu, 4 Jun 2026 14:09:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=A7=86=E9=A2=91=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/DownloadPanel.tsx | 266 ++++++++++++++++++++++++++----- src/components/UserMenu.tsx | 104 ++++++++++++ src/contexts/DownloadContext.tsx | 2 + src/lib/download-db.ts | 10 ++ src/lib/m3u8-downloader.ts | 235 ++++++++++++++++++++------- 5 files changed, 520 insertions(+), 97 deletions(-) diff --git a/src/components/DownloadPanel.tsx b/src/components/DownloadPanel.tsx index 5073b32..1d8a738 100644 --- a/src/components/DownloadPanel.tsx +++ b/src/components/DownloadPanel.tsx @@ -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(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 ( -
-
+
+
{/* 标题栏 */} -
-

下载任务列表

+
+
+

下载任务列表

+

支持查看每个分片的下载、重试、超时和失败日志

+
@@ -66,8 +133,8 @@ export function DownloadPanel() { {/* 任务列表 */}
{tasks.length === 0 ? ( -
- +
+ {/* 任务信息 */} -
-
-

+
+
+

{task.title}

-

{task.url}

+

{task.url}

-
+
{getStatusText(task.status)} - + {task.type}
@@ -105,22 +172,22 @@ export function DownloadPanel() { {/* 进度条 */}
-
+
{task.finishNum} / {task.rangeDownload.targetSegment} 片段 {progress.toFixed(1)}%
-
+
@@ -129,13 +196,13 @@ export function DownloadPanel() { {/* 错误信息 */} {task.errorNum > 0 && ( -
-
+
+
{task.errorNum} 个片段下载失败
@@ -143,13 +210,31 @@ export function DownloadPanel() { )} {/* 操作按钮 */} -
+
+ + {task.status === 'downloading' && ( +
+ + {logStats && ( +
+
+
日志数
+
{logStats.total}
+
+
+
成功分片
+
{logStats.success}
+
+
+
下载中
+
{logStats.downloading}
+
+
+
失败分片
+
{logStats.error}
+
+
+ )} + +
+ {(['all', 'downloading', 'success', 'retry', 'timeout', 'error', 'aborted'] as Array<'all' | M3U8SegmentLogStatus>).map((filter) => ( + + ))} +
+
+ +
+ {filteredLogs.length === 0 ? ( +
+ + + +

暂无匹配的分片日志

+
+ ) : ( +
+ {filteredLogs.map((log) => ( +
+
+ {formatTime(log.timestamp)} +
+
+ + {getLogStatusText(log.status)} + +
+
+
{log.message}
+
segment #{log.index + 1}
+
+
+ {typeof log.retryCount === 'number' && 重试 {log.retryCount}} + {typeof log.durationMs === 'number' && {log.durationMs}ms} + {typeof log.httpStatus === 'number' && HTTP {log.httpStatus}} +
+
+ ))} +
+ )} +
+
+
+ )}
); } diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index eddb777..c14128b 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -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 = () => {
+ {/* 分片下载超时 */} +
+
+

+ 分片下载超时 +

+

+ 单个分片超过该时间仍未完成时会自动判定超时并按原分片重试 +

+
+
+ + 超时时间 + + + {formatDownloadSegmentTimeout(downloadSegmentTimeout)} + +
+
+ + 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%)`, + }} + /> +
+
+ + + +
+
+ {/* 下载模式 */}
diff --git a/src/contexts/DownloadContext.tsx b/src/contexts/DownloadContext.tsx index 73e84eb..4b202da 100644 --- a/src/contexts/DownloadContext.tsx +++ b/src/contexts/DownloadContext.tsx @@ -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; diff --git a/src/lib/download-db.ts b/src/lib/download-db.ts index 72a651e..a328f61 100644 --- a/src/lib/download-db.ts +++ b/src/lib/download-db.ts @@ -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 { diff --git a/src/lib/m3u8-downloader.ts b/src/lib/m3u8-downloader.ts index f965f43..753b197 100644 --- a/src/lib/m3u8-downloader.ts +++ b/src/lib/m3u8-downloader.ts @@ -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 + ): 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(); }