file system api下载

This commit is contained in:
mtvpls
2026-02-26 11:36:39 +08:00
parent 0745e25ed7
commit 34698e943c
5 changed files with 591 additions and 28 deletions
+181 -13
View File
@@ -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);
+131
View File
@@ -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<string>('');
// 邮件通知设置
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 = () => {
</button>
</div>
</div>
{/* 下载模式 */}
<div className='space-y-2'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
</div>
<div className='space-y-2'>
<label className='flex items-center gap-2 cursor-pointer'>
<input
type='radio'
name='downloadMode'
value='browser'
checked={downloadMode === 'browser'}
onChange={() => handleDownloadModeChange('browser')}
className='w-4 h-4 text-green-500'
/>
<span className='text-sm text-gray-700 dark:text-gray-300'>
</span>
</label>
<label className='flex items-center gap-2 cursor-pointer'>
<input
type='radio'
name='downloadMode'
value='filesystem'
checked={downloadMode === 'filesystem'}
onChange={() => handleDownloadModeChange('filesystem')}
className='w-4 h-4 text-green-500'
/>
<span className='text-sm text-gray-700 dark:text-gray-300'>
File System API
</span>
</label>
</div>
{/* 保存路径选择(仅在 filesystem 模式显示) */}
{downloadMode === 'filesystem' && (
<div className='mt-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg space-y-2'>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300'>
</label>
<div className='flex gap-2'>
<input
type='text'
value={filesystemSavePath}
readOnly
placeholder='点击选择保存目录'
className='flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300'
/>
<button
onClick={handleSelectSavePath}
className='px-4 py-2 text-sm bg-green-500 text-white rounded hover:bg-green-600 transition-colors'
>
</button>
</div>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Chrome 86+ Edge 86+
</p>
</div>
)}
</div>
</div>
)}
</div>
+53
View File
@@ -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个
+173 -15
View File
@@ -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<void> {
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<void> {
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<void> {
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);
}
}
}
+53
View File
@@ -0,0 +1,53 @@
// File System Access API 类型定义
interface FileSystemWritableFileStream extends WritableStream {
write(data: BufferSource | Blob | string): Promise<void>;
seek(position: number): Promise<void>;
truncate(size: number): Promise<void>;
close(): Promise<void>;
}
interface FileSystemFileHandle {
createWritable(): Promise<FileSystemWritableFileStream>;
getFile(): Promise<File>;
}
interface FileSystemDirectoryHandle {
getFileHandle(name: string, options?: { create?: boolean }): Promise<FileSystemFileHandle>;
getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<FileSystemDirectoryHandle>;
removeEntry(name: string, options?: { recursive?: boolean }): Promise<void>;
resolve(possibleDescendant: FileSystemHandle): Promise<string[] | null>;
keys(): AsyncIterableIterator<string>;
values(): AsyncIterableIterator<FileSystemHandle>;
entries(): AsyncIterableIterator<[string, FileSystemHandle]>;
}
interface FileSystemHandle {
readonly kind: 'file' | 'directory';
readonly name: string;
isSameEntry(other: FileSystemHandle): Promise<boolean>;
}
interface Window {
showDirectoryPicker(options?: {
id?: string;
mode?: 'read' | 'readwrite';
startIn?: FileSystemHandle | string;
}): Promise<FileSystemDirectoryHandle>;
showOpenFilePicker(options?: {
multiple?: boolean;
excludeAcceptAllOption?: boolean;
types?: Array<{
description?: string;
accept: Record<string, string[]>;
}>;
}): Promise<FileSystemFileHandle[]>;
showSaveFilePicker(options?: {
suggestedName?: string;
types?: Array<{
description?: string;
accept: Record<string, string[]>;
}>;
}): Promise<FileSystemFileHandle>;
}