m3u8-monkey-script/src/index.ts

354 lines
12 KiB
TypeScript

import { log } from './logger';
import { normalizeReferer } from './utils/uri';
import { interceptXHR } from './detection/xhr-intercept';
import { interceptFetch } from './detection/fetch-intercept';
import { monitorDOM } from './detection/dom-monitor';
import { parseM3U8, ParsedPlaylist, SegmentInfo } from './parser/m3u8-parse';
import { fetchSegments } from './downloader/segment-fetch';
import { mergeAndDownload } from './downloader/merge';
import { createPanel } from './ui/panel';
import { showStatus, hideProgress, resetProgress } from './ui/progress';
import { showPlaylistSelector } from './ui/selector';
import { DownloadProgress } from './downloader/segment-fetch';
import { guessFilename } from './utils/filename';
import { vttToSrt } from './utils/subtitle';
const detectedStreams = new Map<string, { url: string; type: 'm3u8' | 'vtt'; headers?: Record<string, string>; parsed?: ParsedPlaylist }>();
let panel: HTMLDivElement | null = null;
let isDownloading = false;
let downloadAborted = false;
function isTopFrame(): boolean {
try {
return window.top === window;
} catch {
return true;
}
}
function isVttUrl(url: string): boolean {
return url.toLowerCase().includes('.vtt');
}
function notifyTopFrame(url: string, headers?: Record<string, string>): void {
if (!isTopFrame()) {
try {
const referer = headers?.Referer || location.href;
const normalizedReferer = normalizeReferer(url, referer);
window.top.postMessage({
__m3u8dl: true,
url,
referer: normalizedReferer,
origin: location.origin,
}, '*');
log.info(`notifyTopFrame: sent via postMessage: ${url} referer=${normalizedReferer}`);
} catch (e) {
log.warn(`notifyTopFrame: postMessage failed: ${e}`);
}
}
}
function addStreamToUI(url: string, parsed?: ParsedPlaylist): void {
if (!panel) return;
const streamsContainer = panel.querySelector('#m3u8-dl-streams') as HTMLElement;
if (!streamsContainer) return;
const existing = streamsContainer.querySelector(`[data-url="${url}"]`);
if (existing) return;
const emptyMsg = streamsContainer.querySelector('.m3u8-dl-empty');
if (emptyMsg) emptyMsg.remove();
const item = document.createElement('div');
item.className = 'm3u8-dl-stream-item';
item.setAttribute('data-url', url);
const entry = detectedStreams.get(url);
const isVtt = entry?.type === 'vtt';
if (isVtt) {
item.innerHTML = `
<div class="m3u8-dl-stream-url">${url}</div>
<div class="m3u8-dl-stream-meta">Subtitle (VTT)</div>
<div class="m3u8-dl-stream-actions">
<button class="m3u8-dl-download-btn" data-url="${url}">Download</button>
</div>
`;
} else {
const segmentCount = parsed && !parsed.isMaster ? parsed.segments.length : '--';
const encrypted = parsed && !parsed.isMaster ? parsed.segments.some(s => s.key !== null) : 'unknown';
item.innerHTML = `
<div class="m3u8-dl-stream-url">${url}</div>
<div class="m3u8-dl-stream-meta">Segments: ${segmentCount} | Encrypted: ${encrypted ? 'AES-128' : 'No'}</div>
<div class="m3u8-dl-stream-actions">
<button class="m3u8-dl-download-btn" data-url="${url}">Download</button>
</div>
`;
}
streamsContainer.appendChild(item);
const downloadBtn = item.querySelector('.m3u8-dl-download-btn') as HTMLButtonElement;
downloadBtn.addEventListener('click', async () => {
if (isDownloading) return;
await startDownload(url);
});
}
function downloadSubtitle(blob: Blob, url: string): void {
const baseName = guessFilename(url, 'text/vtt');
const filename = baseName.replace(/\.(mp4|ts|m4a)$/, '.srt');
log.info(`downloadSubtitle: ${filename} (${blob.size} bytes)`);
const urlObj = URL.createObjectURL(blob);
const target = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window) as any;
const doc = target.document || document;
const a = doc.createElement('a');
a.href = urlObj;
a.download = filename;
doc.body.appendChild(a);
a.click();
setTimeout(() => {
try {
doc.body.removeChild(a);
URL.revokeObjectURL(urlObj);
} catch { /* ignore */ }
}, 1000);
}
async function startDownload(url: string): Promise<void> {
if (isDownloading) {
log.warn('startDownload: already downloading');
return;
}
isDownloading = true;
downloadAborted = false;
const entry = detectedStreams.get(url);
log.info(`startDownload: ${url} type=${entry?.type} entryHeaders=${JSON.stringify(entry?.headers)}`);
if (entry?.type === 'vtt') {
await downloadVttStream(url, entry);
return;
}
let parsed = entry?.parsed;
if (!parsed) {
try {
parsed = await parseM3U8(url, entry?.headers?.Referer, entry?.headers?.Origin);
if (entry) entry.parsed = parsed;
} catch (e) {
log.error(`startDownload: parse failed for ${url}: ${e}`);
isDownloading = false;
return;
}
}
let targetUrl = url;
if (parsed.isMaster && parsed.masterPlaylists) {
const selected = await showPlaylistSelector(parsed.masterPlaylists);
if (!selected) {
log.info('startDownload: user cancelled playlist selection');
isDownloading = false;
return;
}
targetUrl = selected;
try {
parsed = await parseM3U8(targetUrl, entry?.headers?.Referer, entry?.headers?.Origin);
if (entry) entry.parsed = parsed;
} catch (e) {
log.error(`startDownload: parse selected playlist failed: ${e}`);
isDownloading = false;
return;
}
}
if (!parsed.segments || parsed.segments.length === 0) {
log.error('startDownload: no segments found');
isDownloading = false;
return;
}
const segmentUrls = parsed.segments.map(s => s.uri);
const referrer = entry?.headers?.Referer || document.location.href;
const requestOrigin = entry?.headers?.Origin;
resetProgress();
try {
const results = await fetchSegments(
segmentUrls,
referrer,
requestOrigin,
(progress: DownloadProgress) => {
if (downloadAborted) return;
showStatus(progress);
},
5
);
if (downloadAborted) {
log.info('startDownload: download was aborted');
isDownloading = false;
hideProgress();
return;
}
await mergeAndDownload(parsed.segments, results, targetUrl);
} catch (e) {
log.error(`startDownload: download failed: ${e}`);
} finally {
isDownloading = false;
hideProgress();
}
}
async function downloadVttStream(url: string, entry: { url: string; type: 'vtt'; headers?: Record<string, string> }): Promise<void> {
log.info(`downloadVttStream: ${url}`);
isDownloading = true;
try {
return new Promise<void>((resolve, reject) => {
(GM_xmlhttpRequest as any)({
method: 'GET',
url: url,
responseType: 'text',
headers: {
'Referer': entry.headers?.Referer || document.location.href,
'Origin': entry.headers?.Origin || new URL(document.location.href).origin,
},
onload: (response: any) => {
if (response.status >= 200 && response.status < 300) {
const vttText = response.responseText || response.response || '';
const srtText = vttToSrt(vttText);
const blob = new Blob([srtText], { type: 'text/plain' });
downloadSubtitle(blob, url);
resolve();
} else {
log.error(`downloadVttStream: HTTP ${response.status} for ${url}`);
reject(new Error(`Subtitle download failed: HTTP ${response.status}`));
}
},
onerror: (error: any) => {
log.error(`downloadVttStream: error for ${url}: ${error.error}`);
reject(new Error(`Subtitle download error: ${error.error}`));
},
});
});
} finally {
isDownloading = false;
}
}
function onStreamDetected(url: string, headers?: Record<string, string>): void {
if (detectedStreams.has(url)) return;
const type = isVttUrl(url) ? 'vtt' : 'm3u8';
log.info(`onStreamDetected: ${url} type=${type} headers=${JSON.stringify(headers)}`);
detectedStreams.set(url, { url, type, headers });
notifyTopFrame(url, headers);
if (isTopFrame() && !panel && type === 'm3u8') {
panel = createPanel();
const cancelBtn = panel.querySelector('.m3u8-dl-cancel-btn') as HTMLButtonElement;
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
downloadAborted = true;
log.info('Download cancelled by user');
});
}
for (const entry of detectedStreams.values()) {
addStreamToUI(entry.url, entry.parsed);
}
}
if (isTopFrame() && panel) {
addStreamToUI(url);
}
if (isTopFrame() && type === 'm3u8') {
parseM3U8(url, headers?.Referer, headers?.Origin).then(parsed => {
detectedStreams.get(url)!.parsed = parsed;
if (panel) {
addStreamToUI(url, parsed);
}
}).catch(e => {
log.error(`onStreamDetected: pre-parse failed for ${url}: ${e}`);
});
}
}
if (typeof window === 'undefined') {
log.error('Script not running in a browser context');
} else {
log.info(`Script initialized in ${isTopFrame() ? 'top frame' : `iframe (${location.origin})`}`);
interceptXHR(onStreamDetected);
interceptFetch(onStreamDetected);
monitorDOM(onStreamDetected);
if (isTopFrame()) {
window.addEventListener('message', (e: MessageEvent) => {
if (e.data && typeof e.data === 'object' && e.data.__m3u8dl === true && e.data.url) {
const referer = typeof e.data.referer === 'string' ? e.data.referer : '';
const origin = typeof e.data.origin === 'string' ? e.data.origin : '';
const headers: Record<string, string> = {};
if (referer) headers['Referer'] = referer;
if (origin) headers['Origin'] = origin;
onStreamDetected(e.data.url as string, Object.keys(headers).length ? headers : undefined);
}
});
GM_registerMenuCommand('M3U8 Downloader', () => {
if (panel) {
const isVisible = panel.style.display !== 'none';
panel.style.display = isVisible ? 'none' : 'block';
const hideBtn = panel.querySelector('#m3u8-dl-hide') as HTMLButtonElement;
if (hideBtn) hideBtn.textContent = isVisible ? 'Show' : 'Hide';
return;
}
panel = createPanel();
const cancelBtn = panel.querySelector('.m3u8-dl-cancel-btn') as HTMLButtonElement;
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
downloadAborted = true;
log.info('Download cancelled by user');
});
}
for (const entry of detectedStreams.values()) {
addStreamToUI(entry.url, entry.parsed);
}
});
const iframeObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1 && (node as HTMLElement).tagName === 'IFRAME') {
const iframe = node as HTMLIFrameElement;
try {
const iframeWin = iframe.contentWindow;
if (iframeWin && iframeWin.location.origin === window.location.origin) {
log.info(`iframe observer: injecting hooks into same-origin iframe`);
interceptXHR.call(null, (url: string) => {
onStreamDetected(url);
});
interceptFetch.call(null, (url: string) => {
onStreamDetected(url);
});
}
} catch (e) {
log.info(`iframe observer: cross-origin iframe, skipping`);
}
}
}
}
});
iframeObserver.observe(document, { childList: true, subtree: true });
}
}