下载增加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,
|
recommendationCacheKeys,
|
||||||
setRecommendationCache,
|
setRecommendationCache,
|
||||||
} from '@/lib/recommendations/cache';
|
} from '@/lib/recommendations/cache';
|
||||||
|
import { getIndexedDBVideoPlaybackUrl } from '@/lib/indexeddb-video-cache';
|
||||||
import {
|
import {
|
||||||
convertSubtitleFileToVttObjectUrl,
|
convertSubtitleFileToVttObjectUrl,
|
||||||
CUSTOM_SUBTITLE_ACCEPT,
|
CUSTOM_SUBTITLE_ACCEPT,
|
||||||
@@ -2709,6 +2710,19 @@ function PlayPageClient() {
|
|||||||
return Math.round(score * 100) / 100; // 保留两位小数
|
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 (
|
const checkLocalDownload = async (
|
||||||
source: string,
|
source: string,
|
||||||
@@ -3198,6 +3212,7 @@ function PlayPageClient() {
|
|||||||
if (fileSystemCheck.hasLocal && fileSystemCheck.dirHandle) {
|
if (fileSystemCheck.hasLocal && fileSystemCheck.dirHandle) {
|
||||||
// 使用本地文件播放
|
// 使用本地文件播放
|
||||||
try {
|
try {
|
||||||
|
cleanupLocalPlaybackBlobUrls();
|
||||||
// 读取 m3u8 文件
|
// 读取 m3u8 文件
|
||||||
const fileHandle = await fileSystemCheck.dirHandle.getFileHandle('playlist.m3u8', { create: false });
|
const fileHandle = await fileSystemCheck.dirHandle.getFileHandle('playlist.m3u8', { create: false });
|
||||||
const file = await fileHandle.getFile();
|
const file = await fileHandle.getFile();
|
||||||
@@ -3261,8 +3276,36 @@ function PlayPageClient() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果没有 File System API 本地文件,检查服务器端本地下载
|
let indexedDBCheck: Awaited<ReturnType<typeof getIndexedDBVideoPlaybackUrl>> = { hasLocal: false };
|
||||||
if (!fileSystemCheck.hasLocal) {
|
|
||||||
|
// 如果没有 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);
|
const hasLocalFile = await checkLocalDownload(currentSource, currentId, episodeIndex);
|
||||||
if (requestSeq !== videoUrlRequestSeqRef.current) {
|
if (requestSeq !== videoUrlRequestSeqRef.current) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Check, Trash2, X } from 'lucide-react';
|
import { Check, ChevronDown, Trash2, X } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { downloadDB, CompletedTask } from '@/lib/download-db';
|
import { downloadDB, CompletedTask } from '@/lib/download-db';
|
||||||
|
import { deleteIndexedDBVideoCacheByEpisode } from '@/lib/indexeddb-video-cache';
|
||||||
|
|
||||||
import { ConfirmDialog } from './ConfirmDialog';
|
import { ConfirmDialog } from './ConfirmDialog';
|
||||||
|
|
||||||
@@ -13,9 +14,21 @@ interface DownloadManagementPanelProps {
|
|||||||
onClose: () => void;
|
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) {
|
export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementPanelProps) {
|
||||||
const [completedTasks, setCompletedTasks] = useState<CompletedTask[]>([]);
|
const [completedTasks, setCompletedTasks] = useState<CompletedTask[]>([]);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedGroupKeys, setExpandedGroupKeys] = useState<Set<string>>(new Set());
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
@@ -57,6 +70,29 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
|
|||||||
setSelectedIds(newSet);
|
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 () => {
|
const handleDelete = async () => {
|
||||||
if (selectedIds.size === 0) return;
|
if (selectedIds.size === 0) return;
|
||||||
|
|
||||||
@@ -137,6 +173,17 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除文件失败:', task.title, 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';
|
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;
|
if (!mounted || !isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{createPortal(
|
{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
|
<div
|
||||||
className='absolute inset-0 bg-black/50'
|
className='absolute inset-0 bg-black/50'
|
||||||
onClick={onClose}
|
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 */}
|
{/* Header */}
|
||||||
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
|
<div className='flex items-center justify-between border-b border-gray-200 p-3 dark:border-gray-700 sm:p-4'>
|
||||||
<h2 className='text-xl font-semibold text-gray-800 dark:text-gray-200'>
|
<h2 className='text-lg font-semibold text-gray-800 dark:text-gray-200 sm:text-xl'>
|
||||||
下载文件管理
|
下载文件管理
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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' />
|
<X className='w-5 h-5 text-gray-600 dark:text-gray-400' />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
|
<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 items-center gap-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'>
|
<label className='flex items-center gap-2 cursor-pointer'>
|
||||||
<input
|
<input
|
||||||
type='checkbox'
|
type='checkbox'
|
||||||
@@ -210,13 +318,14 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<span className='text-sm text-gray-500 dark:text-gray-400'>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={selectedIds.size === 0 || isDeleting}
|
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' />
|
<Trash2 className='w-4 h-4' />
|
||||||
{isDeleting ? '删除中...' : '删除选中'}
|
{isDeleting ? '删除中...' : '删除选中'}
|
||||||
@@ -224,74 +333,148 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* 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 ? (
|
{completedTasks.length === 0 ? (
|
||||||
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
|
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
|
||||||
暂无下载记录
|
暂无下载记录
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className='space-y-2'>
|
<div className='space-y-3'>
|
||||||
{completedTasks.map((task) => (
|
{videoGroups.map((group) => {
|
||||||
|
const isExpanded = expandedGroupKeys.has(group.key);
|
||||||
|
const selectedCount = group.tasks.filter((task) => selectedIds.has(task.id)).length;
|
||||||
|
const isGroupSelected = selectedCount === group.tasks.length;
|
||||||
|
const isGroupPartiallySelected = selectedCount > 0 && !isGroupSelected;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={task.id}
|
key={group.key}
|
||||||
className={`p-4 border rounded-lg transition-colors cursor-pointer ${
|
className={`border rounded-lg overflow-hidden transition-colors ${
|
||||||
selectedIds.has(task.id)
|
isGroupSelected || isGroupPartiallySelected
|
||||||
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
|
? 'border-green-500 bg-green-50/70 dark:bg-green-900/10'
|
||||||
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800'
|
: 'border-gray-200 dark:border-gray-700'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleToggleSelect(task.id)}
|
|
||||||
>
|
>
|
||||||
<div className='flex items-start gap-3'>
|
<div
|
||||||
<div className='flex-shrink-0 mt-1'>
|
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'
|
||||||
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
|
onClick={() => handleToggleGroupExpand(group.key)}
|
||||||
selectedIds.has(task.id)
|
>
|
||||||
|
<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'
|
? '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'
|
: 'border-gray-300 dark:border-gray-600'
|
||||||
}`}>
|
}`}
|
||||||
{selectedIds.has(task.id) && (
|
aria-label={`选择 ${group.title}`}
|
||||||
<Check className='w-3 h-3 text-white' />
|
>
|
||||||
)}
|
{isGroupSelected ? (
|
||||||
</div>
|
<Check className='h-4 w-4 text-white sm:h-3 sm:w-3' />
|
||||||
</div>
|
) : isGroupPartiallySelected ? (
|
||||||
<div className='flex-1 min-w-0'>
|
<span className='h-0.5 w-3 rounded bg-green-500 sm:w-2.5' />
|
||||||
<div className='flex items-start justify-between gap-2'>
|
) : null}
|
||||||
<div className='flex-1 min-w-0'>
|
</button>
|
||||||
<h3 className='text-sm font-medium text-gray-800 dark:text-gray-200 truncate'>
|
|
||||||
{task.title}
|
<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>
|
</h3>
|
||||||
{task.videoTitle && (
|
<div className='mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
<span>来源: {group.source}</span>
|
||||||
{task.videoTitle}
|
<span>•</span>
|
||||||
</p>
|
<span>{group.tasks.length} 集</span>
|
||||||
)}
|
<span>•</span>
|
||||||
{task.episodeTitle && (
|
<span>{group.downloadModes.map(getDownloadModeLabel).join(' / ')}</span>
|
||||||
<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>
|
</div>
|
||||||
{task.fileSize && (
|
|
||||||
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
|
||||||
{formatFileSize(task.fileSize)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<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='flex items-center gap-2 mt-2 text-xs text-gray-500 dark:text-gray-400'>
|
<div className='min-w-0 sm:min-w-[150px]'>
|
||||||
<span>来源: {task.source}</span>
|
<div className='text-xs text-gray-500 dark:text-gray-400'>
|
||||||
<span>•</span>
|
最近完成:{formatDate(group.lastCompletedAt)}
|
||||||
<span>第 {task.episodeIndex + 1} 集</span>
|
</div>
|
||||||
<span>•</span>
|
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
<span>{task.downloadMode === 'filesystem' ? 'File System API' : '浏览器下载'}</span>
|
总大小:{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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -303,7 +486,7 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={showConfirmDialog}
|
isOpen={showConfirmDialog}
|
||||||
title='确认删除'
|
title='确认删除'
|
||||||
message={`确定要删除选中的 ${selectedIds.size} 个下载记录吗?\n\n注意:如果是 File System API 下载的文件,将会从磁盘删除实际文件。`}
|
message={`确定要删除选中的 ${selectedIds.size} 个下载记录吗?\n\n注意:File System API 文件会从磁盘删除,IndexedDB 缓存会从浏览器独立视频缓存库删除。`}
|
||||||
confirmText='删除'
|
confirmText='删除'
|
||||||
cancelText='取消'
|
cancelText='取消'
|
||||||
variant='danger'
|
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) => {
|
const getLogBadgeClass = (status: M3U8SegmentLogStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'downloading':
|
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'>
|
<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}
|
{task.type}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
const [maxConcurrentDownloads, setMaxConcurrentDownloads] = useState(6);
|
const [maxConcurrentDownloads, setMaxConcurrentDownloads] = useState(6);
|
||||||
const [downloadThreadsPerTask, setDownloadThreadsPerTask] = useState(6);
|
const [downloadThreadsPerTask, setDownloadThreadsPerTask] = useState(6);
|
||||||
const [downloadSegmentTimeout, setDownloadSegmentTimeout] = useState(30000);
|
const [downloadSegmentTimeout, setDownloadSegmentTimeout] = useState(30000);
|
||||||
const [downloadMode, setDownloadMode] = useState<'browser' | 'filesystem'>(
|
const [downloadMode, setDownloadMode] = useState<'browser' | 'filesystem' | 'indexeddb'>(
|
||||||
'browser'
|
'browser'
|
||||||
);
|
);
|
||||||
const [filesystemSavePath, setFilesystemSavePath] = useState<string>('');
|
const [filesystemSavePath, setFilesystemSavePath] = useState<string>('');
|
||||||
@@ -851,7 +851,8 @@ export const UserMenu: React.FC = () => {
|
|||||||
const savedDownloadMode = localStorage.getItem('downloadMode');
|
const savedDownloadMode = localStorage.getItem('downloadMode');
|
||||||
if (
|
if (
|
||||||
savedDownloadMode === 'browser' ||
|
savedDownloadMode === 'browser' ||
|
||||||
savedDownloadMode === 'filesystem'
|
savedDownloadMode === 'filesystem' ||
|
||||||
|
savedDownloadMode === 'indexeddb'
|
||||||
) {
|
) {
|
||||||
setDownloadMode(savedDownloadMode);
|
setDownloadMode(savedDownloadMode);
|
||||||
}
|
}
|
||||||
@@ -1644,7 +1645,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
return seconds > 0 ? `${minutes}分${seconds}秒` : `${minutes}分钟`;
|
return seconds > 0 ? `${minutes}分${seconds}秒` : `${minutes}分钟`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadModeChange = (mode: 'browser' | 'filesystem') => {
|
const handleDownloadModeChange = (mode: 'browser' | 'filesystem' | 'indexeddb') => {
|
||||||
// 如果选择 filesystem 模式,先检测浏览器是否支持
|
// 如果选择 filesystem 模式,先检测浏览器是否支持
|
||||||
if (
|
if (
|
||||||
mode === 'filesystem' &&
|
mode === 'filesystem' &&
|
||||||
@@ -3691,6 +3692,21 @@ export const UserMenu: React.FC = () => {
|
|||||||
File System API(保存分片到本地目录)
|
File System API(保存分片到本地目录)
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 保存路径选择(仅在 filesystem 模式显示) */}
|
{/* 保存路径选择(仅在 filesystem 模式显示) */}
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import React, { createContext, useCallback, useContext, useState, useEffect } fr
|
|||||||
import { M3U8Downloader, M3U8DownloadTask } from '@/lib/m3u8-downloader';
|
import { M3U8Downloader, M3U8DownloadTask } from '@/lib/m3u8-downloader';
|
||||||
import Toast from '@/components/Toast';
|
import Toast from '@/components/Toast';
|
||||||
import { downloadDB } from '@/lib/download-db';
|
import { downloadDB } from '@/lib/download-db';
|
||||||
|
import {
|
||||||
|
buildIndexedDBVideoCacheKey,
|
||||||
|
getBrowserStorageEstimate,
|
||||||
|
getIndexedDBVideoCacheSize,
|
||||||
|
isIndexedDBVideoDownloaded,
|
||||||
|
requestIndexedDBVideoPersistentStorage,
|
||||||
|
} from '@/lib/indexeddb-video-cache';
|
||||||
|
|
||||||
interface DownloadContextType {
|
interface DownloadContextType {
|
||||||
downloader: M3U8Downloader;
|
downloader: M3U8Downloader;
|
||||||
@@ -66,13 +73,17 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
|||||||
onComplete: async (task) => {
|
onComplete: async (task) => {
|
||||||
setTasks(downloader.getAllTasks());
|
setTasks(downloader.getAllTasks());
|
||||||
|
|
||||||
//禁止SzeMeng76抄袭狗抄袭
|
// File System / IndexedDB 模式保存到已完成任务表,用于本地播放命中和下载管理
|
||||||
// 只有 filesystem 模式才保存到已完成任务表
|
if (
|
||||||
if (task.downloadMode === 'filesystem' && task.source && task.videoId && task.episodeIndex !== undefined) {
|
(task.downloadMode === 'filesystem' || task.downloadMode === 'indexeddb') &&
|
||||||
|
task.source &&
|
||||||
|
task.videoId &&
|
||||||
|
task.episodeIndex !== undefined
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
// 计算文件大小
|
// 计算文件大小
|
||||||
let fileSize: number | undefined;
|
let fileSize: number | undefined;
|
||||||
if (task.filesystemDirHandle) {
|
if (task.downloadMode === 'filesystem' && task.filesystemDirHandle) {
|
||||||
try {
|
try {
|
||||||
const sourceDirHandle = await task.filesystemDirHandle.getDirectoryHandle(task.source, { create: false });
|
const sourceDirHandle = await task.filesystemDirHandle.getDirectoryHandle(task.source, { create: false });
|
||||||
const videoIdDirHandle = await sourceDirHandle.getDirectoryHandle(task.videoId, { create: false });
|
const videoIdDirHandle = await sourceDirHandle.getDirectoryHandle(task.videoId, { create: false });
|
||||||
@@ -90,6 +101,17 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('计算文件大小失败:', 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({
|
await downloadDB.saveCompletedTask({
|
||||||
@@ -99,7 +121,7 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
|||||||
videoId: task.videoId,
|
videoId: task.videoId,
|
||||||
episodeIndex: task.episodeIndex,
|
episodeIndex: task.episodeIndex,
|
||||||
completedAt: Date.now(),
|
completedAt: Date.now(),
|
||||||
downloadMode: 'filesystem',
|
downloadMode: task.downloadMode,
|
||||||
fileSize,
|
fileSize,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -173,11 +195,12 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (!savedTasks || savedTasks.length === 0) return;
|
if (!savedTasks || savedTasks.length === 0) return;
|
||||||
|
|
||||||
//禁止SzeMeng76抄袭狗抄袭
|
//禁止SzeMeng76抄袭狗抄袭
|
||||||
// 读取下载模式和目录句柄
|
// 读取目录句柄:只要存在待恢复的 filesystem 任务就需要尝试读取,
|
||||||
const downloadMode = localStorage.getItem('downloadMode') as 'browser' | 'filesystem' || 'browser';
|
// 不依赖当前用户下载模式设置。
|
||||||
|
const hasFilesystemTask = savedTasks.some((task) => task.downloadMode === 'filesystem');
|
||||||
let dirHandle: FileSystemDirectoryHandle | undefined;
|
let dirHandle: FileSystemDirectoryHandle | undefined;
|
||||||
|
|
||||||
if (downloadMode === 'filesystem') {
|
if (hasFilesystemTask) {
|
||||||
const dbName = 'MoonTVPlus';
|
const dbName = 'MoonTVPlus';
|
||||||
const storeName = 'dirHandles';
|
const storeName = 'dirHandles';
|
||||||
|
|
||||||
@@ -226,14 +249,20 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 已完成的 filesystem 任务标记为删除
|
// 已完成的 filesystem/indexeddb 任务标记为删除(已记录到 completedTasks)
|
||||||
if (savedTask.downloadMode === 'filesystem' && savedTask.status === 'done') {
|
if (
|
||||||
|
(savedTask.downloadMode === 'filesystem' || savedTask.downloadMode === 'indexeddb') &&
|
||||||
|
savedTask.status === 'done'
|
||||||
|
) {
|
||||||
tasksToDelete.push(savedTask.id);
|
tasksToDelete.push(savedTask.id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只恢复 filesystem 模式的未完成任务
|
// 只恢复 filesystem/indexeddb 模式的未完成任务
|
||||||
if (savedTask.downloadMode === 'filesystem' && (savedTask.status === 'downloading' || savedTask.status === 'pause' || savedTask.status === 'ready')) {
|
if (
|
||||||
|
(savedTask.downloadMode === 'filesystem' || savedTask.downloadMode === 'indexeddb') &&
|
||||||
|
(savedTask.status === 'downloading' || savedTask.status === 'pause' || savedTask.status === 'ready')
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const taskId = await downloader.createTask(
|
const taskId = await downloader.createTask(
|
||||||
savedTask.url,
|
savedTask.url,
|
||||||
@@ -258,7 +287,15 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
|||||||
task.rangeDownload = savedTask.rangeDownload;
|
task.rangeDownload = savedTask.rangeDownload;
|
||||||
task.segmentLogs = savedTask.segmentLogs || [];
|
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;
|
task.filesystemDirHandle = dirHandle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,7 +338,7 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
// 读取下载模式设置
|
// 读取下载模式设置
|
||||||
const downloadMode = typeof window !== 'undefined'
|
const downloadMode = typeof window !== 'undefined'
|
||||||
? (localStorage.getItem('downloadMode') as 'browser' | 'filesystem') || 'browser'
|
? (localStorage.getItem('downloadMode') as 'browser' | 'filesystem' | 'indexeddb') || 'browser'
|
||||||
: 'browser';
|
: 'browser';
|
||||||
|
|
||||||
// 如果是 filesystem 模式,检查是否已经下载过
|
// 如果是 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 taskId = await downloader.createTask(url, title, type, metadata);
|
||||||
|
|
||||||
// 设置下载模式
|
// 设置下载模式
|
||||||
const task = downloader.getTask(taskId);
|
const task = downloader.getTask(taskId);
|
||||||
if (task) {
|
if (task) {
|
||||||
task.downloadMode = downloadMode;
|
task.downloadMode = downloadMode;
|
||||||
|
if (
|
||||||
|
downloadMode === 'indexeddb' &&
|
||||||
|
metadata?.source &&
|
||||||
|
metadata?.videoId &&
|
||||||
|
metadata?.episodeIndex !== undefined
|
||||||
|
) {
|
||||||
|
task.indexedDBCacheKey = buildIndexedDBVideoCacheKey(
|
||||||
|
metadata.source,
|
||||||
|
metadata.videoId,
|
||||||
|
metadata.episodeIndex
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//禁止SzeMeng76抄袭狗抄袭
|
//禁止SzeMeng76抄袭狗抄袭
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export interface SavedTask {
|
|||||||
source?: string;
|
source?: string;
|
||||||
videoId?: string;
|
videoId?: string;
|
||||||
episodeIndex?: number;
|
episodeIndex?: number;
|
||||||
downloadMode?: 'browser' | 'filesystem';
|
downloadMode?: 'browser' | 'filesystem' | 'indexeddb';
|
||||||
rangeDownload: {
|
rangeDownload: {
|
||||||
isShowRange: boolean;
|
isShowRange: boolean;
|
||||||
startSegment: number;
|
startSegment: number;
|
||||||
@@ -51,7 +51,7 @@ export interface CompletedTask {
|
|||||||
episodeTitle?: string; // 集数标题
|
episodeTitle?: string; // 集数标题
|
||||||
fileSize?: number; // 文件大小(字节)
|
fileSize?: number; // 文件大小(字节)
|
||||||
completedAt: number;
|
completedAt: number;
|
||||||
downloadMode: 'browser' | 'filesystem';
|
downloadMode: 'browser' | 'filesystem' | 'indexeddb';
|
||||||
}
|
}
|
||||||
|
|
||||||
const DB_NAME = 'MoonTVPlus';
|
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 版本
|
* 基于 M3U8Download 项目改造为 TypeScript 版本
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// @ts-ignore - mux.js 没有类型定义
|
// @ts-expect-error - mux.js 没有类型定义
|
||||||
import * as muxjs from 'mux.js';
|
import * as muxjs from 'mux.js';
|
||||||
|
|
||||||
import { AESDecryptor } from './aes-decryptor';
|
import { AESDecryptor } from './aes-decryptor';
|
||||||
|
import {
|
||||||
|
buildIndexedDBVideoCacheKey,
|
||||||
|
deleteIndexedDBVideoCache,
|
||||||
|
getIndexedDBVideoCacheSize,
|
||||||
|
saveIndexedDBVideoManifest,
|
||||||
|
saveIndexedDBVideoSegment,
|
||||||
|
} from './indexeddb-video-cache';
|
||||||
|
|
||||||
export type M3U8SegmentLogStatus =
|
export type M3U8SegmentLogStatus =
|
||||||
| 'queued'
|
| 'queued'
|
||||||
@@ -65,8 +72,9 @@ export interface M3U8DownloadTask {
|
|||||||
};
|
};
|
||||||
//禁止SzeMeng76抄袭狗抄袭
|
//禁止SzeMeng76抄袭狗抄袭
|
||||||
// File System API 相关字段
|
// File System API 相关字段
|
||||||
downloadMode?: 'browser' | 'filesystem';
|
downloadMode?: 'browser' | 'filesystem' | 'indexeddb';
|
||||||
filesystemDirHandle?: FileSystemDirectoryHandle;
|
filesystemDirHandle?: FileSystemDirectoryHandle;
|
||||||
|
indexedDBCacheKey?: string;
|
||||||
m3u8Content?: string; // 原始 M3U8 内容,用于生成本地播放列表
|
m3u8Content?: string; // 原始 M3U8 内容,用于生成本地播放列表
|
||||||
// 视频标识信息(用于区分不同视频)
|
// 视频标识信息(用于区分不同视频)
|
||||||
source?: string;
|
source?: string;
|
||||||
@@ -292,6 +300,11 @@ export class M3U8Downloader {
|
|||||||
await this.deleteFilesystemTask(task);
|
await this.deleteFilesystemTask(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果是 indexeddb 模式且任务未完成,删除独立视频缓存库中的分片
|
||||||
|
if (task.downloadMode === 'indexeddb' && task.status !== 'done') {
|
||||||
|
await this.deleteIndexedDBTask(task);
|
||||||
|
}
|
||||||
|
|
||||||
this.tasks.delete(taskId);
|
this.tasks.delete(taskId);
|
||||||
|
|
||||||
if (this.currentTask?.id === 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);
|
data = this.aesDecrypt(task, data, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
// MP4 转码(如果需要)
|
const persistSegment = (processedData: ArrayBuffer) => {
|
||||||
if (task.type === 'MP4') {
|
this.saveProcessedSegment(task, processedData, index)
|
||||||
this.conversionMp4(task, data, index, (convertedData) => {
|
.then(() => this.markSegmentSuccess(task, index))
|
||||||
if (task.downloadMode === 'filesystem') {
|
.then(() => callback())
|
||||||
// File System API 模式:保存分片到文件系统
|
.catch((error) => {
|
||||||
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) => {
|
|
||||||
console.error('保存分片失败:', error);
|
console.error('保存分片失败:', error);
|
||||||
task.finishList[index].status = 'is-error';
|
task.finishList[index].status = 'is-error';
|
||||||
task.errorNum++;
|
task.errorNum++;
|
||||||
|
this.options.onError?.(task, `保存分片 ${index + 1} 失败: ${error}`);
|
||||||
callback();
|
callback();
|
||||||
});
|
});
|
||||||
} else {
|
};
|
||||||
// 浏览器下载模式:保存到内存
|
|
||||||
task.mediaFileList[index] = data;
|
|
||||||
task.finishList[index].status = 'is-success';
|
|
||||||
task.finishNum++;
|
|
||||||
|
|
||||||
this.options.onProgress?.(task);
|
// MP4 转码(如果需要)
|
||||||
|
if (task.type === 'MP4') {
|
||||||
if (task.finishNum === task.rangeDownload.targetSegment) {
|
this.conversionMp4(task, data, index, persistSegment);
|
||||||
task.status = 'done';
|
} else {
|
||||||
this.downloadFile(task);
|
persistSegment(data);
|
||||||
this.options.onComplete?.(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按当前下载模式持久化分片
|
||||||
|
*/
|
||||||
|
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 {
|
): void {
|
||||||
if (task.type === 'MP4') {
|
if (task.type === 'MP4') {
|
||||||
try {
|
try {
|
||||||
// @ts-ignore - mux.js 的 Transmuxer 在 mp4 子模块下
|
|
||||||
const transMuxer = new muxjs.mp4.Transmuxer({
|
const transMuxer = new muxjs.mp4.Transmuxer({
|
||||||
keepOriginalTimestamps: true,
|
keepOriginalTimestamps: true,
|
||||||
duration: parseInt(task.durationSecond.toString()),
|
duration: parseInt(task.durationSecond.toString()),
|
||||||
@@ -891,6 +906,49 @@ export class M3U8Downloader {
|
|||||||
task.requests = [];
|
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抄袭狗抄袭
|
//禁止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抄袭狗抄袭
|
//禁止SzeMeng76抄袭狗抄袭
|
||||||
/**
|
/**
|
||||||
* 保存加密密钥到文件系统
|
* 保存加密密钥到文件系统
|
||||||
|
|||||||
Reference in New Issue
Block a user