diff --git a/Cargo.lock b/Cargo.lock
index 6b905d75..f0d00193 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -477,15 +477,15 @@ dependencies = [
[[package]]
name = "async-executor"
-version = "1.6.0"
+version = "1.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b0c4a4f319e45986f347ee47fef8bf5e81c9abc3f6f58dc2391439f30df65f0"
+checksum = "fc5ea910c42e5ab19012bab31f53cb4d63d54c3a27730f9a833a88efcf4bb52d"
dependencies = [
- "async-lock 2.8.0",
+ "async-lock 3.1.1",
"async-task",
"concurrent-queue",
"fastrand 2.0.1",
- "futures-lite 1.13.0",
+ "futures-lite 2.0.1",
"slab",
]
@@ -535,9 +535,9 @@ dependencies = [
[[package]]
name = "async-lock"
-version = "3.1.0"
+version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "deb2ab2aa8a746e221ab826c73f48bc6ba41be6763f0855cb249eb6d154cf1d7"
+checksum = "655b9c7fe787d3b25cc0f804a1a8401790f0c5bc395beb5a64dc77d8de079105"
dependencies = [
"event-listener 3.1.0",
"event-listener-strategy",
@@ -689,7 +689,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a37913e8dc4ddcc604f0c6d3bf2887c995153af3611de9e23c352b44c1b9118"
dependencies = [
"async-channel 2.1.0",
- "async-lock 3.1.0",
+ "async-lock 3.1.1",
"async-task",
"fastrand 2.0.1",
"futures-io",
@@ -1273,6 +1273,7 @@ version = "0.20.1"
dependencies = [
"chrono",
"crossbeam-channel",
+ "derive_more",
"ffprobe",
"file-rotate",
"lettre",
@@ -1464,7 +1465,11 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3831c2651acb5177cbd83943f3d9c8912c5ad03c76afcc0e9511ba568ec5ebb"
dependencies = [
+ "fastrand 2.0.1",
"futures-core",
+ "futures-io",
+ "memchr",
+ "parking",
"pin-project-lite",
]
@@ -2711,9 +2716,9 @@ dependencies = [
[[package]]
name = "rustix"
-version = "0.38.24"
+version = "0.38.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ad981d6c340a49cdc40a1028d9c6084ec7e9fa33fcb839cab656a267071e234"
+checksum = "dc99bc2d4f1fed22595588a013687477aedf3cdcfb26558c559edb67b4d9b22e"
dependencies = [
"bitflags 2.4.1",
"errno",
@@ -3346,7 +3351,7 @@ dependencies = [
"cfg-if",
"fastrand 2.0.1",
"redox_syscall 0.4.1",
- "rustix 0.38.24",
+ "rustix 0.38.25",
"windows-sys",
]
@@ -3650,9 +3655,9 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
[[package]]
name = "uuid"
-version = "1.5.0"
+version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "88ad59a7560b41a70d191093a945f0b87bc1deeda46fb237479708a1d6b6cdfc"
+checksum = "5e395fcf16a7a3d8127ec99782007af141946b4795001f876d54fb0d55978560"
dependencies = [
"getrandom",
]
diff --git a/assets/ffplayout.yml b/assets/ffplayout.yml
index 7e12c263..b6bcc087 100644
--- a/assets/ffplayout.yml
+++ b/assets/ffplayout.yml
@@ -35,6 +35,8 @@ logging:
false will set log timestamps to UTC. Path to /var/log/ only if you run this
program as daemon. 'level' can be DEBUG, INFO, WARNING, ERROR.
'ffmpeg_level' can be info, warning, error.
+ 'detect_silence' logs an error message if the audio line is silent for 15
+ seconds during the validation process.
log_to_file: true
backup_count: 7
local_time: true
@@ -43,6 +45,7 @@ logging:
level: DEBUG
ffmpeg_level: error
ingest_level: warning
+ detect_silence: false
processing:
help_text: Default processing for all clips, to have them unique. Mode can be playlist
diff --git a/ffplayout-engine/src/main.rs b/ffplayout-engine/src/main.rs
index fcbea1bf..b93eb50b 100644
--- a/ffplayout-engine/src/main.rs
+++ b/ffplayout-engine/src/main.rs
@@ -19,9 +19,9 @@ use ffplayout::{
};
use ffplayout_lib::utils::{
- folder::fill_filler_list, generate_playlist, get_date, import::import_file, init_logging,
- is_remote, send_mail, test_tcp_port, validate_ffmpeg, validate_playlist, JsonPlaylist,
- OutputMode::*, PlayerControl, PlayoutStatus, ProcessControl,
+ errors::ProcError, folder::fill_filler_list, generate_playlist, get_date, import::import_file,
+ init_logging, is_remote, send_mail, test_tcp_port, validate_ffmpeg, validate_playlist,
+ JsonPlaylist, OutputMode::*, PlayerControl, PlayoutStatus, ProcessControl,
};
#[cfg(debug_assertions)]
@@ -44,7 +44,7 @@ struct StatusData {
/// we save the time difference, so we stay in sync.
///
/// When file not exists we create it, and when it exists we get its values.
-fn status_file(stat_file: &str, playout_stat: &PlayoutStatus) {
+fn status_file(stat_file: &str, playout_stat: &PlayoutStatus) -> Result<(), ProcError> {
debug!("Start ffplayout v{VERSION}, status file path: {stat_file}>");
if !PathBuf::from(stat_file).exists() {
@@ -53,23 +53,19 @@ fn status_file(stat_file: &str, playout_stat: &PlayoutStatus) {
"date": String::new(),
});
- let json: String = serde_json::to_string(&data).expect("Serialize status data failed");
+ let json: String = serde_json::to_string(&data)?;
if let Err(e) = fs::write(stat_file, json) {
error!("Unable to write to status file {stat_file}>: {e}");
};
} else {
- let stat_file = File::options()
- .read(true)
- .write(false)
- .open(stat_file)
- .expect("Could not open status file");
-
- let data: StatusData =
- serde_json::from_reader(stat_file).expect("Could not read status file.");
+ let stat_file = File::options().read(true).write(false).open(stat_file)?;
+ let data: StatusData = serde_json::from_reader(stat_file)?;
*playout_stat.time_shift.lock().unwrap() = data.time_shift;
*playout_stat.date.lock().unwrap() = data.date;
}
+
+ Ok(())
}
/// Set fake time for debugging.
@@ -88,14 +84,14 @@ fn fake_time(args: &Args) {
/// Main function.
/// Here we check the command line arguments and start the player.
/// We also start a JSON RPC server if enabled.
-fn main() {
+fn main() -> Result<(), ProcError> {
let args = get_args();
// use fake time function only in debugging mode
#[cfg(debug_assertions)]
fake_time(&args);
- let mut config = get_config(args.clone());
+ let mut config = get_config(args.clone())?;
let play_control = PlayerControl::new();
let playout_stat = PlayoutStatus::new();
let proc_control = ProcessControl::new();
@@ -116,7 +112,7 @@ fn main() {
}
let logging = init_logging(&config, Some(proc_ctl1), Some(messages.clone()));
- CombinedLogger::init(logging).unwrap();
+ CombinedLogger::init(logging)?;
if let Err(e) = validate_ffmpeg(&mut config) {
error!("{e}");
@@ -179,16 +175,9 @@ fn main() {
let f = File::options()
.read(true)
.write(false)
- .open(&playlist_path)
- .expect("Could not open json playlist file.");
+ .open(&playlist_path)?;
- let playlist: JsonPlaylist = match serde_json::from_reader(f) {
- Ok(p) => p,
- Err(e) => {
- error!("{e:?}");
- exit(1)
- }
- };
+ let playlist: JsonPlaylist = serde_json::from_reader(f)?;
validate_playlist(playlist, Arc::new(AtomicBool::new(false)), config);
@@ -205,7 +194,7 @@ fn main() {
thread::spawn(move || run_server(config_clone1, play_ctl1, play_stat, proc_ctl2));
}
- status_file(&config.general.stat_file, &playout_stat);
+ status_file(&config.general.stat_file, &playout_stat)?;
debug!(
"Use config: {}>",
@@ -233,4 +222,6 @@ fn main() {
}
drop(msg);
+
+ Ok(())
}
diff --git a/ffplayout-engine/src/utils/mod.rs b/ffplayout-engine/src/utils/mod.rs
index beff97ed..5c556319 100644
--- a/ffplayout-engine/src/utils/mod.rs
+++ b/ffplayout-engine/src/utils/mod.rs
@@ -1,7 +1,6 @@
use std::{
fs::File,
path::{Path, PathBuf},
- process::exit,
};
use regex::Regex;
@@ -15,23 +14,22 @@ pub use arg_parse::Args;
use ffplayout_lib::{
filter::Filters,
utils::{
- config::Template, get_sec, parse_log_level_filter, sec_to_time, time_to_sec, Media,
- OutputMode::*, PlayoutConfig, PlayoutStatus, ProcessMode::*,
+ config::Template, errors::ProcError, get_sec, parse_log_level_filter, sec_to_time,
+ time_to_sec, Media, OutputMode::*, PlayoutConfig, PlayoutStatus, ProcessMode::*,
},
vec_strings,
};
/// Read command line arguments, and override the config with them.
-pub fn get_config(args: Args) -> PlayoutConfig {
+pub fn get_config(args: Args) -> Result {
let cfg_path = match args.channel {
Some(c) => {
let path = PathBuf::from(format!("/etc/ffplayout/{c}.yml"));
if !path.is_file() {
- println!(
+ return Err(ProcError::Custom(format!(
"Config file \"{c}\" under \"/etc/ffplayout/\" not found.\n\nCheck arguments!"
- );
- exit(1)
+ )));
}
Some(path)
@@ -53,17 +51,9 @@ pub fn get_config(args: Args) -> PlayoutConfig {
let f = File::options()
.read(true)
.write(false)
- .open(template_file)
- .expect("JSON template file");
+ .open(template_file)?;
- let mut template: Template = match serde_json::from_reader(f) {
- Ok(p) => p,
- Err(e) => {
- error!("Template file not readable! {e}");
-
- exit(1)
- }
- };
+ let mut template: Template = serde_json::from_reader(f)?;
template.sources.sort_by(|d1, d2| d1.start.cmp(&d2.start));
@@ -135,7 +125,7 @@ pub fn get_config(args: Args) -> PlayoutConfig {
config.processing.volume = volume;
}
- config
+ Ok(config)
}
/// Format ingest and HLS logging output
diff --git a/lib/Cargo.toml b/lib/Cargo.toml
index 2b76ca5a..79f55660 100644
--- a/lib/Cargo.toml
+++ b/lib/Cargo.toml
@@ -11,6 +11,7 @@ edition.workspace = true
[dependencies]
chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
crossbeam-channel = "0.5"
+derive_more = "0.99"
ffprobe = "0.3"
file-rotate = "0.7"
lettre = { version = "0.11", features = ["builder", "rustls-tls", "smtp-transport"], default-features = false }
diff --git a/lib/src/utils/config.rs b/lib/src/utils/config.rs
index 0b2bbbc5..2beb101c 100644
--- a/lib/src/utils/config.rs
+++ b/lib/src/utils/config.rs
@@ -222,6 +222,8 @@ pub struct Logging {
pub level: LevelFilter,
pub ffmpeg_level: String,
pub ingest_level: Option,
+ #[serde(default)]
+ pub detect_silence: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
diff --git a/lib/src/utils/errors.rs b/lib/src/utils/errors.rs
new file mode 100644
index 00000000..21723574
--- /dev/null
+++ b/lib/src/utils/errors.rs
@@ -0,0 +1,47 @@
+use std::io;
+
+use derive_more::Display;
+
+#[derive(Debug, Display)]
+pub enum ProcError {
+ #[display(fmt = "Failed to spawn ffmpeg/ffprobe. {}", _0)]
+ CommandSpawn(io::Error),
+ #[display(fmt = "Failed to read data from ffmpeg/ffprobe. {}", _0)]
+ IO(io::Error),
+ #[display(fmt = "{}", _0)]
+ Custom(String),
+ #[display(fmt = "Regex compile error {}", _0)]
+ Regex(String),
+ #[display(fmt = "Thread error {}", _0)]
+ Thread(String),
+}
+
+impl From for ProcError {
+ fn from(err: std::io::Error) -> Self {
+ Self::CommandSpawn(err)
+ }
+}
+
+impl From for ProcError {
+ fn from(err: regex::Error) -> Self {
+ Self::Regex(err.to_string())
+ }
+}
+
+impl From for ProcError {
+ fn from(err: log::SetLoggerError) -> Self {
+ Self::Custom(err.to_string())
+ }
+}
+
+impl From for ProcError {
+ fn from(err: serde_json::Error) -> Self {
+ Self::Custom(err.to_string())
+ }
+}
+
+impl From> for ProcError {
+ fn from(err: Box) -> Self {
+ Self::Thread(format!("{err:?}"))
+ }
+}
diff --git a/lib/src/utils/json_validate.rs b/lib/src/utils/json_validate.rs
index 803a24bd..a2889066 100644
--- a/lib/src/utils/json_validate.rs
+++ b/lib/src/utils/json_validate.rs
@@ -1,45 +1,60 @@
use std::{
- io::{BufRead, BufReader, Error, ErrorKind},
+ io::{BufRead, BufReader},
process::{Command, Stdio},
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
+ time::Instant,
};
+use regex::Regex;
use simplelog::*;
+use crate::filter::FilterType::Audio;
use crate::utils::{
- loop_image, sec_to_time, seek_and_length, valid_source, vec_strings, JsonPlaylist, Media,
- OutputMode::Null, PlayoutConfig, FFMPEG_IGNORE_ERRORS, IMAGE_FORMAT,
+ errors::ProcError, loop_image, sec_to_time, seek_and_length, valid_source, vec_strings,
+ JsonPlaylist, Media, OutputMode::Null, PlayoutConfig, FFMPEG_IGNORE_ERRORS, IMAGE_FORMAT,
};
-/// check if ffmpeg can read the file and apply filter to it.
+/// Validate a single media file.
+///
+/// - Check if file exists
+/// - Check if ffmpeg can read the file
+/// - Check if Metadata exists
+/// - Check if the file is not silent
fn check_media(
mut node: Media,
pos: usize,
begin: f64,
config: &PlayoutConfig,
-) -> Result<(), Error> {
- let mut enc_cmd = vec_strings!["-hide_banner", "-nostats", "-v", "level+error"];
+) -> Result<(), ProcError> {
+ let mut enc_cmd = vec_strings!["-hide_banner", "-nostats", "-v", "level+info"];
let mut error_list = vec![];
let mut config = config.clone();
config.out.mode = Null;
+ let mut process_length = 0.1;
+
+ if config.logging.detect_silence {
+ process_length = 15.0;
+ let seek = node.duration / 4.0;
+
+ // Seek in file, to prevent false silence detection on intros without sound.
+ enc_cmd.append(&mut vec_strings!["-ss", seek]);
+ }
+
node.add_probe();
if node.probe.clone().and_then(|p| p.format).is_none() {
- return Err(Error::new(
- ErrorKind::Other,
- format!(
- "No Metadata at position {pos}> {}, from file \"{}\">",
- sec_to_time(begin),
- node.source
- ),
- ));
+ return Err(ProcError::Custom(format!(
+ "No Metadata at position {pos}> {}, from file \"{}\">",
+ sec_to_time(begin),
+ node.source
+ )));
}
- // take care, that no seek and length command is added.
+ // Take care, that no seek and length command is added.
node.seek = 0.0;
node.out = node.duration;
@@ -60,24 +75,30 @@ fn check_media(
let mut filter = node.filter.unwrap_or_default();
if filter.cmd().len() > 1 {
- filter.cmd()[1] = filter.cmd()[1].replace("realtime=speed=1", "null")
+ let re_clean = Regex::new(r"volume=[0-9.]+")?;
+
+ filter.audio_chain = re_clean
+ .replace_all(&filter.audio_chain, "anull")
+ .to_string();
}
+ filter.add_filter("silencedetect=n=-30dB", 0, Audio);
+
enc_cmd.append(&mut node.cmd.unwrap_or_default());
enc_cmd.append(&mut filter.cmd());
enc_cmd.append(&mut filter.map());
- enc_cmd.append(&mut vec_strings!["-t", "0.1", "-f", "null", "-"]);
+ enc_cmd.append(&mut vec_strings!["-t", process_length, "-f", "null", "-"]);
- let mut enc_proc = match Command::new("ffmpeg")
- .args(enc_cmd.clone())
+ let mut enc_proc = Command::new("ffmpeg")
+ .args(enc_cmd)
.stderr(Stdio::piped())
- .spawn()
- {
- Err(e) => return Err(e),
- Ok(proc) => proc,
- };
+ .spawn()?;
let enc_err = BufReader::new(enc_proc.stderr.take().unwrap());
+ let mut silence_start = 0.0;
+ let mut silence_end = 0.0;
+ let re_start = Regex::new(r"silence_start: ([0-9]+:)?([0-9.]+)")?;
+ let re_end = Regex::new(r"silence_end: ([0-9]+:)?([0-9.]+)")?;
for line in enc_err.lines() {
let line = line?;
@@ -91,11 +112,25 @@ fn check_media(
error_list.push(log_line);
}
}
+
+ if config.logging.detect_silence {
+ if let Some(start) = re_start.captures(&line).and_then(|c| c.get(2)) {
+ silence_start = start.as_str().parse::().unwrap_or_default();
+ }
+
+ if let Some(end) = re_end.captures(&line).and_then(|c| c.get(2)) {
+ silence_end = end.as_str().parse::().unwrap_or_default() + 0.5;
+ }
+ }
+ }
+
+ if silence_end - silence_start > process_length {
+ error_list.push("Audio is totally silent!".to_string());
}
if !error_list.is_empty() {
error!(
- "[Validator]> ffmpeg error on position {pos}> - {}: {}>:\n{}",
+ "[Validator]> ffmpeg error on position {pos}> - {}: {}>: {}",
sec_to_time(begin),
node.source,
error_list.join("\n")
@@ -136,6 +171,7 @@ pub fn validate_playlist(
length += begin;
debug!("Validate playlist from: {date}>");
+ let timer = Instant::now();
for (index, item) in playlist.program.iter().enumerate() {
if is_terminated.load(Ordering::SeqCst) {
@@ -172,5 +208,5 @@ pub fn validate_playlist(
);
}
- debug!("Validation done...");
+ debug!("Validation done, in {:.3?} ...", timer.elapsed(),);
}
diff --git a/lib/src/utils/mod.rs b/lib/src/utils/mod.rs
index 17f7c8c0..9c933ad2 100644
--- a/lib/src/utils/mod.rs
+++ b/lib/src/utils/mod.rs
@@ -23,6 +23,7 @@ use simplelog::*;
pub mod config;
pub mod controller;
+pub mod errors;
pub mod folder;
pub mod generator;
pub mod import;