From 6b7eaa1f66d1fef3af7316ef2072dd180a2f875c Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 1 Jul 2026 14:45:55 +0330 Subject: [PATCH] fix(media): preserve yt-dlp output names --- src-tauri/src/ipc.rs | 15 +++++ src-tauri/src/lib.rs | 91 ++++++++++++++++++++++-------- src-tauri/src/queue.rs | 17 +++--- src/bindings/DownloadStateEvent.ts | 2 +- src/store/downloadStore.ts | 5 ++ 5 files changed, 99 insertions(+), 31 deletions(-) diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 355d815..a8b8549 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -306,11 +306,14 @@ pub enum QueueDirection { } #[derive(Clone, Debug, Serialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export, export_to = "../../src/bindings/")] pub struct DownloadStateEvent { pub id: String, pub status: String, pub error: Option, + #[ts(optional)] + pub file_name: Option, } impl DownloadStateEvent { @@ -319,6 +322,7 @@ impl DownloadStateEvent { id: id.into(), status: status.as_str().to_string(), error: None, + file_name: None, } } @@ -327,6 +331,16 @@ impl DownloadStateEvent { id: id.into(), status: DownloadStatus::Failed.as_str().to_string(), error: Some(error.into()), + file_name: None, + } + } + + pub fn completed_with_file(id: impl Into, file_name: impl Into) -> Self { + Self { + id: id.into(), + status: DownloadStatus::Completed.as_str().to_string(), + error: None, + file_name: Some(file_name.into()), } } @@ -337,6 +351,7 @@ impl DownloadStateEvent { id: id.into(), status: DownloadStatus::Retrying.as_str().to_string(), error: Some(reason.into()), + file_name: None, } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0bb620d..2fa38d5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -65,6 +65,18 @@ fn is_media_processing_line(line: &str) -> bool { || lower.contains("post-process") } +fn media_output_template( + resolved_dest: &std::path::Path, + safe_filename: &str, + format_selector: Option<&str>, +) -> std::path::PathBuf { + if format_selector.is_none() && std::path::Path::new(safe_filename).extension().is_none() { + resolved_dest.join("%(title).200B [%(id)s].%(ext)s") + } else { + resolved_dest.join(safe_filename) + } +} + fn json_str<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str> { value .get(key) @@ -2399,7 +2411,7 @@ pub(crate) async fn start_media_download_internal( user_agent: Option, max_tries: Option, cancel_rx: &mut tokio::sync::watch::Receiver, -) -> Result<(), String> { +) -> Result { let safe_filename = crate::download_ownership::canonical_download_filename(&filename); let resolved_dest = resolve_path(&destination, &app_handle); @@ -2413,6 +2425,8 @@ pub(crate) async fn start_media_download_internal( } let out_path = resolved_dest.join(&safe_filename); + let output_template = + media_output_template(&resolved_dest, &safe_filename, format_selector.as_deref()); let total_tracks: f64 = if let Some(ref format) = format_selector { if format.contains('+') { @@ -2519,8 +2533,10 @@ pub(crate) async fn start_media_download_internal( .arg("--continue") .arg("--compat-options") .arg("no-youtube-unavailable-videos") + .arg("--print") + .arg("after_move:%(filepath)s") .arg("-o") - .arg(out_path.to_string_lossy().to_string()) + .arg(output_template.to_string_lossy().to_string()) .env("PATH", &trusted_path); if let Some(limit) = speed_limit.as_ref() { @@ -2611,6 +2627,7 @@ pub(crate) async fn start_media_download_internal( log::info!("yt-dlp spawned for id: {} (strike {})", id, strike); let mut stderr_tail = String::new(); + let mut final_output_path: Option = None; let failure_reason = loop { tokio::select! { _ = cancel_rx.changed() => { @@ -2626,30 +2643,38 @@ pub(crate) async fn start_media_download_internal( Some(tauri_plugin_shell::process::CommandEvent::Stdout(line_bytes)) => { let line = String::from_utf8_lossy(&line_bytes); if let Some(progress) = parse_media_progress_line(&line) { - let previous_track = current_track; - let overall_fraction = aggregate_media_fraction( - total_tracks, - &mut current_track, - &mut last_fraction, - progress.fraction, - ); - if current_track != previous_track { - last_speed_sample = None; - } - let (speed, eta) = media_progress_speed(&progress, std::time::Instant::now(), &mut last_speed_sample); + let previous_track = current_track; + let overall_fraction = aggregate_media_fraction( + total_tracks, + &mut current_track, + &mut last_fraction, + progress.fraction, + ); + if current_track != previous_track { + last_speed_sample = None; + } + let (speed, eta) = media_progress_speed(&progress, std::time::Instant::now(), &mut last_speed_sample); let now = std::time::Instant::now(); if now.duration_since(last_progress_at) >= std::time::Duration::from_millis(200) { let _ = app_handle.emit("download-progress", DownloadProgressEvent { id: id.to_string(), fraction: overall_fraction, - speed, - eta, - size: progress.size, - size_is_final: false, - }); + speed, + eta, + size: progress.size, + size_is_final: false, + }); last_progress_at = now; } + } else { + let candidate = line.trim(); + if !candidate.is_empty() { + let candidate_path = std::path::PathBuf::from(candidate); + if candidate_path.is_absolute() { + final_output_path = Some(candidate_path); + } + } } } Some(tauri_plugin_shell::process::CommandEvent::Stderr(line_bytes)) => { @@ -2713,8 +2738,13 @@ pub(crate) async fn start_media_download_internal( } Some(tauri_plugin_shell::process::CommandEvent::Terminated(payload)) => { if payload.code == Some(0) { - log::info!("yt-dlp completed successfully for id: {}", id); - if let Ok(metadata) = tokio::fs::metadata(&out_path).await { + log::info!("yt-dlp completed successfully id: {}", id); + let completed_path = final_output_path + .as_ref() + .filter(|path| path.is_file()) + .cloned() + .unwrap_or_else(|| out_path.clone()); + if let Ok(metadata) = tokio::fs::metadata(&completed_path).await { if metadata.is_file() { let _ = app_handle.emit("download-progress", DownloadProgressEvent { id: id.to_string(), @@ -2726,7 +2756,7 @@ pub(crate) async fn start_media_download_internal( }); } } - return Ok(()); + return Ok(completed_path); } log::error!("yt-dlp exited with non-zero code {:?} for id: {}", payload.code, id); cleanup_media_artifacts(&out_path, false).await; @@ -4199,13 +4229,30 @@ fn set_extension_frontend_ready(state: tauri::State<'_, AppState>, ready: bool) mod tests { use super::{ aggregate_media_fraction, build_media_format_options, collect_download_uris, - is_excluded_yt_dlp_format, json_lower, media_progress_speed, + is_excluded_yt_dlp_format, json_lower, media_output_template, media_progress_speed, normalize_speed_limit_for_aria2, parse_firelink_deep_link, parse_media_progress_line, redact_log_line, FirelinkDeepLink, MediaProgress, MEDIA_PROGRESS_PREFIX, }; use serde_json::json; use std::time::{Duration, Instant}; + #[test] + fn media_metadata_fallback_lets_ytdlp_choose_extension() { + let destination = std::path::Path::new("/tmp/firelink"); + let template = media_output_template(destination, "1234567890", None); + assert_eq!( + template, + destination.join("%(title).200B [%(id)s].%(ext)s") + ); + } + + #[test] + fn selected_media_format_keeps_requested_output_path() { + let destination = std::path::Path::new("/tmp/firelink"); + let template = media_output_template(destination, "clip.mp4", Some("best")); + assert_eq!(template, destination.join("clip.mp4")); + } + #[test] fn normalizes_bare_global_speed_limits_as_kib_per_second() { assert_eq!( diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 63dc89a..5182b61 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -1221,20 +1221,21 @@ impl SidecarSpawner for ProductionSpawner { &mut cancel_rx, ) .await; - if outcome.is_ok() { - if let Ok(path) = crate::download_ownership::expected_primary_path( - &self.app_handle, - &payload.destination, - &payload.filename, - ) { - let _ = crate::download_ownership::set_primary_path(&self.app_handle, id, &path); + if let Ok(path) = outcome.as_ref() { + let _ = crate::download_ownership::set_primary_path(&self.app_handle, id, path); + if let Some(file_name) = path.file_name().and_then(|name| name.to_str()) { + use tauri::Emitter; + let _ = self.app_handle.emit( + "download-state", + crate::ipc::DownloadStateEvent::completed_with_file(id, file_name), + ); } } let _ = state .download_coordinator .finish_media(id.to_string()) .await; - outcome + outcome.map(|_| ()) } async fn run_native(&self, id: &str, payload: &SpawnPayload) -> Result<(), String> { diff --git a/src/bindings/DownloadStateEvent.ts b/src/bindings/DownloadStateEvent.ts index 9d5a8a7..8db08c3 100644 --- a/src/bindings/DownloadStateEvent.ts +++ b/src/bindings/DownloadStateEvent.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DownloadStateEvent = { id: string, status: string, error: string | null, }; +export type DownloadStateEvent = { id: string, status: string, error: string | null, fileName?: string, }; diff --git a/src/store/downloadStore.ts b/src/store/downloadStore.ts index bd3389a..d4c9348 100644 --- a/src/store/downloadStore.ts +++ b/src/store/downloadStore.ts @@ -4,6 +4,7 @@ import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent'; import type { DownloadStatus } from '../bindings/DownloadStatus'; import { listenEvent as listen } from '../ipc'; import type { DownloadItem } from '../bindings/DownloadItem'; +import { categoryForFileName } from '../utils/downloads'; interface DownloadProgressState { progressMap: Record; @@ -70,6 +71,10 @@ export async function initDownloadListener() { status, ...(progress ? { fraction: progress.fraction } : {}) }; + if (payload.fileName && payload.fileName !== current.fileName) { + updates.fileName = payload.fileName; + updates.category = categoryForFileName(payload.fileName); + } if (status !== 'downloading') { updates.speed = '-'; updates.eta = '-';