下载增加indexdb模式
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 模式显示) */}
|
||||
|
||||
@@ -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抄袭狗抄袭
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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抄袭狗抄袭
|
||||
/**
|
||||
* 保存加密密钥到文件系统
|
||||
|
||||
Reference in New Issue
Block a user