fix(media): preserve yt-dlp output names

This commit is contained in:
NimBold
2026-07-01 14:45:55 +03:30
parent fdca17910a
commit 6b7eaa1f66
5 changed files with 99 additions and 31 deletions
+15
View File
@@ -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<String>,
#[ts(optional)]
pub file_name: Option<String>,
}
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<String>, file_name: impl Into<String>) -> 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,
}
}
}
+69 -22
View File
@@ -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<String>,
max_tries: Option<i32>,
cancel_rx: &mut tokio::sync::watch::Receiver<bool>,
) -> Result<(), String> {
) -> Result<std::path::PathBuf, String> {
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<std::path::PathBuf> = 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!(
+9 -8
View File
@@ -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> {
+1 -1
View File
@@ -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, };
+5
View File
@@ -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<string, DownloadProgressEvent>;
@@ -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 = '-';