This commit is contained in:
Kyush 2026-06-06 18:46:06 +09:00
commit 91a367f7ce
19 changed files with 1659 additions and 2 deletions

26
esbuild.config.mjs Normal file
View file

@ -0,0 +1,26 @@
import * as esbuild from 'esbuild';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const metadata = fs.readFileSync(
path.join(__dirname, 'metadata.user.js'),
'utf8'
);
esbuild.build({
entryPoints: [path.join(__dirname, 'src', 'index.ts')],
outfile: path.join(__dirname, 'dist', 'm3u8-download.user.js'),
bundle: true,
format: 'iife',
target: 'es2018',
banner: {
js: metadata + '\n',
},
sourcemap: false,
minify: false,
logLevel: 'info',
}).catch(() => process.exit(1));

View file

@ -4,10 +4,19 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"build": "node esbuild.config.mjs",
"watch": "node esbuild.config.mjs --watch"
},
"keywords": [],
"author": "",
"license": "MIT",
"packageManager": "pnpm@10.33.0"
"packageManager": "pnpm@10.33.0",
"dependencies": {
"m3u8-parser": "4.4.0"
},
"devDependencies": {
"@types/m3u8-parser": "^7.2.6",
"esbuild": "^0.28.0",
"typescript": "^6.0.3"
}
}

340
pnpm-lock.yaml generated Normal file
View file

@ -0,0 +1,340 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
m3u8-parser:
specifier: 4.4.0
version: 4.4.0
devDependencies:
'@types/m3u8-parser':
specifier: ^7.2.6
version: 7.2.6
esbuild:
specifier: ^0.28.0
version: 0.28.0
typescript:
specifier: ^6.0.3
version: 6.0.3
packages:
'@esbuild/aix-ppc64@0.28.0':
resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.28.0':
resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.28.0':
resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.28.0':
resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.28.0':
resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.28.0':
resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.28.0':
resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.28.0':
resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.28.0':
resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.28.0':
resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.28.0':
resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.28.0':
resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.28.0':
resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.28.0':
resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.28.0':
resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.28.0':
resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.28.0':
resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.28.0':
resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.28.0':
resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.28.0':
resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.28.0':
resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.28.0':
resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.28.0':
resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.28.0':
resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.28.0':
resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.28.0':
resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@types/m3u8-parser@7.2.6':
resolution: {integrity: sha512-hUlKal7ZESLTSJUGruXh2CiPFPl7IPKmZow5yJf2UWoMSMC+TdrHrvkKGIpGowvURZVW33Owz+6WrawiQdeZVQ==}
dom-walk@0.1.2:
resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==}
esbuild@0.28.0:
resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==}
engines: {node: '>=18'}
hasBin: true
global@4.4.0:
resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==}
m3u8-parser@4.4.0:
resolution: {integrity: sha512-iH2AygTFILtato+XAgnoPYzLHM4R3DjATj7Ozbk7EHdB2XoLF2oyOUguM7Kc4UVHbQHHL/QPaw98r7PbWzG0gg==}
min-document@2.19.2:
resolution: {integrity: sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==}
process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'}
hasBin: true
snapshots:
'@esbuild/aix-ppc64@0.28.0':
optional: true
'@esbuild/android-arm64@0.28.0':
optional: true
'@esbuild/android-arm@0.28.0':
optional: true
'@esbuild/android-x64@0.28.0':
optional: true
'@esbuild/darwin-arm64@0.28.0':
optional: true
'@esbuild/darwin-x64@0.28.0':
optional: true
'@esbuild/freebsd-arm64@0.28.0':
optional: true
'@esbuild/freebsd-x64@0.28.0':
optional: true
'@esbuild/linux-arm64@0.28.0':
optional: true
'@esbuild/linux-arm@0.28.0':
optional: true
'@esbuild/linux-ia32@0.28.0':
optional: true
'@esbuild/linux-loong64@0.28.0':
optional: true
'@esbuild/linux-mips64el@0.28.0':
optional: true
'@esbuild/linux-ppc64@0.28.0':
optional: true
'@esbuild/linux-riscv64@0.28.0':
optional: true
'@esbuild/linux-s390x@0.28.0':
optional: true
'@esbuild/linux-x64@0.28.0':
optional: true
'@esbuild/netbsd-arm64@0.28.0':
optional: true
'@esbuild/netbsd-x64@0.28.0':
optional: true
'@esbuild/openbsd-arm64@0.28.0':
optional: true
'@esbuild/openbsd-x64@0.28.0':
optional: true
'@esbuild/openharmony-arm64@0.28.0':
optional: true
'@esbuild/sunos-x64@0.28.0':
optional: true
'@esbuild/win32-arm64@0.28.0':
optional: true
'@esbuild/win32-ia32@0.28.0':
optional: true
'@esbuild/win32-x64@0.28.0':
optional: true
'@types/m3u8-parser@7.2.6': {}
dom-walk@0.1.2: {}
esbuild@0.28.0:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.0
'@esbuild/android-arm': 0.28.0
'@esbuild/android-arm64': 0.28.0
'@esbuild/android-x64': 0.28.0
'@esbuild/darwin-arm64': 0.28.0
'@esbuild/darwin-x64': 0.28.0
'@esbuild/freebsd-arm64': 0.28.0
'@esbuild/freebsd-x64': 0.28.0
'@esbuild/linux-arm': 0.28.0
'@esbuild/linux-arm64': 0.28.0
'@esbuild/linux-ia32': 0.28.0
'@esbuild/linux-loong64': 0.28.0
'@esbuild/linux-mips64el': 0.28.0
'@esbuild/linux-ppc64': 0.28.0
'@esbuild/linux-riscv64': 0.28.0
'@esbuild/linux-s390x': 0.28.0
'@esbuild/linux-x64': 0.28.0
'@esbuild/netbsd-arm64': 0.28.0
'@esbuild/netbsd-x64': 0.28.0
'@esbuild/openbsd-arm64': 0.28.0
'@esbuild/openbsd-x64': 0.28.0
'@esbuild/openharmony-arm64': 0.28.0
'@esbuild/sunos-x64': 0.28.0
'@esbuild/win32-arm64': 0.28.0
'@esbuild/win32-ia32': 0.28.0
'@esbuild/win32-x64': 0.28.0
global@4.4.0:
dependencies:
min-document: 2.19.2
process: 0.11.10
m3u8-parser@4.4.0:
dependencies:
global: 4.4.0
min-document@2.19.2:
dependencies:
dom-walk: 0.1.2
process@0.11.10: {}
typescript@6.0.3: {}

View file

@ -0,0 +1,61 @@
import { log } from '../logger';
function isM3U8Url(url: string): boolean {
const lower = url.toLowerCase();
return lower.includes('.m3u8') || lower.includes('.m3u') ||
lower.includes('application/x-mpegurl') || lower.includes('application/vnd.apple.mpegurl');
}
export function monitorDOM(onDetected: (url: string) => void): void {
const target = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window) as any;
const doc = target.document || document;
if (!doc) return;
const checkSource = (el: HTMLMediaElement) => {
const src = el.currentSrc || el.src || '';
if (src && isM3U8Url(src)) {
log.info(`DOM monitor: found m3u8 in <${el.tagName.toLowerCase()}> src: ${src}`);
onDetected(src);
}
if (el.children) {
for (let i = 0; i < el.children.length; i++) {
const source = el.children[i] as HTMLSourceElement;
const sourceSrc = source.src || '';
if (sourceSrc && isM3U8Url(sourceSrc)) {
log.info(`DOM monitor: found m3u8 in <source> src: ${sourceSrc}`);
onDetected(sourceSrc);
}
}
}
};
doc.addEventListener('canplay', (e: Event) => {
const el = e.target as HTMLMediaElement;
if (el && (el.tagName === 'VIDEO' || el.tagName === 'AUDIO')) {
checkSource(el);
}
}, true);
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) {
const el = node as HTMLElement;
if (el.tagName === 'VIDEO' || el.tagName === 'AUDIO') {
checkSource(el as HTMLMediaElement);
}
if (el.tagName === 'SOURCE' && el.getAttribute('src')) {
const src = el.getAttribute('src') || '';
if (isM3U8Url(src)) {
log.info(`DOM monitor: found m3u8 in <source> tag: ${src}`);
onDetected(src);
}
}
}
}
}
});
observer.observe(doc, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
}

View file

@ -0,0 +1,42 @@
import { log } from '../logger';
const M3U8_PATTERNS = ['.m3u8', '.m3u', 'application/x-mpegurl', 'application/vnd.apple.mpegurl'];
function isM3U8Url(url: string): boolean {
const lower = url.toLowerCase();
return M3U8_PATTERNS.some(p => lower.includes(p));
}
export function interceptFetch(onDetected: (url: string) => void): void {
const target = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window) as any;
const origFetch = target.fetch;
if (!origFetch) return;
target.fetch = function(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const url = typeof input === 'string' ? input : (input as Request).url;
if (isM3U8Url(url)) {
log.info(`Fetch request: ${url}`);
}
return origFetch.apply(this, arguments as [RequestInfo | URL, RequestInit?]).then((response: Response) => {
if (isM3U8Url(url)) {
const contentType = response.headers.get('Content-Type') || '';
log.info(`Fetch response: ${url} status=${response.status} contentType=${contentType}`);
if (response.status >= 200 && response.status < 300) {
const isM3U8Response = isM3U8Url(url) || contentType.toLowerCase().includes('mpegurl');
if (isM3U8Response) {
onDetected(url);
}
}
}
return response;
}).catch((error: unknown) => {
if (isM3U8Url(url)) {
log.error(`Fetch error: ${url} - ${error}`);
}
throw error;
});
};
}

View file

@ -0,0 +1,58 @@
import { log } from '../logger';
const M3U8_PATTERNS = ['.m3u8', '.m3u', 'application/x-mpegurl', 'application/vnd.apple.mpegurl'];
function isM3U8Url(url: string): boolean {
const lower = url.toLowerCase();
return M3U8_PATTERNS.some(p => lower.includes(p));
}
export function interceptXHR(onDetected: (url: string, headers?: Record<string, string>) => void): void {
const target = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window) as any;
const XHR = target.XMLHttpRequest;
if (!XHR) return;
const origOpen = XHR.prototype.open;
const origSend = XHR.prototype.send;
const origSetHeader = XHR.prototype.setRequestHeader;
XHR.prototype.open = function(method: string, url: string, ...rest: unknown[]): void {
const requestUrl = String(url);
const headers: Record<string, string> = {};
if (isM3U8Url(requestUrl)) {
log.info(`XHR open: ${method} ${requestUrl}`);
}
const origSetHeaderFn = origSetHeader.bind(this);
this._capturedHeaders = headers;
this.setRequestHeader = function(header: string, value: string): void {
headers[header] = value;
origSetHeaderFn(header, value);
};
const origSendFn = origSend.bind(this);
this.send = function(body?: unknown): void {
if (isM3U8Url(requestUrl)) {
this.addEventListener('load', function() {
try {
const status = (this as any).status;
const contentType = this.getResponseHeader('Content-Type') || '';
const responseText = this.responseText || '';
log.info(`XHR load: ${requestUrl} status=${status} contentType=${contentType} bodyLen=${responseText.length}`);
if (status >= 200 && status < 300) {
const isM3U8Response = isM3U8Url(requestUrl) || contentType.toLowerCase().includes('mpegurl') || responseText.startsWith('#EXTM3U');
if (isM3U8Response) {
onDetected(requestUrl, Object.keys(headers).length ? headers : undefined);
}
}
} catch (e) {
log.error(`XHR load handler error: ${e}`);
}
});
}
origSendFn(body);
};
return origOpen.call(this, method, url, ...rest);
};
}

94
src/downloader/merge.ts Normal file
View file

@ -0,0 +1,94 @@
import { log } from '../logger';
import { decryptSegment, CryptoKeyData } from '../utils/crypto';
import { SegmentInfo } from '../parser/m3u8-parse';
import { FetchSegmentResult } from './segment-fetch';
import { guessFilename } from '../utils/filename';
export async function mergeAndDownload(
segments: SegmentInfo[],
fetchedResults: FetchSegmentResult[],
m3u8Url: string
): Promise<void> {
const encrypted = segments.some(s => s.key !== null);
log.info(`mergeAndDownload: ${segments.length} segments, encrypted=${encrypted}`);
const chunks: Uint8Array[] = [];
let currentKey: CryptoKeyData | null = null;
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const result = fetchedResults[i];
if (result.data.byteLength === 0) {
log.warn(`mergeAndDownload: skipping empty segment ${i}`);
continue;
}
if (segment.key && segment.key.value) {
currentKey = {
method: segment.key.method,
value: segment.key.value,
iv: segment.key.iv,
};
try {
const decrypted = await decryptSegment(currentKey, result.data);
chunks.push(decrypted);
log.info(`mergeAndDownload: decrypted segment ${i} (${decrypted.length} bytes)`);
} catch (e) {
log.error(`mergeAndDownload: decryption failed for segment ${i}: ${e}`);
chunks.push(new Uint8Array(result.data));
}
} else if (currentKey) {
try {
const decrypted = await decryptSegment(currentKey, result.data);
chunks.push(decrypted);
log.info(`mergeAndDownload: decrypted segment ${i} with previous key (${decrypted.length} bytes)`);
} catch (e) {
log.error(`mergeAndDownload: decryption failed for segment ${i}: ${e}`);
chunks.push(new Uint8Array(result.data));
}
} else {
chunks.push(new Uint8Array(result.data));
}
}
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
const merged = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.length;
}
log.info(`mergeAndDownload: merged ${chunks.length} chunks, total=${totalLength} bytes`);
const ext = segments.length > 0 && segments[0].uri.toLowerCase().includes('.m4s') ? '.mp4' : '.ts';
const filename = guessFilename(m3u8Url) + (guessFilename(m3u8Url).includes('.') ? '' : ext);
const mimeType = ext === '.mp4' ? 'video/mp4' : 'video/mp2t';
const blob = new Blob([merged], { type: mimeType });
downloadBlob(blob, filename);
}
function downloadBlob(blob: Blob, filename: string): void {
log.info(`downloadBlob: ${filename} (${blob.size} bytes)`);
const url = URL.createObjectURL(blob);
const target = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window) as any;
const doc = target.document || document;
const a = doc.createElement('a');
a.href = url;
a.download = filename;
doc.body.appendChild(a);
a.click();
setTimeout(() => {
try {
doc.body.removeChild(a);
URL.revokeObjectURL(url);
} catch { /* ignore */ }
}, 1000);
log.info(`downloadBlob: download triggered`);
}

View file

@ -0,0 +1,125 @@
import { log } from '../logger';
export interface DownloadProgress {
current: number;
total: number;
bytesDownloaded: number;
speed: number;
eta: number;
}
export interface FetchSegmentResult {
data: ArrayBuffer;
bytes: number;
}
function gmFetchBuffer(url: string, referer?: string): Promise<FetchSegmentResult> {
return new Promise((resolve, reject) => {
(GM_xmlhttpRequest as any)({
method: 'GET',
url: url,
responseType: 'arraybuffer',
referer: referer || document.location.href,
headers: {
'Origin': new URL(document.location.href).origin,
},
onload: (response: any) => {
if (response.status >= 200 && response.status < 300) {
const buffer = response.detail?.response || response.response;
resolve({
data: buffer,
bytes: buffer.byteLength,
});
} else {
reject(new Error(`Segment fetch failed: HTTP ${response.status} for ${url}`));
}
},
onerror: (error: any) => {
reject(new Error(`Segment fetch error: ${error.error} for ${url}`));
},
});
});
}
async function fetchWithRetry(url: string, referer?: string, maxRetries = 3): Promise<FetchSegmentResult> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await gmFetchBuffer(url, referer);
} catch (e) {
lastError = e as Error;
log.warn(`fetchWithRetry: attempt ${attempt}/${maxRetries} failed for ${url}: ${e}`);
if (attempt < maxRetries) {
await new Promise(r => setTimeout(r, 500 * attempt));
}
}
}
throw lastError;
}
export async function fetchSegments(
urls: string[],
referer?: string,
onProgress?: (progress: DownloadProgress) => void,
concurrency = 5
): Promise<FetchSegmentResult[]> {
log.info(`fetchSegments: ${urls.length} segments, concurrency=${concurrency}`);
const results: FetchSegmentResult[] = new Array(urls.length);
let current = 0;
let bytesDownloaded = 0;
let startTime = Date.now();
const queue: number[] = [];
for (let i = 0; i < urls.length; i++) {
queue.push(i);
}
const workers = async () => {
while (queue.length > 0) {
const idx = queue.shift()!;
try {
const result = await fetchWithRetry(urls[idx], referer);
results[idx] = result;
bytesDownloaded += result.bytes;
current++;
const elapsed = (Date.now() - startTime) / 1000;
const speed = elapsed > 0 ? bytesDownloaded / elapsed : 0;
const remaining = urls.length - current;
const eta = speed > 0 ? (remaining * (bytesDownloaded / current || 0)) / speed : 0;
if (onProgress) {
onProgress({
current,
total: urls.length,
bytesDownloaded,
speed,
eta,
});
}
log.info(`fetchSegments: segment ${current}/${urls.length} (${result.bytes} bytes)`);
} catch (e) {
log.error(`fetchSegments: failed segment ${idx}: ${e}`);
results[idx] = { data: new ArrayBuffer(0), bytes: 0 };
current++;
if (onProgress) {
onProgress({
current,
total: urls.length,
bytesDownloaded,
speed: 0,
eta: 0,
});
}
}
}
};
const workerCount = Math.min(concurrency, urls.length);
await Promise.all(Array.from({ length: workerCount }, workers));
log.info(`fetchSegments: completed ${urls.length} segments, total=${bytesDownloaded} bytes`);
return results;
}

222
src/index.ts Normal file
View file

@ -0,0 +1,222 @@
import { log } from './logger';
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';
const detectedM3U8s = new Map<string, { url: string; 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 notifyTopFrame(url: string): void {
if (!isTopFrame()) {
try {
if (window.top && (window.top as any)._m3u8Detected) {
(window.top as any)._m3u8Detected(url);
}
} catch (e) {
log.info('notifyTopFrame: cross-origin iframe cannot notify top (expected)');
}
}
}
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 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);
});
}
async function startDownload(url: string): Promise<void> {
if (isDownloading) {
log.warn('startDownload: already downloading');
return;
}
isDownloading = true;
downloadAborted = false;
const entry = detectedM3U8s.get(url);
let parsed = entry?.parsed;
if (!parsed) {
try {
parsed = await parseM3U8(url);
if (entry) entry.parsed = parsed;
} catch (e) {
log.error(`startDownload: parse failed for ${url}: ${e}`);
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);
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;
resetProgress();
try {
const results = await fetchSegments(
segmentUrls,
referrer,
(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();
}
}
function onM3U8Detected(url: string, headers?: Record<string, string>): void {
if (detectedM3U8s.has(url)) return;
log.info(`onM3U8Detected: ${url}`);
detectedM3U8s.set(url, { url, headers });
notifyTopFrame(url);
if (isTopFrame()) {
addStreamToUI(url);
parseM3U8(url, headers?.Referer).then(parsed => {
detectedM3U8s.get(url)!.parsed = parsed;
addStreamToUI(url, parsed);
}).catch(e => {
log.error(`onM3U8Detected: 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(onM3U8Detected);
interceptFetch(onM3U8Detected);
monitorDOM(onM3U8Detected);
if (isTopFrame()) {
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');
});
}
(window as any)._m3u8Detected = onM3U8Detected;
const target = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window) as any;
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) => {
onM3U8Detected(url);
});
interceptFetch.call(null, (url: string) => {
onM3U8Detected(url);
});
}
} catch (e) {
log.info(`iframe observer: cross-origin iframe, skipping`);
}
}
}
}
});
iframeObserver.observe(document, { childList: true, subtree: true });
}
}

24
src/logger.ts Normal file
View file

@ -0,0 +1,24 @@
export const log = (() => {
let targetConsole: Console;
let prefix: string;
try {
const uW = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : null) as any;
targetConsole = uW && uW.console ? uW.console : console;
} catch {
targetConsole = console;
}
try {
const isTop = window.top === window;
prefix = isTop ? '[M3U8-DL]' : `[M3U8-DL:iframe:${location.origin}]`;
} catch {
prefix = '[M3U8-DL]';
}
return {
info: (...args: unknown[]) => targetConsole.log(`${prefix} [INFO]`, ...args),
warn: (...args: unknown[]) => targetConsole.warn(`${prefix} [WARN]`, ...args),
error: (...args: unknown[]) => targetConsole.error(`${prefix} [ERROR]`, ...args),
};
})();

35
src/parser/key-fetch.ts Normal file
View file

@ -0,0 +1,35 @@
import { log } from '../logger';
export async function fetchKey(keyUri: string, referer?: string): Promise<Uint8Array> {
log.info(`fetchKey: ${keyUri}`);
return new Promise((resolve, reject) => {
(GM_xmlhttpRequest as any)({
method: 'GET',
url: keyUri,
responseType: 'arraybuffer',
referer: referer || document.location.href,
headers: {
'Origin': new URL(document.location.href).origin,
},
onload: (response: any) => {
if (response.status >= 200 && response.status < 300) {
const buffer = response.detail?.response || response.response;
const keyBytes = new Uint8Array(buffer);
log.info(`fetchKey: ${keyUri} size=${keyBytes.length} bytes`);
if (keyBytes.length !== 16) {
log.warn(`fetchKey: key is ${keyBytes.length} bytes, expected 16 for AES-128`);
}
resolve(keyBytes);
} else {
reject(new Error(`Key fetch failed: HTTP ${response.status}`));
}
},
onerror: (error: any) => {
reject(new Error(`Key fetch error: ${error.error}`));
},
});
});
}

143
src/parser/m3u8-parse.ts Normal file
View file

@ -0,0 +1,143 @@
import * as m3u8Parser from 'm3u8-parser';
import { log } from '../logger';
import { resolveUri } from '../utils/uri';
import { fetchKey } from './key-fetch';
export interface SegmentInfo {
uri: string;
key: KeyInfo | null;
}
export interface KeyInfo {
method: string;
uri: string;
iv: Uint8Array | null;
value: Uint8Array | null;
}
export interface ParsedPlaylist {
segments: SegmentInfo[];
isMaster: boolean;
masterPlaylists?: MasterPlaylistInfo[];
}
export interface MasterPlaylistInfo {
uri: string;
resolution?: { width: number; height: number };
bandwidth?: number;
name?: string;
}
function gmFetchText(url: string, referer?: string): Promise<string> {
return new Promise((resolve, reject) => {
(GM_xmlhttpRequest as any)({
method: 'GET',
url: url,
responseType: 'text',
referer: referer || document.location.href,
headers: {
'Origin': new URL(document.location.href).origin,
},
onload: (response: any) => {
if (response.status >= 200 && response.status < 300) {
resolve(response.responseText || response.response || '');
} else {
reject(new Error(`Failed to fetch ${url}: HTTP ${response.status}`));
}
},
onerror: (error: any) => {
reject(new Error(`Failed to fetch ${url}: ${error.error}`));
},
});
});
}
export async function parseM3U8(url: string, referer?: string): Promise<ParsedPlaylist> {
log.info(`parseM3U8: ${url}`);
const content = await gmFetchText(url, referer);
if (!content || !content.startsWith('#EXTM3U')) {
throw new Error(`Invalid m3u8 content from ${url}`);
}
const parser = new m3u8Parser.Parser();
parser.push(content);
parser.end();
const manifest = parser.manifest;
if (manifest.playlists && manifest.playlists.length > 0) {
log.info(`parseM3U8: master playlist with ${manifest.playlists.length} streams`);
const masterPlaylists: MasterPlaylistInfo[] = manifest.playlists.map((pl: any) => ({
uri: resolveUri(url, pl.uri),
resolution: pl.attributes.RESOLUTION ? {
width: pl.attributes.RESOLUTION.width,
height: pl.attributes.RESOLUTION.height,
} : undefined,
bandwidth: pl.attributes.BANDWIDTH,
name: pl.attributes.NAME,
}));
return {
segments: [],
isMaster: true,
masterPlaylists,
};
}
const segments = manifest.segments || [];
const keyCache = new Map<string, Uint8Array>();
const resolvedSegments: SegmentInfo[] = [];
const seen = new Set<string>();
for (const segment of segments) {
const uri = resolveUri(url, segment.uri);
if (seen.has(uri)) continue;
seen.add(uri);
let keyInfo: KeyInfo | null = null;
if (segment.key && segment.key.uri) {
const keyUri = resolveUri(url, segment.key.uri);
const method = segment.key.method || 'AES-128';
if (method !== 'AES-128') {
log.warn(`parseM3U8: unsupported encryption method ${method}`);
}
let keyBytes = keyCache.get(keyUri);
if (!keyBytes) {
try {
keyBytes = await fetchKey(keyUri, referer);
keyCache.set(keyUri, keyBytes);
} catch (e) {
log.error(`parseM3U8: failed to fetch key ${keyUri}: ${e}`);
}
}
let iv: Uint8Array | null = null;
if (segment.key.iv) {
iv = new Uint8Array(segment.key.iv);
} else if (segment.key.ivString) {
const hex = segment.key.ivString.replace('0x', '');
iv = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
iv[i / 2] = parseInt(hex.substr(i, 2), 16);
}
}
keyInfo = {
method,
uri: keyUri,
iv,
value: keyBytes,
};
}
resolvedSegments.push({ uri, key: keyInfo });
}
log.info(`parseM3U8: ${resolvedSegments.length} segments, encrypted=${resolvedSegments.some(s => s.key !== null)}`);
return {
segments: resolvedSegments,
isMaster: false,
};
}

233
src/ui/panel.ts Normal file
View file

@ -0,0 +1,233 @@
import { log } from '../logger';
export interface PanelState {
visible: boolean;
}
export function createPanel(): HTMLDivElement {
log.info('createPanel: creating UI panel');
const target = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window) as any;
const doc = target.document || document;
const panel = doc.createElement('div');
panel.id = 'm3u8-dl-panel';
panel.setAttribute('data-m3u8-dl', 'panel');
panel.innerHTML = `
<div id="m3u8-dl-header">
<span id="m3u8-dl-title">M3U8 Downloader</span>
<div id="m3u8-dl-header-actions">
<button id="m3u8-dl-settings">Settings</button>
<button id="m3u8-dl-hide">Hide</button>
</div>
</div>
<div id="m3u8-dl-content">
<div id="m3u8-dl-streams">
<div class="m3u8-dl-empty">No streams detected yet...</div>
</div>
<div id="m3u8-dl-progress" style="display:none;">
<div class="m3u8-dl-progress-header">
<span class="m3u8-dl-progress-title">Downloading...</span>
<button class="m3u8-dl-cancel-btn">Cancel</button>
</div>
<div class="m3u8-dl-progress-bar">
<div class="m3u8-dl-progress-fill"></div>
</div>
<div class="m3u8-dl-progress-info">
<span class="m3u8-dl-progress-percent">0%</span>
<span class="m3u8-dl-progress-count">0/0</span>
<span class="m3u8-dl-progress-speed">0 B/s</span>
<span class="m3u8-dl-progress-eta">ETA: --</span>
</div>
</div>
</div>
`;
const style = doc.createElement('style');
style.textContent = `
#m3u8-dl-panel {
position: fixed;
top: 10px;
right: 10px;
width: 380px;
max-height: 80vh;
background: #1e1e1e;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
z-index: 2147483647;
overflow: hidden;
transition: transform 0.2s ease;
}
#m3u8-dl-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background: #2d2d2d;
border-bottom: 1px solid #3a3a3a;
cursor: move;
}
#m3u8-dl-title {
font-weight: 600;
font-size: 14px;
}
#m3u8-dl-header-actions {
display: flex;
gap: 6px;
}
#m3u8-dl-header-actions button {
background: #3a3a3a;
color: #ccc;
border: 1px solid #4a4a4a;
border-radius: 4px;
padding: 3px 8px;
font-size: 11px;
cursor: pointer;
}
#m3u8-dl-header-actions button:hover {
background: #4a4a4a;
}
#m3u8-dl-content {
padding: 10px 14px;
max-height: calc(80vh - 50px);
overflow-y: auto;
}
.m3u8-dl-empty {
color: #888;
text-align: center;
padding: 20px 0;
}
.m3u8-dl-stream-item {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 6px;
padding: 10px;
margin-bottom: 8px;
}
.m3u8-dl-stream-url {
color: #4fc3f7;
word-break: break-all;
font-size: 12px;
margin-bottom: 6px;
}
.m3u8-dl-stream-meta {
color: #999;
font-size: 11px;
margin-bottom: 8px;
}
.m3u8-dl-stream-actions {
display: flex;
gap: 6px;
}
.m3u8-dl-stream-actions button {
background: #3a3a3a;
color: #ccc;
border: 1px solid #4a4a4a;
border-radius: 4px;
padding: 4px 12px;
font-size: 12px;
cursor: pointer;
}
.m3u8-dl-stream-actions button:hover {
background: #4a4a4a;
}
.m3u8-dl-stream-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.m3u8-dl-stream-actions button.m3u8-dl-download-btn {
background: #2e7d32;
border-color: #388e3c;
color: #fff;
}
.m3u8-dl-stream-actions button.m3u8-dl-download-btn:hover {
background: #388e3c;
}
#m3u8-dl-progress {
margin-top: 10px;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 6px;
padding: 10px;
}
.m3u8-dl-progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.m3u8-dl-progress-title {
font-weight: 600;
}
.m3u8-dl-cancel-btn {
background: #c62828;
color: #fff;
border: none;
border-radius: 4px;
padding: 3px 10px;
font-size: 11px;
cursor: pointer;
}
.m3u8-dl-progress-bar {
height: 8px;
background: #3a3a3a;
border-radius: 4px;
overflow: hidden;
margin-bottom: 6px;
}
.m3u8-dl-progress-fill {
height: 100%;
background: #4caf50;
border-radius: 4px;
transition: width 0.2s ease;
width: 0%;
}
.m3u8-dl-progress-info {
display: flex;
gap: 12px;
font-size: 11px;
color: #999;
}
`;
doc.head.appendChild(style);
doc.body.appendChild(panel);
let isDragging = false;
let dragOffsetX = 0;
let dragOffsetY = 0;
const header = panel.querySelector('#m3u8-dl-header') as HTMLElement;
header.addEventListener('mousedown', (e) => {
if ((e.target as HTMLElement).tagName === 'BUTTON') return;
isDragging = true;
const rect = panel.getBoundingClientRect();
dragOffsetX = e.clientX - rect.left;
dragOffsetY = e.clientY - rect.top;
e.preventDefault();
});
doc.addEventListener('mousemove', (e) => {
if (!isDragging) return;
panel.style.position = 'fixed';
panel.style.left = (e.clientX - dragOffsetX) + 'px';
panel.style.top = (e.clientY - dragOffsetY) + 'px';
panel.style.right = 'auto';
});
doc.addEventListener('mouseup', () => {
isDragging = false;
});
const hideBtn = panel.querySelector('#m3u8-dl-hide') as HTMLButtonElement;
hideBtn.addEventListener('click', () => {
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
hideBtn.textContent = panel.style.display === 'none' ? 'Show' : 'Hide';
});
log.info('createPanel: panel created and attached');
return panel;
}

55
src/ui/progress.ts Normal file
View file

@ -0,0 +1,55 @@
import { DownloadProgress } from '../downloader/segment-fetch';
function formatBytes(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function formatEta(seconds: number): string {
if (seconds <= 0 || !isFinite(seconds)) return '--';
if (seconds < 60) return Math.ceil(seconds) + 's';
return Math.floor(seconds / 60) + 'm ' + Math.ceil(seconds % 60) + 's';
}
export function showStatus(progress: DownloadProgress): void {
const progressBar = document.querySelector('#m3u8-dl-progress') as HTMLElement;
if (!progressBar) return;
progressBar.style.display = 'block';
const percent = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0;
const fill = progressBar.querySelector('.m3u8-dl-progress-fill') as HTMLElement;
const percentEl = progressBar.querySelector('.m3u8-dl-progress-percent') as HTMLElement;
const countEl = progressBar.querySelector('.m3u8-dl-progress-count') as HTMLElement;
const speedEl = progressBar.querySelector('.m3u8-dl-progress-speed') as HTMLElement;
const etaEl = progressBar.querySelector('.m3u8-dl-progress-eta') as HTMLElement;
if (fill) fill.style.width = percent + '%';
if (percentEl) percentEl.textContent = percent + '%';
if (countEl) countEl.textContent = `${progress.current}/${progress.total}`;
if (speedEl) speedEl.textContent = formatBytes(progress.speed) + '/s';
if (etaEl) etaEl.textContent = 'ETA: ' + formatEta(progress.eta);
}
export function hideProgress(): void {
const progressBar = document.querySelector('#m3u8-dl-progress') as HTMLElement;
if (progressBar) progressBar.style.display = 'none';
}
export function resetProgress(): void {
const progressBar = document.querySelector('#m3u8-dl-progress') as HTMLElement;
if (!progressBar) return;
const fill = progressBar.querySelector('.m3u8-dl-progress-fill') as HTMLElement;
const percentEl = progressBar.querySelector('.m3u8-dl-progress-percent') as HTMLElement;
const countEl = progressBar.querySelector('.m3u8-dl-progress-count') as HTMLElement;
const speedEl = progressBar.querySelector('.m3u8-dl-progress-speed') as HTMLElement;
const etaEl = progressBar.querySelector('.m3u8-dl-progress-eta') as HTMLElement;
if (fill) fill.style.width = '0%';
if (percentEl) percentEl.textContent = '0%';
if (countEl) countEl.textContent = '0/0';
if (speedEl) speedEl.textContent = '0 B/s';
if (etaEl) etaEl.textContent = 'ETA: --';
}

91
src/ui/selector.ts Normal file
View file

@ -0,0 +1,91 @@
import { log } from '../logger';
import { MasterPlaylistInfo } from '../parser/m3u8-parse';
export function showPlaylistSelector(playlists: MasterPlaylistInfo[]): Promise<string> {
log.info(`showPlaylistSelector: ${playlists.length} playlists available`);
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.id = 'm3u8-dl-selector-overlay';
overlay.style.cssText = `
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 2147483646;
display: flex;
align-items: center;
justify-content: center;
`;
const modal = document.createElement('div');
modal.style.cssText = `
background: #1e1e1e;
color: #e0e0e0;
border-radius: 10px;
padding: 20px;
min-width: 320px;
max-width: 500px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
box-shadow: 0 8px 30px rgba(0,0,0,0.6);
`;
modal.innerHTML = `
<h3 style="margin:0 0 16px; font-size:16px;">Select Quality</h3>
<div id="m3u8-dl-playlist-list"></div>
`;
const list = modal.querySelector('#m3u8-dl-playlist-list') as HTMLElement;
for (const pl of playlists) {
const item = document.createElement('div');
item.style.cssText = `
padding: 10px 12px;
margin-bottom: 6px;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
`;
item.onmouseenter = () => { item.style.background = '#3a3a3a'; };
item.onmouseleave = () => { item.style.background = '#2a2a2a'; };
let label = '';
if (pl.resolution) {
label = `${pl.resolution.width}x${pl.resolution.height}`;
}
if (pl.bandwidth) {
const kbps = Math.round(pl.bandwidth / 1000);
label += label ? ` | ${kbps} kbps` : `${kbps} kbps`;
}
if (pl.name) {
label = pl.name + (label ? ` (${label})` : '');
}
if (!label) {
label = pl.uri.substring(pl.uri.lastIndexOf('/') + 1, pl.uri.lastIndexOf('.')) || 'Unknown';
}
item.innerHTML = `
<div style="font-weight:600; margin-bottom:4px;">${label}</div>
<div style="font-size:11px; color:#888; word-break:break-all;">${pl.uri}</div>
`;
item.addEventListener('click', () => {
document.body.removeChild(overlay);
resolve(pl.uri);
});
list.appendChild(item);
}
overlay.appendChild(modal);
document.body.appendChild(overlay);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
document.body.removeChild(overlay);
resolve('');
}
});
});
}

28
src/utils/crypto.ts Normal file
View file

@ -0,0 +1,28 @@
export interface CryptoKeyData {
value: Uint8Array;
iv: Uint8Array | null;
method: string;
}
export async function decryptSegment(
keyData: CryptoKeyData,
data: ArrayBuffer
): Promise<Uint8Array> {
const iv = keyData.iv ? keyData.iv : new Uint8Array(16);
const importedKey = await crypto.subtle.importKey(
'raw',
keyData.value,
{ name: 'AES-CBC', length: 128 },
false,
['decrypt']
);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-CBC', iv: iv.buffer },
importedKey,
data
);
return new Uint8Array(decrypted);
}

31
src/utils/filename.ts Normal file
View file

@ -0,0 +1,31 @@
export function guessFilename(url: string, mime?: string): string {
try {
const urlObj = new URL(url);
let name = urlObj.pathname.replace(/[^\/\\]+$/, '') || '/';
name = name.split('/').filter(Boolean).pop() || 'stream';
if (name.includes('.')) {
return name;
}
const ext = mime ? mimeToExt(mime) : '.mp4';
return name + ext;
} catch {
return 'download' + (mime === 'video/mp4' || mime?.includes('mp4') ? '.mp4' : '.ts');
}
}
function mimeToExt(mime: string): string {
switch (mime) {
case 'video/mp4':
case 'application/mp4':
return '.mp4';
case 'video/MP2T':
case 'video/mp2t':
return '.ts';
case 'audio/mp4':
return '.m4a';
default:
return '.ts';
}
}

20
src/utils/uri.ts Normal file
View file

@ -0,0 +1,20 @@
export function resolveUri(root: string, rel: string): string {
if (rel.startsWith('http://') || rel.startsWith('https://') || rel.startsWith('//')) {
return rel;
}
try {
return new URL(rel, root).href;
} catch {
let parts = root.split('/');
const relParts = rel.split('/').filter(Boolean);
const idx = parts.indexOf(relParts[0]);
if (idx === -1) {
parts.pop();
} else {
parts = parts.slice(0, idx);
}
parts.push(rel);
return parts.join('/');
}
}

20
tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2018",
"module": "ESNext",
"lib": ["ES2018", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}