下载增加indexdb模式

This commit is contained in:
mtvpls
2026-06-21 10:59:05 +08:00
parent f7b6181471
commit 99b58b2e6a
9 changed files with 1465 additions and 158 deletions
+2 -2
View File
@@ -19,7 +19,7 @@ export interface SavedTask {
source?: string;
videoId?: string;
episodeIndex?: number;
downloadMode?: 'browser' | 'filesystem';
downloadMode?: 'browser' | 'filesystem' | 'indexeddb';
rangeDownload: {
isShowRange: boolean;
startSegment: number;
@@ -51,7 +51,7 @@ export interface CompletedTask {
episodeTitle?: string; // 集数标题
fileSize?: number; // 文件大小(字节)
completedAt: number;
downloadMode: 'browser' | 'filesystem';
downloadMode: 'browser' | 'filesystem' | 'indexeddb';
}
const DB_NAME = 'MoonTVPlus';
+648
View File
@@ -0,0 +1,648 @@
/**
* IndexedDB 视频缓存(独立库)
*
* 注意:视频分片/播放列表不存入现有 MoonTVPlus 元信息库,避免大体积 Blob
* 与任务/用户元信息混在一起。
*/
export const INDEXEDDB_VIDEO_CACHE_DB_NAME = 'MoonTVPlusVideoCache';
export const INDEXEDDB_VIDEO_CACHE_DB_VERSION = 1;
export const INDEXEDDB_VIDEO_CACHE_ROUTE_PREFIX = '/__moontv_idb_video__';
const MANIFESTS_STORE = 'manifests';
const SEGMENTS_STORE = 'segments';
const ASSETS_STORE = 'assets';
export interface IndexedDBVideoCacheManifest {
cacheKey: string;
source: string;
videoId: string;
episodeIndex: number;
title: string;
playlistContent: string;
m3u8Content?: string;
segmentCount: number;
totalSize: number;
completed: boolean;
createdAt: number;
updatedAt: number;
mimeType?: string;
}
interface IndexedDBVideoSegmentRecord {
id: string;
cacheKey: string;
index: number;
data: Blob;
size: number;
updatedAt: number;
}
interface IndexedDBVideoAssetRecord {
id: string;
cacheKey: string;
name: string;
data: Blob;
size: number;
mimeType?: string;
updatedAt: number;
}
export interface IndexedDBVideoPlaybackResult {
hasLocal: boolean;
url?: string;
manifest?: IndexedDBVideoCacheManifest;
mode?: 'service-worker' | 'blob';
objectUrls?: string[];
reason?: string;
}
let dbPromise: Promise<IDBDatabase> | null = null;
export function buildIndexedDBVideoCacheKey(
source: string,
videoId: string,
episodeIndex: number
): string {
return `${source}::${videoId}::${episodeIndex}`;
}
function makeSegmentId(cacheKey: string, index: number): string {
return `${cacheKey}:segment:${index}`;
}
function makeAssetId(cacheKey: string, name: string): string {
return `${cacheKey}:asset:${name}`;
}
function assertIndexedDBAvailable(): void {
if (typeof indexedDB === 'undefined') {
throw new Error('当前环境不支持 IndexedDB');
}
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function openIndexedDBVideoCache(): Promise<IDBDatabase> {
assertIndexedDBAvailable();
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(
INDEXEDDB_VIDEO_CACHE_DB_NAME,
INDEXEDDB_VIDEO_CACHE_DB_VERSION
);
request.onerror = () => {
dbPromise = null;
reject(request.error);
};
request.onsuccess = () => {
const db = request.result;
db.onversionchange = () => {
db.close();
dbPromise = null;
};
resolve(db);
};
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(MANIFESTS_STORE)) {
const manifestStore = db.createObjectStore(MANIFESTS_STORE, {
keyPath: 'cacheKey',
});
manifestStore.createIndex('sourceVideoEpisode', ['source', 'videoId', 'episodeIndex'], {
unique: true,
});
manifestStore.createIndex('completed', 'completed', { unique: false });
manifestStore.createIndex('updatedAt', 'updatedAt', { unique: false });
}
if (!db.objectStoreNames.contains(SEGMENTS_STORE)) {
const segmentStore = db.createObjectStore(SEGMENTS_STORE, {
keyPath: 'id',
});
segmentStore.createIndex('cacheKey', 'cacheKey', { unique: false });
}
if (!db.objectStoreNames.contains(ASSETS_STORE)) {
const assetStore = db.createObjectStore(ASSETS_STORE, {
keyPath: 'id',
});
assetStore.createIndex('cacheKey', 'cacheKey', { unique: false });
}
};
});
return dbPromise;
}
export async function getIndexedDBVideoManifestByCacheKey(
cacheKey: string
): Promise<IndexedDBVideoCacheManifest | undefined> {
const db = await openIndexedDBVideoCache();
const tx = db.transaction([MANIFESTS_STORE], 'readonly');
const store = tx.objectStore(MANIFESTS_STORE);
return requestToPromise<IndexedDBVideoCacheManifest | undefined>(store.get(cacheKey));
}
export async function getIndexedDBVideoManifestByEpisode(
source: string,
videoId: string,
episodeIndex: number
): Promise<IndexedDBVideoCacheManifest | undefined> {
const db = await openIndexedDBVideoCache();
const tx = db.transaction([MANIFESTS_STORE], 'readonly');
const store = tx.objectStore(MANIFESTS_STORE);
const index = store.index('sourceVideoEpisode');
return requestToPromise<IndexedDBVideoCacheManifest | undefined>(
index.get([source, videoId, episodeIndex])
);
}
export async function isIndexedDBVideoDownloaded(
source: string,
videoId: string,
episodeIndex: number
): Promise<boolean> {
const manifest = await getIndexedDBVideoManifestByEpisode(source, videoId, episodeIndex);
return Boolean(manifest?.completed && manifest.segmentCount > 0);
}
export async function saveIndexedDBVideoSegment(input: {
cacheKey: string;
index: number;
data: ArrayBuffer | Uint8Array | Blob;
mimeType?: string;
}): Promise<number> {
const db = await openIndexedDBVideoCache();
const blob = input.data instanceof Blob
? input.data
: new Blob([input.data], { type: input.mimeType || 'video/MP2T' });
const record: IndexedDBVideoSegmentRecord = {
id: makeSegmentId(input.cacheKey, input.index),
cacheKey: input.cacheKey,
index: input.index,
data: blob,
size: blob.size,
updatedAt: Date.now(),
};
const tx = db.transaction([SEGMENTS_STORE], 'readwrite');
tx.objectStore(SEGMENTS_STORE).put(record);
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
return blob.size;
}
export async function saveIndexedDBVideoAsset(input: {
cacheKey: string;
name: string;
data: ArrayBuffer | Uint8Array | Blob;
mimeType?: string;
}): Promise<number> {
const db = await openIndexedDBVideoCache();
const blob = input.data instanceof Blob
? input.data
: new Blob([input.data], { type: input.mimeType || 'application/octet-stream' });
const record: IndexedDBVideoAssetRecord = {
id: makeAssetId(input.cacheKey, input.name),
cacheKey: input.cacheKey,
name: input.name,
data: blob,
size: blob.size,
mimeType: input.mimeType,
updatedAt: Date.now(),
};
const tx = db.transaction([ASSETS_STORE], 'readwrite');
tx.objectStore(ASSETS_STORE).put(record);
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
return blob.size;
}
async function getIndexedDBVideoSegment(
cacheKey: string,
index: number
): Promise<IndexedDBVideoSegmentRecord | undefined> {
const db = await openIndexedDBVideoCache();
const tx = db.transaction([SEGMENTS_STORE], 'readonly');
const store = tx.objectStore(SEGMENTS_STORE);
return requestToPromise<IndexedDBVideoSegmentRecord | undefined>(
store.get(makeSegmentId(cacheKey, index))
);
}
async function getIndexedDBVideoAsset(
cacheKey: string,
name: string
): Promise<IndexedDBVideoAssetRecord | undefined> {
const db = await openIndexedDBVideoCache();
const tx = db.transaction([ASSETS_STORE], 'readonly');
const store = tx.objectStore(ASSETS_STORE);
return requestToPromise<IndexedDBVideoAssetRecord | undefined>(
store.get(makeAssetId(cacheKey, name))
);
}
export async function getIndexedDBVideoCacheSize(cacheKey: string): Promise<number> {
const manifest = await getIndexedDBVideoManifestByCacheKey(cacheKey);
if (manifest?.totalSize) return manifest.totalSize;
const db = await openIndexedDBVideoCache();
const tx = db.transaction([SEGMENTS_STORE, ASSETS_STORE], 'readonly');
const sumByIndex = (storeName: string) => new Promise<number>((resolve, reject) => {
const store = tx.objectStore(storeName);
const index = store.index('cacheKey');
const request = index.openCursor(IDBKeyRange.only(cacheKey));
let total = 0;
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
resolve(total);
return;
}
total += Number((cursor.value as { size?: number }).size || 0);
cursor.continue();
};
request.onerror = () => reject(request.error);
});
const [segmentSize, assetSize] = await Promise.all([
sumByIndex(SEGMENTS_STORE),
sumByIndex(ASSETS_STORE),
]);
return segmentSize + assetSize;
}
export async function saveIndexedDBVideoManifest(input: {
cacheKey: string;
source: string;
videoId: string;
episodeIndex: number;
title: string;
playlistContent: string;
m3u8Content?: string;
segmentCount: number;
completed: boolean;
mimeType?: string;
totalSize?: number;
}): Promise<IndexedDBVideoCacheManifest> {
const db = await openIndexedDBVideoCache();
const existing = await getIndexedDBVideoManifestByCacheKey(input.cacheKey);
const now = Date.now();
const totalSize = typeof input.totalSize === 'number'
? input.totalSize
: await getIndexedDBVideoCacheSize(input.cacheKey);
const manifest: IndexedDBVideoCacheManifest = {
cacheKey: input.cacheKey,
source: input.source,
videoId: input.videoId,
episodeIndex: input.episodeIndex,
title: input.title,
playlistContent: input.playlistContent,
m3u8Content: input.m3u8Content,
segmentCount: input.segmentCount,
totalSize,
completed: input.completed,
createdAt: existing?.createdAt || now,
updatedAt: now,
mimeType: input.mimeType,
};
const tx = db.transaction([MANIFESTS_STORE], 'readwrite');
tx.objectStore(MANIFESTS_STORE).put(manifest);
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
return manifest;
}
async function deleteRecordsByCacheKey(
db: IDBDatabase,
storeName: string,
cacheKey: string
): Promise<void> {
if (!db.objectStoreNames.contains(storeName)) return;
await new Promise<void>((resolve, reject) => {
const tx = db.transaction([storeName], 'readwrite');
const store = tx.objectStore(storeName);
const index = store.index('cacheKey');
const request = index.openCursor(IDBKeyRange.only(cacheKey));
request.onsuccess = () => {
const cursor = request.result;
if (cursor) {
cursor.delete();
cursor.continue();
}
};
request.onerror = () => reject(request.error);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
export async function deleteIndexedDBVideoCache(cacheKey: string): Promise<void> {
const db = await openIndexedDBVideoCache();
await deleteRecordsByCacheKey(db, SEGMENTS_STORE, cacheKey);
await deleteRecordsByCacheKey(db, ASSETS_STORE, cacheKey);
const tx = db.transaction([MANIFESTS_STORE], 'readwrite');
tx.objectStore(MANIFESTS_STORE).delete(cacheKey);
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
export async function deleteIndexedDBVideoCacheByEpisode(
source: string,
videoId: string,
episodeIndex: number
): Promise<void> {
const manifest = await getIndexedDBVideoManifestByEpisode(source, videoId, episodeIndex);
if (manifest) {
await deleteIndexedDBVideoCache(manifest.cacheKey);
return;
}
await deleteIndexedDBVideoCache(
buildIndexedDBVideoCacheKey(source, videoId, episodeIndex)
);
}
export async function getIndexedDBVideoStorageUsage(): Promise<{
totalSize: number;
count: number;
}> {
const db = await openIndexedDBVideoCache();
const tx = db.transaction([MANIFESTS_STORE], 'readonly');
const store = tx.objectStore(MANIFESTS_STORE);
const request = store.getAll();
const manifests = await requestToPromise<IndexedDBVideoCacheManifest[]>(request);
return manifests.reduce(
(acc, manifest) => ({
totalSize: acc.totalSize + Number(manifest.totalSize || 0),
count: acc.count + (manifest.completed ? 1 : 0),
}),
{ totalSize: 0, count: 0 }
);
}
export async function requestIndexedDBVideoPersistentStorage(): Promise<boolean> {
if (typeof navigator === 'undefined' || !navigator.storage?.persist) {
return false;
}
try {
return await navigator.storage.persist();
} catch {
return false;
}
}
export async function getBrowserStorageEstimate(): Promise<StorageEstimate | null> {
if (typeof navigator === 'undefined' || !navigator.storage?.estimate) {
return null;
}
try {
return await navigator.storage.estimate();
} catch {
return null;
}
}
function waitForServiceWorkerActivation(
registration: ServiceWorkerRegistration
): Promise<ServiceWorkerRegistration> {
const worker = registration.installing || registration.waiting || registration.active;
if (!worker || worker.state === 'activated') {
return Promise.resolve(registration);
}
return new Promise((resolve, reject) => {
const handleStateChange = () => {
if (worker.state === 'activated') {
worker.removeEventListener('statechange', handleStateChange);
resolve(registration);
} else if (worker.state === 'redundant') {
worker.removeEventListener('statechange', handleStateChange);
reject(new Error('Service Worker 激活失败'));
}
};
worker.addEventListener('statechange', handleStateChange);
handleStateChange();
});
}
async function waitForServiceWorkerController(timeoutMs = 2500): Promise<boolean> {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
return false;
}
if (navigator.serviceWorker.controller) return true;
return new Promise((resolve) => {
const timer = window.setTimeout(() => {
navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
resolve(Boolean(navigator.serviceWorker.controller));
}, timeoutMs);
const onControllerChange = () => {
window.clearTimeout(timer);
navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
resolve(true);
};
navigator.serviceWorker.addEventListener('controllerchange', onControllerChange);
});
}
export async function ensureIndexedDBVideoServiceWorker(): Promise<boolean> {
if (typeof window === 'undefined' || typeof navigator === 'undefined') return false;
if (!('serviceWorker' in navigator)) return false;
try {
const registration = await navigator.serviceWorker.register('/push-sw.js', {
scope: '/',
updateViaCache: 'none',
});
await waitForServiceWorkerActivation(registration);
return waitForServiceWorkerController();
} catch (error) {
console.warn('[IndexedDBVideo] Service Worker 不可用,降级为 Blob URL 播放:', error);
return false;
}
}
export function getIndexedDBVideoServiceWorkerPlaylistUrl(
cacheKey: string,
version?: number
): string {
const encodedCacheKey = encodeURIComponent(cacheKey);
const suffix = version ? `?v=${encodeURIComponent(String(version))}` : '';
return `${INDEXEDDB_VIDEO_CACHE_ROUTE_PREFIX}/${encodedCacheKey}/playlist.m3u8${suffix}`;
}
function isSegmentLine(line: string): boolean {
return /^segment_\d+\.ts$/i.test(line.trim());
}
function parseSegmentIndex(line: string): number | null {
const match = line.trim().match(/^segment_(\d+)\.ts$/i);
if (!match) return null;
const index = Number(match[1]);
return Number.isFinite(index) ? index : null;
}
export async function createIndexedDBVideoBlobPlaybackUrl(
cacheKey: string
): Promise<IndexedDBVideoPlaybackResult> {
const manifest = await getIndexedDBVideoManifestByCacheKey(cacheKey);
if (!manifest?.completed) {
return { hasLocal: false, reason: 'IndexedDB 缓存未完成' };
}
const objectUrls: string[] = [];
const lines = manifest.playlistContent.split('\n');
const modifiedLines: string[] = [];
for (const line of lines) {
const trimmedLine = line.trim();
if (isSegmentLine(trimmedLine)) {
const segmentIndex = parseSegmentIndex(trimmedLine);
if (segmentIndex === null) {
modifiedLines.push(line);
continue;
}
const segment = await getIndexedDBVideoSegment(cacheKey, segmentIndex);
if (!segment?.data) {
objectUrls.forEach((url) => URL.revokeObjectURL(url));
return { hasLocal: false, reason: `缺少 IndexedDB 分片 ${segmentIndex + 1}` };
}
const segmentUrl = URL.createObjectURL(segment.data);
objectUrls.push(segmentUrl);
modifiedLines.push(line.replace(trimmedLine, segmentUrl));
continue;
}
if (trimmedLine === 'key.key') {
const asset = await getIndexedDBVideoAsset(cacheKey, 'key.key');
if (asset?.data) {
const keyUrl = URL.createObjectURL(asset.data);
objectUrls.push(keyUrl);
modifiedLines.push(line.replace(trimmedLine, keyUrl));
} else {
modifiedLines.push(line);
}
continue;
}
if (trimmedLine.includes('URI="key.key"')) {
const asset = await getIndexedDBVideoAsset(cacheKey, 'key.key');
if (asset?.data) {
const keyUrl = URL.createObjectURL(asset.data);
objectUrls.push(keyUrl);
modifiedLines.push(line.replace('URI="key.key"', `URI="${keyUrl}"`));
} else {
modifiedLines.push(line);
}
continue;
}
modifiedLines.push(line);
}
const playlistBlob = new Blob([modifiedLines.join('\n')], {
type: 'application/vnd.apple.mpegurl',
});
const playlistUrl = URL.createObjectURL(playlistBlob);
objectUrls.push(playlistUrl);
return {
hasLocal: true,
url: playlistUrl,
manifest,
mode: 'blob',
objectUrls,
};
}
export async function getIndexedDBVideoPlaybackUrl(
source: string,
videoId: string,
episodeIndex: number,
options: { preferServiceWorker?: boolean } = {}
): Promise<IndexedDBVideoPlaybackResult> {
try {
const manifest = await getIndexedDBVideoManifestByEpisode(source, videoId, episodeIndex);
if (!manifest?.completed) {
return { hasLocal: false, reason: '未找到 IndexedDB 本地缓存' };
}
const preferServiceWorker = options.preferServiceWorker !== false;
if (preferServiceWorker) {
const serviceWorkerReady = await ensureIndexedDBVideoServiceWorker();
if (serviceWorkerReady) {
return {
hasLocal: true,
url: getIndexedDBVideoServiceWorkerPlaylistUrl(
manifest.cacheKey,
manifest.updatedAt
),
manifest,
mode: 'service-worker',
};
}
}
return createIndexedDBVideoBlobPlaybackUrl(manifest.cacheKey);
} catch (error) {
console.error('[IndexedDBVideo] 获取本地播放地址失败:', error);
return { hasLocal: false, reason: String(error) };
}
}
+193 -74
View File
@@ -3,10 +3,17 @@
* 基于 M3U8Download 项目改造为 TypeScript 版本
*/
// @ts-ignore - mux.js 没有类型定义
// @ts-expect-error - mux.js 没有类型定义
import * as muxjs from 'mux.js';
import { AESDecryptor } from './aes-decryptor';
import {
buildIndexedDBVideoCacheKey,
deleteIndexedDBVideoCache,
getIndexedDBVideoCacheSize,
saveIndexedDBVideoManifest,
saveIndexedDBVideoSegment,
} from './indexeddb-video-cache';
export type M3U8SegmentLogStatus =
| 'queued'
@@ -65,8 +72,9 @@ export interface M3U8DownloadTask {
};
//禁止SzeMeng76抄袭狗抄袭
// File System API 相关字段
downloadMode?: 'browser' | 'filesystem';
downloadMode?: 'browser' | 'filesystem' | 'indexeddb';
filesystemDirHandle?: FileSystemDirectoryHandle;
indexedDBCacheKey?: string;
m3u8Content?: string; // 原始 M3U8 内容,用于生成本地播放列表
// 视频标识信息(用于区分不同视频)
source?: string;
@@ -292,6 +300,11 @@ export class M3U8Downloader {
await this.deleteFilesystemTask(task);
}
// 如果是 indexeddb 模式且任务未完成,删除独立视频缓存库中的分片
if (task.downloadMode === 'indexeddb' && task.status !== 'done') {
await this.deleteIndexedDBTask(task);
}
this.tasks.delete(taskId);
if (this.currentTask?.id === taskId) {
@@ -323,6 +336,21 @@ export class M3U8Downloader {
}
}
/**
* 删除 IndexedDB 模式下的任务缓存
*/
private async deleteIndexedDBTask(task: M3U8DownloadTask): Promise<void> {
const cacheKey = this.getIndexedDBCacheKey(task);
if (!cacheKey) return;
try {
await deleteIndexedDBVideoCache(cacheKey);
console.log(`已删除未完成的 IndexedDB 视频缓存: ${cacheKey}`);
} catch (error) {
console.warn('删除 IndexedDB 视频缓存失败:', error);
}
}
/**
* 获取任务信息
*/
@@ -570,86 +598,74 @@ export class M3U8Downloader {
data = this.aesDecrypt(task, data, index);
}
// MP4 转码(如果需要)
if (task.type === 'MP4') {
this.conversionMp4(task, data, index, (convertedData) => {
if (task.downloadMode === 'filesystem') {
// File System API 模式:保存分片到文件系统
this.saveSegmentToFilesystem(task, convertedData, index).then(() => {
task.finishList[index].status = 'is-success';
task.finishNum++;
this.options.onProgress?.(task);
if (task.finishNum === task.rangeDownload.targetSegment) {
task.status = 'done';
this.generateLocalPlaylist(task);
this.options.onComplete?.(task);
}
callback();
}).catch((error) => {
console.error('保存分片失败:', error);
task.finishList[index].status = 'is-error';
task.errorNum++;
callback();
});
} else {
// 浏览器下载模式:保存到内存
task.mediaFileList[index] = convertedData;
task.finishList[index].status = 'is-success';
task.finishNum++;
this.options.onProgress?.(task);
if (task.finishNum === task.rangeDownload.targetSegment) {
task.status = 'done';
this.downloadFile(task);
this.options.onComplete?.(task);
}
callback();
}
});
} else {
if (task.downloadMode === 'filesystem') {
// File System API 模式:保存分片到文件系统
this.saveSegmentToFilesystem(task, data, index).then(() => {
task.finishList[index].status = 'is-success';
task.finishNum++;
this.options.onProgress?.(task);
if (task.finishNum === task.rangeDownload.targetSegment) {
task.status = 'done';
this.generateLocalPlaylist(task);
this.options.onComplete?.(task);
}
callback();
}).catch((error) => {
const persistSegment = (processedData: ArrayBuffer) => {
this.saveProcessedSegment(task, processedData, index)
.then(() => this.markSegmentSuccess(task, index))
.then(() => callback())
.catch((error) => {
console.error('保存分片失败:', error);
task.finishList[index].status = 'is-error';
task.errorNum++;
this.options.onError?.(task, `保存分片 ${index + 1} 失败: ${error}`);
callback();
});
} else {
// 浏览器下载模式:保存到内存
task.mediaFileList[index] = data;
task.finishList[index].status = 'is-success';
task.finishNum++;
};
this.options.onProgress?.(task);
if (task.finishNum === task.rangeDownload.targetSegment) {
task.status = 'done';
this.downloadFile(task);
this.options.onComplete?.(task);
}
callback();
}
// MP4 转码(如果需要)
if (task.type === 'MP4') {
this.conversionMp4(task, data, index, persistSegment);
} else {
persistSegment(data);
}
}
/**
* 按当前下载模式持久化分片
*/
private async saveProcessedSegment(
task: M3U8DownloadTask,
data: ArrayBuffer,
index: number
): Promise<void> {
if (task.downloadMode === 'filesystem') {
await this.saveSegmentToFilesystem(task, data, index);
return;
}
if (task.downloadMode === 'indexeddb') {
await this.saveSegmentToIndexedDB(task, data, index);
return;
}
// 浏览器下载模式:保存到内存,完成后合并触发浏览器下载
task.mediaFileList[index] = data;
}
/**
* 标记分片成功,并在全部完成后执行对应模式的收尾动作
*/
private async markSegmentSuccess(task: M3U8DownloadTask, index: number): Promise<void> {
task.finishList[index].status = 'is-success';
task.finishNum++;
this.options.onProgress?.(task);
if (task.finishNum !== task.rangeDownload.targetSegment) {
return;
}
if (task.downloadMode === 'filesystem') {
await this.generateLocalPlaylist(task);
} else if (task.downloadMode === 'indexeddb') {
await this.generateIndexedDBPlaylist(task);
} else {
this.downloadFile(task);
}
task.status = 'done';
this.options.onComplete?.(task);
}
/**
* 下载文件
*/
@@ -846,7 +862,6 @@ export class M3U8Downloader {
): void {
if (task.type === 'MP4') {
try {
// @ts-ignore - mux.js 的 Transmuxer 在 mp4 子模块下
const transMuxer = new muxjs.mp4.Transmuxer({
keepOriginalTimestamps: true,
duration: parseInt(task.durationSecond.toString()),
@@ -891,6 +906,49 @@ export class M3U8Downloader {
task.requests = [];
}
/**
* 获取 IndexedDB 视频缓存 Key
*/
private getIndexedDBCacheKey(task: M3U8DownloadTask): string | null {
if (task.indexedDBCacheKey) {
return task.indexedDBCacheKey;
}
if (task.source && task.videoId && task.episodeIndex !== undefined) {
task.indexedDBCacheKey = buildIndexedDBVideoCacheKey(
task.source,
task.videoId,
task.episodeIndex
);
return task.indexedDBCacheKey;
}
// 缺少业务标识时仍允许缓存任务,但无法在 play 页面按剧集自动命中
task.indexedDBCacheKey = `task::${task.id}`;
return task.indexedDBCacheKey;
}
/**
* 保存分片到独立 IndexedDB 视频缓存库
*/
private async saveSegmentToIndexedDB(
task: M3U8DownloadTask,
data: ArrayBuffer,
index: number
): Promise<void> {
const cacheKey = this.getIndexedDBCacheKey(task);
if (!cacheKey) {
throw new Error('无法生成 IndexedDB 视频缓存 Key');
}
await saveIndexedDBVideoSegment({
cacheKey,
index,
data,
mimeType: task.type === 'MP4' ? 'video/mp4' : 'video/MP2T',
});
}
//禁止SzeMeng76抄袭狗抄袭
/**
* 保存分片到文件系统
@@ -1010,6 +1068,67 @@ export class M3U8Downloader {
}
}
/**
* 生成 IndexedDB 本地 M3U8 播放列表并保存缓存清单
*/
private async generateIndexedDBPlaylist(task: M3U8DownloadTask): Promise<void> {
const cacheKey = this.getIndexedDBCacheKey(task);
if (!cacheKey || !task.m3u8Content) {
throw new Error('无法生成 IndexedDB 播放列表:缺少缓存 Key 或 M3U8 内容');
}
if (!task.source || !task.videoId || task.episodeIndex === undefined) {
throw new Error('无法生成 IndexedDB 播放列表:缺少视频标识信息');
}
const lines = task.m3u8Content.split('\n');
const modifiedLines: string[] = [];
let segmentIndex = task.rangeDownload.startSegment;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmedLine = line.trim();
// 下载器会先解密再持久化分片,因此本地播放列表不再保留密钥行,
// 避免播放器对已解密分片二次解密。
if (
trimmedLine.startsWith('#EXT-X-KEY:') &&
task.aesConf.method &&
task.aesConf.method !== 'NONE'
) {
continue;
}
if (trimmedLine && !trimmedLine.startsWith('#')) {
if (segmentIndex < task.rangeDownload.endSegment) {
const indent = line.match(/^\s*/)?.[0] || '';
const filename = `segment_${segmentIndex.toString().padStart(5, '0')}.ts`;
modifiedLines.push(indent + filename);
segmentIndex++;
} else {
modifiedLines.push(line);
}
} else {
modifiedLines.push(line);
}
}
const totalSize = await getIndexedDBVideoCacheSize(cacheKey);
await saveIndexedDBVideoManifest({
cacheKey,
source: task.source,
videoId: task.videoId,
episodeIndex: task.episodeIndex,
title: task.title,
playlistContent: modifiedLines.join('\n'),
m3u8Content: task.m3u8Content,
segmentCount: task.rangeDownload.targetSegment,
completed: true,
mimeType: task.type === 'MP4' ? 'video/mp4' : 'video/MP2T',
totalSize,
});
}
//禁止SzeMeng76抄袭狗抄袭
/**
* 保存加密密钥到文件系统