628 lines
27 KiB
Markdown
628 lines
27 KiB
Markdown
# m3u8-download.user.js 구현 계획
|
|
|
|
## 1. 목표
|
|
|
|
Turbo Download Manager(v3.m3) 확장 프로그램의 m3u8 탐지·병합 로직을 참고하여, Tampermonkey/Violentmonkey 등에서 실행 가능한 UserScript를 구현한다.
|
|
|
|
- **개발 시:** `src/` 폴더 아래 모듈화된 구조로 작성하여 유지보수성 확보
|
|
- **빌드 시:** esbuild로 단일 파일로 번들링 후, 파일 상단에 UserScript 메타데이터 주입
|
|
- **출력:** `dist/m3u8-download.user.js` (minify 안 된 가독성 있는 번들 파일)
|
|
|
|
---
|
|
|
|
## 2. 참고 소스 분석 요약
|
|
|
|
### 2.1 Turbo Download Manager의 m3u8 처리 흐름
|
|
|
|
| 단계 | 파일 | 설명 |
|
|
|------|------|------|
|
|
| **탐지** | `worker.js` (webRequest API) + `inject.js` (content script) | `chrome.webRequest.onBeforeRequest`로 `.m3u8` XHR/미디어 요청 가로챔. `<video>/<audio>`의 `canplay` 이벤트로도 수집 |
|
|
| **파싱** | `data/add/index.js` + `data/add/m3u8-parser.js` | `m3u8Parser.Parser`로 playlist 파싱. 마스터 플레이리스트는 해상도 선택 후 재파싱. `.ts` 세그먼트 URI 추출 + 상대 경로 절대화 |
|
|
| **키 획득** | `data/add/index.js` | `#EXT-X-KEY` 지시문에서 key URI를 fetch. AES-128 검증 (16바이트). IV 처리. 중복 키 캐싱 |
|
|
| **다운로드** | `downloads/downloads.js` + `downloads/get.js` | 세그먼트를 순차적으로 다운로드. Multi-thread Range 기반 (`SGet` → `MGet` → `MSGet` → `FGet` → `NFGet` → `SNGet` 클래스 계층) |
|
|
| **저장** | `downloads/file.js` | IndexedDB에 chunk 저장. `disk-write-offset`로 각 세그먼트의 바이트 위치 추적 |
|
|
| **복호화** | `downloads/file.js` | `crypto.subtle` AES-CBC로 각 세그먼트 복호화 |
|
|
| **병합** | `downloads/file.js` | `ReadableStream` + `Response` → `Blob` → `URL.createObjectURL` → `chrome.downloads.download()` |
|
|
|
|
### 2.2 핵심 로직 추출
|
|
|
|
**m3u8 파싱 (`data/add/index.js:112-242`):**
|
|
```
|
|
1. fetch(m3u8_url) → text
|
|
2. m3u8Parser.Parser.push(content), parser.end()
|
|
3. parser.manifest.playlists 존재? → 마스터 플레이리스트 → 해상도 선택 → 재귀 파싱
|
|
4. parser.manifest.segments 존재? → 미디어 플레이리스트 → segment.uri 배열 추출
|
|
5. 상대 URI 절대화: path(root, rel) 함수
|
|
6. 중복 segment 제거
|
|
7. 각 segment의 key.uri fetch → ArrayBuffer → AES-128 검증 → 캐싱
|
|
```
|
|
|
|
**세그먼트 다운로드·병합 (`downloads/downloads.js:48-272` + `downloads/file.js`):**
|
|
```
|
|
1. 세그먼트 URL 배열 순차 반복
|
|
2. 각 세그먼트 fetch → ArrayBuffer → IndexedDB에 offset 저장
|
|
3. 마지막 세그먼트 완료 시:
|
|
- File.stream() → ReadableStream 생성
|
|
- 암호화 있으면: 각 세그먼트 분할 → crypto.subtle.decrypt(AES-CBC)
|
|
- Response(blob) → URL.createObjectURL → 다운로드
|
|
```
|
|
|
|
---
|
|
|
|
## 3. UserScript 구현 아키텍처
|
|
|
|
### 3.1 제약 조건 (확장 프로그램 vs UserScript)
|
|
|
|
| 기능 | 확장 프로그램 | UserScript (Tampermonkey) | 대응 방안 |
|
|
|------|-------------|--------------------------|-----------|
|
|
| 네트워크 요청 가로채기 | `chrome.webRequest` API | ❌ 사용 불가 | `XMLHttpRequest`/`fetch` 오버라이드 + MutationObserver |
|
|
| m3u8 플레이리스트 fetch | 동일 도메인 fetch | CORS 제한 가능 | `GM_xmlhttpRequest` 사용 |
|
|
| 암호화 키 fetch | 동일 도메인 fetch | CORS 제한 가능 | `GM_xmlhttpRequest` 사용 |
|
|
| 세그먼트 다운로드 | `fetch` + Range headers | CORS 제한 가능 | `GM_xmlhttpRequest` 사용 |
|
|
| 대용량 저장 | IndexedDB | 메모리 한계 | 메모리 기반 (Uint8Array). 매우 큰 파일은 경고 |
|
|
| 파일 저장 | `chrome.downloads.download()` | ❌ 사용 불가 | `<a>` 요소로 Blob URL 다운로드 |
|
|
| 백그라운드 처리 | Service Worker + Offscreen Doc | ❌ 사용 불가 | 메인 스레드 + async/await |
|
|
| Referrer 주입 | `chrome.declarativeNetRequest` | ❌ 사용 불가 | `GM_xmlhttpRequest`의 `referer` 옵션 |
|
|
| iframe 내부 요청 탐지 | `content_scripts` + `all_frames: true` | `@all-frames true` 필요 | iframe별 독립 후킹 + `unsafeWindow` 처리 |
|
|
| 크로스 오리진 iframe | `webRequest`로 전역 가로챔 | ❌ `unsafeWindow` 접근 불가 | 크로스 오리진 iframe은 탐지 불가 (브라우저 보안 정책) |
|
|
|
|
### 3.2 프로젝트 디렉토리 구조
|
|
|
|
```
|
|
m3u8-monkey-script/
|
|
├── package.json
|
|
├── esbuild.config.mjs # esbuild 빌드 설정
|
|
├── metadata.user.js # UserScript 메타데이터 블록 (banner로 주입)
|
|
├── src/
|
|
│ ├── index.ts # 진입점: 초기화, 모듈 조립
|
|
│ ├── logger.ts # 로깅 유틸리티 (unsafeWindow.console 기반)
|
|
│ ├── utils/
|
|
│ │ ├── uri.ts # resolveUri(root, rel) 등 URI 유틸
|
|
│ │ ├── crypto.ts # decryptSegment(key, data) AES-CBC 복호화
|
|
│ │ └── filename.ts # guessFilename(url, mime) 파일명 추출
|
|
│ ├── detection/
|
|
│ │ ├── xhr-intercept.ts # XMLHttpRequest.open/send 패치
|
|
│ │ ├── fetch-intercept.ts # fetch() 패치
|
|
│ │ └── dom-monitor.ts # <video>/<audio> source 추적
|
|
│ ├── parser/
|
|
│ │ ├── m3u8-parse.ts # parseM3U8(url), resolveMasterPlaylist
|
|
│ │ └── key-fetch.ts # 암호화 키 fetch + 검증 + 캐싱
|
|
│ ├── downloader/
|
|
│ │ ├── segment-fetch.ts # fetchSegments(...) 세그먼트 다운로드
|
|
│ │ └── merge.ts # mergeAndDownload(...) 병합 + 다운로드
|
|
│ └── ui/
|
|
│ ├── panel.ts # createPanel() 페이지 상단 UI 패널
|
|
│ ├── progress.ts # showStatus(...) 진행률 표시
|
|
│ └── selector.ts # showPlaylistSelector(...) 해상도 선택
|
|
├── dist/
|
|
│ └── m3u8-download.user.js # 빌드 결과 (banner + 번들 코드)
|
|
└── plans/
|
|
└── implementation-plan.md
|
|
```
|
|
|
|
**빌드 흐름:**
|
|
1. `src/index.ts`를 entry point로 esbuild 실행
|
|
2. `--bundle`로 모든 모듈 + 의존성(m3u8-parser)를 단일 파일로 번들
|
|
3. `--banner`로 `metadata.user.js`의 메타데이터 블록을 파일 상단에 주입
|
|
4. `--format=iife`로 Tampermonkey 호환 IIFE 생성
|
|
5. minify 미적용 (`--minify` 생략)하여 가독성 유지
|
|
6. 결과물 `dist/m3u8-download.user.js` 출력
|
|
|
|
### 3.3 파일 크기 예상
|
|
|
|
| 구성 요소 | 예상 크기 |
|
|
|-----------|-----------|
|
|
| UserScript header (banner) | ~1KB |
|
|
| m3u8-parser (bundled) | ~30-40KB |
|
|
| 탐지 모듈 | ~5KB |
|
|
| 파싱/다운로드/병합 로직 | ~10KB |
|
|
| UI 모듈 | ~5KB |
|
|
| 로깅 유틸리티 | ~1KB |
|
|
| **총계 (dist)** | **~52-62KB** |
|
|
|
|
### 3.4 로깅 전략
|
|
|
|
Tampermonkey는 `@grant`가 선언되면 스크립트를 **샌드박스**에서 실행합니다. 샌드박스 내의 `console.log`는 페이지의 DevTools Console에 표시되지 않으므로, 디버깅이 거의 불가능합니다. 로컬 테스트로는 샌드박스 격리, iframe 컨텍스트 분리, CORS 등 실제 런타임 환경에서 발생하는 문제를 파악할 수 없으므로, **모든 로직에 페이지 메인 콘솔을 통한 로그**가 포함되어야 합니다.
|
|
|
|
**핵심 규칙:**
|
|
- 샌드박스 컨텍스트에서는 `unsafeWindow.console`을 사용하여 로그 출력
|
|
- `@all-frames true`로 iframe에서도 실행되므로, iframe 로그는 `[iframe]` 접두사로 구분
|
|
- 각 Phase의 주요 진입점, 분기점, 에러 발생 시 로그 필수
|
|
- 로그 레벨(INFO, WARN, ERROR) 구분 + `[M3U8-DL]` 접두사로 필터링 용이
|
|
|
|
```javascript
|
|
// 로깅 유틸리티
|
|
const log = (() => {
|
|
const targetConsole = unsafeWindow && unsafeWindow.console ? unsafeWindow.console : console;
|
|
const prefix = window.top === window ? '[M3U8-DL]' : `[M3U8-DL:iframe:${location.origin}]`;
|
|
return {
|
|
info: (...args) => targetConsole.log(`${prefix} [INFO]`, ...args),
|
|
warn: (...args) => targetConsole.warn(`${prefix} [WARN]`, ...args),
|
|
error: (...args) => targetConsole.error(`${prefix} [ERROR]`, ...args),
|
|
};
|
|
})();
|
|
```
|
|
|
|
**로그 필수 위치:**
|
|
|
|
| 모듈 | 로그 위치 | 내용 |
|
|
|------|----------|------|
|
|
| 초기화 | 스크립트 진입 시 | 실행 프레임 정보, `unsafeWindow` 가용성 |
|
|
| XHR 후킹 | `open` 호출 시 (m3u8 URL일 때) | URL, method |
|
|
| XHR 후킹 | `load` 이벤트 시 (m3u8 응답) | status, Content-Type, body 길이 |
|
|
| fetch 후킹 | 호출 시 (m3u8 URL일 때) | URL, init.headers |
|
|
| fetch 후킹 | 응답 시 (m3u8 Content-Type) | URL, Content-Type |
|
|
| 파싱 시작 | `parseM3U8()` 진입 | m3u8 URL |
|
|
| 파싱 완료 | segment/key 추출 후 | 세그먼트 수, 암호화 여부 |
|
|
| 마스터 플레이리스트 | 해상도 선택 UI 표시 시 | 이용 가능한 스트림 목록 |
|
|
| 키 fetch | 각 키 요청 전/후 | key URI, 크기, 검증 결과 |
|
|
| 세그먼트 다운로드 | 각 세그먼트 시작/완료 | 인덱스, URL, 크기, 진행률 |
|
|
| 세그먼트 다운로드 | 에러 발생 시 | URL, 에러 메시지, 재시도 횟수 |
|
|
| 복호화 | 각 세그먼트 복호화 전/후 | 인덱스, 키 정보, 결과 크기 |
|
|
| 병합 | 최종 Blob 생성 시 | 총 크기, 세그먼트 수 |
|
|
| 다운로드 | 파일 저장 시작/완료 | 파일명, 크기 |
|
|
| 에러 | 모든 `catch` 블록 | 에러 메시지, 스택, 현재 상태 |
|
|
|
|
---
|
|
|
|
## 4. 상세 구현 계획
|
|
|
|
### Phase 1: m3u8 탐지
|
|
|
|
**목표:** 페이지 및 모든 iframe에서 m3u8 URL을 자동으로 탐지
|
|
|
|
#### 1.1 iframe 컨텍스트 문제
|
|
|
|
각 iframe은 독립된 `window`를 가지므로, 메인 페이지에 후킹을 걸어도 iframe 내부의 `fetch`/XHR 요청은 잡히지 않습니다. Tampermonkey에서 iframe까지 스크립트를 주입하려면 다음이 필요합니다:
|
|
|
|
```javascript
|
|
// @match *://*/*
|
|
// @run-at document-start
|
|
// @all-frames true
|
|
```
|
|
|
|
**주의사항:**
|
|
- `@grant none` 사용 시: 스크립트가 페이지 컨텍스트에서 직접 실행 → `window`가 곧 실제 페이지 window → 후킹이 바로 적용됨
|
|
- `@grant`를 다른 값(GM_xmlhttpRequest 등) 사용 시: 스크립트가 샌드박스에 격리 → `unsafeWindow`를 후킹 대상으로 사용해야 함
|
|
- 크로스 오리진 iframe(`src`가 다른 도메인): `unsafeWindow` 접근 불가 → 탐지 불가 (브라우저 보안 정책)
|
|
- `about:blank` / `srcdoc` / blob URL iframe: `@match`로 안 잡힘 → 메인 프레임에서 MutationObserver로 iframe 생성 감지 후 스크립트 인젝션 필요
|
|
|
|
#### 1.2 구현 방식 (3가지 병행)
|
|
|
|
1. **XMLHttpRequest 오버라이드** (주력)
|
|
- `window.XMLHttpRequest.prototype.open` / `send` 패치
|
|
- URL에 `.m3u8` 포함 또는 `application/x-mpegURL` Content-Type 응답 시 감지
|
|
- `setRequestHeader`도 패칭하여 요청 헤더 수집 (Referer 등)
|
|
- 탐지된 URL을 Set에 수집 (중복 제거)
|
|
- **iframe 대응:** `@all-frames true`로 각 iframe 컨텍스트에도 동일 후킹 적용
|
|
- `@grant`가 `none`이 아닌 경우 `unsafeWindow.XMLHttpRequest.prototype` 대상
|
|
|
|
2. **fetch 오버라이드** (보조)
|
|
- `window.fetch` 패치
|
|
- 요청 URL 또는 응답 Content-Type으로 m3u8 감지
|
|
- `response.clone()`으로 원본 소비 방지
|
|
- **iframe 대응:** `@all-frames true`로 각 iframe 컨텍스트에도 동일 후킹 적용
|
|
- `@grant`가 `none`이 아닌 경우 `unsafeWindow.fetch` 대상
|
|
|
|
3. **DOM 모니터링** (보조)
|
|
- `<video>`, `<audio>` 요소의 `canplay` 이벤트 리스너
|
|
- `MediaSource` API 사용 시 `sourcebuffer` 추가 모니터링 (옵션)
|
|
- `<source>` 태그의 `src` 속성 확인
|
|
- **iframe 대응:** `@all-frames true`로 각 iframe의 DOM도 모니터링
|
|
|
|
#### 1.3 동적 iframe 대응 (MutationObserver)
|
|
|
|
`about:blank`, `srcdoc`, blob URL 등 `@match`로 잡히지 않는 동적 iframe은 메인 프레임에서 감지:
|
|
|
|
```javascript
|
|
// 메인 프레임에서만 실행 (window.top === window)
|
|
const iframeObserver = new MutationObserver(mutations => {
|
|
for (const m of mutations) {
|
|
for (const node of m.addedNodes) {
|
|
if (node.tagName === 'IFRAME') {
|
|
// iframe이 same-origin일 경우 contentWindow에 후킹 스크립트 인젝션
|
|
try {
|
|
const iframeWin = node.contentWindow;
|
|
if (iframeWin && iframeWin.location.origin === window.location.origin) {
|
|
injectHooks(iframeWin);
|
|
}
|
|
} catch (e) {
|
|
// 크로스 오리진 iframe — 접근 불가, 무시
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
iframeObserver.observe(document, { childList: true, subtree: true });
|
|
```
|
|
|
|
#### 1.4 헤더 수집 한계
|
|
|
|
- `fetch`의 `init.headers`로 명시적 헤더만 가시적
|
|
- 브라우저가 자동 추가하는 기본 헤더(User-Agent, Referer, Cookie 등)는 JS 레벨에서 보이지 않음
|
|
- 실제 전송 헤더까지 확인하려면 확장 프로그램(`webRequest.onBeforeSendHeaders`) 또는 외부 도구(mitmproxy) 필요
|
|
- **대응:** `GM_xmlhttpRequest`의 `referer` 옵션으로 수동 전달. XHR 후킹 시 `setRequestHeader`를 통해 명시된 헤더 수집
|
|
|
|
#### 1.5 수집된 URL 처리
|
|
|
|
- `detectedM3U8s: Set<string>`에 저장 (메인 프레임 기준)
|
|
- iframe에서 탐지된 URL은 `window.postMessage` 또는 `top`을 통해 메인 프레임으로 전달
|
|
- 중복 URL은 무시
|
|
- 탐지 시 UI 패널에 표시 (탐지된 프레임 정보 포함)
|
|
|
|
### Phase 2: m3u8 파싱
|
|
|
|
**목표:** m3u8 플레이리스트를 파싱하여 세그먼트 목록과 암호화 키 추출
|
|
|
|
**흐름:**
|
|
```
|
|
parseM3U8(m3u8Url)
|
|
│
|
|
├─ GM_xmlhttpRequest로 m3u8Url fetch (text)
|
|
│
|
|
├─ m3u8Parser.Parser 생성
|
|
│ parser.push(content)
|
|
│ parser.end()
|
|
│
|
|
├─ parser.manifest.playlists 존재?
|
|
│ ├─ Yes → 마스터 플레이리스트
|
|
│ │ 각 playlist의 RESOLUTION 속성 추출
|
|
│ │ 사용자에게 해상도 선택 UI 표시
|
|
│ │ 선택된 playlist의 URI를 재귀 파싱
|
|
│ │
|
|
│ └─ No → 미디어 플레이리스트 진행
|
|
│
|
|
└─ parser.manifest.segments 존재?
|
|
├─ segment.uri 배열 추출
|
|
├─ 상대 URI 절대화 (resolveUri)
|
|
├─ 중복 세그먼트 제거
|
|
│
|
|
└─ 각 segment.key 처리:
|
|
├─ key.uri 존재?
|
|
│ ├─ Yes → GM_xmlhttpRequest로 key fetch (ArrayBuffer)
|
|
│ │ key.value = Uint8Array(buffer)
|
|
│ │ key.length === 16 검증
|
|
│ │ key.method === 'AES-128' 검증
|
|
│ │ key.iv 처리 (없으면 0-filled 16바이트)
|
|
│ │ 중복 key 캐싱 (uri 기반 Map)
|
|
│ │
|
|
│ └─ No → key 없음 (암호화 미사용)
|
|
│
|
|
└─ 결과: { segments: string[], keys: Key[] } 반환
|
|
```
|
|
|
|
**resolveUri 함수:**
|
|
```javascript
|
|
function resolveUri(root, rel) {
|
|
if (rel.startsWith('http://') || rel.startsWith('https://')) return rel;
|
|
const 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('/');
|
|
}
|
|
```
|
|
|
|
### Phase 3: 세그먼트 다운로드
|
|
|
|
**목표:** 모든 .ts 세그먼트를 순차적으로 다운로드
|
|
|
|
**핵심:** `GM_xmlhttpRequest`를 사용하여 CORS 제한 우회
|
|
|
|
```
|
|
fetchSegments(segments, referrer)
|
|
│
|
|
├─ progress = { current: 0, total: segments.length }
|
|
│
|
|
├─ for each segmentUrl in segments:
|
|
│ │
|
|
│ ├─ GM_xmlhttpRequest({
|
|
│ │ method: 'GET',
|
|
│ │ url: segmentUrl,
|
|
│ │ responseType: 'arraybuffer',
|
|
│ │ referer: referrer,
|
|
│ │ onload: (response) => {
|
|
│ │ buffers.push(response.detail?.response || response.response)
|
|
│ │ progress.current++
|
|
│ │ updateUI(progress)
|
|
│ │ }
|
|
│ │ })
|
|
│ │
|
|
│ └─ await 완료
|
|
│
|
|
└─ 반환: ArrayBuffer[] (세그먼트별 데이터)
|
|
```
|
|
|
|
**성능 고려사항:**
|
|
- 세그먼트가 많을 경우 (100+), 병렬 다운로드 고려 (동시 3-5개)
|
|
- Promise pool 패턴으로 동시 요청 수 제한
|
|
- 진행률 UI实时更新
|
|
|
|
### Phase 4: 복호화 및 병합
|
|
|
|
**목표:** 다운로드된 세그먼트를 복호화하여 단일 Blob으로 병합
|
|
|
|
```
|
|
mergeAndDownload(buffers, keys, filename)
|
|
│
|
|
├─ encrypted = keys.length > 0
|
|
│
|
|
├─ if encrypted:
|
|
│ │
|
|
│ ├─ segmentOffsets 계산:
|
|
│ │ offsets[0] = 0
|
|
│ │ offsets[i] = offsets[i-1] + buffers[i-1].byteLength
|
|
│ │
|
|
│ ├─ decryptedChunks = []
|
|
│ ├─ for i = 0 to buffers.length - 1:
|
|
│ │ │
|
|
│ │ ├─ key = keys[i] 또는 가장 최근 key
|
|
│ │ │ (m3u8에서 key는 여러 세그먼트에 공유됨)
|
|
│ │ │
|
|
│ │ ├─ importedKey = await crypto.subtle.importKey(
|
|
│ │ │ 'raw',
|
|
│ │ │ key.value,
|
|
│ │ │ { name: 'AES-CBC', length: 128 },
|
|
│ │ │ false,
|
|
│ │ │ ['decrypt']
|
|
│ │ │ )
|
|
│ │ │
|
|
│ │ ├─ iv = key.iv ? new Uint8Array(key.iv).buffer
|
|
│ │ │ : new ArrayBuffer(16)
|
|
│ │ │
|
|
│ │ ├─ decrypted = await crypto.subtle.decrypt(
|
|
│ │ │ { name: 'AES-CBC', iv },
|
|
│ │ │ importedKey,
|
|
│ │ │ buffers[i]
|
|
│ │ │ )
|
|
│ │ │
|
|
│ │ └─ decryptedChunks.push(new Uint8Array(decrypted))
|
|
│ │
|
|
│ └─ chunks = decryptedChunks
|
|
│
|
|
├─ else:
|
|
│ └─ chunks = buffers.map(b => new Uint8Array(b))
|
|
│
|
|
├─ totalLength = chunks.reduce((sum, c) => sum + c.length, 0)
|
|
│
|
|
├─ merged = new Uint8Array(totalLength)
|
|
├─ offset = 0
|
|
├─ for chunk of chunks:
|
|
│ merged.set(chunk, offset)
|
|
│ offset += chunk.length
|
|
│
|
|
├─ blob = new Blob([merged], { type: 'video/mp2t' })
|
|
│
|
|
└─ downloadBlob(blob, filename)
|
|
```
|
|
|
|
**downloadBlob 함수:**
|
|
```javascript
|
|
function downloadBlob(blob, filename) {
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename || 'download.ts';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
setTimeout(() => {
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}, 1000);
|
|
}
|
|
```
|
|
|
|
### Phase 5: UI
|
|
|
|
**목표:** 사용자에게 탐지된 m3u8 스트림과 다운로드 진행 상황을 표시
|
|
|
|
**UI 구성:**
|
|
```
|
|
┌─────────────────────────────────────────────┐
|
|
│ 🍵 M3U8 Downloader [설정] [숨기기] │
|
|
├─────────────────────────────────────────────┤
|
|
│ 탐지된 스트림: │
|
|
│ ┌─────────────────────────────────────────┐│
|
|
│ │ 🔗 https://example.com/playlist.m3u8 ││
|
|
│ │ 세그먼트: 120 | 암호화: AES-128 ││
|
|
│ │ [다운로드] [취소] ││
|
|
│ └─────────────────────────────────────────┘│
|
|
│ │
|
|
│ 진행 상황: │
|
|
│ [████████████████░░░░░░░░] 65% (78/120) │
|
|
│ 속도: 2.4 MB/s | 남은 시간: 12초 │
|
|
└─────────────────────────────────────────────┘
|
|
```
|
|
|
|
**기능:**
|
|
- 탐지된 m3u8 URL 목록 표시
|
|
- 각 스트림의 세그먼트 수, 암호화 여부 표시
|
|
- 다운로드 버튼 클릭 시 처리 시작
|
|
- 진행률 바 + 속도 + 남은 시간 표시
|
|
- 마스터 플레이리스트일 경우 해상도 선택 모달
|
|
- 다운로드 완료 시 알림
|
|
- 패널 숨기기/보이기 토글
|
|
|
|
### Phase 6: Referrer 처리
|
|
|
|
**목표:** 암호화 키와 세그먼트 fetch 시 올바른 Referrer 헤더 전달
|
|
|
|
```javascript
|
|
GM_xmlhttpRequest({
|
|
method: 'GET',
|
|
url: targetUrl,
|
|
responseType: 'arraybuffer',
|
|
referer: document.location.href, // 현재 페이지 URL
|
|
headers: {
|
|
'Origin': new URL(document.location.href).origin
|
|
},
|
|
onload: callback
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 5. m3u8-parser 통합
|
|
|
|
### 5.1 라이브러리 선택
|
|
|
|
- **videojs/m3u8-parser** (v4.4.0)
|
|
- Turbo Download Manager에서 사용 중인 동일한 버전
|
|
- Apache-2.0 라이선스
|
|
- npm 패키지로 설치하여 번들러에 위임
|
|
|
|
### 5.2 통합 방식
|
|
|
|
npm 의존성으로 설치하면 esbuild가 자동으로 번들에 포함:
|
|
|
|
```bash
|
|
pnpm add m3u8-parser@4.4.0
|
|
```
|
|
|
|
`src/parser/m3u8-parse.ts`에서 import:
|
|
```typescript
|
|
import * as m3u8Parser from 'm3u8-parser';
|
|
|
|
function parseM3U8(url: string) {
|
|
const parser = new m3u8Parser.Parser();
|
|
// ...
|
|
}
|
|
```
|
|
|
|
esbuild가 번들링 시 m3u8-parser의 모든 의존성(mp4box 등)을 자동으로 포함하여 단일 파일로 생성.
|
|
|
|
---
|
|
|
|
## 6. 구현 단계 (작업 순서)
|
|
|
|
| 순서 | 작업 | 의존성 |
|
|
|------|------|--------|
|
|
| **1** | UserScript header 작성 + 프로젝트 구조 설정 | - |
|
|
| **2** | m3u8-parser 인라인 삽입 + 동작 확인 | 1 |
|
|
| **2.1** | 로깅 유틸리티 구현 (`unsafeWindow.console` 기반) | 1 |
|
|
| **3** | m3u8 탐지 모듈 (XHR/fetch 오버라이드 + DOM 모니터링) | 2 |
|
|
| **3.1** | iframe 대응: `@all-frames` + `unsafeWindow` 후킹 + 동적 iframe MutationObserver | 3 |
|
|
| **4** | m3u8 파싱 모듈 (parseM3U8, resolveUri, 마스터 플레이리스트 처리) | 2, 3 |
|
|
| **5** | 암호화 키 fetch + 검증 모듈 | 4 |
|
|
| **6** | 세그먼트 다운로드 모듈 (GM_xmlhttpRequest, 병렬 처리) | 4 |
|
|
| **7** | 복호화 + 병합 모듈 (AES-CBC, Blob 생성) | 5, 6 |
|
|
| **8** | UI 패널 (탐지 목록, 진행률, 해상도 선택) | 3-7 |
|
|
| **9** | Referrer 처리 + 에러 처리 +边界 케이스 | 3-8 |
|
|
| **10** | 테스트 + 디버깅 + 최적화 | 1-9 |
|
|
|
|
---
|
|
|
|
## 7. 에러 처리 및边界 케이스
|
|
|
|
| 상황 | 대응 |
|
|
|------|------|
|
|
| CORS로 인해 m3u8 fetch 실패 | `GM_xmlhttpRequest`로 재시도 |
|
|
| 마스터 플레이리스트 없이 segment 없음 | 사용자에게 오류 메시지 |
|
|
| AES-128以外的 암호화 방식 | 경고 + 다운로드 불가 메시지 |
|
|
| 키 fetch 실패 | 해당 세그먼트 스킵 또는 전체 실패 |
|
|
| 세그먼트 다운로드 실패 | 재시도 (최대 3회) 후 스킵 |
|
|
| 복호화 실패 (IV 불일치 등) | 오류 로그 + 해당 세그먼트 스킵 |
|
|
| 세그먼트 수非常多 (1000+) | 메모리 경고 + 병렬 다운로드 조정 |
|
|
| DASH (.mpd) 플레이리스트 | 미지원 메시지 (별도 구현 필요) |
|
|
| fMP4 세그먼트 (.m4s) | 확장자 `.mp4`로 저장 제안 |
|
|
| 크로스 오리진 iframe | `unsafeWindow` 접근 불가 → 탐지 불가 (브라우저 보안 정책) |
|
|
| `about:blank` / `srcdoc` iframe | MutationObserver로 감지 후 same-origin일 때만 후킹 인젝션 |
|
|
| `unsafeWindow`가 정의되지 않음 | Tampermonkey 버전 확인. GM 샌드박스 정책 변경 가능성 |
|
|
| iframe에서 탐지된 URL 전달 실패 | `window.postMessage` 또는 `top.detectedM3U8s` 직접 접근 |
|
|
|
|
---
|
|
|
|
## 8. iframe 대응 전략 (보강)
|
|
|
|
### 8.1 `@all-frames true`의 동작
|
|
|
|
Tampermonkey는 `@all-frames true` 설정 시, `@match` 패턴과 일치하는 **모든 iframe**에도 스크립트를 주입합니다. 각 iframe은 독립된 실행 컨텍스트를 가지므로, 후킹 코드도 각 iframe에서 개별적으로 실행됩니다.
|
|
|
|
### 8.2 `unsafeWindow`를 통한 후킹
|
|
|
|
`@grant GM_xmlhttpRequest`를 사용하면 스크립트는 샌드박스에서 실행됩니다. 이 경우 `window`는 Tampermonkey의 샌드박스 window이며, 실제 페이지의 window는 `unsafeWindow`를 통해 접근합니다:
|
|
|
|
```javascript
|
|
(function() {
|
|
const target = unsafeWindow || window;
|
|
const origFetch = target.fetch;
|
|
target.fetch = function(...args) {
|
|
// 후킹 로직
|
|
return origFetch.apply(this, args);
|
|
};
|
|
})();
|
|
```
|
|
|
|
### 8.3 탐지된 URL의 중앙 집중
|
|
|
|
각 iframe에서 탐지된 m3u8 URL은 메인 프레임(UI 패널이 있는 곳)으로 전달해야 합니다:
|
|
|
|
```javascript
|
|
// 각 프레임에서 탐지 시
|
|
function onM3U8Detected(url) {
|
|
if (window.top !== window) {
|
|
// iframe 내부: 메인 프레임으로 전달
|
|
try {
|
|
if (window.top._m3u8Detected) {
|
|
window.top._m3u8Detected(url);
|
|
}
|
|
} catch (e) {
|
|
// 크로스 오리진 — 무시
|
|
}
|
|
} else {
|
|
// 메인 프레임: 직접 처리
|
|
addDetectedUrl(url);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 8.4 UI 패널 위치
|
|
|
|
UI 패널은 **메인 프레임**(`window.top === window`)에서만 생성됩니다. iframe에서는 UI를 생성하지 않고 탐지된 URL만 메인 프레임으로 전달합니다.
|
|
|
|
---
|
|
|
|
## 9. GM 메타데이터
|
|
|
|
```javascript
|
|
// ==UserScript==
|
|
// @name M3U8 HLS Downloader
|
|
// @namespace https://github.com/.../m3u8-monkey-script
|
|
// @version 1.0.0
|
|
// @description 자동 탐지 및 다운로드: HLS(m3u8) 스트림의 세그먼트를 병합하여 단일 파일로 저장
|
|
// @author ...
|
|
// @match *://*/*
|
|
// @grant GM_xmlhttpRequest
|
|
// @grant unsafeWindow
|
|
// @run-at document-start
|
|
// @all-frames true
|
|
// ==/UserScript==
|
|
```
|
|
|
|
**필수 GM API:**
|
|
- `GM_xmlhttpRequest`: CORS 우회 네트워크 요청 (m3u8 fetch, 키 fetch, 세그먼트 다운로드)
|
|
- `unsafeWindow`: 샌드박스 격리 시 실제 페이지 window에 접근 (후킹 대상)
|
|
|
|
**핵심 설정:**
|
|
- `@run-at document-start`: 페이지 로드 초기에 인터셉트 설치. 플레이어 초기화 전에 후킹이 걸려야 첫 m3u8 요청까지 탐지
|
|
- `@match *://*/*`: 모든 페이지에서 작동
|
|
- `@all-frames true`: 모든 iframe에도 스크립트 주입. 각 iframe은 독립된 `window`를 가지므로 개별 후킹 필요
|
|
- `@grant unsafeWindow`: `GM_xmlhttpRequest` 사용 시 스크립트가 샌드박스에 격리되므로, `window` 대신 `unsafeWindow`를 후킹 대상으로 사용해야 함
|
|
|
|
**`@grant` 전략:**
|
|
- `@grant none`일 경우: 스크립트가 페이지 컨텍스트에서 직접 실행 → `window`가 곧 실제 페이지 window → 후킹이 바로 적용됨. 하지만 `GM_xmlhttpRequest` 사용 불가
|
|
- `@grant GM_xmlhttpRequest`일 경우: 샌드박스 격리 → `unsafeWindow`를 통해 실제 페이지의 `window.fetch`, `XMLHttpRequest`를 후킹해야 함
|
|
- **결정:** `GM_xmlhttpRequest`가 필수이므로 `unsafeWindow` 함께 선언. 후킹 시 `unsafeWindow` 대상
|
|
|
|
---
|
|
|
|
## 10. 출력 파일
|
|
|
|
- **파일명:** `m3u8-download.user.js`
|
|
- **위치:** 프로젝트 루트 (`C:\Users\SemteulGaram\Sync\Workspace\m3u8-monkey-script\m3u8-download.user.js`)
|
|
- **형식:** 단일 파일, Self-contained (m3u8-parser 인라인 포함)
|