Files
2026-06-21 10:59:05 +08:00

253 lines
7.1 KiB
JavaScript

/* MoonTVPlus Web Push handlers */
self.addEventListener('install', (event) => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('push', (event) => {
if (!event.data) return;
let payload = {};
try {
payload = event.data.json();
} catch (error) {
payload = { title: 'MoonTVPlus', body: event.data.text() };
}
const title = payload.title || 'MoonTVPlus';
const options = {
body: payload.body || payload.message || '',
icon: '/icons/icon-192x192.png',
badge: '/icons/icon-192x192.png',
tag: payload.notificationId || undefined,
data: {
url: payload.url || '/',
notificationId: payload.notificationId,
},
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const targetUrl = new URL(event.notification.data?.url || '/', self.location.origin).href;
event.waitUntil((async () => {
const windowClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
for (const client of windowClients) {
if ('focus' in client) {
await client.focus();
if ('navigate' in client) {
return client.navigate(targetUrl);
}
return;
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(targetUrl);
}
})());
});
/* 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 });
})
);
});