mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(media): preserve yt-dlp output names
This commit is contained in:
@@ -306,11 +306,14 @@ pub enum QueueDirection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, TS)]
|
#[derive(Clone, Debug, Serialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "../../src/bindings/")]
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
pub struct DownloadStateEvent {
|
pub struct DownloadStateEvent {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
|
#[ts(optional)]
|
||||||
|
pub file_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DownloadStateEvent {
|
impl DownloadStateEvent {
|
||||||
@@ -319,6 +322,7 @@ impl DownloadStateEvent {
|
|||||||
id: id.into(),
|
id: id.into(),
|
||||||
status: status.as_str().to_string(),
|
status: status.as_str().to_string(),
|
||||||
error: None,
|
error: None,
|
||||||
|
file_name: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,6 +331,16 @@ impl DownloadStateEvent {
|
|||||||
id: id.into(),
|
id: id.into(),
|
||||||
status: DownloadStatus::Failed.as_str().to_string(),
|
status: DownloadStatus::Failed.as_str().to_string(),
|
||||||
error: Some(error.into()),
|
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(),
|
id: id.into(),
|
||||||
status: DownloadStatus::Retrying.as_str().to_string(),
|
status: DownloadStatus::Retrying.as_str().to_string(),
|
||||||
error: Some(reason.into()),
|
error: Some(reason.into()),
|
||||||
|
file_name: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-22
@@ -65,6 +65,18 @@ fn is_media_processing_line(line: &str) -> bool {
|
|||||||
|| lower.contains("post-process")
|
|| 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> {
|
fn json_str<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str> {
|
||||||
value
|
value
|
||||||
.get(key)
|
.get(key)
|
||||||
@@ -2399,7 +2411,7 @@ pub(crate) async fn start_media_download_internal(
|
|||||||
user_agent: Option<String>,
|
user_agent: Option<String>,
|
||||||
max_tries: Option<i32>,
|
max_tries: Option<i32>,
|
||||||
cancel_rx: &mut tokio::sync::watch::Receiver<bool>,
|
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 safe_filename = crate::download_ownership::canonical_download_filename(&filename);
|
||||||
|
|
||||||
let resolved_dest = resolve_path(&destination, &app_handle);
|
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 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 {
|
let total_tracks: f64 = if let Some(ref format) = format_selector {
|
||||||
if format.contains('+') {
|
if format.contains('+') {
|
||||||
@@ -2519,8 +2533,10 @@ pub(crate) async fn start_media_download_internal(
|
|||||||
.arg("--continue")
|
.arg("--continue")
|
||||||
.arg("--compat-options")
|
.arg("--compat-options")
|
||||||
.arg("no-youtube-unavailable-videos")
|
.arg("no-youtube-unavailable-videos")
|
||||||
|
.arg("--print")
|
||||||
|
.arg("after_move:%(filepath)s")
|
||||||
.arg("-o")
|
.arg("-o")
|
||||||
.arg(out_path.to_string_lossy().to_string())
|
.arg(output_template.to_string_lossy().to_string())
|
||||||
.env("PATH", &trusted_path);
|
.env("PATH", &trusted_path);
|
||||||
|
|
||||||
if let Some(limit) = speed_limit.as_ref() {
|
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);
|
log::info!("yt-dlp spawned for id: {} (strike {})", id, strike);
|
||||||
|
|
||||||
let mut stderr_tail = String::new();
|
let mut stderr_tail = String::new();
|
||||||
|
let mut final_output_path: Option<std::path::PathBuf> = None;
|
||||||
let failure_reason = loop {
|
let failure_reason = loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = cancel_rx.changed() => {
|
_ = cancel_rx.changed() => {
|
||||||
@@ -2626,30 +2643,38 @@ pub(crate) async fn start_media_download_internal(
|
|||||||
Some(tauri_plugin_shell::process::CommandEvent::Stdout(line_bytes)) => {
|
Some(tauri_plugin_shell::process::CommandEvent::Stdout(line_bytes)) => {
|
||||||
let line = String::from_utf8_lossy(&line_bytes);
|
let line = String::from_utf8_lossy(&line_bytes);
|
||||||
if let Some(progress) = parse_media_progress_line(&line) {
|
if let Some(progress) = parse_media_progress_line(&line) {
|
||||||
let previous_track = current_track;
|
let previous_track = current_track;
|
||||||
let overall_fraction = aggregate_media_fraction(
|
let overall_fraction = aggregate_media_fraction(
|
||||||
total_tracks,
|
total_tracks,
|
||||||
&mut current_track,
|
&mut current_track,
|
||||||
&mut last_fraction,
|
&mut last_fraction,
|
||||||
progress.fraction,
|
progress.fraction,
|
||||||
);
|
);
|
||||||
if current_track != previous_track {
|
if current_track != previous_track {
|
||||||
last_speed_sample = None;
|
last_speed_sample = None;
|
||||||
}
|
}
|
||||||
let (speed, eta) = media_progress_speed(&progress, std::time::Instant::now(), &mut last_speed_sample);
|
let (speed, eta) = media_progress_speed(&progress, std::time::Instant::now(), &mut last_speed_sample);
|
||||||
|
|
||||||
let now = std::time::Instant::now();
|
let now = std::time::Instant::now();
|
||||||
if now.duration_since(last_progress_at) >= std::time::Duration::from_millis(200) {
|
if now.duration_since(last_progress_at) >= std::time::Duration::from_millis(200) {
|
||||||
let _ = app_handle.emit("download-progress", DownloadProgressEvent {
|
let _ = app_handle.emit("download-progress", DownloadProgressEvent {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
fraction: overall_fraction,
|
fraction: overall_fraction,
|
||||||
speed,
|
speed,
|
||||||
eta,
|
eta,
|
||||||
size: progress.size,
|
size: progress.size,
|
||||||
size_is_final: false,
|
size_is_final: false,
|
||||||
});
|
});
|
||||||
last_progress_at = now;
|
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)) => {
|
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)) => {
|
Some(tauri_plugin_shell::process::CommandEvent::Terminated(payload)) => {
|
||||||
if payload.code == Some(0) {
|
if payload.code == Some(0) {
|
||||||
log::info!("yt-dlp completed successfully for id: {}", id);
|
log::info!("yt-dlp completed successfully id: {}", id);
|
||||||
if let Ok(metadata) = tokio::fs::metadata(&out_path).await {
|
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() {
|
if metadata.is_file() {
|
||||||
let _ = app_handle.emit("download-progress", DownloadProgressEvent {
|
let _ = app_handle.emit("download-progress", DownloadProgressEvent {
|
||||||
id: id.to_string(),
|
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);
|
log::error!("yt-dlp exited with non-zero code {:?} for id: {}", payload.code, id);
|
||||||
cleanup_media_artifacts(&out_path, false).await;
|
cleanup_media_artifacts(&out_path, false).await;
|
||||||
@@ -4199,13 +4229,30 @@ fn set_extension_frontend_ready(state: tauri::State<'_, AppState>, ready: bool)
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
aggregate_media_fraction, build_media_format_options, collect_download_uris,
|
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,
|
normalize_speed_limit_for_aria2, parse_firelink_deep_link, parse_media_progress_line,
|
||||||
redact_log_line, FirelinkDeepLink, MediaProgress, MEDIA_PROGRESS_PREFIX,
|
redact_log_line, FirelinkDeepLink, MediaProgress, MEDIA_PROGRESS_PREFIX,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::time::{Duration, Instant};
|
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]
|
#[test]
|
||||||
fn normalizes_bare_global_speed_limits_as_kib_per_second() {
|
fn normalizes_bare_global_speed_limits_as_kib_per_second() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -1221,20 +1221,21 @@ impl SidecarSpawner for ProductionSpawner {
|
|||||||
&mut cancel_rx,
|
&mut cancel_rx,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if outcome.is_ok() {
|
if let Ok(path) = outcome.as_ref() {
|
||||||
if let Ok(path) = crate::download_ownership::expected_primary_path(
|
let _ = crate::download_ownership::set_primary_path(&self.app_handle, id, path);
|
||||||
&self.app_handle,
|
if let Some(file_name) = path.file_name().and_then(|name| name.to_str()) {
|
||||||
&payload.destination,
|
use tauri::Emitter;
|
||||||
&payload.filename,
|
let _ = self.app_handle.emit(
|
||||||
) {
|
"download-state",
|
||||||
let _ = crate::download_ownership::set_primary_path(&self.app_handle, id, &path);
|
crate::ipc::DownloadStateEvent::completed_with_file(id, file_name),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = state
|
let _ = state
|
||||||
.download_coordinator
|
.download_coordinator
|
||||||
.finish_media(id.to_string())
|
.finish_media(id.to_string())
|
||||||
.await;
|
.await;
|
||||||
outcome
|
outcome.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_native(&self, id: &str, payload: &SpawnPayload) -> Result<(), String> {
|
async fn run_native(&self, id: &str, payload: &SpawnPayload) -> Result<(), String> {
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// 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, };
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
|||||||
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||||
import { listenEvent as listen } from '../ipc';
|
import { listenEvent as listen } from '../ipc';
|
||||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||||
|
import { categoryForFileName } from '../utils/downloads';
|
||||||
|
|
||||||
interface DownloadProgressState {
|
interface DownloadProgressState {
|
||||||
progressMap: Record<string, DownloadProgressEvent>;
|
progressMap: Record<string, DownloadProgressEvent>;
|
||||||
@@ -70,6 +71,10 @@ export async function initDownloadListener() {
|
|||||||
status,
|
status,
|
||||||
...(progress ? { fraction: progress.fraction } : {})
|
...(progress ? { fraction: progress.fraction } : {})
|
||||||
};
|
};
|
||||||
|
if (payload.fileName && payload.fileName !== current.fileName) {
|
||||||
|
updates.fileName = payload.fileName;
|
||||||
|
updates.category = categoryForFileName(payload.fileName);
|
||||||
|
}
|
||||||
if (status !== 'downloading') {
|
if (status !== 'downloading') {
|
||||||
updates.speed = '-';
|
updates.speed = '-';
|
||||||
updates.eta = '-';
|
updates.eta = '-';
|
||||||
|
|||||||
Reference in New Issue
Block a user