VOD Synchronizer

SOOP 다시보기 VOD를 시청하면 우측 하단에 당시의 타임스탬프를 표시합니다. 타임스탬프를 클릭하여 수정하고 엔터를 누르면 알맞는 재생시간으로 새로고침합니다. 상단 기본 Soop 검색창에 다른 스트리머를 검색하여 Find VOD 버튼을 누르면 그 스트리머의 VOD에서 동일시점을 찾고 새 탭에서 열립니다.

// ==UserScript==
// @name         VOD Synchronizer
// @namespace    http://tampermonkey.net/
// @version      0.1.1
// @description  SOOP 다시보기 VOD를 시청하면 우측 하단에 당시의 타임스탬프를 표시합니다. 타임스탬프를 클릭하여 수정하고 엔터를 누르면 알맞는 재생시간으로 새로고침합니다. 상단 기본 Soop 검색창에 다른 스트리머를 검색하여 Find VOD 버튼을 누르면 그 스트리머의 VOD에서 동일시점을 찾고 새 탭에서 열립니다.
// @author       AINukeHere
// @match        https://vod.sooplive.co.kr/*
// @match        https://ch.sooplive.co.kr/*
// @match        https://www.sooplive.co.kr/*
// @grant        GM_xmlhttpRequest
// @grant        GM_openInTab
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // iframe 내부에서 실행되는 경우 (vod_get.js, streamerID_get.js 기능)
    if (window !== top) {
        // vod_get.js 기능
        if (window.location.hostname === 'ch.sooplive.co.kr') {
            function log(...data){
                console.log('[vod_get.js]', ...data);
            }
            
            function GetVodList(datetime){
                const dateSpanElements = document.querySelectorAll('#contents > div > div > section > section.vod-list > ul > li > div.vod-info > div > span.date');
                const vodLinkList = document.querySelectorAll('#contents > div > div > section > section.vod-list > ul > li > div.vod-info > p > a');
                if (dateSpanElements.length == 0) return null;
                if (vodLinkList.length == 0) return null;
                log("date length", dateSpanElements.length);
                log("link length", vodLinkList.length);
                const request_year = datetime.getFullYear();
                const request_month = datetime.getMonth()+1;
                const request_day = datetime.getDate();
                let resultVODLinks = [];
                let prevMonth = 0;
                let prevDay = 0;
                for (var i = dateSpanElements.length - 1; i >= 0 ; --i){
                    const innerText = dateSpanElements[i].innerText;
                    let year, month, day;
                    
                    // HH시간전 형식인지 체크
                    const timeAgoMatch = innerText.match(/(\d+)시간 전/);
                    if (timeAgoMatch) {
                        const hoursAgo = parseInt(timeAgoMatch[1]);
                        const uploadDate = new Date();
                        uploadDate.setHours(uploadDate.getHours() - hoursAgo);
                        year = uploadDate.getFullYear();
                        month = uploadDate.getMonth() + 1;
                        day = uploadDate.getDate();
                        log(`시간전 형식 파싱: ${hoursAgo}시간전 -> ${year}-${month}-${day}`);
                    } else {
                        // YYYY-MM-DD 형식 처리
                        const [_year, _month, _day] = innerText.split("-");
                        year = parseInt(_year);
                        month = parseInt(_month);
                        day = parseInt(_day);
                    }
                    
                    if (i < dateSpanElements.length - 1){
                        if (prevMonth > request_month || prevDay > request_day){
                            break;
                        }
                    }
                    if (year >= request_year && month >= request_month && day >= request_day){
                        resultVODLinks.push(vodLinkList[i].href);
                        prevMonth = month;
                        prevDay = day;
                        log(`vod added: ${month}-${day} ${vodLinkList[i].href}`);
                    }
                }
                // TODO: 최대치까지 표시됐다면 다음 페이지 검색필요
                // if (vodLinkList.length == 60)

                return resultVODLinks;
            }
            
            function TryGetVodList(request_datetime){
                const intervalID = setInterval(() => {
                    const resultVODLinks = GetVodList(request_datetime);
                    log("TryGetVodList");
                    if (resultVODLinks == null) return;
                    // 부모 페이지로 VOD List 를 보냄
                    window.parent.postMessage(
                        {
                            response: "VOD_LIST",
                            request_datetime: request_datetime,
                            resultVODLinks: resultVODLinks
                        }, 
                    "https://vod.sooplive.co.kr");
                    clearInterval(intervalID);
                }, 100);
            }
            
            log('[vod_get.js] in iframe');
            const params = new URLSearchParams(window.location.search);
            const p_request = params.get("p_request");
            if (p_request === "GET_VOD_LIST"){
                const global_ts = params.get("req_global_ts");
                const request_datetime = new Date(parseInt(global_ts));
                TryGetVodList(request_datetime)
            }
            else{
                window.addEventListener("message", (event) => {
                    if(event.data.request === "GET_VOD_LIST"){
                        const resultVODLinks = GetVodList(event.data.datetime);
                        // 부모 페이지로 VOD List 를 보냄
                        event.source.postMessage(
                            {
                                response: "VOD_LIST",
                                request_datetime: event.data.datetime,
                                resultVODLinks: resultVODLinks
                            }, 
                        event.origin);
                    }
                })
            }
        }
        
        // streamerID_get.js 기능
        if (window.location.hostname === 'www.sooplive.co.kr') {
            function log(...data){
                console.log('[streamerID_get.js]', ...data);
            }
            
            log('in iframe');
            function GetStreamerID(nickname){
                const searchResults = document.querySelectorAll('#container > div.search_strm_area > ul > .strm_list');
                let streamer_id = null;
                if (searchResults){
                    searchResults.forEach(element => {
                        const nicknameBtn = element.querySelector('.nick > button');
                        const idSpan = element.querySelector('.id');
                        if (nickname === nicknameBtn.innerText){
                            streamer_id = idSpan.innerText.slice(1,-1);
                        }
                    });
                }
                return streamer_id;
            }
            
            function TryGetStreamerID(nickname){
                const intervalID = setInterval(() => {
                    log("TryGetStreamerID");
                    const streamer_id = GetStreamerID(nickname);
                    if (streamer_id == null) return;
                    // 부모 페이지로 VOD List 를 보냄
                    window.parent.postMessage(
                        {
                            response: "STREAMER_ID",
                            streamer_nickname: nickname,
                            streamer_id: streamer_id
                        }, 
                    "https://vod.sooplive.co.kr");
                    clearInterval(intervalID);
                }, 100);
            }
            
            const params = new URLSearchParams(window.location.search);
            const p_request = params.get("p_request");
            if (p_request === "GET_STREAMER_ID"){
                const request_nickname = params.get("szKeyword");
                const decoded_nickname = decodeURI(request_nickname)
                TryGetStreamerID(decoded_nickname)
            }
            else{
                window.addEventListener("message", (event) =>{
                    if (event.data.request === "GET_STREAMER_ID"){
                        const streamer_nickname = event.data.nickname;
                        const streamer_id = GetStreamerID(streamer_nickname);
                        if (streamer_id != null){
                            event.source.postMessage(
                                {
                                    response: "STREAMER_ID",
                                    streamer_nickname: streamer_nickname,
                                    streamer_id: streamer_id
                                }, 
                            event.origin);
                            log('streamer_id: ', streamer_id);
                        }
                    }
                });
            }
        }
        
        return; // iframe에서는 여기서 종료
    }

    // 메인 페이지에서 실행되는 경우 (content.js 기능)
    const BTN_TEXT_IDLE = "Find VOD";
    const BTN_TEXT_FINDING_STREAMER_ID = "스트리머 ID를 찾는 중...";
    const BTN_TEXT_FINDING_VOD = "다시보기를 찾는 중...";
    let tsManager = null;
    let vodLinker = null;
    
    function log(...data){
        console.log('[VOD Synchronizer]', ...data);
    }

    class TimestampTooltipManager {
        constructor() {
            this.playTimeTag = null;
            this.streamPeriodTag = null;
            this.tooltip = null;
            this.observer = null;
            this.isEditing = false;
            this.requestGlobalTS = null;
            this.requestSystemTime = null;
            this.isControllableState = false;
            this.startMonitoring();
        }
        
        RequestGlobalTSAsync(global_ts, system_time){
            this.requestGlobalTS = global_ts;
            this.requestSystemTime = system_time;
        }
        
        startMonitoring() {
            this.observeDOMChanges();
            this.createTooltip();
        }
        
        createTooltip() {
            if (!this.tooltip) {
                this.tooltip = document.createElement("div");
                this.tooltip.style.position = "fixed";
                this.tooltip.style.bottom = "20px";
                this.tooltip.style.right = "20px";
                this.tooltip.style.background = "black";
                this.tooltip.style.color = "white";
                this.tooltip.style.padding = "8px 12px";
                this.tooltip.style.borderRadius = "5px";
                this.tooltip.style.fontSize = "14px";
                this.tooltip.style.whiteSpace = "nowrap";
                this.tooltip.style.display = "block";
                this.tooltip.style.zIndex = "1000";
                this.tooltip.contentEditable = "false";
                document.body.appendChild(this.tooltip);

                this.tooltip.addEventListener("dblclick", () => {
                    this.tooltip.contentEditable = "true";
                    this.tooltip.focus();
                    this.isEditing = true;
                    this.tooltip.style.outline = "2px solid red"; 
                    this.tooltip.style.boxShadow = "0 0 10px red";
                });

                this.tooltip.addEventListener("blur", () => {
                    this.tooltip.contentEditable = "false";
                    this.isEditing = false;
                    this.tooltip.style.outline = "none";
                    this.tooltip.style.boxShadow = "none";
                });

                this.tooltip.addEventListener("keydown", (event) => {                    
                    // 숫자 키 (0-9) - 영상 점프 기능만 차단하고 텍스트 입력은 허용
                    if (/^[0-9]$/.test(event.key)) {
                        // 영상 플레이어의 키보드 이벤트만 차단
                        event.stopPropagation();
                        return;
                    }

                    // Enter 키 처리
                    if (event.key === "Enter") {
                        event.preventDefault();
                        this.processTimestampInput(this.tooltip.innerText.trim());
                        this.tooltip.contentEditable = "false";
                        this.tooltip.blur();
                        this.isEditing = false;
                        return;
                    }
                });
            }
            this.updateTooltip();
        }
        
        updateTooltip() {
            setInterval(() => {
                if (!this.tooltip || !this.playTimeTag || !this.streamPeriodTag || this.isEditing) return;
                
                const timestamp = this.getCurDateTime();

                if (timestamp) {
                    this.isControllableState = true;
                    this.tooltip.innerText = timestamp.toLocaleString("ko-KR");
                }
                if (this.requestGlobalTS != null){
                    const currentSystemTime = Date.now();
                    const timeDifference = currentSystemTime - this.requestSystemTime;
                    const adjustedGlobalTS = this.requestGlobalTS + timeDifference; 
                    if (!tsManager.moveToGlobalTS(adjustedGlobalTS, false))
                        window.close();
                    this.requestGlobalTS = null;
                    this.requestSystemTime = null;
                }
            }, 1000);
        }
        
        calculateTimestamp(broadcastInfo, playbackTimeStr) {
            const match = broadcastInfo.match(/방송시간\s*:\s*(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/);

            if (!match) {
                this.tooltip.innerText = "다시보기만 지원하는 기능입니다.";
                return null;
            }

            const startTime = new Date(match[1]);

            if (isNaN(startTime.getTime())) {
                return null;
            }

            const playbackMatch = playbackTimeStr.match(/(\d{2}):(\d{2}):(\d{2})/);
            if (!playbackMatch) {
                log("올바른 재생 시간 형식이 아닙니다.");
                return null;
            }

            const playbackSeconds =
                parseInt(playbackMatch[1]) * 3600 +
                parseInt(playbackMatch[2]) * 60 +
                parseInt(playbackMatch[3]);

            return new Date(startTime.getTime() + playbackSeconds * 1000);
        }
        
        observeDOMChanges() {
            const targetNode = document.body;
            const config = { childList: true, subtree: true };

            this.observer = new MutationObserver(() => {
                const newPlayTimeTag = document.querySelector('#player > div.player_ctrlBox > div.ctrlBox > div.ctrl > div.time_display > span.time-current');
                const newStreamPeriodTag = document.querySelector("#player_area > div.wrapping.player_bottom > div.broadcast_information > div:nth-child(2) > div.cnt_info > ul > li:nth-child(2) > span");

                if (!newPlayTimeTag || !newStreamPeriodTag) return;
                if (newPlayTimeTag !== this.playTimeTag || newStreamPeriodTag !== this.streamPeriodTag) {
                    log("VOD 변경 감지됨! 요소 업데이트 중...");
                    this.playTimeTag = newPlayTimeTag;
                    this.streamPeriodTag = newStreamPeriodTag;
                }
            });

            this.observer.observe(targetNode, config);
        }
        
        getStreamPeriod(){
            const broadcastInfo = this.streamPeriodTag.attributes['tip'].value;
            const match = broadcastInfo.match(/방송시간\s*:\s*(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) ~ (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/);

            if (!match) {
                return null;
            }
            const startTime = new Date(match[1]);
            const endTime = new Date(match[2]);
            return [startTime, endTime];
        }
        
        getCurDateTime(){
            const playbackTimeStr = this.playTimeTag.innerText.trim();
            const broadcastInfo = this.streamPeriodTag.attributes['tip'].value;
            const timestamp = this.calculateTimestamp(broadcastInfo, playbackTimeStr);
            return timestamp;
        }
        
        processTimestampInput(input) {
            const match = input.match(/(\d{4})\.\s*(\d{1,2})\.\s*(\d{1,2})\.\s*(오전|오후)\s*(\d{1,2}):(\d{2}):(\d{2})/);
            
            if (!match) {
                alert("유효한 타임스탬프 형식을 입력하세요. (예: 2024. 10. 22. 오전 5:52:55)");
                return;
            }
        
            let [_, year, month, day, period, hour, minute, second] = match;
            year = parseInt(year);
            month = parseInt(month) - 1; // JavaScript의 Date는 0부터 시작하는 월을 사용
            day = parseInt(day);
            hour = parseInt(hour);
            minute = parseInt(minute);
            second = parseInt(second);
        
            // 오전/오후 변환
            if (period === "오후" && hour !== 12) {
                hour += 12;
            } else if (period === "오전" && hour === 12) {
                hour = 0;
            }
        
            const globalDateTime = new Date(year, month, day, hour, minute, second);
            
            if (isNaN(globalDateTime.getTime())) {
                alert("유효한 날짜로 변환할 수 없습니다.");
                return;
            }
        
            this.moveToGlobalTS(globalDateTime.getTime());
        }
        
        moveToGlobalTS(globalTS, doAlert = true) {
            const [streamStartDateTime, streamEndDateTime] = this.getStreamPeriod();
            const globalDateTime = new Date(parseInt(globalTS));

            if (streamStartDateTime > globalDateTime || globalDateTime > streamEndDateTime) {
                if (doAlert) {
                    alert("입력한 타임스탬프가 방송 기간 밖입니다.");
                }
                return false;
            }
            const playbackTime = Math.floor((globalDateTime.getTime() - streamStartDateTime.getTime()) / 1000);
            const url = new URL(window.location.href);
            url.searchParams.delete('change_global_ts');
            url.searchParams.delete('request_system_time');
            url.searchParams.set('change_second', playbackTime);
            window.location.replace(url.toString());
            return true;
        }
    }

    class VODLinker {
        constructor(){
            this.lastRequest = null;
            this.lastRequestFailedMessage = null;
            this.buttons=[];
            this.curProcessingBtn = null;
            this.iframe=null;
            this.requestSystemTime = null; // VOD List 요청한 시스템 시간 저장
            this.init();            
        }
        
        init(){
            this.createTemp();
            this.updateFindVODButtons();
        }
        
        findVODList(streamer_id){
            vodLinker.curProcessingBtn.innerText = BTN_TEXT_FINDING_VOD;
            // VOD List 요청한 시스템 시간 저장
            this.requestSystemTime = Date.now();
            log('this.requestSystemTime: ', this.requestSystemTime);
            const datetime = tsManager.getCurDateTime();
            const year = datetime.getFullYear();
            const month = datetime.getMonth()+1;
            const monthsParam = `${year}${String(month).padStart(2,"0")}`;

            const url = new URL(`https://ch.sooplive.co.kr/${streamer_id}/vods/review`);
            url.searchParams.set("page",1);
            url.searchParams.set("months",`${monthsParam}${monthsParam}`);
            url.searchParams.set("perPage", 60);
            const reqUrl = new URL(url.toString());
            reqUrl.searchParams.set("p_request", "GET_VOD_LIST");
            reqUrl.searchParams.set("req_global_ts", datetime.getTime());
            log('VOD List 요청: ', reqUrl.toString());
            this.lastRequest = "GET_VOD_LIST";
            this.lastRequestFailedMessage = `VOD를 찾을 수 없습니다. 시도한 검색페이지: ${url.toString()}`;
            this.lastRequestTimeout = setTimeout(() => {
                alert(this.lastRequestFailedMessage);
                this.iframe.src = "";
                this.curProcessingBtn.innerText = BTN_TEXT_IDLE;
                this.curProcessingBtn = null;
            }, 3000);
            this.iframe.src = reqUrl.toString();
        }
        
        findStreamerID(nickname){
            vodLinker.curProcessingBtn.innerText = BTN_TEXT_FINDING_STREAMER_ID;
            const encodedNickname = encodeURI(nickname);
            const url = new URL(`https://www.sooplive.co.kr/search`);
            url.searchParams.set("szLocation", "total_search");
            url.searchParams.set("szSearchType", "streamer");
            url.searchParams.set("szKeyword", encodedNickname);
            url.searchParams.set("szStype", "di");
            url.searchParams.set("szActype", "input_field");
            const reqUrl = new URL(url.toString());
            reqUrl.searchParams.set("p_request", "GET_STREAMER_ID");
            log(`find with ${reqUrl.toString()}`);
            this.lastRequest = "GET_STREAMER_ID";
            this.lastRequestFailedMessage = `스트리머 ID를 찾을 수 없습니다. 검색페이지: ${url.toString()}`;
            this.lastRequestTimeout = setTimeout(() => {
                alert(this.lastRequestFailedMessage);
                this.iframe.src = "";
                this.curProcessingBtn.innerText = "Find VOD";
                this.curProcessingBtn = null;
            }, 3000);
            this.iframe.src = reqUrl.toString();
        }
        
        updateFindVODButtons(){
            setInterval(() => {
                if (!tsManager.isControllableState) return;
                const searchResults = document.querySelectorAll('#areaSuggest > ul > li > a');
                if (searchResults){
                    searchResults.forEach(element => {
                        if (element.querySelector('em')) return;
                        
                        const existsBtn = element.querySelector('.find-vod');
                        if (!existsBtn){
                            const button = document.createElement("button");
                            button.className = "find-vod";
                            button.innerText = BTN_TEXT_IDLE;
                            button.style.background = "gray";
                            button.style.fontSize = "12px";
                            button.style.color = "white";
                            button.style.marginLeft = "20px";
                            button.style.padding = "5px";
                            element.appendChild(button);
                            button.addEventListener('click', function (e){
                                e.preventDefault();       // a 태그의 기본 이동 동작 막기
                                e.stopPropagation();      // 이벤트 버블링 차단
                                if (vodLinker.curProcessingBtn != null){
                                    alert("이미 다른 스트리머를 찾고 있습니다. 잠시 후 다시 시도해주세요.");
                                    return;
                                }
                                vodLinker.curProcessingBtn = button;
                                const nicknameSpan = element.querySelector('span');
                                vodLinker.findStreamerID(nicknameSpan.innerText);
                            });
                        }
                    });
                }
            }, 1000);
        }
        
        createTemp(){
            this.iframe = document.createElement('iframe');
            this.iframe.style.display = "none"; // initially hidden
            document.body.appendChild(this.iframe);
        }
        
        clearLastRequest(){
            if (this.lastRequestTimeout != null){
                clearTimeout(this.lastRequestTimeout);
                this.lastRequestTimeout = null;
                this.lastRequest = null;
                this.lastRequestFailedMessage = null;
            }
        }
    }

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    async function checkOneByOne(vodLinks, request_global_ts){
        if (vodLinks.length > 0){
            for (let i = 0; i < vodLinks.length; i++) {
                const link = vodLinks[i];

                const url = new URL(link);
                url.searchParams.delete('change_second');
                url.searchParams.set('change_global_ts', request_global_ts);
                url.searchParams.set('request_system_time', vodLinker.requestSystemTime);
                window.open(url, "_blank");
            }
        }
    }

    // 초기화
    if (window.location.hostname === 'vod.sooplive.co.kr') {
        tsManager = new TimestampTooltipManager();
        vodLinker = new VODLinker();
        
        const params = new URLSearchParams(window.location.search);
        const global_ts = parseInt(params.get("change_global_ts"));
        const system_time = parseInt(params.get("request_system_time"));
        if (global_ts){
            tsManager.RequestGlobalTSAsync(global_ts, system_time);
        }
        
        window.addEventListener('message', (event) => {
            if (event.data.response === "VOD_LIST"){
                const vodLinks = event.data.resultVODLinks;
                const request_datetime = event.data.request_datetime;
                log("VOD_LIST 받음:", vodLinks);
                vodLinker.clearLastRequest();
                
                checkOneByOne(vodLinks, request_datetime.getTime());
                vodLinker.curProcessingBtn.innerText = "Find VOD";
                vodLinker.curProcessingBtn = null;
            }
            else if (event.data.response === "STREAMER_ID"){
                log("STREAMER_ID 받음:", event.data.streamer_id);
                vodLinker.clearLastRequest();
                vodLinker.findVODList(event.data.streamer_id);
            }
        });
    }
})();