SOOP(숲) - VOD 다운로더

숲 VOD를 다운로드하는 FFmpeg 스크립트를 생성합니다.

  1. // ==UserScript==
  2. // @name SOOP(숲) - VOD 다운로더
  3. // @namespace https://www.sooplive.co.kr/
  4. // @version 2024-12-11
  5. // @description 숲 VOD를 다운로드하는 FFmpeg 스크립트를 생성합니다.
  6. // @author minibox
  7. // @match https://vod.sooplive.co.kr/*/*
  8. // @icon https://www.sooplive.co.kr/favicon.ico
  9. // @run-at document-idle
  10. // @license MIT
  11. // ==/UserScript==
  12.  
  13. function makeCommands(startSec, endSec, useTempDir = false) {
  14. const bjId = vodCore.config.bjId;
  15. const fileItems = vodCore.fileItems;
  16. const randomHash = Math.random().toString(36).substring(7);
  17.  
  18. const ffmpegCommands = [];
  19. const mergeCommands = [];
  20.  
  21. let accumulatedDuration = 0;
  22. const concatList = [];
  23.  
  24. let firstFile;
  25.  
  26. fileItems.forEach((item, index) => {
  27. const { duration, orgFileResolution, levels } = item;
  28.  
  29. const itemStart = accumulatedDuration;
  30. const itemEnd = accumulatedDuration + duration;
  31.  
  32. if (itemEnd > startSec && itemStart < endSec) {
  33. const matchingLevel = levels.find(
  34. (level) => level.resolution === orgFileResolution
  35. );
  36.  
  37. if (matchingLevel) {
  38. const clipStart = Math.max(startSec, itemStart) - itemStart;
  39. const clipEnd = Math.min(endSec, itemEnd) - itemStart;
  40.  
  41. const clipStartFixed = clipStart.toFixed(2);
  42. const clipEndFixed = clipEnd.toFixed(2);
  43.  
  44. let outputFilename = `${randomHash}__part_${index + 1}.ts`;
  45.  
  46. if (useTempDir) {
  47. outputFilename = `${randomHash}__temp/${outputFilename}`;
  48. }
  49.  
  50. if (!firstFile) {
  51. console.log("firstFile", outputFilename);
  52. firstFile = outputFilename;
  53. }
  54.  
  55. let ffmpegCommand = `ffmpeg -i "${matchingLevel.file}" -c copy "${outputFilename}"`;
  56.  
  57. if (clipStart > 0) {
  58. ffmpegCommand = `ffmpeg -ss "${clipStartFixed}" -i "${matchingLevel.file}" -c copy "${outputFilename}"`;
  59. }
  60.  
  61. if (clipEnd < duration) {
  62. ffmpegCommand = `ffmpeg -ss "${clipStartFixed}" -to "${clipEndFixed}" -i "${matchingLevel.file}" -c copy "${outputFilename}"`;
  63. }
  64.  
  65. ffmpegCommands.push(ffmpegCommand);
  66. concatList.push(`file '${outputFilename}'`);
  67. }
  68. }
  69.  
  70. accumulatedDuration += duration;
  71. });
  72.  
  73. let concatFile = `${randomHash}__concat.txt`;
  74.  
  75. if (useTempDir) {
  76. concatFile = `${randomHash}__temp/${concatFile}`;
  77. }
  78.  
  79. const echoCommands = concatList
  80. .map((c) => `echo ${c.replace(`${randomHash}__temp/`, "")}`)
  81. .join(" && ");
  82.  
  83. const mergeFileCommand = `(${echoCommands}) > ${concatFile}`;
  84. mergeCommands.push(mergeFileCommand);
  85.  
  86. const mergeCommand = `ffmpeg -f concat -safe 0 -i ${concatFile} -c copy "${randomHash}__${bjId}.ts"`;
  87. mergeCommands.push(mergeCommand);
  88.  
  89. return {
  90. ffmpegCommands,
  91. mergeCommands,
  92. randomHash,
  93. concatList,
  94. firstFile,
  95. };
  96. }
  97.  
  98. function makeBatUrl(startSec, endSec) {
  99. const { ffmpegCommands, mergeCommands, randomHash, concatList, firstFile } =
  100. makeCommands(startSec, endSec, true);
  101.  
  102. let batCommands = ffmpegCommands;
  103. if (concatList.length > 1) {
  104. batCommands = batCommands.concat(mergeCommands);
  105. }
  106.  
  107. const batHeader = [
  108. "@echo off",
  109. "chcp 65001 >nul",
  110. "title SOOP VOD 다운로더",
  111. "where ffmpeg >nul 2>nul",
  112. "if %errorlevel% neq 0 (",
  113. " echo 영상 다운로드를 위해서는 FFmpeg가 필요합니다.",
  114. " echo 설치 후 다시 시도해주세요.",
  115. " echo.",
  116. " echo 설치 방법: https://wikidocs.net/228271#windows-ffmpeg",
  117. " echo.",
  118. " pause",
  119. " exit /b",
  120. ")",
  121. `mkdir ${randomHash}__temp`,
  122. ];
  123.  
  124. batCommands.unshift(...batHeader);
  125.  
  126. if (concatList.length === 1) {
  127. const bjId = vodCore.config.bjId;
  128. const firstFilePath = firstFile.replace("/", "\\");
  129.  
  130. batCommands.push(
  131. `ren ".\\${firstFilePath}" "../${randomHash}__${bjId}.ts"`
  132. );
  133. }
  134.  
  135. batCommands.push(`rmdir /s /q ${randomHash}__temp`);
  136.  
  137. batCommands.push("echo.");
  138. batCommands.push("echo.");
  139. batCommands.push("echo.");
  140. batCommands.push("echo 영상 다운이 완료되었습니다!");
  141. batCommands.push("echo.");
  142. batCommands.push("pause");
  143.  
  144. const batContent = batCommands.join("\r\n");
  145. const blob = new Blob([batContent], { type: "text/plain" });
  146. const url = URL.createObjectURL(blob);
  147.  
  148. return url;
  149. }
  150.  
  151. function formatTime(seconds) {
  152. const hours = String(Math.floor(seconds / 3600)).padStart(2, "0");
  153. const minutes = String(Math.floor((seconds % 3600) / 60)).padStart(2, "0");
  154. const secs = String(Math.floor(seconds % 60)).padStart(2, "0");
  155. return `${hours}:${minutes}:${secs}`;
  156. }
  157.  
  158. function confirmButtonCallback(startSec, interval) {
  159. const endSec = vodCore.playerController._playingTime;
  160.  
  161. if (startSec >= endSec) {
  162. alert("시작 시간은 종료 시간보다 전에 있어야 합니다.");
  163. return;
  164. }
  165.  
  166. const el = document.getElementById("downloader-confirm-button");
  167.  
  168. if (el) {
  169. el.remove();
  170. clearInterval(interval);
  171. }
  172.  
  173. const batUrl = makeBatUrl(startSec, endSec);
  174.  
  175. const a = document.createElement("a");
  176. a.href = batUrl;
  177. a.download = "download.bat";
  178. a.click();
  179. }
  180.  
  181. function getCurrentPlayingTime() {
  182. const sec = vodCore.playerController._playingTime;
  183. return formatTime(sec);
  184. }
  185.  
  186. function getTotalDuration() {
  187. const sec = vodCore.fileItems.reduce((acc, item) => acc + item.duration, 0);
  188. return sec;
  189. }
  190.  
  191. function downloadButtonCallback(shiftKey) {
  192. const el = document.getElementById("downloader-confirm-button");
  193. if (el) {
  194. if (el.dataset.interval) {
  195. clearInterval(el.dataset.interval);
  196. }
  197.  
  198. el.remove();
  199. }
  200.  
  201. const startSec = vodCore.playerController._playingTime;
  202. const startSecStr = formatTime(startSec);
  203.  
  204. if (shiftKey) {
  205. confirmButtonCallback(0, getTotalDuration());
  206. return;
  207. }
  208.  
  209. const confirmButton = document.createElement("button");
  210. confirmButton.id = "downloader-confirm-button";
  211. confirmButton.textContent = `여기를 눌러 [${startSecStr}] 부터 [${getCurrentPlayingTime()}] 까지 다운로드 하기`;
  212.  
  213. confirmButton.style.position = "fixed";
  214. confirmButton.style.top = "0";
  215. confirmButton.style.left = "0";
  216. confirmButton.style.right = "0";
  217. confirmButton.style.zIndex = "9999";
  218.  
  219. confirmButton.style.padding = "12px";
  220.  
  221. confirmButton.style.backgroundColor = "#455a52";
  222. confirmButton.style.color = "#fff";
  223. confirmButton.style.fontSize = "16px";
  224. confirmButton.style.textAlign = "center";
  225.  
  226. const interval = setInterval(() => {
  227. confirmButton.textContent = `여기를 눌러 [${startSecStr}] 부터 [${getCurrentPlayingTime()}] 까지 다운로드 하기`;
  228. }, 100);
  229.  
  230. confirmButton.dataset.interval = interval;
  231.  
  232. confirmButton.onclick = () => confirmButtonCallback(startSec, interval);
  233.  
  234. document.body.appendChild(confirmButton);
  235. }
  236.  
  237. function main() {
  238. if (!vodCore) {
  239. console.error("vodCore 객체를 찾을 수 없습니다.");
  240. return false;
  241. }
  242.  
  243. const downloadButton = document.createElement("button");
  244. downloadButton.className = "play";
  245. downloadButton.style.background =
  246. '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';
  247.  
  248. downloadButton.onclick = (e) => downloadButtonCallback(e.shiftKey);
  249.  
  250. const ctrlContainer = document.querySelector(
  251. "#player > div.player_ctrlBox > div.ctrlBox > div.ctrl"
  252. );
  253. ctrlContainer.insertBefore(downloadButton, ctrlContainer.children[0]);
  254.  
  255. return true;
  256. }
  257.  
  258. const mainInterval = setInterval(() => {
  259. if (main()) {
  260. clearInterval(mainInterval);
  261. }
  262. }, 100);

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址