增加下载内容导出功能

This commit is contained in:
mtvpls
2026-06-21 16:26:06 +08:00
parent 99b58b2e6a
commit 1a24f3316f
2 changed files with 625 additions and 259 deletions
+125 -43
View File
@@ -120,9 +120,13 @@ export async function openIndexedDBVideoCache(): Promise<IDBDatabase> {
const manifestStore = db.createObjectStore(MANIFESTS_STORE, {
keyPath: 'cacheKey',
});
manifestStore.createIndex('sourceVideoEpisode', ['source', 'videoId', 'episodeIndex'], {
unique: true,
});
manifestStore.createIndex(
'sourceVideoEpisode',
['source', 'videoId', 'episodeIndex'],
{
unique: true,
}
);
manifestStore.createIndex('completed', 'completed', { unique: false });
manifestStore.createIndex('updatedAt', 'updatedAt', { unique: false });
}
@@ -152,7 +156,9 @@ export async function getIndexedDBVideoManifestByCacheKey(
const db = await openIndexedDBVideoCache();
const tx = db.transaction([MANIFESTS_STORE], 'readonly');
const store = tx.objectStore(MANIFESTS_STORE);
return requestToPromise<IndexedDBVideoCacheManifest | undefined>(store.get(cacheKey));
return requestToPromise<IndexedDBVideoCacheManifest | undefined>(
store.get(cacheKey)
);
}
export async function getIndexedDBVideoManifestByEpisode(
@@ -174,7 +180,11 @@ export async function isIndexedDBVideoDownloaded(
videoId: string,
episodeIndex: number
): Promise<boolean> {
const manifest = await getIndexedDBVideoManifestByEpisode(source, videoId, episodeIndex);
const manifest = await getIndexedDBVideoManifestByEpisode(
source,
videoId,
episodeIndex
);
return Boolean(manifest?.completed && manifest.segmentCount > 0);
}
@@ -185,9 +195,10 @@ export async function saveIndexedDBVideoSegment(input: {
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 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),
@@ -217,9 +228,12 @@ export async function saveIndexedDBVideoAsset(input: {
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 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),
@@ -267,30 +281,66 @@ async function getIndexedDBVideoAsset(
);
}
export async function getIndexedDBVideoCacheSize(cacheKey: string): Promise<number> {
export async function getIndexedDBVideoSegments(
cacheKey: string
): Promise<Array<{ index: number; data: Blob; size: number }>> {
const db = await openIndexedDBVideoCache();
const tx = db.transaction([SEGMENTS_STORE], 'readonly');
const store = tx.objectStore(SEGMENTS_STORE);
const index = store.index('cacheKey');
return new Promise((resolve, reject) => {
const segments: Array<{ index: number; data: Blob; size: number }> = [];
const request = index.openCursor(IDBKeyRange.only(cacheKey));
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
segments.sort((a, b) => a.index - b.index);
resolve(segments);
return;
}
const record = cursor.value as IndexedDBVideoSegmentRecord;
segments.push({
index: record.index,
data: record.data,
size: record.size,
});
cursor.continue();
};
request.onerror = () => reject(request.error);
});
}
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;
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);
});
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),
@@ -316,9 +366,10 @@ export async function saveIndexedDBVideoManifest(input: {
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 totalSize =
typeof input.totalSize === 'number'
? input.totalSize
: await getIndexedDBVideoCacheSize(input.cacheKey);
const manifest: IndexedDBVideoCacheManifest = {
cacheKey: input.cacheKey,
@@ -376,7 +427,9 @@ async function deleteRecordsByCacheKey(
});
}
export async function deleteIndexedDBVideoCache(cacheKey: string): Promise<void> {
export async function deleteIndexedDBVideoCache(
cacheKey: string
): Promise<void> {
const db = await openIndexedDBVideoCache();
await deleteRecordsByCacheKey(db, SEGMENTS_STORE, cacheKey);
await deleteRecordsByCacheKey(db, ASSETS_STORE, cacheKey);
@@ -396,7 +449,11 @@ export async function deleteIndexedDBVideoCacheByEpisode(
videoId: string,
episodeIndex: number
): Promise<void> {
const manifest = await getIndexedDBVideoManifestByEpisode(source, videoId, episodeIndex);
const manifest = await getIndexedDBVideoManifestByEpisode(
source,
videoId,
episodeIndex
);
if (manifest) {
await deleteIndexedDBVideoCache(manifest.cacheKey);
return;
@@ -415,7 +472,9 @@ export async function getIndexedDBVideoStorageUsage(): Promise<{
const tx = db.transaction([MANIFESTS_STORE], 'readonly');
const store = tx.objectStore(MANIFESTS_STORE);
const request = store.getAll();
const manifests = await requestToPromise<IndexedDBVideoCacheManifest[]>(request);
const manifests = await requestToPromise<IndexedDBVideoCacheManifest[]>(
request
);
return manifests.reduce(
(acc, manifest) => ({
@@ -453,7 +512,8 @@ export async function getBrowserStorageEstimate(): Promise<StorageEstimate | nul
function waitForServiceWorkerActivation(
registration: ServiceWorkerRegistration
): Promise<ServiceWorkerRegistration> {
const worker = registration.installing || registration.waiting || registration.active;
const worker =
registration.installing || registration.waiting || registration.active;
if (!worker || worker.state === 'activated') {
return Promise.resolve(registration);
@@ -475,7 +535,9 @@ function waitForServiceWorkerActivation(
});
}
async function waitForServiceWorkerController(timeoutMs = 2500): Promise<boolean> {
async function waitForServiceWorkerController(
timeoutMs = 2500
): Promise<boolean> {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
return false;
}
@@ -484,22 +546,32 @@ async function waitForServiceWorkerController(timeoutMs = 2500): Promise<boolean
return new Promise((resolve) => {
const timer = window.setTimeout(() => {
navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
navigator.serviceWorker.removeEventListener(
'controllerchange',
onControllerChange
);
resolve(Boolean(navigator.serviceWorker.controller));
}, timeoutMs);
const onControllerChange = () => {
window.clearTimeout(timer);
navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
navigator.serviceWorker.removeEventListener(
'controllerchange',
onControllerChange
);
resolve(true);
};
navigator.serviceWorker.addEventListener('controllerchange', onControllerChange);
navigator.serviceWorker.addEventListener(
'controllerchange',
onControllerChange
);
});
}
export async function ensureIndexedDBVideoServiceWorker(): Promise<boolean> {
if (typeof window === 'undefined' || typeof navigator === 'undefined') return false;
if (typeof window === 'undefined' || typeof navigator === 'undefined')
return false;
if (!('serviceWorker' in navigator)) return false;
try {
@@ -511,7 +583,10 @@ export async function ensureIndexedDBVideoServiceWorker(): Promise<boolean> {
await waitForServiceWorkerActivation(registration);
return waitForServiceWorkerController();
} catch (error) {
console.warn('[IndexedDBVideo] Service Worker 不可用,降级为 Blob URL 播放:', error);
console.warn(
'[IndexedDBVideo] Service Worker 不可用,降级为 Blob URL 播放:',
error
);
return false;
}
}
@@ -561,7 +636,10 @@ export async function createIndexedDBVideoBlobPlaybackUrl(
const segment = await getIndexedDBVideoSegment(cacheKey, segmentIndex);
if (!segment?.data) {
objectUrls.forEach((url) => URL.revokeObjectURL(url));
return { hasLocal: false, reason: `缺少 IndexedDB 分片 ${segmentIndex + 1}` };
return {
hasLocal: false,
reason: `缺少 IndexedDB 分片 ${segmentIndex + 1}`,
};
}
const segmentUrl = URL.createObjectURL(segment.data);
@@ -619,7 +697,11 @@ export async function getIndexedDBVideoPlaybackUrl(
options: { preferServiceWorker?: boolean } = {}
): Promise<IndexedDBVideoPlaybackResult> {
try {
const manifest = await getIndexedDBVideoManifestByEpisode(source, videoId, episodeIndex);
const manifest = await getIndexedDBVideoManifestByEpisode(
source,
videoId,
episodeIndex
);
if (!manifest?.completed) {
return { hasLocal: false, reason: '未找到 IndexedDB 本地缓存' };
}