Files
chzzk-bypass/timemachine.js
Claude (chzzk-bypass owner) 9a07bf87a7 v1.1.3: livePlaybackJson 내부 meta/live 도 패치 (cosmetic UI only)
발견: live-detail 의 outer boolean (timeMachineActive, timeMachinePlayback)
두 개를 true 로 만들어도 재생바가 안 뜸. 외부 자료
(jaesung9507/nvver playback.go) 확인 결과, 플레이어는 livePlaybackJson 내부의
meta.liveRewind / meta.duration / live.timeMachine 을 보고 UI 를 결정함.
이 필드들은 omitempty 라서 DVR ON 일 때만 응답에 존재.

수정:
- patchLiveDetailData 에서 livePlaybackJson 을 파싱해 meta.liveRewind=true,
  meta.duration 보강 (1h 기본), live.timeMachine=true 주입 후 재직렬화.
- 깨진 live-playback-json 외부 호출은 이전 커밋에서 이미 제거됨.

근본적 한계 (README 에 명시):
- 이건 cosmetic 패치다. CDN 의 DVR window 는 스트리머가 타임머신을 켰을 때만
  서버가 프로비저닝하므로, 스트리머가 꺼둔 라이브는 HLS 매니페스트에 과거
  segment 자체가 없음. 재생바가 떠도 실제 seek 은 동작 안 할 가능성 높음.
- live-playback-json 엔드포인트가 timeMachine-off 채널에서 404 인 것도 같은
  이유. Streamlink/ChzzkDownloader 도 timeMachineActive 일 때만 부름.
- 진짜 force-timemachine 은 서버 사이드(CDN) 의 결정이라 클라이언트에서
  근본적으로 우회 불가.

manifest 버전 1.1.2 → 1.1.3, README 한계 명시.
2026-05-28 14:33:14 +09:00

277 lines
12 KiB
JavaScript

// 치지직 라이브 응답을 가로채 타임머신 기능을 강제로 활성화한다.
// 스트리머가 타임머신을 꺼둔 방송에서도 플레이어에 되감기(seek) UI 를 표시하고,
// 가능하면 DVR 매니페스트(URL) 까지 함께 갈아끼워 실제 되감기도 동작하도록 만든다.
//
// 동작 원리
// - 치지직 웹 플레이어는 `api.chzzk.naver.com/service/v3.2/channels/{id}/live-detail`
// 응답의 `content.timeMachineActive` / `content.timeMachinePlayback` 플래그 두 개로
// 되감기 UI 노출 여부를 결정한다.
// - 같은 채널의 `live-playback-json` 엔드포인트는 DVR 가능한 HLS 매니페스트 URL 을
// 항상 돌려준다. ChzzkDownloader 의 `--stream force-timemachine` 옵션이 쓰는
// 바로 그 엔드포인트다.
// - 따라서 `live-detail` 응답을 가로채서 (1) 두 플래그를 강제로 true 로 만들고,
// (2) `livePlaybackJson` 안의 매니페스트 URL 을 `live-playback-json` 응답으로
// 교체하면, 스트리머 설정과 무관하게 되감기 UI 와 실제 seek 동작 모두 살아난다.
//
// 치지직 React 앱은 axios 기반이라 실제 요청은 `XMLHttpRequest` 로 나가고, 일부 경로는
// `fetch` 도 쓴다. 그래서 두 경로 모두 후킹한다.
// manifest 의 `world: "MAIN"`, `run_at: "document_start"` 덕분에 페이지 자체 스크립트가
// 실행되기 전에 후킹할 수 있다.
(function () {
'use strict';
const TAG = '[chzzk-bypass:timemachine]';
// 치지직은 `service/v3.2/...` 처럼 minor 가 붙은 버전을 쓴다. 점(.) 포함 허용.
const LIVE_DETAIL_RE = /^https:\/\/api\.chzzk\.naver\.com\/service\/v[\d.]+\/channels\/([^\/?#]+)\/live-detail/;
// localStorage 'chzzk-bypass:debug' = '1' 로 켜면 응답 본문을 콘솔에 덤프해 디버깅한다.
// (기본은 off. 일반 사용자 콘솔이 더러워지지 않도록.)
function isDebug() {
try { return localStorage.getItem('chzzk-bypass:debug') === '1'; } catch (_) { return false; }
}
function log() {
try {
// eslint-disable-next-line no-console
console.log.apply(console, [TAG].concat(Array.prototype.slice.call(arguments)));
} catch (_) {}
}
// 원본 fetch 를 캡처. 이후 후킹된 fetch 가 재귀 호출되지 않도록 내부 호출도 이걸 쓴다.
const originalFetch = window.fetch.bind(window);
function urlOf(input) {
try {
if (typeof input === 'string') return input;
if (!input) return '';
// Request 객체
if (typeof input.url === 'string') return input.url;
// URL 객체
if (typeof input.href === 'string') return input.href;
return String(input);
} catch (_) { return ''; }
}
// 별도의 live-playback-json 엔드포인트 fetch 는 이전 버전에서 시도했지만
// 모든 버전이 CORS+404 로 막혀 더 이상 호출하지 않는다. (실측 확인)
// 매니페스트 URL 갈아끼우기는 별도 메커니즘이 필요하며, 현재 조사 중.
// 파싱된 live-detail JSON 객체를 in-place 로 패치. 변경 여부 반환.
async function patchLiveDetailData(data, channelIdFromUrl) {
const content = data && data.content;
if (!content) return false;
// 치지직 live-detail 응답에는 두 개의 별개 플래그가 존재한다.
// - timeMachineActive : 채널/방송 단위 타임머신 활성 여부
// - timeMachinePlayback : 플레이어가 실제 되감기 UI 를 켤지 결정하는 플래그
// 둘 중 어느 하나라도 false 면 UI 가 안 뜨므로 둘 다 true 로 만든다.
// (참고: github.com/jaesung9507/nvver chzzk/live.go LiveDetail 구조체)
const alreadyOn = content.timeMachineActive === true && content.timeMachinePlayback === true;
const channelId = (content.channel && content.channel.channelId) || channelIdFromUrl;
if (alreadyOn) {
log('timeMachine already active for', channelId, '— passthrough');
return false;
}
log('forcing timeMachine ON for', channelId);
content.timeMachineActive = true;
content.timeMachinePlayback = true;
// 내부 livePlaybackJson 의 meta/live 도 같이 패치한다. 플레이어는 외부 boolean
// 두 개만 보는 게 아니라, livePlaybackJson 을 파싱해 meta.liveRewind / meta.duration /
// live.timeMachine 을 확인해서 seekbar UI 여부를 결정한다.
// (참고: jaesung9507/nvver playback.go 의 Meta/Live 구조체. liveRewind/duration 은
// omitempty 라서 DVR ON 일 때만 존재함)
//
// 한계: 이건 어디까지나 UI 만 켜는 cosmetic 패치다. CDN 의 DVR window 는 스트리머가
// 타임머신을 켰을 때만 서버가 프로비저닝하므로, 실제 segment 가 과거 시점에 존재하지
// 않아 seek 자체는 동작하지 않을 수 있다 (재생바가 떠도 누르면 live edge 로 튕김).
try {
const raw = content.livePlaybackJson;
const pb = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (pb && typeof pb === 'object') {
pb.meta = pb.meta || {};
pb.meta.liveRewind = true;
if (typeof pb.meta.duration !== 'number' || pb.meta.duration <= 0) {
pb.meta.duration = 3600; // 1h. 진짜 길이는 CDN 이 정함.
}
pb.live = pb.live || {};
pb.live.timeMachine = true;
content.livePlaybackJson = typeof raw === 'string' ? JSON.stringify(pb) : pb;
log('inner livePlaybackJson meta/live patched (liveRewind, duration, timeMachine)');
}
} catch (e) {
log('inner livePlaybackJson patch failed', e);
}
// 진단용: live-detail content 와 livePlaybackJson 디코딩 결과를 통째로 덤프.
// 플레이어가 어느 필드를 보는지 모를 때 켜서 들여다보는 용도.
// 활성화: devtools 콘솔에서 `localStorage.setItem('chzzk-bypass:debug','1')` 후 새로고침.
if (isDebug()) {
try {
const dump = JSON.parse(JSON.stringify(content));
if (typeof dump.livePlaybackJson === 'string') {
try { dump.__livePlaybackJsonParsed = JSON.parse(dump.livePlaybackJson); } catch (_) {}
}
log('DEBUG live-detail content after patch:', dump);
} catch (e) { log('debug dump failed', e); }
}
return true;
}
// -------- fetch hook --------
window.fetch = async function patchedFetch(input, init) {
const url = urlOf(input);
const match = url && url.match(LIVE_DETAIL_RE);
if (!match) return originalFetch(input, init);
const response = await originalFetch(input, init);
try {
const data = await response.clone().json();
const changed = await patchLiveDetailData(data, match[1]);
if (!changed) return response;
const newBody = JSON.stringify(data);
const headers = new Headers(response.headers);
headers.set('content-length', String(new Blob([newBody]).size));
return new Response(newBody, {
status: response.status,
statusText: response.statusText,
headers,
});
} catch (e) {
log('fetch patch failed, returning original', e);
return response;
}
};
// -------- XHR hook --------
// 치지직 React 앱은 axios 를 쓰므로 live-detail 은 XHR 로 나간다. fetch 만 후킹하면
// 아무 일도 일어나지 않는다 (실제 콘솔 로그에서 확인).
//
// 전략: live-detail URL 이면 원본 XHR.send 는 호출하지 않고, 내부적으로 fetch 로 같은
// 요청을 직접 날려 응답을 받아 패치한 뒤, defineProperty 로 XHR 의 응답 관련 속성을
// 덮어쓰고 readystatechange/load/loadend 이벤트를 합성 발화한다.
const XHRProto = XMLHttpRequest.prototype;
const origOpen = XHRProto.open;
const origSend = XHRProto.send;
const origSetRequestHeader = XHRProto.setRequestHeader;
XHRProto.open = function patchedOpen(method, url) {
try {
this.__cb_url = typeof url === 'string' ? url : (url && url.toString ? url.toString() : '');
this.__cb_method = method;
this.__cb_headers = {};
this.__cb_intercept = false;
if (this.__cb_url) {
const m = this.__cb_url.match(LIVE_DETAIL_RE);
if (m) {
this.__cb_intercept = true;
this.__cb_channel = m[1];
}
}
} catch (_) {}
return origOpen.apply(this, arguments);
};
XHRProto.setRequestHeader = function patchedSetRequestHeader(name, value) {
try {
if (this.__cb_intercept) {
this.__cb_headers[name] = value;
}
} catch (_) {}
return origSetRequestHeader.apply(this, arguments);
};
function setProp(obj, key, value) {
try {
Object.defineProperty(obj, key, { configurable: true, writable: true, value });
} catch (_) {}
}
function synthesizeXHRResponse(xhr, fetchResp, text) {
// 응답 본문/메타 덮어쓰기. readyState/status 등은 prototype 의 getter 라서
// instance 에 data property 로 정의하면 shadow 된다.
setProp(xhr, 'readyState', 4);
setProp(xhr, 'status', fetchResp.status);
setProp(xhr, 'statusText', fetchResp.statusText || '');
setProp(xhr, 'responseText', text);
setProp(xhr, 'responseURL', xhr.__cb_url);
const rt = xhr.responseType || '';
let response;
if (rt === '' || rt === 'text') {
response = text;
} else if (rt === 'json') {
try { response = JSON.parse(text); } catch (_) { response = null; }
} else if (rt === 'arraybuffer') {
response = new TextEncoder().encode(text).buffer;
} else if (rt === 'blob') {
response = new Blob([text], { type: fetchResp.headers.get('content-type') || 'application/json' });
} else {
response = text;
}
setProp(xhr, 'response', response);
// 응답 헤더 메서드
const headerLines = [];
fetchResp.headers.forEach((v, k) => headerLines.push(k + ': ' + v));
const headerBlob = headerLines.join('\r\n');
setProp(xhr, 'getAllResponseHeaders', function () { return headerBlob; });
setProp(xhr, 'getResponseHeader', function (name) { return fetchResp.headers.get(name); });
}
function dispatchXHREvents(xhr) {
try { xhr.dispatchEvent(new Event('readystatechange')); } catch (_) {}
try { xhr.dispatchEvent(new ProgressEvent('load')); } catch (_) {}
try { xhr.dispatchEvent(new ProgressEvent('loadend')); } catch (_) {}
}
XHRProto.send = function patchedSend(body) {
if (!this.__cb_intercept) {
return origSend.apply(this, arguments);
}
const xhr = this;
const channelId = xhr.__cb_channel;
log('XHR live-detail intercepted for', channelId);
(async function () {
let resp;
let text = '';
try {
resp = await originalFetch(xhr.__cb_url, {
method: xhr.__cb_method || 'GET',
headers: xhr.__cb_headers || {},
credentials: 'include',
body: body && xhr.__cb_method && xhr.__cb_method.toUpperCase() !== 'GET' ? body : undefined,
});
text = await resp.text();
} catch (e) {
log('XHR underlying fetch failed', e);
try { xhr.dispatchEvent(new ProgressEvent('error')); } catch (_) {}
try { xhr.dispatchEvent(new ProgressEvent('loadend')); } catch (_) {}
return;
}
try {
let data = null;
try { data = JSON.parse(text); } catch (_) {}
if (data) {
const changed = await patchLiveDetailData(data, channelId);
if (changed) text = JSON.stringify(data);
}
} catch (e) {
log('XHR patch step failed, returning original body', e);
}
synthesizeXHRResponse(xhr, resp, text);
dispatchXHREvents(xhr);
})();
};
log('hooks installed (fetch + XHR)');
})();