SOOP(숲) - VOD 다운로더

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

// ==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);

QingJ © 2025

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