下载增加indexdb模式
This commit is contained in:
@@ -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