优化视频下载

This commit is contained in:
mtvpls
2026-06-04 14:09:12 +08:00
parent caa9cd82c0
commit 63fb51ce99
5 changed files with 520 additions and 97 deletions
+10
View File
@@ -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
View File
@@ -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();
}