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;