Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 97 additions & 35 deletions src-tauri/src/commands/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ fn build_queue_prefix(
}

fn build_output_template(
output_path: &str,
number_playlist_items: bool,
playlist_index: Option<u32>,
playlist_total: Option<u32>,
Expand All @@ -101,11 +100,10 @@ fn build_output_template(
let prefix = build_playlist_prefix(number_playlist_items, playlist_index, playlist_total)
.or_else(|| build_queue_prefix(number_queue_items, queue_index, queue_total))
.unwrap_or_default();
format!("{output_path}/{prefix}%(title)s.%(ext)s")
format!("{prefix}%(title)s.%(ext)s")
}

fn build_chapter_output_template(
output_path: &str,
number_playlist_items: bool,
playlist_index: Option<u32>,
playlist_total: Option<u32>,
Expand All @@ -122,7 +120,11 @@ fn build_chapter_output_template(
} else {
""
};
format!("{output_path}/{item_prefix}{chapter_prefix}%(section_title)s.%(ext)s")
format!("{item_prefix}{chapter_prefix}%(section_title)s.%(ext)s")
}

fn output_path_arg(output_path: &str) -> String {
format!("home:{output_path}")
}

fn build_auto_collection_names(
Expand Down Expand Up @@ -189,6 +191,40 @@ fn parse_printed_filepaths(contents: &str) -> Vec<String> {
paths
}

fn is_media_filepath(path: &str) -> bool {
matches!(
std::path::Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.map(str::to_ascii_lowercase)
.as_deref(),
Some("mp3" | "m4a" | "opus" | "mp4" | "mkv" | "webm" | "flac" | "wav")
)
}

fn newest_media_filepath_in_dir(output_directory: &str) -> Option<String> {
let mut newest: Option<(std::time::SystemTime, String)> = None;
for entry in std::fs::read_dir(output_directory).ok()? {
let entry = entry.ok()?;
let path = entry.path();
let path_text = path.to_string_lossy().to_string();
if !is_media_filepath(&path_text) {
continue;
}
let modified = entry
.metadata()
.and_then(|metadata| metadata.modified())
.ok()?;
if match newest.as_ref() {
Some((current_modified, _)) => modified > *current_modified,
None => true,
} {
newest = Some((modified, path_text));
}
}
newest.map(|(_, path)| path)
}

fn output_filepaths(printed_filepaths: &[String], final_filepath: &Option<String>) -> Vec<String> {
if !printed_filepaths.is_empty() {
return printed_filepaths.to_vec();
Expand Down Expand Up @@ -272,65 +308,56 @@ mod playlist_chapter_tests {
#[test]
fn output_template_is_unchanged_when_numbering_is_off() {
assert_eq!(
build_output_template("/tmp/out", false, None, None, false, None, None),
"/tmp/out/%(title)s.%(ext)s"
build_output_template(false, None, None, false, None, None),
"%(title)s.%(ext)s"
);
}

#[test]
fn output_template_uses_frontend_playlist_index_for_expanded_playlist_items() {
assert_eq!(
build_output_template("/tmp/out", true, Some(3), Some(120), false, None, None),
"/tmp/out/003 - %(title)s.%(ext)s"
build_output_template(true, Some(3), Some(120), false, None, None),
"003 - %(title)s.%(ext)s"
);
}

#[test]
fn output_template_uses_frontend_queue_index_for_regular_queue_items() {
assert_eq!(
build_output_template("/tmp/out", false, None, None, true, Some(7), Some(42)),
"/tmp/out/07 - %(title)s.%(ext)s"
build_output_template(false, None, None, true, Some(7), Some(42)),
"07 - %(title)s.%(ext)s"
);
}

#[test]
fn output_template_prefers_playlist_index_over_queue_index() {
assert_eq!(
build_output_template(
"/tmp/out",
true,
Some(3),
Some(120),
true,
Some(7),
Some(42)
),
"/tmp/out/003 - %(title)s.%(ext)s"
build_output_template(true, Some(3), Some(120), true, Some(7), Some(42)),
"003 - %(title)s.%(ext)s"
);
}

#[test]
fn chapter_template_numbers_chapters_without_playlist_prefix() {
assert_eq!(
build_chapter_output_template("/tmp/out", false, None, None, false, None, None, true),
"/tmp/out/%(section_number)02d - %(section_title)s.%(ext)s"
build_chapter_output_template(false, None, None, false, None, None, true),
"%(section_number)02d - %(section_title)s.%(ext)s"
);
}

#[test]
fn chapter_template_uses_playlist_and_chapter_numbers_when_both_are_enabled() {
assert_eq!(
build_chapter_output_template(
"/tmp/out",
true,
Some(3),
Some(120),
false,
None,
None,
true
),
"/tmp/out/003 - %(section_number)02d - %(section_title)s.%(ext)s"
build_chapter_output_template(true, Some(3), Some(120), false, None, None, true),
"003 - %(section_number)02d - %(section_title)s.%(ext)s"
);
}

#[test]
fn output_path_arg_uses_ytdlp_home_path_slot() {
assert_eq!(
output_path_arg("C:/very/long/folder"),
"home:C:/very/long/folder"
);
}

Expand All @@ -349,6 +376,27 @@ mod playlist_chapter_tests {
);
}

#[test]
fn newest_media_filepath_scans_output_directory() {
let root = std::env::temp_dir().join(format!("youwee-newest-media-{}", std::process::id()));
std::fs::remove_dir_all(&root).ok();
std::fs::create_dir_all(&root).unwrap();
let older = root.join("older.mp4");
let ignored = root.join("note.txt");
let newer = root.join("newer.webm");
std::fs::write(&older, b"old").unwrap();
std::fs::write(&ignored, b"text").unwrap();
std::thread::sleep(std::time::Duration::from_millis(5));
std::fs::write(&newer, b"new").unwrap();

assert_eq!(
newest_media_filepath_in_dir(&root.to_string_lossy()).as_deref(),
Some(newer.to_string_lossy().as_ref())
);

std::fs::remove_dir_all(root).ok();
}

#[test]
fn auto_collection_names_are_default_off() {
assert!(
Expand Down Expand Up @@ -995,7 +1043,6 @@ pub async fn download_video(
let split_embedded_chapters = split_embedded_chapters.unwrap_or(false);
let number_chapter_files = number_chapter_files.unwrap_or(true);
let output_template = build_output_template(
&sanitized_path,
number_playlist_items,
playlist_index,
playlist_total,
Expand All @@ -1017,6 +1064,8 @@ pub async fn download_video(
"--no-warnings".to_string(),
"-f".to_string(),
format_string,
"--paths".to_string(),
output_path_arg(&sanitized_path),
"-o".to_string(),
output_template,
"--print-to-file".to_string(),
Expand All @@ -1041,7 +1090,6 @@ pub async fn download_video(
args.push(format!(
"chapter:{}",
build_chapter_output_template(
&sanitized_path,
number_playlist_items,
playlist_index,
playlist_total,
Expand Down Expand Up @@ -1680,6 +1728,13 @@ pub async fn download_video(
std::fs::remove_file(&filepath_tmp).ok();

if status.code == Some(0) {
let final_path_exists = final_filepath
.as_ref()
.is_some_and(|path| std::path::Path::new(path).exists());
if !final_path_exists {
final_filepath = newest_media_filepath_in_dir(&sanitized_path);
}

let actual_filesize = final_filepath
.as_ref()
.and_then(|fp| std::fs::metadata(fp).ok())
Expand Down Expand Up @@ -2350,6 +2405,13 @@ async fn handle_tokio_download(
}

if status.success() {
let final_path_exists = final_filepath
.as_ref()
.is_some_and(|path| std::path::Path::new(path).exists());
if !final_path_exists {
final_filepath = newest_media_filepath_in_dir(&output_directory);
}

let actual_filesize = final_filepath
.as_ref()
.and_then(|fp| std::fs::metadata(fp).ok())
Expand Down
6 changes: 4 additions & 2 deletions src-tauri/src/commands/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -740,13 +740,15 @@ pub async fn fetch_metadata(
let sanitized_path = sanitize_output_path(&output_path)
.map_err(|e| BackendError::from_message(e).to_wire_string())?;
// Use title only without extension - yt-dlp will add .info.json, .description, .jpg etc
let output_template = format!("{}/%(title)s", sanitized_path);
let output_template = "%(title)s".to_string();

let mut args = vec![
"--skip-download".to_string(),
"--no-warnings".to_string(),
"--no-simulate".to_string(), // Actually write files even with --print
"--no-playlist".to_string(), // Only fetch single video metadata
"--paths".to_string(),
format!("home:{sanitized_path}"),
"-o".to_string(),
output_template.clone(),
];
Expand All @@ -755,7 +757,7 @@ pub async fn fetch_metadata(
// Description output template - yt-dlp adds .description automatically
if write_description {
args.push("-o".to_string());
args.push(format!("description:{}/%(title)s", sanitized_path));
args.push("description:%(title)s".to_string());
}

// Comments require info.json to be written first, then we'll split them
Expand Down
Loading