diff --git a/docs/TODO.md b/docs/TODO.md index 23ff2d5..c353136 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -13,6 +13,10 @@ - 고급 기능: 40~50% ## In Progress +- [~] yt-dlp 번들 내장/탐지 + - [x] 런타임 탐지에 번들 resources 경로 추가 (`src-tauri/resources/engine/**/yt-dlp`) + - [x] 번들 동기화 스크립트 추가 (`npm run sync:ytdlp`) + - [ ] Linux/Windows용 yt-dlp 바이너리 동기화 확장 - [~] 범주(Category) 기능 단계 구현 - [x] Step 1: 범주 기본 데이터/설정 토글/추가 모달 범주 선택 + 저장 경로 자동 반영 - [ ] Step 2: 범주 관리 UI(추가/수정/삭제, 아이콘/확장자 규칙 편집) diff --git a/package.json b/package.json index ef1cc2a..7698df9 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "tauri:build": "bash scripts/version-bump.sh && tauri build", "version:bump": "bash scripts/version-bump.sh", "sync:aria2": "bash scripts/sync-aria2-from-motrix.sh", + "sync:ytdlp": "bash scripts/sync-ytdlp-bundle.sh", "native-host:smoke": "cd tools/native-host && npm run smoke", "native-host:install": "bash tools/native-host/install-macos.sh makoclohjdpempbndoaljeadpngefhcf", "native-host:uninstall": "bash tools/native-host/uninstall-macos.sh" diff --git a/scripts/sync-ytdlp-bundle.sh b/scripts/sync-ytdlp-bundle.sh new file mode 100755 index 0000000..6986642 --- /dev/null +++ b/scripts/sync-ytdlp-bundle.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +DST_BASE="$ROOT_DIR/src-tauri/resources/engine" + +TMP_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +mkdir -p "$DST_BASE/darwin/arm64" "$DST_BASE/darwin/x64" + +YTDLP_URL="${YTDLP_URL:-https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos}" +TARGET_FILE="$TMP_DIR/yt-dlp" + +echo "[sync-ytdlp] downloading: $YTDLP_URL" +curl -fL "$YTDLP_URL" -o "$TARGET_FILE" +chmod +x "$TARGET_FILE" + +cp "$TARGET_FILE" "$DST_BASE/darwin/arm64/yt-dlp" +cp "$TARGET_FILE" "$DST_BASE/darwin/x64/yt-dlp" +chmod +x "$DST_BASE/darwin/arm64/yt-dlp" "$DST_BASE/darwin/x64/yt-dlp" + +echo "[sync-ytdlp] copied to:" +echo " - $DST_BASE/darwin/arm64/yt-dlp" +echo " - $DST_BASE/darwin/x64/yt-dlp" diff --git a/src-tauri/resources/engine/darwin/arm64/yt-dlp b/src-tauri/resources/engine/darwin/arm64/yt-dlp new file mode 100755 index 0000000..f5009f7 Binary files /dev/null and b/src-tauri/resources/engine/darwin/arm64/yt-dlp differ diff --git a/src-tauri/resources/engine/darwin/x64/yt-dlp b/src-tauri/resources/engine/darwin/x64/yt-dlp new file mode 100755 index 0000000..f5009f7 Binary files /dev/null and b/src-tauri/resources/engine/darwin/x64/yt-dlp differ diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index e3d1145..c29f0ce 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -42,6 +42,21 @@ pub struct Aria2AddUriRequest { pub position: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct YtDlpAddUriRequest { + pub rpc: Aria2RpcConfig, + pub url: String, + pub out: Option, + pub dir: Option, + pub split: Option, + pub format: Option, + pub referer: Option, + pub user_agent: Option, + pub options: Option>, + pub position: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Aria2AddTorrentRequest { @@ -799,6 +814,207 @@ fn is_gid_not_found_error(message: &str) -> bool { lower.contains("not found for gid#") } +fn default_ytdlp_binary() -> String { + if cfg!(target_os = "windows") { + "yt-dlp.exe".to_string() + } else { + "yt-dlp".to_string() + } +} + +fn resolve_ytdlp_binary(app: &tauri::AppHandle) -> Option { + let binary_name = default_ytdlp_binary(); + let mut candidates: Vec = Vec::new(); + + for key in ["YTDLP_BIN", "YTDLP_PATH"] { + if let Ok(raw) = env::var(key) { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + candidates.push(trimmed.to_string()); + } + } + } + + if let Ok(path_var) = env::var("PATH") { + for dir in env::split_paths(&path_var) { + candidate_push(&dir.join(&binary_name), &mut candidates); + #[cfg(target_os = "windows")] + { + if !binary_name.to_ascii_lowercase().ends_with(".exe") { + candidate_push(&dir.join(format!("{binary_name}.exe")), &mut candidates); + } + } + } + } + + let source_engine_base = Path::new(env!("CARGO_MANIFEST_DIR")).join("resources").join("engine"); + let mut engine_bases = vec![source_engine_base]; + if let Ok(resource_dir) = app.path().resource_dir() { + engine_bases.push(resource_dir.join("engine")); + } + + let platforms = platform_aliases(); + let arches = arch_aliases(); + for base in engine_bases { + for platform in &platforms { + for arch in &arches { + candidate_push(&base.join(platform).join(arch).join(&binary_name), &mut candidates); + } + candidate_push(&base.join(platform).join(&binary_name), &mut candidates); + } + candidate_push(&base.join(&binary_name), &mut candidates); + } + + if cfg!(target_os = "macos") { + for candidate in ["/opt/homebrew/bin/yt-dlp", "/usr/local/bin/yt-dlp", "/usr/bin/yt-dlp"] { + candidates.push(candidate.to_string()); + } + } + + let mut deduped: Vec = Vec::with_capacity(candidates.len()); + for candidate in candidates { + if !deduped.contains(&candidate) { + deduped.push(candidate); + } + } + + deduped.into_iter().find(|path| Path::new(path).is_file()) +} + +fn run_ytdlp_get_url( + binary: &str, + page_url: &str, + format: Option<&str>, + referer: Option<&str>, + user_agent: Option<&str>, +) -> Result { + let mut cmd = Command::new(binary); + cmd.arg("--no-playlist"); + cmd.arg("--get-url"); + if let Some(fmt) = format { + let trimmed = fmt.trim(); + if !trimmed.is_empty() { + cmd.arg("-f"); + cmd.arg(trimmed); + } + } + 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); + } + } + cmd.arg(page_url.trim()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let output = cmd + .output() + .map_err(|err| format!("yt-dlp 실행 실패: {err}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!( + "yt-dlp 처리 실패(status={}): {}", + output.status, + if stderr.is_empty() { "unknown error" } else { &stderr } + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let direct = stdout + .lines() + .map(|line| line.trim()) + .find(|line| !line.is_empty() && (line.starts_with("http://") || line.starts_with("https://"))) + .map(|line| line.to_string()) + .ok_or_else(|| "yt-dlp 결과에서 다운로드 URL을 찾지 못했습니다.".to_string())?; + + Ok(direct) +} + +fn sanitize_output_filename(raw: &str) -> String { + let mut value = raw.trim().replace(['\r', '\n', '\t'], " "); + value = value + .chars() + .map(|ch| match ch { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + _ => ch, + }) + .collect::(); + while value.contains(" ") { + value = value.replace(" ", " "); + } + value.trim_matches('.').trim().to_string() +} + +fn run_ytdlp_get_filename( + binary: &str, + page_url: &str, + format: Option<&str>, + referer: Option<&str>, + user_agent: Option<&str>, +) -> Result, String> { + let mut cmd = Command::new(binary); + cmd.arg("--no-playlist"); + cmd.arg("--get-filename"); + cmd.arg("-o"); + cmd.arg("%(title).200B.%(ext)s"); + if let Some(fmt) = format { + let trimmed = fmt.trim(); + if !trimmed.is_empty() { + cmd.arg("-f"); + cmd.arg(trimmed); + } + } + 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); + } + } + cmd.arg(page_url.trim()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let output = cmd + .output() + .map_err(|err| format!("yt-dlp 파일명 추출 실패: {err}"))?; + if !output.status.success() { + return Ok(None); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let raw = stdout + .lines() + .map(|line| line.trim()) + .find(|line| !line.is_empty()) + .unwrap_or(""); + if raw.is_empty() { + return Ok(None); + } + + let sanitized = sanitize_output_filename(raw); + if sanitized.is_empty() { + return Ok(None); + } + Ok(Some(sanitized)) +} + #[tauri::command] pub fn engine_start( app: tauri::AppHandle, @@ -968,6 +1184,51 @@ pub async fn aria2_add_uri(request: Aria2AddUriRequest) -> Result Result { + let page_url = request.url.trim(); + if page_url.is_empty() { + return Err("url is required".to_string()); + } + + let binary = resolve_ytdlp_binary(&app).ok_or_else(|| { + "yt-dlp 실행 파일을 찾을 수 없습니다. 번들(resources/engine) 또는 시스템 경로를 확인하세요." + .to_string() + })?; + + let direct_url = run_ytdlp_get_url( + &binary, + page_url, + request.format.as_deref(), + request.referer.as_deref(), + request.user_agent.as_deref(), + )?; + let suggested_out = run_ytdlp_get_filename( + &binary, + page_url, + request.format.as_deref(), + request.referer.as_deref(), + request.user_agent.as_deref(), + )?; + let final_out = if request.out.as_ref().map(|v| v.trim().is_empty()).unwrap_or(true) { + suggested_out + } else { + request.out.clone() + }; + + let aria_request = Aria2AddUriRequest { + rpc: request.rpc, + uri: direct_url, + out: final_out, + dir: request.dir, + split: request.split, + options: request.options, + position: request.position, + }; + + aria2_add_uri(aria_request).await +} + #[tauri::command] pub async fn aria2_add_torrent(request: Aria2AddTorrentRequest) -> Result { if request.torrent_base64.trim().is_empty() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3d60c39..7f73800 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,7 +13,7 @@ use engine::{ aria2_remove_task_record, aria2_resume_all, aria2_resume_task, detect_aria2_binary, engine_start, engine_status, engine_stop, load_torrent_file, - open_path_in_file_manager, stop_engine_for_exit, EngineState, + open_path_in_file_manager, stop_engine_for_exit, yt_dlp_add_uri, EngineState, }; #[tauri::command] @@ -46,6 +46,8 @@ struct ExternalAddRequest { cookie: Option, proxy: Option, split: Option, + extractor: Option, + format: Option, } fn default_extension_ids() -> Vec { @@ -266,6 +268,7 @@ pub fn run() { detect_aria2_binary, aria2_add_torrent, aria2_add_uri, + yt_dlp_add_uri, aria2_change_global_option, aria2_get_task_detail, aria2_list_tasks, diff --git a/src/App.vue b/src/App.vue index 005df77..795cd76 100644 --- a/src/App.vue +++ b/src/App.vue @@ -24,6 +24,7 @@ import { resumeAria2Task, startEngine, stopEngine, + ytDlpAddUri, type Aria2Task, type Aria2TaskDetail, type Aria2TaskSnapshot, @@ -168,6 +169,8 @@ 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 addUrl = ref('') const addOut = ref('') const addSplit = ref(64) @@ -277,6 +280,12 @@ function applyExternalAddPayload(payload: ExternalAddPayload) { if (payload.cookie) addCookie.value = payload.cookie if (payload.proxy) addProxy.value = payload.proxy if (payload.split) addSplit.value = payload.split + if (payload.extractor === 'yt-dlp') { + addExtractor.value = 'yt-dlp' + } + if (payload.format) { + addYtdlpFormat.value = payload.format + } if (payload.out) { applySuggestedCategory(payload.out) } else { @@ -1531,14 +1540,27 @@ async function onSubmitAddTask() { } const gids: string[] = [] for (const uri of uris) { - const gid = await addAria2Uri({ - rpc: rpcConfig(), - uri, - out: uris.length === 1 ? (addOut.value.trim() || undefined) : undefined, - dir: targetDir, - split: addSplit.value, - options: Object.keys(addOptions).length > 0 ? addOptions : undefined, - }) + const gid = + addExtractor.value === 'yt-dlp' + ? await ytDlpAddUri({ + rpc: rpcConfig(), + url: uri, + out: uris.length === 1 ? (addOut.value.trim() || undefined) : undefined, + dir: targetDir, + split: addSplit.value, + format: addYtdlpFormat.value.trim() || undefined, + referer: addReferer.value.trim() || undefined, + userAgent: addUserAgent.value.trim() || undefined, + options: Object.keys(addOptions).length > 0 ? addOptions : undefined, + }) + : await addAria2Uri({ + rpc: rpcConfig(), + uri, + out: uris.length === 1 ? (addOut.value.trim() || undefined) : undefined, + dir: targetDir, + split: addSplit.value, + 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) @@ -1568,6 +1590,8 @@ async function onSubmitAddTask() { } addUrl.value = '' + addExtractor.value = 'aria2' + addYtdlpFormat.value = 'bestvideo*+bestaudio/best' addOut.value = '' addShowAdvanced.value = false addUserAgent.value = '' @@ -2229,6 +2253,19 @@ onUnmounted(() => { URL