8 Commits
v1.3.0 ... main

10 changed files with 677 additions and 566 deletions

View File

@@ -1,5 +1,13 @@
# Changelog # Changelog
## v1.3.2
* dependencies upgraded
## v1.3.1
* dependencies upgraded
## v1.3.0 ## v1.3.0
* allow multiple values for the tags "musicbrainz-artistid" and "musicbrainz-albumartistid" * allow multiple values for the tags "musicbrainz-artistid" and "musicbrainz-albumartistid"

901
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "audio-conv" name = "audio-conv"
version = "1.3.0" version = "1.3.2"
edition = "2018" edition = "2024"
description = "Copies directory structure and converts audio files in it" description = "Copies directory structure and converts audio files in it"
authors = ["Thomas Heck <t@b128.net>"] authors = ["Thomas Heck <t@b128.net>"]
repository = "https://gitlab.com/chpio/audio-conv" repository = "https://gitlab.com/chpio/audio-conv"
@@ -21,22 +21,22 @@ include = [
] ]
[dependencies] [dependencies]
gstreamer-audio = { version = "0.18", features = ["v1_10"] } gstreamer = { version = "0.24", features = ["v1_16"] }
gstreamer = { version = "0.18", features = ["v1_10"] } gstreamer-base = { version = "0.24", features = ["v1_16"] }
gstreamer-base = { version = "0.18", features = ["v1_10"] } gstreamer-audio = { version = "0.24", features = ["v1_16"] }
glib = "0.15" glib = "0.21"
futures = "0.3" futures = "0.3"
num_cpus = "1" num_cpus = "1"
walkdir = "2" walkdir = "2"
libc = "0.2" libc = "0.2"
anyhow = "1" anyhow = "1"
clap = { version = "3", features = ["cargo"] } clap = { version = "4", features = ["cargo"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.8" serde_yaml = "0.9"
regex = "1" regex = "1"
globset = "0.4" globset = "0.4"
derive_more = "0.99" derive_more = { version = "2", features = ["full"] }
tui = { version = "0.17", default-features = false, features = ["crossterm"] } tui = { version = "0.19", default-features = false, features = ["crossterm"] }
[dependencies.tokio] [dependencies.tokio]
version = "1" version = "1"

View File

@@ -5,7 +5,7 @@ second path. The directory structure from the first path gets also copied to the
## Dependencies ## Dependencies
Requires *gstreamer* version 1.10 or higher with the *base* plugin. Requires *gstreamer* version 1.16 or higher with the *base* plugin.
The supported source audio formats (or even other media that is able to contain audio) depend on The supported source audio formats (or even other media that is able to contain audio) depend on
the installed *gstreamer* plugins. the installed *gstreamer* plugins.

58
flake.lock generated
View File

@@ -1,58 +0,0 @@
{
"nodes": {
"flake-utils": {
"locked": {
"lastModified": 1648297722,
"narHash": "sha256-W+qlPsiZd8F3XkzXOzAoR+mpFqzm3ekQkJNa+PIh1BQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "0f8662f1319ad6abf89b3380dd2722369fc51ade",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"import-cargo": {
"locked": {
"lastModified": 1594305518,
"narHash": "sha256-frtArgN42rSaEcEOYWg8sVPMUK+Zgch3c+wejcpX3DY=",
"owner": "edolstra",
"repo": "import-cargo",
"rev": "25d40be4a73d40a2572e0cc233b83253554f06c5",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "import-cargo",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1649352661,
"narHash": "sha256-6IO5W02HKY6pj4uRgStJ2EjIENlpvbb99OlDBBzJMDQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "2bc410afc423de1fd7ce1d84da9f294eee866b3f",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"import-cargo": "import-cargo",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -1,63 +0,0 @@
{
description = "Converts audio files";
inputs = {
nixpkgs.url = github:NixOS/nixpkgs;
flake-utils.url = "github:numtide/flake-utils";
import-cargo.url = github:edolstra/import-cargo;
};
outputs = { self, flake-utils, nixpkgs, import-cargo }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
buildtimeDeps = with pkgs; [
cargo
rustc
pkg-config
];
runtimeDeps = with pkgs; [
gst_all_1.gstreamer
# needed for opus, resample, ...
gst_all_1.gst-plugins-base
# needed for flac
gst_all_1.gst-plugins-good
];
inherit (import-cargo.builders) importCargo;
in {
defaultPackage = pkgs.stdenv.mkDerivation {
name = "audio-conv";
src = self;
nativeBuildInputs = [
# setupHook which makes sure that a CARGO_HOME with vendored dependencies
# exists
(importCargo { lockFile = ./Cargo.lock; inherit pkgs; }).cargoHome
]
++ buildtimeDeps;
buildInputs = runtimeDeps;
buildPhase = ''
cargo build --release --offline
'';
installPhase = ''
install -Dm775 ./target/release/audio-conv $out/bin/audio-conv
'';
};
devShell = pkgs.stdenv.mkDerivation {
name = "audio-conv";
buildInputs = [ pkgs.rustfmt pkgs.rust-analyzer ]
++ buildtimeDeps
++ runtimeDeps;
};
}
);
}

View File

@@ -1,4 +1,5 @@
use anyhow::{Context, Error, Result}; use anyhow::{Context, Error, Result};
use clap::{ArgAction, builder::ValueParser};
use globset::GlobBuilder; use globset::GlobBuilder;
use regex::bytes::{Regex, RegexBuilder}; use regex::bytes::{Regex, RegexBuilder};
use serde::Deserialize; use serde::Deserialize;
@@ -103,6 +104,8 @@ struct ConfigFile {
#[serde(default)] #[serde(default)]
matches: Vec<TranscodeMatchFile>, matches: Vec<TranscodeMatchFile>,
jobs: Option<usize>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -126,27 +129,27 @@ pub fn config() -> Result<Config> {
Arg::new("config") Arg::new("config")
.short('c') .short('c')
.long("config") .long("config")
.allow_invalid_utf8(true)
.required(false) .required(false)
.takes_value(true) .value_parser(ValueParser::path_buf())
.action(ArgAction::Set)
.help("Path to an audio-conv config file, defaults to \"audio-conv.yaml\""), .help("Path to an audio-conv config file, defaults to \"audio-conv.yaml\""),
) )
.arg( .arg(
Arg::new("from") Arg::new("from")
.short('f') .short('f')
.long("from") .long("from")
.allow_invalid_utf8(true)
.required(false) .required(false)
.takes_value(true) .value_parser(ValueParser::path_buf())
.action(ArgAction::Set)
.help("\"from\" directory path"), .help("\"from\" directory path"),
) )
.arg( .arg(
Arg::new("to") Arg::new("to")
.short('t') .short('t')
.long("to") .long("to")
.allow_invalid_utf8(true)
.required(false) .required(false)
.takes_value(true) .value_parser(ValueParser::path_buf())
.action(ArgAction::Set)
.help("\"to\" directory path"), .help("\"to\" directory path"),
) )
.arg( .arg(
@@ -154,7 +157,8 @@ pub fn config() -> Result<Config> {
.short('j') .short('j')
.long("jobs") .long("jobs")
.required(false) .required(false)
.takes_value(true) .value_parser(clap::value_parser!(usize))
.action(ArgAction::Set)
.help("Allow N jobs/transcodes at once. Defaults to number of logical cores"), .help("Allow N jobs/transcodes at once. Defaults to number of logical cores"),
) )
.subcommand(Command::new("init").about("writes an example config")) .subcommand(Command::new("init").about("writes an example config"))
@@ -162,8 +166,8 @@ 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 = arg_matches.value_of_os("config"); let config_path = arg_matches.get_one::<PathBuf>("config");
let force_load = config_path.is_some(); let enforce_config_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)
.unwrap_or_else(|| AsRef::<Path>::as_ref("audio-conv.yaml")); .unwrap_or_else(|| AsRef::<Path>::as_ref("audio-conv.yaml"));
@@ -187,7 +191,7 @@ pub fn config() -> Result<Config> {
let config_file = load_config_file(&config_path) let config_file = load_config_file(&config_path)
.with_context(|| format!("Failed loading config file {}", config_path.display()))?; .with_context(|| format!("Failed loading config file {}", config_path.display()))?;
if force_load && config_file.is_none() { if enforce_config_load && config_file.is_none() {
return Err(Error::msg(format!( return Err(Error::msg(format!(
"could not find config file \"{}\"", "could not find config file \"{}\"",
config_path.display() config_path.display()
@@ -263,7 +267,7 @@ pub fn config() -> Result<Config> {
Ok(Config { Ok(Config {
from: { from: {
arg_matches arg_matches
.value_of_os("from") .get_one::<PathBuf>("from")
.map(|p| current_dir.join(p)) .map(|p| current_dir.join(p))
.or_else(|| { .or_else(|| {
config_file config_file
@@ -277,7 +281,7 @@ pub fn config() -> Result<Config> {
.context("Could not canonicalize \"from\" path")? .context("Could not canonicalize \"from\" path")?
}, },
to: arg_matches to: arg_matches
.value_of_os("to") .get_one::<PathBuf>("to")
.map(|p| current_dir.join(p)) .map(|p| current_dir.join(p))
.or_else(|| { .or_else(|| {
config_file config_file
@@ -291,16 +295,18 @@ pub fn config() -> Result<Config> {
.context("Could not canonicalize \"to\" path")?, .context("Could not canonicalize \"to\" path")?,
matches: transcode_matches, matches: transcode_matches,
jobs: arg_matches jobs: arg_matches
.value_of("jobs") .get_one("jobs")
.map(|jobs_str| { .copied()
jobs_str.parse().with_context(|| { .or_else(|| config_file.as_ref().map(|c| c.jobs).flatten()),
format!( // .map(|jobs_str| {
"Could not parse \"jobs\" argument \"{}\" to a number", // jobs_str.parse().with_context(|| {
&jobs_str // format!(
) // "Could not parse \"jobs\" argument \"{}\" to a number",
}) // &jobs_str
}) // )
.transpose()?, // })
// })
// .transpose()?,
}) })
} }

View File

@@ -5,13 +5,13 @@ mod ui;
use crate::config::{Config, Transcode}; use crate::config::{Config, Transcode};
use anyhow::{Context, Error, Result}; use anyhow::{Context, Error, Result};
use futures::{pin_mut, prelude::*}; use futures::{pin_mut, prelude::*};
use glib::{Boxed, GString}; use glib::Boxed;
use gstreamer::{element_error, prelude::*, Element}; use gstreamer::{Element, element_error, prelude::*};
use gstreamer_base::prelude::*; use gstreamer_base::prelude::*;
use std::{ use std::{
borrow::Cow, borrow::Cow,
error::Error as StdError, error::Error as StdError,
ffi, fmt, fmt,
fmt::Write as FmtWrite, fmt::Write as FmtWrite,
path::{Path, PathBuf}, path::{Path, PathBuf},
result::Result as StdResult, result::Result as StdResult,
@@ -43,7 +43,7 @@ impl fmt::Display for GBoxErrorWrapper {
} }
#[derive(Debug, derive_more::Display, derive_more::Error)] #[derive(Debug, derive_more::Display, derive_more::Error)]
#[display(fmt = "Received error from {}: {} (debug: {:?})", src, error, debug)] #[display("Received error from {}: {} (debug: {:?})", src, error, debug)]
struct GErrorMessage { struct GErrorMessage {
src: String, src: String,
error: String, error: String,
@@ -51,8 +51,15 @@ struct GErrorMessage {
source: glib::Error, source: glib::Error,
} }
fn gmake<T: IsA<Element>>(factory_name: &str) -> Result<T> { fn gmake<T: IsA<Element>>(factory_name: &str, properties: &[(&str, &dyn ToValue)]) -> Result<T> {
let res = gstreamer::ElementFactory::make(factory_name, None) let builder = gstreamer::ElementFactory::make(factory_name);
let builder = properties
.into_iter()
.fold(builder, |builder, (name, value)| {
builder.property(name, value.to_value())
});
let res = builder
.build()
.with_context(|| format!("Could not make gstreamer Element \"{}\"", factory_name))? .with_context(|| format!("Could not make gstreamer Element \"{}\"", factory_name))?
.downcast() .downcast()
.ok() .ok()
@@ -125,7 +132,7 @@ fn get_conversion_args(config: &Config) -> impl Iterator<Item = Result<Conversio
Err(err) => { Err(err) => {
return Err(err).with_context(|| { return Err(err).with_context(|| {
format!("Unable to get mtime for \"to\" file {}", to.display()) format!("Unable to get mtime for \"to\" file {}", to.display())
}) });
} }
} }
}; };
@@ -328,14 +335,13 @@ async fn transcode_gstreamer(
task_id: usize, task_id: usize,
queue: &ui::MsgQueue, queue: &ui::MsgQueue,
) -> Result<()> { ) -> Result<()> {
let file_src: Element = gmake("filesrc")?; let file_src: Element = gmake("filesrc", &[("location", &from_path)])?;
file_src.try_set_property("location", &path_to_gstring(&from_path))?;
let decodebin: Element = gmake("decodebin")?; let decodebin: Element = gmake("decodebin", &[])?;
let src_elems: &[&Element] = &[&file_src, &decodebin]; let src_elems: &[&Element] = &[&file_src, &decodebin];
let pipeline = gstreamer::Pipeline::new(None); let pipeline = gstreamer::Pipeline::new();
pipeline.add_many(src_elems)?; pipeline.add_many(src_elems)?;
Element::link_many(src_elems)?; Element::link_many(src_elems)?;
@@ -374,14 +380,18 @@ async fn transcode_gstreamer(
Some(true) => {} Some(true) => {}
} }
let resample: Element = gmake("audioresample")?; let resample: Element = gmake(
// quality from 0 to 10 "audioresample",
resample.try_set_property("quality", &10i32)?; &[
// quality from 0 to 10
("quality", &10i32),
],
)?;
let mut dest_elems = vec![ let mut dest_elems = vec![
resample, resample,
// `audioconvert` converts audio format, bitdepth, ... // `audioconvert` converts audio format, bitdepth, ...
gmake("audioconvert")?, gmake("audioconvert", &[])?,
]; ];
match &transcode { match &transcode {
@@ -389,28 +399,32 @@ async fn transcode_gstreamer(
bitrate, bitrate,
bitrate_type, bitrate_type,
} => { } => {
let encoder: Element = gmake("opusenc")?; let encoder: Element = gmake(
encoder.try_set_property( "opusenc",
"bitrate", &[
&i32::from(*bitrate) (
.checked_mul(1_000) "bitrate",
.context("Bitrate overflowed")?, &i32::from(*bitrate)
.checked_mul(1_000)
.context("Bitrate overflowed")?,
),
(
"bitrate-type",
match bitrate_type {
config::BitrateType::Vbr => &"1",
config::BitrateType::Cbr => &"0",
},
),
],
)?; )?;
encoder.set_property_from_str(
"bitrate-type",
match bitrate_type {
config::BitrateType::Vbr => "1",
config::BitrateType::Cbr => "0",
},
);
dest_elems.push(encoder); dest_elems.push(encoder);
dest_elems.push(gmake("oggmux")?); dest_elems.push(gmake("oggmux", &[])?);
} }
Transcode::Flac { compression } => { Transcode::Flac { compression } => {
let encoder: Element = gmake("flacenc")?; let encoder: Element =
encoder.set_property_from_str("quality", &compression.to_string()); gmake("flacenc", &[("quality", &compression.to_string())])?;
dest_elems.push(encoder); dest_elems.push(encoder);
} }
@@ -418,20 +432,24 @@ async fn transcode_gstreamer(
bitrate, bitrate,
bitrate_type, bitrate_type,
} => { } => {
let encoder: Element = gmake("lamemp3enc")?; let encoder: Element = gmake(
// target: "1" = "bitrate" "lamemp3enc",
encoder.set_property_from_str("target", "1"); &[
encoder.try_set_property("bitrate", &i32::from(*bitrate))?; // target: "1" = "bitrate"
encoder.try_set_property( ("target", &"1"),
"cbr", ("bitrate", &i32::from(*bitrate)),
match bitrate_type { (
config::BitrateType::Vbr => &false, "cbr",
config::BitrateType::Cbr => &true, match bitrate_type {
}, config::BitrateType::Vbr => &false,
config::BitrateType::Cbr => &true,
},
),
],
)?; )?;
dest_elems.push(encoder); dest_elems.push(encoder);
dest_elems.push(gmake("id3v2mux")?); dest_elems.push(gmake("id3v2mux", &[])?);
} }
Transcode::Copy => { Transcode::Copy => {
@@ -440,8 +458,8 @@ async fn transcode_gstreamer(
} }
}; };
let file_dest: gstreamer_base::BaseSink = gmake("filesink")?; let file_dest: gstreamer_base::BaseSink =
file_dest.try_set_property("location", &path_to_gstring(&to_path_clone))?; gmake("filesink", &[("location", &to_path_clone)])?;
file_dest.set_sync(false); file_dest.set_sync(false);
dest_elems.push(file_dest.upcast()); dest_elems.push(file_dest.upcast());
@@ -521,7 +539,7 @@ async fn transcode_gstreamer(
.map(|s| String::from(s.path_string())) .map(|s| String::from(s.path_string()))
.unwrap_or_else(|| String::from("None")), .unwrap_or_else(|| String::from("None")),
error: err.error().to_string(), error: err.error().to_string(),
debug: err.debug(), debug: err.debug().map(|gstring| gstring.into()),
source: err.error(), source: err.error(),
} }
.into() .into()
@@ -641,10 +659,3 @@ fn path_to_bytes(path: &Path) -> Cow<'_, [u8]> {
Cow::Owned(buf) Cow::Owned(buf)
} }
} }
fn path_to_gstring(path: &Path) -> GString {
let buf = path_to_bytes(path);
ffi::CString::new(buf)
.expect("Path contained null byte")
.into()
}

View File

@@ -1,23 +1,20 @@
use glib::Value; use glib::{GStr, Value, gstr};
use gstreamer::{ use gstreamer::{
tags::{merge_strings_with_comma, CustomTag},
Tag, TagFlag, Tag, TagFlag,
tags::{CustomTag, merge_strings_with_comma},
}; };
pub struct MbArtistId; pub struct MbArtistId;
impl<'a> Tag<'a> for MbArtistId { impl<'a> Tag<'a> for MbArtistId {
type TagType = &'a str; type TagType = &'a str;
const TAG_NAME: &'static GStr = gstr!("musicbrainz-artistid");
fn tag_name<'b>() -> &'b str {
"musicbrainz-artistid"
}
} }
impl CustomTag<'_> for MbArtistId { impl CustomTag<'_> for MbArtistId {
const FLAG: TagFlag = TagFlag::Meta; const FLAG: TagFlag = TagFlag::Meta;
const NICK: &'static str = "artist ID"; const NICK: &'static GStr = gstr!("artist ID");
const DESCRIPTION: &'static str = "MusicBrainz artist ID"; const DESCRIPTION: &'static GStr = gstr!("MusicBrainz artist ID");
fn merge_func(src: &Value) -> Value { fn merge_func(src: &Value) -> Value {
merge_strings_with_comma(src) merge_strings_with_comma(src)
@@ -28,16 +25,13 @@ pub struct MbAlbumArtistId;
impl<'a> Tag<'a> for MbAlbumArtistId { impl<'a> Tag<'a> for MbAlbumArtistId {
type TagType = &'a str; type TagType = &'a str;
const TAG_NAME: &'static GStr = gstr!("musicbrainz-albumartistid");
fn tag_name<'b>() -> &'b str {
"musicbrainz-albumartistid"
}
} }
impl CustomTag<'_> for MbAlbumArtistId { impl CustomTag<'_> for MbAlbumArtistId {
const FLAG: TagFlag = TagFlag::Meta; const FLAG: TagFlag = TagFlag::Meta;
const NICK: &'static str = "album artist ID"; const NICK: &'static GStr = gstr!("album artist ID");
const DESCRIPTION: &'static str = "MusicBrainz album artist ID"; const DESCRIPTION: &'static GStr = gstr!("MusicBrainz album artist ID");
fn merge_func(src: &Value) -> Value { fn merge_func(src: &Value) -> Value {
merge_strings_with_comma(src) merge_strings_with_comma(src)

View File

@@ -6,7 +6,7 @@ use std::{
time::Duration, time::Duration,
}; };
use tokio::{task, time::interval}; use tokio::{task, time::interval};
use tui::{backend::CrosstermBackend, Terminal}; use tui::{Terminal, backend::CrosstermBackend};
pub const UPDATE_INTERVAL_MILLIS: u64 = 100; pub const UPDATE_INTERVAL_MILLIS: u64 = 100;
@@ -92,7 +92,7 @@ impl State {
self.ended_tasks += 1; self.ended_tasks += 1;
} }
Msg::TaskProgress { id, ratio } => { Msg::TaskProgress { id, ratio } => {
let mut task = self let task = self
.running_tasks .running_tasks
.get_mut(&id) .get_mut(&id)
.context("Unable to update task progress; could't find task")?; .context("Unable to update task progress; could't find task")?;