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

183
Cargo.lock generated
View File

@@ -1,7 +1,186 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "bilibili-merge"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"clap",
]
[[package]]
name = "clap"
version = "4.5.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "proc-macro2"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
dependencies = [
"proc-macro2",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.112"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21f182278bf2d2bcb3c88b1b08a37df029d71ce3d3ae26168e3c653b213b99d4"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]

View File

@@ -1,6 +1,6 @@
[package]
name = "bilibili-merge"
version = "0.1.0"
version = "0.2.0"
authors = ["licsber <admin@licsber.site>"]
edition = "2021"
@@ -11,3 +11,4 @@ readme = "README.md"
license = "AGPL-3.0-only"
[dependencies]
clap = { version = "4.5", features = ["derive"] }

View File

@@ -1,9 +1,70 @@
# bilibili-merge
Merge bilibili downloaded videos.
Merge bilibili downloaded videos with audio files.
## Install
```
```bash
cargo install bilibili-merge
```
## Usage
```bash
bilibili-merge [OPTIONS] [PATH]
```
### Arguments
- `PATH` - Path to the directory containing video and audio files (default: current directory)
### Options
- `-n, --dry-run` - Dry run mode: show what would be done without actually doing it
- `-f, --force` - Force overwrite existing files
- `-h, --help` - Print help information
## How It Works
1. **Video File Selection**: The tool automatically finds the **largest file** (by file size) in the specified directory and uses it as the video file. This means:
- If there's only one video file, it will be selected
- If there are multiple files, the largest one (by size) will be selected
- Only files (not directories) are considered
- The selection is based on file size, not file name
2. **Audio File Matching**: The tool looks for an audio file with the same base name as the video file, but with `.m4a` extension. For example:
- Video: `video.mp4` → Audio: `video.m4a`
- Video: `episode_01.flv` → Audio: `episode_01.m4a`
3. **Processing Steps**:
- Converts the audio file from M4A to AAC format
- Renames the original video file to `original.<extension>` as a backup
- Merges the video and audio into a single file
- The merged file is saved as `<original_name>.mp4` (always MP4 format, regardless of input format)
## Examples
```bash
# Merge video and audio in current directory
bilibili-merge
# Merge video and audio in a specific directory
bilibili-merge /path/to/video/directory
# Dry run to see what would be done
bilibili-merge -n
# Force overwrite existing files
bilibili-merge -f
# Combine options
bilibili-merge -n -f /path/to/directory
```
## Notes
- The tool requires `ffmpeg` to be installed and available in your PATH
- By default, the tool will **not** overwrite existing files (use `-f` to force)
- The original video file is automatically backed up as `original.<extension>`
- The output file is always in MP4 format, regardless of the input video format
- In dry-run mode, you'll get a shell script that you can review or execute manually

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)
}