优化视频下载
This commit is contained in:
@@ -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