wip
This commit is contained in:
parent
a492fc6887
commit
91a367f7ce
19 changed files with 1659 additions and 2 deletions
26
esbuild.config.mjs
Normal file
26
esbuild.config.mjs
Normal 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));
|
||||
13
package.json
13
package.json
|
|
@ -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
340
pnpm-lock.yaml
generated
Normal 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: {}
|
||||
61
src/detection/dom-monitor.ts
Normal file
61
src/detection/dom-monitor.ts
Normal 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'] });
|
||||
}
|
||||
42
src/detection/fetch-intercept.ts
Normal file
42
src/detection/fetch-intercept.ts
Normal 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;
|
||||
});
|
||||
};
|
||||
}
|
||||
58
src/detection/xhr-intercept.ts
Normal file
58
src/detection/xhr-intercept.ts
Normal 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
94
src/downloader/merge.ts
Normal 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`);
|
||||
}
|
||||
125
src/downloader/segment-fetch.ts
Normal file
125
src/downloader/segment-fetch.ts
Normal 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
222
src/index.ts
Normal 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
24
src/logger.ts
Normal 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
35
src/parser/key-fetch.ts
Normal 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
143
src/parser/m3u8-parse.ts
Normal 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
233
src/ui/panel.ts
Normal 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
55
src/ui/progress.ts
Normal 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
91
src/ui/selector.ts
Normal 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
28
src/utils/crypto.ts
Normal 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
31
src/utils/filename.ts
Normal 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
20
src/utils/uri.ts
Normal 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
20
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue