diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index c29f0ce..6cada91 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -887,6 +887,7 @@ fn run_ytdlp_get_url( format: Option<&str>, referer: Option<&str>, user_agent: Option<&str>, + extra_headers: &[String], ) -> Result { let mut cmd = Command::new(binary); cmd.arg("--no-playlist"); @@ -912,6 +913,13 @@ fn run_ytdlp_get_url( cmd.arg(trimmed); } } + for header in extra_headers { + let trimmed = header.trim(); + if !trimmed.is_empty() { + cmd.arg("--add-header"); + cmd.arg(trimmed); + } + } cmd.arg(page_url.trim()); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); @@ -960,6 +968,7 @@ fn run_ytdlp_get_filename( format: Option<&str>, referer: Option<&str>, user_agent: Option<&str>, + extra_headers: &[String], ) -> Result, String> { let mut cmd = Command::new(binary); cmd.arg("--no-playlist"); @@ -987,6 +996,13 @@ fn run_ytdlp_get_filename( cmd.arg(trimmed); } } + for header in extra_headers { + let trimmed = header.trim(); + if !trimmed.is_empty() { + cmd.arg("--add-header"); + cmd.arg(trimmed); + } + } cmd.arg(page_url.trim()); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); @@ -1015,6 +1031,118 @@ fn run_ytdlp_get_filename( Ok(Some(sanitized)) } +fn looks_like_hls_url(raw: &str) -> bool { + let lower = raw.to_ascii_lowercase(); + lower.contains(".m3u8") || lower.contains(".m3u") || lower.contains("m3u8") +} + +fn run_ytdlp_direct_download( + binary: &str, + page_url: &str, + out: Option<&str>, + dir: Option<&str>, + format: Option<&str>, + referer: Option<&str>, + user_agent: Option<&str>, + extra_headers: &[String], +) -> Result { + let mut cmd = Command::new(binary); + cmd.arg("--no-playlist"); + cmd.arg("--merge-output-format"); + cmd.arg("mp4"); + cmd.arg("--remux-video"); + cmd.arg("mp4"); + cmd.arg("--restrict-filenames"); + + let fmt = format + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + .unwrap_or("best"); + cmd.arg("-f"); + cmd.arg(fmt); + + if let Some(value) = referer { + let trimmed = value.trim(); + if !trimmed.is_empty() { + cmd.arg("--referer"); + cmd.arg(trimmed); + } + } + if let Some(value) = user_agent { + let trimmed = value.trim(); + if !trimmed.is_empty() { + cmd.arg("--user-agent"); + cmd.arg(trimmed); + } + } + for header in extra_headers { + let trimmed = header.trim(); + if !trimmed.is_empty() { + cmd.arg("--add-header"); + cmd.arg(trimmed); + } + } + if let Some(value) = dir { + let trimmed = value.trim(); + if !trimmed.is_empty() { + cmd.arg("--paths"); + cmd.arg(trimmed); + } + } + if let Some(value) = out { + let trimmed = value.trim(); + if !trimmed.is_empty() { + cmd.arg("-o"); + cmd.arg(trimmed); + } + } else { + cmd.arg("-o"); + cmd.arg("%(title).180B.%(ext)s"); + } + + cmd.arg(page_url.trim()); + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::null()); + cmd.stderr(Stdio::null()); + + let mut child = cmd + .spawn() + .map_err(|err| format!("yt-dlp 다운로드 시작 실패: {err}"))?; + let pid = child.id(); + + std::thread::spawn(move || { + let _ = child.wait(); + }); + + Ok(format!("ytdlp:{pid}")) +} + +fn ytdlp_headers_from_options(options: Option<&BTreeMap>) -> Vec { + let Some(options) = options else { + return Vec::new(); + }; + let Some(raw) = options.get("header") else { + return Vec::new(); + }; + + match raw { + Value::String(v) => { + let trimmed = v.trim(); + if trimmed.is_empty() { + Vec::new() + } else { + vec![trimmed.to_string()] + } + } + Value::Array(arr) => arr + .iter() + .filter_map(|item| item.as_str().map(|v| v.trim().to_string())) + .filter(|v| !v.is_empty()) + .collect(), + _ => Vec::new(), + } +} + #[tauri::command] pub fn engine_start( app: tauri::AppHandle, @@ -1195,6 +1323,20 @@ pub async fn yt_dlp_add_uri(app: tauri::AppHandle, request: YtDlpAddUriRequest) "yt-dlp 실행 파일을 찾을 수 없습니다. 번들(resources/engine) 또는 시스템 경로를 확인하세요." .to_string() })?; + let extra_headers = ytdlp_headers_from_options(request.options.as_ref()); + + if looks_like_hls_url(page_url) { + return run_ytdlp_direct_download( + &binary, + page_url, + request.out.as_deref(), + request.dir.as_deref(), + request.format.as_deref(), + request.referer.as_deref(), + request.user_agent.as_deref(), + &extra_headers, + ); + } let direct_url = run_ytdlp_get_url( &binary, @@ -1202,6 +1344,7 @@ pub async fn yt_dlp_add_uri(app: tauri::AppHandle, request: YtDlpAddUriRequest) request.format.as_deref(), request.referer.as_deref(), request.user_agent.as_deref(), + &extra_headers, )?; let suggested_out = run_ytdlp_get_filename( &binary, @@ -1209,6 +1352,7 @@ pub async fn yt_dlp_add_uri(app: tauri::AppHandle, request: YtDlpAddUriRequest) request.format.as_deref(), request.referer.as_deref(), request.user_agent.as_deref(), + &extra_headers, )?; let final_out = if request.out.as_ref().map(|v| v.trim().is_empty()).unwrap_or(true) { suggested_out diff --git a/src/App.vue b/src/App.vue index 795cd76..00278dd 100644 --- a/src/App.vue +++ b/src/App.vue @@ -170,7 +170,7 @@ const rpcTestMessage = ref('') const showAddModal = ref(false) const addTab = ref('url') const addExtractor = ref<'aria2' | 'yt-dlp'>('aria2') -const addYtdlpFormat = ref('bestvideo*+bestaudio/best') +const addYtdlpFormat = ref('bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best') const addUrl = ref('') const addOut = ref('') const addSplit = ref(64) @@ -1456,6 +1456,10 @@ function guessFileNameFromUri(uri: string): string { } } +function isYtDlpDetachedGid(gid: string): boolean { + return gid.startsWith('ytdlp:') +} + function prependPendingTask(gid: string, fileName: string, uri: string) { const exists = [...tasks.value.active, ...tasks.value.waiting, ...tasks.value.stopped].some((task) => task.gid === gid) if (exists) return @@ -1562,10 +1566,16 @@ async function onSubmitAddTask() { options: Object.keys(addOptions).length > 0 ? addOptions : undefined, }) gids.push(gid) - void inspectAddedTask(gid) - prependPendingTask(gid, uris.length === 1 ? addOut.value || guessFileNameFromUri(uri) : guessFileNameFromUri(uri), uri) + if (!isYtDlpDetachedGid(gid)) { + void inspectAddedTask(gid) + prependPendingTask(gid, uris.length === 1 ? addOut.value || guessFileNameFromUri(uri) : guessFileNameFromUri(uri), uri) + } + } + if (gids.some((gid) => isYtDlpDetachedGid(gid))) { + pushSuccess(`${gids.length}개 작업이 시작되었습니다. (m3u8/hls는 yt-dlp 직접 다운로드)`) + } else { + pushSuccess(`${gids.length}개 작업이 추가되었습니다.`) } - pushSuccess(`${gids.length}개 작업이 추가되었습니다.`) } else { if (!torrentBase64.value.trim()) { pushError('.torrent 파일을 선택하세요.') @@ -1591,7 +1601,7 @@ async function onSubmitAddTask() { addUrl.value = '' addExtractor.value = 'aria2' - addYtdlpFormat.value = 'bestvideo*+bestaudio/best' + addYtdlpFormat.value = 'bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best' addOut.value = '' addShowAdvanced.value = false addUserAgent.value = '' @@ -2263,7 +2273,7 @@ onUnmounted(() => {