add input file matching through configs
and allow codec options for each matching
This commit is contained in:
@@ -17,3 +17,4 @@ anyhow = "1"
|
|||||||
clap = "2"
|
clap = "2"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_yaml = "0.8"
|
serde_yaml = "0.8"
|
||||||
|
regex = "1"
|
||||||
|
|||||||
8
example.audio-conv.yaml
Normal file
8
example.audio-conv.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from: ./music
|
||||||
|
to: ./converted_test
|
||||||
|
matches:
|
||||||
|
- regex: \.flac$
|
||||||
|
to:
|
||||||
|
codec: opus
|
||||||
|
bitrate: 160
|
||||||
|
bitrate_type: vbr
|
||||||
115
src/config.rs
115
src/config.rs
@@ -1,23 +1,86 @@
|
|||||||
use anyhow::{Context, Error, Result};
|
use anyhow::{Context, Error, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use regex::bytes::{Regex, RegexBuilder};
|
||||||
|
use serde::Deserialize;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub from: PathBuf,
|
pub from: PathBuf,
|
||||||
pub to: PathBuf,
|
pub to: PathBuf,
|
||||||
|
pub matches: Vec<TranscodeMatch>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct TranscodeMatch {
|
||||||
|
pub regex: Regex,
|
||||||
|
pub to: Transcode,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(tag = "codec")]
|
||||||
|
pub enum Transcode {
|
||||||
|
#[serde(rename = "opus")]
|
||||||
|
Opus {
|
||||||
|
#[serde(default = "default_opus_bitrate")]
|
||||||
|
bitrate: u16,
|
||||||
|
|
||||||
|
#[serde(default = "default_opus_bitrate_type")]
|
||||||
|
bitrate_type: OpusBitrateType,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Transcode {
|
||||||
|
pub fn extention(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Transcode::Opus { .. } => "opus",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_opus_bitrate() -> u16 {
|
||||||
|
160
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_opus_bitrate_type() -> OpusBitrateType {
|
||||||
|
OpusBitrateType::Vbr
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Transcode {
|
||||||
|
fn default() -> Self {
|
||||||
|
Transcode::Opus {
|
||||||
|
bitrate: default_opus_bitrate(),
|
||||||
|
bitrate_type: default_opus_bitrate_type(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub enum OpusBitrateType {
|
||||||
|
#[serde(rename = "cbr")]
|
||||||
|
Cbr,
|
||||||
|
#[serde(rename = "vbr")]
|
||||||
|
Vbr,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
struct ConfigFile {
|
struct ConfigFile {
|
||||||
pub from: Option<PathBuf>,
|
from: Option<PathBuf>,
|
||||||
pub to: Option<PathBuf>,
|
to: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
matches: Vec<TranscodeMatchFile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TranscodeMatchFile {
|
||||||
|
regex: String,
|
||||||
|
to: Transcode,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn config() -> Result<Config> {
|
pub fn config() -> Result<Config> {
|
||||||
use clap::Arg;
|
use clap::Arg;
|
||||||
|
|
||||||
let matches = clap::App::new("audio-conv")
|
let arg_matches = clap::App::new("audio-conv")
|
||||||
.version(clap::crate_version!())
|
.version(clap::crate_version!())
|
||||||
.about("Converts audio files")
|
.about("Converts audio files")
|
||||||
.arg(
|
.arg(
|
||||||
@@ -48,7 +111,7 @@ pub fn config() -> Result<Config> {
|
|||||||
|
|
||||||
let current_dir = std::env::current_dir().context("could not get current directory")?;
|
let current_dir = std::env::current_dir().context("could not get current directory")?;
|
||||||
|
|
||||||
let config_path = matches.value_of_os("config");
|
let config_path = arg_matches.value_of_os("config");
|
||||||
let force_load = config_path.is_some();
|
let force_load = config_path.is_some();
|
||||||
let config_path = config_path
|
let config_path = config_path
|
||||||
.map(AsRef::<Path>::as_ref)
|
.map(AsRef::<Path>::as_ref)
|
||||||
@@ -69,9 +132,40 @@ pub fn config() -> Result<Config> {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let transcode_matches = config_file
|
||||||
|
.as_ref()
|
||||||
|
.map(|config_file| {
|
||||||
|
config_file
|
||||||
|
.matches
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
Ok(TranscodeMatch {
|
||||||
|
regex: RegexBuilder::new(&m.regex)
|
||||||
|
.case_insensitive(true)
|
||||||
|
.build()
|
||||||
|
.context("failed compiling regex")?,
|
||||||
|
to: m.to.clone(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()
|
||||||
|
})
|
||||||
|
.transpose()?
|
||||||
|
.filter(|matches| !matches.is_empty())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let default_regex = RegexBuilder::new("\\.flac$")
|
||||||
|
.case_insensitive(true)
|
||||||
|
.build()
|
||||||
|
.expect("failed compiling default match regex");
|
||||||
|
|
||||||
|
vec![TranscodeMatch {
|
||||||
|
regex: default_regex,
|
||||||
|
to: Transcode::default(),
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
Ok(Config {
|
Ok(Config {
|
||||||
from: {
|
from: {
|
||||||
matches
|
arg_matches
|
||||||
.value_of_os("from")
|
.value_of_os("from")
|
||||||
.map(|p| current_dir.join(p))
|
.map(|p| current_dir.join(p))
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
@@ -85,7 +179,7 @@ pub fn config() -> Result<Config> {
|
|||||||
.canonicalize()
|
.canonicalize()
|
||||||
.context("could not canonicalize \"from\" path")?
|
.context("could not canonicalize \"from\" path")?
|
||||||
},
|
},
|
||||||
to: matches
|
to: arg_matches
|
||||||
.value_of_os("to")
|
.value_of_os("to")
|
||||||
.map(|p| current_dir.join(p))
|
.map(|p| current_dir.join(p))
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
@@ -98,6 +192,7 @@ pub fn config() -> Result<Config> {
|
|||||||
.ok_or_else(|| Error::msg("\"to\" not configured"))?
|
.ok_or_else(|| Error::msg("\"to\" not configured"))?
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
.context("could not canonicalize \"to\" path")?,
|
.context("could not canonicalize \"to\" path")?,
|
||||||
|
matches: transcode_matches,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +203,6 @@ fn load_config_file(path: &Path) -> Result<Option<ConfigFile>> {
|
|||||||
Err(err) => return Err(Error::new(err)),
|
Err(err) => return Err(Error::new(err)),
|
||||||
};
|
};
|
||||||
let config: ConfigFile =
|
let config: ConfigFile =
|
||||||
serde_yaml::from_reader(&mut file).context("could not read config file")?;
|
serde_yaml::from_reader(&mut file).context("could not parse config file")?;
|
||||||
Ok(Some(config))
|
Ok(Some(config))
|
||||||
}
|
}
|
||||||
|
|||||||
136
src/main.rs
136
src/main.rs
@@ -1,5 +1,6 @@
|
|||||||
mod config;
|
mod config;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use futures::{channel::mpsc, prelude::*};
|
use futures::{channel::mpsc, prelude::*};
|
||||||
use glib::GString;
|
use glib::GString;
|
||||||
@@ -7,18 +8,19 @@ use gstreamer::Element;
|
|||||||
use gstreamer_audio::{prelude::*, AudioEncoder};
|
use gstreamer_audio::{prelude::*, AudioEncoder};
|
||||||
use gstreamer_base::prelude::*;
|
use gstreamer_base::prelude::*;
|
||||||
use std::{
|
use std::{
|
||||||
|
borrow::Cow,
|
||||||
ffi,
|
ffi,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
fn gmake<T: IsA<Element>>(factory_name: &str) -> Result<T> {
|
fn gmake<T: IsA<Element>>(factory_name: &str) -> Result<T> {
|
||||||
let res = gstreamer::ElementFactory::make(factory_name, None)
|
let res = gstreamer::ElementFactory::make(factory_name, None)
|
||||||
.with_context(|| format!("could not make {}", factory_name))?
|
.with_context(|| format!("could not make gstreamer Element \"{}\"", factory_name))?
|
||||||
.downcast()
|
.downcast()
|
||||||
.ok()
|
.ok()
|
||||||
.with_context(|| {
|
.with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"could not cast {} into `{}`",
|
"could not cast gstreamer Element \"{}\" into `{}`",
|
||||||
factory_name,
|
factory_name,
|
||||||
std::any::type_name::<T>()
|
std::any::type_name::<T>()
|
||||||
)
|
)
|
||||||
@@ -26,32 +28,56 @@ fn gmake<T: IsA<Element>>(factory_name: &str) -> Result<T> {
|
|||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_path_pairs(input: PathBuf, output: PathBuf) -> impl Iterator<Item = (PathBuf, PathBuf)> {
|
struct ConvertionArgs {
|
||||||
walkdir::WalkDir::new(input.as_path())
|
from: PathBuf,
|
||||||
|
to: PathBuf,
|
||||||
|
transcode: config::Transcode,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_path_pairs(config: Config) -> impl Iterator<Item = ConvertionArgs> {
|
||||||
|
walkdir::WalkDir::new(&config.from)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
.filter(|e| e.file_type().is_file())
|
.filter(|e| e.file_type().is_file())
|
||||||
.filter(|e| {
|
.filter_map(move |e| {
|
||||||
e.path()
|
let from_bytes = path_to_bytes(e.path());
|
||||||
.extension()
|
|
||||||
.map(|ext| ext == "flac")
|
let transcode = config
|
||||||
.unwrap_or(false)
|
.matches
|
||||||
})
|
.iter()
|
||||||
.map(move |e| {
|
.filter(|m| m.regex.is_match(from_bytes.as_ref()))
|
||||||
let mut out = output.join(e.path().strip_prefix(&input).unwrap());
|
.map(|m| m.to.clone())
|
||||||
out.set_extension("opus");
|
.next();
|
||||||
(e, out)
|
let transcode = if let Some(transcode) = transcode {
|
||||||
})
|
transcode
|
||||||
.filter(|(e, out)| {
|
} else {
|
||||||
let in_mtime = e.metadata().unwrap().modified().unwrap();
|
return None;
|
||||||
let out_mtime = out.metadata().and_then(|md| md.modified());
|
};
|
||||||
match out_mtime {
|
|
||||||
Ok(out_mtime) => out_mtime < in_mtime,
|
let mut to = config.to.join(e.path().strip_prefix(&config.from).unwrap());
|
||||||
|
to.set_extension(transcode.extention());
|
||||||
|
|
||||||
|
let is_newer = {
|
||||||
|
// TODO: error handling
|
||||||
|
let from_mtime = e.metadata().unwrap().modified().unwrap();
|
||||||
|
let to_mtime = to.metadata().and_then(|md| md.modified());
|
||||||
|
match to_mtime {
|
||||||
|
Ok(to_mtime) => to_mtime < from_mtime,
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
|
||||||
Err(err) => panic!(err),
|
Err(err) => panic!(err),
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !is_newer {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(ConvertionArgs {
|
||||||
|
from: e.path().to_path_buf(),
|
||||||
|
to,
|
||||||
|
transcode,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.map(|(e, out)| (e.into_path(), out))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
@@ -62,16 +88,21 @@ fn main() -> Result<()> {
|
|||||||
|
|
||||||
// move blocking directory reading to an external thread
|
// move blocking directory reading to an external thread
|
||||||
let pair_producer = std::thread::spawn(|| {
|
let pair_producer = std::thread::spawn(|| {
|
||||||
let produce_pairs = futures::stream::iter(get_path_pairs(config.from, config.to))
|
let produce_pairs = futures::stream::iter(get_path_pairs(config))
|
||||||
.map(Ok)
|
.map(Ok)
|
||||||
.forward(pair_tx)
|
.forward(pair_tx)
|
||||||
.map(|res| res.context("sending path pairs failed"));
|
.map(|res| res.context("sending path pairs failed"));
|
||||||
futures::executor::block_on(produce_pairs)
|
futures::executor::block_on(produce_pairs)
|
||||||
});
|
});
|
||||||
|
|
||||||
let transcoder = pair_rx.for_each_concurrent(num_cpus::get(), |(src, dest)| async move {
|
let transcoder = pair_rx.for_each_concurrent(num_cpus::get(), |args| async move {
|
||||||
if let Err(err) = transcode(src.as_path(), dest.as_path()).await {
|
if let Err(err) = transcode(&args).await {
|
||||||
println!("err {} => {}:\n{:?}", src.display(), dest.display(), err);
|
println!(
|
||||||
|
"err {} => {}:\n{:?}",
|
||||||
|
args.from.display(),
|
||||||
|
args.to.display(),
|
||||||
|
err
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
futures::executor::block_on(transcoder);
|
futures::executor::block_on(transcoder);
|
||||||
@@ -83,13 +114,13 @@ fn main() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn transcode(src: &Path, dest: &Path) -> Result<()> {
|
async fn transcode(args: &ConvertionArgs) -> Result<()> {
|
||||||
let file_src: gstreamer_base::BaseSrc = gmake("filesrc")?;
|
let file_src: gstreamer_base::BaseSrc = gmake("filesrc")?;
|
||||||
file_src.set_property("location", &path_to_gstring(src))?;
|
file_src.set_property("location", &path_to_gstring(&args.from))?;
|
||||||
|
|
||||||
// encode into a tmp file first, then rename to actuall file name, that way we're writing
|
// encode into a tmp file first, then rename to actuall file name, that way we're writing
|
||||||
// "whole" files to the intended file path, ignoring partial files in the mtime check
|
// "whole" files to the intended file path, ignoring partial files in the mtime check
|
||||||
let tmp_dest = dest.with_extension("tmp");
|
let tmp_dest = args.to.with_extension("tmp");
|
||||||
let file_dest: gstreamer_base::BaseSink = gmake("filesink")?;
|
let file_dest: gstreamer_base::BaseSink = gmake("filesink")?;
|
||||||
file_dest.set_property("location", &path_to_gstring(&tmp_dest))?;
|
file_dest.set_property("location", &path_to_gstring(&tmp_dest))?;
|
||||||
file_dest.set_sync(false);
|
file_dest.set_sync(false);
|
||||||
@@ -99,9 +130,24 @@ async fn transcode(src: &Path, dest: &Path) -> Result<()> {
|
|||||||
resample.set_property("quality", &7)?;
|
resample.set_property("quality", &7)?;
|
||||||
|
|
||||||
let encoder: AudioEncoder = gmake("opusenc")?;
|
let encoder: AudioEncoder = gmake("opusenc")?;
|
||||||
encoder.set_property("bitrate", &160_000)?;
|
|
||||||
// 0 = cbr; 1 = vbr
|
let config::Transcode::Opus {
|
||||||
encoder.set_property_from_str("bitrate-type", "1");
|
bitrate,
|
||||||
|
bitrate_type,
|
||||||
|
} = &args.transcode;
|
||||||
|
encoder.set_property(
|
||||||
|
"bitrate",
|
||||||
|
&i32::from(*bitrate)
|
||||||
|
.checked_mul(1_000)
|
||||||
|
.context("bitrate overflowed")?,
|
||||||
|
)?;
|
||||||
|
encoder.set_property_from_str(
|
||||||
|
"bitrate-type",
|
||||||
|
match bitrate_type {
|
||||||
|
config::OpusBitrateType::Vbr => "1",
|
||||||
|
config::OpusBitrateType::Cbr => "0",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
let elems: &[&Element] = &[
|
let elems: &[&Element] = &[
|
||||||
file_src.upcast_ref(),
|
file_src.upcast_ref(),
|
||||||
@@ -123,8 +169,9 @@ async fn transcode(src: &Path, dest: &Path) -> Result<()> {
|
|||||||
let bus = pipeline.get_bus().context("pipe get bus")?;
|
let bus = pipeline.get_bus().context("pipe get bus")?;
|
||||||
|
|
||||||
std::fs::create_dir_all(
|
std::fs::create_dir_all(
|
||||||
dest.parent()
|
args.to
|
||||||
.with_context(|| format!("could not get parent dir for {}", dest.display()))?,
|
.parent()
|
||||||
|
.with_context(|| format!("could not get parent dir for {}", args.to.display()))?,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
rm_file_on_err(&tmp_dest, async {
|
rm_file_on_err(&tmp_dest, async {
|
||||||
@@ -158,7 +205,7 @@ async fn transcode(src: &Path, dest: &Path) -> Result<()> {
|
|||||||
.set_state(gstreamer::State::Null)
|
.set_state(gstreamer::State::Null)
|
||||||
.context("Unable to set the pipeline to the `Null` state")?;
|
.context("Unable to set the pipeline to the `Null` state")?;
|
||||||
|
|
||||||
std::fs::rename(&tmp_dest, dest)?;
|
std::fs::rename(&tmp_dest, &args.to)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -181,19 +228,17 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn path_to_gstring(path: &Path) -> GString {
|
fn path_to_bytes(path: &Path) -> Cow<'_, [u8]> {
|
||||||
let buf = {
|
|
||||||
let mut buf = Vec::<u8>::new();
|
|
||||||
|
|
||||||
// https://stackoverflow.com/a/59224987/5572146
|
// https://stackoverflow.com/a/59224987/5572146
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::ffi::OsStrExt;
|
use std::os::unix::ffi::OsStrExt;
|
||||||
buf.extend(path.as_os_str().as_bytes());
|
Cow::Borrowed(path.as_os_str().as_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
|
let mut buf = Vec::<u8>::new();
|
||||||
// NOT TESTED
|
// NOT TESTED
|
||||||
// FIXME: test and post answer to https://stackoverflow.com/questions/38948669
|
// FIXME: test and post answer to https://stackoverflow.com/questions/38948669
|
||||||
use std::os::windows::ffi::OsStrExt;
|
use std::os::windows::ffi::OsStrExt;
|
||||||
@@ -203,10 +248,13 @@ fn path_to_gstring(path: &Path) -> GString {
|
|||||||
.map(|char| char.to_ne_bytes())
|
.map(|char| char.to_ne_bytes())
|
||||||
.flatten(),
|
.flatten(),
|
||||||
);
|
);
|
||||||
|
Cow::Owned(buf)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
buf
|
|
||||||
};
|
fn path_to_gstring(path: &Path) -> GString {
|
||||||
|
let buf = path_to_bytes(path);
|
||||||
ffi::CString::new(buf).unwrap().into()
|
ffi::CString::new(buf)
|
||||||
|
.expect("Path contained null byte")
|
||||||
|
.into()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user