feat: bundle yt-dlp and improve add modal usability

This commit is contained in:
tongki078
2026-02-26 12:16:51 +09:00
parent d85fdc1101
commit 5d8fb9db55
11 changed files with 399 additions and 35 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -42,6 +42,21 @@ pub struct Aria2AddUriRequest {
pub position: Option<u32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct YtDlpAddUriRequest {
pub rpc: Aria2RpcConfig,
pub url: String,
pub out: Option<String>,
pub dir: Option<String>,
pub split: Option<u16>,
pub format: Option<String>,
pub referer: Option<String>,
pub user_agent: Option<String>,
pub options: Option<BTreeMap<String, Value>>,
pub position: Option<u32>,
}
#[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<String> {
let binary_name = default_ytdlp_binary();
let mut candidates: Vec<String> = 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<String> = 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<String, String> {
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::<String>();
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<Option<String>, 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<String, String
.ok_or_else(|| format!("aria2.addUri returned unexpected result: {result}"))
}
#[tauri::command]
pub async fn yt_dlp_add_uri(app: tauri::AppHandle, request: YtDlpAddUriRequest) -> Result<String, String> {
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<String, String> {
if request.torrent_base64.trim().is_empty() {

View File

@@ -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<String>,
proxy: Option<String>,
split: Option<u32>,
extractor: Option<String>,
format: Option<String>,
}
fn default_extension_ids() -> Vec<String> {
@@ -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,