本地播放

This commit is contained in:
mtvpls
2026-02-26 20:55:40 +08:00
parent 11626f77f2
commit 17376ff0cd
3 changed files with 239 additions and 136 deletions
+86 -19
View File
@@ -47,6 +47,10 @@ export interface M3U8DownloadTask {
downloadMode?: 'browser' | 'filesystem';
filesystemDirHandle?: FileSystemDirectoryHandle;
m3u8Content?: string; // 原始 M3U8 内容,用于生成本地播放列表
// 视频标识信息(用于区分不同视频)
source?: string;
videoId?: string;
episodeIndex?: number;
}
export interface M3U8DownloaderOptions {
@@ -67,7 +71,16 @@ export class M3U8Downloader {
/**
* 创建下载任务
*/
async createTask(url: string, title: string, type: 'TS' | 'MP4' = 'TS'): Promise<string> {
async createTask(
url: string,
title: string,
type: 'TS' | 'MP4' = 'TS',
metadata?: {
source?: string;
videoId?: string;
episodeIndex?: number;
}
): Promise<string> {
const taskId = 't_' + Date.now() + Math.random().toString(36).substr(2, 9);
try {
@@ -85,11 +98,11 @@ export class M3U8Downloader {
// 自动选择最高清晰度
url = streams[0].url;
const subM3u8Content = await this.fetchM3U8(url);
return this.processM3U8Content(taskId, url, title, type, subM3u8Content);
return this.processM3U8Content(taskId, url, title, type, subM3u8Content, metadata);
}
}
return this.processM3U8Content(taskId, url, title, type, m3u8Content);
return this.processM3U8Content(taskId, url, title, type, m3u8Content, metadata);
} catch (error) {
throw new Error(`创建任务失败: ${error}`);
}
@@ -103,7 +116,12 @@ export class M3U8Downloader {
url: string,
title: string,
type: 'TS' | 'MP4',
m3u8Content: string
m3u8Content: string,
metadata?: {
source?: string;
videoId?: string;
episodeIndex?: number;
}
): string {
const task: M3U8DownloadTask = {
id: taskId,
@@ -115,7 +133,7 @@ export class M3U8Downloader {
tsUrlList: [],
requests: [],
mediaFileList: [],
downloadIndex: 0,
downloadIndex: 0, // 初始化为 0,在 startTask 时会设置为正确的值
downloading: false,
durationSecond: 0,
beginTime: new Date(),
@@ -125,7 +143,7 @@ export class M3U8Downloader {
retryCountdown: 0,
rangeDownload: {
isShowRange: false,
startSegment: 1,
startSegment: 0, // 改为从 0 开始
endSegment: 0,
targetSegment: 0,
},
@@ -137,6 +155,9 @@ export class M3U8Downloader {
decryption: null,
},
m3u8Content, // 保存原始 M3U8 内容
source: metadata?.source,
videoId: metadata?.videoId,
episodeIndex: metadata?.episodeIndex,
};
// 解析 TS 片段
@@ -185,6 +206,19 @@ export class M3U8Downloader {
await this.getAESKey(task);
}
// 重置下载索引到第一个未完成的片段
if (task.status === 'ready' || task.status === 'pause') {
// 找到第一个未完成的片段
let firstIncompleteIndex = 0;
for (let i = 0; i < task.finishList.length; i++) {
if (task.finishList[i].status !== 'is-success') {
firstIncompleteIndex = i;
break;
}
}
task.downloadIndex = firstIncompleteIndex;
}
task.status = 'downloading';
this.currentTask = task;
this.downloadTS(task);
@@ -264,7 +298,7 @@ export class M3U8Downloader {
// 找到第一个失败的片段索引
let firstErrorIndex = task.rangeDownload.endSegment;
for (let i = task.rangeDownload.startSegment - 1; i < task.rangeDownload.endSegment; i++) {
for (let i = task.rangeDownload.startSegment; i < task.rangeDownload.endSegment; i++) {
if (task.finishList[i] && task.finishList[i].status === '') {
firstErrorIndex = Math.min(firstErrorIndex, i);
}
@@ -405,7 +439,7 @@ export class M3U8Downloader {
});
} else {
// 浏览器下载模式:保存到内存
task.mediaFileList[index - task.rangeDownload.startSegment + 1] = convertedData;
task.mediaFileList[index] = convertedData;
task.finishList[index].status = 'is-success';
task.finishNum++;
@@ -443,7 +477,7 @@ export class M3U8Downloader {
});
} else {
// 浏览器下载模式:保存到内存
task.mediaFileList[index - task.rangeDownload.startSegment + 1] = data;
task.mediaFileList[index] = data;
task.finishList[index].status = 'is-success';
task.finishNum++;
@@ -664,7 +698,7 @@ export class M3U8Downloader {
transMuxer.on('data', (segment: any) => {
// 第一个片段需要包含初始化段
if (index === task.rangeDownload.startSegment - 1) {
if (index === 0) {
const combinedData = new Uint8Array(
segment.initSegment.byteLength + segment.data.byteLength
);
@@ -713,10 +747,28 @@ export class M3U8Downloader {
throw new Error('未选择保存目录');
}
// 创建子目录结构:source/videoId/ep{episodeIndex+1}
let targetDirHandle = task.filesystemDirHandle;
if (task.source && task.videoId && task.episodeIndex !== undefined) {
try {
// 创建 source 目录
const sourceDirHandle = await task.filesystemDirHandle.getDirectoryHandle(task.source, { create: true });
// 创建 videoId 目录
const videoIdDirHandle = await sourceDirHandle.getDirectoryHandle(task.videoId, { create: true });
// 创建 ep{n} 目录
const epDirHandle = await videoIdDirHandle.getDirectoryHandle(`ep${task.episodeIndex + 1}`, { create: true });
targetDirHandle = epDirHandle;
} catch (error) {
console.error('创建子目录失败:', error);
throw error;
}
}
const filename = `segment_${index.toString().padStart(5, '0')}.ts`;
try {
const fileHandle = await task.filesystemDirHandle.getFileHandle(filename, { create: true });
const fileHandle = await targetDirHandle.getFileHandle(filename, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(data);
await writable.close();
@@ -735,6 +787,21 @@ export class M3U8Downloader {
return;
}
// 获取目标目录句柄(如果有子目录结构)
let targetDirHandle = task.filesystemDirHandle;
if (task.source && task.videoId && task.episodeIndex !== undefined) {
try {
const sourceDirHandle = await task.filesystemDirHandle.getDirectoryHandle(task.source, { create: false });
const videoIdDirHandle = await sourceDirHandle.getDirectoryHandle(task.videoId, { create: false });
const epDirHandle = await videoIdDirHandle.getDirectoryHandle(`ep${task.episodeIndex + 1}`, { create: false });
targetDirHandle = epDirHandle;
} catch (error) {
console.error('获取子目录失败:', error);
return;
}
}
try {
const lines = task.m3u8Content.split('\n');
const modifiedLines: string[] = [];
@@ -749,7 +816,7 @@ export class M3U8Downloader {
if (task.aesConf.method && task.aesConf.method !== 'NONE') {
// 如果有加密,保存密钥文件
if (task.aesConf.key) {
await this.saveKeyToFilesystem(task, task.aesConf.key);
await this.saveKeyToFilesystem(task, task.aesConf.key, targetDirHandle);
}
const modifiedLine = line.replace(/URI="[^"]+"/g, 'URI="key.key"');
modifiedLines.push(modifiedLine);
@@ -759,7 +826,7 @@ export class M3U8Downloader {
}
// 替换视频片段 URL
else if (trimmedLine && !trimmedLine.startsWith('#')) {
if (segmentIndex >= task.rangeDownload.startSegment && segmentIndex < task.rangeDownload.endSegment) {
if (segmentIndex < task.rangeDownload.endSegment) {
const indent = line.match(/^\s*/)?.[0] || '';
const filename = `segment_${segmentIndex.toString().padStart(5, '0')}.ts`;
modifiedLines.push(indent + filename);
@@ -776,12 +843,10 @@ export class M3U8Downloader {
// 保存播放列表
const playlistContent = modifiedLines.join('\n');
const fileHandle = await task.filesystemDirHandle.getFileHandle('playlist.m3u8', { create: true });
const fileHandle = await targetDirHandle.getFileHandle('playlist.m3u8', { create: true });
const writable = await fileHandle.createWritable();
await writable.write(playlistContent);
await writable.close();
console.log('本地播放列表生成成功');
} catch (error) {
console.error('生成播放列表失败:', error);
}
@@ -792,14 +857,16 @@ export class M3U8Downloader {
*/
private async saveKeyToFilesystem(
task: M3U8DownloadTask,
keyData: ArrayBuffer
keyData: ArrayBuffer,
targetDirHandle?: FileSystemDirectoryHandle
): Promise<void> {
if (!task.filesystemDirHandle) {
const dirHandle = targetDirHandle || task.filesystemDirHandle;
if (!dirHandle) {
return;
}
try {
const fileHandle = await task.filesystemDirHandle.getFileHandle('key.key', { create: true });
const fileHandle = await dirHandle.getFileHandle('key.key', { create: true });
const writable = await fileHandle.createWritable();
await writable.write(keyData);
await writable.close();