// ==UserScript==
// @name SOOP (숲) - M3U8 링크 복사 버튼 추가
// @namespace http://tampermonkey.net/
// @version 20250612
// @description 숲 LIVE, VOD M3U8 링크를 복사할 수 있게 해주는 버튼을 추가합니다.
// @author You
// @match https://play.sooplive.co.kr/*/*
// @match https://vod.sooplive.co.kr/player/*
// @exclude */embed*
// @grant GM_setClipboard
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_download
// @run-at document-start
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// --- Global Variables ---
const isVodPage = window.location.href.startsWith("https://vod.sooplive.co.kr");
let currentBroadcastInfo = {};
let copyButtonLiElement = null;
let copyButtonElement = null;
let currentDownloadController = null; // Controller to manage and abort downloads
// --- SVG Icons ---
const linkIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`;
const closeIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`;
const copyIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`;
const checkIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
const downloadIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`;
const terminalIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg>`;
// --- Utility Functions ---
const getLiveBroadAid = async (id, broadNumber) => {
const data = { bid: id, bno: broadNumber, mode: 'landing', player_type: 'html5', stream_type: 'common', quality: 'original', type: 'aid' };
const requestOptions = { method: 'POST', body: new URLSearchParams(data), credentials: 'include' };
try {
const response = await fetch('https://live.sooplive.co.kr/afreeca/player_live_api.php', requestOptions);
const result = await response.json();
return result.CHANNEL.AID || null;
} catch (error) { console.error('[getLiveBroadAid] AID Fetch Error:', error); return null; }
};
function getLiveBroadcastInfo() {
const pathParts = window.location.pathname.split('/');
if (pathParts.length >= 3 && pathParts[1] && pathParts[2] && !isNaN(parseInt(pathParts[2]))) {
return { userId: pathParts[1], broadNo: parseInt(pathParts[2]) };
}
return null;
}
function showToastMessage(message, autoHide = true) {
let toast = document.querySelector('.m3u8-toast-message-userscript');
if (toast) toast.remove();
toast = document.createElement('div');
toast.className = 'm3u8-toast-message-userscript';
toast.innerHTML = message;
document.body.appendChild(toast);
setTimeout(() => toast.classList.add('show'), 10);
if (autoHide) {
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 500);
}, 3000);
}
}
function formatTime(totalSeconds, showMs = false) {
const hours = Math.floor(totalSeconds / 3600).toString().padStart(2, '0');
const minutes = Math.floor((totalSeconds % 3600) / 60).toString().padStart(2, '0');
const seconds = Math.floor(totalSeconds % 60).toString().padStart(2, '0');
if (showMs) {
const ms = (totalSeconds - Math.floor(totalSeconds)).toFixed(3).substring(2);
return `${hours}:${minutes}:${seconds}.${ms}`;
}
return `${hours}:${minutes}:${seconds}`;
}
function formatShortDate(dateObj) {
const yy = dateObj.getFullYear().toString().slice(-2);
const mo = (dateObj.getMonth() + 1).toString().padStart(2, '0');
const dd = dateObj.getDate().toString().padStart(2, '0');
const hh = dateObj.getHours().toString().padStart(2, '0');
const mi = dateObj.getMinutes().toString().padStart(2, '0');
const ss = dateObj.getSeconds().toString().padStart(2, '0');
return `${yy}.${mo}.${dd}. ${hh}:${mi}:${ss}`;
}
function formatKoreanDuration(totalSeconds) {
const sec = Math.floor(totalSeconds);
if (sec < 0) return "0초";
const hours = Math.floor(sec / 3600);
const minutes = Math.floor((sec % 3600) / 60);
const seconds = sec % 60;
const parts = [];
if (hours > 0) parts.push(`${hours}시간`);
if (minutes > 0) parts.push(`${minutes}분`);
if (seconds > 0 || parts.length === 0) parts.push(`${seconds}초`);
return parts.join(' ');
}
function parseTimeToSeconds(timeStr) {
const parts = timeStr.split(':');
return parseInt(parts[0], 10) * 3600 + parseInt(parts[1], 10) * 60 + parseFloat(parts[2]);
}
// --- Theme & Style ---
function getCurrentTheme() {
return document.documentElement.getAttribute('dark') === 'true' || document.body.getAttribute('dark') === 'true' ? 'dark' : 'light';
}
function updateButtonAppearance() {
if (!copyButtonLiElement || !copyButtonElement) return;
const theme = getCurrentTheme();
const svgIcon = copyButtonElement.querySelector('svg');
if (!svgIcon) return;
if (theme === 'dark') {
copyButtonLiElement.style.backgroundColor = 'rgba(70, 70, 77, 0.8)';
svgIcon.style.stroke = '#e0e0e0';
} else {
copyButtonLiElement.style.backgroundColor = 'rgba(225, 226, 230, 0.9)';
svgIcon.style.stroke = '#4a4a52';
}
}
function applyGlobalStyles() {
const styleId = 'm3u8UserScriptGlobalStyle';
if (document.getElementById(styleId)) document.getElementById(styleId).remove();
const isDark = getCurrentTheme() === 'dark';
GM_addStyle(`
:root {
--modal-bg: ${isDark ? '#252529' : '#f7f7f8'}; --modal-text: ${isDark ? '#f0f0f1' : '#1a1a1c'};
--modal-border: ${isDark ? '#3a3a3f' : '#e1e2e6'}; --modal-item-hover: ${isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)'};
--modal-footer-bg: ${isDark ? '#1e1e21' : '#f0f0f2'}; --btn-bg: ${isDark ? '#3a3a3f' : '#fff'};
--btn-border: ${isDark ? '#555' : '#d8d8de'}; --btn-hover-bg: ${isDark ? '#505055' : '#f5f5f5'};
--btn-active-bg: ${isDark ? '#4785ff' : '#0062f3'}; --btn-active-border: ${isDark ? '#4785ff' : '#0062f3'};
--success-bg: ${isDark ? '#2E7D32' : '#28a745'}; --toast-bg: ${isDark ? 'rgba(40, 40, 45, 0.95)' : 'rgba(247, 247, 248, 0.95)'};
--toast-text: ${isDark ? '#f0f0f1' : '#1a1a1c'}; --toast-border: ${isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)'};
--disabled-bg: ${isDark ? '#404044' : '#e9e9ed'};
--disabled-text: ${isDark ? '#777' : '#999'};
}
#m3u8CopyButtonUserScriptItem { display: inline-flex; align-items: center; justify-content: center; vertical-align: middle; margin-right: 8px; border-radius: 50%; width: 30px; height: 30px; transition: background-color 0.2s ease, transform 0.1s ease; }
#m3u8CopyButtonUserScriptItem:hover { background-color: ${isDark ? 'rgba(85, 85, 92, 0.9)' : 'rgba(210, 211, 215, 1)'} !important; }
#m3u8CopyButtonUserScriptItem:active { transform: scale(0.9); }
#m3u8CopyButtonUserScriptButton { background-color: transparent !important; border: none !important; cursor: pointer !important; padding: 0 !important; display: flex !important; align-items: center !important; justify-content: center !important; width: 100%; height: 100%; border-radius: 50%; }
.m3u8-toast-message-userscript { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); background-color: var(--toast-bg); color: var(--toast-text); padding: 12px 24px; border-radius: 8px; z-index: 9999999; font-size: 14px; opacity: 0; transition: opacity 0.4s ease-in-out, bottom 0.4s ease-in-out; backdrop-filter: blur(5px); box-shadow: 0 4px 15px rgba(0,0,0,0.2); border: 1px solid var(--toast-border); text-align: center; font-weight: 500; }
.m3u8-toast-message-userscript.show { opacity: 1; bottom: 70px; }
.vod-modal-overlay, .sub-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 100000; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(8px); opacity: 0; transition: opacity 0.3s ease; pointer-events: none; }
.vod-modal-overlay.visible, .sub-modal-overlay.visible { opacity: 1; pointer-events: auto; }
.vod-modal-content { background-color: var(--modal-bg); color: var(--modal-text); border: 1px solid var(--modal-border); width: 90%; max-width: 800px; max-height: 85vh; border-radius: 16px; display: flex; flex-direction: column; box-shadow: 0 10px 30px rgba(0,0,0,0.2); transform: scale(0.95); transition: transform 0.3s ease; overflow: hidden; }
.vod-modal-overlay.visible .vod-modal-content { transform: scale(1); }
.vod-modal-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; flex-shrink: 0; border-bottom: 1px solid var(--modal-border); }
.vod-modal-header h2 { font-size: 1.1rem; font-weight: 700; margin: 0; }
.vod-edit-notice { margin: 16px 24px 0; padding: 12px 15px; border-radius: 8px; font-size: 0.85rem; line-height: 1.5; text-align: center; background-color: ${isDark ? 'rgba(255, 193, 7, 0.1)' : 'rgba(255, 165, 0, 0.1)'}; border: 1px solid ${isDark ? 'rgba(255, 193, 7, 0.3)' : 'rgba(255, 165, 0, 0.3)'}; color: ${isDark ? '#ffc107' : '#d9480f'}; }
.vod-modal-header .close-btn { cursor: pointer; background: none; border: none; padding: 5px; color: var(--modal-text); display: flex; align-items: center; justify-content: center; border-radius: 50%; opacity: 0.7; transition: opacity 0.2s, background-color 0.2s; }
.vod-modal-header .close-btn:hover { opacity: 1; background-color: var(--modal-item-hover); }
.vod-modal-body { overflow-y: auto; padding: 8px 8px 8px 24px; }
.vod-modal-list { list-style: none; padding: 0; margin: 0; }
.vod-modal-item { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 14px 0; border-bottom: 1px solid var(--modal-border); transition: background-color 0.2s ease; }
.vod-modal-item:last-child { border-bottom: none; }
.vod-modal-item:hover { background-color: var(--modal-item-hover); }
.vod-modal-item-info { display: flex; flex-direction: column; flex-grow: 1; overflow: hidden; margin-right: 20px; }
.vod-modal-item-date { font-size: 0.9rem; font-weight: 600; margin-bottom: 6px; font-family: 'SF Mono', Consolas, 'Courier New', monospace; }
.vod-modal-item-url { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'SF Mono', Consolas, 'Courier New', monospace; font-size: 0.8rem; opacity: 0.7; }
.vod-modal-copy-btn, .sub-modal-download-btn { padding: 8px 12px; border-radius: 8px; border: 1px solid var(--btn-border); background-color: var(--btn-bg); color: var(--modal-text); cursor: pointer; font-weight: 600; font-size: 0.85rem; flex-shrink: 0; display: flex; align-items: center; gap: 6px; transition: all 0.2s ease; text-decoration: none; }
.vod-modal-copy-btn:hover, .sub-modal-download-btn:hover { background-color: var(--btn-hover-bg); border-color: var(--btn-border); }
.vod-modal-copy-btn:disabled, .sub-modal-download-btn:disabled { background-color: var(--disabled-bg) !important; color: var(--disabled-text) !important; border-color: transparent !important; cursor: not-allowed !important; }
.vod-modal-copy-btn:disabled svg, .sub-modal-download-btn:disabled svg { stroke: var(--disabled-text) !important; }
.vod-modal-copy-btn.copied, .sub-modal-download-btn.downloading { background-color: var(--success-bg) !important; border-color: var(--success-bg) !important; color: white !important; }
.vod-modal-footer { padding: 16px 24px; flex-shrink: 0; border-top: 1px solid var(--modal-border); background-color: var(--modal-footer-bg); }
.vod-modal-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 10px; }
.vod-modal-actions button { width: 100%; justify-content: center; }
.sub-modal-content { background-color: var(--modal-bg); color: var(--modal-text); border: 1px solid var(--modal-border); width: 90%; max-width: 750px; border-radius: 12px; box-shadow: 0 5px 20px rgba(0,0,0,0.2); display: flex; flex-direction: column; max-height: 90vh; }
.sub-modal-header { padding: 16px 20px; border-bottom: 1px solid var(--modal-border); display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; }
.sub-modal-header h3 { margin: 0; font-size: 1.1em; }
.sub-modal-body { padding: 16px 20px; overflow-y: auto; }
.ffmpeg-os-tabs { display: flex; gap: 10px; margin-bottom: 15px; }
.ffmpeg-os-tabs button { flex-grow: 1; }
.ffmpeg-os-tabs button.active { background-color: var(--btn-active-bg); border-color: var(--btn-active-border); color: white; }
.ffmpeg-command-area { display: none; }
.ffmpeg-command-area.active { display: block; }
.ffmpeg-textarea { width: 100%; min-height: 220px; height: auto; padding: 15px; border-radius: 6px; font-family: 'SF Mono', Consolas, 'Courier New', monospace; font-size: 0.9em; line-height: 1.5; background-color: var(--modal-footer-bg); color: var(--modal-text); border: 1px solid var(--modal-border); resize: none; white-space: pre-wrap; word-break: break-all; }
.clipping-controls { border: 1px solid var(--modal-border); border-radius: 8px; padding: 15px; margin-top: 15px; display: none; }
.clipping-controls.active { display: block; }
.clipping-controls label { font-weight: 600; margin-bottom: 10px; display: flex; align-items: center; cursor: pointer; user-select: none;}
.clipping-controls input[type="checkbox"] { margin-right: 10px; transform: scale(1.2); }
.clipping-options { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
.clipping-options input[type="text"] { flex-grow: 1; padding: 8px; border-radius: 4px; border: 1px solid var(--btn-border); background-color: var(--btn-bg); color: var(--modal-text); font-family: 'SF Mono', Consolas; }
.clipping-options button { padding: 8px; }
`);
}
// --- Download Controller ---
class DownloadController {
constructor() {
this.isCancelled = false;
}
cancel() {
this.isCancelled = true;
}
}
// --- Modal Logic ---
function openVodListModal(vodFiles) {
if (document.getElementById('vodListModalOverlay')) return;
const { writer_nick, broad_start, full_title, total_file_duration, write_tm } = currentBroadcastInfo.vodInfo;
const vodStartDate = new Date(broad_start.replace(/-/g, '/'));
const safeTitle = (full_title || "soop_vod").replace(/[<>:"/\\|?*!]/g, '_');
const safeNick = (writer_nick || "BJ").replace(/[<>:"/\\|?*!]/g, '_');
const formattedDate = `${vodStartDate.getFullYear()}${(vodStartDate.getMonth()+1).toString().padStart(2,'0')}${vodStartDate.getDate().toString().padStart(2,'0')}`;
let vodFilename = `${formattedDate}_${safeNick}_${safeTitle}`;
let editNoticeHtml = '';
if (write_tm && total_file_duration) {
try {
const [startStr, endStr] = write_tm.split(' ~ ');
const startTime = new Date(startStr.replace(/-/g, '/'));
const endTime = new Date(endStr.replace(/-/g, '/'));
const expectedDuration = (endTime - startTime) / 1000;
const actualDuration = total_file_duration / 1000;
if (actualDuration < expectedDuration - 60) {
editNoticeHtml = `<div class="vod-edit-notice"><strong>[주의]</strong> 이 VOD는 원본 방송에서 일부 편집되었거나 '같이보기' 영상일 수 있습니다.</div>`;
}
} catch(e) {
console.error("Error parsing VOD edit time:", e);
}
}
let mainListHtml = '';
let totalDurationSec = 0;
vodFiles.forEach((file, index) => {
const durationSec = file.duration / 1000;
totalDurationSec += durationSec;
const startTime = new Date(file.file_start.replace(/-/g, '/'));
const endTime = new Date(startTime.getTime() + file.duration);
mainListHtml += `
<li class="vod-modal-item">
<div class="vod-modal-item-info">
<div class="vod-modal-item-date">[${String(index + 1).padStart(2, '0')}] ${formatShortDate(startTime)} ~ ${formatShortDate(endTime)} (${formatKoreanDuration(durationSec)})</div>
<span class="vod-modal-item-url">${file.file}</span>
</div>
<button class="vod-modal-copy-btn" data-url="${file.file}">${copyIconSvg}<span>링크 복사</span></button>
</li>`;
});
const isFSApiSupported = !!window.showSaveFilePicker;
const mainModalHtml = `
<div class="vod-modal-overlay" id="vodListModalOverlay">
<div class="vod-modal-content" id="vodListModalContent">
<div class="vod-modal-header">
<h2>분할된 VOD 목록 (${vodFiles.length}개) - 총 길이: ${formatKoreanDuration(totalDurationSec)}</h2>
<button class="close-btn" id="vodModalCloseBtn" title="닫기">${closeIconSvg}</button>
</div>
${editNoticeHtml}
<div class="vod-modal-body"><ul class="vod-modal-list">${mainListHtml}</ul></div>
<div class="vod-modal-footer">
<div class="vod-modal-actions">
<button class="vod-modal-copy-btn" id="vodModalAllCopyBtn">${copyIconSvg} <span>모든 링크 복사</span></button>
<button class="vod-modal-copy-btn" id="vodModalFfmpegBtn" disabled title="개발 중인 기능입니다.">${terminalIconSvg} <span>(개발중) FFmpeg 명령어 생성</span></button>
<button class="vod-modal-copy-btn" id="vodModalBrowserDownloadBtn" disabled title="개발 중인 기능입니다.">
${downloadIconSvg} <span>(개발중) 브라우저에서 다운로드</span>
</button>
</div>
</div>
</div>
</div>`;
function generateFfmpegModal() {
const randomFolderName = Math.random().toString(36).substring(2, 11);
const ffmpegInputOptions = `-f concat -safe 0 -allowed_extensions ALL -protocol_whitelist file,http,https,tcp,tls,crypto`;
let ffmpegWinCmd = `set "DOWNLOAD_DIR=${randomFolderName}" & mkdir "%DOWNLOAD_DIR%" & (for %i in (${vodFiles.map(f => `'${f.file}'`).join(' ')}) do @echo file %i) > "%DOWNLOAD_DIR%\\filelist.txt" & ffmpeg ${ffmpegInputOptions} -i "%DOWNLOAD_DIR%\\filelist.txt" -c copy "%DOWNLOAD_DIR%\\${vodFilename}.mp4" & rmdir /s /q "%DOWNLOAD_DIR%\\filelist.txt" & echo. & echo *** 다운로드 완료! 저장 폴더: %DOWNLOAD_DIR% ***`;
let ffmpegMacCmd = `DOWNLOAD_DIR="${randomFolderName}"; mkdir -p "$DOWNLOAD_DIR"; printf "file '%s'\\n" ${vodFiles.map(f => `'${f.file.replace(/'/g, "'\\''")}'`).join(' ')} > "$DOWNLOAD_DIR/filelist.txt" && ffmpeg ${ffmpegInputOptions} -i "$DOWNLOAD_DIR/filelist.txt" -c copy "$DOWNLOAD_DIR/'${vodFilename}.mp4'" && rm "$DOWNLOAD_DIR/filelist.txt" && echo "✅ 다운로드 완료! 저장 폴더: $DOWNLOAD_DIR"`;
const clippingEnabled = document.getElementById('clipping-checkbox')?.checked;
if (clippingEnabled) {
try {
const startSec = parseTimeToSeconds(document.getElementById('clip-start-time').value);
const endSec = parseTimeToSeconds(document.getElementById('clip-end-time').value);
if (startSec >= endSec) {
showToastMessage("오류: 시작 시간은 종료 시간보다 빨라야 합니다.", false);
return null;
}
const clipCommands = [];
const concatList = [];
let accumulatedDuration = 0;
vodFiles.forEach((item, index) => {
const itemDuration = item.duration / 1000;
const itemStart = accumulatedDuration;
const itemEnd = accumulatedDuration + itemDuration;
if (itemEnd > startSec && itemStart < endSec) {
const clipStart = Math.max(0, startSec - itemStart);
const clipDuration = (Math.min(itemEnd, endSec) - itemStart) - clipStart;
const outputFilename = `part_${index + 1}.ts`;
let command = `ffmpeg -ss ${formatTime(clipStart, true)} -i "${item.file}" -t ${clipDuration.toFixed(3)} -c copy`;
clipCommands.push({ command, outputFilename });
concatList.push(`file '${outputFilename}'`);
}
accumulatedDuration += itemDuration;
});
if (clipCommands.length > 0) {
const finalFilename = `${vodFilename}_${formatTime(startSec).replace(/:/g,'')}-${formatTime(endSec).replace(/:/g,'')}`;
const winClipCmds = clipCommands.map(c => `${c.command} "%TEMP_DIR%\\${c.outputFilename}"`).join(' & ');
const winConcatList = concatList.map(c => `echo ${c.replace(/'/g, '')}`).join(' & ');
ffmpegWinCmd = `@echo off\nchcp 65001 >nul\nset "TEMP_DIR=${randomFolderName}__temp"\nmkdir "%TEMP_DIR%"\n${winClipCmds}\n(${winConcatList}) > "%TEMP_DIR%\\list.txt"\nffmpeg -f concat -safe 0 -i "%TEMP_DIR%\\list.txt" -c copy "${finalFilename}.mp4"\nrmdir /s /q "%TEMP_DIR%"\necho.\necho *** 클립 다운로드 완료: ${finalFilename}.mp4 ***\npause`;
const macClipCmds = clipCommands.map(c => `${c.command} "$TEMP_DIR/${c.outputFilename}"`).join(' && ');
const macConcatList = concatList.map(c => `printf "${c}\\n"`).join('; ');
ffmpegMacCmd = `TEMP_DIR="${randomFolderName}__temp"; mkdir -p "$TEMP_DIR" && ${macClipCmds} && (${macConcatList}) > "$TEMP_DIR/list.txt" && ffmpeg -f concat -safe 0 -i "$TEMP_DIR/list.txt" -c copy '${finalFilename}.mp4' && rm -rf "$TEMP_DIR" && echo "✅ 클립 다운로드 완료: ${finalFilename}.mp4"`;
}
} catch (e) {
showToastMessage("오류: 시간 형식이 잘못되었습니다 (HH:MM:SS).", false);
console.error("Time parse error:", e);
return null;
}
}
let existingModal = document.getElementById('ffmpegModalOverlay');
if(existingModal) existingModal.remove();
const ffmpegModalHtml = `
<div class="sub-modal-overlay" id="ffmpegModalOverlay">
<div class="sub-modal-content">
<div class="sub-modal-header"><h3>FFmpeg 명령어 생성</h3><button class="close-btn" data-target="ffmpegModalOverlay">${closeIconSvg}</button></div>
<div class="sub-modal-body">
<div class="clipping-controls active">
<label><input type="checkbox" id="clipping-checkbox" ${clippingEnabled ? 'checked' : ''}>✂️ VOD 일부만 잘라서 받기</label>
<div class="clipping-options" style="display: ${clippingEnabled ? 'flex' : 'none'}">
<input type="text" id="clip-start-time" value="${formatTime(0)}" placeholder="HH:MM:SS">
<span>~</span>
<input type="text" id="clip-end-time" value="${formatTime(totalDurationSec)}" placeholder="HH:MM:SS">
<button id="set-full-time-btn" class="vod-modal-copy-btn">전체 시간</button>
</div>
</div>
<div class="ffmpeg-os-tabs"><button class="vod-modal-copy-btn active" data-os="win">Windows</button><button class="vod-modal-copy-btn" data-os="mac">macOS / Linux</button></div>
<div id="win-cmd" class="ffmpeg-command-area active"><textarea class="ffmpeg-textarea" readonly>${ffmpegWinCmd}</textarea></div>
<div id="mac-cmd" class="ffmpeg-command-area"><textarea class="ffmpeg-textarea" readonly>${ffmpegMacCmd}</textarea></div>
<button class="vod-modal-copy-btn" id="ffmpegCopyCmdBtn" style="width:100%; margin-top: 15px; justify-content: center;">${copyIconSvg}<span>Windows 명령어 복사</span></button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', ffmpegModalHtml);
if(clippingEnabled){
const startTimeInput = document.getElementById('clip-start-time');
const endTimeInput = document.getElementById('clip-end-time');
if (startTimeInput) startTimeInput.value = startTimeInput.value || formatTime(0);
if (endTimeInput) endTimeInput.value = endTimeInput.value || formatTime(totalDurationSec);
}
return document.getElementById('ffmpegModalOverlay');
}
const downloadNotice = isFSApiSupported
? `최신 브라우저가 감지되었습니다. <strong>File System Access API</strong>를 사용하여<br>메모리를 거의 사용하지 않는 실시간 스트리밍 다운로드를 시작합니다.`
: `이 기능은 현재 사용 중인 브라우저에서 지원되지 않습니다.<br><strong>Chrome, Edge, Opera</strong> 등 최신 브라우저를 사용해 주세요.`;
const downloadListHtml = vodFiles.map((file, index) => `
<li class="vod-modal-item">
<div class="vod-modal-item-info"><strong>Part ${String(index + 1).padStart(2, '0')}</strong><span class="vod-modal-item-url">${file.file}</span></div>
<button class="sub-modal-download-btn" data-url="${file.file}" data-filename="${vodFilename}_part${String(index + 1).padStart(2, '0')}.ts" ${!isFSApiSupported ? 'disabled' : ''}>
${downloadIconSvg}<span>${isFSApiSupported ? '다운로드' : '지원 안함'}</span>
</button>
</li>`).join('');
const downloadModalHtml = `
<div class="sub-modal-overlay" id="browserDownloadModalOverlay">
<div class="sub-modal-content">
<div class="sub-modal-header"><h3>브라우저에서 직접 다운로드</h3><button class="close-btn" data-target="browserDownloadModalOverlay">${closeIconSvg}</button></div>
<div class="sub-modal-body">
<p style="font-size: 0.9em; margin: 0 0 15px; opacity: 0.9; line-height:1.6; padding: 10px; background-color: var(--modal-footer-bg); border-radius: 6px;">${downloadNotice}</p>
<ul class="vod-modal-list">${downloadListHtml}</ul>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', mainModalHtml + downloadModalHtml);
setTimeout(() => document.getElementById('vodListModalOverlay').classList.add('visible'), 10);
const handleCopy = (button, text, msg) => {
GM_setClipboard(text, 'text');
showToastMessage(msg);
const original = button.innerHTML;
button.innerHTML = `${checkIconSvg} <span>복사 완료!</span>`;
button.classList.add('copied');
button.disabled = true;
setTimeout(() => { button.innerHTML = original; button.classList.remove('copied'); button.disabled = false; }, 2000);
};
const openSubModal = (id) => document.getElementById(id)?.classList.add('visible');
const closeSubModal = (id) => {
if (currentDownloadController) {
currentDownloadController.cancel();
}
document.getElementById(id)?.classList.remove('visible');
}
function setupFfmpegModalEvents() {
const ffmpegModal = document.getElementById('ffmpegModalOverlay');
if(!ffmpegModal) return;
ffmpegModal.querySelector('.close-btn').addEventListener('click', () => closeSubModal('ffmpegModalOverlay'));
const ffmpegCopyBtn = ffmpegModal.querySelector('#ffmpegCopyCmdBtn');
const osTabs = ffmpegModal.querySelectorAll('.ffmpeg-os-tabs button');
osTabs.forEach(tab => {
tab.addEventListener('click', () => {
osTabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
ffmpegModal.querySelectorAll('.ffmpeg-command-area').forEach(area => area.classList.remove('active'));
const os = tab.dataset.os;
ffmpegModal.querySelector(`#${os}-cmd`).classList.add('active');
ffmpegCopyBtn.querySelector('span').textContent = `${os === 'win' ? 'Windows' : 'macOS'} 명령어 복사`;
});
});
ffmpegCopyBtn.addEventListener('click', (e) => {
const activeOS = ffmpegModal.querySelector('.ffmpeg-os-tabs button.active').dataset.os;
const command = ffmpegModal.querySelector(`#${activeOS}-cmd .ffmpeg-textarea`).value;
handleCopy(e.currentTarget, command, `${activeOS === 'win' ? 'Windows' : 'macOS'} 명령어가 복사되었습니다.`);
});
const clippingCheckbox = document.getElementById('clipping-checkbox');
const clippingOptions = document.querySelector('.clipping-options');
const setFullTimeBtn = document.getElementById('set-full-time-btn');
clippingCheckbox.addEventListener('change', (e) => {
clippingOptions.style.display = e.target.checked ? 'flex' : 'none';
generateFfmpegModal();
setupFfmpegModalEvents();
});
document.getElementById('clip-start-time').addEventListener('change', () => { generateFfmpegModal(); setupFfmpegModalEvents(); });
document.getElementById('clip-end-time').addEventListener('change', () => { generateFfmpegModal(); setupFfmpegModalEvents(); });
setFullTimeBtn.addEventListener('click', () => {
document.getElementById('clip-start-time').value = formatTime(0);
document.getElementById('clip-end-time').value = formatTime(totalDurationSec);
generateFfmpegModal();
setupFfmpegModalEvents();
});
}
document.getElementById('vodModalCloseBtn').addEventListener('click', () => {
if (currentDownloadController) {
currentDownloadController.cancel();
}
const overlay = document.getElementById('vodListModalOverlay');
overlay.classList.remove('visible');
setTimeout(() => {
const ffmpegModal = document.getElementById('ffmpegModalOverlay');
const downloadModal = document.getElementById('browserDownloadModalOverlay');
if (overlay) overlay.remove();
if (ffmpegModal) ffmpegModal.remove();
if (downloadModal) downloadModal.remove();
}, 300);
});
document.getElementById('vodModalAllCopyBtn').addEventListener('click', (e) => handleCopy(e.currentTarget, vodFiles.map(f => f.file).join('\n'), "모든 VOD 링크가 복사되었습니다."));
document.querySelectorAll('.vod-modal-item .vod-modal-copy-btn').forEach(btn => btn.addEventListener('click', (e) => handleCopy(e.currentTarget, e.currentTarget.getAttribute('data-url'), "선택한 VOD 링크가 복사되었습니다.")));
// These buttons are now disabled, so these event listeners won't be triggered by a user click.
// They are left here in case the buttons are re-enabled in a future version.
document.getElementById('vodModalFfmpegBtn').addEventListener('click', () => {
let ffmpegModal = generateFfmpegModal();
if (ffmpegModal) {
openSubModal('ffmpegModalOverlay');
setupFfmpegModalEvents();
}
});
document.getElementById('vodModalBrowserDownloadBtn').addEventListener('click', () => openSubModal('browserDownloadModalOverlay'));
document.querySelectorAll('.sub-modal-header .close-btn[data-target="browserDownloadModalOverlay"]').forEach(btn => btn.addEventListener('click', (e) => closeSubModal(e.currentTarget.dataset.target)));
document.querySelectorAll('.sub-modal-download-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const button = e.currentTarget;
if (currentDownloadController) {
showToastMessage("다른 다운로드가 이미 진행 중입니다.", false);
return;
}
const m3u8Url = button.dataset.url;
const filename = button.dataset.filename;
button.disabled = true;
button.innerHTML = `<span>준비 중...</span>`;
downloadWithFileSystemAPI(m3u8Url, filename, button);
});
});
}
// --- Real-time Streaming via File System Access API ---
async function downloadWithFileSystemAPI(m3u8PlaylistUrl, filename, button) {
currentDownloadController = new DownloadController();
let writableStream = null;
try {
if (!m3u8PlaylistUrl.includes('/playlist.m3u8')) {
showToastMessage('오래된 VOD 형식은 스트리밍 다운로드를 지원하지 않습니다.<br>FFmpeg를 사용하거나 링크를 직접 복사하세요.', false);
resetButton(button, false);
return;
}
const baseUrl = m3u8PlaylistUrl.replace('/playlist.m3u8', '');
const [videoManifest, audioManifest] = await Promise.all([
fetchManifest(`${baseUrl}/video.m3u8?cv=v1`),
fetchManifest(`${baseUrl}/audio.m3u8?cv=v1`)
]);
const allSegments = [...parseManifest(videoManifest), ...parseManifest(audioManifest)];
const totalSegments = allSegments.length;
const fileHandle = await window.showSaveFilePicker({
suggestedName: filename,
types: [{ description: 'MPEG-TS Video', accept: { 'video/mp2t': ['.ts'] } }],
});
writableStream = await fileHandle.createWritable();
showToastMessage(`총 ${totalSegments}개 조각 실시간 다운로드 시작...`, false);
for (let i = 0; i < totalSegments; i++) {
if (currentDownloadController.isCancelled) {
showToastMessage('다운로드가 중단되었습니다.');
break;
}
button.innerHTML = `<span>${i + 1} / ${totalSegments}</span>`;
const segmentBlob = await downloadSegment(`${baseUrl}/${allSegments[i]}`);
await writableStream.write(segmentBlob);
}
if (!currentDownloadController.isCancelled) {
showToastMessage('다운로드 완료!', true);
button.innerHTML = `${checkIconSvg} <span>저장 완료</span>`;
resetButton(button, true);
}
} catch (error) {
if (error.name === 'AbortError') {
showToastMessage('파일 저장이 취소되었습니다.');
} else if (!currentDownloadController.isCancelled) {
console.error('File System API Download failed:', error);
showToastMessage(`오류 발생: ${error.message}`, false);
}
} finally {
if (writableStream) {
await writableStream.close();
}
currentDownloadController = null;
if (!button.innerHTML.includes("완료")) {
resetButton(button, false);
}
}
}
function resetButton(button, isSuccess) {
if (isSuccess) {
setTimeout(() => {
button.disabled = false;
button.innerHTML = `${downloadIconSvg}<span>다운로드</span>`;
}, 3000);
} else {
button.disabled = false;
button.innerHTML = `${downloadIconSvg}<span>다운로드</span>`;
}
}
function fetchManifest(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET', url: url,
onload: (response) => {
if (response.status === 200) resolve(response.responseText);
else reject(new Error(`매니페스트 로드 실패 (${response.status})`));
},
onerror: () => reject(new Error('네트워크 오류'))
});
});
}
function parseManifest(manifestText) {
return manifestText.split('\n').filter(line => line.trim() && !line.startsWith('#'));
}
function downloadSegment(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET', url: url, responseType: 'blob',
onload: (response) => {
if (response.status === 200) resolve(response.response);
else reject(new Error(`조각 다운로드 실패 (${response.status})`));
},
onerror: () => reject(new Error('네트워크 오류'))
});
});
}
// --- Core Logic ---
async function handleCopyButtonClick() {
if (!currentBroadcastInfo) { showToastMessage("방송 정보를 아직 가져오지 못했습니다."); return; }
if (isVodPage) {
if (Array.isArray(currentBroadcastInfo.vodFiles) && currentBroadcastInfo.vodFiles.length > 0) {
openVodListModal(currentBroadcastInfo.vodFiles);
} else { showToastMessage("VOD 정보를 찾지 못했습니다.<br>잠시 후 다시 시도해주세요."); }
} else {
showToastMessage("m3u8 링크 추출 중...", false);
const aid = await getLiveBroadAid(currentBroadcastInfo.userId, currentBroadcastInfo.broadNo);
if (aid) {
const m3u8Link = `https://live-global-cdn-v02.sooplive.co.kr/live-stm-12/auth_playlist.m3u8?aid=${aid}`;
GM_setClipboard(m3u8Link, 'text');
showToastMessage("LIVE m3u8 링크가 클립보드에 복사되었습니다.");
} else { showToastMessage("LIVE m3u8 링크 추출에 실패했습니다. (AID 오류)"); }
}
}
function ensureCopyLinkButton() {
let itemListContainer = document.querySelector('.broadcast_information .player_item_list ul');
if (!itemListContainer) itemListContainer = document.querySelector('.broadcast_information .column[number="1"] .player_item_list ul');
if (!itemListContainer) return;
if (!document.getElementById('m3u8CopyButtonUserScriptItem')) {
copyButtonLiElement = document.createElement('li');
copyButtonLiElement.id = 'm3u8CopyButtonUserScriptItem';
copyButtonElement = document.createElement('button');
copyButtonElement.id = 'm3u8CopyButtonUserScriptButton';
copyButtonElement.innerHTML = linkIconSvg;
copyButtonElement.title = '스트림/VOD 링크 복사 및 도구';
copyButtonElement.addEventListener('click', handleCopyButtonClick);
copyButtonLiElement.appendChild(copyButtonElement);
itemListContainer.insertBefore(copyButtonLiElement, itemListContainer.firstChild);
}
updateButtonAppearance();
}
function setupMainObserver() {
const broadcastInfoArea = document.querySelector('.broadcast_information');
if (!broadcastInfoArea) { setTimeout(setupMainObserver, 500); return; }
const mainObserver = new MutationObserver(() => ensureCopyLinkButton());
mainObserver.observe(broadcastInfoArea, { childList: true, subtree: true });
ensureCopyLinkButton();
console.log("SOOP Script: Main Observer is running.");
}
function fetchVodInfoDirectly() {
const vodId = window.location.pathname.split('/')[2];
if (!vodId || isNaN(parseInt(vodId))) return;
GM_xmlhttpRequest({
method: "POST", url: "https://api.m.sooplive.co.kr/station/video/a/view",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
data: `nTitleNo=${vodId}&nPlaylistIdx=0`,
onload: (response) => {
try {
const data = JSON.parse(response.responseText);
if (data?.result === 1 && Array.isArray(data.data.files)) {
const modifiedFiles = data.data.files.map(file => {
if (file.file && file.file.includes('.smil/playlist.m3u8')) {
file.file = file.file.replace('.smil/playlist.m3u8', '.mp4/playlist.m3u8');
}
return file;
});
currentBroadcastInfo.vodFiles = modifiedFiles;
currentBroadcastInfo.vodInfo = data.data;
console.log(`[VOD] Fetched and modified ${modifiedFiles.length} VOD file(s) for original quality.`);
setupMainObserver();
} else { showToastMessage("VOD 정보 분석 실패."); }
} catch (e) { console.error("VOD Info Parse Error:", e); showToastMessage("VOD 정보 분석 오류."); }
},
onerror: () => { showToastMessage("VOD 정보 요청 실패 (네트워크 오류)."); }
});
}
function initializeScript() {
console.log(`SOOP Link Copy Script v4.4 Initializing (Page: ${isVodPage ? 'VOD' : 'LIVE'})`);
applyGlobalStyles();
const themeObserver = new MutationObserver(() => { applyGlobalStyles(); updateButtonAppearance(); });
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['dark'] });
themeObserver.observe(document.body, { attributes: true, attributeFilter: ['dark'] });
if (isVodPage) fetchVodInfoDirectly();
else {
currentBroadcastInfo = getLiveBroadcastInfo();
if (currentBroadcastInfo) setupMainObserver();
}
}
// --- Script Execution ---
if (document.readyState === 'complete' || document.readyState === 'interactive') {
initializeScript();
} else {
window.addEventListener('DOMContentLoaded', initializeScript, false);
}
})();