feat: argparse and dry-run.

This commit is contained in:
2026-01-04 01:46:39 +08:00
parent 21e0e96841
commit 5ce56bda36
10 changed files with 656 additions and 90 deletions

35
src/audio_converter.rs Normal file
View File

@@ -0,0 +1,35 @@
use crate::ffmpeg::FFmpegCommand;
use std::io::Result;
use std::path::{Path, PathBuf};
pub fn convert_audio_to_aac(audio_path: &Path, dry_run: bool, overwrite: bool) -> Result<PathBuf> {
let aac_path = audio_path.with_extension("aac");
if dry_run {
let mut script = crate::dry_run::ShellScript::new();
script.add_comment("Convert audio to AAC format");
script.add_variable("AUDIO_AAC", &aac_path);
crate::dry_run::print_shell_script(&script);
} else {
println!("Converting audio: {} -> {}", audio_path.display(), aac_path.display());
}
if dry_run {
FFmpegCommand::new()
.overwrite(overwrite)
.input_var("AUDIO_FILE")
.codec("copy")
.output_var("AUDIO_AAC")
.execute(true)?;
} else {
FFmpegCommand::new()
.overwrite(overwrite)
.input(audio_path)
.codec("copy")
.output(&aac_path)
.execute(false)?;
println!("Audio conversion completed: {}", aac_path.display());
}
Ok(aac_path)
}

48
src/dry_run.rs Normal file
View File

@@ -0,0 +1,48 @@
use std::path::Path;
pub struct ShellScript {
lines: Vec<String>,
}
impl ShellScript {
pub fn new() -> Self {
Self {
lines: Vec::new(),
}
}
pub fn add_comment(&mut self, comment: &str) {
self.lines.push(format!("# {}", comment));
}
pub fn add_variable(&mut self, name: &str, value: &Path) {
let escaped = escape_path(value);
self.lines.push(format!("{}=\"{}\"", name, escaped));
}
pub fn add_command(&mut self, command: &str) {
self.lines.push(command.to_string());
}
pub fn add_empty_line(&mut self) {
self.lines.push(String::new());
}
pub fn output(&self) -> String {
self.lines.join("\n")
}
}
fn escape_path(path: &Path) -> String {
path.display().to_string().replace('"', r#"\""#).replace('$', r#"\$"#)
}
pub fn print_shell_script_header() {
println!("#!/bin/sh");
println!("# Generated by bilibili-merge dry-run mode");
println!();
}
pub fn print_shell_script(script: &ShellScript) {
println!("{}", script.output());
}

148
src/ffmpeg.rs Normal file
View File

@@ -0,0 +1,148 @@
use crate::dry_run::ShellScript;
use std::io::Result;
use std::path::Path;
use std::process::Command;
use std::io;
pub struct FFmpegCommand {
inputs: Vec<String>,
output: Option<String>,
codec: Option<String>,
overwrite: bool,
}
impl FFmpegCommand {
pub fn new() -> Self {
Self {
inputs: Vec::new(),
output: None,
codec: None,
overwrite: false,
}
}
pub fn input(mut self, path: &Path) -> Self {
self.inputs.push(path.display().to_string());
self
}
pub fn input_var(mut self, var: &str) -> Self {
self.inputs.push(format!("${}", var));
self
}
pub fn output(mut self, path: &Path) -> Self {
self.output = Some(path.display().to_string());
self
}
pub fn output_var(mut self, var: &str) -> Self {
self.output = Some(format!("${}", var));
self
}
pub fn codec(mut self, codec: &str) -> Self {
self.codec = Some(codec.to_string());
self
}
pub fn overwrite(mut self, overwrite: bool) -> Self {
self.overwrite = overwrite;
self
}
pub fn execute(self, dry_run: bool) -> Result<()> {
if dry_run {
self.execute_dry_run()
} else {
self.execute_real()
}
}
fn execute_dry_run(self) -> Result<()> {
let mut script = ShellScript::new();
let mut cmd_parts = vec!["ffmpeg".to_string()];
if self.overwrite {
cmd_parts.push("-y".to_string());
}
cmd_parts.push("-hide_banner".to_string());
for input in &self.inputs {
cmd_parts.push("-i".to_string());
cmd_parts.push(input.clone());
}
if let Some(ref codec) = self.codec {
cmd_parts.push("-c".to_string());
cmd_parts.push(codec.clone());
}
if let Some(ref output) = self.output {
cmd_parts.push(output.clone());
}
let cmd_str = cmd_parts.iter()
.map(|arg| {
if arg.starts_with('$') {
format!("\"{}\"", arg)
} else if arg.contains(' ') || arg.contains('$') {
format!("\"{}\"", arg.replace('"', r#"\""#))
} else {
arg.clone()
}
})
.collect::<Vec<_>>()
.join(" ");
script.add_command(&cmd_str);
crate::dry_run::print_shell_script(&script);
Ok(())
}
fn execute_real(self) -> Result<()> {
let mut cmd = Command::new("ffmpeg");
if self.overwrite {
cmd.arg("-y");
}
cmd.arg("-hide_banner");
for input in &self.inputs {
cmd.arg("-i");
if input.starts_with('$') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("Cannot execute with variable input: {}", input)
));
}
cmd.arg(input);
}
if let Some(ref codec) = self.codec {
cmd.arg("-c");
cmd.arg(codec);
}
if let Some(ref output) = self.output {
if output.starts_with('$') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("Cannot execute with variable output: {}", output)
));
}
cmd.arg(output);
}
let output = cmd.spawn()?.wait_with_output()?;
if !output.status.success() {
return Err(io::Error::new(
io::ErrorKind::Other,
format!("ffmpeg exited with status {}", output.status)
));
}
Ok(())
}
}

37
src/file_finder.rs Normal file
View File

@@ -0,0 +1,37 @@
use std::io::Result;
use std::path::{Path, PathBuf};
pub fn find_largest_video_file(path: &Path) -> Result<PathBuf> {
let mut largest_size = 0;
let mut largest_file = PathBuf::from(path);
for entry in std::fs::read_dir(path)? {
let entry_path = entry?.path().canonicalize()?;
if entry_path.is_dir() {
continue;
}
let size = entry_path.metadata()?.len();
if size >= largest_size {
largest_size = size;
largest_file = entry_path.into();
}
}
Ok(largest_file)
}
pub fn find_audio_file(video_path: &Path) -> Result<PathBuf> {
let video_name = video_path.file_name().unwrap().to_str().unwrap();
let video_extension = video_path.extension().unwrap();
let audio_name = format!("{}.m4a", &video_name[..video_name.len() - video_extension.len() - 1]);
let audio_path = video_path.parent().unwrap().join(audio_name);
if !audio_path.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Audio file does not exist: {}", audio_path.display())
));
}
Ok(audio_path)
}

View File

@@ -1,15 +1,52 @@
mod audio_converter;
mod dry_run;
mod ffmpeg;
mod file_finder;
mod merge;
mod video_merger;
use std::env;
use clap::Parser;
use std::io::Result;
use std::path::PathBuf;
fn main() -> Result<()> {
let args: Vec<String> = env::args().collect();
let merge_path = if args.len() > 1 {
PathBuf::from(args[1].as_str())
} else {
PathBuf::from(".")
};
merge::merge_video_from_path(&merge_path)
#[derive(Parser)]
#[command(name = "bilibili-merge")]
#[command(about = "Merge bilibili downloaded videos with audio")]
#[command(long_about = "Merge bilibili downloaded videos with audio.
This tool automatically:
1. Finds the largest video file in the specified directory (by file size)
2. Looks for the corresponding audio file (.m4a) with the same base name
3. Converts the audio to AAC format
4. Merges the video and audio into a single file
The original video file will be renamed to 'original.<extension>' as a backup.
The merged file will be saved as '<original_name>.mp4' regardless of the input format.")]
struct Args {
/// Path to the directory containing video and audio files
///
/// The tool will automatically select the largest file (by size) in this directory
/// as the video file to merge. Make sure only one video file exists, or the largest
/// one will be selected.
#[arg(default_value = ".")]
path: PathBuf,
/// Dry run mode: show what would be done without actually doing it
///
/// In dry-run mode, the tool will output a shell script showing all operations
/// that would be performed. You can copy and execute this script manually if needed.
#[arg(short = 'n', long = "dry-run")]
dry_run: bool,
/// Force overwrite existing files
///
/// By default, the tool will not overwrite existing files. Use this flag to
/// force overwrite if the output file already exists.
#[arg(short = 'f', long = "force")]
force: bool,
}
fn main() -> Result<()> {
let args = Args::parse();
merge::merge_video_from_path(&args.path, args.dry_run, args.force)
}

View File

@@ -1,86 +1,57 @@
use crate::audio_converter::convert_audio_to_aac;
use crate::dry_run::{print_shell_script_header, ShellScript};
use crate::file_finder::{find_audio_file, find_largest_video_file};
use crate::video_merger::merge_video_and_audio;
use std::io::Result;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::{fs, io};
use std::path::Path;
fn guess_video_path(path: &Path) -> Result<PathBuf> {
let mut largest_size = 0;
let mut largest_file = PathBuf::from(path);
for entry in fs::read_dir(path)? {
let entry_path = entry?.path().canonicalize()?;
if entry_path.is_dir() {
continue;
}
let size = entry_path.metadata()?.len();
if size >= largest_size {
largest_size = size;
largest_file = entry_path.into();
}
pub fn merge_video_from_path(path: &Path, dry_run: bool, overwrite: bool) -> Result<()> {
if dry_run {
println!("=== DRY RUN MODE ===");
println!("This is a dry run. Below is the shell script that would be executed:");
println!();
print_shell_script_header();
}
Ok(largest_file)
}
println!("Step 1: Searching for video file in: {}", path.display());
let video_path = find_largest_video_file(path)?;
println!("Found video file: {}", video_path.display());
fn convert_audio_to_aac(path: &Path) -> Result<()> {
let save_path = path.with_extension("aac");
let output = Command::new("ffmpeg")
.arg("-y")
.arg("-hide_banner")
.arg("-i").arg(path)
.arg("-c").arg("copy")
.arg(save_path)
.spawn()?.wait_with_output();
if dry_run {
let mut script = ShellScript::new();
script.add_comment("File paths");
script.add_variable("VIDEO_FILE", &video_path);
crate::dry_run::print_shell_script(&script);
}
match output {
Ok(res) => {
if !res.status.success() {
return Err(io::Error::new(io::ErrorKind::Other, format!("ffmpeg exited with status {}", res.status)));
}
}
Err(e) => return Err(e)
println!("Step 2: Looking for corresponding audio file...");
let audio_path = find_audio_file(&video_path)?;
println!("Found audio file: {}", audio_path.display());
if dry_run {
let mut script = ShellScript::new();
script.add_variable("AUDIO_FILE", &audio_path);
crate::dry_run::print_shell_script(&script);
}
println!();
println!("Step 3: Converting audio to AAC format...");
let aac_path = convert_audio_to_aac(&audio_path, dry_run, overwrite)?;
println!();
println!("Step 4: Merging video and audio...");
let output_path = merge_video_and_audio(&video_path, &aac_path, dry_run, overwrite)?;
if dry_run {
println!();
println!("=== DRY RUN COMPLETE ===");
println!("To actually perform these operations, run without -n flag.");
println!("You can also copy the shell script above and execute it manually.");
} else {
println!();
println!("=== MERGE COMPLETE ===");
println!("Video file: {}", output_path.display());
}
Ok(())
}
fn merge(video_path: &Path, audio_path: &Path) -> Result<()> {
let origin_video_extension = video_path.extension().unwrap().to_str().unwrap();
let origin_filename = format!("original.{}", &origin_video_extension);
let origin_path = video_path.parent().unwrap().join(origin_filename);
fs::rename(video_path, &origin_path)?;
let output = Command::new("ffmpeg")
.arg("-y")
.arg("-hide_banner")
.arg("-i").arg(origin_path)
.arg("-i").arg(audio_path)
.arg("-c").arg("copy")
.arg(video_path)
.spawn()?.wait_with_output();
match output {
Ok(res) => {
if !res.status.success() {
return Err(io::Error::new(io::ErrorKind::Other, format!("ffmpeg exited with status {}", res.status)));
}
}
Err(e) => return Err(e)
}
Ok(())
}
pub fn merge_video_from_path(path: &Path) -> Result<()> {
let video_path = guess_video_path(path)?;
let video_name = video_path.file_name().unwrap().to_str().unwrap();
let video_extension = video_path.extension().unwrap();
let audio_name = format!("{}.m4a", &video_name[..video_name.len() - video_extension.len() - 1]);
let audio_path = video_path.parent().unwrap().join(audio_name);
if !audio_path.exists() {
return Err(io::Error::new(io::ErrorKind::NotFound, "Audio file does not exist"));
}
convert_audio_to_aac(&audio_path).expect("Audio file corrupted.");
merge(&video_path, &audio_path)
}

49
src/video_merger.rs Normal file
View File

@@ -0,0 +1,49 @@
use crate::dry_run::ShellScript;
use crate::ffmpeg::FFmpegCommand;
use std::io::Result;
use std::path::{Path, PathBuf};
use std::fs;
pub fn merge_video_and_audio(video_path: &Path, audio_path: &Path, dry_run: bool, overwrite: bool) -> Result<PathBuf> {
let origin_video_extension = video_path.extension().unwrap().to_str().unwrap();
let origin_filename = format!("original.{}", &origin_video_extension);
let origin_path = video_path.parent().unwrap().join(origin_filename);
let output_path = video_path.with_extension("mp4");
if dry_run {
let mut script = ShellScript::new();
script.add_comment("Rename original video file");
script.add_variable("VIDEO_BACKUP", &origin_path);
script.add_variable("VIDEO_OUTPUT", &output_path);
script.add_command("mv \"$VIDEO_FILE\" \"$VIDEO_BACKUP\"");
script.add_empty_line();
script.add_comment("Merge video and audio");
crate::dry_run::print_shell_script(&script);
FFmpegCommand::new()
.overwrite(overwrite)
.input_var("VIDEO_BACKUP")
.input_var("AUDIO_AAC")
.codec("copy")
.output_var("VIDEO_OUTPUT")
.execute(true)?;
return Ok(output_path);
}
println!("Renaming original video: {} -> {}", video_path.display(), origin_path.display());
fs::rename(video_path, &origin_path)?;
println!("Merging video and audio:");
println!(" Video: {}", origin_path.display());
println!(" Audio: {}", audio_path.display());
println!(" Output: {}", output_path.display());
FFmpegCommand::new()
.overwrite(overwrite)
.input(&origin_path)
.input(audio_path)
.codec("copy")
.output(&output_path)
.execute(false)?;
println!("Merge completed: {}", output_path.display());
Ok(output_path)
}