feat: route m3u8 to yt-dlp direct with header-aware handling

This commit is contained in:
tongki078
2026-02-26 13:02:20 +09:00
parent 5d8fb9db55
commit f4e0243ac2
2 changed files with 160 additions and 6 deletions

View File

@@ -887,6 +887,7 @@ fn run_ytdlp_get_url(
format: Option<&str>,
referer: Option<&str>,
user_agent: Option<&str>,
extra_headers: &[String],
) -> Result<String, String> {
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<Option<String>, 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<String, String> {
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<String, Value>>) -> Vec<String> {
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

View File

@@ -170,7 +170,7 @@ const rpcTestMessage = ref('')
const showAddModal = ref(false)
const addTab = ref<AddTab>('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(() => {
</label>
<label v-if="addExtractor === 'yt-dlp'">
<span>yt-dlp 포맷</span>
<input v-model="addYtdlpFormat" type="text" placeholder="bestvideo*+bestaudio/best" />
<input v-model="addYtdlpFormat" type="text" placeholder="bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best" />
</label>
</div>
</template>