// ==UserScript==
// @name SOOP(숲) - VOD 다운로더
// @namespace https://www.sooplive.co.kr/
// @version 2024-12-11
// @description 숲 VOD를 다운로드하는 FFmpeg 스크립트를 생성합니다.
// @author minibox
// @match https://vod.sooplive.co.kr/*/*
// @icon https://www.sooplive.co.kr/favicon.ico
// @run-at document-idle
// @license MIT
// ==/UserScript==
function makeCommands(startSec, endSec, useTempDir = false) {
const bjId = vodCore.config.bjId;
const fileItems = vodCore.fileItems;
const randomHash = Math.random().toString(36).substring(7);
const ffmpegCommands = [];
const mergeCommands = [];
let accumulatedDuration = 0;
const concatList = [];
let firstFile;
fileItems.forEach((item, index) => {
const { duration, orgFileResolution, levels } = item;
const itemStart = accumulatedDuration;
const itemEnd = accumulatedDuration + duration;
if (itemEnd > startSec && itemStart < endSec) {
const matchingLevel = levels.find(
(level) => level.resolution === orgFileResolution
);
if (matchingLevel) {
const clipStart = Math.max(startSec, itemStart) - itemStart;
const clipEnd = Math.min(endSec, itemEnd) - itemStart;
const clipStartFixed = clipStart.toFixed(2);
const clipEndFixed = clipEnd.toFixed(2);
let outputFilename = `${randomHash}__part_${index + 1}.ts`;
if (useTempDir) {
outputFilename = `${randomHash}__temp/${outputFilename}`;
}
if (!firstFile) {
console.log("firstFile", outputFilename);
firstFile = outputFilename;
}
let ffmpegCommand = `ffmpeg -i "${matchingLevel.file}" -c copy "${outputFilename}"`;
if (clipStart > 0) {
ffmpegCommand = `ffmpeg -ss "${clipStartFixed}" -i "${matchingLevel.file}" -c copy "${outputFilename}"`;
}
if (clipEnd < duration) {
ffmpegCommand = `ffmpeg -ss "${clipStartFixed}" -to "${clipEndFixed}" -i "${matchingLevel.file}" -c copy "${outputFilename}"`;
}
ffmpegCommands.push(ffmpegCommand);
concatList.push(`file '${outputFilename}'`);
}
}
accumulatedDuration += duration;
});
let concatFile = `${randomHash}__concat.txt`;
if (useTempDir) {
concatFile = `${randomHash}__temp/${concatFile}`;
}
const echoCommands = concatList
.map((c) => `echo ${c.replace(`${randomHash}__temp/`, "")}`)
.join(" && ");
const mergeFileCommand = `(${echoCommands}) > ${concatFile}`;
mergeCommands.push(mergeFileCommand);
const mergeCommand = `ffmpeg -f concat -safe 0 -i ${concatFile} -c copy "${randomHash}__${bjId}.ts"`;
mergeCommands.push(mergeCommand);
return {
ffmpegCommands,
mergeCommands,
randomHash,
concatList,
firstFile,
};
}
function makeBatUrl(startSec, endSec) {
const { ffmpegCommands, mergeCommands, randomHash, concatList, firstFile } =
makeCommands(startSec, endSec, true);
let batCommands = ffmpegCommands;
if (concatList.length > 1) {
batCommands = batCommands.concat(mergeCommands);
}
const batHeader = [
"@echo off",
"chcp 65001 >nul",
"title SOOP VOD 다운로더",
"where ffmpeg >nul 2>nul",
"if %errorlevel% neq 0 (",
" echo 영상 다운로드를 위해서는 FFmpeg가 필요합니다.",
" echo 설치 후 다시 시도해주세요.",
" echo.",
" echo 설치 방법: https://wikidocs.net/228271#windows-ffmpeg",
" echo.",
" pause",
" exit /b",
")",
`mkdir ${randomHash}__temp`,
];
batCommands.unshift(...batHeader);
if (concatList.length === 1) {
const bjId = vodCore.config.bjId;
const firstFilePath = firstFile.replace("/", "\\");
batCommands.push(
`ren ".\\${firstFilePath}" "../${randomHash}__${bjId}.ts"`
);
}
batCommands.push(`rmdir /s /q ${randomHash}__temp`);
batCommands.push("echo.");
batCommands.push("echo.");
batCommands.push("echo.");
batCommands.push("echo 영상 다운이 완료되었습니다!");
batCommands.push("echo.");
batCommands.push("pause");
const batContent = batCommands.join("\r\n");
const blob = new Blob([batContent], { type: "text/plain" });
const url = URL.createObjectURL(blob);
return url;
}
function formatTime(seconds) {
const hours = String(Math.floor(seconds / 3600)).padStart(2, "0");
const minutes = String(Math.floor((seconds % 3600) / 60)).padStart(2, "0");
const secs = String(Math.floor(seconds % 60)).padStart(2, "0");
return `${hours}:${minutes}:${secs}`;
}
function confirmButtonCallback(startSec, interval) {
const endSec = vodCore.playerController._playingTime;
if (startSec >= endSec) {
alert("시작 시간은 종료 시간보다 전에 있어야 합니다.");
return;
}
const el = document.getElementById("downloader-confirm-button");
if (el) {
el.remove();
clearInterval(interval);
}
const batUrl = makeBatUrl(startSec, endSec);
const a = document.createElement("a");
a.href = batUrl;
a.download = "download.bat";
a.click();
}
function getCurrentPlayingTime() {
const sec = vodCore.playerController._playingTime;
return formatTime(sec);
}
function getTotalDuration() {
const sec = vodCore.fileItems.reduce((acc, item) => acc + item.duration, 0);
return sec;
}
function downloadButtonCallback(shiftKey) {
const el = document.getElementById("downloader-confirm-button");
if (el) {
if (el.dataset.interval) {
clearInterval(el.dataset.interval);
}
el.remove();
}
const startSec = vodCore.playerController._playingTime;
const startSecStr = formatTime(startSec);
if (shiftKey) {
confirmButtonCallback(0, getTotalDuration());
return;
}
const confirmButton = document.createElement("button");
confirmButton.id = "downloader-confirm-button";
confirmButton.textContent = `여기를 눌러 [${startSecStr}] 부터 [${getCurrentPlayingTime()}] 까지 다운로드 하기`;
confirmButton.style.position = "fixed";
confirmButton.style.top = "0";
confirmButton.style.left = "0";
confirmButton.style.right = "0";
confirmButton.style.zIndex = "9999";
confirmButton.style.padding = "12px";
confirmButton.style.backgroundColor = "#455a52";
confirmButton.style.color = "#fff";
confirmButton.style.fontSize = "16px";
confirmButton.style.textAlign = "center";
const interval = setInterval(() => {
confirmButton.textContent = `여기를 눌러 [${startSecStr}] 부터 [${getCurrentPlayingTime()}] 까지 다운로드 하기`;
}, 100);
confirmButton.dataset.interval = interval;
confirmButton.onclick = () => confirmButtonCallback(startSec, interval);
document.body.appendChild(confirmButton);
}
function main() {
if (!vodCore) {
console.error("vodCore 객체를 찾을 수 없습니다.");
return false;
}
const downloadButton = document.createElement("button");
downloadButton.className = "play";
downloadButton.style.background =
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20height%3D%2224%22%20viewBox%3D%220%20-960%20960%20960%22%20width%3D%2224%22%20fill%3D%22%23FFF%22%3E%3Cpath%20d%3D%22M480-320%20280-520l56-58%20104%20104v-326h80v326l104-104%2056%2058zM240-160q-33%200-56.5-23.5T160-240v-120h80v120h480v-120h80v120q0%2033-23.5%2056.5T720-160z%22%2F%3E%3C%2Fsvg%3E") 50% 50% no-repeat';
downloadButton.onclick = (e) => downloadButtonCallback(e.shiftKey);
const ctrlContainer = document.querySelector(
"#player > div.player_ctrlBox > div.ctrlBox > div.ctrl"
);
ctrlContainer.insertBefore(downloadButton, ctrlContainer.children[0]);
return true;
}
const mainInterval = setInterval(() => {
if (main()) {
clearInterval(mainInterval);
}
}, 100);