From 34698e943c95277520a8c069ed8590fd5424088d Mon Sep 17 00:00:00 2001 From: mtvpls Date: Thu, 26 Feb 2026 11:36:39 +0800 Subject: [PATCH] =?UTF-8?q?file=20system=20api=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/play/page.tsx | 194 ++++++++++++++++++++++++++++-- src/components/UserMenu.tsx | 131 ++++++++++++++++++++ src/contexts/DownloadContext.tsx | 53 ++++++++ src/lib/m3u8-downloader.ts | 188 ++++++++++++++++++++++++++--- src/types/file-system-access.d.ts | 53 ++++++++ 5 files changed, 591 insertions(+), 28 deletions(-) create mode 100644 src/types/file-system-access.d.ts diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 65977cc..d29b654 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -1610,6 +1610,74 @@ function PlayPageClient() { return false; }; + /** + * 检查 File System API 本地下载 + */ + const checkFileSystemDownload = async ( + title: string + ): Promise<{ hasLocal: boolean; dirHandle?: FileSystemDirectoryHandle }> => { + try { + // 从 IndexedDB 读取目录句柄 + const dbName = 'MoonTVPlus'; + const storeName = 'dirHandles'; + + return new Promise((resolve) => { + const request = indexedDB.open(dbName, 1); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(storeName)) { + db.createObjectStore(storeName); + } + }; + + request.onsuccess = async (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + // 检查 object store 是否存在 + if (!db.objectStoreNames.contains(storeName)) { + db.close(); + resolve({ hasLocal: false }); + return; + } + + const transaction = db.transaction([storeName], 'readonly'); + const store = transaction.objectStore(storeName); + const getRequest = store.get('downloadDir'); + + getRequest.onsuccess = async () => { + const dirHandle = getRequest.result as FileSystemDirectoryHandle | undefined; + if (!dirHandle) { + resolve({ hasLocal: false }); + return; + } + + try { + // 检查是否存在 playlist.m3u8 文件 + await dirHandle.getFileHandle('playlist.m3u8', { create: false }); + console.log('找到本地下载文件:', title); + resolve({ hasLocal: true, dirHandle }); + } catch (error) { + // 文件不存在 + resolve({ hasLocal: false }); + } + }; + + getRequest.onerror = () => { + resolve({ hasLocal: false }); + }; + }; + + request.onerror = () => { + resolve({ hasLocal: false }); + }; + }); + } catch (error) { + console.error('检查 File System API 下载失败:', error); + return { hasLocal: false }; + } + }; + /** * 刷新xiaoya链接(静默刷新,不改变videoUrl状态) * @param hls HLS实例 @@ -1901,17 +1969,43 @@ function PlayPageClient() { setVideoQualities([]); } - // 检查是否有本地下载的文件 - const hasLocalFile = await checkLocalDownload(currentSource, currentId, episodeIndex); + // 检查是否有 File System API 本地下载的文件 + const episodeTitle = detailData?.episodes_titles?.[episodeIndex] || `第${episodeIndex + 1}集`; + const fileSystemCheck = await checkFileSystemDownload(episodeTitle); - if (hasLocalFile) { - // 使用本地代理接口,URL以.m3u8结尾以便Artplayer自动识别 - newUrl = `/api/offline-download/local/${currentSource}/${currentId}/${episodeIndex}/playlist.m3u8`; - console.log('使用本地下载文件播放:', newUrl); - } else if (sourceProxyMode && newUrl) { - // 如果视频源启用了代理模式,且不是本地下载,则通过代理播放 - newUrl = `/api/proxy/vod/m3u8?url=${encodeURIComponent(newUrl)}&source=${encodeURIComponent(currentSource)}`; - console.log('使用代理模式播放:', newUrl); + if (fileSystemCheck.hasLocal && fileSystemCheck.dirHandle) { + // 使用本地文件播放 + try { + const fileHandle = await fileSystemCheck.dirHandle.getFileHandle('playlist.m3u8', { create: false }); + const file = await fileHandle.getFile(); + const content = await file.text(); + + // 创建 Blob URL + const blob = new Blob([content], { type: 'application/vnd.apple.mpegurl' }); + newUrl = URL.createObjectURL(blob); + + // 保存目录句柄到 ref,供 HLS loader 使用 + (window as any).__localFileDirHandle = fileSystemCheck.dirHandle; + + console.log('使用 File System API 本地文件播放:', episodeTitle); + } catch (error) { + console.error('读取本地文件失败:', error); + } + } + + // 如果没有 File System API 本地文件,检查服务器端本地下载 + if (!fileSystemCheck.hasLocal) { + const hasLocalFile = await checkLocalDownload(currentSource, currentId, episodeIndex); + + if (hasLocalFile) { + // 使用本地代理接口,URL以.m3u8结尾以便Artplayer自动识别 + newUrl = `/api/offline-download/local/${currentSource}/${currentId}/${episodeIndex}/playlist.m3u8`; + console.log('使用服务器端本地下载文件播放:', newUrl); + } else if (sourceProxyMode && newUrl) { + // 如果视频源启用了代理模式,且不是本地下载,则通过代理播放 + newUrl = `/api/proxy/vod/m3u8?url=${encodeURIComponent(newUrl)}&source=${encodeURIComponent(currentSource)}`; + console.log('使用代理模式播放:', newUrl); + } } if (newUrl !== videoUrl) { @@ -2705,6 +2799,64 @@ 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 { @@ -4899,6 +5051,9 @@ 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' @@ -4942,6 +5097,21 @@ function PlayPageClient() { const bufferConfig = getBufferConfig(bufferStrategy); + // 选择合适的 Loader + let loaderClass; + if (localFileDirHandle) { + // 使用本地文件 Loader + const LocalFileHlsLoader = createLocalFileHlsLoader(Hls, localFileDirHandle); + loaderClass = LocalFileHlsLoader; + console.log('使用本地文件 HLS Loader'); + } else if (shouldUseCustomLoader) { + // 使用自定义广告过滤 Loader + loaderClass = CustomHlsJsLoader; + } else { + // 使用默认 Loader + loaderClass = Hls.DefaultConfig.loader; + } + const hls = new Hls({ debug: false, // 关闭日志 enableWorker: true, // WebWorker 解码,降低主线程压力 @@ -4953,9 +5123,7 @@ function PlayPageClient() { maxBufferSize: bufferConfig.maxBufferSize, // 最大缓冲大小 /* 自定义loader */ - loader: (shouldUseCustomLoader - ? CustomHlsJsLoader - : Hls.DefaultConfig.loader) as any, + loader: loaderClass as any, }); hls.loadSource(url); diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index 7ffdfa5..aa6471f 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -131,6 +131,8 @@ export const UserMenu: React.FC = () => { const [exactSearch, setExactSearch] = useState(true); const [maxConcurrentDownloads, setMaxConcurrentDownloads] = useState(6); const [downloadThreadsPerTask, setDownloadThreadsPerTask] = useState(6); + const [downloadMode, setDownloadMode] = useState<'browser' | 'filesystem'>('browser'); + const [filesystemSavePath, setFilesystemSavePath] = useState(''); // 邮件通知设置 const [userEmail, setUserEmail] = useState(''); @@ -570,6 +572,18 @@ export const UserMenu: React.FC = () => { if (savedDownloadThreadsPerTask !== null) { setDownloadThreadsPerTask(Number(savedDownloadThreadsPerTask)); } + + // 加载下载模式设置 + const savedDownloadMode = localStorage.getItem('downloadMode'); + if (savedDownloadMode === 'browser' || savedDownloadMode === 'filesystem') { + setDownloadMode(savedDownloadMode); + } + + // 加载保存路径设置 + const savedFilesystemSavePath = localStorage.getItem('filesystemSavePath'); + if (savedFilesystemSavePath !== null) { + setFilesystemSavePath(savedFilesystemSavePath); + } } }, []); @@ -932,6 +946,59 @@ export const UserMenu: React.FC = () => { } }; + const handleDownloadModeChange = (mode: 'browser' | 'filesystem') => { + // 如果选择 filesystem 模式,先检测浏览器是否支持 + if (mode === 'filesystem' && typeof window !== 'undefined' && !('showDirectoryPicker' in window)) { + setConfirmDialog({ + isOpen: true, + title: '浏览器不支持', + message: '您的浏览器不支持 File System Access API,请使用 Chrome 86+ 或 Edge 86+', + onConfirm: () => { + setConfirmDialog({ ...confirmDialog, isOpen: false }); + }, + }); + return; + } + + setDownloadMode(mode); + if (typeof window !== 'undefined') { + localStorage.setItem('downloadMode', mode); + } + }; + + const handleSelectSavePath = async () => { + try { + const dirHandle = await (window as any).showDirectoryPicker(); + setFilesystemSavePath(dirHandle.name); + localStorage.setItem('filesystemSavePath', dirHandle.name); + + // 保存目录句柄到 IndexedDB + 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); + } + }; + + request.onsuccess = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + const transaction = db.transaction([storeName], 'readwrite'); + const store = transaction.objectStore(storeName); + store.put(dirHandle, 'downloadDir'); + }; + + request.onerror = () => { + console.error('无法打开 IndexedDB'); + }; + } catch (err) { + console.error('用户取消选择目录', err); + } + }; + const handleFluidSearchToggle = (value: boolean) => { setFluidSearch(value); if (typeof window !== 'undefined') { @@ -2198,6 +2265,70 @@ export const UserMenu: React.FC = () => { + + {/* 下载模式 */} +
+
+

+ 下载模式 +

+
+
+ + +
+ + {/* 保存路径选择(仅在 filesystem 模式显示) */} + {downloadMode === 'filesystem' && ( +
+ +
+ + +
+

+ 需要 Chrome 86+ 或 Edge 86+ 浏览器支持 +

+
+ )} +
)} diff --git a/src/contexts/DownloadContext.tsx b/src/contexts/DownloadContext.tsx index 93d8db4..d6c52a3 100644 --- a/src/contexts/DownloadContext.tsx +++ b/src/contexts/DownloadContext.tsx @@ -65,6 +65,59 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { const addDownloadTask = useCallback(async (url: string, title: string, type: 'TS' | 'MP4' = 'TS') => { try { const taskId = await downloader.createTask(url, title, type); + + // 读取下载模式设置 + const downloadMode = typeof window !== 'undefined' + ? (localStorage.getItem('downloadMode') as 'browser' | 'filesystem') || 'browser' + : 'browser'; + + // 如果是 filesystem 模式,从 IndexedDB 读取目录句柄 + if (downloadMode === 'filesystem' && typeof window !== 'undefined') { + 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); + } + }; + + 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('未找到保存目录,使用浏览器下载模式'); + } + }; + }; + } catch (error) { + console.error('读取目录句柄失败:', error); + } + } + setTasks(downloader.getAllTasks()); // 从localStorage读取最大同时下载限制,默认6个 diff --git a/src/lib/m3u8-downloader.ts b/src/lib/m3u8-downloader.ts index a3fc4d8..95c406d 100644 --- a/src/lib/m3u8-downloader.ts +++ b/src/lib/m3u8-downloader.ts @@ -43,6 +43,10 @@ export interface M3U8DownloadTask { key: ArrayBuffer | null; decryption: AESDecryptor | null; }; + // File System API 相关字段 + downloadMode?: 'browser' | 'filesystem'; + filesystemDirHandle?: FileSystemDirectoryHandle; + m3u8Content?: string; // 原始 M3U8 内容,用于生成本地播放列表 } export interface M3U8DownloaderOptions { @@ -132,6 +136,7 @@ export class M3U8Downloader { key: null, decryption: null, }, + m3u8Content, // 保存原始 M3U8 内容 }; // 解析 TS 片段 @@ -378,7 +383,67 @@ export class M3U8Downloader { // MP4 转码(如果需要) if (task.type === 'MP4') { this.conversionMp4(task, data, index, (convertedData) => { - task.mediaFileList[index - task.rangeDownload.startSegment + 1] = convertedData; + if (task.downloadMode === 'filesystem') { + // File System API 模式:保存分片到文件系统 + this.saveSegmentToFilesystem(task, convertedData, index).then(() => { + task.finishList[index].status = 'is-success'; + task.finishNum++; + this.options.onProgress?.(task); + + if (task.finishNum === task.rangeDownload.targetSegment) { + task.status = 'done'; + this.generateLocalPlaylist(task); + this.options.onComplete?.(task); + } + + callback(); + }).catch((error) => { + console.error('保存分片失败:', error); + task.finishList[index].status = 'is-error'; + task.errorNum++; + callback(); + }); + } else { + // 浏览器下载模式:保存到内存 + task.mediaFileList[index - task.rangeDownload.startSegment + 1] = convertedData; + task.finishList[index].status = 'is-success'; + task.finishNum++; + + this.options.onProgress?.(task); + + if (task.finishNum === task.rangeDownload.targetSegment) { + task.status = 'done'; + this.downloadFile(task); + this.options.onComplete?.(task); + } + + callback(); + } + }); + } else { + if (task.downloadMode === 'filesystem') { + // File System API 模式:保存分片到文件系统 + this.saveSegmentToFilesystem(task, data, index).then(() => { + task.finishList[index].status = 'is-success'; + task.finishNum++; + this.options.onProgress?.(task); + + if (task.finishNum === task.rangeDownload.targetSegment) { + task.status = 'done'; + this.generateLocalPlaylist(task); + this.options.onComplete?.(task); + } + + callback(); + }).catch((error) => { + console.error('保存分片失败:', error); + task.finishList[index].status = 'is-error'; + task.errorNum++; + callback(); + }); + } else { + // 浏览器下载模式:保存到内存 + task.mediaFileList[index - task.rangeDownload.startSegment + 1] = data; task.finishList[index].status = 'is-success'; task.finishNum++; @@ -391,21 +456,7 @@ export class M3U8Downloader { } callback(); - }); - } else { - task.mediaFileList[index - task.rangeDownload.startSegment + 1] = data; - task.finishList[index].status = 'is-success'; - task.finishNum++; - - this.options.onProgress?.(task); - - if (task.finishNum === task.rangeDownload.targetSegment) { - task.status = 'done'; - this.downloadFile(task); - this.options.onComplete?.(task); } - - callback(); } } @@ -649,4 +700,111 @@ export class M3U8Downloader { }); task.requests = []; } + + /** + * 保存分片到文件系统 + */ + private async saveSegmentToFilesystem( + task: M3U8DownloadTask, + data: ArrayBuffer, + index: number + ): Promise { + if (!task.filesystemDirHandle) { + throw new Error('未选择保存目录'); + } + + const filename = `segment_${index.toString().padStart(5, '0')}.ts`; + + try { + const fileHandle = await task.filesystemDirHandle.getFileHandle(filename, { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(data); + await writable.close(); + } catch (error) { + console.error(`保存分片 ${filename} 失败:`, error); + throw error; + } + } + + /** + * 生成本地 M3U8 播放列表 + */ + private async generateLocalPlaylist(task: M3U8DownloadTask): Promise { + if (!task.filesystemDirHandle || !task.m3u8Content) { + console.error('无法生成播放列表:缺少目录句柄或 M3U8 内容'); + return; + } + + try { + const lines = task.m3u8Content.split('\n'); + const modifiedLines: string[] = []; + let segmentIndex = task.rangeDownload.startSegment; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmedLine = line.trim(); + + // 替换加密密钥 URI + if (trimmedLine.startsWith('#EXT-X-KEY:')) { + if (task.aesConf.method && task.aesConf.method !== 'NONE') { + // 如果有加密,保存密钥文件 + if (task.aesConf.key) { + await this.saveKeyToFilesystem(task, task.aesConf.key); + } + const modifiedLine = line.replace(/URI="[^"]+"/g, 'URI="key.key"'); + modifiedLines.push(modifiedLine); + } else { + modifiedLines.push(line); + } + } + // 替换视频片段 URL + else if (trimmedLine && !trimmedLine.startsWith('#')) { + if (segmentIndex >= task.rangeDownload.startSegment && segmentIndex < task.rangeDownload.endSegment) { + const indent = line.match(/^\s*/)?.[0] || ''; + const filename = `segment_${segmentIndex.toString().padStart(5, '0')}.ts`; + modifiedLines.push(indent + filename); + segmentIndex++; + } else { + modifiedLines.push(line); + } + } + // 保持其他所有行不变 + else { + modifiedLines.push(line); + } + } + + // 保存播放列表 + const playlistContent = modifiedLines.join('\n'); + const fileHandle = await task.filesystemDirHandle.getFileHandle('playlist.m3u8', { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(playlistContent); + await writable.close(); + + console.log('本地播放列表生成成功'); + } catch (error) { + console.error('生成播放列表失败:', error); + } + } + + /** + * 保存加密密钥到文件系统 + */ + private async saveKeyToFilesystem( + task: M3U8DownloadTask, + keyData: ArrayBuffer + ): Promise { + if (!task.filesystemDirHandle) { + return; + } + + try { + const fileHandle = await task.filesystemDirHandle.getFileHandle('key.key', { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(keyData); + await writable.close(); + } catch (error) { + console.error('保存密钥失败:', error); + } + } } diff --git a/src/types/file-system-access.d.ts b/src/types/file-system-access.d.ts new file mode 100644 index 0000000..33ebd64 --- /dev/null +++ b/src/types/file-system-access.d.ts @@ -0,0 +1,53 @@ +// File System Access API 类型定义 +interface FileSystemWritableFileStream extends WritableStream { + write(data: BufferSource | Blob | string): Promise; + seek(position: number): Promise; + truncate(size: number): Promise; + close(): Promise; +} + +interface FileSystemFileHandle { + createWritable(): Promise; + getFile(): Promise; +} + +interface FileSystemDirectoryHandle { + getFileHandle(name: string, options?: { create?: boolean }): Promise; + getDirectoryHandle(name: string, options?: { create?: boolean }): Promise; + removeEntry(name: string, options?: { recursive?: boolean }): Promise; + resolve(possibleDescendant: FileSystemHandle): Promise; + keys(): AsyncIterableIterator; + values(): AsyncIterableIterator; + entries(): AsyncIterableIterator<[string, FileSystemHandle]>; +} + +interface FileSystemHandle { + readonly kind: 'file' | 'directory'; + readonly name: string; + isSameEntry(other: FileSystemHandle): Promise; +} + +interface Window { + showDirectoryPicker(options?: { + id?: string; + mode?: 'read' | 'readwrite'; + startIn?: FileSystemHandle | string; + }): Promise; + + showOpenFilePicker(options?: { + multiple?: boolean; + excludeAcceptAllOption?: boolean; + types?: Array<{ + description?: string; + accept: Record; + }>; + }): Promise; + + showSaveFilePicker(options?: { + suggestedName?: string; + types?: Array<{ + description?: string; + accept: Record; + }>; + }): Promise; +}