17 Commits
wip ... main

12 changed files with 770 additions and 775 deletions

View File

@@ -6,7 +6,3 @@ indent_size = 4
charset = utf-8 charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
[*.yaml]
indent_style = space
indent_size = 2

View File

@@ -1,5 +1,18 @@
# Changelog # Changelog
## v1.3.2
* dependencies upgraded
## v1.3.1
* dependencies upgraded
## v1.3.0
* allow multiple values for the tags "musicbrainz-artistid" and "musicbrainz-albumartistid"
* fix "from", "to" & "config" cli argument processing
## v1.2.2 ## v1.2.2
* dependencies upgraded * dependencies upgraded

950
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.2.2" 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.17", features = ["v1_10"] } gstreamer = { version = "0.24", features = ["v1_16"] }
gstreamer = { version = "0.17", features = ["v1_10"] } gstreamer-base = { version = "0.24", features = ["v1_16"] }
gstreamer-base = { version = "0.17", features = ["v1_10"] } gstreamer-audio = { version = "0.24", features = ["v1_16"] }
glib = "0.14" 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 = "2" 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.16", 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.

View File

@@ -13,6 +13,10 @@ matches:
bitrate: 160 bitrate: 160
bitrate_type: vbr # or cbr bitrate_type: vbr # or cbr
# for copy (copies file without transcoding it):
# to:
# codec: copy
# for mp3: # for mp3:
# to: # to:
# codec: mp3 # codec: mp3
@@ -25,12 +29,3 @@ matches:
# codec: flac # codec: flac
# # effort spend for the compression. 0 (fastes compression) to 9 (highest compression) # # effort spend for the compression. 0 (fastes compression) to 9 (highest compression)
# compression: 8 # compression: 8
# copies the whole file without transcoding it or extracting audio from it. Using Copy on Write
# if supported by the filesystem.
# to:
# codec: copy
# extracts the audio without transcoding it
# to:
# codec: copyaudio

58
flake.lock generated
View File

@@ -1,58 +0,0 @@
{
"nodes": {
"flake-utils": {
"locked": {
"lastModified": 1629481132,
"narHash": "sha256-JHgasjPR0/J1J3DRm4KxM4zTyAj4IOJY8vIl75v/kPI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "997f7efcb746a9c140ce1f13c72263189225f482",
"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": 1629897889,
"narHash": "sha256-YoY/umk+NUtLFJgvTJkup6nLJb+sGEZ21hrupKTp7EI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6248814b6892af7dc0cf973b49690fd102088e02",
"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;
@@ -50,9 +51,6 @@ pub enum Transcode {
#[serde(rename = "copy")] #[serde(rename = "copy")]
Copy, Copy,
#[serde(rename = "copyaudio")]
CopyAudio,
} }
impl Transcode { impl Transcode {
@@ -62,7 +60,6 @@ impl Transcode {
Transcode::Flac { .. } => "flac", Transcode::Flac { .. } => "flac",
Transcode::Mp3 { .. } => "mp3", Transcode::Mp3 { .. } => "mp3",
Transcode::Copy => "", Transcode::Copy => "",
Transcode::CopyAudio => "",
} }
} }
} }
@@ -107,6 +104,8 @@ struct ConfigFile {
#[serde(default)] #[serde(default)]
matches: Vec<TranscodeMatchFile>, matches: Vec<TranscodeMatchFile>,
jobs: Option<usize>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -121,50 +120,54 @@ struct TranscodeMatchFile {
} }
pub fn config() -> Result<Config> { pub fn config() -> Result<Config> {
use clap::{App, Arg, SubCommand}; use clap::{Arg, Command};
let arg_matches = App::new("audio-conv") let arg_matches = Command::new("audio-conv")
.version(clap::crate_version!()) .version(clap::crate_version!())
.about("Converts audio files") .about("Converts audio files")
.arg( .arg(
Arg::with_name("config") Arg::new("config")
.short("c") .short('c')
.long("config") .long("config")
.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::with_name("from") Arg::new("from")
.short("f") .short('f')
.long("from") .long("from")
.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::with_name("to") Arg::new("to")
.short("t") .short('t')
.long("to") .long("to")
.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(
Arg::with_name("jobs") Arg::new("jobs")
.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(SubCommand::with_name("init").about("writes an example config")) .subcommand(Command::new("init").about("writes an example config"))
.get_matches(); .get_matches();
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"));
@@ -188,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()
@@ -264,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
@@ -278,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
@@ -292,23 +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_os("jobs") .get_one("jobs")
.map(|jobs_os_str| { .copied()
let jobs_str = jobs_os_str.to_str().with_context(|| { .or_else(|| config_file.as_ref().map(|c| c.jobs).flatten()),
// TODO: use `OsStr.display` when it lands // .map(|jobs_str| {
// https://github.com/rust-lang/rust/pull/80841 // jobs_str.parse().with_context(|| {
format!( // format!(
"Could not convert \"jobs\" argument to string due to invalid characters", // "Could not parse \"jobs\" argument \"{}\" to a number",
) // &jobs_str
})?; // )
jobs_str.parse().with_context(|| { // })
format!( // })
"Could not parse \"jobs\" argument \"{}\" to a number", // .transpose()?,
&jobs_str
)
})
})
.transpose()?,
}) })
} }

View File

@@ -1,30 +1,27 @@
mod config; mod config;
mod tag;
mod ui; 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::{GBoxed, GString}; use glib::Boxed;
use gstreamer::{ use gstreamer::{Element, element_error, prelude::*};
element_error, prelude::*, Caps, Element, Pad, PadBuilder, PadDirection, PadPresence,
PadTemplate, Structure,
};
use gstreamer_base::prelude::*; use gstreamer_base::prelude::*;
use std::{ use std::{
borrow::Cow, borrow::Cow,
collections::VecDeque,
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,
sync::{Arc, Mutex}, sync::Arc,
time::Duration, time::Duration,
}; };
use tokio::{fs, io::AsyncWriteExt, task, time::interval}; use tokio::{fs, io::AsyncWriteExt, task, time::interval};
#[derive(Clone, Debug, GBoxed)] #[derive(Clone, Debug, Boxed)]
#[gboxed(type_name = "GBoxErrorWrapper")] #[boxed_type(name = "GBoxErrorWrapper")]
struct GBoxErrorWrapper(Arc<Error>); struct GBoxErrorWrapper(Arc<Error>);
impl GBoxErrorWrapper { impl GBoxErrorWrapper {
@@ -46,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,
@@ -54,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()
@@ -107,15 +111,30 @@ fn get_conversion_args(config: &Config) -> impl Iterator<Item = Result<Conversio
) )
})?; })?;
let is_newer = if let Transcode::CopyAudio = transcode { let mut to = config.to.join(&rel_path);
// we are doing the "is newer check" in the transcoder, because we do not know to.set_extension(transcode.extension());
// the file extension at this moment, which is derived from the audio type in
// the source file let is_newer = {
true let from_mtime = e
} else { .metadata()
let from_path = config.to.join(&rel_path); .map_err(Error::new)
let to_path = from_path.with_extension(transcode.extension()); .and_then(|md| md.modified().map_err(Error::new))
is_file_newer(&from_path, &to_path)? .with_context(|| {
format!(
"Unable to get mtime for \"from\" file {}",
e.path().display()
)
})?;
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) => {
return Err(err).with_context(|| {
format!("Unable to get mtime for \"to\" file {}", to.display())
});
}
}
}; };
if is_newer { if is_newer {
@@ -160,6 +179,9 @@ async fn main() -> Result<()> {
async fn main_loop(ui_queue: ui::MsgQueue) -> Result<()> { async fn main_loop(ui_queue: ui::MsgQueue) -> Result<()> {
let (config, conv_args) = task::spawn_blocking(|| -> Result<_> { let (config, conv_args) = task::spawn_blocking(|| -> Result<_> {
gstreamer::init()?; gstreamer::init()?;
gstreamer::tags::register::<tag::MbArtistId>();
gstreamer::tags::register::<tag::MbAlbumArtistId>();
let config = config::config().context("Could not get the config")?; let config = config::config().context("Could not get the config")?;
let conv_args = get_conversion_args(&config) let conv_args = get_conversion_args(&config)
@@ -271,7 +293,7 @@ async fn transcode(
let to_path_tmp = to_path.with_extension("tmp"); let to_path_tmp = to_path.with_extension("tmp");
rm_file_on_err(&to_path_tmp, async { rm_file_on_err(&to_path_tmp, async {
let new_extension = match args.transcode { match args.transcode {
Transcode::Copy => { Transcode::Copy => {
fs::copy(&from_path, &to_path_tmp).await.with_context(|| { fs::copy(&from_path, &to_path_tmp).await.with_context(|| {
format!( format!(
@@ -280,7 +302,6 @@ async fn transcode(
to_path_tmp.display() to_path_tmp.display()
) )
})?; })?;
None
} }
_ => { _ => {
to_path.set_extension(args.transcode.extension()); to_path.set_extension(args.transcode.extension());
@@ -294,10 +315,6 @@ async fn transcode(
) )
.await? .await?
} }
};
if let Some(new_extension) = new_extension {
to_path.set_extension(new_extension);
} }
fs::rename(&to_path_tmp, &to_path).await.with_context(|| { fs::rename(&to_path_tmp, &to_path).await.with_context(|| {
@@ -317,15 +334,14 @@ async fn transcode_gstreamer(
transcode: Transcode, transcode: Transcode,
task_id: usize, task_id: usize,
queue: &ui::MsgQueue, queue: &ui::MsgQueue,
) -> Result<Option<&'static str>> { ) -> Result<()> {
let file_src: Element = gmake("filesrc")?; let file_src: Element = gmake("filesrc", &[("location", &from_path)])?;
file_src.set_property("location", &path_to_gstring(&from_path))?;
let decodebin: Element = gmake("parsebin")?; 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)?;
@@ -333,10 +349,6 @@ async fn transcode_gstreamer(
// downgrade pipeline RC to a weak RC to break the reference cycle // downgrade pipeline RC to a weak RC to break the reference cycle
let pipeline_weak = pipeline.downgrade(); let pipeline_weak = pipeline.downgrade();
let new_extension = Arc::new(Mutex::new(None));
let new_extension_clone = new_extension.clone();
let from_path_clone = from_path.to_owned();
let to_path_clone = to_path.to_owned(); let to_path_clone = to_path.to_owned();
decodebin.connect_pad_added(move |decodebin, src_pad| { decodebin.connect_pad_added(move |decodebin, src_pad| {
let insert_sink = || -> Result<()> { let insert_sink = || -> Result<()> {
@@ -348,157 +360,108 @@ async fn transcode_gstreamer(
} }
}; };
let is_audio_mime = src_pad.current_caps().and_then(|caps| { let is_audio = src_pad.current_caps().and_then(|caps| {
println!("{:?}", caps); caps.structure(0).map(|s| {
caps.structure(0).as_ref().map(|s| {
let name = s.name(); let name = s.name();
(name.starts_with("audio/"), name) name.starts_with("audio/")
}) })
}); });
let audio_mime = match is_audio_mime { match is_audio {
None => { None => {
return Err(Error::msg(format!( return Err(Error::msg(format!(
"Failed to get media type from pad {}", "Failed to get media type from pad {}",
src_pad.name() src_pad.name()
))); )));
} }
Some((false, ..)) => { Some(false) => {
// not audio pad... ignoring // not audio pad... ignoring
return Ok(()); return Ok(());
} }
Some((true, mime)) => mime, Some(true) => {}
}; }
let mut dest_elems = VecDeque::new(); let resample: Element = gmake(
"audioresample",
&[
// quality from 0 to 10
("quality", &10i32),
],
)?;
let is_transcoding = match &transcode { let mut dest_elems = vec![
resample,
// `audioconvert` converts audio format, bitdepth, ...
gmake("audioconvert", &[])?,
];
match &transcode {
Transcode::Opus { Transcode::Opus {
bitrate, bitrate,
bitrate_type, bitrate_type,
} => { } => {
let encoder: Element = gmake("opusenc")?; let encoder: Element = gmake(
encoder.set_property( "opusenc",
&[
(
"bitrate", "bitrate",
&i32::from(*bitrate) &i32::from(*bitrate)
.checked_mul(1_000) .checked_mul(1_000)
.context("Bitrate overflowed")?, .context("Bitrate overflowed")?,
)?; ),
encoder.set_property_from_str( (
"bitrate-type", "bitrate-type",
match bitrate_type { match bitrate_type {
config::BitrateType::Vbr => "1", config::BitrateType::Vbr => &"1",
config::BitrateType::Cbr => "0", config::BitrateType::Cbr => &"0",
}, },
); ),
],
)?;
dest_elems.push_back(encoder); dest_elems.push(encoder);
dest_elems.push_back(gmake("oggmux")?); dest_elems.push(gmake("oggmux", &[])?);
true
} }
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_back(encoder); dest_elems.push(encoder);
true
} }
Transcode::Mp3 { Transcode::Mp3 {
bitrate, bitrate,
bitrate_type, bitrate_type,
} => { } => {
let encoder: Element = gmake("lamemp3enc")?; let encoder: Element = gmake(
"lamemp3enc",
&[
// target: "1" = "bitrate" // target: "1" = "bitrate"
encoder.set_property_from_str("target", "1"); ("target", &"1"),
encoder.set_property("bitrate", &i32::from(*bitrate))?; ("bitrate", &i32::from(*bitrate)),
encoder.set_property( (
"cbr", "cbr",
match bitrate_type { match bitrate_type {
config::BitrateType::Vbr => &false, config::BitrateType::Vbr => &false,
config::BitrateType::Cbr => &true, config::BitrateType::Cbr => &true,
}, },
),
],
)?; )?;
dest_elems.push_back(encoder); dest_elems.push(encoder);
dest_elems.push_back(gmake("id3v2mux")?); dest_elems.push(gmake("id3v2mux", &[])?);
true
} }
Transcode::Copy => { Transcode::Copy => {
// already handled outside this fn // already handled outside this fn
unreachable!(); unreachable!();
} }
Transcode::CopyAudio => {
let (extension, mux) = match audio_mime {
"audio/ogg" | "audio/opus" | "audio/x-opus" => {
let mux: Element = gmake("oggmux")?;
// let caps = Caps::new_simple("audio/x-opus", &[]);
let template = PadTemplate::new(
"audio_%u",
PadDirection::Sink,
PadPresence::Request,
// &Caps::builder_full_with_any_features().structure(Structure::new("opus", "")).build()
&src_pad.current_caps().unwrap(),
)?;
// println!("{:?}", caps);
mux.add_pad(&Pad::from_template(&template, Some("audio_%u")))?;
("opus", Some(mux))
}
"audio/mpeg" => ("mp3", None),
"audio/flac" => ("flac", Some(gmake("oggmux")?)),
_ => {
return Err(Error::msg(format!(
"Unsupprted audio mime type \"{}\"",
audio_mime
)))
}
}; };
let is_newer = is_file_newer( let file_dest: gstreamer_base::BaseSink =
&from_path_clone, gmake("filesink", &[("location", &to_path_clone)])?;
&from_path_clone.with_extension(extension),
)?;
if !is_newer {
return Ok(());
}
if let Some(mux) = mux {
dest_elems.push_back(mux);
}
new_extension_clone
.lock()
.expect("Could not lock extension mutex")
.replace(extension);
false
}
};
if is_transcoding {
let resample: Element = gmake("audioresample")?;
// quality from 0 to 10
resample.set_property("quality", &10)?;
let elems = [gmake("decodebin")?, gmake("audioconvert")?, resample];
// reversed order because we are pushing to the front
for elem in IntoIterator::into_iter(elems).into_iter().rev() {
dest_elems.push_front(elem);
}
}
let file_dest: gstreamer_base::BaseSink = gmake("filesink")?;
file_dest.set_property("location", &path_to_gstring(&to_path_clone))?;
file_dest.set_sync(false); file_dest.set_sync(false);
dest_elems.push_back(file_dest.upcast()); dest_elems.push(file_dest.upcast());
let dest_elem_refs: Vec<_> = dest_elems.iter().collect(); let dest_elem_refs: Vec<_> = dest_elems.iter().collect();
pipeline.add_many(&dest_elem_refs)?; pipeline.add_many(&dest_elem_refs)?;
@@ -512,8 +475,7 @@ async fn transcode_gstreamer(
.get(0) .get(0)
.unwrap() .unwrap()
.static_pad("sink") .static_pad("sink")
.or_else(|| dest_elems.get(0).unwrap().static_pad("audio_0")) .expect("1. dest element has no sinkpad");
.context("1. dest element has no sinkpad")?;
src_pad.link(&sink_pad)?; src_pad.link(&sink_pad)?;
Ok(()) Ok(())
@@ -577,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()
@@ -653,33 +615,7 @@ async fn transcode_gstreamer(
.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")?;
let mut new_extension = new_extension Ok(())
.lock()
.expect("Could not lock extension mutex");
Ok(new_extension.take())
}
fn is_file_newer(from_path: &Path, to_path: &Path) -> Result<bool> {
let from_mtime = from_path
.metadata()
.map_err(Error::new)
.and_then(|md| md.modified().map_err(Error::new))
.with_context(|| {
format!(
"Unable to get mtime for \"from\" file {}",
from_path.display()
)
})?;
let to_mtime = to_path.metadata().and_then(|md| md.modified());
match to_mtime {
Ok(to_mtime) => Ok(to_mtime < from_mtime),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(true),
Err(err) => {
return Err(err).with_context(|| {
format!("Unable to get mtime for \"to\" file {}", to_path.display())
})
}
}
} }
async fn rm_file_on_err<F, T>(path: &Path, f: F) -> Result<T> async fn rm_file_on_err<F, T>(path: &Path, f: F) -> Result<T>
@@ -723,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()
}

39
src/tag.rs Normal file
View File

@@ -0,0 +1,39 @@
use glib::{GStr, Value, gstr};
use gstreamer::{
Tag, TagFlag,
tags::{CustomTag, merge_strings_with_comma},
};
pub struct MbArtistId;
impl<'a> Tag<'a> for MbArtistId {
type TagType = &'a str;
const TAG_NAME: &'static GStr = gstr!("musicbrainz-artistid");
}
impl CustomTag<'_> for MbArtistId {
const FLAG: TagFlag = TagFlag::Meta;
const NICK: &'static GStr = gstr!("artist ID");
const DESCRIPTION: &'static GStr = gstr!("MusicBrainz artist ID");
fn merge_func(src: &Value) -> Value {
merge_strings_with_comma(src)
}
}
pub struct MbAlbumArtistId;
impl<'a> Tag<'a> for MbAlbumArtistId {
type TagType = &'a str;
const TAG_NAME: &'static GStr = gstr!("musicbrainz-albumartistid");
}
impl CustomTag<'_> for MbAlbumArtistId {
const FLAG: TagFlag = TagFlag::Meta;
const NICK: &'static GStr = gstr!("album artist ID");
const DESCRIPTION: &'static GStr = gstr!("MusicBrainz album artist ID");
fn merge_func(src: &Value) -> Value {
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")?;