下载增加indexdb模式

This commit is contained in:
mtvpls
2026-06-21 10:59:05 +08:00
parent f7b6181471
commit 99b58b2e6a
9 changed files with 1465 additions and 158 deletions
+194
View File
@@ -56,3 +56,197 @@ self.addEventListener('notificationclick', (event) => {
}
})());
});
/* IndexedDB video cache virtual files
* Route: /__moontv_idb_video__/<encoded-cache-key>/playlist.m3u8
* /__moontv_idb_video__/<encoded-cache-key>/segment_00000.ts
* /__moontv_idb_video__/<encoded-cache-key>/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 });
})
);
});
+45 -2
View File
@@ -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<ReturnType<typeof getIndexedDBVideoPlaybackUrl>> = { 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;
+246 -63
View File
@@ -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<CompletedTask[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [expandedGroupKeys, setExpandedGroupKeys] = useState<Set<string>>(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<VideoDownloadGroup[]>(() => {
const groupMap = new Map<string, CompletedTask[]>();
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<CompletedTask['downloadMode']>(
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(
<div className='fixed inset-0 z-[9999] flex items-center justify-center p-4'>
<div className='fixed inset-0 z-[9999] flex items-end justify-center p-0 sm:items-center sm:p-4'>
<div
className='absolute inset-0 bg-black/50'
onClick={onClose}
/>
<div className='relative w-full max-w-4xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-lg shadow-xl flex flex-col'>
<div className='relative flex h-[92dvh] max-h-[92dvh] w-full max-w-4xl flex-col rounded-t-2xl bg-white shadow-xl dark:bg-gray-900 sm:h-auto sm:max-h-[90vh] sm:rounded-lg'>
{/* Header */}
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
<h2 className='text-xl font-semibold text-gray-800 dark:text-gray-200'>
<div className='flex items-center justify-between border-b border-gray-200 p-3 dark:border-gray-700 sm:p-4'>
<h2 className='text-lg font-semibold text-gray-800 dark:text-gray-200 sm:text-xl'>
</h2>
<button
onClick={onClose}
className='p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors'
className='rounded p-2 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800'
aria-label='关闭下载文件管理'
>
<X className='w-5 h-5 text-gray-600 dark:text-gray-400' />
</button>
</div>
{/* Toolbar */}
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
<div className='flex items-center gap-4'>
<div className='flex flex-col gap-3 border-b border-gray-200 p-3 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between sm:p-4'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4'>
<label className='flex items-center gap-2 cursor-pointer'>
<input
type='checkbox'
@@ -210,13 +318,14 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
</span>
</label>
<span className='text-sm text-gray-500 dark:text-gray-400'>
{selectedIds.size} / {completedTasks.length}
{selectedIds.size} / {completedTasks.length}
{videoGroups.length > 0 && `,共 ${videoGroups.length} 个视频`}
</span>
</div>
<button
onClick={handleDelete}
disabled={selectedIds.size === 0 || isDeleting}
className='px-4 py-2 text-sm bg-red-500 text-white rounded hover:bg-red-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2'
className='flex w-full items-center justify-center gap-2 rounded bg-red-500 px-4 py-2.5 text-sm text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto sm:py-2'
>
<Trash2 className='w-4 h-4' />
{isDeleting ? '删除中...' : '删除选中'}
@@ -224,74 +333,148 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
</div>
{/* Content */}
<div className='flex-1 overflow-y-auto p-4'>
<div className='flex-1 overflow-y-auto p-3 sm:p-4'>
{completedTasks.length === 0 ? (
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
</div>
) : (
<div className='space-y-2'>
{completedTasks.map((task) => (
<div className='space-y-3'>
{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 (
<div
key={task.id}
className={`p-4 border rounded-lg transition-colors cursor-pointer ${
selectedIds.has(task.id)
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800'
key={group.key}
className={`border rounded-lg overflow-hidden transition-colors ${
isGroupSelected || isGroupPartiallySelected
? 'border-green-500 bg-green-50/70 dark:bg-green-900/10'
: 'border-gray-200 dark:border-gray-700'
}`}
onClick={() => handleToggleSelect(task.id)}
>
<div className='flex items-start gap-3'>
<div className='flex-shrink-0 mt-1'>
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
selectedIds.has(task.id)
<div
className='flex cursor-pointer items-start gap-2 p-3 hover:bg-gray-50 dark:hover:bg-gray-800/80 sm:gap-3 sm:p-4'
onClick={() => handleToggleGroupExpand(group.key)}
>
<button
type='button'
onClick={(event) => {
event.stopPropagation();
handleToggleGroupSelect(group);
}}
className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded border-2 transition-colors sm:mt-1 sm:h-5 sm:w-5 ${
isGroupSelected
? 'border-green-500 bg-green-500'
: isGroupPartiallySelected
? 'border-green-500 bg-green-100 dark:bg-green-900/40'
: 'border-gray-300 dark:border-gray-600'
}`}>
{selectedIds.has(task.id) && (
<Check className='w-3 h-3 text-white' />
)}
</div>
</div>
<div className='flex-1 min-w-0'>
<div className='flex items-start justify-between gap-2'>
<div className='flex-1 min-w-0'>
<h3 className='text-sm font-medium text-gray-800 dark:text-gray-200 truncate'>
{task.title}
}`}
aria-label={`选择 ${group.title}`}
>
{isGroupSelected ? (
<Check className='h-4 w-4 text-white sm:h-3 sm:w-3' />
) : isGroupPartiallySelected ? (
<span className='h-0.5 w-3 rounded bg-green-500 sm:w-2.5' />
) : null}
</button>
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3'>
<div className='min-w-0 flex-1'>
<h3 className='line-clamp-2 text-sm font-semibold text-gray-800 dark:text-gray-200 sm:truncate'>
{group.title}
</h3>
{task.videoTitle && (
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
{task.videoTitle}
</p>
)}
{task.episodeTitle && (
<p className='text-xs text-gray-500 dark:text-gray-400'>
{task.episodeTitle}
</p>
)}
</div>
<div className='flex-shrink-0 text-right'>
<div className='text-xs text-gray-500 dark:text-gray-400'>
{formatDate(task.completedAt)}
<div className='mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-gray-500 dark:text-gray-400'>
<span>: {group.source}</span>
<span></span>
<span>{group.tasks.length} </span>
<span></span>
<span>{group.downloadModes.map(getDownloadModeLabel).join(' / ')}</span>
</div>
{task.fileSize && (
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
{formatFileSize(task.fileSize)}
</div>
)}
</div>
</div>
<div className='flex items-center gap-2 mt-2 text-xs text-gray-500 dark:text-gray-400'>
<span>: {task.source}</span>
<span></span>
<span> {task.episodeIndex + 1} </span>
<span></span>
<span>{task.downloadMode === 'filesystem' ? 'File System API' : '浏览器下载'}</span>
<div className='flex w-full flex-row-reverse items-center justify-between gap-3 text-left sm:w-auto sm:flex-row sm:items-start sm:text-right'>
<div className='min-w-0 sm:min-w-[150px]'>
<div className='text-xs text-gray-500 dark:text-gray-400'>
{formatDate(group.lastCompletedAt)}
</div>
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
{formatFileSize(group.totalSize)}
</div>
{selectedCount > 0 && (
<div className='mt-1 text-xs text-green-600 dark:text-green-400'>
{selectedCount}
</div>
)}
</div>
<ChevronDown
className={`h-5 w-5 flex-shrink-0 text-gray-400 transition-transform sm:mt-1 ${
isExpanded ? 'rotate-180' : ''
}`}
/>
</div>
</div>
</div>
</div>
{isExpanded && (
<div className='border-t border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900'>
{group.tasks.map((task) => (
<div
key={task.id}
className={`flex cursor-pointer items-start gap-2 px-3 py-3.5 transition-colors sm:gap-3 sm:px-4 sm:py-3 ${
selectedIds.has(task.id)
? 'bg-green-50 dark:bg-green-900/20'
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
onClick={() => handleToggleSelect(task.id)}
>
<div className='flex-shrink-0 sm:mt-1'>
<div className={`flex h-8 w-8 items-center justify-center rounded border-2 sm:h-5 sm:w-5 ${
selectedIds.has(task.id)
? 'border-green-500 bg-green-500'
: 'border-gray-300 dark:border-gray-600'
}`}>
{selectedIds.has(task.id) && (
<Check className='h-4 w-4 text-white sm:h-3 sm:w-3' />
)}
</div>
</div>
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
<div className='min-w-0 flex-1'>
<h4 className='line-clamp-2 text-sm font-medium text-gray-800 dark:text-gray-200 sm:truncate'>
{task.episodeIndex + 1}
{task.episodeTitle ? `${task.episodeTitle}` : ''}
</h4>
<p className='mt-1 truncate text-xs text-gray-500 dark:text-gray-400'>
{task.title}
</p>
</div>
<div className='flex-shrink-0 text-left sm:text-right'>
<div className='text-xs text-gray-500 dark:text-gray-400'>
{formatDate(task.completedAt)}
</div>
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
{formatFileSize(task.fileSize)}
</div>
</div>
</div>
<div className='mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400'>
<span> {task.episodeIndex + 1} </span>
<span></span>
<span>{getDownloadModeLabel(task.downloadMode)}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
))}
);
})}
</div>
)}
</div>
@@ -303,7 +486,7 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
<ConfirmDialog
isOpen={showConfirmDialog}
title='确认删除'
message={`确定要删除选中的 ${selectedIds.size} 个下载记录吗?\n\n注意:如果是 File System API 下载的文件,将会从磁盘删除实际文件`}
message={`确定要删除选中的 ${selectedIds.size} 个下载记录吗?\n\n注意:File System API 文件会从磁盘删除,IndexedDB 缓存会从浏览器独立视频缓存库删除`}
confirmText='删除'
cancelText='取消'
variant='danger'
+15
View File
@@ -62,6 +62,18 @@ export function DownloadPanel() {
}
};
const getDownloadModeText = (mode: M3U8DownloadTask['downloadMode']) => {
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() {
<span className='rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-500 dark:border-slate-700 dark:text-slate-400'>
{task.type}
</span>
<span className='rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-500 dark:border-slate-700 dark:text-slate-400'>
{getDownloadModeText(task.downloadMode)}
</span>
</div>
</div>
+19 -3
View File
@@ -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<string>('');
@@ -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
</span>
</label>
<label className='flex items-start gap-2 cursor-pointer'>
<input
type='radio'
name='downloadMode'
value='indexeddb'
checked={downloadMode === 'indexeddb'}
onChange={() =>
handleDownloadModeChange('indexeddb')
}
className='mt-0.5 w-4 h-4 text-green-500'
/>
<span className='text-sm text-gray-700 dark:text-gray-300'>
IndexedDB 线
</span>
</label>
</div>
{/* 保存路径选择(仅在 filesystem 模式显示) */}
+103 -14
View File
@@ -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抄袭狗抄袭
+2 -2
View File
@@ -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';
+648
View File
@@ -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<IDBDatabase> | 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<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function openIndexedDBVideoCache(): Promise<IDBDatabase> {
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<IndexedDBVideoCacheManifest | undefined> {
const db = await openIndexedDBVideoCache();
const tx = db.transaction([MANIFESTS_STORE], 'readonly');
const store = tx.objectStore(MANIFESTS_STORE);
return requestToPromise<IndexedDBVideoCacheManifest | undefined>(store.get(cacheKey));
}
export async function getIndexedDBVideoManifestByEpisode(
source: string,
videoId: string,
episodeIndex: number
): Promise<IndexedDBVideoCacheManifest | undefined> {
const db = await openIndexedDBVideoCache();
const tx = db.transaction([MANIFESTS_STORE], 'readonly');
const store = tx.objectStore(MANIFESTS_STORE);
const index = store.index('sourceVideoEpisode');
return requestToPromise<IndexedDBVideoCacheManifest | undefined>(
index.get([source, videoId, episodeIndex])
);
}
export async function isIndexedDBVideoDownloaded(
source: string,
videoId: string,
episodeIndex: number
): Promise<boolean> {
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<number> {
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<void>((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<number> {
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<void>((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<IndexedDBVideoSegmentRecord | undefined> {
const db = await openIndexedDBVideoCache();
const tx = db.transaction([SEGMENTS_STORE], 'readonly');
const store = tx.objectStore(SEGMENTS_STORE);
return requestToPromise<IndexedDBVideoSegmentRecord | undefined>(
store.get(makeSegmentId(cacheKey, index))
);
}
async function getIndexedDBVideoAsset(
cacheKey: string,
name: string
): Promise<IndexedDBVideoAssetRecord | undefined> {
const db = await openIndexedDBVideoCache();
const tx = db.transaction([ASSETS_STORE], 'readonly');
const store = tx.objectStore(ASSETS_STORE);
return requestToPromise<IndexedDBVideoAssetRecord | undefined>(
store.get(makeAssetId(cacheKey, name))
);
}
export async function getIndexedDBVideoCacheSize(cacheKey: string): Promise<number> {
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<number>((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<IndexedDBVideoCacheManifest> {
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<void>((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<void> {
if (!db.objectStoreNames.contains(storeName)) return;
await new Promise<void>((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<void> {
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<void>((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<void> {
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<IndexedDBVideoCacheManifest[]>(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<boolean> {
if (typeof navigator === 'undefined' || !navigator.storage?.persist) {
return false;
}
try {
return await navigator.storage.persist();
} catch {
return false;
}
}
export async function getBrowserStorageEstimate(): Promise<StorageEstimate | null> {
if (typeof navigator === 'undefined' || !navigator.storage?.estimate) {
return null;
}
try {
return await navigator.storage.estimate();
} catch {
return null;
}
}
function waitForServiceWorkerActivation(
registration: ServiceWorkerRegistration
): Promise<ServiceWorkerRegistration> {
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<boolean> {
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<boolean> {
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<IndexedDBVideoPlaybackResult> {
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<IndexedDBVideoPlaybackResult> {
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) };
}
}
+193 -74
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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抄袭狗抄袭
/**
* 保存加密密钥到文件系统