Merge pull request #389 from jb-alvarado/master

This commit is contained in:
jb-alvarado 2023-09-12 17:54:58 +00:00 committed by GitHub
commit 887dabff8c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 1345 additions and 732 deletions

1
.gitignore vendored
View File

@ -26,3 +26,4 @@ ffpapi.1.gz
/public/ /public/
tmp/ tmp/
.vscode/ .vscode/
assets/playlist_template.json

826
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
[workspace] [workspace]
members = ["ffplayout-api", "ffplayout-engine", "lib", "tests"] members = ["ffplayout-api", "ffplayout-engine", "lib", "tests"]
default-members = ["ffplayout-api", "ffplayout-engine", "tests"] default-members = ["ffplayout-api", "ffplayout-engine", "tests"]
resolver = "2"
[workspace.package] [workspace.package]
version = "0.20.0" version = "0.20.0"

View File

@ -1,4 +1,4 @@
### This project is taking a summer break, during this time the Issues and Discussions are closed. In October both will be open again. Until then have a good time! ### This project is taking a summer break, during this time the Issues and Discussions are closed. In October both will be open again. Until then have a good time!
**ffplayout** **ffplayout**
================ ================
@ -54,6 +54,7 @@ Check the [releases](https://github.com/ffplayout/ffplayout/releases/latest) for
- import playlist from text or m3u file, with CLI or frontend - import playlist from text or m3u file, with CLI or frontend
- audio only, for radio mode (experimental *) - audio only, for radio mode (experimental *)
- [Piggyback Mode](/ffplayout-api/README.md#piggyback-mode), mostly for non Linux systems (experimental *) - [Piggyback Mode](/ffplayout-api/README.md#piggyback-mode), mostly for non Linux systems (experimental *)
- generate playlist based on [template](/docs/playlist_gen.md) (experimental *)
For preview stream, read: [/docs/preview_stream.md](/docs/preview_stream.md) For preview stream, read: [/docs/preview_stream.md](/docs/preview_stream.md)
@ -176,14 +177,12 @@ Output from `{"media":"current"}` show:
```JSON ```JSON
{ {
"jsonrpc": "2.0",
"result": {
"current_media": { "current_media": {
"category": "", "category": "",
"duration": 154.2, "duration": 154.2,
"out": 154.2, "out": 154.2,
"seek": 0.0, "seek": 0.0,
"source": "/opt/tv-media/clip.mp4" "source": "/opt/tv-media/clip.mp4"
}, },
"index": 39, "index": 39,
"play_mode": "playlist", "play_mode": "playlist",
@ -191,8 +190,6 @@ Output from `{"media":"current"}` show:
"remaining_sec": 86.39228000699876, "remaining_sec": 86.39228000699876,
"start_sec": 24713.631999999998, "start_sec": 24713.631999999998,
"start_time": "06:51:53.631" "start_time": "06:51:53.631"
},
"id": 1
} }
``` ```

View File

@ -21,6 +21,10 @@ Using live ingest to inject a live stream.
The different output modes. The different output modes.
### **[Playlist Generation](/docs/playlist_gen.md)**
Generate playlists based on template.
### **[Multi Audio Tracks](/docs/multi_audio.md)** ### **[Multi Audio Tracks](/docs/multi_audio.md)**
Output multiple audio tracks. Output multiple audio tracks.

71
docs/playlist_gen.md Normal file
View File

@ -0,0 +1,71 @@
## Playlist generation template
It is possible to generate playlists based on templates. A template could look like:
```JSON
{
"sources": [
{
"start": "00:00:00",
"duration": "02:00:00",
"shuffle": true,
"paths": [
"/path/to/folder/1"
]
},
{
"start": "02:00:00",
"duration": "04:00:00",
"shuffle": false,
"paths": [
"/path/to/folder/2",
"/path/to/folder/3",
"/path/to/folder/4"
]
},
{
"start": "06:00:00",
"duration": "10:00:00",
"shuffle": true,
"paths": [
"/path/to/folder/5"
]
},
{
"start": "16:00:00",
"duration": "06:00:00",
"shuffle": false,
"paths": [
"/path/to/folder/6",
"/path/to/folder/7"
]
},
{
"start": "22:00:00",
"duration": "02:00:00",
"shuffle": true,
"paths": [
"/path/to/folder/8"
]
}
]
}
```
This can be used as file and run through CLI:
```BASH
ffplayout -g 2023-09-04 - 2023-09-10 --template 'path/to/playlist_template.json'
```
Or through API:
```BASH
curl -X POST http://127.0.0.1:8787/api/playlist/1/generate/2023-00-05
-H 'Content-Type: application/json' -H 'Authorization: Bearer <TOKEN>'
--data '{ "paths": "template": {"sources": [\
{"start": "00:00:00", "duration": "10:00:00", "shuffle": true, "paths": ["path/1", "path/2"]}, \
{"start": "10:00:00", "duration": "14:00:00", "shuffle": false, "paths": ["path/3", "path/4"]}]}}'
```

View File

@ -11,11 +11,11 @@ edition.workspace = true
[dependencies] [dependencies]
ffplayout-lib = { path = "../lib" } ffplayout-lib = { path = "../lib" }
actix-files = "0.6" actix-files = "0.6"
actix-multipart = "0.5" actix-multipart = "0.6"
actix-web = "4" actix-web = "4"
actix-web-grants = "3" actix-web-grants = "3"
actix-web-httpauth = "0.6" actix-web-httpauth = "0.8"
argon2 = "0.4" argon2 = "0.5"
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
clap = { version = "4.3", features = ["derive"] } clap = { version = "4.3", features = ["derive"] }
derive_more = "0.99" derive_more = "0.99"
@ -23,19 +23,19 @@ faccess = "0.2"
futures-util = { version = "0.3", default-features = false, features = ["std"] } futures-util = { version = "0.3", default-features = false, features = ["std"] }
jsonwebtoken = "8" jsonwebtoken = "8"
lexical-sort = "0.3" lexical-sort = "0.3"
once_cell = "1.10" once_cell = "1.18"
rand = "0.8" rand = "0.8"
regex = "1" regex = "1"
relative-path = "1.6" relative-path = "1.8"
reqwest = { version = "0.11", features = ["blocking", "json"] } reqwest = { version = "0.11", features = ["blocking", "json"] }
rpassword = "6.0" rpassword = "7.2"
sanitize-filename = "0.3" sanitize-filename = "0.5"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
serde_yaml = "0.9" serde_yaml = "0.9"
simplelog = { version = "^0.12", features = ["paris"] } simplelog = { version = "0.12", features = ["paris"] }
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] } sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] }
tokio = { version = "1.25", features = ["full"] } tokio = { version = "1.29", features = ["full"] }
[[bin]] [[bin]]
name = "ffpapi" name = "ffpapi"

View File

@ -8,7 +8,7 @@
/// ///
/// For all endpoints an (Bearer) authentication is required.\ /// For all endpoints an (Bearer) authentication is required.\
/// `{id}` represent the channel id, and at default is 1. /// `{id}` represent the channel id, and at default is 1.
use std::{collections::HashMap, env, fs, path::Path}; use std::{collections::HashMap, env, fs, path::PathBuf};
use actix_multipart::Multipart; use actix_multipart::Multipart;
use actix_web::{delete, get, http::StatusCode, patch, post, put, web, HttpResponse, Responder}; use actix_web::{delete, get, http::StatusCode, patch, post, put, web, HttpResponse, Responder};
@ -46,6 +46,7 @@ use crate::{
use ffplayout_lib::{ use ffplayout_lib::{
utils::{ utils::{
get_date_range, import::import_file, sec_to_time, time_to_sec, JsonPlaylist, PlayoutConfig, get_date_range, import::import_file, sec_to_time, time_to_sec, JsonPlaylist, PlayoutConfig,
Template,
}, },
vec_strings, vec_strings,
}; };
@ -72,19 +73,20 @@ pub struct DateObj {
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
struct FileObj { struct FileObj {
#[serde(default)] #[serde(default)]
path: String, path: PathBuf,
} }
#[derive(Debug, Default, Deserialize, Serialize)] #[derive(Debug, Default, Deserialize, Serialize)]
pub struct PathsObj { pub struct PathsObj {
#[serde(default)] #[serde(default)]
paths: Vec<String>, paths: Vec<String>,
template: Option<Template>,
} }
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
pub struct ImportObj { pub struct ImportObj {
#[serde(default)] #[serde(default)]
file: String, file: PathBuf,
#[serde(default)] #[serde(default)]
date: String, date: String,
} }
@ -724,7 +726,7 @@ pub async fn get_playlist(
/// ```BASH /// ```BASH
/// curl -X POST http://127.0.0.1:8787/api/playlist/1/ /// curl -X POST http://127.0.0.1:8787/api/playlist/1/
/// -H 'Content-Type: application/json' -H 'Authorization: Bearer <TOKEN>' /// -H 'Content-Type: application/json' -H 'Authorization: Bearer <TOKEN>'
/// -- data "{<JSON playlist data>}" /// --data "{<JSON playlist data>}"
/// ``` /// ```
#[post("/playlist/{id}/")] #[post("/playlist/{id}/")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
@ -746,7 +748,7 @@ pub async fn save_playlist(
/// ```BASH /// ```BASH
/// curl -X POST http://127.0.0.1:8787/api/playlist/1/generate/2022-06-20 /// curl -X POST http://127.0.0.1:8787/api/playlist/1/generate/2022-06-20
/// -H 'Content-Type: application/json' -H 'Authorization: Bearer <TOKEN>' /// -H 'Content-Type: application/json' -H 'Authorization: Bearer <TOKEN>'
/// /// -- data '{ "paths": [<list of paths>] }' # <- data is optional /// /// --data '{ "paths": [<list of paths>] }' # <- data is optional
/// ``` /// ```
#[post("/playlist/{id}/generate/{date}")] #[post("/playlist/{id}/generate/{date}")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
@ -764,10 +766,11 @@ pub async fn gen_playlist(
for path in &obj.paths { for path in &obj.paths {
let (p, _, _) = norm_abs_path(&config.storage.path, path); let (p, _, _) = norm_abs_path(&config.storage.path, path);
path_list.push(p.to_string_lossy().to_string()); path_list.push(p);
} }
config.storage.paths = path_list; config.storage.paths = path_list;
config.general.template = obj.template.clone();
} }
match generate_playlist(config, channel.name).await { match generate_playlist(config, channel.name).await {
@ -921,8 +924,8 @@ async fn import_playlist(
payload: Multipart, payload: Multipart,
obj: web::Query<ImportObj>, obj: web::Query<ImportObj>,
) -> Result<HttpResponse, ServiceError> { ) -> Result<HttpResponse, ServiceError> {
let file = Path::new(&obj.file).file_name().unwrap_or_default(); let file = obj.file.file_name().unwrap_or_default();
let path = env::temp_dir().join(file).to_string_lossy().to_string(); let path = env::temp_dir().join(file);
let (config, _) = playout_config(&pool.clone().into_inner(), &id).await?; let (config, _) = playout_config(&pool.clone().into_inner(), &id).await?;
let channel = handles::select_channel(&pool.clone().into_inner(), &id).await?; let channel = handles::select_channel(&pool.clone().into_inner(), &id).await?;

View File

@ -82,8 +82,8 @@ async fn create_schema(conn: &Pool<Sqlite>) -> Result<SqliteQueryResult, sqlx::E
pub async fn db_init(domain: Option<String>) -> Result<&'static str, Box<dyn std::error::Error>> { pub async fn db_init(domain: Option<String>) -> Result<&'static str, Box<dyn std::error::Error>> {
let db_path = db_path()?; let db_path = db_path()?;
if !Sqlite::database_exists(&db_path).await.unwrap_or(false) { if !Sqlite::database_exists(db_path).await.unwrap_or(false) {
Sqlite::create_database(&db_path).await.unwrap(); Sqlite::create_database(db_path).await.unwrap();
let pool = db_pool().await?; let pool = db_pool().await?;

View File

@ -7,7 +7,7 @@ use crate::utils::db_path;
pub async fn db_pool() -> Result<Pool<Sqlite>, sqlx::Error> { pub async fn db_pool() -> Result<Pool<Sqlite>, sqlx::Error> {
let db_path = db_path().unwrap(); let db_path = db_path().unwrap();
let conn = SqlitePool::connect(&db_path).await?; let conn = SqlitePool::connect(db_path).await?;
Ok(conn) Ok(conn)
} }

View File

@ -3,8 +3,7 @@ use std::{path::Path, process::exit};
use actix_files::Files; use actix_files::Files;
use actix_web::{dev::ServiceRequest, middleware, web, App, Error, HttpMessage, HttpServer}; use actix_web::{dev::ServiceRequest, middleware, web, App, Error, HttpMessage, HttpServer};
use actix_web_grants::permissions::AttachPermissions; use actix_web_grants::permissions::AttachPermissions;
use actix_web_httpauth::extractors::bearer::BearerAuth; use actix_web_httpauth::{extractors::bearer::BearerAuth, middleware::HttpAuthentication};
use actix_web_httpauth::middleware::HttpAuthentication;
use clap::Parser; use clap::Parser;
use simplelog::*; use simplelog::*;
@ -29,15 +28,22 @@ use utils::{args_parse::Args, control::ProcessControl, db_path, init_config, run
use ffplayout_lib::utils::{init_logging, PlayoutConfig}; use ffplayout_lib::utils::{init_logging, PlayoutConfig};
async fn validator(req: ServiceRequest, credentials: BearerAuth) -> Result<ServiceRequest, Error> { async fn validator(
req: ServiceRequest,
credentials: BearerAuth,
) -> Result<ServiceRequest, (Error, ServiceRequest)> {
// We just get permissions from JWT // We just get permissions from JWT
let claims = auth::decode_jwt(credentials.token()).await?; match auth::decode_jwt(credentials.token()).await {
req.attach(vec![Role::set_role(&claims.role)]); Ok(claims) => {
req.attach(vec![Role::set_role(&claims.role)]);
req.extensions_mut() req.extensions_mut()
.insert(LoginUser::new(claims.id, claims.username)); .insert(LoginUser::new(claims.id, claims.username));
Ok(req) Ok(req)
}
Err(e) => Err((e, req)),
}
} }
fn public_path() -> &'static str { fn public_path() -> &'static str {
@ -49,7 +55,7 @@ fn public_path() -> &'static str {
return "./public/"; return "./public/";
} }
"./ffplayout-frontend/dist" "./ffplayout-frontend/.output/public/"
} }
#[actix_web::main] #[actix_web::main]
@ -77,11 +83,9 @@ async fn main() -> std::io::Result<()> {
}; };
if let Some(conn) = args.listen { if let Some(conn) = args.listen {
if let Ok(p) = db_path() { if db_path().is_err() {
if !Path::new(&p).is_file() { error!("Database is not initialized! Init DB first and add admin user.");
error!("Database is not initialized! Init DB first and add admin user."); exit(1);
exit(1);
}
} }
init_config(&pool).await; init_config(&pool).await;
let ip_port = conn.split(':').collect::<Vec<&str>>(); let ip_port = conn.split(':').collect::<Vec<&str>>();

View File

@ -1,4 +1,4 @@
use std::{fs, path::Path}; use std::{fs, path::PathBuf};
use rand::prelude::*; use rand::prelude::*;
use simplelog::*; use simplelog::*;
@ -31,22 +31,14 @@ pub async fn create_channel(
Err(_) => rand::thread_rng().gen_range(71..99), Err(_) => rand::thread_rng().gen_range(71..99),
}; };
let mut config = let mut config = PlayoutConfig::new(Some(PathBuf::from(
PlayoutConfig::new(Some("/usr/share/ffplayout/ffplayout.yml.orig".to_string())); "/usr/share/ffplayout/ffplayout.yml.orig",
)));
config.general.stat_file = format!(".ffp_{channel_name}",); config.general.stat_file = format!(".ffp_{channel_name}",);
config.logging.path = config.logging.path.join(&channel_name);
config.logging.path = Path::new(&config.logging.path)
.join(&channel_name)
.to_string_lossy()
.to_string();
config.rpc_server.address = format!("127.0.0.1:70{:7>2}", channel_num); config.rpc_server.address = format!("127.0.0.1:70{:7>2}", channel_num);
config.playlist.path = config.playlist.path.join(channel_name);
config.playlist.path = Path::new(&config.playlist.path)
.join(channel_name)
.to_string_lossy()
.to_string();
config.out.output_param = config config.out.output_param = config
.out .out

View File

@ -1,4 +1,8 @@
use std::{fs, io::Write, path::PathBuf}; use std::{
fs,
io::Write,
path::{Path, PathBuf},
};
use actix_multipart::Multipart; use actix_multipart::Multipart;
use actix_web::{web, HttpResponse}; use actix_web::{web, HttpResponse};
@ -51,10 +55,9 @@ pub struct VideoFile {
/// Normalize absolut path /// Normalize absolut path
/// ///
/// This function takes care, that it is not possible to break out from root_path. /// This function takes care, that it is not possible to break out from root_path.
/// It also gives alway a relative path back. /// It also gives always a relative path back.
pub fn norm_abs_path(root_path: &str, input_path: &str) -> (PathBuf, String, String) { pub fn norm_abs_path(root_path: &Path, input_path: &str) -> (PathBuf, String, String) {
let mut path = PathBuf::from(root_path); let path_relative = RelativePath::new(&root_path.to_string_lossy())
let path_relative = RelativePath::new(root_path)
.normalize() .normalize()
.to_string() .to_string()
.replace("../", ""); .replace("../", "");
@ -62,13 +65,15 @@ pub fn norm_abs_path(root_path: &str, input_path: &str) -> (PathBuf, String, Str
.normalize() .normalize()
.to_string() .to_string()
.replace("../", ""); .replace("../", "");
let path_suffix = path let path_suffix = root_path
.file_name() .file_name()
.unwrap_or_default() .unwrap_or_default()
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
if input_path.starts_with(root_path) || source_relative.starts_with(&path_relative) { if input_path.starts_with(&*root_path.to_string_lossy())
|| source_relative.starts_with(&path_relative)
{
source_relative = source_relative source_relative = source_relative
.strip_prefix(&path_relative) .strip_prefix(&path_relative)
.and_then(|s| s.strip_prefix('/')) .and_then(|s| s.strip_prefix('/'))
@ -82,9 +87,9 @@ pub fn norm_abs_path(root_path: &str, input_path: &str) -> (PathBuf, String, Str
.to_string(); .to_string();
} }
path = path.join(&source_relative); let path = &root_path.join(&source_relative);
(path, path_suffix, source_relative) (path.to_path_buf(), path_suffix, source_relative)
} }
/// File Browser /// File Browser
@ -300,7 +305,7 @@ pub async fn upload(
conn: &Pool<Sqlite>, conn: &Pool<Sqlite>,
id: i32, id: i32,
mut payload: Multipart, mut payload: Multipart,
path: &str, path: &Path,
abs_path: bool, abs_path: bool,
) -> Result<HttpResponse, ServiceError> { ) -> Result<HttpResponse, ServiceError> {
while let Some(mut field) = payload.try_next().await? { while let Some(mut field) = payload.try_next().await? {
@ -318,9 +323,9 @@ pub async fn upload(
let filepath; let filepath;
if abs_path { if abs_path {
filepath = PathBuf::from(path); filepath = path.to_path_buf();
} else { } else {
let target_path = valid_path(conn, id, path).await?; let target_path = valid_path(conn, id, &path.to_string_lossy()).await?;
filepath = target_path.join(filename); filepath = target_path.join(filename);
} }

View File

@ -74,21 +74,25 @@ pub async fn init_config(conn: &Pool<Sqlite>) {
INSTANCE.set(config).unwrap(); INSTANCE.set(config).unwrap();
} }
pub fn db_path() -> Result<String, Box<dyn std::error::Error>> { pub fn db_path() -> Result<&'static str, Box<dyn std::error::Error>> {
let sys_path = Path::new("/usr/share/ffplayout/db"); let sys_path = Path::new("/usr/share/ffplayout/db");
let mut db_path = "./ffplayout.db".to_string(); let mut db_path = "./ffplayout.db";
if sys_path.is_dir() && !sys_path.writable() { if sys_path.is_dir() && !sys_path.writable() {
error!("Path {} is not writable!", sys_path.display()); error!("Path {} is not writable!", sys_path.display());
} }
if sys_path.is_dir() && sys_path.writable() { if sys_path.is_dir() && sys_path.writable() {
db_path = "/usr/share/ffplayout/db/ffplayout.db".to_string(); db_path = "/usr/share/ffplayout/db/ffplayout.db";
} else if Path::new("./assets").is_dir() { } else if Path::new("./assets").is_dir() {
db_path = "./assets/ffplayout.db".to_string(); db_path = "./assets/ffplayout.db";
} }
Ok(db_path) if Path::new(db_path).is_file() {
return Ok(db_path);
}
Err(format!("DB path {db_path} not exists!").into())
} }
pub async fn run_args(mut args: Args) -> Result<(), i32> { pub async fn run_args(mut args: Args) -> Result<(), i32> {
@ -190,6 +194,7 @@ pub fn read_playout_config(path: &str) -> Result<PlayoutConfig, Box<dyn Error>>
let mut config: PlayoutConfig = serde_yaml::from_reader(file)?; let mut config: PlayoutConfig = serde_yaml::from_reader(file)?;
config.playlist.start_sec = Some(time_to_sec(&config.playlist.day_start)); config.playlist.start_sec = Some(time_to_sec(&config.playlist.day_start));
config.playlist.length_sec = Some(time_to_sec(&config.playlist.length));
Ok(config) Ok(config)
} }

View File

@ -3,7 +3,7 @@ use std::{fs, path::PathBuf};
use simplelog::*; use simplelog::*;
use sqlx::{Pool, Sqlite}; use sqlx::{Pool, Sqlite};
use crate::utils::{errors::ServiceError, playout_config}; use crate::utils::{errors::ServiceError, files::norm_abs_path, playout_config};
use ffplayout_lib::utils::{ use ffplayout_lib::utils::{
generate_playlist as playlist_generator, json_reader, json_writer, JsonPlaylist, PlayoutConfig, generate_playlist as playlist_generator, json_reader, json_writer, JsonPlaylist, PlayoutConfig,
}; };
@ -78,16 +78,32 @@ pub async fn write_playlist(
} }
pub async fn generate_playlist( pub async fn generate_playlist(
config: PlayoutConfig, mut config: PlayoutConfig,
channel: String, channel: String,
) -> Result<JsonPlaylist, ServiceError> { ) -> Result<JsonPlaylist, ServiceError> {
if let Some(mut template) = config.general.template.take() {
for source in template.sources.iter_mut() {
let mut paths = vec![];
for path in &source.paths {
let (safe_path, _, _) =
norm_abs_path(&config.storage.path, &path.to_string_lossy());
paths.push(safe_path);
}
source.paths = paths;
}
config.general.template = Some(template);
}
match playlist_generator(&config, Some(channel)) { match playlist_generator(&config, Some(channel)) {
Ok(playlists) => { Ok(playlists) => {
if !playlists.is_empty() { if !playlists.is_empty() {
Ok(playlists[0].clone()) Ok(playlists[0].clone())
} else { } else {
Err(ServiceError::Conflict( Err(ServiceError::Conflict(
"Playlist could not be written, possible already exists!".into(), "The playlist could not be written, maybe it already exists!".into(),
)) ))
} }
} }

View File

@ -16,13 +16,15 @@ chrono = { version = "0.4", default-features = false, features = ["clock", "std"
clap = { version = "4.3", features = ["derive"] } clap = { version = "4.3", features = ["derive"] }
crossbeam-channel = "0.5" crossbeam-channel = "0.5"
futures = "0.3" futures = "0.3"
itertools = "0.11"
notify = "6.0" notify = "6.0"
notify-debouncer-full = { version = "*", default-features = false } notify-debouncer-full = { version = "*", default-features = false }
rand = "0.8"
regex = "1" regex = "1"
reqwest = { version = "0.11", features = ["blocking", "json"] } reqwest = { version = "0.11", features = ["blocking", "json"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
simplelog = { version = "^0.12", features = ["paris"] } simplelog = { version = "0.12", features = ["paris"] }
tiny_http = { version = "0.12", default-features = false } tiny_http = { version = "0.12", default-features = false }
zeromq = { git = "https://github.com/zeromq/zmq.rs.git", default-features = false, features = [ zeromq = { git = "https://github.com/zeromq/zmq.rs.git", default-features = false, features = [
"async-std-runtime", "async-std-runtime",

View File

@ -28,8 +28,8 @@ pub fn source_generator(
Folder => { Folder => {
info!("Playout in folder mode"); info!("Playout in folder mode");
debug!( debug!(
"Monitor folder: <b><magenta>{}</></b>", "Monitor folder: <b><magenta>{:?}</></b>",
&config.storage.path config.storage.path
); );
let config_clone = config.clone(); let config_clone = config.clone();

View File

@ -40,7 +40,7 @@ impl CurrentProgram {
is_terminated: Arc<AtomicBool>, is_terminated: Arc<AtomicBool>,
player_control: &PlayerControl, player_control: &PlayerControl,
) -> Self { ) -> Self {
let json = read_json(config, None, is_terminated.clone(), true, 0.0); let json = read_json(config, None, is_terminated.clone(), true, false);
if let Some(file) = &json.current_file { if let Some(file) = &json.current_file {
info!("Read Playlist: <b><magenta>{}</></b>", file); info!("Read Playlist: <b><magenta>{}</></b>", file);
@ -78,7 +78,7 @@ impl CurrentProgram {
fn check_update(&mut self, seek: bool) { fn check_update(&mut self, seek: bool) {
if self.json_path.is_none() { if self.json_path.is_none() {
// If the playlist was missing, we check here to see if it came back. // If the playlist was missing, we check here to see if it came back.
let json = read_json(&self.config, None, self.is_terminated.clone(), seek, 0.0); let json = read_json(&self.config, None, self.is_terminated.clone(), seek, false);
if let Some(file) = &json.current_file { if let Some(file) = &json.current_file {
info!("Read Playlist: <b><magenta>{file}</></b>"); info!("Read Playlist: <b><magenta>{file}</></b>");
@ -105,7 +105,7 @@ impl CurrentProgram {
self.json_path.clone(), self.json_path.clone(),
self.is_terminated.clone(), self.is_terminated.clone(),
false, false,
0.0, false,
); );
self.json_mod = json.modified; self.json_mod = json.modified;
@ -133,17 +133,20 @@ impl CurrentProgram {
} }
// Check if day is past and it is time for a new playlist. // Check if day is past and it is time for a new playlist.
fn check_for_next_playlist(&mut self) { fn check_for_next_playlist(&mut self) -> bool {
let current_time = get_sec(); let current_time = get_sec();
let start_sec = self.config.playlist.start_sec.unwrap(); let start_sec = self.config.playlist.start_sec.unwrap();
let target_length = self.config.playlist.length_sec.unwrap(); let target_length = self.config.playlist.length_sec.unwrap();
let (delta, total_delta) = get_delta(&self.config, &current_time); let (delta, total_delta) = get_delta(&self.config, &current_time);
let mut duration = self.current_node.out; let mut duration = self.current_node.out;
let mut next = false;
if self.current_node.duration > self.current_node.out { if self.current_node.duration > self.current_node.out {
duration = self.current_node.duration duration = self.current_node.duration
} }
trace!("delta: {delta}, total_delta: {total_delta}");
let mut next_start = let mut next_start =
self.current_node.begin.unwrap_or_default() - start_sec + duration + delta; self.current_node.begin.unwrap_or_default() - start_sec + duration + delta;
@ -153,21 +156,20 @@ impl CurrentProgram {
next_start += self.config.general.stop_threshold; next_start += self.config.general.stop_threshold;
} }
trace!("next_start: {next_start}, target_length: {target_length}");
// Check if we over the target length or we are close to it, if so we load the next playlist. // Check if we over the target length or we are close to it, if so we load the next playlist.
if next_start >= target_length if next_start >= target_length
|| is_close(total_delta, 0.0, 2.0) || is_close(total_delta, 0.0, 2.0)
|| is_close(total_delta, target_length, 2.0) || is_close(total_delta, target_length, 2.0)
{ {
let json = read_json( trace!("get next day");
&self.config, next = true;
None,
self.is_terminated.clone(), let json = read_json(&self.config, None, self.is_terminated.clone(), false, true);
false,
next_start,
);
if let Some(file) = &json.current_file { if let Some(file) = &json.current_file {
info!("Read Playlist: <b><magenta>{}</></b>", file); info!("Read next Playlist: <b><magenta>{}</></b>", file);
} }
let data = json!({ let data = json!({
@ -192,8 +194,12 @@ impl CurrentProgram {
if json.current_file.is_none() { if json.current_file.is_none() {
self.playout_stat.list_init.store(true, Ordering::SeqCst); self.playout_stat.list_init.store(true, Ordering::SeqCst);
} else {
self.playout_stat.list_init.store(false, Ordering::SeqCst);
} }
} }
next
} }
// Check if last and/or next clip is a advertisement. // Check if last and/or next clip is a advertisement.
@ -257,6 +263,7 @@ impl CurrentProgram {
// Prepare init clip. // Prepare init clip.
fn init_clip(&mut self) { fn init_clip(&mut self) {
trace!("init_clip");
self.get_current_clip(); self.get_current_clip();
if !self.playout_stat.list_init.load(Ordering::SeqCst) { if !self.playout_stat.list_init.load(Ordering::SeqCst) {
@ -307,47 +314,48 @@ impl Iterator for CurrentProgram {
let new_length = new_node.begin.unwrap_or_default() + new_node.duration; let new_length = new_node.begin.unwrap_or_default() + new_node.duration;
trace!("Init playlist after playlist end"); trace!("Init playlist after playlist end");
self.check_for_next_playlist(); let next_playlist = self.check_for_next_playlist();
if new_length if new_length
>= self.config.playlist.length_sec.unwrap() >= self.config.playlist.length_sec.unwrap()
+ self.config.playlist.start_sec.unwrap() + self.config.playlist.start_sec.unwrap()
{ {
self.init_clip(); self.init_clip();
} else if next_playlist
&& self.player_control.current_list.lock().unwrap().len() > 1
{
let index = self
.player_control
.current_index
.fetch_add(1, Ordering::SeqCst);
self.current_node = gen_source(
&self.config,
self.player_control.current_list.lock().unwrap()[index].clone(),
&self.playout_stat.chain,
&self.player_control,
0,
);
return Some(self.current_node.clone());
} else { } else {
// fill missing length from playlist // fill missing length from playlist
let mut current_time = get_sec(); let mut current_time = get_sec();
let (_, total_delta) = get_delta(&self.config, &current_time); let (_, total_delta) = get_delta(&self.config, &current_time);
let mut out = total_delta.abs();
let mut duration = out + 0.001;
trace!("Total delta on list init: {total_delta}"); trace!("Total delta on list init: {total_delta}");
let filler_index = self.player_control.filler_index.load(Ordering::SeqCst); let out = if DUMMY_LEN > total_delta {
let mut filler = total_delta
self.player_control.filler_list.lock().unwrap()[filler_index].clone();
filler.add_probe();
// If there is no filler, the duration of the dummy clip should not be too long.
// This would take away the ability to restart the playlist when the playout registers a change.
if filler.duration > 0.0 {
if duration > filler.duration {
out = filler.duration;
}
duration = filler.duration;
} else if DUMMY_LEN > total_delta {
duration = total_delta;
out = total_delta;
} else { } else {
duration = DUMMY_LEN; DUMMY_LEN
out = DUMMY_LEN; };
}
let duration = out + 0.001;
if self.json_path.is_some() { if self.json_path.is_some() {
// When playlist is missing, we always need to init the playlist the next iteration. // When playlist is missing, we always need to init the playlist the next iteration.
self.playout_stat.list_init.store(false, Ordering::SeqCst); self.playout_stat.list_init.store(true, Ordering::SeqCst);
} }
if self.config.playlist.start_sec.unwrap() > current_time { if self.config.playlist.start_sec.unwrap() > current_time {
@ -429,28 +437,14 @@ impl Iterator for CurrentProgram {
let index = self.player_control.current_index.load(Ordering::SeqCst); let index = self.player_control.current_index.load(Ordering::SeqCst);
self.current_node = Media::new(index, "", false); self.current_node = Media::new(index, "", false);
self.current_node.begin = Some(get_sec()); self.current_node.begin = Some(get_sec());
let mut out = total_delta.abs();
let mut duration = out + 0.001;
let filler_index = self.player_control.filler_index.load(Ordering::SeqCst); let out = if DUMMY_LEN > total_delta {
let mut filler = total_delta
self.player_control.filler_list.lock().unwrap()[filler_index].clone();
filler.add_probe();
if filler.duration > 0.0 {
if duration > filler.duration {
out = filler.duration;
}
duration = filler.duration;
} else if DUMMY_LEN > total_delta {
duration = total_delta;
out = total_delta;
} else { } else {
duration = DUMMY_LEN; DUMMY_LEN
out = DUMMY_LEN; };
}
let duration = out + 0.001;
self.current_node.duration = duration; self.current_node.duration = duration;
self.current_node.out = out; self.current_node.out = out;
@ -605,7 +599,7 @@ pub fn gen_source(
error!("Source not found: <b><magenta>\"{}\"</></b>", node.source); error!("Source not found: <b><magenta>\"{}\"</></b>", node.source);
} }
let filler_source = Path::new(&config.storage.filler); let filler_source = &config.storage.filler;
if filler_source.is_dir() && !player_control.filler_list.lock().unwrap().is_empty() { if filler_source.is_dir() && !player_control.filler_list.lock().unwrap().is_empty() {
let filler_index = player_control.filler_index.fetch_add(1, Ordering::SeqCst); let filler_index = player_control.filler_index.fetch_add(1, Ordering::SeqCst);
@ -629,17 +623,19 @@ pub fn gen_source(
node.cmd = Some(loop_filler(&node)); node.cmd = Some(loop_filler(&node));
node.probe = filler_media.probe; node.probe = filler_media.probe;
} else if filler_source.is_file() { } else if filler_source.is_file() {
let probe = MediaProbe::new(&config.storage.filler); let probe = MediaProbe::new(&config.storage.filler.to_string_lossy());
if config if config
.storage .storage
.filler .filler
.to_string_lossy()
.to_string()
.rsplit_once('.') .rsplit_once('.')
.map(|(_, e)| e.to_lowercase()) .map(|(_, e)| e.to_lowercase())
.filter(|c| IMAGE_FORMAT.contains(&c.as_str())) .filter(|c| IMAGE_FORMAT.contains(&c.as_str()))
.is_some() .is_some()
{ {
node.source = config.storage.filler.clone(); node.source = config.storage.filler.clone().to_string_lossy().to_string();
node.cmd = Some(loop_image(&node)); node.cmd = Some(loop_image(&node));
node.probe = Some(probe); node.probe = Some(probe);
} else if let Some(filler_duration) = probe } else if let Some(filler_duration) = probe
@ -649,7 +645,7 @@ pub fn gen_source(
.and_then(|d| d.parse::<f64>().ok()) .and_then(|d| d.parse::<f64>().ok())
{ {
// Create placeholder from config filler. // Create placeholder from config filler.
node.source = config.storage.filler.clone(); node.source = config.storage.filler.clone().to_string_lossy().to_string();
node.out = if node.duration > duration && filler_duration > duration { node.out = if node.duration > duration && filler_duration > duration {
duration duration

View File

@ -1,6 +1,6 @@
use std::{ use std::{
fs::{self, File}, fs::{self, File},
path::{Path, PathBuf}, path::PathBuf,
process::exit, process::exit,
sync::{atomic::AtomicBool, Arc, Mutex}, sync::{atomic::AtomicBool, Arc, Mutex},
thread, thread,
@ -107,7 +107,7 @@ fn main() {
let messages = Arc::new(Mutex::new(Vec::new())); let messages = Arc::new(Mutex::new(Vec::new()));
// try to create logging folder, if not exist // try to create logging folder, if not exist
if config.logging.log_to_file && !Path::new(&config.logging.path).is_dir() { if config.logging.log_to_file && config.logging.path.is_dir() {
if let Err(e) = fs::create_dir_all(&config.logging.path) { if let Err(e) = fs::create_dir_all(&config.logging.path) {
println!("Logging path not exists! {e}"); println!("Logging path not exists! {e}");
@ -163,11 +163,11 @@ fn main() {
} }
if args.validate { if args.validate {
let mut playlist_path = Path::new(&config.playlist.path).to_owned(); let mut playlist_path = config.playlist.path.clone();
let start_sec = config.playlist.start_sec.unwrap(); let start_sec = config.playlist.start_sec.unwrap();
let date = get_date(false, start_sec, 0.0); let date = get_date(false, start_sec, false);
if playlist_path.is_dir() || is_remote(&config.playlist.path) { if playlist_path.is_dir() || is_remote(&playlist_path.to_string_lossy()) {
let d: Vec<&str> = date.split('-').collect(); let d: Vec<&str> = date.split('-').collect();
playlist_path = playlist_path playlist_path = playlist_path
.join(d[0]) .join(d[0])
@ -213,7 +213,9 @@ fn main() {
); );
// Fill filler list, can also be a single file. // Fill filler list, can also be a single file.
thread::spawn(move || fill_filler_list(config_clone2, play_ctl2)); thread::spawn(move || {
fill_filler_list(&config_clone2, Some(play_ctl2));
});
match config.out.mode { match config.out.mode {
// write files/playlist to HLS m3u8 playlist // write files/playlist to HLS m3u8 playlist

View File

@ -1,6 +1,5 @@
use std::{ use std::{
io::{prelude::*, BufReader, BufWriter, Read}, io::{prelude::*, BufReader, BufWriter, Read},
path::Path,
process::{Command, Stdio}, process::{Command, Stdio},
sync::atomic::Ordering, sync::atomic::Ordering,
thread::{self, sleep}, thread::{self, sleep},
@ -101,11 +100,11 @@ pub fn player(
let task_node = node.clone(); let task_node = node.clone();
let server_running = proc_control.server_is_running.load(Ordering::SeqCst); let server_running = proc_control.server_is_running.load(Ordering::SeqCst);
if Path::new(&config.task.path).is_file() { if config.task.path.is_file() {
thread::spawn(move || task_runner::run(task_config, task_node, server_running)); thread::spawn(move || task_runner::run(task_config, task_node, server_running));
} else { } else {
error!( error!(
"<bright-blue>{}</> executable not exists!", "<bright-blue>{:?}</> executable not exists!",
config.task.path config.task.path
); );
} }
@ -159,15 +158,11 @@ pub fn player(
thread::spawn(move || stderr_reader(dec_err, Decoder, dec_p_ctl)); thread::spawn(move || stderr_reader(dec_err, Decoder, dec_p_ctl));
loop { loop {
// when server is running, read from channel // when server is running, read from it
if proc_control.server_is_running.load(Ordering::SeqCst) { if proc_control.server_is_running.load(Ordering::SeqCst) {
if !live_on { if !live_on {
info!("Switch from {} to live ingest", config.processing.mode); info!("Switch from {} to live ingest", config.processing.mode);
if let Err(e) = enc_writer.flush() {
error!("Encoder error: {e}")
}
if let Err(e) = proc_control.stop(Decoder) { if let Err(e) = proc_control.stop(Decoder) {
error!("{e}") error!("{e}")
} }
@ -188,11 +183,8 @@ pub fn player(
if live_on { if live_on {
info!("Switch from live ingest to {}", config.processing.mode); info!("Switch from live ingest to {}", config.processing.mode);
if let Err(e) = enc_writer.flush() {
error!("Encoder error: {e}")
}
live_on = false; live_on = false;
break;
} }
let dec_bytes_len = match dec_reader.read(&mut buffer[..]) { let dec_bytes_len = match dec_reader.read(&mut buffer[..]) {

View File

@ -1,3 +1,5 @@
use std::path::PathBuf;
use clap::Parser; use clap::Parser;
use ffplayout_lib::utils::{OutputMode, ProcessMode}; use ffplayout_lib::utils::{OutputMode, ProcessMode};
@ -13,10 +15,24 @@ pub struct Args {
pub channel: Option<String>, pub channel: Option<String>,
#[clap(short, long, help = "File path to ffplayout.yml")] #[clap(short, long, help = "File path to ffplayout.yml")]
pub config: Option<String>, pub config: Option<PathBuf>,
#[clap(short, long, help = "File path for logging")] #[clap(short, long, help = "File path for logging")]
pub log: Option<String>, pub log: Option<PathBuf>,
#[clap(
short,
long,
help = "Target date (YYYY-MM-DD) for text/m3u to playlist import"
)]
pub date: Option<String>,
#[cfg(debug_assertions)]
#[clap(long, help = "fake date time, for debugging")]
pub fake_time: Option<String>,
#[clap(short, long, help = "Play folder content")]
pub folder: Option<PathBuf>,
#[clap( #[clap(
short, short,
@ -27,37 +43,14 @@ pub struct Args {
)] )]
pub generate: Option<Vec<String>>, pub generate: Option<Vec<String>>,
#[clap(long, help = "Optional path list for playlist generations", num_args = 1..)]
pub paths: Option<Vec<String>>,
#[clap(short = 'm', long, help = "Playing mode: folder, playlist")]
pub play_mode: Option<ProcessMode>,
#[clap(short, long, help = "Play folder content")]
pub folder: Option<String>,
#[clap(
short,
long,
help = "Target date (YYYY-MM-DD) for text/m3u to playlist import"
)]
pub date: Option<String>,
#[clap( #[clap(
long, long,
help = "Import a given text/m3u file and create a playlist from it" help = "Import a given text/m3u file and create a playlist from it"
)] )]
pub import: Option<String>, pub import: Option<PathBuf>,
#[clap(short, long, help = "Path to playlist, or playlist root folder.")] #[clap(short, long, help = "Loop playlist infinitely")]
pub playlist: Option<String>, pub infinit: bool,
#[clap(
short,
long,
help = "Start time in 'hh:mm:ss', 'now' for start with first"
)]
pub start: Option<String>,
#[clap( #[clap(
short = 't', short = 't',
@ -69,8 +62,24 @@ pub struct Args {
#[clap(long, help = "Override logging level")] #[clap(long, help = "Override logging level")]
pub level: Option<String>, pub level: Option<String>,
#[clap(short, long, help = "Loop playlist infinitely")] #[clap(long, help = "Optional path list for playlist generations", num_args = 1..)]
pub infinit: bool, pub paths: Option<Vec<PathBuf>>,
#[clap(short = 'm', long, help = "Playing mode: folder, playlist")]
pub play_mode: Option<ProcessMode>,
#[clap(short, long, help = "Path to playlist, or playlist root folder.")]
pub playlist: Option<PathBuf>,
#[clap(
short,
long,
help = "Start time in 'hh:mm:ss', 'now' for start with first"
)]
pub start: Option<String>,
#[clap(short = 'T', long, help = "JSON Template file for generating playlist")]
pub template: Option<PathBuf>,
#[clap(short, long, help = "Set output mode: desktop, hls, null, stream")] #[clap(short, long, help = "Set output mode: desktop, hls, null, stream")]
pub output: Option<OutputMode>, pub output: Option<OutputMode>,
@ -78,10 +87,6 @@ pub struct Args {
#[clap(short, long, help = "Set audio volume")] #[clap(short, long, help = "Set audio volume")]
pub volume: Option<f64>, pub volume: Option<f64>,
#[cfg(debug_assertions)]
#[clap(long, help = "fake date time, for debugging")]
pub fake_time: Option<String>,
#[clap(long, help = "validate given playlist")] #[clap(long, help = "validate given playlist")]
pub validate: bool, pub validate: bool,
} }

View File

@ -1,7 +1,4 @@
use std::{ use std::{fs::File, path::PathBuf, process::exit};
path::{Path, PathBuf},
process::exit,
};
use regex::Regex; use regex::Regex;
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
@ -14,8 +11,8 @@ pub use arg_parse::Args;
use ffplayout_lib::{ use ffplayout_lib::{
filter::Filters, filter::Filters,
utils::{ utils::{
get_sec, parse_log_level_filter, sec_to_time, time_to_sec, Media, OutputMode::*, config::Template, get_sec, parse_log_level_filter, sec_to_time, time_to_sec, Media,
PlayoutConfig, ProcessMode::*, OutputMode::*, PlayoutConfig, ProcessMode::*,
}, },
vec_strings, vec_strings,
}; };
@ -33,7 +30,7 @@ pub fn get_config(args: Args) -> PlayoutConfig {
exit(1) exit(1)
} }
Some(path.display().to_string()) Some(path)
} }
None => args.config, None => args.config,
}; };
@ -48,12 +45,33 @@ pub fn get_config(args: Args) -> PlayoutConfig {
config.general.validate = true; config.general.validate = true;
} }
if let Some(template_file) = args.template {
let f = File::options()
.read(true)
.write(false)
.open(template_file)
.expect("JSON 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)
}
};
template.sources.sort_by(|d1, d2| d1.start.cmp(&d2.start));
config.general.template = Some(template);
}
if let Some(paths) = args.paths { if let Some(paths) = args.paths {
config.storage.paths = paths; config.storage.paths = paths;
} }
if let Some(log_path) = args.log { if let Some(log_path) = args.log {
if Path::new(&log_path).is_dir() { if log_path.is_dir() {
config.logging.log_to_file = true; config.logging.log_to_file = true;
} }
config.logging.path = log_path; config.logging.path = log_path;

@ -1 +1 @@
Subproject commit 2f3234221a0aef8e70d9e2b5e9bbfb1fe51921fc Subproject commit 2ca3aa73b2992f6997276f5c8fb906f9ee703bf2

View File

@ -9,11 +9,12 @@ repository.workspace = true
edition.workspace = true edition.workspace = true
[dependencies] [dependencies]
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
crossbeam-channel = "0.5" crossbeam-channel = "0.5"
ffprobe = "0.3" ffprobe = "0.3"
file-rotate = "0.7.0" file-rotate = "0.7"
lettre = "0.10" lettre = "0.10"
lexical-sort = "0.3"
log = "0.4" log = "0.4"
rand = "0.8" rand = "0.8"
regex = "1" regex = "1"
@ -22,7 +23,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
serde_yaml = "0.9" serde_yaml = "0.9"
shlex = "1.1" shlex = "1.1"
simplelog = { version = "^0.12", features = ["paris"] } simplelog = { version = "0.12", features = ["paris"] }
time = { version = "0.3", features = ["formatting", "macros"] } time = { version = "0.3", features = ["formatting", "macros"] }
walkdir = "2" walkdir = "2"
@ -32,3 +33,6 @@ features = ["shlobj", "std", "winerror"]
[target.x86_64-unknown-linux-musl.dependencies] [target.x86_64-unknown-linux-musl.dependencies]
openssl = { version = "0.10", features = ["vendored"] } openssl = { version = "0.10", features = ["vendored"] }
[target.'cfg(not(target_arch = "windows"))'.dependencies]
signal-child = "1"

View File

@ -6,6 +6,7 @@ use std::{
str::FromStr, str::FromStr,
}; };
use chrono::NaiveTime;
use log::LevelFilter; use log::LevelFilter;
use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use shlex::split; use shlex::split;
@ -124,6 +125,19 @@ where
} }
} }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Template {
pub sources: Vec<Source>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Source {
pub start: NaiveTime,
pub duration: NaiveTime,
pub shuffle: bool,
pub paths: Vec<PathBuf>,
}
/// Global Config /// Global Config
/// ///
/// This we init ones, when ffplayout is starting and use them globally in the hole program. /// This we init ones, when ffplayout is starting and use them globally in the hole program.
@ -163,6 +177,9 @@ pub struct General {
#[serde(skip_serializing, skip_deserializing)] #[serde(skip_serializing, skip_deserializing)]
pub ffmpeg_libs: Vec<String>, pub ffmpeg_libs: Vec<String>,
#[serde(skip_serializing, skip_deserializing)]
pub template: Option<Template>,
#[serde(default, skip_serializing, skip_deserializing)] #[serde(default, skip_serializing, skip_deserializing)]
pub validate: bool, pub validate: bool,
} }
@ -196,7 +213,7 @@ pub struct Logging {
pub local_time: bool, pub local_time: bool,
pub timestamp: bool, pub timestamp: bool,
#[serde(alias = "log_path")] #[serde(alias = "log_path")]
pub path: String, pub path: PathBuf,
#[serde( #[serde(
alias = "log_level", alias = "log_level",
serialize_with = "log_level_to_string", serialize_with = "log_level_to_string",
@ -255,7 +272,7 @@ pub struct Ingest {
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Playlist { pub struct Playlist {
pub help_text: String, pub help_text: String,
pub path: String, pub path: PathBuf,
pub day_start: String, pub day_start: String,
#[serde(skip_serializing, skip_deserializing)] #[serde(skip_serializing, skip_deserializing)]
@ -272,11 +289,11 @@ pub struct Playlist {
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Storage { pub struct Storage {
pub help_text: String, pub help_text: String,
pub path: String, pub path: PathBuf,
#[serde(skip_serializing, skip_deserializing)] #[serde(skip_serializing, skip_deserializing)]
pub paths: Vec<String>, pub paths: Vec<PathBuf>,
#[serde(alias = "filler_clip")] #[serde(alias = "filler_clip")]
pub filler: String, pub filler: PathBuf,
pub extensions: Vec<String>, pub extensions: Vec<String>,
pub shuffle: bool, pub shuffle: bool,
} }
@ -304,7 +321,7 @@ pub struct Text {
#[derive(Debug, Default, Serialize, Deserialize, Clone)] #[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct Task { pub struct Task {
pub enable: bool, pub enable: bool,
pub path: String, pub path: PathBuf,
} }
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
@ -335,11 +352,11 @@ fn default_channels() -> u8 {
impl PlayoutConfig { impl PlayoutConfig {
/// Read config from YAML file, and set some extra config values. /// Read config from YAML file, and set some extra config values.
pub fn new(cfg_path: Option<String>) -> Self { pub fn new(cfg_path: Option<PathBuf>) -> Self {
let mut config_path = PathBuf::from("/etc/ffplayout/ffplayout.yml"); let mut config_path = PathBuf::from("/etc/ffplayout/ffplayout.yml");
if let Some(cfg) = cfg_path { if let Some(cfg) = cfg_path {
config_path = PathBuf::from(cfg); config_path = cfg;
} }
if !config_path.is_file() { if !config_path.is_file() {

View File

@ -7,6 +7,9 @@ use std::{
}, },
}; };
#[cfg(not(windows))]
use signal_child::Signalable;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use simplelog::*; use simplelog::*;
@ -71,9 +74,15 @@ impl ProcessControl {
match unit { match unit {
Decoder => { Decoder => {
if let Some(proc) = self.decoder_term.lock().unwrap().as_mut() { if let Some(proc) = self.decoder_term.lock().unwrap().as_mut() {
#[cfg(not(windows))]
if let Err(e) = proc.term() {
return Err(format!("Decoder {e:?}"));
}
#[cfg(windows)]
if let Err(e) = proc.kill() { if let Err(e) = proc.kill() {
return Err(format!("Decoder {e:?}")); return Err(format!("Decoder {e:?}"));
}; }
} }
} }
Encoder => { Encoder => {
@ -177,7 +186,7 @@ impl PlayerControl {
Self { Self {
current_media: Arc::new(Mutex::new(None)), current_media: Arc::new(Mutex::new(None)),
current_list: Arc::new(Mutex::new(vec![Media::new(0, "", false)])), current_list: Arc::new(Mutex::new(vec![Media::new(0, "", false)])),
filler_list: Arc::new(Mutex::new(vec![Media::new(0, "", false)])), filler_list: Arc::new(Mutex::new(vec![])),
current_index: Arc::new(AtomicUsize::new(0)), current_index: Arc::new(AtomicUsize::new(0)),
filler_index: Arc::new(AtomicUsize::new(0)), filler_index: Arc::new(AtomicUsize::new(0)),
} }

View File

@ -1,9 +1,6 @@
use std::{ use std::sync::{
path::Path, atomic::Ordering,
sync::{ {Arc, Mutex},
atomic::Ordering,
{Arc, Mutex},
},
}; };
use rand::{seq::SliceRandom, thread_rng}; use rand::{seq::SliceRandom, thread_rng};
@ -37,18 +34,18 @@ impl FolderSource {
if config.general.generate.is_some() && !config.storage.paths.is_empty() { if config.general.generate.is_some() && !config.storage.paths.is_empty() {
for path in &config.storage.paths { for path in &config.storage.paths {
path_list.push(path.clone()) path_list.push(path)
} }
} else { } else {
path_list.push(config.storage.path.clone()) path_list.push(&config.storage.path)
} }
for path in &path_list { for path in &path_list {
if !Path::new(path).is_dir() { if !path.is_dir() {
error!("Path not exists: <b><magenta>{path}</></b>"); error!("Path not exists: <b><magenta>{path:?}</></b>");
} }
for entry in WalkDir::new(path.clone()) for entry in WalkDir::new(path)
.into_iter() .into_iter()
.flat_map(|e| e.ok()) .flat_map(|e| e.ok())
.filter(|f| f.path().is_file()) .filter(|f| f.path().is_file())
@ -90,6 +87,22 @@ impl FolderSource {
} }
} }
pub fn from_list(
config: &PlayoutConfig,
filter_chain: Option<Arc<Mutex<Vec<String>>>>,
player_control: &PlayerControl,
list: Vec<Media>,
) -> Self {
*player_control.current_list.lock().unwrap() = list;
Self {
config: config.clone(),
filter_chain,
player_control: player_control.clone(),
current_node: Media::new(0, "", false),
}
}
fn shuffle(&mut self) { fn shuffle(&mut self) {
let mut rng = thread_rng(); let mut rng = thread_rng();
let mut nodes = self.player_control.current_list.lock().unwrap(); let mut nodes = self.player_control.current_list.lock().unwrap();
@ -160,23 +173,30 @@ impl Iterator for FolderSource {
} }
} }
pub fn fill_filler_list(config: PlayoutConfig, player_control: PlayerControl) { pub fn fill_filler_list(
config: &PlayoutConfig,
player_control: Option<PlayerControl>,
) -> Vec<Media> {
let mut filler_list = vec![]; let mut filler_list = vec![];
let filler_path = &config.storage.filler;
if Path::new(&config.storage.filler).is_dir() { if filler_path.is_dir() {
debug!(
"Fill filler list from: <b><magenta>{}</></b>",
config.storage.filler
);
for (index, entry) in WalkDir::new(&config.storage.filler) for (index, entry) in WalkDir::new(&config.storage.filler)
.into_iter() .into_iter()
.flat_map(|e| e.ok()) .flat_map(|e| e.ok())
.filter(|f| f.path().is_file()) .filter(|f| f.path().is_file())
.filter(|f| include_file_extension(&config, f.path())) .filter(|f| include_file_extension(config, f.path()))
.enumerate() .enumerate()
{ {
filler_list.push(Media::new(index, &entry.path().to_string_lossy(), false)); let mut media = Media::new(index, &entry.path().to_string_lossy(), false);
if let Some(control) = player_control.as_ref() {
control.filler_list.lock().unwrap().push(media);
} else {
media.add_probe();
filler_list.push(media);
}
} }
if config.storage.shuffle { if config.storage.shuffle {
@ -190,9 +210,17 @@ pub fn fill_filler_list(config: PlayoutConfig, player_control: PlayerControl) {
for (index, item) in filler_list.iter_mut().enumerate() { for (index, item) in filler_list.iter_mut().enumerate() {
item.index = Some(index); item.index = Some(index);
} }
} else { } else if filler_path.is_file() {
filler_list.push(Media::new(0, &config.storage.filler, false)); let mut media = Media::new(0, &config.storage.filler.to_string_lossy(), false);
if let Some(control) = player_control.as_ref() {
control.filler_list.lock().unwrap().push(media);
} else {
media.add_probe();
filler_list.push(media);
}
} }
*player_control.filler_list.lock().unwrap() = filler_list; filler_list
} }

View File

@ -4,22 +4,192 @@
/// ///
/// The generator takes the files from storage, which are set in config. /// The generator takes the files from storage, which are set in config.
/// It also respect the shuffle/sort mode. /// It also respect the shuffle/sort mode.
///
/// Beside that it is really very basic, without any logic.
use std::{ use std::{
fs::{create_dir_all, write}, fs::{create_dir_all, write},
io::Error, io::Error,
path::Path,
process::exit, process::exit,
}; };
use chrono::Timelike;
use lexical_sort::{natural_lexical_cmp, StringSort};
use rand::{seq::SliceRandom, thread_rng, Rng};
use simplelog::*; use simplelog::*;
use walkdir::WalkDir;
use super::{folder::FolderSource, PlayerControl}; use super::{folder::FolderSource, PlayerControl};
use crate::utils::{ use crate::utils::{
get_date_range, json_serializer::JsonPlaylist, time_to_sec, Media, PlayoutConfig, folder::fill_filler_list, gen_dummy, get_date_range, include_file_extension,
json_serializer::JsonPlaylist, sum_durations, time_to_sec, Media, PlayoutConfig, Template,
}; };
pub fn random_list(clip_list: Vec<Media>, total_length: f64) -> Vec<Media> {
let mut max_attempts = 10000;
let mut randomized_clip_list: Vec<Media> = vec![];
let mut target_duration = 0.0;
let clip_list_length = clip_list.len();
let usage_limit = (total_length / sum_durations(&clip_list)).floor() + 1.0;
let mut last_clip = Media::new(0, "", false);
while target_duration < total_length && max_attempts > 0 {
let index = rand::thread_rng().gen_range(0..clip_list_length);
let selected_clip = clip_list[index].clone();
let selected_clip_count = randomized_clip_list
.iter()
.filter(|&n| *n == selected_clip)
.count() as f64;
if selected_clip_count == usage_limit
|| last_clip == selected_clip
|| target_duration + selected_clip.duration > total_length
{
max_attempts -= 1;
continue;
}
target_duration += selected_clip.duration;
randomized_clip_list.push(selected_clip.clone());
max_attempts -= 1;
last_clip = selected_clip;
}
randomized_clip_list
}
pub fn ordered_list(clip_list: Vec<Media>, total_length: f64) -> Vec<Media> {
let mut index = 0;
let mut skip_count = 0;
let mut ordered_clip_list: Vec<Media> = vec![];
let mut target_duration = 0.0;
let clip_list_length = clip_list.len();
while target_duration < total_length && skip_count < clip_list_length {
if index == clip_list_length {
index = 0;
}
let selected_clip = clip_list[index].clone();
if sum_durations(&ordered_clip_list) + selected_clip.duration > total_length
|| (!ordered_clip_list.is_empty()
&& selected_clip == ordered_clip_list[ordered_clip_list.len() - 1])
{
skip_count += 1;
index += 1;
continue;
}
target_duration += selected_clip.duration;
ordered_clip_list.push(selected_clip);
index += 1;
}
ordered_clip_list
}
pub fn filler_list(config: &PlayoutConfig, total_length: f64) -> Vec<Media> {
let filler_list = fill_filler_list(config, None);
let mut index = 0;
let mut filler_clip_list: Vec<Media> = vec![];
let mut target_duration = 0.0;
let clip_list_length = filler_list.len();
if clip_list_length > 0 {
while target_duration < total_length {
if index == clip_list_length {
index = 0;
}
let selected_clip = filler_list[index].clone();
target_duration += selected_clip.duration;
filler_clip_list.push(selected_clip);
index += 1;
}
let over_length = target_duration - total_length;
let last_index = filler_clip_list.len() - 1;
filler_clip_list[last_index].out = filler_clip_list[last_index].duration - over_length;
} else {
let mut dummy = Media::new(0, "", false);
let (source, cmd) = gen_dummy(config, total_length);
dummy.source = source;
dummy.cmd = Some(cmd);
dummy.duration = total_length;
dummy.out = total_length;
filler_clip_list.push(dummy);
}
filler_clip_list
}
pub fn generate_from_template(
config: &PlayoutConfig,
player_control: &PlayerControl,
template: Template,
) -> FolderSource {
let mut media_list = vec![];
let mut rng = thread_rng();
let mut index: usize = 0;
for source in template.sources {
let mut source_list = vec![];
let duration = (source.duration.hour() as f64 * 3600.0)
+ (source.duration.minute() as f64 * 60.0)
+ source.duration.second() as f64;
debug!("Generating playlist block with <yellow>{duration:.2}</> seconds length");
for path in source.paths {
debug!("Search files in <b><magenta>{path:?}</></b>");
let mut file_list = WalkDir::new(path.clone())
.into_iter()
.flat_map(|e| e.ok())
.filter(|f| f.path().is_file())
.filter(|f| include_file_extension(config, f.path()))
.map(|p| p.path().to_string_lossy().to_string())
.collect::<Vec<String>>();
if !source.shuffle {
file_list.string_sort_unstable(natural_lexical_cmp);
}
for entry in file_list {
let media = Media::new(0, &entry, true);
source_list.push(media);
}
}
let mut timed_list = if source.shuffle {
source_list.shuffle(&mut rng);
random_list(source_list, duration)
} else {
ordered_list(source_list, duration)
};
let total_length = sum_durations(&timed_list);
if duration > total_length {
let mut filler = filler_list(config, duration - total_length);
timed_list.append(&mut filler);
}
media_list.append(&mut timed_list);
}
for item in media_list.iter_mut() {
item.index = Some(index);
index += 1;
}
FolderSource::from_list(config, None, player_control, media_list)
}
/// Generate playlists /// Generate playlists
pub fn generate_playlist( pub fn generate_playlist(
config: &PlayoutConfig, config: &PlayoutConfig,
@ -36,9 +206,10 @@ pub fn generate_playlist(
} }
}; };
let player_control = PlayerControl::new(); let player_control = PlayerControl::new();
let playlist_root = Path::new(&config.playlist.path); let playlist_root = &config.playlist.path;
let mut playlists = vec![]; let mut playlists = vec![];
let mut date_range = vec![]; let mut date_range = vec![];
let mut from_template = false;
let channel = match channel_name { let channel = match channel_name {
Some(name) => name, Some(name) => name,
@ -47,8 +218,8 @@ pub fn generate_playlist(
if !playlist_root.is_dir() { if !playlist_root.is_dir() {
error!( error!(
"Playlist folder <b><magenta>{}</></b> not exists!", "Playlist folder <b><magenta>{:?}</></b> not exists!",
&config.playlist.path config.playlist.path
); );
exit(1); exit(1);
@ -62,8 +233,16 @@ pub fn generate_playlist(
date_range = get_date_range(&date_range) date_range = get_date_range(&date_range)
} }
let media_list = FolderSource::new(config, None, &player_control); // gives an iterator with infinit length
let list_length = media_list.player_control.current_list.lock().unwrap().len(); let folder_iter = if let Some(template) = &config.general.template {
from_template = true;
generate_from_template(config, &player_control, template.clone())
} else {
FolderSource::new(config, None, &player_control)
};
let list_length = player_control.current_list.lock().unwrap().len();
for date in date_range { for date in date_range {
let d: Vec<&str> = date.split('-').collect(); let d: Vec<&str> = date.split('-').collect();
@ -71,6 +250,8 @@ pub fn generate_playlist(
let month = d[1]; let month = d[1];
let playlist_path = playlist_root.join(year).join(month); let playlist_path = playlist_root.join(year).join(month);
let playlist_file = &playlist_path.join(format!("{date}.json")); let playlist_file = &playlist_path.join(format!("{date}.json"));
let mut length = 0.0;
let mut round = 0;
create_dir_all(playlist_path)?; create_dir_all(playlist_path)?;
@ -88,12 +269,6 @@ pub fn generate_playlist(
playlist_file.display() playlist_file.display()
); );
// TODO: handle filler folder
let mut filler = Media::new(0, &config.storage.filler, true);
let filler_length = filler.duration;
let mut length = 0.0;
let mut round = 0;
let mut playlist = JsonPlaylist { let mut playlist = JsonPlaylist {
channel: channel.clone(), channel: channel.clone(),
date, date,
@ -103,30 +278,38 @@ pub fn generate_playlist(
program: vec![], program: vec![],
}; };
for item in media_list.clone() { if from_template {
let duration = item.duration; let media_list = player_control.current_list.lock().unwrap();
playlist.program = media_list.to_vec();
} else {
for item in folder_iter.clone() {
let duration = item.duration;
if total_length > length + duration { if total_length >= length + duration {
playlist.program.push(item); playlist.program.push(item);
length += duration; length += duration;
} else if filler_length > 0.0 && filler_length > total_length - length { } else if round == list_length - 1 {
filler.out = total_length - length; break;
playlist.program.push(filler); } else {
round += 1;
}
}
break; let list_duration = sum_durations(&playlist.program);
} else if round == list_length - 1 {
break; if config.playlist.length_sec.unwrap() > list_duration {
} else { let time_left = config.playlist.length_sec.unwrap() - list_duration;
round += 1; let mut fillers = filler_list(config, time_left);
playlist.program.append(&mut fillers);
} }
} }
playlists.push(playlist.clone());
let json: String = serde_json::to_string_pretty(&playlist)?; let json: String = serde_json::to_string_pretty(&playlist)?;
write(playlist_file, json)?; write(playlist_file, json)?;
playlists.push(playlist);
} }
Ok(playlists) Ok(playlists)

View File

@ -12,7 +12,7 @@ pub fn import_file(
config: &PlayoutConfig, config: &PlayoutConfig,
date: &str, date: &str,
channel_name: Option<String>, channel_name: Option<String>,
path: &str, path: &Path,
) -> Result<String, Error> { ) -> Result<String, Error> {
let file = File::open(path)?; let file = File::open(path)?;
let reader = BufReader::new(file); let reader = BufReader::new(file);
@ -25,13 +25,13 @@ pub fn import_file(
program: vec![], program: vec![],
}; };
let playlist_root = Path::new(&config.playlist.path); let playlist_root = &config.playlist.path;
if !playlist_root.is_dir() { if !playlist_root.is_dir() {
return Err(Error::new( return Err(Error::new(
ErrorKind::Other, ErrorKind::Other,
format!( format!(
"Playlist folder <b><magenta>{}</></b> not exists!", "Playlist folder <b><magenta>{:?}</></b> not exists!",
&config.playlist.path, config.playlist.path,
), ),
)); ));
} }

View File

@ -142,14 +142,14 @@ pub fn read_json(
path: Option<String>, path: Option<String>,
is_terminated: Arc<AtomicBool>, is_terminated: Arc<AtomicBool>,
seek: bool, seek: bool,
next_start: f64, get_next: bool,
) -> JsonPlaylist { ) -> JsonPlaylist {
let config_clone = config.clone(); let config_clone = config.clone();
let mut playlist_path = Path::new(&config.playlist.path).to_owned(); let mut playlist_path = config.playlist.path.clone();
let start_sec = config.playlist.start_sec.unwrap(); let start_sec = config.playlist.start_sec.unwrap();
let date = get_date(seek, start_sec, next_start); let date = get_date(seek, start_sec, get_next);
if playlist_path.is_dir() || is_remote(&config.playlist.path) { if playlist_path.is_dir() || is_remote(&config.playlist.path.to_string_lossy()) {
let d: Vec<&str> = date.split('-').collect(); let d: Vec<&str> = date.split('-').collect();
playlist_path = playlist_path playlist_path = playlist_path
.join(d[0]) .join(d[0])

View File

@ -165,7 +165,7 @@ pub fn validate_playlist(
begin += item.out - item.seek; begin += item.out - item.seek;
} }
if !config.playlist.infinit && length > begin + 1.0 { if !config.playlist.infinit && length > begin + 1.2 {
error!( error!(
"Playlist from <yellow>{date}</> not long enough, <yellow>{}</> needed!", "Playlist from <yellow>{date}</> not long enough, <yellow>{}</> needed!",
sec_to_time(length - begin), sec_to_time(length - begin),

View File

@ -2,7 +2,7 @@ extern crate log;
extern crate simplelog; extern crate simplelog;
use std::{ use std::{
path::Path, path::PathBuf,
sync::{atomic::Ordering, Arc, Mutex}, sync::{atomic::Ordering, Arc, Mutex},
thread::{self, sleep}, thread::{self, sleep},
time::Duration, time::Duration,
@ -199,21 +199,18 @@ pub fn init_logging(
}; };
}; };
if app_config.log_to_file && &app_config.path != "none" { if app_config.log_to_file && app_config.path.exists() {
let file_config = log_config let file_config = log_config
.clone() .clone()
.set_time_format_custom(format_description!( .set_time_format_custom(format_description!(
"[[[year]-[month]-[day] [hour]:[minute]:[second].[subsecond digits:5]]" "[[[year]-[month]-[day] [hour]:[minute]:[second].[subsecond digits:5]]"
)) ))
.build(); .build();
let mut log_path = "logs/ffplayout.log".to_string(); let mut log_path = PathBuf::from("logs/ffplayout.log");
if Path::new(&app_config.path).is_dir() { if app_config.path.is_dir() {
log_path = Path::new(&app_config.path) log_path = app_config.path.join("ffplayout.log");
.join("ffplayout.log") } else if app_config.path.is_file() {
.display()
.to_string();
} else if Path::new(&app_config.path).is_file() {
log_path = app_config.path log_path = app_config.path
} else { } else {
println!("Logging path not exists!") println!("Logging path not exists!")

View File

@ -13,7 +13,7 @@ use std::{
use std::env; use std::env;
use chrono::{prelude::*, Duration}; use chrono::{prelude::*, Duration};
use ffprobe::{ffprobe, Format, Stream}; use ffprobe::{ffprobe, Format, Stream as FFStream};
use rand::prelude::*; use rand::prelude::*;
use regex::Regex; use regex::Regex;
use reqwest::header; use reqwest::header;
@ -24,7 +24,7 @@ use simplelog::*;
pub mod config; pub mod config;
pub mod controller; pub mod controller;
pub mod folder; pub mod folder;
mod generator; pub mod generator;
pub mod import; pub mod import;
pub mod json_serializer; pub mod json_serializer;
mod json_validate; mod json_validate;
@ -38,7 +38,7 @@ pub use config::{
OutputMode::{self, *}, OutputMode::{self, *},
PlayoutConfig, PlayoutConfig,
ProcessMode::{self, *}, ProcessMode::{self, *},
DUMMY_LEN, FFMPEG_IGNORE_ERRORS, FFMPEG_UNRECOVERABLE_ERRORS, IMAGE_FORMAT, Template, DUMMY_LEN, FFMPEG_IGNORE_ERRORS, FFMPEG_UNRECOVERABLE_ERRORS, IMAGE_FORMAT,
}; };
pub use controller::{ pub use controller::{
PlayerControl, PlayoutStatus, ProcessControl, PlayerControl, PlayoutStatus, ProcessControl,
@ -148,14 +148,15 @@ impl Media {
pub fn add_probe(&mut self) { pub fn add_probe(&mut self) {
if self.probe.is_none() { if self.probe.is_none() {
let probe = MediaProbe::new(&self.source); let probe = MediaProbe::new(&self.source);
self.probe = Some(probe.clone());
if let Some(dur) = probe if let Some(dur) = probe
.format .format
.clone()
.and_then(|f| f.duration) .and_then(|f| f.duration)
.map(|d| d.parse().unwrap()) .map(|d| d.parse().unwrap())
.filter(|d| !is_close(*d, self.duration, 0.5)) .filter(|d| !is_close(*d, self.duration, 0.5))
{ {
self.probe = Some(probe);
self.duration = dur; self.duration = dur;
if self.out == 0.0 { if self.out == 0.0 {
@ -205,8 +206,8 @@ fn is_empty_string(st: &String) -> bool {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct MediaProbe { pub struct MediaProbe {
pub format: Option<Format>, pub format: Option<Format>,
pub audio_streams: Vec<Stream>, pub audio_streams: Vec<FFStream>,
pub video_streams: Vec<Stream>, pub video_streams: Vec<FFStream>,
} }
impl MediaProbe { impl MediaProbe {
@ -321,14 +322,14 @@ pub fn get_sec() -> f64 {
/// ///
/// - When time is before playlist start, get date from yesterday. /// - When time is before playlist start, get date from yesterday.
/// - When given next_start is over target length (normally a full day), get date from tomorrow. /// - When given next_start is over target length (normally a full day), get date from tomorrow.
pub fn get_date(seek: bool, start: f64, next_start: f64) -> String { pub fn get_date(seek: bool, start: f64, get_next: bool) -> String {
let local: DateTime<Local> = time_now(); let local: DateTime<Local> = time_now();
if seek && start > get_sec() { if seek && start > get_sec() {
return (local - Duration::days(1)).format("%Y-%m-%d").to_string(); return (local - Duration::days(1)).format("%Y-%m-%d").to_string();
} }
if start == 0.0 && next_start >= 86400.0 { if start == 0.0 && get_next && get_sec() > 86397.9 {
return (local + Duration::days(1)).format("%Y-%m-%d").to_string(); return (local + Duration::days(1)).format("%Y-%m-%d").to_string();
} }
@ -409,6 +410,17 @@ pub fn is_close(a: f64, b: f64, to: f64) -> bool {
false false
} }
/// add duration from all media clips
pub fn sum_durations(clip_list: &Vec<Media>) -> f64 {
let mut list_duration = 0.0;
for item in clip_list {
list_duration += item.out
}
list_duration
}
/// Get delta between clip start and current time. This value we need to check, /// Get delta between clip start and current time. This value we need to check,
/// if we still in sync. /// if we still in sync.
/// ///

View File

@ -41,3 +41,7 @@ path = "src/engine_playlist.rs"
[[test]] [[test]]
name = "engine_cmd" name = "engine_cmd"
path = "src/engine_cmd.rs" path = "src/engine_cmd.rs"
[[test]]
name = "engine_generator"
path = "src/engine_generator.rs"

View File

@ -1,4 +1,4 @@
use std::fs; use std::{fs, path::PathBuf};
use ffplayout::{input::playlist::gen_source, utils::prepare_output_cmd}; use ffplayout::{input::playlist::gen_source, utils::prepare_output_cmd};
use ffplayout_lib::{ use ffplayout_lib::{
@ -8,7 +8,7 @@ use ffplayout_lib::{
#[test] #[test]
fn video_audio_input() { fn video_audio_input() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
let player_control = PlayerControl::new(); let player_control = PlayerControl::new();
config.out.mode = Stream; config.out.mode = Stream;
config.processing.add_logo = true; config.processing.add_logo = true;
@ -36,7 +36,7 @@ fn video_audio_input() {
#[test] #[test]
fn video_audio_custom_filter1_input() { fn video_audio_custom_filter1_input() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
let player_control = PlayerControl::new(); let player_control = PlayerControl::new();
config.out.mode = Stream; config.out.mode = Stream;
config.processing.add_logo = false; config.processing.add_logo = false;
@ -62,7 +62,7 @@ fn video_audio_custom_filter1_input() {
#[test] #[test]
fn video_audio_custom_filter2_input() { fn video_audio_custom_filter2_input() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
let player_control = PlayerControl::new(); let player_control = PlayerControl::new();
config.out.mode = Stream; config.out.mode = Stream;
config.processing.add_logo = false; config.processing.add_logo = false;
@ -90,7 +90,7 @@ fn video_audio_custom_filter2_input() {
#[test] #[test]
fn video_audio_custom_filter3_input() { fn video_audio_custom_filter3_input() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
let player_control = PlayerControl::new(); let player_control = PlayerControl::new();
config.out.mode = Stream; config.out.mode = Stream;
config.processing.add_logo = false; config.processing.add_logo = false;
@ -117,7 +117,7 @@ fn video_audio_custom_filter3_input() {
#[test] #[test]
fn dual_audio_aevalsrc_input() { fn dual_audio_aevalsrc_input() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
let player_control = PlayerControl::new(); let player_control = PlayerControl::new();
config.out.mode = Stream; config.out.mode = Stream;
config.processing.audio_tracks = 2; config.processing.audio_tracks = 2;
@ -144,7 +144,7 @@ fn dual_audio_aevalsrc_input() {
#[test] #[test]
fn dual_audio_input() { fn dual_audio_input() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
let player_control = PlayerControl::new(); let player_control = PlayerControl::new();
config.out.mode = Stream; config.out.mode = Stream;
config.processing.audio_tracks = 2; config.processing.audio_tracks = 2;
@ -170,7 +170,7 @@ fn dual_audio_input() {
#[test] #[test]
fn video_separate_audio_input() { fn video_separate_audio_input() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
let player_control = PlayerControl::new(); let player_control = PlayerControl::new();
config.out.mode = Stream; config.out.mode = Stream;
config.processing.audio_tracks = 1; config.processing.audio_tracks = 1;
@ -204,7 +204,7 @@ fn video_separate_audio_input() {
#[test] #[test]
fn video_audio_stream() { fn video_audio_stream() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
config.out.mode = Stream; config.out.mode = Stream;
config.processing.add_logo = false; config.processing.add_logo = false;
config.out.output_cmd = Some(vec_strings![ config.out.output_cmd = Some(vec_strings![
@ -263,7 +263,7 @@ fn video_audio_stream() {
#[test] #[test]
fn video_audio_filter1_stream() { fn video_audio_filter1_stream() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
config.out.mode = Stream; config.out.mode = Stream;
config.processing.add_logo = false; config.processing.add_logo = false;
config.text.add_text = false; config.text.add_text = false;
@ -338,7 +338,7 @@ fn video_audio_filter1_stream() {
#[test] #[test]
fn video_audio_filter2_stream() { fn video_audio_filter2_stream() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
config.out.mode = Stream; config.out.mode = Stream;
config.processing.add_logo = false; config.processing.add_logo = false;
config.text.add_text = true; config.text.add_text = true;
@ -421,7 +421,7 @@ fn video_audio_filter2_stream() {
#[test] #[test]
fn video_audio_filter3_stream() { fn video_audio_filter3_stream() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
config.out.mode = Stream; config.out.mode = Stream;
config.processing.add_logo = false; config.processing.add_logo = false;
config.text.add_text = true; config.text.add_text = true;
@ -507,7 +507,7 @@ fn video_audio_filter3_stream() {
#[test] #[test]
fn video_audio_filter4_stream() { fn video_audio_filter4_stream() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
config.out.mode = Stream; config.out.mode = Stream;
config.processing.add_logo = false; config.processing.add_logo = false;
config.text.add_text = true; config.text.add_text = true;
@ -593,7 +593,7 @@ fn video_audio_filter4_stream() {
#[test] #[test]
fn video_dual_audio_stream() { fn video_dual_audio_stream() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
config.out.mode = Stream; config.out.mode = Stream;
config.processing.add_logo = false; config.processing.add_logo = false;
config.processing.audio_tracks = 2; config.processing.audio_tracks = 2;
@ -664,7 +664,7 @@ fn video_dual_audio_stream() {
#[test] #[test]
fn video_dual_audio_filter_stream() { fn video_dual_audio_filter_stream() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
config.out.mode = Stream; config.out.mode = Stream;
config.processing.add_logo = false; config.processing.add_logo = false;
config.processing.audio_tracks = 2; config.processing.audio_tracks = 2;
@ -744,7 +744,7 @@ fn video_dual_audio_filter_stream() {
#[test] #[test]
fn video_audio_multi_stream() { fn video_audio_multi_stream() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
config.out.mode = Stream; config.out.mode = Stream;
config.processing.add_logo = false; config.processing.add_logo = false;
config.out.output_cmd = Some(vec_strings![ config.out.output_cmd = Some(vec_strings![
@ -833,7 +833,7 @@ fn video_audio_multi_stream() {
#[test] #[test]
fn video_dual_audio_multi_stream() { fn video_dual_audio_multi_stream() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
config.out.mode = Stream; config.out.mode = Stream;
config.processing.add_logo = false; config.processing.add_logo = false;
config.processing.audio_tracks = 2; config.processing.audio_tracks = 2;
@ -947,7 +947,7 @@ fn video_dual_audio_multi_stream() {
#[test] #[test]
fn video_audio_text_multi_stream() { fn video_audio_text_multi_stream() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
config.out.mode = Stream; config.out.mode = Stream;
config.processing.add_logo = false; config.processing.add_logo = false;
config.text.add_text = true; config.text.add_text = true;
@ -1060,7 +1060,7 @@ fn video_audio_text_multi_stream() {
#[test] #[test]
fn video_dual_audio_multi_filter_stream() { fn video_dual_audio_multi_filter_stream() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
config.out.mode = Stream; config.out.mode = Stream;
config.processing.add_logo = false; config.processing.add_logo = false;
config.processing.audio_tracks = 2; config.processing.audio_tracks = 2;
@ -1189,7 +1189,7 @@ fn video_dual_audio_multi_filter_stream() {
#[test] #[test]
fn video_audio_text_filter_stream() { fn video_audio_text_filter_stream() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
config.out.mode = Stream; config.out.mode = Stream;
config.processing.add_logo = false; config.processing.add_logo = false;
config.processing.audio_tracks = 1; config.processing.audio_tracks = 1;
@ -1311,7 +1311,7 @@ fn video_audio_text_filter_stream() {
#[test] #[test]
fn video_audio_hls() { fn video_audio_hls() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
let player_control = PlayerControl::new(); let player_control = PlayerControl::new();
config.out.mode = HLS; config.out.mode = HLS;
config.processing.add_logo = false; config.processing.add_logo = false;
@ -1397,7 +1397,7 @@ fn video_audio_hls() {
#[test] #[test]
fn video_audio_sub_meta_hls() { fn video_audio_sub_meta_hls() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
let player_control = PlayerControl::new(); let player_control = PlayerControl::new();
config.out.mode = HLS; config.out.mode = HLS;
config.processing.add_logo = false; config.processing.add_logo = false;
@ -1491,7 +1491,7 @@ fn video_audio_sub_meta_hls() {
#[test] #[test]
fn video_multi_audio_hls() { fn video_multi_audio_hls() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
let player_control = PlayerControl::new(); let player_control = PlayerControl::new();
config.out.mode = HLS; config.out.mode = HLS;
config.processing.add_logo = false; config.processing.add_logo = false;
@ -1580,7 +1580,7 @@ fn video_multi_audio_hls() {
#[test] #[test]
fn multi_video_audio_hls() { fn multi_video_audio_hls() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
let player_control = PlayerControl::new(); let player_control = PlayerControl::new();
config.out.mode = HLS; config.out.mode = HLS;
config.processing.add_logo = false; config.processing.add_logo = false;
@ -1694,7 +1694,7 @@ fn multi_video_audio_hls() {
#[test] #[test]
fn multi_video_multi_audio_hls() { fn multi_video_multi_audio_hls() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
let player_control = PlayerControl::new(); let player_control = PlayerControl::new();
config.out.mode = HLS; config.out.mode = HLS;
config.processing.add_logo = false; config.processing.add_logo = false;

View File

@ -0,0 +1,148 @@
use std::{
fs,
path::{Path, PathBuf},
};
use chrono::NaiveTime;
use simplelog::*;
use ffplayout_lib::utils::{
config::{Source, Template},
generator::*,
*,
};
#[test]
#[ignore]
fn test_random_list() {
let clip_list = vec![
Media::new(0, "./assets/with_audio.mp4", true), // 30 seconds
Media::new(0, "./assets/dual_audio.mp4", true), // 30 seconds
Media::new(0, "./assets/av_sync.mp4", true), // 30 seconds
Media::new(0, "./assets/ad.mp4", true), // 25 seconds
];
let r_list = random_list(clip_list.clone(), 200.0);
let r_duration = sum_durations(&r_list);
assert!(200.0 >= r_duration, "duration is {r_duration}");
assert!(r_duration >= 170.0);
}
#[test]
#[ignore]
fn test_ordered_list() {
let clip_list = vec![
Media::new(0, "./assets/with_audio.mp4", true), // 30 seconds
Media::new(0, "./assets/dual_audio.mp4", true), // 30 seconds
Media::new(0, "./assets/av_sync.mp4", true), // 30 seconds
Media::new(0, "./assets/ad.mp4", true), // 25 seconds
];
let o_list = ordered_list(clip_list.clone(), 85.0);
assert_eq!(o_list.len(), 3);
assert_eq!(o_list[2].duration, 25.0);
assert_eq!(sum_durations(&o_list), 85.0);
let o_list = ordered_list(clip_list, 120.0);
assert_eq!(o_list.len(), 4);
assert_eq!(o_list[2].duration, 30.0);
assert_eq!(sum_durations(&o_list), 115.0);
}
#[test]
#[ignore]
fn test_filler_list() {
let mut config = PlayoutConfig::new(None);
config.storage.filler = "assets/".into();
let f_list = filler_list(&config, 2440.0);
assert_eq!(sum_durations(&f_list), 2440.0);
}
#[test]
#[ignore]
fn test_generate_playlist_from_folder() {
let mut config = PlayoutConfig::new(None);
config.general.generate = Some(vec!["2023-09-11".to_string()]);
config.processing.mode = Playlist;
config.logging.log_to_file = false;
config.logging.timestamp = false;
config.logging.level = LevelFilter::Error;
config.storage.filler = "assets/".into();
config.playlist.length_sec = Some(86400.0);
config.playlist.path = "assets/playlists".into();
let logging = init_logging(&config, None, None);
CombinedLogger::init(logging).unwrap_or_default();
let playlist = generate_playlist(&config, Some("Channel 1".to_string()));
assert!(playlist.is_ok());
let playlist_file = Path::new("assets/playlists/2023/09/2023-09-11.json");
assert!(playlist_file.is_file());
fs::remove_file(playlist_file).unwrap();
let total_duration = sum_durations(&playlist.unwrap()[0].program);
assert!(
total_duration > 86399.0 && total_duration < 86401.0,
"total_duration is {total_duration}"
);
}
#[test]
#[ignore]
fn test_generate_playlist_from_template() {
let mut config = PlayoutConfig::new(None);
config.general.generate = Some(vec!["2023-09-12".to_string()]);
config.general.template = Some(Template {
sources: vec![
Source {
start: NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
duration: NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
shuffle: false,
paths: vec![PathBuf::from("assets/")],
},
Source {
start: NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
duration: NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
shuffle: true,
paths: vec![PathBuf::from("assets/")],
},
],
});
config.processing.mode = Playlist;
config.logging.log_to_file = false;
config.logging.timestamp = false;
config.logging.level = LevelFilter::Error;
config.storage.filler = "assets/".into();
config.playlist.length_sec = Some(86400.0);
config.playlist.path = "assets/playlists".into();
let logging = init_logging(&config, None, None);
CombinedLogger::init(logging).unwrap_or_default();
let playlist = generate_playlist(&config, Some("Channel 1".to_string()));
assert!(playlist.is_ok());
let playlist_file = Path::new("assets/playlists/2023/09/2023-09-12.json");
assert!(playlist_file.is_file());
fs::remove_file(playlist_file).unwrap();
let total_duration = sum_durations(&playlist.unwrap()[0].program);
assert!(
total_duration > 86399.0 && total_duration < 86401.0,
"total_duration is {total_duration}"
);
}

View File

@ -17,6 +17,48 @@ fn timed_stop(sec: u64, proc_ctl: ProcessControl) {
proc_ctl.stop_all(); proc_ctl.stop_all();
} }
#[test]
#[serial]
#[ignore]
fn playlist_missing() {
let mut config = PlayoutConfig::new(None);
config.mail.recipient = "".into();
config.processing.mode = Playlist;
config.ingest.enable = false;
config.text.add_text = false;
config.playlist.day_start = "00:00:00".into();
config.playlist.start_sec = Some(0.0);
config.playlist.length = "24:00:00".into();
config.playlist.length_sec = Some(86400.0);
config.playlist.path = "assets/playlists".into();
config.storage.filler = "assets/with_audio.mp4".into();
config.logging.log_to_file = false;
config.logging.timestamp = false;
config.logging.level = LevelFilter::Trace;
config.out.mode = Null;
config.out.output_count = 1;
config.out.output_filter = None;
config.out.output_cmd = Some(vec_strings!["-f", "null", "-"]);
let play_control = PlayerControl::new();
let playout_stat = PlayoutStatus::new();
let proc_control = ProcessControl::new();
let proc_ctl = proc_control.clone();
let logging = init_logging(&config, None, None);
CombinedLogger::init(logging).unwrap_or_default();
mock_time::set_mock_time("2023-02-07T23:59:45");
thread::spawn(move || timed_stop(28, proc_ctl));
player(&config, &play_control, playout_stat.clone(), proc_control);
let playlist_date = &*playout_stat.current_date.lock().unwrap();
assert_eq!(playlist_date, "2023-02-08");
}
#[test] #[test]
#[serial] #[serial]
#[ignore] #[ignore]
@ -34,6 +76,7 @@ fn playlist_change_at_midnight() {
config.storage.filler = "assets/with_audio.mp4".into(); config.storage.filler = "assets/with_audio.mp4".into();
config.logging.log_to_file = false; config.logging.log_to_file = false;
config.logging.timestamp = false; config.logging.timestamp = false;
config.logging.level = LevelFilter::Trace;
config.out.mode = Null; config.out.mode = Null;
config.out.output_count = 1; config.out.output_count = 1;
config.out.output_filter = None; config.out.output_filter = None;
@ -58,6 +101,48 @@ fn playlist_change_at_midnight() {
assert_eq!(playlist_date, "2023-02-09"); assert_eq!(playlist_date, "2023-02-09");
} }
#[test]
#[serial]
#[ignore]
fn playlist_change_before_midnight() {
let mut config = PlayoutConfig::new(None);
config.mail.recipient = "".into();
config.processing.mode = Playlist;
config.ingest.enable = false;
config.text.add_text = false;
config.playlist.day_start = "23:59:45".into();
config.playlist.start_sec = Some(0.0);
config.playlist.length = "24:00:00".into();
config.playlist.length_sec = Some(86400.0);
config.playlist.path = "assets/playlists".into();
config.storage.filler = "assets/with_audio.mp4".into();
config.logging.log_to_file = false;
config.logging.timestamp = false;
config.logging.level = LevelFilter::Trace;
config.out.mode = Null;
config.out.output_count = 1;
config.out.output_filter = None;
config.out.output_cmd = Some(vec_strings!["-f", "null", "-"]);
let play_control = PlayerControl::new();
let playout_stat = PlayoutStatus::new();
let proc_control = ProcessControl::new();
let proc_ctl = proc_control.clone();
let logging = init_logging(&config, None, None);
CombinedLogger::init(logging).unwrap_or_default();
mock_time::set_mock_time("2023-02-08T23:59:30");
thread::spawn(move || timed_stop(35, proc_ctl));
player(&config, &play_control, playout_stat.clone(), proc_control);
let playlist_date = &*playout_stat.current_date.lock().unwrap();
assert_eq!(playlist_date, "2023-02-09");
}
#[test] #[test]
#[serial] #[serial]
#[ignore] #[ignore]

View File

@ -1,3 +1,5 @@
use std::path::PathBuf;
#[cfg(test)] #[cfg(test)]
use chrono::prelude::*; use chrono::prelude::*;
@ -22,23 +24,23 @@ fn mock_date_time() {
fn get_date_yesterday() { fn get_date_yesterday() {
mock_time::set_mock_time("2022-05-20T05:59:24"); mock_time::set_mock_time("2022-05-20T05:59:24");
let date = get_date(true, 21600.0, 86400.0); let date = get_date(true, 21600.0, false);
assert_eq!("2022-05-19".to_string(), date); assert_eq!("2022-05-19".to_string(), date);
} }
#[test] #[test]
fn get_date_tomorrow() { fn get_date_tomorrow() {
mock_time::set_mock_time("2022-05-20T23:59:30"); mock_time::set_mock_time("2022-05-20T23:59:58");
let date = get_date(false, 0.0, 86400.01); let date = get_date(false, 0.0, true);
assert_eq!("2022-05-21".to_string(), date); assert_eq!("2022-05-21".to_string(), date);
} }
#[test] #[test]
fn test_delta() { fn test_delta() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string())); let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.yml")));
config.mail.recipient = "".into(); config.mail.recipient = "".into();
config.processing.mode = Playlist; config.processing.mode = Playlist;
config.playlist.day_start = "00:00:00".into(); config.playlist.day_start = "00:00:00".into();