diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index b22a73b..dfc7668 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -1614,7 +1614,10 @@ function PlayPageClient() { * 检查 File System API 本地下载 */ const checkFileSystemDownload = async ( - title: string + title: string, + source?: string, + videoId?: string, + episodeIndex?: number ): Promise<{ hasLocal: boolean; dirHandle?: FileSystemDirectoryHandle }> => { try { // 从 IndexedDB 读取目录句柄 @@ -1653,10 +1656,20 @@ function PlayPageClient() { } try { - // 检查是否存在 playlist.m3u8 文件 - await dirHandle.getFileHandle('playlist.m3u8', { create: false }); - console.log('找到本地下载文件:', title); - resolve({ hasLocal: true, dirHandle }); + // 如果有 source、videoId 和 episodeIndex,检查子目录 + if (source && videoId && episodeIndex !== undefined) { + const sourceDirHandle = await dirHandle.getDirectoryHandle(source, { create: false }); + const videoIdDirHandle = await sourceDirHandle.getDirectoryHandle(videoId, { create: false }); + const epDirHandle = await videoIdDirHandle.getDirectoryHandle(`ep${episodeIndex + 1}`, { create: false }); + + // 检查是否存在 playlist.m3u8 文件 + await epDirHandle.getFileHandle('playlist.m3u8', { create: false }); + console.log('找到本地下载文件:', title, `(${source}/${videoId}/ep${episodeIndex + 1})`); + resolve({ hasLocal: true, dirHandle: epDirHandle }); + } else { + // 缺少必要参数 + resolve({ hasLocal: false }); + } } catch (error) { // 文件不存在 resolve({ hasLocal: false }); @@ -1971,23 +1984,74 @@ function PlayPageClient() { // 检查是否有 File System API 本地下载的文件 const episodeTitle = detailData?.episodes_titles?.[episodeIndex] || `第${episodeIndex + 1}集`; - const fileSystemCheck = await checkFileSystemDownload(episodeTitle); + const fileSystemCheck = await checkFileSystemDownload( + episodeTitle, + currentSource || undefined, + currentId || undefined, + episodeIndex + ); if (fileSystemCheck.hasLocal && fileSystemCheck.dirHandle) { // 使用本地文件播放 try { + // 读取 m3u8 文件 const fileHandle = await fileSystemCheck.dirHandle.getFileHandle('playlist.m3u8', { create: false }); const file = await fileHandle.getFile(); - const content = await file.text(); + let content = await file.text(); - // 创建 Blob URL - const blob = new Blob([content], { type: 'application/vnd.apple.mpegurl' }); - newUrl = URL.createObjectURL(blob); + // 解析 m3u8 文件,为每个 ts 文件创建 Blob URL + const lines = content.split('\n'); + const modifiedLines: string[] = []; + const blobUrls: string[] = []; // 保存 Blob URL 以便后续清理 - // 保存目录句柄到 ref,供 HLS loader 使用 - (window as any).__localFileDirHandle = fileSystemCheck.dirHandle; + for (const line of lines) { + const trimmedLine = line.trim(); - console.log('使用 File System API 本地文件播放:', episodeTitle); + // 如果是 ts 文件 + if (trimmedLine.endsWith('.ts')) { + try { + // 读取 ts 文件 + const tsFileHandle = await fileSystemCheck.dirHandle.getFileHandle(trimmedLine, { create: false }); + const tsFile = await tsFileHandle.getFile(); + + // 创建 Blob URL + const blobUrl = URL.createObjectURL(tsFile); + blobUrls.push(blobUrl); + + // 替换为 Blob URL + modifiedLines.push(line.replace(trimmedLine, blobUrl)); + } catch (error) { + console.error(`读取 ts 文件失败: ${trimmedLine}`, error); + modifiedLines.push(line); + } + } + // 如果是加密密钥 + else if (trimmedLine.includes('key.key')) { + try { + const keyFileHandle = await fileSystemCheck.dirHandle.getFileHandle('key.key', { create: false }); + const keyFile = await keyFileHandle.getFile(); + const keyBlobUrl = URL.createObjectURL(keyFile); + blobUrls.push(keyBlobUrl); + modifiedLines.push(line.replace('key.key', keyBlobUrl)); + } catch (error) { + console.error('读取密钥文件失败:', error); + modifiedLines.push(line); + } + } + else { + modifiedLines.push(line); + } + } + + // 创建修改后的 m3u8 的 Blob URL + const modifiedContent = modifiedLines.join('\n'); + const m3u8Blob = new Blob([modifiedContent], { type: 'application/vnd.apple.mpegurl' }); + newUrl = URL.createObjectURL(m3u8Blob); + + // 保存 Blob URLs 到 window,以便在切换视频时清理 + (window as any).__localFileBlobUrls = blobUrls; + + console.log('使用 File System API 本地文件播放(Blob URL 模式):', episodeTitle); } catch (error) { console.error('读取本地文件失败:', error); } @@ -2088,7 +2152,11 @@ function PlayPageClient() { // M3U8格式 - 使用新的下载器,TS 格式 try { const downloadTitle = `${videoTitle}_第${episodeIndex + 1}集`; - await addDownloadTask(proxyUrl, downloadTitle, 'TS'); + await addDownloadTask(proxyUrl, downloadTitle, 'TS', { + source: currentSource || undefined, + videoId: currentId || undefined, + episodeIndex, + }); successCount++; } catch (error) { console.error(`添加下载任务失败 (第${episodeIndex + 1}集):`, error); @@ -2799,64 +2867,6 @@ function PlayPageClient() { } }; - // 创建本地文件 HLS loader 的工厂函数 - const createLocalFileHlsLoader = (HlsLib: any, dirHandle: FileSystemDirectoryHandle) => { - return class LocalFileHlsLoader extends HlsLib.DefaultConfig.loader { - constructor(config: any) { - super(config); - const originalLoad = this.load.bind(this); - - this.load = async function (context: any, config: any, callbacks: any) { - try { - const url = context.url; - - // 提取文件名 - let filename = ''; - if (url.includes('blob:')) { - // 如果是 blob URL,从 M3U8 内容中提取文件名 - filename = url.split('/').pop() || ''; - } else { - filename = url.split('/').pop() || ''; - } - - console.log('尝试加载本地文件:', filename); - - // 从 File System API 读取文件 - const fileHandle = await dirHandle.getFileHandle(filename, { create: false }); - const file = await fileHandle.getFile(); - - let data: string | ArrayBuffer; - if (filename.endsWith('.m3u8')) { - data = await file.text(); - } else { - data = await file.arrayBuffer(); - } - - // 调用成功回调 - callbacks.onSuccess( - { - url: url, - data: data, - }, - { - trequest: performance.now(), - tfirst: performance.now(), - tload: performance.now(), - loaded: file.size, - total: file.size, - }, - context - ); - } catch (error) { - console.error('加载本地文件失败:', error); - // 如果本地文件加载失败,回退到网络加载 - originalLoad(context, config, callbacks); - } - }; - } - }; - }; - // 创建自定义 HLS loader 的工厂函数 const createCustomHlsLoader = (HlsLib: any) => { return class CustomHlsJsLoader extends HlsLib.DefaultConfig.loader { @@ -5051,9 +5061,6 @@ function PlayPageClient() { // 每次创建HLS实例时,都读取最新的blockAdEnabled状态 const shouldUseCustomLoader = blockAdEnabledRef.current; - // 检查是否有本地文件目录句柄 - const localFileDirHandle = (window as any).__localFileDirHandle as FileSystemDirectoryHandle | undefined; - // 从localStorage读取缓冲策略 const bufferStrategy = typeof window !== 'undefined' ? localStorage.getItem('bufferStrategy') || 'medium' @@ -5099,12 +5106,7 @@ function PlayPageClient() { // 选择合适的 Loader let loaderClass; - if (localFileDirHandle) { - // 使用本地文件 Loader - const LocalFileHlsLoader = createLocalFileHlsLoader(Hls, localFileDirHandle); - loaderClass = LocalFileHlsLoader; - console.log('使用本地文件 HLS Loader'); - } else if (shouldUseCustomLoader) { + if (shouldUseCustomLoader) { // 使用自定义广告过滤 Loader loaderClass = CustomHlsJsLoader; } else { diff --git a/src/contexts/DownloadContext.tsx b/src/contexts/DownloadContext.tsx index d6c52a3..5882a58 100644 --- a/src/contexts/DownloadContext.tsx +++ b/src/contexts/DownloadContext.tsx @@ -7,7 +7,16 @@ import { M3U8Downloader, M3U8DownloadTask } from '@/lib/m3u8-downloader'; interface DownloadContextType { downloader: M3U8Downloader; tasks: M3U8DownloadTask[]; - addDownloadTask: (url: string, title: string, type?: 'TS' | 'MP4') => Promise; + addDownloadTask: ( + url: string, + title: string, + type?: 'TS' | 'MP4', + metadata?: { + source?: string; + videoId?: string; + episodeIndex?: number; + } + ) => Promise; startTask: (taskId: string) => void; pauseTask: (taskId: string) => void; cancelTask: (taskId: string) => void; @@ -62,9 +71,18 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { }, })); - const addDownloadTask = useCallback(async (url: string, title: string, type: 'TS' | 'MP4' = 'TS') => { + const addDownloadTask = useCallback(async ( + url: string, + title: string, + type: 'TS' | 'MP4' = 'TS', + metadata?: { + source?: string; + videoId?: string; + episodeIndex?: number; + } + ) => { try { - const taskId = await downloader.createTask(url, title, type); + const taskId = await downloader.createTask(url, title, type, metadata); // 读取下载模式设置 const downloadMode = typeof window !== 'undefined' @@ -76,43 +94,59 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { try { const dbName = 'MoonTVPlus'; const storeName = 'dirHandles'; - const request = indexedDB.open(dbName, 1); - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - if (!db.objectStoreNames.contains(storeName)) { - db.createObjectStore(storeName); - } - }; + // 使用 Promise 包装 IndexedDB 操作,确保在启动任务前完成 + await new Promise((resolve, reject) => { + const request = indexedDB.open(dbName, 1); - request.onsuccess = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - - // 检查 object store 是否存在 - if (!db.objectStoreNames.contains(storeName)) { - console.warn('Object store 不存在,跳过读取'); - db.close(); - return; - } - - const transaction = db.transaction([storeName], 'readonly'); - const store = transaction.objectStore(storeName); - const getRequest = store.get('downloadDir'); - - getRequest.onsuccess = () => { - const dirHandle = getRequest.result as FileSystemDirectoryHandle | undefined; - if (dirHandle) { - // 更新任务的下载模式和目录句柄 - const task = downloader.getTask(taskId); - if (task) { - task.downloadMode = 'filesystem'; - task.filesystemDirHandle = dirHandle; - } - } else { - console.warn('未找到保存目录,使用浏览器下载模式'); + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(storeName)) { + db.createObjectStore(storeName); } }; - }; + + request.onsuccess = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + // 检查 object store 是否存在 + if (!db.objectStoreNames.contains(storeName)) { + console.warn('Object store 不存在,跳过读取'); + db.close(); + resolve(); + return; + } + + const transaction = db.transaction([storeName], 'readonly'); + const store = transaction.objectStore(storeName); + const getRequest = store.get('downloadDir'); + + getRequest.onsuccess = () => { + const dirHandle = getRequest.result as FileSystemDirectoryHandle | undefined; + if (dirHandle) { + // 更新任务的下载模式和目录句柄 + const task = downloader.getTask(taskId); + if (task) { + task.downloadMode = 'filesystem'; + task.filesystemDirHandle = dirHandle; + } + } else { + console.warn('未找到保存目录,使用浏览器下载模式'); + } + db.close(); + resolve(); + }; + + getRequest.onerror = () => { + db.close(); + reject(new Error('读取目录句柄失败')); + }; + }; + + request.onerror = () => { + reject(new Error('打开 IndexedDB 失败')); + }; + }); } catch (error) { console.error('读取目录句柄失败:', error); } diff --git a/src/lib/m3u8-downloader.ts b/src/lib/m3u8-downloader.ts index 95c406d..01c8069 100644 --- a/src/lib/m3u8-downloader.ts +++ b/src/lib/m3u8-downloader.ts @@ -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 { + async createTask( + url: string, + title: string, + type: 'TS' | 'MP4' = 'TS', + metadata?: { + source?: string; + videoId?: string; + episodeIndex?: number; + } + ): Promise { 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 { - 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();