From 99b58b2e6a05a4f09d18d65568268639668554e3 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sun, 21 Jun 2026 10:59:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=A2=9E=E5=8A=A0indexdb?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/push-sw.js | 194 ++++++ src/app/play/page.tsx | 47 +- src/components/DownloadManagementPanel.tsx | 309 ++++++++-- src/components/DownloadPanel.tsx | 15 + src/components/UserMenu.tsx | 22 +- src/contexts/DownloadContext.tsx | 117 +++- src/lib/download-db.ts | 4 +- src/lib/indexeddb-video-cache.ts | 648 +++++++++++++++++++++ src/lib/m3u8-downloader.ts | 267 ++++++--- 9 files changed, 1465 insertions(+), 158 deletions(-) create mode 100644 src/lib/indexeddb-video-cache.ts diff --git a/public/push-sw.js b/public/push-sw.js index 855cf60..55dee4c 100644 --- a/public/push-sw.js +++ b/public/push-sw.js @@ -56,3 +56,197 @@ self.addEventListener('notificationclick', (event) => { } })()); }); + +/* IndexedDB video cache virtual files + * Route: /__moontv_idb_video__//playlist.m3u8 + * /__moontv_idb_video__//segment_00000.ts + * /__moontv_idb_video__//key.key + */ +const IDB_VIDEO_DB_NAME = 'MoonTVPlusVideoCache'; +const IDB_VIDEO_DB_VERSION = 1; +const IDB_VIDEO_ROUTE_PREFIX = '/__moontv_idb_video__'; +const IDB_VIDEO_MANIFESTS_STORE = 'manifests'; +const IDB_VIDEO_SEGMENTS_STORE = 'segments'; +const IDB_VIDEO_ASSETS_STORE = 'assets'; + +let idbVideoDbPromise = null; + +function openIDBVideoCache() { + if (idbVideoDbPromise) return idbVideoDbPromise; + + idbVideoDbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(IDB_VIDEO_DB_NAME, IDB_VIDEO_DB_VERSION); + + request.onerror = () => { + idbVideoDbPromise = null; + reject(request.error); + }; + + request.onsuccess = () => { + const db = request.result; + db.onversionchange = () => { + db.close(); + idbVideoDbPromise = null; + }; + resolve(db); + }; + + request.onupgradeneeded = () => { + const db = request.result; + + if (!db.objectStoreNames.contains(IDB_VIDEO_MANIFESTS_STORE)) { + const manifestStore = db.createObjectStore(IDB_VIDEO_MANIFESTS_STORE, { + keyPath: 'cacheKey', + }); + manifestStore.createIndex('sourceVideoEpisode', ['source', 'videoId', 'episodeIndex'], { + unique: true, + }); + manifestStore.createIndex('completed', 'completed', { unique: false }); + manifestStore.createIndex('updatedAt', 'updatedAt', { unique: false }); + } + + if (!db.objectStoreNames.contains(IDB_VIDEO_SEGMENTS_STORE)) { + const segmentStore = db.createObjectStore(IDB_VIDEO_SEGMENTS_STORE, { + keyPath: 'id', + }); + segmentStore.createIndex('cacheKey', 'cacheKey', { unique: false }); + } + + if (!db.objectStoreNames.contains(IDB_VIDEO_ASSETS_STORE)) { + const assetStore = db.createObjectStore(IDB_VIDEO_ASSETS_STORE, { + keyPath: 'id', + }); + assetStore.createIndex('cacheKey', 'cacheKey', { unique: false }); + } + }; + }); + + return idbVideoDbPromise; +} + +function idbVideoGet(storeName, key) { + return openIDBVideoCache().then((db) => new Promise((resolve, reject) => { + const tx = db.transaction([storeName], 'readonly'); + const store = tx.objectStore(storeName); + const request = store.get(key); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + })); +} + +function makeIDBVideoSegmentId(cacheKey, index) { + return `${cacheKey}:segment:${index}`; +} + +function makeIDBVideoAssetId(cacheKey, name) { + return `${cacheKey}:asset:${name}`; +} + +function parseIDBVideoRequest(url) { + if (url.origin !== self.location.origin) return null; + if (!url.pathname.startsWith(`${IDB_VIDEO_ROUTE_PREFIX}/`)) return null; + + const rest = url.pathname.slice(IDB_VIDEO_ROUTE_PREFIX.length + 1); + const slashIndex = rest.indexOf('/'); + if (slashIndex <= 0) return null; + + const cacheKey = decodeURIComponent(rest.slice(0, slashIndex)); + const fileName = rest.slice(slashIndex + 1); + + if (!cacheKey || !fileName) return null; + + if (fileName === 'playlist.m3u8') { + return { cacheKey, type: 'playlist' }; + } + + if (fileName === 'key.key') { + return { cacheKey, type: 'key' }; + } + + const segmentMatch = fileName.match(/^segment_(\d+)\.ts$/i); + if (segmentMatch) { + return { + cacheKey, + type: 'segment', + index: Number(segmentMatch[1]), + }; + } + + return null; +} + +async function handleIDBVideoRequest(info) { + const manifest = await idbVideoGet(IDB_VIDEO_MANIFESTS_STORE, info.cacheKey); + if (!manifest || !manifest.completed) { + return new Response('IndexedDB video cache not found', { status: 404 }); + } + + if (info.type === 'playlist') { + return new Response(manifest.playlistContent || '', { + status: 200, + headers: { + 'Content-Type': 'application/vnd.apple.mpegurl; charset=utf-8', + 'Cache-Control': 'no-store', + 'Access-Control-Allow-Origin': '*', + }, + }); + } + + if (info.type === 'segment') { + const segment = await idbVideoGet( + IDB_VIDEO_SEGMENTS_STORE, + makeIDBVideoSegmentId(info.cacheKey, info.index) + ); + + if (!segment || !segment.data) { + return new Response('IndexedDB video segment not found', { status: 404 }); + } + + return new Response(segment.data, { + status: 200, + headers: { + 'Content-Type': segment.data.type || manifest.mimeType || 'video/MP2T', + 'Content-Length': String(segment.size || segment.data.size || 0), + 'Cache-Control': 'no-store', + 'Access-Control-Allow-Origin': '*', + }, + }); + } + + if (info.type === 'key') { + const asset = await idbVideoGet( + IDB_VIDEO_ASSETS_STORE, + makeIDBVideoAssetId(info.cacheKey, 'key.key') + ); + + if (!asset || !asset.data) { + return new Response('IndexedDB video key not found', { status: 404 }); + } + + return new Response(asset.data, { + status: 200, + headers: { + 'Content-Type': asset.mimeType || 'application/octet-stream', + 'Content-Length': String(asset.size || asset.data.size || 0), + 'Cache-Control': 'no-store', + 'Access-Control-Allow-Origin': '*', + }, + }); + } + + return new Response('Unsupported IndexedDB video cache request', { status: 400 }); +} + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + const info = parseIDBVideoRequest(url); + if (!info) return; + + event.respondWith( + handleIDBVideoRequest(info).catch((error) => { + console.error('[IndexedDBVideoSW] request failed:', error); + return new Response('IndexedDB video cache error', { status: 500 }); + }) + ); +}); diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 91dade9..cd377f8 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -61,6 +61,7 @@ import { recommendationCacheKeys, setRecommendationCache, } from '@/lib/recommendations/cache'; +import { getIndexedDBVideoPlaybackUrl } from '@/lib/indexeddb-video-cache'; import { convertSubtitleFileToVttObjectUrl, CUSTOM_SUBTITLE_ACCEPT, @@ -2709,6 +2710,19 @@ function PlayPageClient() { return Math.round(score * 100) / 100; // 保留两位小数 }; + const cleanupLocalPlaybackBlobUrls = () => { + if (typeof window === 'undefined') return; + const urls = (window as any).__localFileBlobUrls; + if (Array.isArray(urls)) { + urls.forEach((url) => { + if (typeof url === 'string' && url.startsWith('blob:')) { + URL.revokeObjectURL(url); + } + }); + } + (window as any).__localFileBlobUrls = []; + }; + // 检查是否有本地下载的视频 const checkLocalDownload = async ( source: string, @@ -3198,6 +3212,7 @@ function PlayPageClient() { if (fileSystemCheck.hasLocal && fileSystemCheck.dirHandle) { // 使用本地文件播放 try { + cleanupLocalPlaybackBlobUrls(); // 读取 m3u8 文件 const fileHandle = await fileSystemCheck.dirHandle.getFileHandle('playlist.m3u8', { create: false }); const file = await fileHandle.getFile(); @@ -3261,8 +3276,36 @@ function PlayPageClient() { } } - // 如果没有 File System API 本地文件,检查服务器端本地下载 - if (!fileSystemCheck.hasLocal) { + let indexedDBCheck: Awaited> = { hasLocal: false }; + + // 如果没有 File System API 本地文件,检查 IndexedDB 应用内离线缓存 + if (!fileSystemCheck.hasLocal && currentSource && currentId) { + indexedDBCheck = await getIndexedDBVideoPlaybackUrl( + currentSource, + currentId, + episodeIndex, + { preferServiceWorker: true } + ); + if (requestSeq !== videoUrlRequestSeqRef.current) { + indexedDBCheck.objectUrls?.forEach((url) => URL.revokeObjectURL(url)); + return; + } + + if (indexedDBCheck.hasLocal && indexedDBCheck.url) { + cleanupLocalPlaybackBlobUrls(); + if (indexedDBCheck.objectUrls?.length) { + (window as any).__localFileBlobUrls = indexedDBCheck.objectUrls; + } + newUrl = indexedDBCheck.url; + console.log( + `使用 IndexedDB 本地缓存播放(${indexedDBCheck.mode === 'service-worker' ? 'Service Worker' : 'Blob 降级'} 模式):`, + episodeTitle + ); + } + } + + // 如果没有 File System API / IndexedDB 本地文件,检查服务器端本地下载 + if (!fileSystemCheck.hasLocal && !indexedDBCheck.hasLocal) { const hasLocalFile = await checkLocalDownload(currentSource, currentId, episodeIndex); if (requestSeq !== videoUrlRequestSeqRef.current) { return; diff --git a/src/components/DownloadManagementPanel.tsx b/src/components/DownloadManagementPanel.tsx index 186d815..3188fd8 100644 --- a/src/components/DownloadManagementPanel.tsx +++ b/src/components/DownloadManagementPanel.tsx @@ -1,10 +1,11 @@ 'use client'; -import { Check, Trash2, X } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { Check, ChevronDown, Trash2, X } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { downloadDB, CompletedTask } from '@/lib/download-db'; +import { deleteIndexedDBVideoCacheByEpisode } from '@/lib/indexeddb-video-cache'; import { ConfirmDialog } from './ConfirmDialog'; @@ -13,9 +14,21 @@ interface DownloadManagementPanelProps { onClose: () => void; } +interface VideoDownloadGroup { + key: string; + source: string; + videoId: string; + title: string; + tasks: CompletedTask[]; + totalSize?: number; + lastCompletedAt: number; + downloadModes: CompletedTask['downloadMode'][]; +} + export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementPanelProps) { const [completedTasks, setCompletedTasks] = useState([]); const [selectedIds, setSelectedIds] = useState>(new Set()); + const [expandedGroupKeys, setExpandedGroupKeys] = useState>(new Set()); const [isDeleting, setIsDeleting] = useState(false); const [mounted, setMounted] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false); @@ -57,6 +70,29 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP setSelectedIds(newSet); }; + const handleToggleGroupSelect = (group: VideoDownloadGroup) => { + const newSet = new Set(selectedIds); + const allSelected = group.tasks.every((task) => newSet.has(task.id)); + + if (allSelected) { + group.tasks.forEach((task) => newSet.delete(task.id)); + } else { + group.tasks.forEach((task) => newSet.add(task.id)); + } + + setSelectedIds(newSet); + }; + + const handleToggleGroupExpand = (groupKey: string) => { + const newSet = new Set(expandedGroupKeys); + if (newSet.has(groupKey)) { + newSet.delete(groupKey); + } else { + newSet.add(groupKey); + } + setExpandedGroupKeys(newSet); + }; + const handleDelete = async () => { if (selectedIds.size === 0) return; @@ -137,6 +173,17 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP } catch (error) { console.error('删除文件失败:', task.title, error); } + } else if (task.downloadMode === 'indexeddb') { + try { + await deleteIndexedDBVideoCacheByEpisode( + task.source, + task.videoId, + task.episodeIndex + ); + console.log('已删除 IndexedDB 视频缓存:', task.source, task.videoId, task.episodeIndex); + } catch (error) { + console.error('删除 IndexedDB 视频缓存失败:', task.title, error); + } } } @@ -171,33 +218,94 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; }; + const getDownloadModeLabel = (mode: CompletedTask['downloadMode']) => { + switch (mode) { + case 'filesystem': + return 'File System API'; + case 'indexeddb': + return 'IndexedDB 缓存'; + case 'browser': + default: + return '浏览器下载'; + } + }; + + const getGroupTitle = (tasks: CompletedTask[]) => { + const taskWithVideoTitle = tasks.find((task) => task.videoTitle); + if (taskWithVideoTitle?.videoTitle) return taskWithVideoTitle.videoTitle; + + const firstTitle = tasks[0]?.title || '未知视频'; + return firstTitle + .replace(/[_\s-]*第\s*\d+\s*集.*$/u, '') + .replace(/[_\s-]*EP?\s*\d+.*$/iu, '') + .trim() || firstTitle; + }; + + const videoGroups = useMemo(() => { + const groupMap = new Map(); + + for (const task of completedTasks) { + const key = `${task.source}::${task.videoId}`; + const groupTasks = groupMap.get(key) || []; + groupTasks.push(task); + groupMap.set(key, groupTasks); + } + + return Array.from(groupMap.entries()) + .map(([key, tasks]) => { + const sortedTasks = [...tasks].sort((a, b) => { + if (a.episodeIndex !== b.episodeIndex) { + return a.episodeIndex - b.episodeIndex; + } + return b.completedAt - a.completedAt; + }); + const totalSize = sortedTasks.reduce((sum, task) => sum + (task.fileSize || 0), 0); + const modeSet = new Set( + sortedTasks.map((task) => task.downloadMode) + ); + + return { + key, + source: sortedTasks[0].source, + videoId: sortedTasks[0].videoId, + title: getGroupTitle(sortedTasks), + tasks: sortedTasks, + totalSize: totalSize > 0 ? totalSize : undefined, + lastCompletedAt: Math.max(...sortedTasks.map((task) => task.completedAt)), + downloadModes: Array.from(modeSet), + }; + }) + .sort((a, b) => b.lastCompletedAt - a.lastCompletedAt); + }, [completedTasks]); + if (!mounted || !isOpen) return null; return ( <> {createPortal( -
+
-
+
{/* Header */} -
-

+
+

下载文件管理

{/* Toolbar */} -
-
+
+
- 已选择 {selectedIds.size} / {completedTasks.length} + 已选择 {selectedIds.size} / {completedTasks.length} 集 + {videoGroups.length > 0 && `,共 ${videoGroups.length} 个视频`}
{/* Content */} -
+
{completedTasks.length === 0 ? (
暂无下载记录
) : ( -
- {completedTasks.map((task) => ( +
+ {videoGroups.map((group) => { + const isExpanded = expandedGroupKeys.has(group.key); + const selectedCount = group.tasks.filter((task) => selectedIds.has(task.id)).length; + const isGroupSelected = selectedCount === group.tasks.length; + const isGroupPartiallySelected = selectedCount > 0 && !isGroupSelected; + + return (
handleToggleSelect(task.id)} > -
-
-
handleToggleGroupExpand(group.key)} + > +
-
-
-
-
-

- {task.title} + }`} + aria-label={`选择 ${group.title}`} + > + {isGroupSelected ? ( + + ) : isGroupPartiallySelected ? ( + + ) : null} + + +
+
+
+

+ {group.title}

- {task.videoTitle && ( -

- {task.videoTitle} -

- )} - {task.episodeTitle && ( -

- {task.episodeTitle} -

- )} -
-
-
- {formatDate(task.completedAt)} +
+ 来源: {group.source} + + {group.tasks.length} 集 + + {group.downloadModes.map(getDownloadModeLabel).join(' / ')}
- {task.fileSize && ( -
- {formatFileSize(task.fileSize)} -
- )}
-
-
- 来源: {task.source} - - 第 {task.episodeIndex + 1} 集 - - {task.downloadMode === 'filesystem' ? 'File System API' : '浏览器下载'} +
+
+
+ 最近完成:{formatDate(group.lastCompletedAt)} +
+
+ 总大小:{formatFileSize(group.totalSize)} +
+ {selectedCount > 0 && ( +
+ 已选 {selectedCount} 集 +
+ )} +
+ +
+ + {isExpanded && ( +
+ {group.tasks.map((task) => ( +
handleToggleSelect(task.id)} + > +
+
+ {selectedIds.has(task.id) && ( + + )} +
+
+
+
+
+

+ 第 {task.episodeIndex + 1} 集 + {task.episodeTitle ? `:${task.episodeTitle}` : ''} +

+

+ {task.title} +

+
+
+
+ {formatDate(task.completedAt)} +
+
+ {formatFileSize(task.fileSize)} +
+
+
+
+ 第 {task.episodeIndex + 1} 集 + + {getDownloadModeLabel(task.downloadMode)} +
+
+
+ ))} +
+ )}

- ))} + ); + })}
)}
@@ -303,7 +486,7 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP { + switch (mode) { + case 'filesystem': + return 'File System'; + case 'indexeddb': + return 'IndexedDB'; + case 'browser': + default: + return '浏览器'; + } + }; + const getLogBadgeClass = (status: M3U8SegmentLogStatus) => { switch (status) { case 'downloading': @@ -167,6 +179,9 @@ export function DownloadPanel() { {task.type} + + {getDownloadModeText(task.downloadMode)} +
diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index d655048..b43da50 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -213,7 +213,7 @@ export const UserMenu: React.FC = () => { const [maxConcurrentDownloads, setMaxConcurrentDownloads] = useState(6); const [downloadThreadsPerTask, setDownloadThreadsPerTask] = useState(6); const [downloadSegmentTimeout, setDownloadSegmentTimeout] = useState(30000); - const [downloadMode, setDownloadMode] = useState<'browser' | 'filesystem'>( + const [downloadMode, setDownloadMode] = useState<'browser' | 'filesystem' | 'indexeddb'>( 'browser' ); const [filesystemSavePath, setFilesystemSavePath] = useState(''); @@ -851,7 +851,8 @@ export const UserMenu: React.FC = () => { const savedDownloadMode = localStorage.getItem('downloadMode'); if ( savedDownloadMode === 'browser' || - savedDownloadMode === 'filesystem' + savedDownloadMode === 'filesystem' || + savedDownloadMode === 'indexeddb' ) { setDownloadMode(savedDownloadMode); } @@ -1644,7 +1645,7 @@ export const UserMenu: React.FC = () => { return seconds > 0 ? `${minutes}分${seconds}秒` : `${minutes}分钟`; }; - const handleDownloadModeChange = (mode: 'browser' | 'filesystem') => { + const handleDownloadModeChange = (mode: 'browser' | 'filesystem' | 'indexeddb') => { // 如果选择 filesystem 模式,先检测浏览器是否支持 if ( mode === 'filesystem' && @@ -3691,6 +3692,21 @@ export const UserMenu: React.FC = () => { File System API(保存分片到本地目录) +
{/* 保存路径选择(仅在 filesystem 模式显示) */} diff --git a/src/contexts/DownloadContext.tsx b/src/contexts/DownloadContext.tsx index 4b202da..6941bd2 100644 --- a/src/contexts/DownloadContext.tsx +++ b/src/contexts/DownloadContext.tsx @@ -5,6 +5,13 @@ import React, { createContext, useCallback, useContext, useState, useEffect } fr import { M3U8Downloader, M3U8DownloadTask } from '@/lib/m3u8-downloader'; import Toast from '@/components/Toast'; import { downloadDB } from '@/lib/download-db'; +import { + buildIndexedDBVideoCacheKey, + getBrowserStorageEstimate, + getIndexedDBVideoCacheSize, + isIndexedDBVideoDownloaded, + requestIndexedDBVideoPersistentStorage, +} from '@/lib/indexeddb-video-cache'; interface DownloadContextType { downloader: M3U8Downloader; @@ -66,13 +73,17 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { onComplete: async (task) => { setTasks(downloader.getAllTasks()); - //禁止SzeMeng76抄袭狗抄袭 - // 只有 filesystem 模式才保存到已完成任务表 - if (task.downloadMode === 'filesystem' && task.source && task.videoId && task.episodeIndex !== undefined) { + // File System / IndexedDB 模式保存到已完成任务表,用于本地播放命中和下载管理 + if ( + (task.downloadMode === 'filesystem' || task.downloadMode === 'indexeddb') && + task.source && + task.videoId && + task.episodeIndex !== undefined + ) { try { // 计算文件大小 let fileSize: number | undefined; - if (task.filesystemDirHandle) { + if (task.downloadMode === 'filesystem' && task.filesystemDirHandle) { try { const sourceDirHandle = await task.filesystemDirHandle.getDirectoryHandle(task.source, { create: false }); const videoIdDirHandle = await sourceDirHandle.getDirectoryHandle(task.videoId, { create: false }); @@ -90,6 +101,17 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { } catch (error) { console.error('计算文件大小失败:', error); } + } else if (task.downloadMode === 'indexeddb') { + try { + const cacheKey = task.indexedDBCacheKey || buildIndexedDBVideoCacheKey( + task.source, + task.videoId, + task.episodeIndex + ); + fileSize = await getIndexedDBVideoCacheSize(cacheKey); + } catch (error) { + console.error('计算 IndexedDB 缓存大小失败:', error); + } } await downloadDB.saveCompletedTask({ @@ -99,7 +121,7 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { videoId: task.videoId, episodeIndex: task.episodeIndex, completedAt: Date.now(), - downloadMode: 'filesystem', + downloadMode: task.downloadMode, fileSize, }); } catch (error) { @@ -173,11 +195,12 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { if (!savedTasks || savedTasks.length === 0) return; //禁止SzeMeng76抄袭狗抄袭 - // 读取下载模式和目录句柄 - const downloadMode = localStorage.getItem('downloadMode') as 'browser' | 'filesystem' || 'browser'; + // 读取目录句柄:只要存在待恢复的 filesystem 任务就需要尝试读取, + // 不依赖当前用户下载模式设置。 + const hasFilesystemTask = savedTasks.some((task) => task.downloadMode === 'filesystem'); let dirHandle: FileSystemDirectoryHandle | undefined; - if (downloadMode === 'filesystem') { + if (hasFilesystemTask) { const dbName = 'MoonTVPlus'; const storeName = 'dirHandles'; @@ -226,14 +249,20 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { continue; } - // 已完成的 filesystem 任务标记为删除 - if (savedTask.downloadMode === 'filesystem' && savedTask.status === 'done') { + // 已完成的 filesystem/indexeddb 任务标记为删除(已记录到 completedTasks) + if ( + (savedTask.downloadMode === 'filesystem' || savedTask.downloadMode === 'indexeddb') && + savedTask.status === 'done' + ) { tasksToDelete.push(savedTask.id); continue; } - // 只恢复 filesystem 模式的未完成任务 - if (savedTask.downloadMode === 'filesystem' && (savedTask.status === 'downloading' || savedTask.status === 'pause' || savedTask.status === 'ready')) { + // 只恢复 filesystem/indexeddb 模式的未完成任务 + if ( + (savedTask.downloadMode === 'filesystem' || savedTask.downloadMode === 'indexeddb') && + (savedTask.status === 'downloading' || savedTask.status === 'pause' || savedTask.status === 'ready') + ) { try { const taskId = await downloader.createTask( savedTask.url, @@ -258,7 +287,15 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { task.rangeDownload = savedTask.rangeDownload; task.segmentLogs = savedTask.segmentLogs || []; - if (dirHandle) { + if (savedTask.downloadMode === 'indexeddb' && savedTask.source && savedTask.videoId && savedTask.episodeIndex !== undefined) { + task.indexedDBCacheKey = buildIndexedDBVideoCacheKey( + savedTask.source, + savedTask.videoId, + savedTask.episodeIndex + ); + } + + if (savedTask.downloadMode === 'filesystem' && dirHandle) { task.filesystemDirHandle = dirHandle; } } @@ -301,7 +338,7 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { try { // 读取下载模式设置 const downloadMode = typeof window !== 'undefined' - ? (localStorage.getItem('downloadMode') as 'browser' | 'filesystem') || 'browser' + ? (localStorage.getItem('downloadMode') as 'browser' | 'filesystem' | 'indexeddb') || 'browser' : 'browser'; // 如果是 filesystem 模式,检查是否已经下载过 @@ -371,12 +408,64 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { } } + // 如果是 IndexedDB 模式,检查独立视频缓存库是否已有完整缓存 + if ( + downloadMode === 'indexeddb' && + typeof window !== 'undefined' && + metadata?.source && + metadata?.videoId && + metadata?.episodeIndex !== undefined + ) { + try { + const alreadyDownloaded = await isIndexedDBVideoDownloaded( + metadata.source, + metadata.videoId, + metadata.episodeIndex + ); + + if (alreadyDownloaded) { + console.log('视频已下载(IndexedDB 缓存检查),跳过:', title, metadata); + setToast({ message: `${title} 已经缓存过了,无需重复下载`, type: 'info' }); + return; + } + + // 尽量请求持久化存储;失败不阻塞,后续仍可降级由浏览器管理配额。 + requestIndexedDBVideoPersistentStorage().catch(() => undefined); + + const estimate = await getBrowserStorageEstimate(); + if (estimate?.quota && estimate?.usage) { + const freeBytes = estimate.quota - estimate.usage; + // 低于 512MB 时提示风险,但不阻塞下载(实际大小需解析后才准确)。 + if (freeBytes > 0 && freeBytes < 512 * 1024 * 1024) { + setToast({ + message: '浏览器可用存储空间较低,IndexedDB 缓存可能失败', + type: 'info', + }); + } + } + } catch (error) { + console.error('检查 IndexedDB 下载状态失败:', error); + } + } + const taskId = await downloader.createTask(url, title, type, metadata); // 设置下载模式 const task = downloader.getTask(taskId); if (task) { task.downloadMode = downloadMode; + if ( + downloadMode === 'indexeddb' && + metadata?.source && + metadata?.videoId && + metadata?.episodeIndex !== undefined + ) { + task.indexedDBCacheKey = buildIndexedDBVideoCacheKey( + metadata.source, + metadata.videoId, + metadata.episodeIndex + ); + } } //禁止SzeMeng76抄袭狗抄袭 diff --git a/src/lib/download-db.ts b/src/lib/download-db.ts index a328f61..b8a4c6b 100644 --- a/src/lib/download-db.ts +++ b/src/lib/download-db.ts @@ -19,7 +19,7 @@ export interface SavedTask { source?: string; videoId?: string; episodeIndex?: number; - downloadMode?: 'browser' | 'filesystem'; + downloadMode?: 'browser' | 'filesystem' | 'indexeddb'; rangeDownload: { isShowRange: boolean; startSegment: number; @@ -51,7 +51,7 @@ export interface CompletedTask { episodeTitle?: string; // 集数标题 fileSize?: number; // 文件大小(字节) completedAt: number; - downloadMode: 'browser' | 'filesystem'; + downloadMode: 'browser' | 'filesystem' | 'indexeddb'; } const DB_NAME = 'MoonTVPlus'; diff --git a/src/lib/indexeddb-video-cache.ts b/src/lib/indexeddb-video-cache.ts new file mode 100644 index 0000000..ccea026 --- /dev/null +++ b/src/lib/indexeddb-video-cache.ts @@ -0,0 +1,648 @@ +/** + * IndexedDB 视频缓存(独立库) + * + * 注意:视频分片/播放列表不存入现有 MoonTVPlus 元信息库,避免大体积 Blob + * 与任务/用户元信息混在一起。 + */ + +export const INDEXEDDB_VIDEO_CACHE_DB_NAME = 'MoonTVPlusVideoCache'; +export const INDEXEDDB_VIDEO_CACHE_DB_VERSION = 1; +export const INDEXEDDB_VIDEO_CACHE_ROUTE_PREFIX = '/__moontv_idb_video__'; + +const MANIFESTS_STORE = 'manifests'; +const SEGMENTS_STORE = 'segments'; +const ASSETS_STORE = 'assets'; + +export interface IndexedDBVideoCacheManifest { + cacheKey: string; + source: string; + videoId: string; + episodeIndex: number; + title: string; + playlistContent: string; + m3u8Content?: string; + segmentCount: number; + totalSize: number; + completed: boolean; + createdAt: number; + updatedAt: number; + mimeType?: string; +} + +interface IndexedDBVideoSegmentRecord { + id: string; + cacheKey: string; + index: number; + data: Blob; + size: number; + updatedAt: number; +} + +interface IndexedDBVideoAssetRecord { + id: string; + cacheKey: string; + name: string; + data: Blob; + size: number; + mimeType?: string; + updatedAt: number; +} + +export interface IndexedDBVideoPlaybackResult { + hasLocal: boolean; + url?: string; + manifest?: IndexedDBVideoCacheManifest; + mode?: 'service-worker' | 'blob'; + objectUrls?: string[]; + reason?: string; +} + +let dbPromise: Promise | null = null; + +export function buildIndexedDBVideoCacheKey( + source: string, + videoId: string, + episodeIndex: number +): string { + return `${source}::${videoId}::${episodeIndex}`; +} + +function makeSegmentId(cacheKey: string, index: number): string { + return `${cacheKey}:segment:${index}`; +} + +function makeAssetId(cacheKey: string, name: string): string { + return `${cacheKey}:asset:${name}`; +} + +function assertIndexedDBAvailable(): void { + if (typeof indexedDB === 'undefined') { + throw new Error('当前环境不支持 IndexedDB'); + } +} + +function requestToPromise(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +export async function openIndexedDBVideoCache(): Promise { + assertIndexedDBAvailable(); + + if (dbPromise) return dbPromise; + + dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open( + INDEXEDDB_VIDEO_CACHE_DB_NAME, + INDEXEDDB_VIDEO_CACHE_DB_VERSION + ); + + request.onerror = () => { + dbPromise = null; + reject(request.error); + }; + + request.onsuccess = () => { + const db = request.result; + db.onversionchange = () => { + db.close(); + dbPromise = null; + }; + resolve(db); + }; + + request.onupgradeneeded = () => { + const db = request.result; + + if (!db.objectStoreNames.contains(MANIFESTS_STORE)) { + const manifestStore = db.createObjectStore(MANIFESTS_STORE, { + keyPath: 'cacheKey', + }); + manifestStore.createIndex('sourceVideoEpisode', ['source', 'videoId', 'episodeIndex'], { + unique: true, + }); + manifestStore.createIndex('completed', 'completed', { unique: false }); + manifestStore.createIndex('updatedAt', 'updatedAt', { unique: false }); + } + + if (!db.objectStoreNames.contains(SEGMENTS_STORE)) { + const segmentStore = db.createObjectStore(SEGMENTS_STORE, { + keyPath: 'id', + }); + segmentStore.createIndex('cacheKey', 'cacheKey', { unique: false }); + } + + if (!db.objectStoreNames.contains(ASSETS_STORE)) { + const assetStore = db.createObjectStore(ASSETS_STORE, { + keyPath: 'id', + }); + assetStore.createIndex('cacheKey', 'cacheKey', { unique: false }); + } + }; + }); + + return dbPromise; +} + +export async function getIndexedDBVideoManifestByCacheKey( + cacheKey: string +): Promise { + const db = await openIndexedDBVideoCache(); + const tx = db.transaction([MANIFESTS_STORE], 'readonly'); + const store = tx.objectStore(MANIFESTS_STORE); + return requestToPromise(store.get(cacheKey)); +} + +export async function getIndexedDBVideoManifestByEpisode( + source: string, + videoId: string, + episodeIndex: number +): Promise { + const db = await openIndexedDBVideoCache(); + const tx = db.transaction([MANIFESTS_STORE], 'readonly'); + const store = tx.objectStore(MANIFESTS_STORE); + const index = store.index('sourceVideoEpisode'); + return requestToPromise( + index.get([source, videoId, episodeIndex]) + ); +} + +export async function isIndexedDBVideoDownloaded( + source: string, + videoId: string, + episodeIndex: number +): Promise { + const manifest = await getIndexedDBVideoManifestByEpisode(source, videoId, episodeIndex); + return Boolean(manifest?.completed && manifest.segmentCount > 0); +} + +export async function saveIndexedDBVideoSegment(input: { + cacheKey: string; + index: number; + data: ArrayBuffer | Uint8Array | Blob; + mimeType?: string; +}): Promise { + const db = await openIndexedDBVideoCache(); + const blob = input.data instanceof Blob + ? input.data + : new Blob([input.data], { type: input.mimeType || 'video/MP2T' }); + + const record: IndexedDBVideoSegmentRecord = { + id: makeSegmentId(input.cacheKey, input.index), + cacheKey: input.cacheKey, + index: input.index, + data: blob, + size: blob.size, + updatedAt: Date.now(), + }; + + const tx = db.transaction([SEGMENTS_STORE], 'readwrite'); + tx.objectStore(SEGMENTS_STORE).put(record); + + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + + return blob.size; +} + +export async function saveIndexedDBVideoAsset(input: { + cacheKey: string; + name: string; + data: ArrayBuffer | Uint8Array | Blob; + mimeType?: string; +}): Promise { + const db = await openIndexedDBVideoCache(); + const blob = input.data instanceof Blob + ? input.data + : new Blob([input.data], { type: input.mimeType || 'application/octet-stream' }); + + const record: IndexedDBVideoAssetRecord = { + id: makeAssetId(input.cacheKey, input.name), + cacheKey: input.cacheKey, + name: input.name, + data: blob, + size: blob.size, + mimeType: input.mimeType, + updatedAt: Date.now(), + }; + + const tx = db.transaction([ASSETS_STORE], 'readwrite'); + tx.objectStore(ASSETS_STORE).put(record); + + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + + return blob.size; +} + +async function getIndexedDBVideoSegment( + cacheKey: string, + index: number +): Promise { + const db = await openIndexedDBVideoCache(); + const tx = db.transaction([SEGMENTS_STORE], 'readonly'); + const store = tx.objectStore(SEGMENTS_STORE); + return requestToPromise( + store.get(makeSegmentId(cacheKey, index)) + ); +} + +async function getIndexedDBVideoAsset( + cacheKey: string, + name: string +): Promise { + const db = await openIndexedDBVideoCache(); + const tx = db.transaction([ASSETS_STORE], 'readonly'); + const store = tx.objectStore(ASSETS_STORE); + return requestToPromise( + store.get(makeAssetId(cacheKey, name)) + ); +} + +export async function getIndexedDBVideoCacheSize(cacheKey: string): Promise { + const manifest = await getIndexedDBVideoManifestByCacheKey(cacheKey); + if (manifest?.totalSize) return manifest.totalSize; + + const db = await openIndexedDBVideoCache(); + const tx = db.transaction([SEGMENTS_STORE, ASSETS_STORE], 'readonly'); + + const sumByIndex = (storeName: string) => new Promise((resolve, reject) => { + const store = tx.objectStore(storeName); + const index = store.index('cacheKey'); + const request = index.openCursor(IDBKeyRange.only(cacheKey)); + let total = 0; + + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + resolve(total); + return; + } + total += Number((cursor.value as { size?: number }).size || 0); + cursor.continue(); + }; + request.onerror = () => reject(request.error); + }); + + const [segmentSize, assetSize] = await Promise.all([ + sumByIndex(SEGMENTS_STORE), + sumByIndex(ASSETS_STORE), + ]); + + return segmentSize + assetSize; +} + +export async function saveIndexedDBVideoManifest(input: { + cacheKey: string; + source: string; + videoId: string; + episodeIndex: number; + title: string; + playlistContent: string; + m3u8Content?: string; + segmentCount: number; + completed: boolean; + mimeType?: string; + totalSize?: number; +}): Promise { + const db = await openIndexedDBVideoCache(); + const existing = await getIndexedDBVideoManifestByCacheKey(input.cacheKey); + const now = Date.now(); + const totalSize = typeof input.totalSize === 'number' + ? input.totalSize + : await getIndexedDBVideoCacheSize(input.cacheKey); + + const manifest: IndexedDBVideoCacheManifest = { + cacheKey: input.cacheKey, + source: input.source, + videoId: input.videoId, + episodeIndex: input.episodeIndex, + title: input.title, + playlistContent: input.playlistContent, + m3u8Content: input.m3u8Content, + segmentCount: input.segmentCount, + totalSize, + completed: input.completed, + createdAt: existing?.createdAt || now, + updatedAt: now, + mimeType: input.mimeType, + }; + + const tx = db.transaction([MANIFESTS_STORE], 'readwrite'); + tx.objectStore(MANIFESTS_STORE).put(manifest); + + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + + return manifest; +} + +async function deleteRecordsByCacheKey( + db: IDBDatabase, + storeName: string, + cacheKey: string +): Promise { + if (!db.objectStoreNames.contains(storeName)) return; + + await new Promise((resolve, reject) => { + const tx = db.transaction([storeName], 'readwrite'); + const store = tx.objectStore(storeName); + const index = store.index('cacheKey'); + const request = index.openCursor(IDBKeyRange.only(cacheKey)); + + request.onsuccess = () => { + const cursor = request.result; + if (cursor) { + cursor.delete(); + cursor.continue(); + } + }; + + request.onerror = () => reject(request.error); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); +} + +export async function deleteIndexedDBVideoCache(cacheKey: string): Promise { + const db = await openIndexedDBVideoCache(); + await deleteRecordsByCacheKey(db, SEGMENTS_STORE, cacheKey); + await deleteRecordsByCacheKey(db, ASSETS_STORE, cacheKey); + + const tx = db.transaction([MANIFESTS_STORE], 'readwrite'); + tx.objectStore(MANIFESTS_STORE).delete(cacheKey); + + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); +} + +export async function deleteIndexedDBVideoCacheByEpisode( + source: string, + videoId: string, + episodeIndex: number +): Promise { + const manifest = await getIndexedDBVideoManifestByEpisode(source, videoId, episodeIndex); + if (manifest) { + await deleteIndexedDBVideoCache(manifest.cacheKey); + return; + } + + await deleteIndexedDBVideoCache( + buildIndexedDBVideoCacheKey(source, videoId, episodeIndex) + ); +} + +export async function getIndexedDBVideoStorageUsage(): Promise<{ + totalSize: number; + count: number; +}> { + const db = await openIndexedDBVideoCache(); + const tx = db.transaction([MANIFESTS_STORE], 'readonly'); + const store = tx.objectStore(MANIFESTS_STORE); + const request = store.getAll(); + const manifests = await requestToPromise(request); + + return manifests.reduce( + (acc, manifest) => ({ + totalSize: acc.totalSize + Number(manifest.totalSize || 0), + count: acc.count + (manifest.completed ? 1 : 0), + }), + { totalSize: 0, count: 0 } + ); +} + +export async function requestIndexedDBVideoPersistentStorage(): Promise { + if (typeof navigator === 'undefined' || !navigator.storage?.persist) { + return false; + } + + try { + return await navigator.storage.persist(); + } catch { + return false; + } +} + +export async function getBrowserStorageEstimate(): Promise { + if (typeof navigator === 'undefined' || !navigator.storage?.estimate) { + return null; + } + + try { + return await navigator.storage.estimate(); + } catch { + return null; + } +} + +function waitForServiceWorkerActivation( + registration: ServiceWorkerRegistration +): Promise { + const worker = registration.installing || registration.waiting || registration.active; + + if (!worker || worker.state === 'activated') { + return Promise.resolve(registration); + } + + return new Promise((resolve, reject) => { + const handleStateChange = () => { + if (worker.state === 'activated') { + worker.removeEventListener('statechange', handleStateChange); + resolve(registration); + } else if (worker.state === 'redundant') { + worker.removeEventListener('statechange', handleStateChange); + reject(new Error('Service Worker 激活失败')); + } + }; + + worker.addEventListener('statechange', handleStateChange); + handleStateChange(); + }); +} + +async function waitForServiceWorkerController(timeoutMs = 2500): Promise { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { + return false; + } + + if (navigator.serviceWorker.controller) return true; + + return new Promise((resolve) => { + const timer = window.setTimeout(() => { + navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange); + resolve(Boolean(navigator.serviceWorker.controller)); + }, timeoutMs); + + const onControllerChange = () => { + window.clearTimeout(timer); + navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange); + resolve(true); + }; + + navigator.serviceWorker.addEventListener('controllerchange', onControllerChange); + }); +} + +export async function ensureIndexedDBVideoServiceWorker(): Promise { + if (typeof window === 'undefined' || typeof navigator === 'undefined') return false; + if (!('serviceWorker' in navigator)) return false; + + try { + const registration = await navigator.serviceWorker.register('/push-sw.js', { + scope: '/', + updateViaCache: 'none', + }); + + await waitForServiceWorkerActivation(registration); + return waitForServiceWorkerController(); + } catch (error) { + console.warn('[IndexedDBVideo] Service Worker 不可用,降级为 Blob URL 播放:', error); + return false; + } +} + +export function getIndexedDBVideoServiceWorkerPlaylistUrl( + cacheKey: string, + version?: number +): string { + const encodedCacheKey = encodeURIComponent(cacheKey); + const suffix = version ? `?v=${encodeURIComponent(String(version))}` : ''; + return `${INDEXEDDB_VIDEO_CACHE_ROUTE_PREFIX}/${encodedCacheKey}/playlist.m3u8${suffix}`; +} + +function isSegmentLine(line: string): boolean { + return /^segment_\d+\.ts$/i.test(line.trim()); +} + +function parseSegmentIndex(line: string): number | null { + const match = line.trim().match(/^segment_(\d+)\.ts$/i); + if (!match) return null; + const index = Number(match[1]); + return Number.isFinite(index) ? index : null; +} + +export async function createIndexedDBVideoBlobPlaybackUrl( + cacheKey: string +): Promise { + const manifest = await getIndexedDBVideoManifestByCacheKey(cacheKey); + if (!manifest?.completed) { + return { hasLocal: false, reason: 'IndexedDB 缓存未完成' }; + } + + const objectUrls: string[] = []; + const lines = manifest.playlistContent.split('\n'); + const modifiedLines: string[] = []; + + for (const line of lines) { + const trimmedLine = line.trim(); + + if (isSegmentLine(trimmedLine)) { + const segmentIndex = parseSegmentIndex(trimmedLine); + if (segmentIndex === null) { + modifiedLines.push(line); + continue; + } + + const segment = await getIndexedDBVideoSegment(cacheKey, segmentIndex); + if (!segment?.data) { + objectUrls.forEach((url) => URL.revokeObjectURL(url)); + return { hasLocal: false, reason: `缺少 IndexedDB 分片 ${segmentIndex + 1}` }; + } + + const segmentUrl = URL.createObjectURL(segment.data); + objectUrls.push(segmentUrl); + modifiedLines.push(line.replace(trimmedLine, segmentUrl)); + continue; + } + + if (trimmedLine === 'key.key') { + const asset = await getIndexedDBVideoAsset(cacheKey, 'key.key'); + if (asset?.data) { + const keyUrl = URL.createObjectURL(asset.data); + objectUrls.push(keyUrl); + modifiedLines.push(line.replace(trimmedLine, keyUrl)); + } else { + modifiedLines.push(line); + } + continue; + } + + if (trimmedLine.includes('URI="key.key"')) { + const asset = await getIndexedDBVideoAsset(cacheKey, 'key.key'); + if (asset?.data) { + const keyUrl = URL.createObjectURL(asset.data); + objectUrls.push(keyUrl); + modifiedLines.push(line.replace('URI="key.key"', `URI="${keyUrl}"`)); + } else { + modifiedLines.push(line); + } + continue; + } + + modifiedLines.push(line); + } + + const playlistBlob = new Blob([modifiedLines.join('\n')], { + type: 'application/vnd.apple.mpegurl', + }); + const playlistUrl = URL.createObjectURL(playlistBlob); + objectUrls.push(playlistUrl); + + return { + hasLocal: true, + url: playlistUrl, + manifest, + mode: 'blob', + objectUrls, + }; +} + +export async function getIndexedDBVideoPlaybackUrl( + source: string, + videoId: string, + episodeIndex: number, + options: { preferServiceWorker?: boolean } = {} +): Promise { + try { + const manifest = await getIndexedDBVideoManifestByEpisode(source, videoId, episodeIndex); + if (!manifest?.completed) { + return { hasLocal: false, reason: '未找到 IndexedDB 本地缓存' }; + } + + const preferServiceWorker = options.preferServiceWorker !== false; + if (preferServiceWorker) { + const serviceWorkerReady = await ensureIndexedDBVideoServiceWorker(); + if (serviceWorkerReady) { + return { + hasLocal: true, + url: getIndexedDBVideoServiceWorkerPlaylistUrl( + manifest.cacheKey, + manifest.updatedAt + ), + manifest, + mode: 'service-worker', + }; + } + } + + return createIndexedDBVideoBlobPlaybackUrl(manifest.cacheKey); + } catch (error) { + console.error('[IndexedDBVideo] 获取本地播放地址失败:', error); + return { hasLocal: false, reason: String(error) }; + } +} diff --git a/src/lib/m3u8-downloader.ts b/src/lib/m3u8-downloader.ts index 753b197..4ebc292 100644 --- a/src/lib/m3u8-downloader.ts +++ b/src/lib/m3u8-downloader.ts @@ -3,10 +3,17 @@ * 基于 M3U8Download 项目改造为 TypeScript 版本 */ -// @ts-ignore - mux.js 没有类型定义 +// @ts-expect-error - mux.js 没有类型定义 import * as muxjs from 'mux.js'; import { AESDecryptor } from './aes-decryptor'; +import { + buildIndexedDBVideoCacheKey, + deleteIndexedDBVideoCache, + getIndexedDBVideoCacheSize, + saveIndexedDBVideoManifest, + saveIndexedDBVideoSegment, +} from './indexeddb-video-cache'; export type M3U8SegmentLogStatus = | 'queued' @@ -65,8 +72,9 @@ export interface M3U8DownloadTask { }; //禁止SzeMeng76抄袭狗抄袭 // File System API 相关字段 - downloadMode?: 'browser' | 'filesystem'; + downloadMode?: 'browser' | 'filesystem' | 'indexeddb'; filesystemDirHandle?: FileSystemDirectoryHandle; + indexedDBCacheKey?: string; m3u8Content?: string; // 原始 M3U8 内容,用于生成本地播放列表 // 视频标识信息(用于区分不同视频) source?: string; @@ -292,6 +300,11 @@ export class M3U8Downloader { await this.deleteFilesystemTask(task); } + // 如果是 indexeddb 模式且任务未完成,删除独立视频缓存库中的分片 + if (task.downloadMode === 'indexeddb' && task.status !== 'done') { + await this.deleteIndexedDBTask(task); + } + this.tasks.delete(taskId); if (this.currentTask?.id === taskId) { @@ -323,6 +336,21 @@ export class M3U8Downloader { } } + /** + * 删除 IndexedDB 模式下的任务缓存 + */ + private async deleteIndexedDBTask(task: M3U8DownloadTask): Promise { + const cacheKey = this.getIndexedDBCacheKey(task); + if (!cacheKey) return; + + try { + await deleteIndexedDBVideoCache(cacheKey); + console.log(`已删除未完成的 IndexedDB 视频缓存: ${cacheKey}`); + } catch (error) { + console.warn('删除 IndexedDB 视频缓存失败:', error); + } + } + /** * 获取任务信息 */ @@ -570,86 +598,74 @@ export class M3U8Downloader { data = this.aesDecrypt(task, data, index); } - // MP4 转码(如果需要) - if (task.type === 'MP4') { - this.conversionMp4(task, data, index, (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] = 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) => { + const persistSegment = (processedData: ArrayBuffer) => { + this.saveProcessedSegment(task, processedData, index) + .then(() => this.markSegmentSuccess(task, index)) + .then(() => callback()) + .catch((error) => { console.error('保存分片失败:', error); task.finishList[index].status = 'is-error'; task.errorNum++; + this.options.onError?.(task, `保存分片 ${index + 1} 失败: ${error}`); callback(); }); - } else { - // 浏览器下载模式:保存到内存 - task.mediaFileList[index] = 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(); - } + // MP4 转码(如果需要) + if (task.type === 'MP4') { + this.conversionMp4(task, data, index, persistSegment); + } else { + persistSegment(data); } } + /** + * 按当前下载模式持久化分片 + */ + private async saveProcessedSegment( + task: M3U8DownloadTask, + data: ArrayBuffer, + index: number + ): Promise { + if (task.downloadMode === 'filesystem') { + await this.saveSegmentToFilesystem(task, data, index); + return; + } + + if (task.downloadMode === 'indexeddb') { + await this.saveSegmentToIndexedDB(task, data, index); + return; + } + + // 浏览器下载模式:保存到内存,完成后合并触发浏览器下载 + task.mediaFileList[index] = data; + } + + /** + * 标记分片成功,并在全部完成后执行对应模式的收尾动作 + */ + private async markSegmentSuccess(task: M3U8DownloadTask, index: number): Promise { + task.finishList[index].status = 'is-success'; + task.finishNum++; + + this.options.onProgress?.(task); + + if (task.finishNum !== task.rangeDownload.targetSegment) { + return; + } + + if (task.downloadMode === 'filesystem') { + await this.generateLocalPlaylist(task); + } else if (task.downloadMode === 'indexeddb') { + await this.generateIndexedDBPlaylist(task); + } else { + this.downloadFile(task); + } + + task.status = 'done'; + this.options.onComplete?.(task); + } + /** * 下载文件 */ @@ -846,7 +862,6 @@ export class M3U8Downloader { ): void { if (task.type === 'MP4') { try { - // @ts-ignore - mux.js 的 Transmuxer 在 mp4 子模块下 const transMuxer = new muxjs.mp4.Transmuxer({ keepOriginalTimestamps: true, duration: parseInt(task.durationSecond.toString()), @@ -891,6 +906,49 @@ export class M3U8Downloader { task.requests = []; } + /** + * 获取 IndexedDB 视频缓存 Key + */ + private getIndexedDBCacheKey(task: M3U8DownloadTask): string | null { + if (task.indexedDBCacheKey) { + return task.indexedDBCacheKey; + } + + if (task.source && task.videoId && task.episodeIndex !== undefined) { + task.indexedDBCacheKey = buildIndexedDBVideoCacheKey( + task.source, + task.videoId, + task.episodeIndex + ); + return task.indexedDBCacheKey; + } + + // 缺少业务标识时仍允许缓存任务,但无法在 play 页面按剧集自动命中 + task.indexedDBCacheKey = `task::${task.id}`; + return task.indexedDBCacheKey; + } + + /** + * 保存分片到独立 IndexedDB 视频缓存库 + */ + private async saveSegmentToIndexedDB( + task: M3U8DownloadTask, + data: ArrayBuffer, + index: number + ): Promise { + const cacheKey = this.getIndexedDBCacheKey(task); + if (!cacheKey) { + throw new Error('无法生成 IndexedDB 视频缓存 Key'); + } + + await saveIndexedDBVideoSegment({ + cacheKey, + index, + data, + mimeType: task.type === 'MP4' ? 'video/mp4' : 'video/MP2T', + }); + } + //禁止SzeMeng76抄袭狗抄袭 /** * 保存分片到文件系统 @@ -1010,6 +1068,67 @@ export class M3U8Downloader { } } + /** + * 生成 IndexedDB 本地 M3U8 播放列表并保存缓存清单 + */ + private async generateIndexedDBPlaylist(task: M3U8DownloadTask): Promise { + const cacheKey = this.getIndexedDBCacheKey(task); + if (!cacheKey || !task.m3u8Content) { + throw new Error('无法生成 IndexedDB 播放列表:缺少缓存 Key 或 M3U8 内容'); + } + + if (!task.source || !task.videoId || task.episodeIndex === undefined) { + throw new Error('无法生成 IndexedDB 播放列表:缺少视频标识信息'); + } + + 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(); + + // 下载器会先解密再持久化分片,因此本地播放列表不再保留密钥行, + // 避免播放器对已解密分片二次解密。 + if ( + trimmedLine.startsWith('#EXT-X-KEY:') && + task.aesConf.method && + task.aesConf.method !== 'NONE' + ) { + continue; + } + + if (trimmedLine && !trimmedLine.startsWith('#')) { + if (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 totalSize = await getIndexedDBVideoCacheSize(cacheKey); + await saveIndexedDBVideoManifest({ + cacheKey, + source: task.source, + videoId: task.videoId, + episodeIndex: task.episodeIndex, + title: task.title, + playlistContent: modifiedLines.join('\n'), + m3u8Content: task.m3u8Content, + segmentCount: task.rangeDownload.targetSegment, + completed: true, + mimeType: task.type === 'MP4' ? 'video/mp4' : 'video/MP2T', + totalSize, + }); + } + //禁止SzeMeng76抄袭狗抄袭 /** * 保存加密密钥到文件系统