354 lines
12 KiB
TypeScript
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 });
|
|
}
|
|
}
|