// 치지직 라이브 응답을 가로채 타임머신 기능을 강제로 활성화한다. // 스트리머가 타임머신을 꺼둔 방송에서도 플레이어에 되감기(seek) UI 를 표시하고, // 가능하면 DVR 매니페스트(URL) 까지 함께 갈아끼워 실제 되감기도 동작하도록 만든다. // // 동작 원리 // - 치지직 웹 플레이어는 `api.chzzk.naver.com/service/vN/channels/{id}/live-detail` // 응답의 `content.timeMachineActive` 플래그로 되감기 UI 노출 여부를 결정한다. // - 같은 채널의 `live-playback-json` 엔드포인트는 DVR 가능한 HLS 매니페스트 URL 을 // 항상 돌려준다. ChzzkDownloader 의 `--stream force-timemachine` 옵션이 쓰는 // 바로 그 엔드포인트다. // - 따라서 `live-detail` 응답을 가로채서 (1) 플래그를 강제로 true 로 만들고, // (2) `livePlaybackJson` 안의 매니페스트 URL 을 `live-playback-json` 응답으로 // 교체하면, 스트리머 설정과 무관하게 되감기 UI 와 실제 seek 동작 모두 살아난다. // // manifest 의 `world: "MAIN"`, `run_at: "document_start"` 덕분에 페이지 자체 스크립트가 // 실행되기 전에 fetch 를 후킹할 수 있다. (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/; // 시점/빌드별로 다른 버전이 응답하므로 순차 시도한다. const PLAYBACK_JSON_VERSIONS = ['v3.2', 'v3.1', 'v3', 'v2', 'v1']; function log() { try { // eslint-disable-next-line no-console console.log.apply(console, [TAG].concat(Array.prototype.slice.call(arguments))); } catch (_) {} } const originalFetch = window.fetch.bind(window); async function fetchPlaybackJson(channelId) { for (const ver of PLAYBACK_JSON_VERSIONS) { try { const url = `https://api.chzzk.naver.com/service/${ver}/channels/${channelId}/live-playback-json`; const resp = await originalFetch(url, { credentials: 'include' }); if (!resp.ok) continue; const json = await resp.json(); const playback = json && json.content && json.content.playbackJson; if (playback) { log('playback-json hit via', ver); return playback; } } catch (e) { log('playback-json fetch error on', ver, e); } } return null; } // livePlaybackJson 은 외부에선 JSON 문자열로 들어오지만, 가끔 이미 파싱된 // 객체 형태로 들어오는 빌드도 있다. 두 경우 모두 처리한다. function setLivePlaybackJson(content, replacement) { if (typeof content.livePlaybackJson === 'string') { content.livePlaybackJson = typeof replacement === 'string' ? replacement : JSON.stringify(replacement); } else { content.livePlaybackJson = typeof replacement === 'string' ? JSON.parse(replacement) : replacement; } } async function patchLiveDetail(response, channelIdFromUrl) { let data; try { data = await response.clone().json(); } catch (e) { log('response is not JSON, passthrough'); return response; } const content = data && data.content; if (!content) return response; // 치지직 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 response; } log('forcing timeMachine ON for', channelId); content.timeMachineActive = true; content.timeMachinePlayback = true; // DVR 매니페스트로 교체 시도. 실패해도 플래그는 살려서 UI 만이라도 노출. if (channelId) { const playback = await fetchPlaybackJson(channelId); if (playback) { setLivePlaybackJson(content, playback); log('livePlaybackJson swapped for DVR manifest'); } else { log('playback-json unavailable; UI shown but seek may not work'); } } 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, }); } window.fetch = async function patchedFetch(input, init) { let url = ''; try { url = typeof input === 'string' ? input : (input && input.url) || ''; } catch (_) { url = ''; } const match = url && url.match(LIVE_DETAIL_RE); if (!match) { return originalFetch(input, init); } let response; try { response = await originalFetch(input, init); } catch (e) { throw e; } try { return await patchLiveDetail(response, match[1]); } catch (e) { log('patch failed, returning original response', e); return response; } }; log('fetch hook installed'); })();