remove engine and ffpapi, fix tests and dependencies, handle start/stop/restart channel

This commit is contained in:
jb-alvarado 2024-06-11 17:30:53 +02:00
parent c5d179b36a
commit 0dbdd1f46e
79 changed files with 959 additions and 12481 deletions

444
Cargo.lock generated
View File

@ -258,13 +258,13 @@ dependencies = [
[[package]]
name = "actix-web-httpauth"
version = "0.8.1"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d613edf08a42ccc6864c941d30fe14e1b676a77d16f1dbadc1174d065a0a775"
checksum = "456348ed9dcd72a13a1f4a660449fafdecee9ac8205552e286809eb5b0b29bd3"
dependencies = [
"actix-utils",
"actix-web",
"base64 0.21.7",
"base64 0.22.1",
"futures-core",
"futures-util",
"log",
@ -294,7 +294,7 @@ dependencies = [
"futures-util",
"http 0.2.12",
"impl-more",
"itertools 0.12.1",
"itertools",
"local-channel",
"mediatype",
"mime",
@ -320,18 +320,6 @@ dependencies = [
"syn 2.0.66",
]
[[package]]
name = "actix-web-static-files"
version = "4.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adf6d1ef6d7a60e084f9e0595e2a5234abda14e76c105ecf8e2d0e8800c41a1f"
dependencies = [
"actix-web",
"derive_more",
"futures-util",
"static-files",
]
[[package]]
name = "addr2line"
version = "0.22.0"
@ -478,12 +466,6 @@ dependencies = [
"password-hash",
]
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "async-trait"
version = "0.1.80"
@ -689,17 +671,11 @@ dependencies = [
"stacker",
]
[[package]]
name = "chunked_transfer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "clap"
version = "4.5.6"
version = "4.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9689a29b593160de5bc4aacab7b5d54fb52231de70122626c178e6a368994c7"
checksum = "5db83dced34638ad474f39f250d7fea9598bdd239eaced1bdf45d597da0f433f"
dependencies = [
"clap_builder",
"clap_derive",
@ -707,9 +683,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.6"
version = "4.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5387378c84f6faa26890ebf9f0a92989f8873d4d380467bcd0d8d8620424df"
checksum = "f7e204572485eb3fbf28f871612191521df159bc3e15a9f5064c66dba3a8c05f"
dependencies = [
"anstream",
"anstyle",
@ -970,6 +946,17 @@ dependencies = [
"subtle",
]
[[package]]
name = "displaydoc"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.66",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
@ -1072,7 +1059,7 @@ checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a"
[[package]]
name = "ffplayout"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"actix-files",
"actix-multipart",
@ -1080,19 +1067,16 @@ dependencies = [
"actix-web-grants",
"actix-web-httpauth",
"actix-web-lab",
"actix-web-static-files",
"argon2",
"chrono",
"clap",
"crossbeam-channel",
"derive_more",
"faccess",
"ffplayout-lib",
"ffprobe",
"flexi_logger",
"futures-util",
"home",
"itertools 0.13.0",
"jsonwebtoken",
"lazy_static",
"lettre",
@ -1129,101 +1113,6 @@ dependencies = [
"zeromq",
]
[[package]]
name = "ffplayout-api"
version = "0.23.0"
dependencies = [
"actix-files",
"actix-multipart",
"actix-web",
"actix-web-grants",
"actix-web-httpauth",
"actix-web-lab",
"actix-web-static-files",
"argon2",
"chrono",
"clap",
"derive_more",
"faccess",
"ffplayout-lib",
"futures-util",
"home",
"jsonwebtoken",
"lazy_static",
"lexical-sort",
"local-ip-address",
"once_cell",
"parking_lot",
"path-clean",
"rand",
"regex",
"relative-path",
"reqwest",
"rpassword",
"sanitize-filename",
"serde",
"serde_json",
"simplelog",
"sqlx",
"static-files",
"sysinfo",
"tokio",
"tokio-stream",
"toml_edit",
"uuid",
]
[[package]]
name = "ffplayout-engine"
version = "0.23.0"
dependencies = [
"chrono",
"clap",
"crossbeam-channel",
"ffplayout-lib",
"futures",
"itertools 0.13.0",
"notify",
"notify-debouncer-full",
"rand",
"regex",
"reqwest",
"serde",
"serde_json",
"simplelog",
"tiny_http",
"zeromq",
]
[[package]]
name = "ffplayout-lib"
version = "0.23.0"
dependencies = [
"chrono",
"crossbeam-channel",
"derive_more",
"ffprobe",
"file-rotate",
"home",
"lazy_static",
"lettre",
"lexical-sort",
"log",
"num-traits",
"rand",
"regex",
"reqwest",
"serde",
"serde_json",
"shlex",
"signal-child",
"simplelog",
"time",
"toml_edit",
"walkdir",
"winapi",
]
[[package]]
name = "ffprobe"
version = "0.4.0"
@ -1586,12 +1475,12 @@ dependencies = [
[[package]]
name = "http-body-util"
version = "0.1.1"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d"
checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f"
dependencies = [
"bytes",
"futures-core",
"futures-util",
"http 1.1.0",
"http-body",
"pin-project-lite",
@ -1605,9 +1494,9 @@ checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "httparse"
version = "1.8.0"
version = "1.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904"
checksum = "9f3935c160d00ac752e09787e6e6bfc26494c2183cc922f1bc678a60d4733bc2"
[[package]]
name = "httpdate"
@ -1694,6 +1583,124 @@ dependencies = [
"cc",
]
[[package]]
name = "icu_collections"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locid"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_locid_transform"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e"
dependencies = [
"displaydoc",
"icu_locid",
"icu_locid_transform_data",
"icu_provider",
"tinystr",
"zerovec",
]
[[package]]
name = "icu_locid_transform_data"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e"
[[package]]
name = "icu_normalizer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f"
dependencies = [
"displaydoc",
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"utf16_iter",
"utf8_iter",
"write16",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516"
[[package]]
name = "icu_properties"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f8ac670d7422d7f76b32e17a5db556510825b29ec9154f235977c9caba61036"
dependencies = [
"displaydoc",
"icu_collections",
"icu_locid_transform",
"icu_properties_data",
"icu_provider",
"tinystr",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569"
[[package]]
name = "icu_provider"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9"
dependencies = [
"displaydoc",
"icu_locid",
"icu_provider_macros",
"stable_deref_trait",
"tinystr",
"writeable",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_provider_macros"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.66",
]
[[package]]
name = "ident_case"
version = "1.0.1"
@ -1710,6 +1717,18 @@ dependencies = [
"unicode-normalization",
]
[[package]]
name = "idna"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4716a3a0933a1d01c2f72450e89596eb51dd34ef3c211ccd875acdf1f8fe47ed"
dependencies = [
"icu_normalizer",
"icu_properties",
"smallvec",
"utf8_iter",
]
[[package]]
name = "impl-more"
version = "0.1.6"
@ -1778,15 +1797,6 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.11"
@ -1876,7 +1886,7 @@ dependencies = [
"futures-io",
"futures-util",
"httpdate",
"idna",
"idna 0.5.0",
"mime",
"nom",
"percent-encoding",
@ -1928,6 +1938,12 @@ version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
[[package]]
name = "litemap"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704"
[[package]]
name = "local-channel"
version = "0.1.5"
@ -3024,7 +3040,7 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce81b7bd7c4493975347ef60d8c7e8b742d4694f4c49f93e0a12ea263938176c"
dependencies = [
"itertools 0.12.1",
"itertools",
"nom",
"unicode_categories",
]
@ -3222,6 +3238,12 @@ dependencies = [
"urlencoding",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "stacker"
version = "0.1.15"
@ -3375,6 +3397,17 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
[[package]]
name = "synstructure"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.66",
]
[[package]]
name = "sysinfo"
version = "0.30.12"
@ -3413,12 +3446,11 @@ dependencies = [
[[package]]
name = "tests"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"chrono",
"crossbeam-channel",
"ffplayout-engine",
"ffplayout-lib",
"ffplayout",
"ffprobe",
"file-rotate",
"lettre",
@ -3430,8 +3462,9 @@ dependencies = [
"serde_json",
"serial_test",
"shlex",
"simplelog",
"sqlx",
"time",
"tokio",
"toml_edit",
"walkdir",
]
@ -3490,15 +3523,13 @@ dependencies = [
]
[[package]]
name = "tiny_http"
version = "0.12.0"
name = "tinystr"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
dependencies = [
"ascii",
"chunked_transfer",
"httpdate",
"log",
"displaydoc",
"zerovec",
]
[[package]]
@ -3748,12 +3779,12 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.0"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633"
checksum = "f7c25da092f0a868cdf09e8674cd3b7ef3a7d92a24253e663a2fb85e2496de56"
dependencies = [
"form_urlencoded",
"idna",
"idna 1.0.0",
"percent-encoding",
]
@ -3763,6 +3794,18 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf16_iter"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
@ -4166,6 +4209,42 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "write16"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
[[package]]
name = "writeable"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
[[package]]
name = "yoke"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5"
dependencies = [
"serde",
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.66",
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.7.34"
@ -4186,6 +4265,27 @@ dependencies = [
"syn 2.0.66",
]
[[package]]
name = "zerofrom"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.66",
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.1"
@ -4219,6 +4319,28 @@ dependencies = [
"uuid",
]
[[package]]
name = "zerovec"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb2cc8827d6c0994478a15c53f374f46fbd41bea663d809b14744bc42e6b109c"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97cf56601ee5052b4417d90c8755c6683473c926039908196cf35d99f893ebe7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.66",
]
[[package]]
name = "zstd"
version = "0.13.1"

View File

@ -1,10 +1,10 @@
[workspace]
members = ["ffplayout", "ffplayout-api", "ffplayout-engine", "lib", "tests"]
default-members = ["ffplayout", "ffplayout-api", "ffplayout-engine", "tests"]
members = ["ffplayout", "tests"]
default-members = ["ffplayout", "tests"]
resolver = "2"
[workspace.package]
version = "0.23.0"
version = "0.24.0"
license = "GPL-3.0"
repository = "https://github.com/ffplayout/ffplayout"
authors = ["Jonathan Baecker <jonbae77@gmail.com>"]

1
debian/postinst vendored
View File

@ -23,7 +23,6 @@ if [ ! -d "/usr/share/ffplayout/db" ]; then
chown -R ${sysUser}: "/usr/share/ffplayout"
chown -R ${sysUser}: "/var/lib/ffplayout"
chown -R ${sysUser}: "/etc/ffplayout"
fi
if [ ! -d "/var/log/ffplayout" ]; then

2
debian/postrm vendored
View File

@ -6,7 +6,7 @@ sysUser="ffpu"
case "$1" in
abort-install|purge)
deluser $sysUser
rm -rf /usr/share/ffplayout /var/log/ffplayout /etc/ffplayout /var/lib/ffplayout /home/$sysUser
rm -rf /usr/share/ffplayout /var/log/ffplayout /var/lib/ffplayout /home/$sysUser
;;
remove)

View File

@ -1,60 +0,0 @@
[package]
name = "ffplayout-api"
description = "Rest API for ffplayout"
readme = "README.md"
version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
edition.workspace = true
[features]
default = ["embed_frontend"]
embed_frontend = []
[dependencies]
ffplayout-lib = { path = "../lib" }
actix-files = "0.6"
actix-multipart = "0.6"
actix-web = "4"
actix-web-grants = "4"
actix-web-httpauth = "0.8"
actix-web-lab = "0.20"
actix-web-static-files = "4.0"
argon2 = "0.5"
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
clap = { version = "4.3", features = ["derive"] }
derive_more = "0.99"
faccess = "0.2"
futures-util = { version = "0.3", default-features = false, features = ["std"] }
home = "0.5"
jsonwebtoken = "9"
lazy_static = "1.4"
lexical-sort = "0.3"
local-ip-address = "0.6"
once_cell = "1.18"
parking_lot = "0.12"
path-clean = "1.0"
rand = "0.8"
regex = "1"
relative-path = "1.8"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
rpassword = "7.2"
sanitize-filename = "0.5"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
simplelog = { version = "0.12", features = ["paris"] }
static-files = "0.2"
sysinfo ={ version = "0.30", features = ["linux-netdevs"] }
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] }
tokio = { version = "1.29", features = ["full"] }
tokio-stream = "0.1"
toml_edit = {version ="0.22", features = ["serde"]}
uuid = "1.8"
[build-dependencies]
static-files = "0.2"
[[bin]]
name = "ffpapi"
path = "src/main.rs"

View File

@ -1,63 +0,0 @@
**ffplayout-api**
================
ffplayout-api (ffpapi) is a non strict REST API for ffplayout. It makes it possible to control the engine, read and manipulate the config, save playlist, etc.
To be able to use the API it is necessary to initialize the settings database first. To do that, run:
```BASH
ffpapi -i
```
Then add an admin user:
```BASH
ffpapi -u <USERNAME> -p <PASSWORD> -m <MAIL ADDRESS>
```
Then run the API thru the systemd service, or like:
```BASH
ffpapi -l 127.0.0.1:8787
```
Possible Arguments
-----
```BASH
OPTIONS:
-a, --ask ask for user credentials
-d, --domain <DOMAIN> domain name for initialization
-h, --help Print help information
-i, --init Initialize Database
-l, --listen <LISTEN> Listen on IP:PORT, like: 127.0.0.1:8787
-m, --mail <MAIL> Admin mail address
-p, --password <PASSWORD> Admin password
-u, --username <USERNAME> Create admin user
-V, --version Print version information
```
If you plan to run ffpapi with systemd set permission from **/usr/share/ffplayout** and content to user **ffpu:ffpu**. User **ffpu** has to be created.
**For possible endpoints read: [api endpoints](/docs/api.md)**
ffpapi can also serve the browser based frontend, just run in your browser `127.0.0.1:8787`.
"Piggyback" Mode
-----
ffplayout was originally planned to run under Linux as a SystemD service. It is also designed so that the engine and ffpapi run completely independently of each other. This is to increase flexibility and stability.
Nevertheless, programs compiled in Rust can basically run on all systems supported by the language. And so this repo also offers binaries for other platforms.
In the past, however, it was only possible under Linux to start/stop/restart the ffplayout engine process through ffpapi. This limit no longer exists since v0.17.0, because the "piggyback" mode was introduced here. This means that ffpapi recognizes which platform it is running on, and if it is not on Linux, it starts the engine as a child process. Thus it is now possible to control ffplayout engine completely on all platforms. The disadvantage here is that the engine process is dependent on ffpapi; if it closes or crashes, the engine also closes.
Under Linux, this mode can be simulated by starting ffpapi with the environment variable `PIGGYBACK_MODE=true`. This scenario is also conceivable in container operation, for example.
**Run in piggyback mode:**
```BASH
PIGGYBACK_MODE=True ffpapi -l 127.0.0.1:8787
```
This function is experimental, use it with caution.

View File

@ -1,15 +0,0 @@
use static_files::NpmBuild;
fn main() -> std::io::Result<()> {
if !cfg!(debug_assertions) && cfg!(feature = "embed_frontend") {
NpmBuild::new("../ffplayout-frontend")
.install()?
.run("generate")?
.target("../ffplayout-frontend/.output/public")
.change_detection()
.to_resource_dir()
.build()
} else {
Ok(())
}
}

View File

@ -1,46 +0,0 @@
use actix_web::error::ErrorUnauthorized;
use actix_web::Error;
use chrono::{TimeDelta, Utc};
use jsonwebtoken::{self, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use crate::utils::{GlobalSettings, Role};
// Token lifetime
const JWT_EXPIRATION_DAYS: i64 = 7;
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
pub struct Claims {
pub id: i32,
pub username: String,
pub role: Role,
exp: i64,
}
impl Claims {
pub fn new(id: i32, username: String, role: Role) -> Self {
Self {
id,
username,
role,
exp: (Utc::now() + TimeDelta::try_days(JWT_EXPIRATION_DAYS).unwrap()).timestamp(),
}
}
}
/// Create a json web token (JWT)
pub fn create_jwt(claims: Claims) -> Result<String, Error> {
let config = GlobalSettings::global();
let encoding_key = EncodingKey::from_secret(config.secret.as_bytes());
jsonwebtoken::encode(&Header::default(), &claims, &encoding_key)
.map_err(|e| ErrorUnauthorized(e.to_string()))
}
/// Decode a json web token (JWT)
pub async fn decode_jwt(token: &str) -> Result<Claims, Error> {
let config = GlobalSettings::global();
let decoding_key = DecodingKey::from_secret(config.secret.as_bytes());
jsonwebtoken::decode::<Claims>(token, &decoding_key, &Validation::default())
.map(|data| data.claims)
.map_err(|e| ErrorUnauthorized(e.to_string()))
}

View File

@ -1,2 +0,0 @@
pub mod auth;
pub mod routes;

File diff suppressed because it is too large Load Diff

View File

@ -1,352 +0,0 @@
use std::env;
use argon2::{
password_hash::{rand_core::OsRng, SaltString},
Argon2, PasswordHasher,
};
use rand::{distributions::Alphanumeric, Rng};
use simplelog::*;
use sqlx::{migrate::MigrateDatabase, sqlite::SqliteQueryResult, Pool, Sqlite};
use tokio::task;
use crate::db::{
db_pool,
models::{Channel, TextPreset, User},
};
use crate::utils::{db_path, local_utc_offset, GlobalSettings, Role};
async fn create_schema(conn: &Pool<Sqlite>) -> Result<SqliteQueryResult, sqlx::Error> {
let query = "PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS global
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
secret TEXT NOT NULL,
UNIQUE(secret)
);
CREATE TABLE IF NOT EXISTS roles
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
UNIQUE(name)
);
CREATE TABLE IF NOT EXISTS channels
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
preview_url TEXT NOT NULL,
config_path TEXT NOT NULL,
extra_extensions TEXT NOT NULL,
service TEXT NOT NULL,
UNIQUE(name, service)
);
CREATE TABLE IF NOT EXISTS presets
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
text TEXT NOT NULL,
x TEXT NOT NULL,
y TEXT NOT NULL,
fontsize TEXT NOT NULL,
line_spacing TEXT NOT NULL,
fontcolor TEXT NOT NULL,
box TEXT NOT NULL,
boxcolor TEXT NOT NULL,
boxborderw TEXT NOT NULL,
alpha TEXT NOT NULL,
channel_id INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (channel_id) REFERENCES channels (id) ON UPDATE SET NULL ON DELETE SET NULL,
UNIQUE(name)
);
CREATE TABLE IF NOT EXISTS user
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
mail TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
role_id INTEGER NOT NULL DEFAULT 2,
channel_id INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (role_id) REFERENCES roles (id) ON UPDATE SET NULL ON DELETE SET NULL,
FOREIGN KEY (channel_id) REFERENCES channels (id) ON UPDATE SET NULL ON DELETE SET NULL,
UNIQUE(mail, username)
);";
sqlx::query(query).execute(conn).await
}
pub async fn db_init(domain: Option<String>) -> Result<&'static str, Box<dyn std::error::Error>> {
let db_path = db_path()?;
if !Sqlite::database_exists(db_path).await.unwrap_or(false) {
Sqlite::create_database(db_path).await.unwrap();
let pool = db_pool().await?;
match create_schema(&pool).await {
Ok(_) => info!("Database created Successfully"),
Err(e) => panic!("{e}"),
}
}
let secret: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(80)
.map(char::from)
.collect();
let url = match domain {
Some(d) => format!("http://{d}/live/stream.m3u8"),
None => "http://localhost/live/stream.m3u8".to_string(),
};
let config_path = if env::consts::OS == "linux" {
"/etc/ffplayout/ffplayout.toml"
} else {
"./assets/ffplayout.toml"
};
let query = "CREATE TRIGGER global_row_count
BEFORE INSERT ON global
WHEN (SELECT COUNT(*) FROM global) >= 1
BEGIN
SELECT RAISE(FAIL, 'Database is already initialized!');
END;
INSERT INTO global(secret) VALUES($1);
INSERT INTO channels(name, preview_url, config_path, extra_extensions, service)
VALUES('Channel 1', $2, $3, 'jpg,jpeg,png', 'ffplayout.service');
INSERT INTO roles(name) VALUES('admin'), ('user'), ('guest');
INSERT INTO presets(name, text, x, y, fontsize, line_spacing, fontcolor, box, boxcolor, boxborderw, alpha, channel_id)
VALUES('Default', 'Wellcome to ffplayout messenger!', '(w-text_w)/2', '(h-text_h)/2', '24', '4', '#ffffff@0xff', '0', '#000000@0x80', '4', '1.0', '1'),
('Empty Text', '', '0', '0', '24', '4', '#000000', '0', '#000000', '0', '0', '1'),
('Bottom Text fade in', 'The upcoming event will be delayed by a few minutes.', '(w-text_w)/2', '(h-line_h)*0.9', '24', '4', '#ffffff',
'1', '#000000@0x80', '4', 'ifnot(ld(1),st(1,t));if(lt(t,ld(1)+1),0,if(lt(t,ld(1)+2),(t-(ld(1)+1))/1,if(lt(t,ld(1)+8),1,if(lt(t,ld(1)+9),(1-(t-(ld(1)+8)))/1,0))))', '1'),
('Scrolling Text', 'We have a very important announcement to make.', 'ifnot(ld(1),st(1,t));if(lt(t,ld(1)+1),w+4,w-w/12*mod(t-ld(1),12*(w+tw)/w))', '(h-line_h)*0.9',
'24', '4', '#ffffff', '1', '#000000@0x80', '4', '1.0', '1');";
let pool = db_pool().await?;
sqlx::query(query)
.bind(secret)
.bind(url)
.bind(config_path)
.execute(&pool)
.await?;
Ok("Database initialized!")
}
pub async fn select_global(conn: &Pool<Sqlite>) -> Result<GlobalSettings, sqlx::Error> {
let query = "SELECT secret FROM global WHERE id = 1";
sqlx::query_as(query).fetch_one(conn).await
}
pub async fn select_channel(conn: &Pool<Sqlite>, id: &i32) -> Result<Channel, sqlx::Error> {
let query = "SELECT * FROM channels WHERE id = $1";
let mut result: Channel = sqlx::query_as(query).bind(id).fetch_one(conn).await?;
result.utc_offset = local_utc_offset();
Ok(result)
}
pub async fn select_all_channels(conn: &Pool<Sqlite>) -> Result<Vec<Channel>, sqlx::Error> {
let query = "SELECT * FROM channels";
let mut results: Vec<Channel> = sqlx::query_as(query).fetch_all(conn).await?;
for result in results.iter_mut() {
result.utc_offset = local_utc_offset();
}
Ok(results)
}
pub async fn update_channel(
conn: &Pool<Sqlite>,
id: i32,
channel: Channel,
) -> Result<SqliteQueryResult, sqlx::Error> {
let query = "UPDATE channels SET name = $2, preview_url = $3, config_path = $4, extra_extensions = $5 WHERE id = $1";
sqlx::query(query)
.bind(id)
.bind(channel.name)
.bind(channel.preview_url)
.bind(channel.config_path)
.bind(channel.extra_extensions)
.execute(conn)
.await
}
pub async fn insert_channel(conn: &Pool<Sqlite>, channel: Channel) -> Result<Channel, sqlx::Error> {
let query = "INSERT INTO channels (name, preview_url, config_path, extra_extensions, service) VALUES($1, $2, $3, $4, $5)";
let result = sqlx::query(query)
.bind(channel.name)
.bind(channel.preview_url)
.bind(channel.config_path)
.bind(channel.extra_extensions)
.bind(channel.service)
.execute(conn)
.await?;
sqlx::query_as("SELECT * FROM channels WHERE id = $1")
.bind(result.last_insert_rowid())
.fetch_one(conn)
.await
}
pub async fn delete_channel(
conn: &Pool<Sqlite>,
id: &i32,
) -> Result<SqliteQueryResult, sqlx::Error> {
let query = "DELETE FROM channels WHERE id = $1";
sqlx::query(query).bind(id).execute(conn).await
}
pub async fn select_last_channel(conn: &Pool<Sqlite>) -> Result<i32, sqlx::Error> {
let query = "SELECT id FROM channels ORDER BY id DESC LIMIT 1;";
sqlx::query_scalar(query).fetch_one(conn).await
}
pub async fn select_role(conn: &Pool<Sqlite>, id: &i32) -> Result<Role, sqlx::Error> {
let query = "SELECT name FROM roles WHERE id = $1";
let result: Role = sqlx::query_as(query).bind(id).fetch_one(conn).await?;
Ok(result)
}
pub async fn select_login(conn: &Pool<Sqlite>, user: &str) -> Result<User, sqlx::Error> {
let query = "SELECT id, mail, username, password, role_id FROM user WHERE username = $1";
sqlx::query_as(query).bind(user).fetch_one(conn).await
}
pub async fn select_user(conn: &Pool<Sqlite>, user: &str) -> Result<User, sqlx::Error> {
let query = "SELECT id, mail, username, role_id FROM user WHERE username = $1";
sqlx::query_as(query).bind(user).fetch_one(conn).await
}
pub async fn select_user_by_id(conn: &Pool<Sqlite>, id: i32) -> Result<User, sqlx::Error> {
let query = "SELECT id, mail, username, role_id FROM user WHERE id = $1";
sqlx::query_as(query).bind(id).fetch_one(conn).await
}
pub async fn select_users(conn: &Pool<Sqlite>) -> Result<Vec<User>, sqlx::Error> {
let query = "SELECT id, username FROM user";
sqlx::query_as(query).fetch_all(conn).await
}
pub async fn insert_user(
conn: &Pool<Sqlite>,
user: User,
) -> Result<SqliteQueryResult, sqlx::Error> {
let password_hash = task::spawn_blocking(move || {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(user.password.clone().as_bytes(), &salt)
.unwrap();
hash.to_string()
})
.await
.unwrap();
let query = "INSERT INTO user (mail, username, password, role_id) VALUES($1, $2, $3, $4)";
sqlx::query(query)
.bind(user.mail)
.bind(user.username)
.bind(password_hash)
.bind(user.role_id)
.execute(conn)
.await
}
pub async fn update_user(
conn: &Pool<Sqlite>,
id: i32,
fields: String,
) -> Result<SqliteQueryResult, sqlx::Error> {
let query = format!("UPDATE user SET {fields} WHERE id = $1");
sqlx::query(&query).bind(id).execute(conn).await
}
pub async fn delete_user(
conn: &Pool<Sqlite>,
name: &str,
) -> Result<SqliteQueryResult, sqlx::Error> {
let query = "DELETE FROM user WHERE username = $1;";
sqlx::query(query).bind(name).execute(conn).await
}
pub async fn select_presets(conn: &Pool<Sqlite>, id: i32) -> Result<Vec<TextPreset>, sqlx::Error> {
let query = "SELECT * FROM presets WHERE channel_id = $1";
sqlx::query_as(query).bind(id).fetch_all(conn).await
}
pub async fn update_preset(
conn: &Pool<Sqlite>,
id: &i32,
preset: TextPreset,
) -> Result<SqliteQueryResult, sqlx::Error> {
let query =
"UPDATE presets SET name = $1, text = $2, x = $3, y = $4, fontsize = $5, line_spacing = $6,
fontcolor = $7, alpha = $8, box = $9, boxcolor = $10, boxborderw = $11 WHERE id = $12";
sqlx::query(query)
.bind(preset.name)
.bind(preset.text)
.bind(preset.x)
.bind(preset.y)
.bind(preset.fontsize)
.bind(preset.line_spacing)
.bind(preset.fontcolor)
.bind(preset.alpha)
.bind(preset.r#box)
.bind(preset.boxcolor)
.bind(preset.boxborderw)
.bind(id)
.execute(conn)
.await
}
pub async fn insert_preset(
conn: &Pool<Sqlite>,
preset: TextPreset,
) -> Result<SqliteQueryResult, sqlx::Error> {
let query =
"INSERT INTO presets (channel_id, name, text, x, y, fontsize, line_spacing, fontcolor, alpha, box, boxcolor, boxborderw)
VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)";
sqlx::query(query)
.bind(preset.channel_id)
.bind(preset.name)
.bind(preset.text)
.bind(preset.x)
.bind(preset.y)
.bind(preset.fontsize)
.bind(preset.line_spacing)
.bind(preset.fontcolor)
.bind(preset.alpha)
.bind(preset.r#box)
.bind(preset.boxcolor)
.bind(preset.boxborderw)
.execute(conn)
.await
}
pub async fn delete_preset(
conn: &Pool<Sqlite>,
id: &i32,
) -> Result<SqliteQueryResult, sqlx::Error> {
let query = "DELETE FROM presets WHERE id = $1;";
sqlx::query(query).bind(id).execute(conn).await
}

View File

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

View File

@ -1,118 +0,0 @@
use regex::Regex;
use serde::{
de::{self, Visitor},
Deserialize, Serialize,
};
#[derive(Debug, Deserialize, Serialize, sqlx::FromRow)]
pub struct User {
#[sqlx(default)]
#[serde(skip_deserializing)]
pub id: i32,
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub mail: Option<String>,
pub username: String,
#[sqlx(default)]
#[serde(skip_serializing, default = "empty_string")]
pub password: String,
#[sqlx(default)]
#[serde(skip_serializing)]
pub role_id: Option<i32>,
#[sqlx(default)]
#[serde(skip_serializing)]
pub channel_id: Option<i32>,
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
}
fn empty_string() -> String {
"".to_string()
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct LoginUser {
pub id: i32,
pub username: String,
}
impl LoginUser {
pub fn new(id: i32, username: String) -> Self {
Self { id, username }
}
}
#[derive(Debug, Deserialize, Serialize, Clone, sqlx::FromRow)]
pub struct TextPreset {
#[sqlx(default)]
#[serde(skip_deserializing)]
pub id: i32,
pub channel_id: i32,
pub name: String,
pub text: String,
pub x: String,
pub y: String,
#[serde(deserialize_with = "deserialize_number_or_string")]
pub fontsize: String,
#[serde(deserialize_with = "deserialize_number_or_string")]
pub line_spacing: String,
pub fontcolor: String,
pub r#box: String,
pub boxcolor: String,
#[serde(deserialize_with = "deserialize_number_or_string")]
pub boxborderw: String,
#[serde(deserialize_with = "deserialize_number_or_string")]
pub alpha: String,
}
/// Deserialize number or string
pub fn deserialize_number_or_string<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
struct StringOrNumberVisitor;
impl<'de> Visitor<'de> for StringOrNumberVisitor {
type Value = String;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string or a number")
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
let re = Regex::new(r"0,([0-9]+)").unwrap();
let clean_string = re.replace_all(value, "0.$1").to_string();
Ok(clean_string)
}
fn visit_u64<E: de::Error>(self, value: u64) -> Result<Self::Value, E> {
Ok(value.to_string())
}
fn visit_i64<E: de::Error>(self, value: i64) -> Result<Self::Value, E> {
Ok(value.to_string())
}
fn visit_f64<E: de::Error>(self, value: f64) -> Result<Self::Value, E> {
Ok(value.to_string())
}
}
deserializer.deserialize_any(StringOrNumberVisitor)
}
#[derive(Debug, Deserialize, Serialize, sqlx::FromRow)]
pub struct Channel {
#[serde(skip_deserializing)]
pub id: i32,
pub name: String,
pub preview_url: String,
pub config_path: String,
pub extra_extensions: String,
pub service: String,
#[sqlx(default)]
#[serde(default)]
pub utc_offset: i32,
}

View File

@ -1,21 +0,0 @@
use std::sync::{Arc, Mutex};
use clap::Parser;
use lazy_static::lazy_static;
use sysinfo::{Disks, Networks, System};
pub mod api;
pub mod db;
pub mod sse;
pub mod utils;
use utils::args_parse::Args;
lazy_static! {
pub static ref ARGS: Args = Args::parse();
pub static ref DISKS: Arc<Mutex<Disks>> =
Arc::new(Mutex::new(Disks::new_with_refreshed_list()));
pub static ref NETWORKS: Arc<Mutex<Networks>> =
Arc::new(Mutex::new(Networks::new_with_refreshed_list()));
pub static ref SYS: Arc<Mutex<System>> = Arc::new(Mutex::new(System::new_all()));
}

View File

@ -1,194 +0,0 @@
use std::{collections::HashSet, env, process::exit, sync::Arc};
use actix_files::Files;
use actix_web::{
dev::ServiceRequest, middleware::Logger, web, App, Error, HttpMessage, HttpServer,
};
use actix_web_grants::authorities::AttachAuthorities;
use actix_web_httpauth::{extractors::bearer::BearerAuth, middleware::HttpAuthentication};
#[cfg(all(not(debug_assertions), feature = "embed_frontend"))]
use actix_web_static_files::ResourceFiles;
use path_clean::PathClean;
use simplelog::*;
use tokio::sync::Mutex;
use ffplayout_api::{
api::{auth, routes::*},
db::{db_pool, models::LoginUser},
sse::{broadcast::Broadcaster, routes::*, AuthState},
utils::{control::ProcessControl, db_path, init_config, run_args},
ARGS,
};
#[cfg(any(debug_assertions, not(feature = "embed_frontend")))]
use ffplayout_api::utils::public_path;
use ffplayout_lib::utils::{init_logging, PlayoutConfig};
#[cfg(all(not(debug_assertions), feature = "embed_frontend"))]
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
async fn validator(
req: ServiceRequest,
credentials: BearerAuth,
) -> Result<ServiceRequest, (Error, ServiceRequest)> {
// We just get permissions from JWT
match auth::decode_jwt(credentials.token()).await {
Ok(claims) => {
req.attach(vec![claims.role]);
req.extensions_mut()
.insert(LoginUser::new(claims.id, claims.username));
Ok(req)
}
Err(e) => Err((e, req)),
}
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let mut config = PlayoutConfig::new(None, None);
config.mail.recipient = String::new();
config.logging.log_to_file = false;
config.logging.timestamp = false;
let logging = init_logging(&config, None, None);
CombinedLogger::init(logging).unwrap();
if let Err(c) = run_args().await {
exit(c);
}
let pool = match db_pool().await {
Ok(p) => p,
Err(e) => {
error!("{e}");
exit(1);
}
};
if let Some(conn) = &ARGS.listen {
if db_path().is_err() {
error!("Database is not initialized! Init DB first and add admin user.");
exit(1);
}
init_config(&pool).await;
let ip_port = conn.split(':').collect::<Vec<&str>>();
let addr = ip_port[0];
let port = ip_port[1].parse::<u16>().unwrap();
let engine_process = web::Data::new(ProcessControl::new());
let auth_state = web::Data::new(AuthState {
uuids: Mutex::new(HashSet::new()),
});
let broadcast_data = Broadcaster::create();
info!("running ffplayout API, listen on http://{conn}");
// no 'allow origin' here, give it to the reverse proxy
HttpServer::new(move || {
let auth = HttpAuthentication::bearer(validator);
let db_pool = web::Data::new(pool.clone());
// Customize logging format to get IP though proxies.
let logger = Logger::new("%{r}a \"%r\" %s %b \"%{Referer}i\" \"%{User-Agent}i\" %T")
.exclude_regex(r"/_nuxt/*");
let mut web_app = App::new()
.app_data(db_pool)
.app_data(engine_process.clone())
.app_data(auth_state.clone())
.app_data(web::Data::from(Arc::clone(&broadcast_data)))
.wrap(logger)
.service(login)
.service(
web::scope("/api")
.wrap(auth.clone())
.service(add_user)
.service(get_user)
.service(get_by_name)
.service(get_users)
.service(remove_user)
.service(get_playout_config)
.service(update_playout_config)
.service(add_preset)
.service(get_presets)
.service(update_preset)
.service(delete_preset)
.service(get_channel)
.service(get_all_channels)
.service(patch_channel)
.service(add_channel)
.service(remove_channel)
.service(update_user)
.service(send_text_message)
.service(control_playout)
.service(media_current)
.service(media_next)
.service(media_last)
.service(process_control)
.service(get_playlist)
.service(save_playlist)
.service(gen_playlist)
.service(del_playlist)
.service(get_log)
.service(file_browser)
.service(add_dir)
.service(move_rename)
.service(remove)
.service(save_file)
.service(import_playlist)
.service(get_program)
.service(get_system_stat)
.service(generate_uuid),
)
.service(
web::scope("/data")
.service(validate_uuid)
.service(event_stream),
)
.service(get_file);
if let Some(public) = &ARGS.public {
// When public path is set as argument use this path for serving extra static files,
// is useful for HLS stream etc.
let absolute_path = if public.is_absolute() {
public.to_path_buf()
} else {
env::current_dir().unwrap_or_default().join(public)
}
.clean();
web_app = web_app.service(Files::new("/", absolute_path));
} else {
// When no public path is given as argument, use predefine keywords in path,
// like /live; /preview; /public, or HLS extensions to recognize file should get from public folder
web_app = web_app.service(get_public);
}
#[cfg(all(not(debug_assertions), feature = "embed_frontend"))]
{
// in release mode embed frontend
let generated = generate();
web_app =
web_app.service(ResourceFiles::new("/", generated).resolve_not_found_to_root());
}
#[cfg(any(debug_assertions, not(feature = "embed_frontend")))]
{
// in debug mode get frontend from path
web_app = web_app.service(Files::new("/", public_path()).index_file("index.html"));
}
web_app
})
.bind((addr, port))?
.run()
.await
} else {
error!("Run ffpapi with listen parameter!");
Ok(())
}
}

View File

@ -1,160 +0,0 @@
use std::{sync::Arc, time::Duration};
use actix_web::{rt::time::interval, web};
use actix_web_lab::{
sse::{self, Sse},
util::InfallibleStream,
};
use ffplayout_lib::utils::PlayoutConfig;
use parking_lot::Mutex;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use crate::utils::{control::media_info, system};
#[derive(Debug, Clone)]
struct Client {
_channel: i32,
config: PlayoutConfig,
endpoint: String,
sender: mpsc::Sender<sse::Event>,
}
impl Client {
fn new(
_channel: i32,
config: PlayoutConfig,
endpoint: String,
sender: mpsc::Sender<sse::Event>,
) -> Self {
Self {
_channel,
config,
endpoint,
sender,
}
}
}
pub struct Broadcaster {
inner: Mutex<BroadcasterInner>,
}
#[derive(Debug, Clone, Default)]
struct BroadcasterInner {
clients: Vec<Client>,
}
impl Broadcaster {
/// Constructs new broadcaster and spawns ping loop.
pub fn create() -> Arc<Self> {
let this = Arc::new(Broadcaster {
inner: Mutex::new(BroadcasterInner::default()),
});
Broadcaster::spawn_ping(Arc::clone(&this));
this
}
/// Pings clients every 10 seconds to see if they are alive and remove them from the broadcast
/// list if not.
fn spawn_ping(this: Arc<Self>) {
actix_web::rt::spawn(async move {
let mut interval = interval(Duration::from_secs(1));
let mut counter = 0;
loop {
interval.tick().await;
if counter % 10 == 0 {
this.remove_stale_clients().await;
}
this.broadcast_playout().await;
this.broadcast_system().await;
counter = (counter + 1) % 61;
}
});
}
/// Removes all non-responsive clients from broadcast list.
async fn remove_stale_clients(&self) {
let clients = self.inner.lock().clients.clone();
let mut ok_clients = Vec::new();
for client in clients {
if client
.sender
.send(sse::Event::Comment("ping".into()))
.await
.is_ok()
{
ok_clients.push(client.clone());
}
}
self.inner.lock().clients = ok_clients;
}
/// Registers client with broadcaster, returning an SSE response body.
pub async fn new_client(
&self,
channel: i32,
config: PlayoutConfig,
endpoint: String,
) -> Sse<InfallibleStream<ReceiverStream<sse::Event>>> {
let (tx, rx) = mpsc::channel(10);
tx.send(sse::Data::new("connected").into()).await.unwrap();
self.inner
.lock()
.clients
.push(Client::new(channel, config, endpoint, tx));
Sse::from_infallible_receiver(rx)
}
/// Broadcasts playout status to clients.
pub async fn broadcast_playout(&self) {
let clients = self.inner.lock().clients.clone();
for client in clients.iter().filter(|client| client.endpoint == "playout") {
match media_info(&client.config, "current".into()).await {
Ok(res) => {
let _ = client
.sender
.send(
sse::Data::new(res.text().await.unwrap_or_else(|_| "Success".into()))
.into(),
)
.await;
}
Err(_) => {
let _ = client
.sender
.send(sse::Data::new("not running").into())
.await;
}
};
}
}
/// Broadcasts system status to clients.
pub async fn broadcast_system(&self) {
let clients = self.inner.lock().clients.clone();
for client in clients {
if &client.endpoint == "system" {
if let Ok(stat) = web::block(move || system::stat(client.config.clone())).await {
let stat_string = stat.to_string();
let _ = client.sender.send(sse::Data::new(stat_string).into()).await;
};
}
}
}
}

View File

@ -1,55 +0,0 @@
use std::{
collections::HashSet,
time::{Duration, SystemTime},
};
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::utils::errors::ServiceError;
pub mod broadcast;
pub mod routes;
#[derive(Debug, Eq, Hash, PartialEq, Clone, Copy)]
pub struct UuidData {
pub uuid: Uuid,
pub expiration: SystemTime,
}
impl UuidData {
pub fn new() -> Self {
Self {
uuid: Uuid::new_v4(),
expiration: SystemTime::now() + Duration::from_secs(2 * 3600), // 2 hours
}
}
}
impl Default for UuidData {
fn default() -> Self {
Self::new()
}
}
pub struct AuthState {
pub uuids: Mutex<HashSet<UuidData>>,
}
/// Remove all UUIDs from HashSet which are older the expiration time.
pub fn prune_uuids(uuids: &mut HashSet<UuidData>) {
uuids.retain(|entry| entry.expiration > SystemTime::now());
}
pub fn check_uuid(uuids: &mut HashSet<UuidData>, uuid: &str) -> Result<&'static str, ServiceError> {
let client_uuid = Uuid::parse_str(uuid)?;
prune_uuids(uuids);
match uuids.iter().find(|entry| entry.uuid == client_uuid) {
Some(_) => Ok("UUID is valid"),
None => Err(ServiceError::Unauthorized(
"Invalid or expired UUID".to_string(),
)),
}
}

View File

@ -1,82 +0,0 @@
use actix_web::{get, post, web, Responder};
use actix_web_grants::proc_macro::protect;
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Sqlite};
use super::{check_uuid, prune_uuids, AuthState, UuidData};
use crate::sse::broadcast::Broadcaster;
use crate::utils::{errors::ServiceError, playout_config, Role};
#[derive(Deserialize, Serialize)]
struct User {
#[serde(default, skip_serializing)]
endpoint: String,
uuid: String,
}
impl User {
fn new(endpoint: String, uuid: String) -> Self {
Self { endpoint, uuid }
}
}
/// **Get generated UUID**
///
/// ```BASH
/// curl -X GET 'http://127.0.0.1:8787/api/generate-uuid' -H 'Authorization: Bearer <TOKEN>'
/// ```
#[post("/generate-uuid")]
#[protect(any("Role::Admin", "Role::User"), ty = "Role")]
async fn generate_uuid(data: web::Data<AuthState>) -> Result<impl Responder, ServiceError> {
let mut uuids = data.uuids.lock().await;
let new_uuid = UuidData::new();
let user_auth = User::new(String::new(), new_uuid.uuid.to_string());
prune_uuids(&mut uuids);
uuids.insert(new_uuid);
Ok(web::Json(user_auth))
}
/// **Validate UUID**
///
/// ```BASH
/// curl -X GET 'http://127.0.0.1:8787/data/validate?uuid=f2f8c29b-712a-48c5-8919-b535d3a05a3a'
/// ```
#[get("/validate")]
async fn validate_uuid(
data: web::Data<AuthState>,
user: web::Query<User>,
) -> Result<impl Responder, ServiceError> {
let mut uuids = data.uuids.lock().await;
match check_uuid(&mut uuids, user.uuid.as_str()) {
Ok(s) => Ok(web::Json(s)),
Err(e) => Err(e),
}
}
/// **Connect to event handler**
///
/// ```BASH
/// curl -X GET 'http://127.0.0.1:8787/data/event/1?endpoint=system&uuid=f2f8c29b-712a-48c5-8919-b535d3a05a3a'
/// ```
#[get("/event/{channel}")]
async fn event_stream(
pool: web::Data<Pool<Sqlite>>,
broadcaster: web::Data<Broadcaster>,
data: web::Data<AuthState>,
id: web::Path<i32>,
user: web::Query<User>,
) -> Result<impl Responder, ServiceError> {
let mut uuids = data.uuids.lock().await;
check_uuid(&mut uuids, user.uuid.as_str())?;
let (config, _) = playout_config(&pool.clone().into_inner(), &id).await?;
Ok(broadcaster
.new_client(*id, config, user.endpoint.clone())
.await)
}

View File

@ -1,36 +0,0 @@
use std::path::PathBuf;
use clap::Parser;
#[derive(Parser, Debug, Clone)]
#[clap(version,
about = "REST API for ffplayout",
long_about = None)]
pub struct Args {
#[clap(short, long, help = "ask for user credentials")]
pub ask: bool,
#[clap(long, help = "path to database file")]
pub db: Option<PathBuf>,
#[clap(long, help = "path to public files")]
pub public: Option<PathBuf>,
#[clap(short, long, help = "Listen on IP:PORT, like: 127.0.0.1:8787")]
pub listen: Option<String>,
#[clap(short, long, help = "Initialize Database")]
pub init: bool,
#[clap(short, long, help = "domain name for initialization")]
pub domain: Option<String>,
#[clap(short, long, help = "Create admin user")]
pub username: Option<String>,
#[clap(short, long, help = "Admin mail address")]
pub mail: Option<String>,
#[clap(short, long, help = "Admin password")]
pub password: Option<String>,
}

View File

@ -1,74 +0,0 @@
use std::{fs, path::PathBuf};
use rand::prelude::*;
use simplelog::*;
use sqlx::{Pool, Sqlite};
use crate::utils::{
control::{control_service, ServiceCmd},
errors::ServiceError,
};
use ffplayout_lib::utils::PlayoutConfig;
use crate::db::{handles, models::Channel};
use crate::utils::playout_config;
pub async fn create_channel(
conn: &Pool<Sqlite>,
target_channel: Channel,
) -> Result<Channel, ServiceError> {
if !target_channel.service.starts_with("ffplayout@") {
return Err(ServiceError::BadRequest("Bad service name!".to_string()));
}
if !target_channel.config_path.starts_with("/etc/ffplayout") {
return Err(ServiceError::BadRequest("Bad config path!".to_string()));
}
let channel_name = target_channel.name.to_lowercase().replace(' ', "");
let channel_num = match handles::select_last_channel(conn).await {
Ok(num) => num + 1,
Err(_) => rand::thread_rng().gen_range(71..99),
};
let mut config = PlayoutConfig::new(
Some(PathBuf::from("/usr/share/ffplayout/ffplayout.toml.orig")),
None,
);
config.general.stat_file = format!(".ffp_{channel_name}",);
config.logging.path = config.logging.path.join(&channel_name);
config.rpc_server.address = format!("127.0.0.1:70{:7>2}", channel_num);
config.playlist.path = config.playlist.path.join(channel_name);
config.out.output_param = config
.out
.output_param
.replace("stream.m3u8", &format!("stream{channel_num}.m3u8"))
.replace("stream-%d.ts", &format!("stream{channel_num}-%d.ts"));
let toml_string = toml_edit::ser::to_string(&config)?;
fs::write(&target_channel.config_path, toml_string)?;
let new_channel = handles::insert_channel(conn, target_channel).await?;
control_service(conn, &config, new_channel.id, &ServiceCmd::Enable, None).await?;
Ok(new_channel)
}
pub async fn delete_channel(conn: &Pool<Sqlite>, id: i32) -> Result<(), ServiceError> {
let channel = handles::select_channel(conn, &id).await?;
let (config, _) = playout_config(conn, &id).await?;
control_service(conn, &config, channel.id, &ServiceCmd::Stop, None).await?;
control_service(conn, &config, channel.id, &ServiceCmd::Disable, None).await?;
if let Err(e) = fs::remove_file(channel.config_path) {
error!("{e}");
};
handles::delete_channel(conn, &id).await?;
Ok(())
}

View File

@ -1,345 +0,0 @@
use std::{
collections::HashMap,
env, fmt,
str::FromStr,
sync::atomic::{AtomicBool, Ordering},
};
use actix_web::web;
use reqwest::{header::AUTHORIZATION, Client, Response};
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Sqlite};
use tokio::{
process::{Child, Command},
sync::Mutex,
};
use crate::db::handles::select_channel;
use crate::utils::errors::ServiceError;
use ffplayout_lib::{utils::PlayoutConfig, vec_strings};
#[derive(Debug, Deserialize, Serialize, Clone)]
struct TextParams {
control: String,
message: HashMap<String, String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ControlParams {
pub control: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct MediaParams {
media: String,
}
/// ffplayout engine process
///
/// When running not on Linux, or with environment variable `PIGGYBACK_MODE=true`,
/// the engine get startet and controlled from ffpapi
pub struct ProcessControl {
pub engine_child: Mutex<Option<Child>>,
pub is_running: AtomicBool,
pub piggyback: AtomicBool,
}
impl ProcessControl {
pub fn new() -> Self {
let piggyback = if env::consts::OS != "linux" || env::var("PIGGYBACK_MODE").is_ok() {
AtomicBool::new(true)
} else {
AtomicBool::new(false)
};
Self {
engine_child: Mutex::new(None),
is_running: AtomicBool::new(false),
piggyback,
}
}
}
impl ProcessControl {
pub async fn start(&self) -> Result<String, ServiceError> {
#[cfg(not(debug_assertions))]
let engine_path = "ffplayout";
#[cfg(debug_assertions)]
let engine_path = "./target/debug/ffplayout";
match Command::new(engine_path).kill_on_drop(true).spawn() {
Ok(proc) => *self.engine_child.lock().await = Some(proc),
Err(_) => return Err(ServiceError::InternalServerError),
};
self.is_running.store(true, Ordering::SeqCst);
Ok("Success".to_string())
}
pub async fn stop(&self) -> Result<String, ServiceError> {
if let Some(proc) = self.engine_child.lock().await.as_mut() {
if proc.kill().await.is_err() {
return Err(ServiceError::InternalServerError);
};
}
self.wait().await?;
self.is_running.store(false, Ordering::SeqCst);
Ok("Success".to_string())
}
pub async fn restart(&self) -> Result<String, ServiceError> {
self.stop().await?;
self.start().await?;
self.is_running.store(true, Ordering::SeqCst);
Ok("Success".to_string())
}
/// Wait for process to proper close.
/// This prevents orphaned/zombi processes in system
pub async fn wait(&self) -> Result<String, ServiceError> {
if let Some(proc) = self.engine_child.lock().await.as_mut() {
if proc.wait().await.is_err() {
return Err(ServiceError::InternalServerError);
};
}
Ok("Success".to_string())
}
pub fn status(&self) -> Result<String, ServiceError> {
if self.is_running.load(Ordering::SeqCst) {
Ok("active".to_string())
} else {
Ok("not running".to_string())
}
}
}
impl Default for ProcessControl {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ServiceCmd {
Enable,
Disable,
Start,
Stop,
Restart,
Status,
}
impl FromStr for ServiceCmd {
type Err = String;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match input.to_lowercase().as_str() {
"enable" => Ok(Self::Enable),
"disable" => Ok(Self::Disable),
"start" => Ok(Self::Start),
"stop" => Ok(Self::Stop),
"restart" => Ok(Self::Restart),
"status" => Ok(Self::Status),
_ => Err(format!("Command '{input}' not found!")),
}
}
}
impl fmt::Display for ServiceCmd {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
Self::Enable => write!(f, "enable"),
Self::Disable => write!(f, "disable"),
Self::Start => write!(f, "start"),
Self::Stop => write!(f, "stop"),
Self::Restart => write!(f, "restart"),
Self::Status => write!(f, "status"),
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Process {
pub command: ServiceCmd,
}
struct SystemD {
service: String,
cmd: Vec<String>,
}
impl SystemD {
async fn new(conn: &Pool<Sqlite>, id: i32) -> Result<Self, ServiceError> {
let channel = select_channel(conn, &id).await?;
Ok(Self {
service: channel.service,
cmd: vec_strings!["/usr/bin/systemctl"],
})
}
fn enable(mut self) -> Result<String, ServiceError> {
self.cmd
.append(&mut vec!["enable".to_string(), self.service]);
Command::new("sudo").args(self.cmd).spawn()?;
Ok("Success".to_string())
}
fn disable(mut self) -> Result<String, ServiceError> {
self.cmd
.append(&mut vec!["disable".to_string(), self.service]);
Command::new("sudo").args(self.cmd).spawn()?;
Ok("Success".to_string())
}
fn start(mut self) -> Result<String, ServiceError> {
self.cmd
.append(&mut vec!["start".to_string(), self.service]);
Command::new("sudo").args(self.cmd).spawn()?;
Ok("Success".to_string())
}
fn stop(mut self) -> Result<String, ServiceError> {
self.cmd.append(&mut vec!["stop".to_string(), self.service]);
Command::new("sudo").args(self.cmd).spawn()?;
Ok("Success".to_string())
}
fn restart(mut self) -> Result<String, ServiceError> {
self.cmd
.append(&mut vec!["restart".to_string(), self.service]);
Command::new("sudo").args(self.cmd).spawn()?;
Ok("Success".to_string())
}
async fn status(mut self) -> Result<String, ServiceError> {
self.cmd
.append(&mut vec!["is-active".to_string(), self.service]);
let output = Command::new("sudo").args(self.cmd).output().await?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
}
async fn post_request<T>(config: &PlayoutConfig, obj: T) -> Result<Response, ServiceError>
where
T: Serialize,
{
let url = format!("http://{}", config.rpc_server.address);
let client = Client::new();
match client
.post(&url)
.header(AUTHORIZATION, &config.rpc_server.authorization)
.json(&obj)
.send()
.await
{
Ok(result) => Ok(result),
Err(e) => Err(ServiceError::ServiceUnavailable(e.to_string())),
}
}
pub async fn send_message(
config: &PlayoutConfig,
message: HashMap<String, String>,
) -> Result<Response, ServiceError> {
let json_obj = TextParams {
control: "text".into(),
message,
};
post_request(config, json_obj).await
}
pub async fn control_state(
config: &PlayoutConfig,
command: &str,
) -> Result<Response, ServiceError> {
let json_obj = ControlParams {
control: command.to_owned(),
};
post_request(config, json_obj).await
}
pub async fn media_info(config: &PlayoutConfig, command: String) -> Result<Response, ServiceError> {
let json_obj = MediaParams { media: command };
post_request(config, json_obj).await
}
pub async fn control_service(
conn: &Pool<Sqlite>,
config: &PlayoutConfig,
id: i32,
command: &ServiceCmd,
engine: Option<web::Data<ProcessControl>>,
) -> Result<String, ServiceError> {
if let Some(en) = engine {
if en.piggyback.load(Ordering::SeqCst) {
match command {
ServiceCmd::Start => en.start().await,
ServiceCmd::Stop => {
if control_state(config, "stop_all").await.is_ok() {
en.stop().await
} else {
Err(ServiceError::NoContent("Nothing to stop".to_string()))
}
}
ServiceCmd::Restart => {
if control_state(config, "stop_all").await.is_ok() {
en.restart().await
} else {
Err(ServiceError::NoContent("Nothing to restart".to_string()))
}
}
ServiceCmd::Status => en.status(),
_ => Err(ServiceError::Conflict(
"Engine runs in piggyback mode, in this mode this command is not allowed."
.to_string(),
)),
}
} else {
execute_systemd(conn, id, command).await
}
} else {
execute_systemd(conn, id, command).await
}
}
async fn execute_systemd(
conn: &Pool<Sqlite>,
id: i32,
command: &ServiceCmd,
) -> Result<String, ServiceError> {
let system_d = SystemD::new(conn, id).await?;
match command {
ServiceCmd::Enable => system_d.enable(),
ServiceCmd::Disable => system_d.disable(),
ServiceCmd::Start => system_d.start(),
ServiceCmd::Stop => system_d.stop(),
ServiceCmd::Restart => system_d.restart(),
ServiceCmd::Status => system_d.status().await,
}
}

View File

@ -1,105 +0,0 @@
use actix_web::{error::ResponseError, Error, HttpResponse};
use derive_more::Display;
#[derive(Debug, Display)]
pub enum ServiceError {
#[display(fmt = "Internal Server Error")]
InternalServerError,
#[display(fmt = "BadRequest: {_0}")]
BadRequest(String),
#[display(fmt = "Conflict: {_0}")]
Conflict(String),
#[display(fmt = "Forbidden: {_0}")]
Forbidden(String),
#[display(fmt = "Unauthorized: {_0}")]
Unauthorized(String),
#[display(fmt = "NoContent: {_0}")]
NoContent(String),
#[display(fmt = "ServiceUnavailable: {_0}")]
ServiceUnavailable(String),
}
// impl ResponseError trait allows to convert our errors into http responses with appropriate data
impl ResponseError for ServiceError {
fn error_response(&self) -> HttpResponse {
match self {
ServiceError::InternalServerError => {
HttpResponse::InternalServerError().json("Internal Server Error. Please try later.")
}
ServiceError::BadRequest(ref message) => HttpResponse::BadRequest().json(message),
ServiceError::Conflict(ref message) => HttpResponse::Conflict().json(message),
ServiceError::Forbidden(ref message) => HttpResponse::Forbidden().json(message),
ServiceError::Unauthorized(ref message) => HttpResponse::Unauthorized().json(message),
ServiceError::NoContent(ref message) => HttpResponse::NoContent().json(message),
ServiceError::ServiceUnavailable(ref message) => {
HttpResponse::ServiceUnavailable().json(message)
}
}
}
}
impl From<String> for ServiceError {
fn from(err: String) -> ServiceError {
ServiceError::BadRequest(err)
}
}
impl From<Error> for ServiceError {
fn from(err: Error) -> ServiceError {
ServiceError::BadRequest(err.to_string())
}
}
impl From<actix_multipart::MultipartError> for ServiceError {
fn from(err: actix_multipart::MultipartError) -> ServiceError {
ServiceError::BadRequest(err.to_string())
}
}
impl From<std::io::Error> for ServiceError {
fn from(err: std::io::Error) -> ServiceError {
ServiceError::NoContent(err.to_string())
}
}
impl From<std::num::ParseIntError> for ServiceError {
fn from(err: std::num::ParseIntError) -> ServiceError {
ServiceError::BadRequest(err.to_string())
}
}
impl From<actix_web::error::BlockingError> for ServiceError {
fn from(err: actix_web::error::BlockingError) -> ServiceError {
ServiceError::BadRequest(err.to_string())
}
}
impl From<sqlx::Error> for ServiceError {
fn from(err: sqlx::Error) -> ServiceError {
ServiceError::BadRequest(err.to_string())
}
}
impl From<tokio::task::JoinError> for ServiceError {
fn from(err: tokio::task::JoinError) -> ServiceError {
ServiceError::BadRequest(err.to_string())
}
}
impl From<toml_edit::ser::Error> for ServiceError {
fn from(err: toml_edit::ser::Error) -> ServiceError {
ServiceError::BadRequest(err.to_string())
}
}
impl From<uuid::Error> for ServiceError {
fn from(err: uuid::Error) -> ServiceError {
ServiceError::BadRequest(err.to_string())
}
}

View File

@ -1,456 +0,0 @@
use std::{
io::Write,
path::{Path, PathBuf},
};
use actix_multipart::Multipart;
use actix_web::{web, HttpResponse};
use futures_util::TryStreamExt as _;
use lazy_static::lazy_static;
use lexical_sort::{natural_lexical_cmp, PathSort};
use rand::{distributions::Alphanumeric, Rng};
use relative_path::RelativePath;
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Sqlite};
use tokio::fs;
use simplelog::*;
use crate::utils::{errors::ServiceError, playout_config};
use ffplayout_lib::utils::{file_extension, MediaProbe};
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct PathObject {
pub source: String,
parent: Option<String>,
parent_folders: Option<Vec<String>>,
folders: Option<Vec<String>>,
files: Option<Vec<VideoFile>>,
#[serde(default)]
pub folders_only: bool,
}
impl PathObject {
fn new(source: String, parent: Option<String>) -> Self {
Self {
source,
parent,
parent_folders: Some(vec![]),
folders: Some(vec![]),
files: Some(vec![]),
folders_only: false,
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct MoveObject {
source: String,
target: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct VideoFile {
name: String,
duration: f64,
}
lazy_static! {
pub static ref HOME_DIR: String = home::home_dir()
.unwrap_or("/home/h1wl3n2og".into()) // any random not existing folder
.as_os_str()
.to_string_lossy()
.to_string();
}
const FOLDER_WHITELIST: &[&str; 6] = &[
"/media",
"/mnt",
"/playlists",
"/tv-media",
"/usr/share/ffplayout",
"/var/lib/ffplayout",
];
/// Normalize absolut path
///
/// This function takes care, that it is not possible to break out from root_path.
pub fn norm_abs_path(
root_path: &Path,
input_path: &str,
) -> Result<(PathBuf, String, String), ServiceError> {
let path_relative = RelativePath::new(&root_path.to_string_lossy())
.normalize()
.to_string()
.replace("../", "");
let mut source_relative = RelativePath::new(input_path)
.normalize()
.to_string()
.replace("../", "");
let path_suffix = root_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if input_path.starts_with(&*root_path.to_string_lossy())
|| source_relative.starts_with(&path_relative)
{
source_relative = source_relative
.strip_prefix(&path_relative)
.and_then(|s| s.strip_prefix('/'))
.unwrap_or_default()
.to_string();
} else {
source_relative = source_relative
.strip_prefix(&path_suffix)
.and_then(|s| s.strip_prefix('/'))
.unwrap_or(&source_relative)
.to_string();
}
let path = &root_path.join(&source_relative);
if !FOLDER_WHITELIST.iter().any(|f| path.starts_with(f))
&& !path.starts_with(HOME_DIR.to_string())
{
return Err(ServiceError::Forbidden(
"Access forbidden: Folder cannot be opened.".to_string(),
));
}
Ok((path.to_path_buf(), path_suffix, source_relative))
}
/// File Browser
///
/// Take input path and give file and folder list from it back.
/// Input should be a relative path segment, but when it is a absolut path, the norm_abs_path function
/// will take care, that user can not break out from given storage path in config.
pub async fn browser(
conn: &Pool<Sqlite>,
id: i32,
path_obj: &PathObject,
) -> Result<PathObject, ServiceError> {
let (config, channel) = playout_config(conn, &id).await?;
let mut channel_extensions = channel
.extra_extensions
.split(',')
.map(|e| e.to_string())
.collect::<Vec<String>>();
let mut parent_folders = vec![];
let mut extensions = config.storage.extensions;
extensions.append(&mut channel_extensions);
let (path, parent, path_component) = norm_abs_path(&config.storage.path, &path_obj.source)?;
let parent_path = if !path_component.is_empty() {
path.parent().unwrap()
} else {
&config.storage.path
};
let mut obj = PathObject::new(path_component, Some(parent));
obj.folders_only = path_obj.folders_only;
if path != parent_path && !path_obj.folders_only {
let mut parents = fs::read_dir(&parent_path).await?;
while let Some(child) = parents.next_entry().await? {
if child.metadata().await?.is_dir() {
parent_folders.push(
child
.path()
.file_name()
.unwrap()
.to_string_lossy()
.to_string(),
);
}
}
parent_folders.path_sort(natural_lexical_cmp);
obj.parent_folders = Some(parent_folders);
}
let mut paths_obj = fs::read_dir(path).await?;
let mut files = vec![];
let mut folders = vec![];
while let Some(child) = paths_obj.next_entry().await? {
let f_meta = child.metadata().await?;
// ignore hidden files/folders on unix
if child.path().to_string_lossy().to_string().contains("/.") {
continue;
}
if f_meta.is_dir() {
folders.push(
child
.path()
.file_name()
.unwrap()
.to_string_lossy()
.to_string(),
);
} else if f_meta.is_file() && !path_obj.folders_only {
if let Some(ext) = file_extension(&child.path()) {
if extensions.contains(&ext.to_string().to_lowercase()) {
files.push(child.path())
}
}
}
}
folders.path_sort(natural_lexical_cmp);
files.path_sort(natural_lexical_cmp);
let mut media_files = vec![];
for file in files {
match MediaProbe::new(file.to_string_lossy().as_ref()) {
Ok(probe) => {
let mut duration = 0.0;
if let Some(dur) = probe.format.duration {
duration = dur.parse().unwrap_or_default()
}
let video = VideoFile {
name: file.file_name().unwrap().to_string_lossy().to_string(),
duration,
};
media_files.push(video);
}
Err(e) => error!("{e:?}"),
};
}
obj.folders = Some(folders);
obj.files = Some(media_files);
Ok(obj)
}
pub async fn create_directory(
conn: &Pool<Sqlite>,
id: i32,
path_obj: &PathObject,
) -> Result<HttpResponse, ServiceError> {
let (config, _) = playout_config(conn, &id).await?;
let (path, _, _) = norm_abs_path(&config.storage.path, &path_obj.source)?;
if let Err(e) = fs::create_dir_all(&path).await {
return Err(ServiceError::BadRequest(e.to_string()));
}
info!(
"create folder: <b><magenta>{}</></b>",
path.to_string_lossy()
);
Ok(HttpResponse::Ok().into())
}
async fn copy_and_delete(source: &PathBuf, target: &PathBuf) -> Result<MoveObject, ServiceError> {
match fs::copy(&source, &target).await {
Ok(_) => {
if let Err(e) = fs::remove_file(source).await {
error!("{e}");
return Err(ServiceError::BadRequest(
"Removing File not possible!".into(),
));
};
return Ok(MoveObject {
source: source
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
target: target
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
});
}
Err(e) => {
error!("{e}");
Err(ServiceError::BadRequest("Error in file copy!".into()))
}
}
}
async fn rename(source: &PathBuf, target: &PathBuf) -> Result<MoveObject, ServiceError> {
match fs::rename(source, target).await {
Ok(_) => Ok(MoveObject {
source: source
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
target: target
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
}),
Err(e) => {
error!("{e}");
copy_and_delete(source, target).await
}
}
}
pub async fn rename_file(
conn: &Pool<Sqlite>,
id: i32,
move_object: &MoveObject,
) -> Result<MoveObject, ServiceError> {
let (config, _) = playout_config(conn, &id).await?;
let (source_path, _, _) = norm_abs_path(&config.storage.path, &move_object.source)?;
let (mut target_path, _, _) = norm_abs_path(&config.storage.path, &move_object.target)?;
if !source_path.exists() {
return Err(ServiceError::BadRequest("Source file not exist!".into()));
}
if (source_path.is_dir() || source_path.is_file()) && source_path.parent() == Some(&target_path)
{
return rename(&source_path, &target_path).await;
}
if target_path.is_dir() {
target_path = target_path.join(source_path.file_name().unwrap());
}
if target_path.is_file() {
return Err(ServiceError::BadRequest(
"Target file already exists!".into(),
));
}
if source_path.is_file() && target_path.parent().is_some() {
return rename(&source_path, &target_path).await;
}
Err(ServiceError::InternalServerError)
}
pub async fn remove_file_or_folder(
conn: &Pool<Sqlite>,
id: i32,
source_path: &str,
) -> Result<(), ServiceError> {
let (config, _) = playout_config(conn, &id).await?;
let (source, _, _) = norm_abs_path(&config.storage.path, source_path)?;
if !source.exists() {
return Err(ServiceError::BadRequest("Source does not exists!".into()));
}
if source.is_dir() {
match fs::remove_dir(source).await {
Ok(_) => return Ok(()),
Err(e) => {
error!("{e}");
return Err(ServiceError::BadRequest(
"Delete folder failed! (Folder must be empty)".into(),
));
}
};
}
if source.is_file() {
match fs::remove_file(source).await {
Ok(_) => return Ok(()),
Err(e) => {
error!("{e}");
return Err(ServiceError::BadRequest("Delete file failed!".into()));
}
};
}
Err(ServiceError::InternalServerError)
}
async fn valid_path(conn: &Pool<Sqlite>, id: i32, path: &str) -> Result<PathBuf, ServiceError> {
let (config, _) = playout_config(conn, &id).await?;
let (test_path, _, _) = norm_abs_path(&config.storage.path, path)?;
if !test_path.is_dir() {
return Err(ServiceError::BadRequest("Target folder not exists!".into()));
}
Ok(test_path)
}
pub async fn upload(
conn: &Pool<Sqlite>,
id: i32,
_size: u64,
mut payload: Multipart,
path: &Path,
abs_path: bool,
) -> Result<HttpResponse, ServiceError> {
while let Some(mut field) = payload.try_next().await? {
let content_disposition = field.content_disposition();
debug!("{content_disposition}");
let rand_string: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(20)
.map(char::from)
.collect();
let filename = content_disposition
.get_filename()
.map_or_else(|| rand_string.to_string(), sanitize_filename::sanitize);
let filepath = if abs_path {
path.to_path_buf()
} else {
valid_path(conn, id, &path.to_string_lossy())
.await?
.join(filename)
};
let filepath_clone = filepath.clone();
let _file_size = match filepath.metadata() {
Ok(metadata) => metadata.len(),
Err(_) => 0,
};
// INFO: File exist check should be enough because file size and content length are different.
// The error catching in the loop should normally prevent unfinished files from existing on disk.
// If this is not enough, a second check can be implemented: is_close(file_size as i64, size as i64, 1000)
if filepath.is_file() {
return Err(ServiceError::Conflict("Target already exists!".into()));
}
let mut f = web::block(|| std::fs::File::create(filepath_clone)).await??;
loop {
match field.try_next().await {
Ok(Some(chunk)) => {
f = web::block(move || f.write_all(&chunk).map(|_| f)).await??;
}
Ok(None) => break,
Err(e) => {
if e.to_string().contains("stream is incomplete") {
info!("Delete non finished file: {filepath:?}");
tokio::fs::remove_file(filepath).await?
}
return Err(e.into());
}
}
}
}
Ok(HttpResponse::Ok().into())
}

View File

@ -1,390 +0,0 @@
use std::{
env,
error::Error,
fmt,
fs::{self, metadata, File},
io::{stdin, stdout, Read, Write},
path::{Path, PathBuf},
str::FromStr,
};
use chrono::{format::ParseErrorKind, prelude::*};
use faccess::PathExt;
use once_cell::sync::OnceCell;
use path_clean::PathClean;
use rpassword::read_password;
use serde::{de, Deserialize, Deserializer, Serialize};
use simplelog::*;
use sqlx::{sqlite::SqliteRow, FromRow, Pool, Row, Sqlite};
use crate::ARGS;
pub mod args_parse;
pub mod channels;
pub mod control;
pub mod errors;
pub mod files;
pub mod playlist;
pub mod system;
use crate::db::{
db_pool,
handles::{db_init, insert_user, select_channel, select_global},
models::{Channel, User},
};
use crate::utils::errors::ServiceError;
use ffplayout_lib::utils::{time_to_sec, PlayoutConfig};
#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
pub enum Role {
Admin,
User,
Guest,
}
impl Role {
pub fn set_role(role: &str) -> Self {
match role {
"admin" => Role::Admin,
"user" => Role::User,
_ => Role::Guest,
}
}
}
impl FromStr for Role {
type Err = String;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match input {
"admin" => Ok(Self::Admin),
"user" => Ok(Self::User),
_ => Ok(Self::Guest),
}
}
}
impl fmt::Display for Role {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
Self::Admin => write!(f, "admin"),
Self::User => write!(f, "user"),
Self::Guest => write!(f, "guest"),
}
}
}
impl<'r> sqlx::decode::Decode<'r, ::sqlx::Sqlite> for Role
where
&'r str: sqlx::decode::Decode<'r, sqlx::Sqlite>,
{
fn decode(
value: <sqlx::Sqlite as sqlx::database::HasValueRef<'r>>::ValueRef,
) -> Result<Role, Box<dyn Error + 'static + Send + Sync>> {
let value = <&str as sqlx::decode::Decode<sqlx::Sqlite>>::decode(value)?;
Ok(value.parse()?)
}
}
impl FromRow<'_, SqliteRow> for Role {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
match row.get("name") {
"admin" => Ok(Self::Admin),
"user" => Ok(Self::User),
_ => Ok(Self::Guest),
}
}
}
#[derive(Debug, sqlx::FromRow)]
pub struct GlobalSettings {
pub secret: String,
}
impl GlobalSettings {
async fn new(conn: &Pool<Sqlite>) -> Self {
let global_settings = select_global(conn);
match global_settings.await {
Ok(g) => g,
Err(_) => GlobalSettings {
secret: String::new(),
},
}
}
pub fn global() -> &'static GlobalSettings {
INSTANCE.get().expect("Config is not initialized")
}
}
static INSTANCE: OnceCell<GlobalSettings> = OnceCell::new();
pub async fn init_config(conn: &Pool<Sqlite>) {
let config = GlobalSettings::new(conn).await;
INSTANCE.set(config).unwrap();
}
pub fn db_path() -> Result<&'static str, Box<dyn std::error::Error>> {
if let Some(path) = ARGS.db.clone() {
let absolute_path = if path.is_absolute() {
path
} else {
env::current_dir()?.join(path)
}
.clean();
if let Some(abs_path) = absolute_path.parent() {
if abs_path.writable() {
return Ok(Box::leak(
absolute_path.to_string_lossy().to_string().into_boxed_str(),
));
}
error!("Given database path is not writable!");
}
}
let sys_path = Path::new("/usr/share/ffplayout/db");
let mut db_path = "./ffplayout.db";
if sys_path.is_dir() && !sys_path.writable() {
error!("Path {} is not writable!", sys_path.display());
}
if sys_path.is_dir() && sys_path.writable() {
db_path = "/usr/share/ffplayout/db/ffplayout.db";
} else if Path::new("./assets").is_dir() {
db_path = "./assets/ffplayout.db";
}
Ok(db_path)
}
pub fn public_path() -> PathBuf {
let path = PathBuf::from("./ffplayout-frontend/.output/public/");
if cfg!(debug_assertions) && path.is_dir() {
return path;
}
let path = PathBuf::from("/usr/share/ffplayout/public/");
if path.is_dir() {
return path;
}
PathBuf::from("./public/")
}
pub async fn run_args() -> Result<(), i32> {
let mut args = ARGS.clone();
if !args.init && args.listen.is_none() && !args.ask && args.username.is_none() {
error!("Wrong number of arguments! Run ffpapi --help for more information.");
return Err(0);
}
if args.init {
if let Err(e) = db_init(args.domain).await {
panic!("{e}");
};
return Err(0);
}
if args.ask {
let mut user = String::new();
print!("Username: ");
stdout().flush().unwrap();
stdin()
.read_line(&mut user)
.expect("Did not enter a correct name?");
if let Some('\n') = user.chars().next_back() {
user.pop();
}
if let Some('\r') = user.chars().next_back() {
user.pop();
}
args.username = Some(user);
print!("Password: ");
stdout().flush().unwrap();
let password = read_password();
args.password = password.ok();
let mut mail = String::new();
print!("Mail: ");
stdout().flush().unwrap();
stdin()
.read_line(&mut mail)
.expect("Did not enter a correct name?");
if let Some('\n') = mail.chars().next_back() {
mail.pop();
}
if let Some('\r') = mail.chars().next_back() {
mail.pop();
}
args.mail = Some(mail);
}
if let Some(username) = args.username {
if args.mail.is_none() || args.password.is_none() {
error!("Mail/password missing!");
return Err(1);
}
let user = User {
id: 0,
mail: Some(args.mail.unwrap()),
username: username.clone(),
password: args.password.unwrap(),
role_id: Some(1),
channel_id: Some(1),
token: None,
};
match db_pool().await {
Ok(conn) => {
if let Err(e) = insert_user(&conn, user).await {
error!("{e}");
return Err(1);
};
}
Err(e) => {
error!("{e}");
return Err(1);
}
};
info!("Create admin user \"{username}\" done...");
return Err(0);
}
Ok(())
}
pub fn read_playout_config(path: &str) -> Result<PlayoutConfig, Box<dyn Error>> {
let mut file = File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let mut config: PlayoutConfig = toml_edit::de::from_str(&contents)?;
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)
}
pub async fn playout_config(
conn: &Pool<Sqlite>,
channel_id: &i32,
) -> Result<(PlayoutConfig, Channel), ServiceError> {
if let Ok(channel) = select_channel(conn, channel_id).await {
match read_playout_config(&channel.config_path.clone()) {
Ok(config) => return Ok((config, channel)),
Err(e) => error!("{e}"),
}
}
Err(ServiceError::BadRequest(
"Error in getting config!".to_string(),
))
}
pub async fn read_log_file(
conn: &Pool<Sqlite>,
channel_id: &i32,
date: &str,
) -> Result<String, ServiceError> {
if let Ok(channel) = select_channel(conn, channel_id).await {
let mut date_str = "".to_string();
if !date.is_empty() {
date_str.push('.');
date_str.push_str(date);
}
if let Ok(config) = read_playout_config(&channel.config_path) {
let mut log_path = Path::new(&config.logging.path)
.join("ffplayout.log")
.display()
.to_string();
log_path.push_str(&date_str);
let file_size = metadata(&log_path)?.len() as f64;
let file_content = if file_size > 5000000.0 {
error!("Log file to big: {}", sizeof_fmt(file_size));
format!("The log file is larger ({}) than the hard limit of 5MB, the probability is very high that something is wrong with the playout. Check this on the server with `less {log_path}`.", sizeof_fmt(file_size))
} else {
fs::read_to_string(log_path)?
};
return Ok(file_content);
}
}
Err(ServiceError::NoContent(
"Requested log file not exists, or not readable.".to_string(),
))
}
/// get human readable file size
pub fn sizeof_fmt(mut num: f64) -> String {
let suffix = 'B';
for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"] {
if num.abs() < 1024.0 {
return format!("{num:.1}{unit}{suffix}");
}
num /= 1024.0;
}
format!("{num:.1}Yi{suffix}")
}
pub fn local_utc_offset() -> i32 {
let mut offset = Local::now().format("%:z").to_string();
let operator = offset.remove(0);
let mut utc_offset = 0;
if let Some((r, f)) = offset.split_once(':') {
utc_offset = r.parse::<i32>().unwrap_or(0) * 60 + f.parse::<i32>().unwrap_or(0);
if operator == '-' && utc_offset > 0 {
utc_offset = -utc_offset;
}
}
utc_offset
}
pub fn naive_date_time_from_str<'de, D>(deserializer: D) -> Result<NaiveDateTime, D::Error>
where
D: Deserializer<'de>,
{
let s: String = Deserialize::deserialize(deserializer)?;
match NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S") {
Ok(date_time) => Ok(date_time),
Err(e) => {
if e.kind() == ParseErrorKind::TooShort {
NaiveDateTime::parse_from_str(&format!("{s}T00:00:00"), "%Y-%m-%dT%H:%M:%S")
.map_err(de::Error::custom)
} else {
NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S%#z").map_err(de::Error::custom)
}
}
}
}

View File

@ -1,152 +0,0 @@
use std::{fs, path::PathBuf};
use simplelog::*;
use sqlx::{Pool, Sqlite};
use crate::utils::{errors::ServiceError, files::norm_abs_path, playout_config};
use ffplayout_lib::utils::{
generate_playlist as playlist_generator, json_reader, json_writer, JsonPlaylist, PlayoutConfig,
};
pub async fn read_playlist(
conn: &Pool<Sqlite>,
id: i32,
date: String,
) -> Result<JsonPlaylist, ServiceError> {
let (config, _) = playout_config(conn, &id).await?;
let (path, _, _) = norm_abs_path(&config.playlist.path, "")?;
let mut playlist_path = path;
let d: Vec<&str> = date.split('-').collect();
playlist_path = playlist_path
.join(d[0])
.join(d[1])
.join(date.clone())
.with_extension("json");
match json_reader(&playlist_path) {
Ok(p) => Ok(p),
Err(e) => Err(ServiceError::NoContent(e.to_string())),
}
}
pub async fn write_playlist(
conn: &Pool<Sqlite>,
id: i32,
json_data: JsonPlaylist,
) -> Result<String, ServiceError> {
let (config, _) = playout_config(conn, &id).await?;
let date = json_data.date.clone();
let mut playlist_path = config.playlist.path;
let d: Vec<&str> = date.split('-').collect();
if !playlist_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("json"))
.unwrap_or(false)
{
playlist_path = playlist_path
.join(d[0])
.join(d[1])
.join(date.clone())
.with_extension("json");
}
let mut file_exists = false;
if let Some(p) = playlist_path.parent() {
fs::create_dir_all(p)?;
}
if playlist_path.is_file() {
file_exists = true;
if let Ok(existing_data) = json_reader(&playlist_path) {
if json_data == existing_data {
return Err(ServiceError::Conflict(format!(
"Playlist from {date}, already exists!"
)));
}
}
}
match json_writer(&playlist_path, json_data) {
Ok(_) => {
let mut msg = format!("Write playlist from {date} success!");
if file_exists {
msg = format!("Update playlist from {date} success!");
}
return Ok(msg);
}
Err(e) => {
error!("{e}");
}
}
Err(ServiceError::InternalServerError)
}
pub async fn generate_playlist(
mut config: PlayoutConfig,
channel: String,
) -> 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)) {
Ok(playlists) => {
if !playlists.is_empty() {
Ok(playlists[0].clone())
} else {
Err(ServiceError::Conflict(
"The playlist could not be written, maybe it already exists!".into(),
))
}
}
Err(e) => {
error!("{e}");
Err(ServiceError::InternalServerError)
}
}
}
pub async fn delete_playlist(
conn: &Pool<Sqlite>,
id: i32,
date: &str,
) -> Result<String, ServiceError> {
let (config, _) = playout_config(conn, &id).await?;
let mut playlist_path = PathBuf::from(&config.playlist.path);
let d: Vec<&str> = date.split('-').collect();
playlist_path = playlist_path
.join(d[0])
.join(d[1])
.join(date)
.with_extension("json");
if playlist_path.is_file() {
match fs::remove_file(playlist_path) {
Ok(_) => Ok(format!("Delete playlist from {date} success!")),
Err(e) => {
error!("{e}");
Err(ServiceError::InternalServerError)
}
}
} else {
Ok(format!("No playlist to delete on: {date}"))
}
}

View File

@ -1,176 +0,0 @@
use std::fmt;
use local_ip_address::list_afinet_netifas;
use serde::Serialize;
use sysinfo::System;
use crate::{DISKS, NETWORKS, SYS};
use ffplayout_lib::utils::PlayoutConfig;
const IGNORE_INTERFACES: [&str; 7] = ["docker", "lxdbr", "tab", "tun", "virbr", "veth", "vnet"];
#[derive(Debug, Serialize)]
pub struct Cpu {
pub cores: f32,
pub usage: f32,
}
#[derive(Debug, Default, Serialize)]
pub struct Storage {
pub path: String,
pub total: u64,
pub used: u64,
}
#[derive(Debug, Serialize)]
pub struct Load {
pub one: f64,
pub five: f64,
pub fifteen: f64,
}
#[derive(Debug, Serialize)]
pub struct Memory {
pub total: u64,
pub used: u64,
pub free: u64,
}
#[derive(Debug, Default, Serialize)]
pub struct Network {
pub name: String,
pub current_in: u64,
pub total_in: u64,
pub current_out: u64,
pub total_out: u64,
}
#[derive(Debug, Serialize)]
pub struct MySystem {
pub name: Option<String>,
pub kernel: Option<String>,
pub version: Option<String>,
pub ffp_version: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct Swap {
pub total: u64,
pub used: u64,
pub free: u64,
}
#[derive(Debug, Serialize)]
pub struct SystemStat {
pub cpu: Cpu,
pub load: Load,
pub memory: Memory,
pub network: Network,
pub storage: Storage,
pub swap: Swap,
pub system: MySystem,
}
impl fmt::Display for SystemStat {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", serde_json::to_string(self).unwrap())
}
}
pub fn stat(config: PlayoutConfig) -> SystemStat {
let mut disks = DISKS.lock().unwrap();
let mut networks = NETWORKS.lock().unwrap();
let mut sys = SYS.lock().unwrap();
let network_interfaces = list_afinet_netifas().unwrap_or_default();
let mut usage = 0.0;
let mut interfaces = vec![];
for (name, ip) in network_interfaces.iter() {
if !ip.is_loopback()
&& !IGNORE_INTERFACES
.iter()
.any(|&prefix| name.starts_with(prefix))
{
interfaces.push((name, ip))
}
}
interfaces.dedup_by(|a, b| a.0 == b.0);
disks.refresh();
networks.refresh();
sys.refresh_cpu_usage();
sys.refresh_memory();
let cores = sys.cpus().len() as f32;
for cpu in sys.cpus() {
usage += cpu.cpu_usage();
}
let cpu = Cpu {
cores,
usage: usage * cores / 100.0,
};
let mut storage = Storage::default();
for disk in &*disks {
if disk.mount_point().to_string_lossy().len() > 1
&& config.storage.path.starts_with(disk.mount_point())
{
storage.path = disk.name().to_string_lossy().to_string();
storage.total = disk.total_space();
storage.used = disk.available_space();
}
}
let load_avg = System::load_average();
let load = Load {
one: load_avg.one,
five: load_avg.five,
fifteen: load_avg.fifteen,
};
let memory = Memory {
total: sys.total_memory(),
used: sys.used_memory(),
free: sys.total_memory() - sys.used_memory(),
};
let mut network = Network::default();
for (interface_name, data) in &*networks {
if !interfaces.is_empty() && interface_name == interfaces[0].0 {
network.name.clone_from(interface_name);
network.current_in = data.received();
network.total_in = data.total_received();
network.current_out = data.transmitted();
network.total_out = data.total_transmitted();
}
}
let swap = Swap {
total: sys.total_swap(),
used: sys.used_swap(),
free: sys.free_swap(),
};
let system = MySystem {
name: System::name(),
kernel: System::kernel_version(),
version: System::os_version(),
ffp_version: Some(env!("CARGO_PKG_VERSION").to_string()),
};
SystemStat {
cpu,
storage,
load,
memory,
network,
system,
swap,
}
}

View File

@ -1,221 +0,0 @@
[package]
name = "ffplayout-engine"
description = "24/7 playout based on rust and ffmpeg"
readme = "README.md"
version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
edition.workspace = true
default-run = "ffplayout_engine"
[dependencies]
ffplayout-lib = { path = "../lib" }
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
clap = { version = "4.3", features = ["derive"] }
crossbeam-channel = "0.5"
futures = "0.3"
itertools = "0.13"
notify = "6.0"
notify-debouncer-full = { version = "*", default-features = false }
rand = "0.8"
regex = "1"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
simplelog = { version = "0.12", features = ["paris"] }
tiny_http = { version = "0.12", default-features = false }
zeromq = { version = "0.4", default-features = false, features = [
"tokio-runtime",
"tcp-transport",
] }
[[bin]]
name = "ffplayout_engine"
path = "src/main.rs"
# DEBIAN DEB PACKAGE
[package.metadata.deb]
name = "ffplayout"
priority = "optional"
section = "net"
license-file = ["../LICENSE", "0"]
depends = ""
recommends = "sudo"
suggests = "ffmpeg"
copyright = "Copyright (c) 2022, Jonathan Baecker. All rights reserved."
conf-files = ["/etc/ffplayout/ffplayout.toml"]
assets = [
[
"../target/x86_64-unknown-linux-musl/release/ffpapi",
"/usr/bin/",
"755",
],
[
"../target/x86_64-unknown-linux-musl/release/ffplayout",
"/usr/bin/",
"755",
],
[
"../assets/ffpapi.service",
"/lib/systemd/system/",
"644",
],
[
"../assets/ffplayout.service",
"/lib/systemd/system/",
"644",
],
[
"../assets/ffplayout@.service",
"/lib/systemd/system/",
"644",
],
[
"../assets/11-ffplayout",
"/etc/sudoers.d/",
"644",
],
[
"../assets/advanced.toml",
"/etc/ffplayout/",
"644",
],
[
"../assets/ffplayout.toml",
"/etc/ffplayout/",
"644",
],
[
"../assets/logo.png",
"/usr/share/ffplayout/",
"644",
],
[
"../assets/ffplayout.toml",
"/usr/share/ffplayout/ffplayout.toml.orig",
"644",
],
[
"../assets/ffplayout.conf",
"/usr/share/ffplayout/ffplayout.conf.example",
"644",
],
[
"../README.md",
"/usr/share/doc/ffplayout/README",
"644",
],
[
"../assets/ffpapi.1.gz",
"/usr/share/man/man1/",
"644",
],
[
"../assets/ffplayout.1.gz",
"/usr/share/man/man1/",
"644",
],
]
maintainer-scripts = "../debian/"
systemd-units = { enable = false, unit-scripts = "../assets" }
[package.metadata.deb.variants.arm64]
assets = [
[
"../target/aarch64-unknown-linux-gnu/release/ffpapi",
"/usr/bin/",
"755",
],
[
"../target/aarch64-unknown-linux-gnu/release/ffplayout",
"/usr/bin/",
"755",
],
[
"../assets/ffpapi.service",
"/lib/systemd/system/",
"644",
],
[
"../assets/ffplayout.service",
"/lib/systemd/system/",
"644",
],
[
"../assets/ffplayout@.service",
"/lib/systemd/system/",
"644",
],
[
"../assets/11-ffplayout",
"/etc/sudoers.d/",
"644",
],
[
"../assets/ffplayout.toml",
"/etc/ffplayout/",
"644",
],
[
"../assets/advanced.toml",
"/etc/ffplayout/",
"644",
],
[
"../assets/logo.png",
"/usr/share/ffplayout/",
"644",
],
[
"../assets/ffplayout.toml",
"/usr/share/ffplayout/ffplayout.toml.orig",
"644",
],
[
"../assets/ffplayout.conf",
"/usr/share/ffplayout/ffplayout.conf.example",
"644",
],
[
"../README.md",
"/usr/share/doc/ffplayout/README",
"644",
],
[
"../assets/ffpapi.1.gz",
"/usr/share/man/man1/",
"644",
],
[
"../assets/ffplayout.1.gz",
"/usr/share/man/man1/",
"644",
],
]
# REHL RPM PACKAGE
[package.metadata.generate-rpm]
name = "ffplayout"
license = "GPL-3.0"
assets = [
{ source = "../target/x86_64-unknown-linux-musl/release/ffpapi", dest = "/usr/bin/ffpapi", mode = "755" },
{ source = "../target/x86_64-unknown-linux-musl/release/ffplayout", dest = "/usr/bin/ffplayout", mode = "755" },
{ source = "../assets/advanced.toml", dest = "/etc/ffplayout/advanced.toml", mode = "644", config = true },
{ source = "../assets/ffplayout.toml", dest = "/etc/ffplayout/ffplayout.toml", mode = "644", config = true },
{ source = "../assets/ffpapi.service", dest = "/lib/systemd/system/ffpapi.service", mode = "644" },
{ source = "../assets/ffplayout.service", dest = "/lib/systemd/system/ffplayout.service", mode = "644" },
{ source = "../assets/ffplayout@.service", dest = "/lib/systemd/system/ffplayout@.service", mode = "644" },
{ source = "../assets/11-ffplayout", dest = "/etc/sudoers.d/11-ffplayout", mode = "644" },
{ source = "../README.md", dest = "/usr/share/doc/ffplayout/README", mode = "644" },
{ source = "../assets/ffpapi.1.gz", dest = "/usr/share/man/man1/ffpapi.1.gz", mode = "644", doc = true },
{ source = "../assets/ffplayout.1.gz", dest = "/usr/share/man/man1/ffplayout.1.gz", mode = "644", doc = true },
{ source = "../LICENSE", dest = "/usr/share/doc/ffplayout/LICENSE", mode = "644" },
{ source = "../assets/logo.png", dest = "/usr/share/ffplayout/logo.png", mode = "644" },
{ source = "../assets/ffplayout.toml", dest = "/usr/share/ffplayout/ffplayout.toml.orig", mode = "644" },
{ source = "../assets/ffplayout.conf", dest = "/usr/share/ffplayout/ffplayout.conf.example", mode = "644" },
{ source = "../debian/postinst", dest = "/usr/share/ffplayout/postinst", mode = "755" },
]
auto-req = "no"
post_install_script = "/usr/share/ffplayout/postinst"

View File

@ -1,34 +0,0 @@
**ffplayout-engine**
================
Start with Arguments
-----
ffplayout also allows the passing of parameters:
```
OPTIONS:
-c, --config <CONFIG> File path to ffplayout.yml
-d, --date <DATE> Target date (YYYY-MM-DD) for text/m3u to playlist import
-f, --folder <FOLDER> Play folder content
--fake-time <FAKE_TIME> fake date time, for debugging
-g, --generate <YYYY-MM-DD>... Generate playlist for dates, like: 2022-01-01 - 2022-01-10
-h, --help Print help information
-i, --infinit Loop playlist infinitely
--import <IMPORT> Import a given text/m3u file and create a playlist from it
-l, --log <LOG> File path for logging
-m, --play-mode <PLAY_MODE> Playing mode: folder, playlist
-o, --output <OUTPUT> Set output mode: desktop, hls, stream
-p, --playlist <PLAYLIST> Path from playlist
-s, --start <START> Start time in 'hh:mm:ss', 'now' for start with first
-t, --length <LENGTH> Set length in 'hh:mm:ss', 'none' for no length check
-v, --volume <VOLUME> Set audio volume
-V, --version Print version information
```
You can run the command like:
```Bash
./ffplayout -l none -p ~/playlist.json -o desktop
```

View File

@ -1,102 +0,0 @@
use std::{
path::Path,
sync::{
atomic::{AtomicBool, Ordering},
mpsc::channel,
{Arc, Mutex},
},
thread::sleep,
time::Duration,
};
use notify::{
event::{CreateKind, ModifyKind, RemoveKind, RenameMode},
EventKind::{Create, Modify, Remove},
RecursiveMode, Watcher,
};
use notify_debouncer_full::new_debouncer;
use simplelog::*;
use ffplayout_lib::utils::{include_file_extension, Media, PlayoutConfig};
/// Create a watcher, which monitor file changes.
/// When a change is register, update the current file list.
/// This makes it possible, to play infinitely and and always new files to it.
pub fn watchman(
config: PlayoutConfig,
is_terminated: Arc<AtomicBool>,
sources: Arc<Mutex<Vec<Media>>>,
) {
let path = Path::new(&config.storage.path);
if !path.exists() {
error!("Folder path not exists: '{path:?}'");
panic!("Folder path not exists: '{path:?}'");
}
// let (tx, rx) = channel();
let (tx, rx) = channel();
let mut debouncer = new_debouncer(Duration::from_secs(1), None, tx).unwrap();
debouncer
.watcher()
.watch(path, RecursiveMode::Recursive)
.unwrap();
debouncer.cache().add_root(path, RecursiveMode::Recursive);
while !is_terminated.load(Ordering::SeqCst) {
if let Ok(result) = rx.try_recv() {
match result {
Ok(events) => events.iter().for_each(|event| match event.kind {
Create(CreateKind::File) | Modify(ModifyKind::Name(RenameMode::To)) => {
let new_path = &event.paths[0];
if new_path.is_file() && include_file_extension(&config, new_path) {
let index = sources.lock().unwrap().len();
let media = Media::new(index, &new_path.to_string_lossy(), false);
sources.lock().unwrap().push(media);
info!("Create new file: <b><magenta>{new_path:?}</></b>");
}
}
Remove(RemoveKind::File) | Modify(ModifyKind::Name(RenameMode::From)) => {
let old_path = &event.paths[0];
if !old_path.is_file() && include_file_extension(&config, old_path) {
sources
.lock()
.unwrap()
.retain(|x| x.source != old_path.to_string_lossy());
info!("Remove file: <b><magenta>{old_path:?}</></b>");
}
}
Modify(ModifyKind::Name(RenameMode::Both)) => {
let old_path = &event.paths[0];
let new_path = &event.paths[1];
let mut media_list = sources.lock().unwrap();
if let Some(index) = media_list
.iter()
.position(|x| *x.source == old_path.display().to_string()) {
let media = Media::new(index, &new_path.to_string_lossy(), false);
media_list[index] = media;
info!("Move file: <b><magenta>{old_path:?}</></b> to <b><magenta>{new_path:?}</></b>");
} else if include_file_extension(&config, new_path) {
let index = media_list.len();
let media = Media::new(index, &new_path.to_string_lossy(), false);
media_list.push(media);
info!("Create new file: <b><magenta>{new_path:?}</></b>");
}
}
_ => debug!("Not tracked file event: {event:?}")
}),
Err(errors) => errors.iter().for_each(|error| error!("{error:?}")),
}
}
sleep(Duration::from_secs(3));
}
}

View File

@ -1,167 +0,0 @@
use std::{
io::{BufRead, BufReader, Error, Read},
process::{exit, ChildStderr, Command, Stdio},
sync::atomic::Ordering,
thread,
};
use crossbeam_channel::Sender;
use simplelog::*;
use crate::utils::{log_line, valid_stream};
use ffplayout_lib::{
utils::{
controller::ProcessUnit::*, test_tcp_port, Media, PlayoutConfig, ProcessControl,
FFMPEG_IGNORE_ERRORS, FFMPEG_UNRECOVERABLE_ERRORS,
},
vec_strings,
};
fn server_monitor(
level: &str,
ignore: Vec<String>,
buffer: BufReader<ChildStderr>,
proc_ctl: ProcessControl,
) -> Result<(), Error> {
for line in buffer.lines() {
let line = line?;
if !FFMPEG_IGNORE_ERRORS.iter().any(|i| line.contains(*i))
&& !ignore.iter().any(|i| line.contains(i))
{
log_line(&line, level);
}
if line.contains("rtmp") && line.contains("Unexpected stream") && !valid_stream(&line) {
if let Err(e) = proc_ctl.stop(Ingest) {
error!("{e}");
};
}
if FFMPEG_UNRECOVERABLE_ERRORS
.iter()
.any(|i| line.contains(*i))
{
proc_ctl.stop_all();
}
}
Ok(())
}
/// ffmpeg Ingest Server
///
/// Start ffmpeg in listen mode, and wait for input.
pub fn ingest_server(
config: PlayoutConfig,
ingest_sender: Sender<(usize, [u8; 65088])>,
proc_control: ProcessControl,
) -> Result<(), Error> {
let mut buffer: [u8; 65088] = [0; 65088];
let mut server_cmd = vec_strings!["-hide_banner", "-nostats", "-v", "level+info"];
let stream_input = config.ingest.input_cmd.clone().unwrap();
let mut dummy_media = Media::new(0, "Live Stream", false);
dummy_media.unit = Ingest;
dummy_media.add_filter(&config, &None);
if let Some(ingest_input_cmd) = config
.advanced
.as_ref()
.and_then(|a| a.ingest.input_cmd.clone())
{
server_cmd.append(&mut ingest_input_cmd.clone());
}
server_cmd.append(&mut stream_input.clone());
if let Some(mut filter) = dummy_media.filter {
server_cmd.append(&mut filter.cmd());
server_cmd.append(&mut filter.map());
}
if let Some(mut cmd) = config.processing.cmd {
server_cmd.append(&mut cmd);
}
let mut is_running;
if let Some(url) = stream_input.iter().find(|s| s.contains("://")) {
if !test_tcp_port(url) {
proc_control.stop_all();
exit(1);
}
info!("Start ingest server, listening on: <b><magenta>{url}</></b>",);
};
debug!(
"Server CMD: <bright-blue>\"ffmpeg {}\"</>",
server_cmd.join(" ")
);
while !proc_control.is_terminated.load(Ordering::SeqCst) {
let proc_ctl = proc_control.clone();
let level = config.logging.ingest_level.clone().unwrap();
let ignore = config.logging.ignore_lines.clone();
let mut server_proc = match Command::new("ffmpeg")
.args(server_cmd.clone())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Err(e) => {
error!("couldn't spawn ingest server: {e}");
panic!("couldn't spawn ingest server: {e}")
}
Ok(proc) => proc,
};
let mut ingest_reader = BufReader::new(server_proc.stdout.take().unwrap());
let server_err = BufReader::new(server_proc.stderr.take().unwrap());
let error_reader_thread =
thread::spawn(move || server_monitor(&level, ignore, server_err, proc_ctl));
*proc_control.server_term.lock().unwrap() = Some(server_proc);
is_running = false;
loop {
let bytes_len = match ingest_reader.read(&mut buffer[..]) {
Ok(length) => length,
Err(e) => {
debug!("Ingest server read {e:?}");
break;
}
};
if !is_running {
proc_control.server_is_running.store(true, Ordering::SeqCst);
is_running = true;
}
if bytes_len > 0 {
if let Err(e) = ingest_sender.send((bytes_len, buffer)) {
error!("Ingest server write error: {e:?}");
proc_control.is_terminated.store(true, Ordering::SeqCst);
break;
}
} else {
break;
}
}
drop(ingest_reader);
proc_control
.server_is_running
.store(false, Ordering::SeqCst);
if let Err(e) = proc_control.wait(Ingest) {
error!("{e}")
}
if let Err(e) = error_reader_thread.join() {
error!("{e:?}");
};
}
Ok(())
}

View File

@ -1,51 +0,0 @@
use std::{
sync::{atomic::AtomicBool, Arc},
thread,
};
use simplelog::*;
use ffplayout_lib::utils::{Media, PlayoutConfig, PlayoutStatus, ProcessMode::*};
pub mod folder;
pub mod ingest;
pub mod playlist;
pub use folder::watchman;
pub use ingest::ingest_server;
pub use playlist::CurrentProgram;
use ffplayout_lib::utils::{controller::PlayerControl, folder::FolderSource};
/// Create a source iterator from playlist, or from folder.
pub fn source_generator(
config: PlayoutConfig,
player_control: &PlayerControl,
playout_stat: PlayoutStatus,
is_terminated: Arc<AtomicBool>,
) -> Box<dyn Iterator<Item = Media>> {
match config.processing.mode {
Folder => {
info!("Playout in folder mode");
debug!(
"Monitor folder: <b><magenta>{:?}</></b>",
config.storage.path
);
let config_clone = config.clone();
let folder_source = FolderSource::new(&config, playout_stat.chain, player_control);
let node_clone = folder_source.player_control.current_list.clone();
// Spawn a thread to monitor folder for file changes.
thread::spawn(move || watchman(config_clone, is_terminated.clone(), node_clone));
Box::new(folder_source) as Box<dyn Iterator<Item = Media>>
}
Playlist => {
info!("Playout in playlist mode");
let program = CurrentProgram::new(&config, playout_stat, is_terminated, player_control);
Box::new(program) as Box<dyn Iterator<Item = Media>>
}
}
}

View File

@ -1,848 +0,0 @@
use std::{
fs,
path::Path,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
};
use serde_json::json;
use simplelog::*;
use ffplayout_lib::utils::{
controller::PlayerControl,
gen_dummy, get_delta, is_close, is_remote,
json_serializer::{read_json, set_defaults},
loop_filler, loop_image, modified_time, seek_and_length, time_in_seconds, JsonPlaylist, Media,
MediaProbe, PlayoutConfig, PlayoutStatus, IMAGE_FORMAT,
};
/// Struct for current playlist.
///
/// Here we prepare the init clip and build a iterator where we pull our clips.
#[derive(Debug)]
pub struct CurrentProgram {
config: PlayoutConfig,
start_sec: f64,
end_sec: f64,
json_playlist: JsonPlaylist,
player_control: PlayerControl,
current_node: Media,
is_terminated: Arc<AtomicBool>,
playout_stat: PlayoutStatus,
last_json_path: Option<String>,
last_node_ad: bool,
}
/// Prepare a playlist iterator.
impl CurrentProgram {
pub fn new(
config: &PlayoutConfig,
playout_stat: PlayoutStatus,
is_terminated: Arc<AtomicBool>,
player_control: &PlayerControl,
) -> Self {
Self {
config: config.clone(),
start_sec: config.playlist.start_sec.unwrap(),
end_sec: config.playlist.length_sec.unwrap(),
json_playlist: JsonPlaylist::new(
"1970-01-01".to_string(),
config.playlist.start_sec.unwrap(),
),
player_control: player_control.clone(),
current_node: Media::new(0, "", false),
is_terminated,
playout_stat,
last_json_path: None,
last_node_ad: false,
}
}
// Check if there is no current playlist or file got updated,
// and when is so load/reload it.
fn load_or_update_playlist(&mut self, seek: bool) {
let mut get_current = false;
let mut reload = false;
if let Some(path) = self.json_playlist.path.clone() {
if (Path::new(&path).is_file() || is_remote(&path))
&& self.json_playlist.modified != modified_time(&path)
{
info!("Reload playlist <b><magenta>{path}</></b>");
self.playout_stat.list_init.store(true, Ordering::SeqCst);
get_current = true;
reload = true;
}
} else {
get_current = true;
}
if get_current {
self.json_playlist = read_json(
&mut self.config,
&self.player_control,
self.json_playlist.path.clone(),
self.is_terminated.clone(),
seek,
false,
);
if !reload {
if let Some(file) = &self.json_playlist.path {
info!("Read playlist: <b><magenta>{file}</></b>");
}
if *self.playout_stat.date.lock().unwrap() != self.json_playlist.date {
self.set_status(self.json_playlist.date.clone());
}
self.playout_stat
.current_date
.lock()
.unwrap()
.clone_from(&self.json_playlist.date);
}
self.player_control
.current_list
.lock()
.unwrap()
.clone_from(&self.json_playlist.program);
if self.json_playlist.path.is_none() {
trace!("missing playlist");
self.current_node = Media::new(0, "", false);
self.playout_stat.list_init.store(true, Ordering::SeqCst);
self.player_control.current_index.store(0, Ordering::SeqCst);
}
}
}
// Check if day is past and it is time for a new playlist.
fn check_for_playlist(&mut self, seek: bool) -> bool {
let (delta, total_delta) = get_delta(&self.config, &time_in_seconds());
let mut next = false;
let duration = if self.current_node.duration >= self.current_node.out {
self.current_node.duration
} else {
// maybe out is longer to be able to loop
self.current_node.out
};
let node_index = self.current_node.index.unwrap_or_default();
let mut next_start =
self.current_node.begin.unwrap_or_default() - self.start_sec + duration + delta;
if node_index > 0
&& node_index == self.player_control.current_list.lock().unwrap().len() - 1
{
next_start += self.config.general.stop_threshold;
}
trace!(
"delta: {delta} | total_delta: {total_delta}, index: {node_index} \nnext_start: {next_start} | end_sec: {} | source {}",
self.end_sec,
self.current_node.source
);
// Check if we over the target length or we are close to it, if so we load the next playlist.
if !self.config.playlist.infinit
&& (next_start >= self.end_sec
|| is_close(total_delta, 0.0, 2.0)
|| is_close(total_delta, self.end_sec, 2.0))
{
trace!("get next day");
next = true;
self.json_playlist = read_json(
&mut self.config,
&self.player_control,
None,
self.is_terminated.clone(),
false,
true,
);
if let Some(file) = &self.json_playlist.path {
info!("Read next playlist: <b><magenta>{file}</></b>");
}
self.playout_stat.list_init.store(false, Ordering::SeqCst);
self.set_status(self.json_playlist.date.clone());
self.player_control
.current_list
.lock()
.unwrap()
.clone_from(&self.json_playlist.program);
self.player_control.current_index.store(0, Ordering::SeqCst);
} else {
self.load_or_update_playlist(seek)
}
next
}
fn set_status(&mut self, date: String) {
if *self.playout_stat.date.lock().unwrap() != date
&& *self.playout_stat.time_shift.lock().unwrap() != 0.0
{
info!("Reset playout status");
}
self.playout_stat
.current_date
.lock()
.unwrap()
.clone_from(&date);
*self.playout_stat.time_shift.lock().unwrap() = 0.0;
if let Err(e) = fs::write(
&self.config.general.stat_file,
serde_json::to_string(&json!({
"time_shift": 0.0,
"date": date,
}))
.unwrap(),
) {
error!("Unable to write status file: {e}");
};
}
// Check if last and/or next clip is a advertisement.
fn last_next_ad(&mut self, node: &mut Media) {
let index = self.player_control.current_index.load(Ordering::SeqCst);
let current_list = self.player_control.current_list.lock().unwrap();
if index + 1 < current_list.len() && &current_list[index + 1].category == "advertisement" {
node.next_ad = true;
}
if index > 0
&& index < current_list.len()
&& &current_list[index - 1].category == "advertisement"
{
node.last_ad = true;
}
}
// Get current time and when we are before start time,
// we add full seconds of a day to it.
fn get_current_time(&mut self) -> f64 {
let mut time_sec = time_in_seconds();
if time_sec < self.start_sec {
time_sec += 86400.0 // self.config.playlist.length_sec.unwrap();
}
time_sec
}
// On init or reload we need to seek for the current clip.
fn get_current_clip(&mut self) {
let mut time_sec = self.get_current_time();
let shift = *self.playout_stat.time_shift.lock().unwrap();
if shift != 0.0 {
info!("Shift playlist start for <yellow>{shift:.3}</> seconds");
time_sec += shift;
}
if self.config.playlist.infinit
&& self.json_playlist.length.unwrap() < 86400.0
&& time_sec > self.json_playlist.length.unwrap() + self.start_sec
{
self.recalculate_begin(true)
}
for (i, item) in self
.player_control
.current_list
.lock()
.unwrap()
.iter()
.enumerate()
{
if item.begin.unwrap() + item.out - item.seek > time_sec {
self.playout_stat.list_init.store(false, Ordering::SeqCst);
self.player_control.current_index.store(i, Ordering::SeqCst);
break;
}
}
}
// Prepare init clip.
fn init_clip(&mut self) -> bool {
trace!("init_clip");
self.get_current_clip();
let mut is_filler = false;
if !self.playout_stat.list_init.load(Ordering::SeqCst) {
let time_sec = self.get_current_time();
let index = self.player_control.current_index.load(Ordering::SeqCst);
let nodes = self.player_control.current_list.lock().unwrap();
let last_index = nodes.len() - 1;
// de-instance node to preserve original values in list
let mut node_clone = nodes[index].clone();
// Important! When no manual drop is happen here, lock is still active in handle_list_init
drop(nodes);
trace!("Clip from init: {}", node_clone.source);
node_clone.seek += time_sec
- (node_clone.begin.unwrap() - *self.playout_stat.time_shift.lock().unwrap());
self.last_next_ad(&mut node_clone);
self.player_control
.current_index
.fetch_add(1, Ordering::SeqCst);
self.current_node = handle_list_init(
&self.config,
node_clone,
&self.playout_stat,
&self.player_control,
last_index,
);
if self
.current_node
.source
.contains(&self.config.storage.path.to_string_lossy().to_string())
|| self.current_node.source.contains("color=c=#121212")
{
is_filler = true;
}
}
is_filler
}
fn fill_end(&mut self, total_delta: f64) {
// Fill end from playlist
let index = self.player_control.current_index.load(Ordering::SeqCst);
let mut media = Media::new(index, "", false);
media.begin = Some(time_in_seconds());
media.duration = total_delta;
media.out = total_delta;
self.last_next_ad(&mut media);
self.current_node = gen_source(
&self.config,
media,
&self.playout_stat,
&self.player_control,
0,
);
self.player_control
.current_list
.lock()
.unwrap()
.push(self.current_node.clone());
self.current_node.last_ad = self.last_node_ad;
self.current_node
.add_filter(&self.config, &self.playout_stat.chain);
self.player_control
.current_index
.fetch_add(1, Ordering::SeqCst);
}
fn recalculate_begin(&mut self, extend: bool) {
debug!("Infinit playlist reaches end, recalculate clip begins.");
let mut time_sec = time_in_seconds();
if extend {
time_sec = self.start_sec + self.json_playlist.length.unwrap();
}
self.json_playlist.start_sec = Some(time_sec);
set_defaults(&mut self.json_playlist);
self.player_control
.current_list
.lock()
.unwrap()
.clone_from(&self.json_playlist.program);
}
}
/// Build the playlist iterator
impl Iterator for CurrentProgram {
type Item = Media;
fn next(&mut self) -> Option<Self::Item> {
self.last_json_path.clone_from(&self.json_playlist.path);
self.last_node_ad = self.current_node.last_ad;
self.check_for_playlist(self.playout_stat.list_init.load(Ordering::SeqCst));
if self.playout_stat.list_init.load(Ordering::SeqCst) {
trace!("Init playlist, from next iterator");
let mut init_clip_is_filler = false;
if self.json_playlist.path.is_some() {
init_clip_is_filler = self.init_clip();
}
if self.playout_stat.list_init.load(Ordering::SeqCst) && !init_clip_is_filler {
// On init load, playlist could be not long enough, or clips are not found
// so we fill the gap with a dummy.
trace!("Init clip is no filler");
let mut current_time = time_in_seconds();
let (_, total_delta) = get_delta(&self.config, &current_time);
if self.start_sec > current_time {
current_time += self.end_sec + 1.0;
}
let mut last_index = 0;
let length = self.player_control.current_list.lock().unwrap().len();
if length > 0 {
last_index = length - 1;
}
let mut media = Media::new(length, "", false);
media.begin = Some(current_time);
media.duration = total_delta;
media.out = total_delta;
self.last_next_ad(&mut media);
self.current_node = gen_source(
&self.config,
media,
&self.playout_stat,
&self.player_control,
last_index,
);
}
return Some(self.current_node.clone());
}
if self.player_control.current_index.load(Ordering::SeqCst)
< self.player_control.current_list.lock().unwrap().len()
{
// get next clip from current playlist
let mut is_last = false;
let index = self.player_control.current_index.load(Ordering::SeqCst);
let node_list = self.player_control.current_list.lock().unwrap();
let mut node = node_list[index].clone();
let last_index = node_list.len() - 1;
drop(node_list);
if index == last_index {
is_last = true
}
self.last_next_ad(&mut node);
self.current_node = timed_source(
node,
&self.config,
is_last,
&self.playout_stat,
&self.player_control,
last_index,
);
self.player_control
.current_index
.fetch_add(1, Ordering::SeqCst);
Some(self.current_node.clone())
} else {
let (_, total_delta) = get_delta(&self.config, &self.start_sec);
if !self.config.playlist.infinit
&& self.last_json_path == self.json_playlist.path
&& total_delta.abs() > 1.0
{
// Playlist is to early finish,
// and if we have to fill it with a placeholder.
trace!("Total delta on list end: {total_delta}");
self.fill_end(total_delta);
return Some(self.current_node.clone());
}
// Get first clip from next playlist.
let c_list = self.player_control.current_list.lock().unwrap();
let mut first_node = c_list[0].clone();
drop(c_list);
if self.config.playlist.infinit {
self.recalculate_begin(false)
}
self.player_control.current_index.store(0, Ordering::SeqCst);
self.last_next_ad(&mut first_node);
first_node.last_ad = self.last_node_ad;
self.current_node = gen_source(
&self.config,
first_node,
&self.playout_stat,
&self.player_control,
0,
);
self.player_control.current_index.store(1, Ordering::SeqCst);
Some(self.current_node.clone())
}
}
}
/// Prepare input clip:
///
/// - check begin and length from clip
/// - return clip only if we are in 24 hours time range
fn timed_source(
node: Media,
config: &PlayoutConfig,
last: bool,
playout_stat: &PlayoutStatus,
player_control: &PlayerControl,
last_index: usize,
) -> Media {
let (delta, total_delta) = get_delta(config, &node.begin.unwrap());
let mut shifted_delta = delta;
let mut new_node = node.clone();
new_node.process = Some(false);
trace!("Node begin: {}", node.begin.unwrap());
trace!("timed source is last: {last}");
if config.playlist.length.contains(':') {
let time_shift = playout_stat.time_shift.lock().unwrap();
if *playout_stat.current_date.lock().unwrap() == *playout_stat.date.lock().unwrap()
&& *time_shift != 0.0
{
shifted_delta = delta - *time_shift;
debug!("Delta: <yellow>{shifted_delta:.3}</>, shifted: <yellow>{delta:.3}</>");
} else {
debug!("Delta: <yellow>{shifted_delta:.3}</>");
}
if config.general.stop_threshold > 0.0
&& shifted_delta.abs() > config.general.stop_threshold
{
error!("Clip begin out of sync for <yellow>{delta:.3}</> seconds.");
new_node.cmd = None;
return new_node;
}
}
if (total_delta > node.out - node.seek && !last)
|| node.index.unwrap() < 2
|| !config.playlist.length.contains(':')
|| config.playlist.infinit
{
// when we are in the 24 hour range, get the clip
new_node.process = Some(true);
new_node = gen_source(config, node, playout_stat, player_control, last_index);
} else if total_delta <= 0.0 {
info!("Begin is over play time, skip: {}", node.source);
} else if total_delta < node.duration - node.seek || last {
new_node = handle_list_end(
config,
node,
total_delta,
playout_stat,
player_control,
last_index,
);
}
new_node
}
fn duplicate_for_seek_and_loop(node: &mut Media, player_control: &PlayerControl) {
warn!("Clip loops and has seek value: duplicate clip to separate loop and seek.");
let mut nodes = player_control.current_list.lock().unwrap();
let index = node.index.unwrap_or_default();
let mut node_duplicate = node.clone();
node_duplicate.seek = 0.0;
let orig_seek = node.seek;
node.out = node.duration;
if node.seek > node.duration {
node.seek %= node.duration;
node_duplicate.out = node_duplicate.out - orig_seek - (node.out - node.seek);
} else {
node_duplicate.out -= node_duplicate.duration;
}
if node.seek == node.out {
node.seek = node_duplicate.seek;
node.out = node_duplicate.out;
} else if node_duplicate.out - node_duplicate.seek > 1.2 {
node_duplicate.begin =
Some(node_duplicate.begin.unwrap_or_default() + (node.out - node.seek));
nodes.insert(index + 1, node_duplicate);
for (i, item) in nodes.iter_mut().enumerate() {
item.index = Some(i);
}
}
}
/// Generate the source CMD, or when clip not exist, get a dummy.
pub fn gen_source(
config: &PlayoutConfig,
mut node: Media,
playout_stat: &PlayoutStatus,
player_control: &PlayerControl,
last_index: usize,
) -> Media {
let node_index = node.index.unwrap_or_default();
let mut duration = node.out - node.seek;
if duration < 1.0 {
warn!("Clip is less then 1 second long (<yellow>{duration:.3}</>), adjust length.");
duration = 1.2;
if node.seek > 1.0 {
node.seek -= 1.2;
} else {
node.out = 1.2;
}
}
trace!("Clip new length: {duration}, duration: {}", node.duration);
if node.probe.is_none() && !node.source.is_empty() {
if let Err(e) = node.add_probe(true) {
trace!("{e:?}");
};
} else {
trace!("Node has a probe...")
}
// separate if condition, because of node.add_probe() in last condition
if node.probe.is_some() {
if node
.source
.rsplit_once('.')
.map(|(_, e)| e.to_lowercase())
.filter(|c| IMAGE_FORMAT.contains(&c.as_str()))
.is_some()
{
node.cmd = Some(loop_image(&node));
} else {
if node.seek > 0.0 && node.out > node.duration {
duplicate_for_seek_and_loop(&mut node, player_control);
}
node.cmd = Some(seek_and_length(&mut node));
}
} else {
trace!("clip index: {node_index} | last index: {last_index}");
// Last index is the index from the last item from the node list.
if node_index < last_index {
error!("Source not found: <b><magenta>{}</></b>", node.source);
}
let mut filler_list = vec![];
match player_control.filler_list.try_lock() {
Ok(list) => filler_list = list.to_vec(),
Err(e) => error!("Lock filler list error: {e}"),
}
// Set list_init to true, to stay in sync.
playout_stat.list_init.store(true, Ordering::SeqCst);
if config.storage.filler.is_dir() && !filler_list.is_empty() {
let filler_index = player_control.filler_index.fetch_add(1, Ordering::SeqCst);
let mut filler_media = filler_list[filler_index].clone();
trace!("take filler: {}", filler_media.source);
if filler_index == filler_list.len() - 1 {
// reset index for next round
player_control.filler_index.store(0, Ordering::SeqCst)
}
if filler_media.probe.is_none() {
if let Err(e) = filler_media.add_probe(false) {
error!("{e:?}");
};
}
if filler_media.duration > duration {
filler_media.out = duration;
}
node.source = filler_media.source;
node.seek = 0.0;
node.out = filler_media.out;
node.duration = filler_media.duration;
node.cmd = Some(loop_filler(&node));
node.probe = filler_media.probe;
} else {
match MediaProbe::new(&config.storage.filler.to_string_lossy()) {
Ok(probe) => {
if config
.storage
.filler
.to_string_lossy()
.to_string()
.rsplit_once('.')
.map(|(_, e)| e.to_lowercase())
.filter(|c| IMAGE_FORMAT.contains(&c.as_str()))
.is_some()
{
node.source = config.storage.filler.clone().to_string_lossy().to_string();
node.cmd = Some(loop_image(&node));
node.probe = Some(probe);
} else if let Some(filler_duration) = probe
.clone()
.format
.duration
.and_then(|d| d.parse::<f64>().ok())
{
// Create placeholder from config filler.
let mut filler_out = filler_duration;
if filler_duration > duration {
filler_out = duration;
}
node.source = config.storage.filler.clone().to_string_lossy().to_string();
node.seek = 0.0;
node.out = filler_out;
node.duration = filler_duration;
node.cmd = Some(loop_filler(&node));
node.probe = Some(probe);
} else {
// Create colored placeholder.
let (source, cmd) = gen_dummy(config, duration);
node.source = source;
node.cmd = Some(cmd);
}
}
Err(e) => {
// Create colored placeholder.
error!("Filler error: {e}");
let mut dummy_duration = 60.0;
if dummy_duration > duration {
dummy_duration = duration;
}
let (source, cmd) = gen_dummy(config, dummy_duration);
node.seek = 0.0;
node.out = dummy_duration;
node.duration = dummy_duration;
node.source = source;
node.cmd = Some(cmd);
}
}
}
warn!(
"Generate filler with <yellow>{:.2}</> seconds length!",
node.out
);
}
node.add_filter(config, &playout_stat.chain);
trace!(
"return gen_source: {}, seek: {}, out: {}",
node.source,
node.seek,
node.out,
);
node
}
/// Handle init clip, but this clip can be the last one in playlist,
/// this we have to figure out and calculate the right length.
fn handle_list_init(
config: &PlayoutConfig,
mut node: Media,
playout_stat: &PlayoutStatus,
player_control: &PlayerControl,
last_index: usize,
) -> Media {
debug!("Playlist init");
let (_, total_delta) = get_delta(config, &node.begin.unwrap());
if !config.playlist.infinit && node.out - node.seek > total_delta {
node.out = total_delta + node.seek;
}
gen_source(config, node, playout_stat, player_control, last_index)
}
/// when we come to last clip in playlist,
/// or when we reached total playtime,
/// we end up here
fn handle_list_end(
config: &PlayoutConfig,
mut node: Media,
total_delta: f64,
playout_stat: &PlayoutStatus,
player_control: &PlayerControl,
last_index: usize,
) -> Media {
debug!("Last clip from day");
let mut out = if node.seek > 0.0 {
node.seek + total_delta
} else {
if node.duration > total_delta {
warn!("Adjust clip duration to: <yellow>{total_delta:.2}</>");
}
total_delta
};
// out can't be longer then duration
if out > node.duration {
out = node.duration
}
if node.duration > total_delta && total_delta > 1.0 && node.duration - node.seek >= total_delta
{
node.out = out;
} else {
warn!("Playlist is not long enough: <yellow>{total_delta:.2}</> seconds needed");
}
node.process = Some(true);
gen_source(config, node, playout_stat, player_control, last_index)
}

View File

@ -1,4 +0,0 @@
pub mod input;
pub mod output;
pub mod rpc;
pub mod utils;

View File

@ -1,236 +0,0 @@
use std::{
fs::{self, File},
path::Path,
process::exit,
sync::{atomic::AtomicBool, Arc, Mutex},
thread,
};
#[cfg(debug_assertions)]
use chrono::prelude::*;
use serde::{Deserialize, Serialize};
use serde_json::json;
use simplelog::*;
use ffplayout_engine::{
output::{player, write_hls},
rpc::run_server,
utils::{arg_parse::get_args, get_config},
};
use ffplayout_lib::utils::{
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)]
use ffplayout_engine::utils::Args;
#[cfg(debug_assertions)]
use ffplayout_lib::utils::{mock_time, time_now};
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Serialize, Deserialize)]
struct StatusData {
time_shift: f64,
date: String,
}
/// Here we create a status file in temp folder.
/// We need this for reading/saving program status.
/// For example when we skip a playing file,
/// 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) -> Result<(), ProcError> {
debug!("Start ffplayout v{VERSION}, status file path: <b><magenta>{stat_file}</></b>");
if !Path::new(stat_file).exists() {
let data = json!({
"time_shift": 0.0,
"date": String::new(),
});
let json: String = serde_json::to_string(&data)?;
if let Err(e) = fs::write(stat_file, json) {
error!("Unable to write to status file <b><magenta>{stat_file}</></b>: {e}");
};
} else {
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.
/// When no time is given, we use the current time.
/// When a time is given, we use this time instead.
#[cfg(debug_assertions)]
fn fake_time(args: &Args) {
if let Some(fake_time) = &args.fake_time {
mock_time::set_mock_time(fake_time);
} else {
let local: DateTime<Local> = time_now();
mock_time::set_mock_time(&local.format("%Y-%m-%dT%H:%M:%S").to_string());
}
}
/// Main function.
/// Here we check the command line arguments and start the player.
/// We also start a JSON RPC server if enabled.
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 play_control = PlayerControl::new();
let playout_stat = PlayoutStatus::new();
let proc_control = ProcessControl::new();
let play_ctl1 = play_control.clone();
let play_ctl2 = play_control.clone();
let play_stat = playout_stat.clone();
let proc_ctl1 = proc_control.clone();
let proc_ctl2 = proc_control.clone();
let messages = Arc::new(Mutex::new(Vec::new()));
// try to create logging folder, if not exist
if config.logging.log_to_file
&& !config.logging.path.is_dir()
&& !config.logging.path.ends_with(".log")
{
if let Err(e) = fs::create_dir_all(&config.logging.path) {
eprintln!("Logging path not exists! {e}");
exit(1);
}
}
let logging = init_logging(&config, Some(proc_ctl1), Some(messages.clone()));
CombinedLogger::init(logging)?;
if let Err(e) = validate_ffmpeg(&mut config) {
error!("{e}");
exit(1);
};
let config_clone1 = config.clone();
let config_clone2 = config.clone();
if !matches!(config.processing.audio_channels, 2 | 4 | 6 | 8) {
error!(
"Encoding {} channel(s) is not allowed. Only 2, 4, 6 and 8 channels are supported!",
config.processing.audio_channels
);
exit(1);
}
if config.general.generate.is_some() {
// run a simple playlist generator and save them to disk
if let Err(e) = generate_playlist(&config, None) {
error!("{e}");
exit(1);
};
exit(0);
}
if let Some(path) = args.import {
if args.date.is_none() {
error!("Import needs date parameter!");
exit(1);
}
// convert text/m3u file to playlist
match import_file(&config, &args.date.unwrap(), None, &path) {
Ok(m) => {
info!("{m}");
exit(0);
}
Err(e) => {
error!("{e}");
exit(1);
}
}
}
if args.validate {
let play_ctl3 = play_control.clone();
let mut playlist_path = config.playlist.path.clone();
let start_sec = config.playlist.start_sec.unwrap();
let date = get_date(false, start_sec, false);
if playlist_path.is_dir() || is_remote(&playlist_path.to_string_lossy()) {
let d: Vec<&str> = date.split('-').collect();
playlist_path = playlist_path
.join(d[0])
.join(d[1])
.join(date.clone())
.with_extension("json");
}
let f = File::options()
.read(true)
.write(false)
.open(&playlist_path)?;
let playlist: JsonPlaylist = serde_json::from_reader(f)?;
validate_playlist(
config,
play_ctl3,
playlist,
Arc::new(AtomicBool::new(false)),
);
exit(0);
}
if config.rpc_server.enable {
// If RPC server is enable we also fire up a JSON RPC server.
if !test_tcp_port(&config.rpc_server.address) {
exit(1)
}
thread::spawn(move || run_server(config_clone1, play_ctl1, play_stat, proc_ctl2));
}
status_file(&config.general.stat_file, &playout_stat)?;
debug!(
"Use config: <b><magenta>{}</></b>",
config.general.config_path
);
// Fill filler list, can also be a single file.
thread::spawn(move || {
fill_filler_list(&config_clone2, Some(play_ctl2));
});
match config.out.mode {
// write files/playlist to HLS m3u8 playlist
HLS => write_hls(&config, play_control, playout_stat, proc_control),
// play on desktop or stream to a remote target
_ => player(&config, &play_control, playout_stat, proc_control),
}
info!("Playout done...");
let msg = messages.lock().unwrap();
if msg.len() > 0 {
send_mail(&config, msg.join("\n"));
}
drop(msg);
Ok(())
}

View File

@ -1,88 +0,0 @@
use std::process::{self, Command, Stdio};
use simplelog::*;
use ffplayout_lib::{filter::v_drawtext, utils::PlayoutConfig, vec_strings};
/// Desktop Output
///
/// Instead of streaming, we run a ffplay instance and play on desktop.
pub fn output(config: &PlayoutConfig, log_format: &str) -> process::Child {
let mut enc_filter: Vec<String> = vec![];
let mut enc_cmd = vec_strings!["-hide_banner", "-nostats", "-v", log_format];
if let Some(encoder_input_cmd) = config
.advanced
.as_ref()
.and_then(|a| a.encoder.input_cmd.clone())
{
enc_cmd.append(&mut encoder_input_cmd.clone());
}
enc_cmd.append(&mut vec_strings![
"-autoexit",
"-i",
"pipe:0",
"-window_title",
"ffplayout"
]);
if let Some(mut cmd) = config.out.output_cmd.clone() {
if !cmd.iter().any(|i| {
[
"-c",
"-c:v",
"-c:v:0",
"-b:v",
"-b:v:0",
"-vcodec",
"-c:a",
"-acodec",
"-crf",
"-map",
"-filter_complex",
]
.contains(&i.as_str())
}) {
enc_cmd.append(&mut cmd);
} else {
warn!("ffplay doesn't support given output parameters, they will be skipped!");
}
}
if config.text.add_text && !config.text.text_from_filename && !config.processing.audio_only {
if let Some(socket) = config.text.zmq_stream_socket.clone() {
debug!(
"Using drawtext filter, listening on address: <yellow>{}</>",
socket
);
let mut filter: String = "null,".to_string();
filter.push_str(v_drawtext::filter_node(config, None, &None).as_str());
enc_filter = vec!["-vf".to_string(), filter];
}
}
enc_cmd.append(&mut enc_filter);
debug!(
"Encoder CMD: <bright-blue>\"ffplay {}\"</>",
enc_cmd.join(" ")
);
let enc_proc = match Command::new("ffplay")
.args(enc_cmd)
.stdin(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Err(e) => {
error!("couldn't spawn encoder process: {e}");
panic!("couldn't spawn encoder process: {e}")
}
Ok(proc) => proc,
};
enc_proc
}

View File

@ -1,275 +0,0 @@
/*
This module write the files compression directly to a hls (m3u8) playlist,
without pre- and post-processing.
Example config:
out:
output_param: >-
...
-flags +cgop
-f hls
-hls_time 6
-hls_list_size 600
-hls_flags append_list+delete_segments+omit_endlist+program_date_time
-hls_segment_filename /var/www/html/live/stream-%d.ts /var/www/html/live/stream.m3u8
*/
use std::{
io::{BufRead, BufReader, Error},
process::{exit, Command, Stdio},
sync::atomic::Ordering,
thread::{self, sleep},
time::Duration,
};
use simplelog::*;
use crate::input::source_generator;
use crate::utils::{log_line, prepare_output_cmd, task_runner, valid_stream};
use ffplayout_lib::{
utils::{
controller::ProcessUnit::*, get_delta, sec_to_time, stderr_reader, test_tcp_port, Media,
PlayerControl, PlayoutConfig, PlayoutStatus, ProcessControl,
},
vec_strings,
};
/// Ingest Server for HLS
fn ingest_to_hls_server(
config: PlayoutConfig,
playout_stat: PlayoutStatus,
proc_control: ProcessControl,
) -> Result<(), Error> {
let playlist_init = playout_stat.list_init;
let mut server_prefix = vec_strings!["-hide_banner", "-nostats", "-v", "level+info"];
let stream_input = config.ingest.input_cmd.clone().unwrap();
let mut dummy_media = Media::new(0, "Live Stream", false);
dummy_media.unit = Ingest;
if let Some(ingest_input_cmd) = config
.advanced
.as_ref()
.and_then(|a| a.ingest.input_cmd.clone())
{
server_prefix.append(&mut ingest_input_cmd.clone());
}
server_prefix.append(&mut stream_input.clone());
let mut is_running;
if let Some(url) = stream_input.iter().find(|s| s.contains("://")) {
if !test_tcp_port(url) {
proc_control.stop_all();
exit(1);
}
info!("Start ingest server, listening on: <b><magenta>{url}</></b>");
};
loop {
dummy_media.add_filter(&config, &playout_stat.chain);
let server_cmd = prepare_output_cmd(&config, server_prefix.clone(), &dummy_media.filter);
debug!(
"Server CMD: <bright-blue>\"ffmpeg {}\"</>",
server_cmd.join(" ")
);
let proc_ctl = proc_control.clone();
let mut server_proc = match Command::new("ffmpeg")
.args(server_cmd.clone())
.stderr(Stdio::piped())
.spawn()
{
Err(e) => {
error!("couldn't spawn ingest server: {e}");
panic!("couldn't spawn ingest server: {e}");
}
Ok(proc) => proc,
};
let server_err = BufReader::new(server_proc.stderr.take().unwrap());
*proc_control.server_term.lock().unwrap() = Some(server_proc);
is_running = false;
for line in server_err.lines() {
let line = line?;
if line.contains("rtmp") && line.contains("Unexpected stream") && !valid_stream(&line) {
if let Err(e) = proc_ctl.stop(Ingest) {
error!("{e}");
};
}
if !is_running {
proc_control.server_is_running.store(true, Ordering::SeqCst);
playlist_init.store(true, Ordering::SeqCst);
is_running = true;
info!("Switch from {} to live ingest", config.processing.mode);
if let Err(e) = proc_control.stop(Decoder) {
error!("{e}");
}
}
log_line(&line, &config.logging.ffmpeg_level);
}
if proc_control.server_is_running.load(Ordering::SeqCst) {
info!("Switch from live ingest to {}", config.processing.mode);
}
proc_control
.server_is_running
.store(false, Ordering::SeqCst);
if let Err(e) = proc_control.wait(Ingest) {
error!("{e}")
}
if proc_control.is_terminated.load(Ordering::SeqCst) {
break;
}
}
Ok(())
}
/// HLS Writer
///
/// Write with single ffmpeg instance directly to a HLS playlist.
pub fn write_hls(
config: &PlayoutConfig,
player_control: PlayerControl,
playout_stat: PlayoutStatus,
proc_control: ProcessControl,
) {
let config_clone = config.clone();
let ff_log_format = format!("level+{}", config.logging.ffmpeg_level.to_lowercase());
let play_stat = playout_stat.clone();
let play_stat2 = playout_stat.clone();
let proc_control_c = proc_control.clone();
let get_source = source_generator(
config.clone(),
&player_control,
playout_stat,
proc_control.is_terminated.clone(),
);
// spawn a thread for ffmpeg ingest server and create a channel for package sending
if config.ingest.enable {
thread::spawn(move || ingest_to_hls_server(config_clone, play_stat, proc_control_c));
}
for node in get_source {
*player_control.current_media.lock().unwrap() = Some(node.clone());
let ignore = config.logging.ignore_lines.clone();
let mut cmd = match &node.cmd {
Some(cmd) => cmd.clone(),
None => break,
};
if !node.process.unwrap() {
continue;
}
info!(
"Play for <yellow>{}</>: <b><magenta>{}</></b>",
sec_to_time(node.out - node.seek),
node.source
);
if config.task.enable {
if config.task.path.is_file() {
let task_config = config.clone();
let task_node = node.clone();
let server_running = proc_control.server_is_running.load(Ordering::SeqCst);
let stat = play_stat2.clone();
thread::spawn(move || {
task_runner::run(task_config, task_node, stat, server_running)
});
} else {
error!(
"<bright-blue>{:?}</> executable not exists!",
config.task.path
);
}
}
let mut enc_prefix = vec_strings!["-hide_banner", "-nostats", "-v", &ff_log_format];
if let Some(encoder_input_cmd) = config
.advanced
.as_ref()
.and_then(|a| a.encoder.input_cmd.clone())
{
enc_prefix.append(&mut encoder_input_cmd.clone());
}
let mut read_rate = 1.0;
if let Some(begin) = &node.begin {
let (delta, _) = get_delta(config, begin);
let duration = node.out - node.seek;
let speed = duration / (duration + delta);
if node.seek == 0.0
&& speed > 0.0
&& speed < 1.3
&& delta < config.general.stop_threshold
{
read_rate = speed;
}
}
enc_prefix.append(&mut vec_strings!["-readrate", read_rate]);
enc_prefix.append(&mut cmd);
let enc_cmd = prepare_output_cmd(config, enc_prefix, &node.filter);
debug!(
"HLS writer CMD: <bright-blue>\"ffmpeg {}\"</>",
enc_cmd.join(" ")
);
let mut dec_proc = match Command::new("ffmpeg")
.args(enc_cmd)
.stderr(Stdio::piped())
.spawn()
{
Ok(proc) => proc,
Err(e) => {
error!("couldn't spawn ffmpeg process: {e}");
panic!("couldn't spawn ffmpeg process: {e}")
}
};
let enc_err = BufReader::new(dec_proc.stderr.take().unwrap());
*proc_control.decoder_term.lock().unwrap() = Some(dec_proc);
if let Err(e) = stderr_reader(enc_err, ignore, Decoder, proc_control.clone()) {
error!("{e:?}")
};
if let Err(e) = proc_control.wait(Decoder) {
error!("{e}");
}
while proc_control.server_is_running.load(Ordering::SeqCst) {
sleep(Duration::from_secs(1));
}
}
sleep(Duration::from_secs(1));
proc_control.stop_all();
}

View File

@ -1,263 +0,0 @@
use std::{
io::{prelude::*, BufReader, BufWriter, Read},
process::{Command, Stdio},
sync::atomic::Ordering,
thread::{self, sleep},
time::Duration,
};
use crossbeam_channel::bounded;
use simplelog::*;
mod desktop;
mod hls;
mod null;
mod stream;
pub use hls::write_hls;
use crate::input::{ingest_server, source_generator};
use crate::utils::task_runner;
use ffplayout_lib::utils::{
sec_to_time, stderr_reader, OutputMode::*, PlayerControl, PlayoutConfig, PlayoutStatus,
ProcessControl, ProcessUnit::*,
};
use ffplayout_lib::vec_strings;
/// Player
///
/// Here we create the input file loop, from playlist, or folder source.
/// Then we read the stdout from the reader ffmpeg instance
/// and write it to the stdin from the streamer ffmpeg instance.
/// If it is configured we also fire up a ffmpeg ingest server instance,
/// for getting live feeds.
/// When a live ingest arrive, it stops the current playing and switch to the live source.
/// When ingest stops, it switch back to playlist/folder mode.
pub fn player(
config: &PlayoutConfig,
play_control: &PlayerControl,
playout_stat: PlayoutStatus,
proc_control: ProcessControl,
) {
let config_clone = config.clone();
let ff_log_format = format!("level+{}", config.logging.ffmpeg_level.to_lowercase());
let ignore_enc = config.logging.ignore_lines.clone();
let mut buffer = [0; 65088];
let mut live_on = false;
let playlist_init = playout_stat.list_init.clone();
let play_stat = playout_stat.clone();
// get source iterator
let node_sources = source_generator(
config.clone(),
play_control,
playout_stat,
proc_control.is_terminated.clone(),
);
// get ffmpeg output instance
let mut enc_proc = match config.out.mode {
Desktop => desktop::output(config, &ff_log_format),
Null => null::output(config, &ff_log_format),
Stream => stream::output(config, &ff_log_format),
_ => panic!("Output mode doesn't exists!"),
};
let mut enc_writer = BufWriter::new(enc_proc.stdin.take().unwrap());
let enc_err = BufReader::new(enc_proc.stderr.take().unwrap());
*proc_control.encoder_term.lock().unwrap() = Some(enc_proc);
let enc_p_ctl = proc_control.clone();
// spawn a thread to log ffmpeg output error messages
let error_encoder_thread =
thread::spawn(move || stderr_reader(enc_err, ignore_enc, Encoder, enc_p_ctl));
let proc_control_c = proc_control.clone();
let mut ingest_receiver = None;
// spawn a thread for ffmpeg ingest server and create a channel for package sending
if config.ingest.enable {
let (ingest_sender, rx) = bounded(96);
ingest_receiver = Some(rx);
thread::spawn(move || ingest_server(config_clone, ingest_sender, proc_control_c));
}
'source_iter: for node in node_sources {
*play_control.current_media.lock().unwrap() = Some(node.clone());
let ignore_dec = config.logging.ignore_lines.clone();
if proc_control.is_terminated.load(Ordering::SeqCst) {
debug!("Playout is terminated, break out from source loop");
break;
}
trace!("Decoder CMD: {:?}", node.cmd);
let mut cmd = match &node.cmd {
Some(cmd) => cmd.clone(),
None => break,
};
if !node.process.unwrap() {
// process true/false differs from node.cmd = None in that way,
// that source is valid but to show for playing,
// so better skip it and jump to the next one.
continue;
}
let c_index = if cfg!(debug_assertions) {
format!(
" ({}/{})",
node.index.unwrap() + 1,
play_control.current_list.lock().unwrap().len()
)
} else {
String::new()
};
info!(
"Play for <yellow>{}</>{c_index}: <b><magenta>{} {}</></b>",
sec_to_time(node.out - node.seek),
node.source,
node.audio
);
if config.task.enable {
if config.task.path.is_file() {
let task_config = config.clone();
let task_node = node.clone();
let server_running = proc_control.server_is_running.load(Ordering::SeqCst);
let stat = play_stat.clone();
thread::spawn(move || {
task_runner::run(task_config, task_node, stat, server_running)
});
} else {
error!(
"<bright-blue>{:?}</> executable not exists!",
config.task.path
);
}
}
let mut dec_cmd = vec_strings!["-hide_banner", "-nostats", "-v", &ff_log_format];
if let Some(decoder_input_cmd) = config
.advanced
.as_ref()
.and_then(|a| a.decoder.input_cmd.clone())
{
dec_cmd.append(&mut decoder_input_cmd.clone());
}
dec_cmd.append(&mut cmd);
if let Some(mut filter) = node.filter {
dec_cmd.append(&mut filter.cmd());
dec_cmd.append(&mut filter.map());
}
if let Some(mut cmd) = config.processing.cmd.clone() {
dec_cmd.append(&mut cmd);
}
debug!(
"Decoder CMD: <bright-blue>\"ffmpeg {}\"</>",
dec_cmd.join(" ")
);
// create ffmpeg decoder instance, for reading the input files
let mut dec_proc = match Command::new("ffmpeg")
.args(dec_cmd)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(proc) => proc,
Err(e) => {
error!("couldn't spawn decoder process: {e}");
panic!("couldn't spawn decoder process: {e}")
}
};
let mut dec_reader = BufReader::new(dec_proc.stdout.take().unwrap());
let dec_err = BufReader::new(dec_proc.stderr.take().unwrap());
*proc_control.decoder_term.lock().unwrap() = Some(dec_proc);
let dec_p_ctl = proc_control.clone();
let error_decoder_thread =
thread::spawn(move || stderr_reader(dec_err, ignore_dec, Decoder, dec_p_ctl));
loop {
// when server is running, read from it
if proc_control.server_is_running.load(Ordering::SeqCst) {
if !live_on {
info!("Switch from {} to live ingest", config.processing.mode);
if let Err(e) = proc_control.stop(Decoder) {
error!("{e}")
}
live_on = true;
playlist_init.store(true, Ordering::SeqCst);
}
for rx in ingest_receiver.as_ref().unwrap().try_iter() {
if let Err(e) = enc_writer.write(&rx.1[..rx.0]) {
error!("Error from Ingest: {:?}", e);
break 'source_iter;
};
}
// read from decoder instance
} else {
if live_on {
info!("Switch from live ingest to {}", config.processing.mode);
live_on = false;
break;
}
let dec_bytes_len = match dec_reader.read(&mut buffer[..]) {
Ok(length) => length,
Err(e) => {
error!("Reading error from decoder: {e:?}");
break 'source_iter;
}
};
if dec_bytes_len > 0 {
if let Err(e) = enc_writer.write(&buffer[..dec_bytes_len]) {
error!("Encoder write error: {}", e.kind());
break 'source_iter;
};
} else {
break;
}
}
}
if let Err(e) = proc_control.wait(Decoder) {
error!("{e}")
}
if let Err(e) = error_decoder_thread.join() {
error!("{e:?}");
};
}
trace!("Out of source loop");
sleep(Duration::from_secs(1));
proc_control.stop_all();
if let Err(e) = error_encoder_thread.join() {
error!("{e:?}");
};
}

View File

@ -1,52 +0,0 @@
use std::process::{self, Command, Stdio};
use simplelog::*;
use crate::utils::prepare_output_cmd;
use ffplayout_lib::{
utils::{Media, PlayoutConfig, ProcessUnit::*},
vec_strings,
};
/// Desktop Output
///
/// Instead of streaming, we run a ffplay instance and play on desktop.
pub fn output(config: &PlayoutConfig, log_format: &str) -> process::Child {
let mut media = Media::new(0, "", false);
media.unit = Encoder;
media.add_filter(config, &None);
let mut enc_prefix = vec_strings!["-hide_banner", "-nostats", "-v", log_format];
if let Some(input_cmd) = config
.advanced
.as_ref()
.and_then(|a| a.encoder.input_cmd.clone())
{
enc_prefix.append(&mut input_cmd.clone());
}
enc_prefix.append(&mut vec_strings!["-re", "-i", "pipe:0"]);
let enc_cmd = prepare_output_cmd(config, enc_prefix, &media.filter);
debug!(
"Encoder CMD: <bright-blue>\"ffmpeg {}\"</>",
enc_cmd.join(" ")
);
let enc_proc = match Command::new("ffmpeg")
.args(enc_cmd)
.stdin(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Err(e) => {
error!("couldn't spawn encoder process: {e}");
panic!("couldn't spawn encoder process: {e}")
}
Ok(proc) => proc,
};
enc_proc
}

View File

@ -1,52 +0,0 @@
use std::process::{self, Command, Stdio};
use simplelog::*;
use crate::utils::prepare_output_cmd;
use ffplayout_lib::{
utils::{Media, PlayoutConfig, ProcessUnit::*},
vec_strings,
};
/// Streaming Output
///
/// Prepare the ffmpeg command for streaming output
pub fn output(config: &PlayoutConfig, log_format: &str) -> process::Child {
let mut media = Media::new(0, "", false);
media.unit = Encoder;
media.add_filter(config, &None);
let mut enc_prefix = vec_strings!["-hide_banner", "-nostats", "-v", log_format];
if let Some(input_cmd) = config
.advanced
.as_ref()
.and_then(|a| a.encoder.input_cmd.clone())
{
enc_prefix.append(&mut input_cmd.clone());
}
enc_prefix.append(&mut vec_strings!["-re", "-i", "pipe:0"]);
let enc_cmd = prepare_output_cmd(config, enc_prefix, &media.filter);
debug!(
"Encoder CMD: <bright-blue>\"ffmpeg {}\"</>",
enc_cmd.join(" ")
);
let enc_proc = match Command::new("ffmpeg")
.args(enc_cmd)
.stdin(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Err(e) => {
error!("couldn't spawn encoder process: {e}");
panic!("couldn't spawn encoder process: {e}")
}
Ok(proc) => proc,
};
enc_proc
}

View File

@ -1,5 +0,0 @@
mod server;
mod zmq_cmd;
pub use server::run_server;
pub use zmq_cmd::zmq_send;

View File

@ -1,587 +0,0 @@
use std::{fmt, sync::atomic::Ordering};
use regex::Regex;
extern crate serde;
extern crate serde_json;
extern crate tiny_http;
use futures::executor::block_on;
use serde::{
de::{self, Visitor},
Deserialize, Serialize,
};
use serde_json::{json, Map};
use simplelog::*;
use std::collections::HashMap;
use std::io::{Cursor, Error as IoError};
use tiny_http::{Header, Method, Request, Response, Server};
use crate::rpc::zmq_send;
use crate::utils::{get_data_map, get_media_map};
use ffplayout_lib::utils::{
get_delta, write_status, Ingest, OutputMode::*, PlayerControl, PlayoutConfig, PlayoutStatus,
ProcessControl,
};
#[derive(Default, Deserialize, Clone, Debug)]
struct TextFilter {
text: Option<String>,
#[serde(default, deserialize_with = "deserialize_number_or_string")]
x: Option<String>,
#[serde(default, deserialize_with = "deserialize_number_or_string")]
y: Option<String>,
#[serde(default, deserialize_with = "deserialize_number_or_string")]
fontsize: Option<String>,
#[serde(default, deserialize_with = "deserialize_number_or_string")]
line_spacing: Option<String>,
fontcolor: Option<String>,
#[serde(default, deserialize_with = "deserialize_number_or_string")]
alpha: Option<String>,
#[serde(default, deserialize_with = "deserialize_number_or_string")]
r#box: Option<String>,
boxcolor: Option<String>,
#[serde(default, deserialize_with = "deserialize_number_or_string")]
boxborderw: Option<String>,
}
/// Deserialize number or string
pub fn deserialize_number_or_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct StringOrNumberVisitor;
impl<'de> Visitor<'de> for StringOrNumberVisitor {
type Value = Option<String>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string or a number")
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
let re = Regex::new(r"0,([0-9]+)").unwrap();
let clean_string = re.replace_all(value, "0.$1").to_string();
Ok(Some(clean_string))
}
fn visit_u64<E: de::Error>(self, value: u64) -> Result<Self::Value, E> {
Ok(Some(value.to_string()))
}
fn visit_i64<E: de::Error>(self, value: i64) -> Result<Self::Value, E> {
Ok(Some(value.to_string()))
}
fn visit_f64<E: de::Error>(self, value: f64) -> Result<Self::Value, E> {
Ok(Some(value.to_string()))
}
}
deserializer.deserialize_any(StringOrNumberVisitor)
}
impl fmt::Display for TextFilter {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let escaped_text = self
.text
.clone()
.unwrap_or_default()
.replace('\'', "'\\\\\\''")
.replace('\\', "\\\\\\\\")
.replace('%', "\\\\\\%")
.replace(':', "\\:");
let mut s = format!("text='{escaped_text}'");
if let Some(v) = &self.x {
if !v.is_empty() {
s.push_str(&format!(":x='{v}'"));
}
}
if let Some(v) = &self.y {
if !v.is_empty() {
s.push_str(&format!(":y='{v}'"));
}
}
if let Some(v) = &self.fontsize {
if !v.is_empty() {
s.push_str(&format!(":fontsize={v}"));
}
}
if let Some(v) = &self.line_spacing {
if !v.is_empty() {
s.push_str(&format!(":line_spacing={v}"));
}
}
if let Some(v) = &self.fontcolor {
if !v.is_empty() {
s.push_str(&format!(":fontcolor={v}"));
}
}
if let Some(v) = &self.alpha {
if !v.is_empty() {
s.push_str(&format!(":alpha='{v}'"));
}
}
if let Some(v) = &self.r#box {
if !v.is_empty() {
s.push_str(&format!(":box={v}"));
}
}
if let Some(v) = &self.boxcolor {
if !v.is_empty() {
s.push_str(&format!(":boxcolor={v}"));
}
}
if let Some(v) = &self.boxborderw {
if !v.is_empty() {
s.push_str(&format!(":boxborderw={v}"));
}
}
write!(f, "{s}")
}
}
/// Covert JSON string to ffmpeg filter command.
fn filter_from_json(raw_text: serde_json::Value) -> String {
let filter: TextFilter = serde_json::from_value(raw_text).unwrap_or_default();
filter.to_string()
}
#[derive(Debug, Serialize, Deserialize)]
struct ResponseData {
message: String,
}
/// Read the request body and convert it to a string
fn read_request_body(request: &mut Request) -> Result<String, IoError> {
let mut buffer = String::new();
let body = request.as_reader();
match body.read_to_string(&mut buffer) {
Ok(_) => Ok(buffer),
Err(error) => Err(error),
}
}
/// create client response in JSON format
fn json_response(data: serde_json::Map<String, serde_json::Value>) -> Response<Cursor<Vec<u8>>> {
let response_body = serde_json::to_string(&data).unwrap();
// create HTTP-Response
Response::from_string(response_body)
.with_status_code(200)
.with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap())
}
/// create client error message
fn error_response(answer: &str, code: i32) -> Response<Cursor<Vec<u8>>> {
error!("RPC: {answer}");
Response::from_string(answer)
.with_status_code(code)
.with_header(Header::from_bytes(&b"Content-Type"[..], &b"text/plain"[..]).unwrap())
}
/// control playout: jump to last clip
fn control_back(
config: &PlayoutConfig,
play_control: &PlayerControl,
playout_stat: &PlayoutStatus,
proc: &ProcessControl,
) -> Response<Cursor<Vec<u8>>> {
let current_date = playout_stat.current_date.lock().unwrap().clone();
let current_list = play_control.current_list.lock().unwrap();
let mut date = playout_stat.date.lock().unwrap();
let index = play_control.current_index.load(Ordering::SeqCst);
let mut time_shift = playout_stat.time_shift.lock().unwrap();
if index > 1 && current_list.len() > 1 {
if let Some(proc) = proc.decoder_term.lock().unwrap().as_mut() {
if let Err(e) = proc.kill() {
error!("Decoder {e:?}")
};
if let Err(e) = proc.wait() {
error!("Decoder {e:?}")
};
info!("Move to last clip");
let mut data_map = Map::new();
let mut media = current_list[index - 2].clone();
play_control.current_index.fetch_sub(2, Ordering::SeqCst);
if let Err(e) = media.add_probe(false) {
error!("{e:?}");
};
let (delta, _) = get_delta(config, &media.begin.unwrap_or(0.0));
*time_shift = delta;
date.clone_from(&current_date);
write_status(config, &current_date, delta);
data_map.insert("operation".to_string(), json!("move_to_last"));
data_map.insert("shifted_seconds".to_string(), json!(delta));
data_map.insert("media".to_string(), get_media_map(media));
return json_response(data_map);
}
return error_response("Jump to last clip failed!", 500);
}
error_response("Clip index out of range!", 400)
}
/// control playout: jump to next clip
fn control_next(
config: &PlayoutConfig,
play_control: &PlayerControl,
playout_stat: &PlayoutStatus,
proc: &ProcessControl,
) -> Response<Cursor<Vec<u8>>> {
let current_date = playout_stat.current_date.lock().unwrap().clone();
let current_list = play_control.current_list.lock().unwrap();
let mut date = playout_stat.date.lock().unwrap();
let index = play_control.current_index.load(Ordering::SeqCst);
let mut time_shift = playout_stat.time_shift.lock().unwrap();
if index < current_list.len() {
if let Some(proc) = proc.decoder_term.lock().unwrap().as_mut() {
if let Err(e) = proc.kill() {
error!("Decoder {e:?}")
};
if let Err(e) = proc.wait() {
error!("Decoder {e:?}")
};
info!("Move to next clip");
let mut data_map = Map::new();
let mut media = current_list[index].clone();
if let Err(e) = media.add_probe(false) {
error!("{e:?}");
};
let (delta, _) = get_delta(config, &media.begin.unwrap_or(0.0));
*time_shift = delta;
date.clone_from(&current_date);
write_status(config, &current_date, delta);
data_map.insert("operation".to_string(), json!("move_to_next"));
data_map.insert("shifted_seconds".to_string(), json!(delta));
data_map.insert("media".to_string(), get_media_map(media));
return json_response(data_map);
}
return error_response("Jump to next clip failed!", 500);
}
error_response("Last clip can not be skipped!", 400)
}
/// control playout: reset playlist state
fn control_reset(
config: &PlayoutConfig,
playout_stat: &PlayoutStatus,
proc: &ProcessControl,
) -> Response<Cursor<Vec<u8>>> {
let current_date = playout_stat.current_date.lock().unwrap().clone();
let mut date = playout_stat.date.lock().unwrap();
let mut time_shift = playout_stat.time_shift.lock().unwrap();
if let Some(proc) = proc.decoder_term.lock().unwrap().as_mut() {
if let Err(e) = proc.kill() {
error!("Decoder {e:?}")
};
if let Err(e) = proc.wait() {
error!("Decoder {e:?}")
};
info!("Reset playout to original state");
let mut data_map = Map::new();
*time_shift = 0.0;
date.clone_from(&current_date);
playout_stat.list_init.store(true, Ordering::SeqCst);
write_status(config, &current_date, 0.0);
data_map.insert("operation".to_string(), json!("reset_playout_state"));
return json_response(data_map);
}
error_response("Reset playout state failed!", 400)
}
/// control playout: stop playlout
fn control_stop(proc: &ProcessControl) -> Response<Cursor<Vec<u8>>> {
proc.stop_all();
let mut data_map = Map::new();
data_map.insert("message".to_string(), json!("Stop playout!"));
json_response(data_map)
}
/// control playout: create text filter for ffmpeg
fn control_text(
data: HashMap<String, serde_json::Value>,
config: &PlayoutConfig,
playout_stat: &PlayoutStatus,
proc: &ProcessControl,
) -> Response<Cursor<Vec<u8>>> {
if data.contains_key("message") {
let filter = filter_from_json(data["message"].clone());
debug!("Got drawtext command: <bright-blue>\"{filter}\"</>");
let mut data_map = Map::new();
if !filter.is_empty() && config.text.zmq_stream_socket.is_some() {
if let Some(clips_filter) = playout_stat.chain.clone() {
*clips_filter.lock().unwrap() = vec![filter.clone()];
}
if config.out.mode == HLS {
if proc.server_is_running.load(Ordering::SeqCst) {
let filter_server = format!("drawtext@dyntext reinit {filter}");
if let Ok(reply) = block_on(zmq_send(
&filter_server,
&config.text.zmq_server_socket.clone().unwrap(),
)) {
data_map.insert("message".to_string(), json!(reply));
return json_response(data_map);
};
} else if let Err(e) = proc.stop(Ingest) {
error!("Ingest {e:?}")
}
}
if config.out.mode != HLS || !proc.server_is_running.load(Ordering::SeqCst) {
let filter_stream = format!("drawtext@dyntext reinit {filter}");
if let Ok(reply) = block_on(zmq_send(
&filter_stream,
&config.text.zmq_stream_socket.clone().unwrap(),
)) {
data_map.insert("message".to_string(), json!(reply));
return json_response(data_map);
};
}
}
}
error_response("text message missing!", 400)
}
/// media info: get infos about current clip
fn media_current(
config: &PlayoutConfig,
playout_stat: &PlayoutStatus,
play_control: &PlayerControl,
proc: &ProcessControl,
) -> Response<Cursor<Vec<u8>>> {
if let Some(media) = play_control.current_media.lock().unwrap().clone() {
let data_map = get_data_map(
config,
media,
playout_stat,
proc.server_is_running.load(Ordering::SeqCst),
);
return json_response(data_map);
};
error_response("No current clip...", 204)
}
/// media info: get infos about next clip
fn media_next(
config: &PlayoutConfig,
playout_stat: &PlayoutStatus,
play_control: &PlayerControl,
) -> Response<Cursor<Vec<u8>>> {
let index = play_control.current_index.load(Ordering::SeqCst);
let current_list = play_control.current_list.lock().unwrap();
if index < current_list.len() {
let media = current_list[index].clone();
let data_map = get_data_map(config, media, playout_stat, false);
return json_response(data_map);
}
error_response("There is no next clip", 500)
}
/// media info: get infos about last clip
fn media_last(
config: &PlayoutConfig,
playout_stat: &PlayoutStatus,
play_control: &PlayerControl,
) -> Response<Cursor<Vec<u8>>> {
let index = play_control.current_index.load(Ordering::SeqCst);
let current_list = play_control.current_list.lock().unwrap();
if index > 1 && index - 2 < current_list.len() {
let media = current_list[index - 2].clone();
let data_map = get_data_map(config, media, playout_stat, false);
return json_response(data_map);
}
error_response("There is no last clip", 500)
}
/// response builder
/// convert request body to struct and create response according to the request values
fn build_response(
mut request: Request,
config: &PlayoutConfig,
play_control: &PlayerControl,
playout_stat: &PlayoutStatus,
proc_control: &ProcessControl,
) {
if let Ok(body) = read_request_body(&mut request) {
if let Ok(data) = serde_json::from_str::<HashMap<String, serde_json::Value>>(&body) {
if let Some(control_value) = data.get("control").and_then(|c| c.as_str()) {
match control_value {
"back" => {
let _ = request.respond(control_back(
config,
play_control,
playout_stat,
proc_control,
));
}
"next" => {
let _ = request.respond(control_next(
config,
play_control,
playout_stat,
proc_control,
));
}
"reset" => {
let _ = request.respond(control_reset(config, playout_stat, proc_control));
}
"stop_all" => {
let _ = request.respond(control_stop(proc_control));
}
"text" => {
let _ =
request.respond(control_text(data, config, playout_stat, proc_control));
}
_ => (),
}
} else if let Some(media_value) = data.get("media").and_then(|m| m.as_str()) {
match media_value {
"current" => {
let _ = request.respond(media_current(
config,
playout_stat,
play_control,
proc_control,
));
}
"next" => {
let _ = request.respond(media_next(config, playout_stat, play_control));
}
"last" => {
let _ = request.respond(media_last(config, playout_stat, play_control));
}
_ => (),
}
}
} else {
error!("Error parsing JSON request.");
let _ = request.respond(error_response("Invalid JSON request", 400));
}
} else {
error!("Error reading request body.");
let _ = request.respond(error_response("Invalid JSON request", 500));
}
}
/// request handler
/// check if authorization header with correct value exists and forward traffic to build_response()
fn handle_request(
request: Request,
config: &PlayoutConfig,
play_control: &PlayerControl,
playout_stat: &PlayoutStatus,
proc_control: &ProcessControl,
) {
// Check Authorization-Header
match request
.headers()
.iter()
.find(|h| h.field.equiv("Authorization"))
{
Some(header) => {
let auth_value = header.value.as_str();
if auth_value == config.rpc_server.authorization {
// create and send response
build_response(request, config, play_control, playout_stat, proc_control)
} else {
let _ = request.respond(error_response("Unauthorized", 401));
}
}
None => {
let _ = request.respond(error_response("Missing authorization", 401));
}
}
}
/// JSON RPC Server
///
/// A simple rpc server for getting status information and controlling player:
///
/// - current clip information
/// - jump to next clip
/// - get last clip
/// - reset player state to original clip
pub fn run_server(
config: PlayoutConfig,
play_control: PlayerControl,
playout_stat: PlayoutStatus,
proc_control: ProcessControl,
) {
let addr = config.rpc_server.address.clone();
info!("RPC server listening on {addr}");
let server = Server::http(addr).expect("Failed to start server");
for request in server.incoming_requests() {
match request.method() {
Method::Post => handle_request(
request,
&config,
&play_control,
&playout_stat,
&proc_control,
),
_ => {
// Method not allowed
let response = Response::from_string("Method not allowed")
.with_status_code(405)
.with_header(
Header::from_bytes(&b"Content-Type"[..], &b"text/plain"[..]).unwrap(),
);
let _ = request.respond(response);
}
}
}
}

View File

@ -1,13 +0,0 @@
use std::error::Error;
use zeromq::{Socket, SocketRecv, SocketSend, ZmqMessage};
pub async fn zmq_send(msg: &str, socket_addr: &str) -> Result<String, Box<dyn Error>> {
let mut socket = zeromq::ReqSocket::new();
socket.connect(&format!("tcp://{socket_addr}")).await?;
socket.send(msg.into()).await?;
let repl: ZmqMessage = socket.recv().await?;
let response = String::from_utf8(repl.into_vec()[0].to_vec())?;
Ok(response)
}

View File

@ -1,109 +0,0 @@
use std::path::PathBuf;
use clap::Parser;
use ffplayout_lib::utils::{OutputMode, ProcessMode};
#[derive(Parser, Debug, Clone)]
#[clap(version,
about = "ffplayout, Rust based 24/7 playout solution.",
override_usage = "Run without any command to use config file only, or with commands to override parameters:
\n ffplayout (ARGS) [OPTIONS]\n\n Pass channel name only in multi channel environment!",
long_about = None)]
pub struct Args {
#[clap(long, help = "File path to advanced.toml")]
pub advanced_config: Option<PathBuf>,
#[clap(index = 1, value_parser, help = "Channel name")]
pub channel: Option<String>,
#[clap(short, long, help = "File path to ffplayout.toml")]
pub config: Option<PathBuf>,
#[clap(short, long, help = "File path for logging")]
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(
short,
long,
help = "Generate playlist for dates, like: 2022-01-01 - 2022-01-10",
name = "YYYY-MM-DD",
num_args = 1..,
)]
pub generate: Option<Vec<String>>,
#[clap(
long,
help = "Import a given text/m3u file and create a playlist from it"
)]
pub import: Option<PathBuf>,
#[clap(short, long, help = "Loop playlist infinitely")]
pub infinit: bool,
#[clap(
short = 't',
long,
help = "Set length in 'hh:mm:ss', 'none' for no length check"
)]
pub length: Option<String>,
#[clap(long, help = "Override logging level")]
pub level: Option<String>,
#[clap(long, help = "Optional path list for playlist generations", num_args = 1..)]
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")]
pub output: Option<OutputMode>,
#[clap(short, long, help = "Set audio volume")]
pub volume: Option<f64>,
#[clap(long, help = "Skip validation process")]
pub skip_validation: bool,
#[clap(long, help = "validate given playlist")]
pub validate: bool,
}
/// Get arguments from command line, and return them.
#[cfg(not(test))]
pub fn get_args() -> Args {
Args::parse()
}
#[cfg(test)]
pub fn get_args() -> Args {
Args::parse_from(["-o desktop"].iter())
}

View File

@ -1,298 +0,0 @@
use std::{
env,
fs::File,
path::{Path, PathBuf},
};
use regex::Regex;
use serde_json::{json, Map, Value};
use simplelog::*;
pub mod arg_parse;
pub mod task_runner;
pub use arg_parse::Args;
use ffplayout_lib::{
filter::Filters,
utils::{
config::Template, errors::ProcError, parse_log_level_filter, time_in_seconds, 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) -> Result<PlayoutConfig, ProcError> {
let cfg_path = match args.channel {
Some(c) => {
let path = PathBuf::from(format!("/etc/ffplayout/{c}.toml"));
if !path.is_file() {
return Err(ProcError::Custom(format!(
"Config file \"{c}\" under \"/etc/ffplayout/\" not found.\n\nCheck arguments!"
)));
}
Some(path)
}
None => args.config,
};
let mut adv_config_path = PathBuf::from("/etc/ffplayout/advanced.toml");
if let Some(adv_path) = args.advanced_config {
adv_config_path = adv_path;
} else if !adv_config_path.is_file() {
if Path::new("./assets/advanced.toml").is_file() {
adv_config_path = PathBuf::from("./assets/advanced.toml")
} else if let Some(p) = env::current_exe().ok().as_ref().and_then(|op| op.parent()) {
adv_config_path = p.join("advanced.toml")
};
}
let mut config = PlayoutConfig::new(cfg_path, Some(adv_config_path));
if let Some(gen) = args.generate {
config.general.generate = Some(gen);
}
if args.validate {
config.general.validate = true;
}
if let Some(template_file) = args.template {
let f = File::options()
.read(true)
.write(false)
.open(template_file)?;
let mut template: Template = serde_json::from_reader(f)?;
template.sources.sort_by(|d1, d2| d1.start.cmp(&d2.start));
config.general.template = Some(template);
}
if let Some(paths) = args.paths {
config.storage.paths = paths;
}
if let Some(log_path) = args.log {
if log_path != Path::new("none") {
config.logging.log_to_file = true;
config.logging.path = log_path;
} else {
config.logging.log_to_file = false;
config.logging.timestamp = false;
}
}
if let Some(playlist) = args.playlist {
config.playlist.path = playlist;
}
if let Some(mode) = args.play_mode {
config.processing.mode = mode;
}
if let Some(folder) = args.folder {
config.storage.path = folder;
config.processing.mode = Folder;
}
if let Some(start) = args.start {
config.playlist.day_start.clone_from(&start);
config.playlist.start_sec = Some(time_to_sec(&start));
}
if let Some(length) = args.length {
config.playlist.length.clone_from(&length);
if length.contains(':') {
config.playlist.length_sec = Some(time_to_sec(&length));
} else {
config.playlist.length_sec = Some(86400.0);
}
}
if let Some(level) = args.level {
if let Ok(filter) = parse_log_level_filter(&level) {
config.logging.level = filter;
}
}
if args.infinit {
config.playlist.infinit = args.infinit;
}
if let Some(output) = args.output {
config.out.mode = output;
if config.out.mode == Null {
config.out.output_count = 1;
config.out.output_filter = None;
config.out.output_cmd = Some(vec_strings!["-f", "null", "-"]);
}
}
config.general.skip_validation = args.skip_validation;
if let Some(volume) = args.volume {
config.processing.volume = volume;
}
Ok(config)
}
/// Format ingest and HLS logging output
pub fn log_line(line: &str, level: &str) {
if line.contains("[info]") && level.to_lowercase() == "info" {
info!("<bright black>[Server]</> {}", line.replace("[info] ", ""))
} else if line.contains("[warning]")
&& (level.to_lowercase() == "warning" || level.to_lowercase() == "info")
{
warn!(
"<bright black>[Server]</> {}",
line.replace("[warning] ", "")
)
} else if line.contains("[error]")
&& !line.contains("Input/output error")
&& !line.contains("Broken pipe")
{
error!("<bright black>[Server]</> {}", line.replace("[error] ", ""));
} else if line.contains("[fatal]") {
error!("<bright black>[Server]</> {}", line.replace("[fatal] ", ""))
}
}
/// Compare incoming stream name with expecting name, but ignore question mark.
pub fn valid_stream(msg: &str) -> bool {
if let Some((unexpected, expected)) = msg.split_once(',') {
let re = Regex::new(r".*Unexpected stream|expecting|[\s]+|\?$").unwrap();
let unexpected = re.replace_all(unexpected, "");
let expected = re.replace_all(expected, "");
if unexpected == expected {
return true;
}
}
false
}
/// Prepare output parameters
///
/// Seek for multiple outputs and add mapping for it.
pub fn prepare_output_cmd(
config: &PlayoutConfig,
mut cmd: Vec<String>,
filters: &Option<Filters>,
) -> Vec<String> {
let mut output_params = config.out.clone().output_cmd.unwrap();
let mut new_params = vec![];
let mut count = 0;
let re_v = Regex::new(r"\[?0:v(:0)?\]?").unwrap();
if let Some(mut filter) = filters.clone() {
for (i, param) in output_params.iter().enumerate() {
if filter.video_out_link.len() > count && re_v.is_match(param) {
// replace mapping with link from filter struct
new_params.push(filter.video_out_link[count].clone());
} else {
new_params.push(param.clone());
}
// Check if parameter is a output
if i > 0
&& !param.starts_with('-')
&& !output_params[i - 1].starts_with('-')
&& i < output_params.len() - 1
{
count += 1;
if filter.video_out_link.len() > count
&& !output_params.contains(&"-map".to_string())
{
new_params.append(&mut vec_strings![
"-map",
filter.video_out_link[count].clone()
]);
for i in 0..config.processing.audio_tracks {
new_params.append(&mut vec_strings!["-map", format!("0:a:{i}")]);
}
}
}
}
output_params = new_params;
cmd.append(&mut filter.cmd());
// add mapping at the begin, if needed
if !filter.map().iter().all(|item| output_params.contains(item))
&& filter.output_chain.is_empty()
&& filter.video_out_link.is_empty()
{
cmd.append(&mut filter.map())
} else if &output_params[0] != "-map" && !filter.video_out_link.is_empty() {
cmd.append(&mut vec_strings!["-map", filter.video_out_link[0].clone()]);
for i in 0..config.processing.audio_tracks {
cmd.append(&mut vec_strings!["-map", format!("0:a:{i}")]);
}
}
}
cmd.append(&mut output_params);
cmd
}
/// map media struct to json object
pub fn get_media_map(media: Media) -> Value {
let mut obj = json!({
"in": media.seek,
"out": media.out,
"duration": media.duration,
"category": media.category,
"source": media.source,
});
if let Some(title) = media.title {
obj.as_object_mut()
.unwrap()
.insert("title".to_string(), Value::String(title));
}
obj
}
/// prepare json object for response
pub fn get_data_map(
config: &PlayoutConfig,
media: Media,
playout_stat: &PlayoutStatus,
server_is_running: bool,
) -> Map<String, Value> {
let mut data_map = Map::new();
let current_time = time_in_seconds();
let shift = *playout_stat.time_shift.lock().unwrap();
let begin = media.begin.unwrap_or(0.0) - shift;
let played_time = current_time - begin;
data_map.insert("index".to_string(), json!(media.index));
data_map.insert("ingest".to_string(), json!(server_is_running));
data_map.insert("mode".to_string(), json!(config.processing.mode));
data_map.insert(
"shift".to_string(),
json!((shift * 1000.0).round() / 1000.0),
);
data_map.insert(
"elapsed".to_string(),
json!((played_time * 1000.0).round() / 1000.0),
);
data_map.insert("media".to_string(), get_media_map(media));
data_map
}

View File

@ -1,25 +0,0 @@
use std::process::Command;
use simplelog::*;
use crate::utils::get_data_map;
use ffplayout_lib::utils::{config::PlayoutConfig, Media, PlayoutStatus};
pub fn run(config: PlayoutConfig, node: Media, playout_stat: PlayoutStatus, server_running: bool) {
let obj =
serde_json::to_string(&get_data_map(&config, node, &playout_stat, server_running)).unwrap();
trace!("Run task: {obj}");
match Command::new(config.task.path).arg(obj).spawn() {
Ok(mut c) => {
let status = c.wait().expect("Error in waiting for the task process!");
if !status.success() {
error!("Process stops with error.");
}
}
Err(e) => {
error!("Couldn't spawn task runner: {e}")
}
}
}

View File

@ -1,6 +1,6 @@
[package]
name = "ffplayout"
description = "Rest API for ffplayout"
description = "24/7 playout based on rust and ffmpeg"
readme = "README.md"
version.workspace = true
license.workspace = true
@ -13,16 +13,14 @@ default = ["embed_frontend"]
embed_frontend = []
[dependencies]
ffplayout-lib = { path = "../lib" }
actix-files = "0.6"
actix-multipart = "0.6"
actix-web = "4"
actix-web-grants = "4"
actix-web-httpauth = "0.8"
actix-web-lab = "0.20"
actix-web-static-files = "4.0"
argon2 = "0.5"
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
chrono = { version = "0.4", default-features = false, features = ["clock", "std", "serde"] }
clap = { version = "4.3", features = ["derive"] }
crossbeam-channel = "0.5"
derive_more = "0.99"
@ -31,7 +29,6 @@ ffprobe = "0.4"
flexi_logger = { version = "0.28", features = ["kv", "colors"] }
futures-util = { version = "0.3", default-features = false, features = ["std"] }
home = "0.5"
itertools = "0.13"
jsonwebtoken = "9"
lazy_static = "1.4"
lettre = { version = "0.11", features = ["builder", "rustls-tls", "smtp-transport", "tokio1", "tokio1-rustls-tls"], default-features = false }
@ -78,3 +75,99 @@ static-files = "0.2"
[[bin]]
name = "ffplayout"
path = "src/main.rs"
# DEBIAN DEB PACKAGE
[package.metadata.deb]
name = "ffplayout"
priority = "optional"
section = "net"
license-file = ["../LICENSE", "0"]
depends = ""
suggests = "ffmpeg"
copyright = "Copyright (c) 2024, Jonathan Baecker. All rights reserved."
assets = [
[
"../target/x86_64-unknown-linux-musl/release/ffplayout",
"/usr/bin/",
"755",
],
[
"../assets/ffplayout.service",
"/lib/systemd/system/",
"644",
],
[
"../assets/logo.png",
"/usr/share/ffplayout/",
"644",
],
[
"../assets/ffplayout.conf",
"/usr/share/ffplayout/ffplayout.conf.example",
"644",
],
[
"../README.md",
"/usr/share/doc/ffplayout/README",
"644",
],
[
"../assets/ffplayout.1.gz",
"/usr/share/man/man1/",
"644",
],
]
maintainer-scripts = "../debian/"
systemd-units = { enable = false, unit-scripts = "../assets" }
[package.metadata.deb.variants.arm64]
assets = [
[
"../target/aarch64-unknown-linux-gnu/release/ffplayout",
"/usr/bin/",
"755",
],
[
"../assets/ffplayout.service",
"/lib/systemd/system/",
"644",
],
[
"../assets/logo.png",
"/usr/share/ffplayout/",
"644",
],
[
"../assets/ffplayout.conf",
"/usr/share/ffplayout/ffplayout.conf.example",
"644",
],
[
"../README.md",
"/usr/share/doc/ffplayout/README",
"644",
],
[
"../assets/ffplayout.1.gz",
"/usr/share/man/man1/",
"644",
],
]
# REHL RPM PACKAGE
[package.metadata.generate-rpm]
name = "ffplayout"
license = "GPL-3.0"
assets = [
{ source = "../target/x86_64-unknown-linux-musl/release/ffplayout", dest = "/usr/bin/ffplayout", mode = "755" },
{ source = "../assets/ffplayout.service", dest = "/lib/systemd/system/ffplayout.service", mode = "644" },
{ source = "../README.md", dest = "/usr/share/doc/ffplayout/README", mode = "644" },
{ source = "../assets/ffplayout.1.gz", dest = "/usr/share/man/man1/ffplayout.1.gz", mode = "644", doc = true },
{ source = "../LICENSE", dest = "/usr/share/doc/ffplayout/LICENSE", mode = "644" },
{ source = "../assets/logo.png", dest = "/usr/share/ffplayout/logo.png", mode = "644" },
{ source = "../assets/ffplayout.conf", dest = "/usr/share/ffplayout/ffplayout.conf.example", mode = "644" },
{ source = "../debian/postinst", dest = "/usr/share/ffplayout/postinst", mode = "755" },
]
auto-req = "no"
post_install_script = "/usr/share/ffplayout/postinst"

View File

@ -34,13 +34,14 @@ use serde::{Deserialize, Serialize};
use sqlx::{Pool, Sqlite};
use tokio::fs;
use crate::api::auth::{create_jwt, Claims};
use crate::player::utils::{
get_data_map, get_date_range, import::import_file, sec_to_time, time_to_sec, JsonPlaylist,
};
use crate::utils::{
channels::{create_channel, delete_channel},
config::{PlayoutConfig, Template},
control::{control_state, send_message, ControlParams, Process},
control::{control_state, send_message, ControlParams, Process, ProcessCtl},
errors::ServiceError,
files::{
browser, create_directory, norm_abs_path, remove_file_or_folder, rename_file, upload,
@ -51,10 +52,6 @@ use crate::utils::{
public_path, read_log_file, system, Role, TextFilter,
};
use crate::vec_strings;
use crate::{
api::auth::{create_jwt, Claims},
utils::control::ProcessControl,
};
use crate::{
db::{
handles,
@ -723,10 +720,26 @@ pub async fn media_current(
#[post("/control/{id}/process/")]
#[protect(any("Role::GlobalAdmin", "Role::User"), ty = "Role")]
pub async fn process_control(
_id: web::Path<i32>,
_proc: web::Json<Process>,
_engine_process: web::Data<ProcessControl>,
id: web::Path<i32>,
proc: web::Json<Process>,
controllers: web::Data<Mutex<ChannelController>>,
) -> Result<impl Responder, ServiceError> {
let manager = controllers.lock().unwrap().get(*id).unwrap();
match proc.into_inner().command {
ProcessCtl::Start => {
manager.async_start().await;
}
ProcessCtl::Stop => {
manager.async_stop().await;
}
ProcessCtl::Restart => {
manager.async_stop().await;
tokio::time::sleep(tokio::time::Duration::from_millis(1500)).await;
manager.async_start().await;
}
}
Ok(web::Json("no implemented"))
}
@ -828,7 +841,7 @@ pub async fn gen_playlist(
.clone_from(&obj.template);
}
match generate_playlist(manager).await {
match generate_playlist(manager) {
Ok(playlist) => Ok(web::Json(playlist)),
Err(e) => Err(e),
}

View File

@ -98,6 +98,16 @@ pub async fn update_stat(
.await
}
pub async fn update_player(
conn: &Pool<Sqlite>,
id: i32,
active: bool,
) -> Result<SqliteQueryResult, sqlx::Error> {
let query = "UPDATE channels SET active = $2 WHERE id = $1";
sqlx::query(query).bind(id).bind(active).execute(conn).await
}
pub async fn insert_channel(conn: &Pool<Sqlite>, channel: Channel) -> Result<Channel, sqlx::Error> {
let query = "INSERT INTO channels (name, preview_url, extra_extensions) VALUES($1, $2, $3)";
let result = sqlx::query(query)

View File

@ -1,7 +1,7 @@
use std::{
collections::HashSet,
env, io,
process::{self, exit},
process::exit,
sync::{Arc, Mutex},
thread,
};
@ -22,7 +22,7 @@ use path_clean::PathClean;
use ffplayout::{
api::{auth, routes::*},
db::{db_pool, handles, models::LoginUser},
player::controller::{self, ChannelController, ChannelManager},
player::controller::{ChannelController, ChannelManager},
sse::{broadcast::Broadcaster, routes::*, AuthState},
utils::{
config::PlayoutConfig,
@ -92,13 +92,13 @@ async fn main() -> std::io::Result<()> {
for channel in channels.iter() {
let config = PlayoutConfig::new(&pool, channel.id).await;
let channel_manager = ChannelManager::new(channel.clone(), config.clone());
let channel_manager =
ChannelManager::new(Some(pool.clone()), channel.clone(), config.clone());
channel_controllers
.lock()
.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?
.add(channel_manager.clone());
let controllers = channel_controllers.clone();
let m_queue = Arc::new(Mutex::new(MailQueue::new(channel.id, config.mail)));
if let Ok(mut mqs) = mail_queues.lock() {
@ -106,17 +106,7 @@ async fn main() -> std::io::Result<()> {
}
if channel.active {
let pool_clone = pool.clone();
thread::spawn(move || {
if let Err(e) = controller::start(pool_clone, channel_manager) {
error!("{e}");
};
if controllers.lock().unwrap().run_count() == 0 {
process::exit(0)
};
});
channel_manager.async_start().await;
}
}

View File

@ -15,7 +15,7 @@ use log::*;
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Sqlite};
use crate::db::models::Channel;
use crate::db::{handles, models::Channel};
use crate::player::{
output::{player, write_hls},
utils::{folder::fill_filler_list, Media},
@ -48,6 +48,7 @@ use ProcessUnit::*;
#[derive(Clone, Debug, Default)]
pub struct ChannelManager {
pub db_pool: Option<Pool<Sqlite>>,
pub config: Arc<Mutex<PlayoutConfig>>,
pub channel: Arc<Mutex<Channel>>,
pub decoder: Arc<Mutex<Option<Child>>>,
@ -64,11 +65,13 @@ pub struct ChannelManager {
pub filler_list: Arc<Mutex<Vec<Media>>>,
pub current_index: Arc<AtomicUsize>,
pub filler_index: Arc<AtomicUsize>,
pub run_count: Arc<AtomicUsize>,
}
impl ChannelManager {
pub fn new(channel: Channel, config: PlayoutConfig) -> Self {
pub fn new(db_pool: Option<Pool<Sqlite>>, channel: Channel, config: PlayoutConfig) -> Self {
Self {
db_pool,
is_alive: Arc::new(AtomicBool::new(channel.active)),
channel: Arc::new(Mutex::new(channel)),
config: Arc::new(Mutex::new(config)),
@ -77,6 +80,7 @@ impl ChannelManager {
filler_list: Arc::new(Mutex::new(vec![])),
current_index: Arc::new(AtomicUsize::new(0)),
filler_index: Arc::new(AtomicUsize::new(0)),
run_count: Arc::new(AtomicUsize::new(0)),
..Default::default()
}
}
@ -93,6 +97,29 @@ impl ChannelManager {
channel.utc_offset.clone_from(&other.utc_offset);
}
pub async fn async_start(&self) {
self.run_count.fetch_add(1, Ordering::SeqCst);
self.is_alive.store(true, Ordering::SeqCst);
self.is_terminated.store(false, Ordering::SeqCst);
let pool_clone = self.db_pool.clone().unwrap();
let self_clone = self.clone();
let channel_id = self.channel.lock().unwrap().id;
if let Err(e) = handles::update_player(&pool_clone, channel_id, true).await {
error!("Unable write to player status: {e}");
};
thread::spawn(move || {
let run_count = self_clone.run_count.clone();
if let Err(e) = start_channel(self_clone) {
run_count.fetch_sub(1, Ordering::SeqCst);
error!("{e}");
};
});
}
pub fn stop(&self, unit: ProcessUnit) -> Result<(), ProcessError> {
let mut channel = self.channel.lock()?;
@ -156,11 +183,44 @@ impl ChannelManager {
Ok(())
}
pub async fn async_stop(&self) {
debug!("Stop all child processes");
self.is_terminated.store(true, Ordering::SeqCst);
self.ingest_is_running.store(false, Ordering::SeqCst);
self.run_count.fetch_sub(1, Ordering::SeqCst);
let pool = self.db_pool.clone().unwrap();
let channel_id = self.channel.lock().unwrap().id;
if let Err(e) = handles::update_player(&pool, channel_id, false).await {
error!("Unable write to player status: {e}");
};
if self.is_alive.load(Ordering::SeqCst) {
self.is_alive.store(false, Ordering::SeqCst);
trace!("Playout is alive and processes are terminated");
for unit in [Decoder, Encoder, Ingest] {
if let Err(e) = self.stop(unit) {
if !e.to_string().contains("exited process") {
error!("{e}")
}
}
if let Err(e) = self.wait(unit) {
if !e.to_string().contains("exited process") {
error!("{e}")
}
}
}
}
}
/// No matter what is running, terminate them all.
pub fn stop_all(&self) {
debug!("Stop all child processes");
self.is_terminated.store(true, Ordering::SeqCst);
self.ingest_is_running.store(false, Ordering::SeqCst);
self.run_count.fetch_sub(1, Ordering::SeqCst);
if self.is_alive.load(Ordering::SeqCst) {
self.is_alive.store(false, Ordering::SeqCst);
@ -222,7 +282,7 @@ impl ChannelController {
}
}
pub fn start(db_pool: Pool<Sqlite>, manager: ChannelManager) -> Result<(), ProcessError> {
pub fn start_channel(manager: ChannelManager) -> Result<(), ProcessError> {
let config = manager.config.lock()?.clone();
let mode = config.output.mode.clone();
let filler_list = manager.filler_list.clone();
@ -234,8 +294,8 @@ pub fn start(db_pool: Pool<Sqlite>, manager: ChannelManager) -> Result<(), Proce
match mode {
// write files/playlist to HLS m3u8 playlist
HLS => write_hls(manager, db_pool),
HLS => write_hls(manager),
// play on desktop or stream to a remote target
_ => player(manager, db_pool),
_ => player(manager),
}
}

View File

@ -1,7 +1,6 @@
use std::thread;
use simplelog::*;
use sqlx::{Pool, Sqlite};
use log::*;
pub mod folder;
pub mod ingest;
@ -18,10 +17,7 @@ use crate::player::{
use crate::utils::config::ProcessMode::*;
/// Create a source iterator from playlist, or from folder.
pub fn source_generator(
manager: ChannelManager,
db_pool: Pool<Sqlite>,
) -> Box<dyn Iterator<Item = Media>> {
pub fn source_generator(manager: ChannelManager) -> Box<dyn Iterator<Item = Media>> {
let config = manager.config.lock().unwrap().clone();
let is_terminated = manager.is_terminated.clone();
let current_list = manager.current_list.clone();
@ -45,7 +41,7 @@ pub fn source_generator(
}
Playlist => {
info!("Playout in playlist mode");
let program = CurrentProgram::new(manager, db_pool);
let program = CurrentProgram::new(manager);
Box::new(program) as Box<dyn Iterator<Item = Media>>
}

View File

@ -6,8 +6,7 @@ use std::{
},
};
use simplelog::*;
use sqlx::{Pool, Sqlite};
use log::*;
use crate::db::handles;
use crate::player::{
@ -26,9 +25,8 @@ use crate::utils::config::{PlayoutConfig, IMAGE_FORMAT};
/// Here we prepare the init clip and build a iterator where we pull our clips.
#[derive(Debug)]
pub struct CurrentProgram {
manager: ChannelManager,
config: PlayoutConfig,
db_pool: Pool<Sqlite>,
manager: ChannelManager,
start_sec: f64,
end_sec: f64,
json_playlist: JsonPlaylist,
@ -40,14 +38,13 @@ pub struct CurrentProgram {
/// Prepare a playlist iterator.
impl CurrentProgram {
pub fn new(manager: ChannelManager, db_pool: Pool<Sqlite>) -> Self {
pub fn new(manager: ChannelManager) -> Self {
let config = manager.config.lock().unwrap().clone();
let is_terminated = manager.is_terminated.clone();
Self {
manager,
config: config.clone(),
db_pool,
manager,
start_sec: config.playlist.start_sec.unwrap(),
end_sec: config.playlist.length_sec.unwrap(),
json_playlist: JsonPlaylist::new(
@ -211,11 +208,12 @@ impl CurrentProgram {
.last_date
.clone_from(&Some(date.clone()));
self.manager.channel.lock().unwrap().time_shift = 0.0;
let db_pool = self.manager.db_pool.clone().unwrap();
if let Err(e) = tokio::runtime::Runtime::new()
.unwrap()
.block_on(handles::update_stat(
&self.db_pool,
&db_pool,
self.config.general.channel_id,
date,
0.0,

View File

@ -26,7 +26,6 @@ use std::{
};
use log::*;
use sqlx::{Pool, Sqlite};
use crate::utils::{logging::log_line, task_runner};
use crate::vec_strings;
@ -147,7 +146,7 @@ fn ingest_to_hls_server(manager: ChannelManager) -> Result<(), ProcessError> {
/// HLS Writer
///
/// Write with single ffmpeg instance directly to a HLS playlist.
pub fn write_hls(manager: ChannelManager, db_pool: Pool<Sqlite>) -> Result<(), ProcessError> {
pub fn write_hls(manager: ChannelManager) -> Result<(), ProcessError> {
let config = manager.config.lock()?.clone();
let current_media = manager.current_media.clone();
@ -156,7 +155,7 @@ pub fn write_hls(manager: ChannelManager, db_pool: Pool<Sqlite>) -> Result<(), P
let channel_mgr_2 = manager.clone();
let ingest_is_running = manager.ingest_is_running.clone();
let get_source = source_generator(manager.clone(), db_pool);
let get_source = source_generator(manager.clone());
// spawn a thread for ffmpeg ingest server and create a channel for package sending
if config.ingest.enable {

View File

@ -8,7 +8,6 @@ use std::{
use crossbeam_channel::bounded;
use log::*;
use sqlx::{Pool, Sqlite};
mod desktop;
mod hls;
@ -34,7 +33,7 @@ use crate::vec_strings;
/// for getting live feeds.
/// When a live ingest arrive, it stops the current playing and switch to the live source.
/// When ingest stops, it switch back to playlist/folder mode.
pub fn player(manager: ChannelManager, db_pool: Pool<Sqlite>) -> Result<(), ProcessError> {
pub fn player(manager: ChannelManager) -> Result<(), ProcessError> {
let config = manager.config.lock()?.clone();
let config_clone = config.clone();
let ff_log_format = format!("level+{}", config.logging.ffmpeg_level.to_lowercase());
@ -47,7 +46,7 @@ pub fn player(manager: ChannelManager, db_pool: Pool<Sqlite>) -> Result<(), Proc
let ingest_is_running = manager.ingest_is_running.clone();
// get source iterator
let node_sources = source_generator(manager.clone(), db_pool);
let node_sources = source_generator(manager.clone());
// get ffmpeg output instance
let mut enc_proc = match config.output.mode {

View File

@ -1,18 +1,9 @@
use std::{
error::Error,
fmt,
str::FromStr,
sync::{
atomic::{AtomicBool, Ordering},
Mutex,
},
};
use std::{error::Error, fmt, str::FromStr, sync::atomic::Ordering};
use log::*;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use sqlx::{Pool, Sqlite};
use tokio::process::Child;
use zeromq::{Socket, SocketRecv, SocketSend, ZmqMessage};
use crate::db::handles;
@ -38,59 +29,40 @@ struct MediaParams {
media: String,
}
/// ffplayout engine process
///
/// When running not on Linux, or with environment variable `PIGGYBACK_MODE=true`,
/// the engine get startet and controlled from ffpapi
pub struct ProcessControl {
pub engine_child: Mutex<Option<Child>>,
pub is_running: AtomicBool,
pub piggyback: AtomicBool,
}
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ServiceCmd {
Enable,
Disable,
pub enum ProcessCtl {
Start,
Stop,
Restart,
Status,
}
impl FromStr for ServiceCmd {
impl FromStr for ProcessCtl {
type Err = String;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match input.to_lowercase().as_str() {
"enable" => Ok(Self::Enable),
"disable" => Ok(Self::Disable),
"start" => Ok(Self::Start),
"stop" => Ok(Self::Stop),
"restart" => Ok(Self::Restart),
"status" => Ok(Self::Status),
_ => Err(format!("Command '{input}' not found!")),
}
}
}
impl fmt::Display for ServiceCmd {
impl fmt::Display for ProcessCtl {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
Self::Enable => write!(f, "enable"),
Self::Disable => write!(f, "disable"),
Self::Start => write!(f, "start"),
Self::Stop => write!(f, "stop"),
Self::Restart => write!(f, "restart"),
Self::Status => write!(f, "status"),
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Process {
pub command: ServiceCmd,
pub command: ProcessCtl,
}
async fn zmq_send(msg: &str, socket_addr: &str) -> Result<String, Box<dyn Error>> {

View File

@ -84,7 +84,7 @@ pub async fn write_playlist(
Err(ServiceError::InternalServerError)
}
pub async fn generate_playlist(manager: ChannelManager) -> Result<JsonPlaylist, ServiceError> {
pub fn generate_playlist(manager: ChannelManager) -> Result<JsonPlaylist, ServiceError> {
let mut config = manager.config.lock().unwrap();
if let Some(mut template) = config.general.template.take() {

View File

@ -1,39 +0,0 @@
[package]
name = "ffplayout-lib"
description = "Library for ffplayout"
readme = "README.md"
version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
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.4"
file-rotate = "0.7"
home = "0.5"
lazy_static = "1.4"
lettre = { version = "0.11", features = ["builder", "rustls-tls", "smtp-transport"], default-features = false }
lexical-sort = "0.3"
log = "0.4"
num-traits = "0.2"
rand = "0.8"
regex = "1"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
shlex = "1.1"
simplelog = { version = "0.12", features = ["paris"] }
time = { version = "0.3", features = ["formatting", "macros"] }
toml_edit = {version ="0.22", features = ["serde"]}
walkdir = "2"
[target."cfg(windows)".dependencies.winapi]
version = "0.3"
features = ["shlobj", "std", "winerror"]
[target.'cfg(not(target_arch = "windows"))'.dependencies]
signal-child = "1"

View File

@ -1,4 +0,0 @@
**ffplayout-lib**
================
This folder only contains helper functions which are used in multiple apps.

View File

@ -1,39 +0,0 @@
use regex::Regex;
use simplelog::*;
/// Apply custom filters
pub fn filter_node(filter: &str) -> (String, String) {
let re = Regex::new(r"^;?(\[[0-9]:[^\[]+\])?|\[[^\[]+\]$").unwrap(); // match start/end link
let mut video_filter = String::new();
let mut audio_filter = String::new();
// match chain with audio and video filter
if filter.contains("[c_v_out]") && filter.contains("[c_a_out]") {
let v_pos = filter.find("[c_v_out]").unwrap();
let a_pos = filter.find("[c_a_out]").unwrap();
let mut delimiter = "[c_v_out]";
// split delimiter should be first filter output link
if v_pos > a_pos {
delimiter = "[c_a_out]";
}
if let Some((f_1, f_2)) = filter.split_once(delimiter) {
if f_2.contains("[c_a_out]") {
video_filter = re.replace_all(f_1, "").to_string();
audio_filter = re.replace_all(f_2, "").to_string();
} else {
video_filter = re.replace_all(f_2, "").to_string();
audio_filter = re.replace_all(f_1, "").to_string();
}
}
} else if filter.contains("[c_v_out]") {
video_filter = re.replace_all(filter, "").to_string();
} else if filter.contains("[c_a_out]") {
audio_filter = re.replace_all(filter, "").to_string();
} else if !filter.is_empty() && filter != "~" {
error!("Custom filter is not well formatted, use correct out link names (\"[c_v_out]\" and/or \"[c_a_out]\"). Filter skipped!")
}
(video_filter, audio_filter)
}

View File

@ -1,793 +0,0 @@
use std::{
fmt,
path::Path,
sync::{Arc, Mutex},
};
use regex::Regex;
use simplelog::*;
mod custom;
pub mod v_drawtext;
use crate::utils::{
controller::ProcessUnit::*, custom_format, fps_calc, is_close, Media, OutputMode::*,
PlayoutConfig,
};
use super::vec_strings;
#[derive(Clone, Debug, Copy, Eq, PartialEq)]
pub enum FilterType {
Audio,
Video,
}
impl fmt::Display for FilterType {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
FilterType::Audio => write!(f, "a"),
FilterType::Video => write!(f, "v"),
}
}
}
use FilterType::*;
#[derive(Debug, Clone)]
pub struct Filters {
pub audio_chain: String,
pub video_chain: String,
pub output_chain: Vec<String>,
pub audio_map: Vec<String>,
pub video_map: Vec<String>,
pub audio_out_link: Vec<String>,
pub video_out_link: Vec<String>,
pub output_map: Vec<String>,
config: PlayoutConfig,
audio_position: i32,
video_position: i32,
audio_last: i32,
video_last: i32,
}
impl Filters {
pub fn new(config: PlayoutConfig, audio_position: i32) -> Self {
Self {
audio_chain: String::new(),
video_chain: String::new(),
output_chain: vec![],
audio_map: vec![],
video_map: vec![],
audio_out_link: vec![],
video_out_link: vec![],
output_map: vec![],
config,
audio_position,
video_position: 0,
audio_last: -1,
video_last: -1,
}
}
pub fn add_filter(&mut self, filter: &str, track_nr: i32, filter_type: FilterType) {
let (map, chain, position, last) = match filter_type {
Audio => (
&mut self.audio_map,
&mut self.audio_chain,
self.audio_position,
&mut self.audio_last,
),
Video => (
&mut self.video_map,
&mut self.video_chain,
self.video_position,
&mut self.video_last,
),
};
if *last != track_nr {
// start new filter chain
let mut selector = String::new();
let mut sep = String::new();
if !chain.is_empty() {
selector = format!("[{filter_type}out{last}]");
sep = ";".to_string()
}
chain.push_str(&selector);
if filter.starts_with("aevalsrc") || filter.starts_with("movie") {
chain.push_str(&format!("{sep}{filter}"));
} else {
chain.push_str(&format!(
// build audio/video selector like [0:a:0]
"{sep}[{position}:{filter_type}:{track_nr}]{filter}",
));
}
let m = format!("[{filter_type}out{track_nr}]");
map.push(m.clone());
self.output_map.append(&mut vec_strings!["-map", m]);
*last = track_nr;
} else if filter.starts_with(';') || filter.starts_with('[') {
chain.push_str(filter);
} else {
chain.push_str(&format!(",{filter}"))
}
}
pub fn cmd(&mut self) -> Vec<String> {
if !self.output_chain.is_empty() {
return self.output_chain.clone();
}
let mut v_chain = self.video_chain.clone();
let mut a_chain = self.audio_chain.clone();
if self.video_last >= 0 && !v_chain.ends_with(']') {
v_chain.push_str(&format!("[vout{}]", self.video_last));
}
if self.audio_last >= 0 && !a_chain.ends_with(']') {
a_chain.push_str(&format!("[aout{}]", self.audio_last));
}
let mut f_chain = v_chain;
let mut cmd = vec![];
if !a_chain.is_empty() {
if !f_chain.is_empty() {
f_chain.push(';');
}
f_chain.push_str(&a_chain);
}
if !f_chain.is_empty() {
cmd.push("-filter_complex".to_string());
cmd.push(f_chain);
}
cmd
}
pub fn map(&mut self) -> Vec<String> {
let mut o_map = self.output_map.clone();
if self.video_last == -1 && !self.config.processing.audio_only {
let v_map = "0:v".to_string();
if !o_map.contains(&v_map) {
o_map.append(&mut vec_strings!["-map", v_map]);
};
}
if self.audio_last == -1 {
for i in 0..self.config.processing.audio_tracks {
let a_map = format!("{}:a:{i}", self.audio_position);
if !o_map.contains(&a_map) {
o_map.append(&mut vec_strings!["-map", a_map]);
};
}
}
o_map
}
}
impl Default for Filters {
fn default() -> Self {
Self::new(PlayoutConfig::new(None, None), 0)
}
}
fn deinterlace(field_order: &Option<String>, chain: &mut Filters, config: &PlayoutConfig) {
if let Some(order) = field_order {
if order != "progressive" {
let deinterlace = match config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.deinterlace.clone())
{
Some(deinterlace) => deinterlace,
None => "yadif=0:-1:0".to_string(),
};
chain.add_filter(&deinterlace, 0, Video);
}
}
}
fn pad(aspect: f64, chain: &mut Filters, v_stream: &ffprobe::Stream, config: &PlayoutConfig) {
if !is_close(aspect, config.processing.aspect, 0.03) {
let mut scale = String::new();
if let (Some(w), Some(h)) = (v_stream.width, v_stream.height) {
if w > config.processing.width && aspect > config.processing.aspect {
scale = match config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.pad_scale_w.clone())
{
Some(pad_scale_w) => {
custom_format(&format!("{pad_scale_w},"), &[&config.processing.width])
}
None => format!("scale={}:-1,", config.processing.width),
};
} else if h > config.processing.height && aspect < config.processing.aspect {
scale = match config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.pad_scale_h.clone())
{
Some(pad_scale_h) => {
custom_format(&format!("{pad_scale_h},"), &[&config.processing.width])
}
None => format!("scale=-1:{},", config.processing.height),
};
}
}
let pad = match config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.pad_video.clone())
{
Some(pad_video) => custom_format(
&format!("{scale}{pad_video}"),
&[
&config.processing.width.to_string(),
&config.processing.height.to_string(),
],
),
None => format!(
"{}pad=max(iw\\,ih*({1}/{2})):ow/({1}/{2}):(ow-iw)/2:(oh-ih)/2",
scale, config.processing.width, config.processing.height
),
};
chain.add_filter(&pad, 0, Video)
}
}
fn fps(fps: f64, chain: &mut Filters, config: &PlayoutConfig) {
if fps != config.processing.fps {
let fps_filter = match config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.fps.clone())
{
Some(fps) => custom_format(&fps, &[&config.processing.fps]),
None => format!("fps={}", config.processing.fps),
};
chain.add_filter(&fps_filter, 0, Video)
}
}
fn scale(
width: Option<i64>,
height: Option<i64>,
aspect: f64,
chain: &mut Filters,
config: &PlayoutConfig,
) {
// width: i64, height: i64
if let (Some(w), Some(h)) = (width, height) {
if w != config.processing.width || h != config.processing.height {
let scale = match config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.scale.clone())
{
Some(scale) => custom_format(
&scale,
&[&config.processing.width, &config.processing.height],
),
None => format!(
"scale={}:{}",
config.processing.width, config.processing.height
),
};
chain.add_filter(&scale, 0, Video);
} else {
chain.add_filter("null", 0, Video);
}
if !is_close(aspect, config.processing.aspect, 0.03) {
let dar = match config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.set_dar.clone())
{
Some(set_dar) => custom_format(&set_dar, &[&config.processing.aspect]),
None => format!("setdar=dar={}", config.processing.aspect),
};
chain.add_filter(&dar, 0, Video);
}
} else {
let scale = match config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.scale.clone())
{
Some(scale) => custom_format(
&scale,
&[&config.processing.width, &config.processing.height],
),
None => format!(
"scale={}:{}",
config.processing.width, config.processing.height
),
};
chain.add_filter(&scale, 0, Video);
let dar = match config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.set_dar.clone())
{
Some(set_dar) => custom_format(&set_dar, &[&config.processing.aspect]),
None => format!("setdar=dar={}", config.processing.aspect),
};
chain.add_filter(&dar, 0, Video);
}
}
fn fade(
node: &mut Media,
chain: &mut Filters,
nr: i32,
filter_type: FilterType,
config: &PlayoutConfig,
) {
let mut t = "";
let mut fade_audio = false;
if filter_type == Audio {
t = "a";
if node.duration_audio > 0.0 && node.duration_audio != node.duration {
fade_audio = true;
}
}
if node.seek > 0.0 || node.unit == Ingest {
let mut fade_in = format!("{t}fade=in:st=0:d=0.5");
if t == "a" {
if let Some(fade) = config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.afade_in.clone())
{
fade_in = custom_format(&fade, &[t]);
}
} else if let Some(fade) = config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.fade_in.clone())
{
fade_in = custom_format(&fade, &[t]);
};
chain.add_filter(&fade_in, nr, filter_type);
}
if (node.out != node.duration && node.out - node.seek > 1.0) || fade_audio {
let mut fade_out = format!("{t}fade=out:st={}:d=1.0", (node.out - node.seek - 1.0));
if t == "a" {
if let Some(fade) = config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.afade_out.clone())
{
fade_out = custom_format(&fade, &[node.out - node.seek - 1.0]);
}
} else if let Some(fade) = config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.fade_out.clone())
.clone()
{
fade_out = custom_format(&fade, &[node.out - node.seek - 1.0]);
};
chain.add_filter(&fade_out, nr, filter_type);
}
}
fn overlay(node: &mut Media, chain: &mut Filters, config: &PlayoutConfig) {
if config.processing.add_logo
&& Path::new(&config.processing.logo).is_file()
&& &node.category != "advertisement"
{
let mut logo_chain = format!(
"null[v];movie={}:loop=0,setpts=N/(FRAME_RATE*TB),format=rgba,colorchannelmixer=aa={}",
config
.processing
.logo
.replace('\\', "/")
.replace(':', "\\\\:"),
config.processing.logo_opacity,
);
if node.last_ad {
match config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.overlay_logo_fade_in.clone())
{
Some(fade_in) => logo_chain.push_str(&format!(",{fade_in}")),
None => logo_chain.push_str(",fade=in:st=0:d=1.0:alpha=1"),
};
}
if node.next_ad {
let length = node.out - node.seek - 1.0;
match config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.overlay_logo_fade_out.clone())
{
Some(fade_out) => {
logo_chain.push_str(&custom_format(&format!(",{fade_out}"), &[length]))
}
None => logo_chain.push_str(&format!(",fade=out:st={length}:d=1.0:alpha=1")),
}
}
if !config.processing.logo_scale.is_empty() {
match &config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.overlay_logo_scale.clone())
{
Some(logo_scale) => logo_chain.push_str(&custom_format(
&format!(",{logo_scale}"),
&[&config.processing.logo_scale],
)),
None => logo_chain.push_str(&format!(",scale={}", config.processing.logo_scale)),
}
}
match config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.overlay_logo.clone())
{
Some(overlay) => {
if !overlay.starts_with(',') {
logo_chain.push(',');
}
logo_chain.push_str(&custom_format(
&overlay,
&[&config.processing.logo_position],
))
}
None => logo_chain.push_str(&format!(
"[l];[v][l]overlay={}:shortest=1",
config.processing.logo_position
)),
};
chain.add_filter(&logo_chain, 0, Video);
}
}
fn extend_video(node: &mut Media, chain: &mut Filters, config: &PlayoutConfig) {
if let Some(video_duration) = node
.probe
.as_ref()
.and_then(|p| p.video_streams.first())
.and_then(|v| v.duration.as_ref())
.and_then(|v| v.parse::<f64>().ok())
{
if node.out - node.seek > video_duration - node.seek + 0.1 && node.duration >= node.out {
let duration = (node.out - node.seek) - (video_duration - node.seek);
let tpad = match config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.tpad.clone())
{
Some(pad) => custom_format(&pad, &[duration]),
None => format!("tpad=stop_mode=add:stop_duration={duration}"),
};
chain.add_filter(&tpad, 0, Video)
}
}
}
/// add drawtext filter for lower thirds messages
fn add_text(
node: &mut Media,
chain: &mut Filters,
config: &PlayoutConfig,
filter_chain: &Option<Arc<Mutex<Vec<String>>>>,
) {
if config.text.add_text
&& (config.text.text_from_filename || config.out.mode == HLS || node.unit == Encoder)
{
let filter = v_drawtext::filter_node(config, Some(node), filter_chain);
chain.add_filter(&filter, 0, Video);
}
}
fn add_audio(node: &Media, chain: &mut Filters, nr: i32, config: &PlayoutConfig) {
let audio = match config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.aevalsrc.clone())
{
Some(aevalsrc) => custom_format(&aevalsrc, &[node.out - node.seek]),
None => format!(
"aevalsrc=0:channel_layout=stereo:duration={}:sample_rate=48000",
node.out - node.seek
),
};
chain.add_filter(&audio, nr, Audio);
}
fn extend_audio(node: &mut Media, chain: &mut Filters, nr: i32, config: &PlayoutConfig) {
if !Path::new(&node.audio).is_file() {
if let Some(audio_duration) = node
.probe
.as_ref()
.and_then(|p| p.audio_streams.first())
.and_then(|a| a.duration.clone())
.and_then(|a| a.parse::<f64>().ok())
{
if node.out - node.seek > audio_duration - node.seek + 0.1 && node.duration >= node.out
{
let apad = match config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.apad.clone())
{
Some(apad) => custom_format(&apad, &[node.out - node.seek]),
None => format!("apad=whole_dur={}", node.out - node.seek),
};
chain.add_filter(&apad, nr, Audio)
}
}
}
}
fn audio_volume(chain: &mut Filters, config: &PlayoutConfig, nr: i32) {
if config.processing.volume != 1.0 {
let volume = match config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.volume.clone())
{
Some(volume) => custom_format(&volume, &[config.processing.volume]),
None => format!("volume={}", config.processing.volume),
};
chain.add_filter(&volume, nr, Audio)
}
}
fn aspect_calc(aspect_string: &Option<String>, config: &PlayoutConfig) -> f64 {
let mut source_aspect = config.processing.aspect;
if let Some(aspect) = aspect_string {
let aspect_vec: Vec<&str> = aspect.split(':').collect();
let w = aspect_vec[0].parse::<f64>().unwrap();
let h = aspect_vec[1].parse::<f64>().unwrap();
source_aspect = w / h;
}
source_aspect
}
pub fn split_filter(
chain: &mut Filters,
count: usize,
nr: i32,
filter_type: FilterType,
config: &PlayoutConfig,
) {
if count > 1 {
let out_link = match filter_type {
Audio => &mut chain.audio_out_link,
Video => &mut chain.video_out_link,
};
for i in 0..count {
let link = format!("[{filter_type}out_{nr}_{i}]");
if !out_link.contains(&link) {
out_link.push(link)
}
}
let split = match config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.split.clone())
{
Some(split) => custom_format(&split, &[count.to_string(), out_link.join("")]),
None => format!("split={count}{}", out_link.join("")),
};
chain.add_filter(&split, nr, filter_type);
}
}
/// Process output filter chain and add new filters to existing ones.
fn process_output_filters(config: &PlayoutConfig, chain: &mut Filters, custom_filter: &str) {
let filter =
if (config.text.add_text && !config.text.text_from_filename) || config.out.mode == HLS {
let re_v = Regex::new(r"\[[0:]+[v^\[]+([:0]+)?\]").unwrap(); // match video filter input link
let _re_a = Regex::new(r"\[[0:]+[a^\[]+([:0]+)?\]").unwrap(); // match video filter input link
let mut cf = custom_filter.to_string();
if !chain.video_chain.is_empty() {
cf = re_v
.replace(&cf, &format!("{},", chain.video_chain))
.to_string()
}
if !chain.audio_chain.is_empty() {
let audio_split = chain
.audio_chain
.split(';')
.enumerate()
.map(|(i, p)| p.replace(&format!("[aout{i}]"), ""))
.collect::<Vec<String>>();
for i in 0..config.processing.audio_tracks {
cf = cf.replace(
&format!("[0:a:{i}]"),
&format!("{},", &audio_split[i as usize]),
)
}
}
cf
} else {
custom_filter.to_string()
};
chain.output_chain = vec_strings!["-filter_complex", filter]
}
fn custom(filter: &str, chain: &mut Filters, nr: i32, filter_type: FilterType) {
if !filter.is_empty() {
chain.add_filter(filter, nr, filter_type);
}
}
pub fn filter_chains(
config: &PlayoutConfig,
node: &mut Media,
filter_chain: &Option<Arc<Mutex<Vec<String>>>>,
) -> Filters {
let mut filters = Filters::new(config.clone(), 0);
if node.unit == Encoder {
if !config.processing.audio_only {
add_text(node, &mut filters, config, filter_chain);
}
if let Some(f) = config.out.output_filter.clone() {
process_output_filters(config, &mut filters, &f)
} else if config.out.output_count > 1 && !config.processing.audio_only {
split_filter(&mut filters, config.out.output_count, 0, Video, config);
}
return filters;
}
if !config.processing.audio_only && !config.processing.copy_video {
if let Some(probe) = node.probe.as_ref() {
if Path::new(&node.audio).is_file() {
filters.audio_position = 1;
}
if let Some(v_stream) = &probe.video_streams.first() {
let aspect = aspect_calc(&v_stream.display_aspect_ratio, config);
let frame_per_sec = fps_calc(&v_stream.r_frame_rate, 1.0);
deinterlace(&v_stream.field_order, &mut filters, config);
pad(aspect, &mut filters, v_stream, config);
fps(frame_per_sec, &mut filters, config);
scale(
v_stream.width,
v_stream.height,
aspect,
&mut filters,
config,
);
}
extend_video(node, &mut filters, config);
} else {
fps(0.0, &mut filters, config);
scale(None, None, 1.0, &mut filters, config);
}
add_text(node, &mut filters, config, filter_chain);
fade(node, &mut filters, 0, Video, config);
overlay(node, &mut filters, config);
}
let (proc_vf, proc_af) = if node.unit == Ingest {
custom::filter_node(&config.ingest.custom_filter)
} else {
custom::filter_node(&config.processing.custom_filter)
};
let (list_vf, list_af) = custom::filter_node(&node.custom_filter);
if !config.processing.copy_video {
custom(&proc_vf, &mut filters, 0, Video);
custom(&list_vf, &mut filters, 0, Video);
}
let mut audio_indexes = vec![];
if config.processing.audio_track_index == -1 {
for i in 0..config.processing.audio_tracks {
audio_indexes.push(i)
}
} else {
audio_indexes.push(config.processing.audio_track_index)
}
if !config.processing.copy_audio {
for i in audio_indexes {
if node
.probe
.as_ref()
.and_then(|p| p.audio_streams.get(i as usize))
.is_some()
|| Path::new(&node.audio).is_file()
{
extend_audio(node, &mut filters, i, config);
} else if node.unit == Decoder {
if !node.source.contains("color=c=") {
warn!(
"Missing audio track (id {i}) from <b><magenta>{}</></b>",
node.source
);
}
add_audio(node, &mut filters, i, config);
}
// add at least anull filter, for correct filter construction,
// is important for split filter in HLS mode
filters.add_filter("anull", i, Audio);
fade(node, &mut filters, i, Audio, config);
audio_volume(&mut filters, config, i);
custom(&proc_af, &mut filters, i, Audio);
custom(&list_af, &mut filters, i, Audio);
}
} else if config.processing.audio_track_index > -1 {
error!("Setting 'audio_track_index' other than '-1' is not allowed in audio copy mode!")
}
if config.out.mode == HLS {
if let Some(f) = config.out.output_filter.clone() {
process_output_filters(config, &mut filters, &f)
}
}
filters
}

View File

@ -1,78 +0,0 @@
use std::{
ffi::OsStr,
path::Path,
sync::{Arc, Mutex},
};
use regex::Regex;
use crate::utils::{controller::ProcessUnit::*, custom_format, Media, PlayoutConfig};
pub fn filter_node(
config: &PlayoutConfig,
node: Option<&Media>,
filter_chain: &Option<Arc<Mutex<Vec<String>>>>,
) -> String {
let mut filter = String::new();
let mut font = String::new();
if Path::new(&config.text.fontfile).is_file() {
font = format!(":fontfile='{}'", config.text.fontfile)
}
let zmq_socket = match node.map(|n| n.unit) {
Some(Ingest) => config.text.zmq_server_socket.clone(),
_ => config.text.zmq_stream_socket.clone(),
};
if config.text.text_from_filename && node.is_some() {
let source = node.unwrap_or(&Media::new(0, "", false)).source.clone();
let text = match Regex::new(&config.text.regex)
.ok()
.and_then(|r| r.captures(&source))
{
Some(t) => t[1].to_string(),
None => Path::new(&source)
.file_stem()
.unwrap_or_else(|| OsStr::new(&source))
.to_string_lossy()
.to_string(),
};
let escaped_text = text
.replace('\'', "'\\\\\\''")
.replace('%', "\\\\\\%")
.replace(':', "\\:");
filter = match &config
.advanced
.clone()
.and_then(|a| a.decoder.filters.drawtext_from_file)
{
Some(drawtext) => custom_format(drawtext, &[&escaped_text, &config.text.style, &font]),
None => format!("drawtext=text='{escaped_text}':{}{font}", config.text.style),
};
} else if let Some(socket) = zmq_socket {
let mut filter_cmd = format!("text=''{font}");
if let Some(chain) = filter_chain {
if let Some(link) = chain.lock().unwrap().iter().find(|&l| l.contains("text")) {
filter_cmd = link.to_string();
}
}
filter = match config
.advanced
.as_ref()
.and_then(|a| a.decoder.filters.drawtext_from_zmq.clone())
{
Some(drawtext) => custom_format(&drawtext, &[&socket.replace(':', "\\:"), &filter_cmd]),
None => format!(
"zmq=b=tcp\\\\://'{}',drawtext@dyntext={filter_cmd}",
socket.replace(':', "\\:")
),
};
}
filter
}

View File

@ -1,8 +0,0 @@
extern crate log;
extern crate simplelog;
pub mod filter;
pub mod macros;
pub mod utils;
use utils::advanced_config::AdvancedConfig;

View File

@ -1,6 +0,0 @@
#[macro_export]
macro_rules! vec_strings {
($($str:expr),*) => ({
vec![$($str.to_string(),)*] as Vec<String>
});
}

View File

@ -1,98 +0,0 @@
use std::{fs::File, io::Read, path::PathBuf};
use serde::{Deserialize, Serialize};
use shlex::split;
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct AdvancedConfig {
pub decoder: DecoderConfig,
pub encoder: EncoderConfig,
pub ingest: IngestConfig,
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct DecoderConfig {
pub input_param: Option<String>,
pub output_param: Option<String>,
pub filters: Filters,
#[serde(skip_serializing, skip_deserializing)]
pub input_cmd: Option<Vec<String>>,
#[serde(skip_serializing, skip_deserializing)]
pub output_cmd: Option<Vec<String>>,
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct EncoderConfig {
pub input_param: Option<String>,
#[serde(skip_serializing, skip_deserializing)]
pub input_cmd: Option<Vec<String>>,
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct IngestConfig {
pub input_param: Option<String>,
#[serde(skip_serializing, skip_deserializing)]
pub input_cmd: Option<Vec<String>>,
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct Filters {
pub deinterlace: Option<String>,
pub pad_scale_w: Option<String>,
pub pad_scale_h: Option<String>,
pub pad_video: Option<String>,
pub fps: Option<String>,
pub scale: Option<String>,
pub set_dar: Option<String>,
pub fade_in: Option<String>,
pub fade_out: Option<String>,
pub overlay_logo_scale: Option<String>,
pub overlay_logo_fade_in: Option<String>,
pub overlay_logo_fade_out: Option<String>,
pub overlay_logo: Option<String>,
pub tpad: Option<String>,
pub drawtext_from_file: Option<String>,
pub drawtext_from_zmq: Option<String>,
pub aevalsrc: Option<String>,
pub afade_in: Option<String>,
pub afade_out: Option<String>,
pub apad: Option<String>,
pub volume: Option<String>,
pub split: Option<String>,
}
impl AdvancedConfig {
pub fn new(cfg_path: PathBuf) -> Self {
let mut config: AdvancedConfig = Default::default();
if let Ok(mut file) = File::open(cfg_path) {
let mut contents = String::new();
if let Err(e) = file.read_to_string(&mut contents) {
eprintln!("Read advanced config file: {e}")
};
if let Ok(tm) = toml_edit::de::from_str(&contents) {
config = tm
};
if let Some(input_parm) = &config.decoder.input_param {
config.decoder.input_cmd = split(input_parm);
}
if let Some(output_param) = &config.decoder.output_param {
config.decoder.output_cmd = split(output_param);
}
if let Some(input_param) = &config.encoder.input_param {
config.encoder.input_cmd = split(input_param);
}
if let Some(input_param) = &config.ingest.input_param {
config.ingest.input_cmd = split(input_param);
}
};
config
}
}

View File

@ -1,585 +0,0 @@
use std::{
env, fmt,
fs::File,
io::Read,
path::{Path, PathBuf},
process,
str::FromStr,
};
use chrono::NaiveTime;
use log::LevelFilter;
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use shlex::split;
use crate::AdvancedConfig;
use super::vec_strings;
use crate::utils::{free_tcp_socket, time_to_sec, OutputMode::*};
pub const DUMMY_LEN: f64 = 60.0;
pub const IMAGE_FORMAT: [&str; 21] = [
"bmp", "dds", "dpx", "exr", "gif", "hdr", "j2k", "jpg", "jpeg", "pcx", "pfm", "pgm", "phm",
"png", "psd", "ppm", "sgi", "svg", "tga", "tif", "webp",
];
// Some well known errors can be safely ignore
pub const FFMPEG_IGNORE_ERRORS: [&str; 11] = [
"ac-tex damaged",
"codec s302m, is muxed as a private data stream",
"corrupt decoded frame in stream",
"corrupt input packet in stream",
"end mismatch left",
"Packet corrupt",
"Referenced QT chapter track not found",
"skipped MB in I-frame at",
"Thread message queue blocking",
"Warning MVs not available",
"frame size not set",
];
pub const FFMPEG_UNRECOVERABLE_ERRORS: [&str; 5] = [
"Address already in use",
"Invalid argument",
"Numerical result",
"Error initializing complex filters",
"Error while decoding stream #0:0: Invalid data found when processing input",
];
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum OutputMode {
Desktop,
HLS,
Null,
Stream,
}
impl FromStr for OutputMode {
type Err = String;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match input {
"desktop" => Ok(Self::Desktop),
"hls" => Ok(Self::HLS),
"null" => Ok(Self::Null),
"stream" => Ok(Self::Stream),
_ => Err("Use 'desktop', 'hls', 'null' or 'stream'".to_string()),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ProcessMode {
Folder,
Playlist,
}
impl fmt::Display for ProcessMode {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
ProcessMode::Folder => write!(f, "folder"),
ProcessMode::Playlist => write!(f, "playlist"),
}
}
}
impl FromStr for ProcessMode {
type Err = String;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match input {
"folder" => Ok(Self::Folder),
"playlist" => Ok(Self::Playlist),
_ => Err("Use 'folder' or 'playlist'".to_string()),
}
}
}
pub fn string_to_log_level<'de, D>(deserializer: D) -> Result<LevelFilter, D::Error>
where
D: Deserializer<'de>,
{
let s: String = Deserialize::deserialize(deserializer)?;
match s.to_lowercase().as_str() {
"debug" => Ok(LevelFilter::Debug),
"error" => Ok(LevelFilter::Error),
"info" => Ok(LevelFilter::Info),
"trace" => Ok(LevelFilter::Trace),
"warning" => Ok(LevelFilter::Warn),
"off" => Ok(LevelFilter::Off),
_ => Err(de::Error::custom("Error level not exists!")),
}
}
fn log_level_to_string<S>(l: &LevelFilter, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match l {
LevelFilter::Debug => s.serialize_str("DEBUG"),
LevelFilter::Error => s.serialize_str("ERROR"),
LevelFilter::Info => s.serialize_str("INFO"),
LevelFilter::Trace => s.serialize_str("TRACE"),
LevelFilter::Warn => s.serialize_str("WARNING"),
LevelFilter::Off => s.serialize_str("OFF"),
}
}
#[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
///
/// This we init ones, when ffplayout is starting and use them globally in the hole program.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PlayoutConfig {
#[serde(default, skip_serializing, skip_deserializing)]
pub advanced: Option<AdvancedConfig>,
pub general: General,
pub rpc_server: RpcServer,
pub mail: Mail,
pub logging: Logging,
pub processing: Processing,
pub ingest: Ingest,
pub playlist: Playlist,
pub storage: Storage,
pub text: Text,
#[serde(default)]
pub task: Task,
pub out: Out,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct General {
pub help_text: String,
pub stop_threshold: f64,
#[serde(default, skip_serializing, skip_deserializing)]
pub config_path: String,
#[serde(default)]
pub stat_file: String,
#[serde(skip_serializing, skip_deserializing)]
pub generate: Option<Vec<String>>,
#[serde(skip_serializing, skip_deserializing)]
pub ffmpeg_filters: Vec<String>,
#[serde(skip_serializing, skip_deserializing)]
pub ffmpeg_libs: Vec<String>,
#[serde(skip_serializing, skip_deserializing)]
pub template: Option<Template>,
#[serde(default, skip_serializing, skip_deserializing)]
pub skip_validation: bool,
#[serde(default, skip_serializing, skip_deserializing)]
pub validate: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RpcServer {
pub help_text: String,
pub enable: bool,
pub address: String,
pub authorization: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Mail {
pub help_text: String,
pub subject: String,
pub smtp_server: String,
pub starttls: bool,
pub sender_addr: String,
pub sender_pass: String,
pub recipient: String,
pub mail_level: String,
pub interval: u64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Logging {
pub help_text: String,
pub log_to_file: bool,
pub backup_count: usize,
pub local_time: bool,
pub timestamp: bool,
#[serde(alias = "log_path")]
pub path: PathBuf,
#[serde(
alias = "log_level",
serialize_with = "log_level_to_string",
deserialize_with = "string_to_log_level"
)]
pub level: LevelFilter,
pub ffmpeg_level: String,
pub ingest_level: Option<String>,
#[serde(default)]
pub detect_silence: bool,
#[serde(default)]
pub ignore_lines: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Processing {
pub help_text: String,
pub mode: ProcessMode,
#[serde(default)]
pub audio_only: bool,
#[serde(default = "default_track_index")]
pub audio_track_index: i32,
#[serde(default)]
pub copy_audio: bool,
#[serde(default)]
pub copy_video: bool,
pub width: i64,
pub height: i64,
pub aspect: f64,
pub fps: f64,
pub add_logo: bool,
pub logo: String,
pub logo_scale: String,
pub logo_opacity: f32,
pub logo_position: String,
#[serde(default = "default_tracks")]
pub audio_tracks: i32,
#[serde(default = "default_channels")]
pub audio_channels: u8,
pub volume: f64,
#[serde(default)]
pub custom_filter: String,
#[serde(skip_serializing, skip_deserializing)]
pub cmd: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Ingest {
pub help_text: String,
pub enable: bool,
input_param: String,
#[serde(default)]
pub custom_filter: String,
#[serde(skip_serializing, skip_deserializing)]
pub input_cmd: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Playlist {
pub help_text: String,
pub path: PathBuf,
pub day_start: String,
#[serde(skip_serializing, skip_deserializing)]
pub start_sec: Option<f64>,
pub length: String,
#[serde(skip_serializing, skip_deserializing)]
pub length_sec: Option<f64>,
pub infinit: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Storage {
pub help_text: String,
pub path: PathBuf,
#[serde(skip_serializing, skip_deserializing)]
pub paths: Vec<PathBuf>,
#[serde(alias = "filler_clip")]
pub filler: PathBuf,
pub extensions: Vec<String>,
pub shuffle: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Text {
pub help_text: String,
pub add_text: bool,
#[serde(skip_serializing, skip_deserializing)]
pub node_pos: Option<usize>,
#[serde(skip_serializing, skip_deserializing)]
pub zmq_stream_socket: Option<String>,
#[serde(skip_serializing, skip_deserializing)]
pub zmq_server_socket: Option<String>,
pub fontfile: String,
pub text_from_filename: bool,
pub style: String,
pub regex: String,
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct Task {
pub help_text: String,
pub enable: bool,
pub path: PathBuf,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Out {
pub help_text: String,
pub mode: OutputMode,
pub output_param: String,
#[serde(skip_serializing, skip_deserializing)]
pub output_count: usize,
#[serde(skip_serializing, skip_deserializing)]
pub output_filter: Option<String>,
#[serde(skip_serializing, skip_deserializing)]
pub output_cmd: Option<Vec<String>>,
}
fn default_track_index() -> i32 {
-1
}
fn default_tracks() -> i32 {
1
}
fn default_channels() -> u8 {
2
}
impl PlayoutConfig {
/// Read config from YAML file, and set some extra config values.
pub fn new(cfg_path: Option<PathBuf>, advanced_path: Option<PathBuf>) -> Self {
let mut config_path = PathBuf::from("/etc/ffplayout/ffplayout.toml");
if let Some(cfg) = cfg_path {
config_path = cfg;
}
if !config_path.is_file() {
if Path::new("./assets/ffplayout.toml").is_file() {
config_path = PathBuf::from("./assets/ffplayout.toml")
} else if let Some(p) = env::current_exe().ok().as_ref().and_then(|op| op.parent()) {
config_path = p.join("ffplayout.toml")
};
}
let mut file = match File::open(&config_path) {
Ok(file) => file,
Err(_) => {
eprintln!(
"ffplayout.toml not found!\nPut \"ffplayout.toml\" in \"/etc/playout/\" or beside the executable!"
);
process::exit(1);
}
};
let mut contents = String::new();
if let Err(e) = file.read_to_string(&mut contents) {
eprintln!("Read config file: {e}")
};
let mut config: PlayoutConfig = toml_edit::de::from_str(&contents).unwrap();
if let Some(adv_path) = advanced_path {
config.advanced = Some(AdvancedConfig::new(adv_path))
}
config.general.generate = None;
config.general.config_path = config_path.to_string_lossy().to_string();
config.general.stat_file = home::home_dir()
.unwrap_or_else(env::temp_dir)
.join(if config.general.stat_file.is_empty() {
".ffp_status"
} else {
&config.general.stat_file
})
.display()
.to_string();
if config.logging.ingest_level.is_none() {
config.logging.ingest_level = Some(config.logging.ffmpeg_level.clone())
}
config.playlist.start_sec = Some(time_to_sec(&config.playlist.day_start));
if config.playlist.length.contains(':') {
config.playlist.length_sec = Some(time_to_sec(&config.playlist.length));
} else {
config.playlist.length_sec = Some(86400.0);
}
if config.processing.add_logo && !Path::new(&config.processing.logo).is_file() {
config.processing.add_logo = false;
}
config.processing.logo_scale = config
.processing
.logo_scale
.trim_end_matches('~')
.to_string();
if config.processing.audio_tracks < 1 {
config.processing.audio_tracks = 1
}
let mut process_cmd = vec_strings![];
let advanced_output_cmd = config
.advanced
.as_ref()
.and_then(|a| a.decoder.output_cmd.clone());
if config.processing.audio_only {
process_cmd.append(&mut vec_strings!["-vn"]);
} else if config.processing.copy_video {
process_cmd.append(&mut vec_strings!["-c:v", "copy"]);
} else if let Some(decoder_cmd) = &advanced_output_cmd {
process_cmd.append(&mut decoder_cmd.clone());
} else {
let bitrate = format!(
"{}k",
config.processing.width * config.processing.height / 16
);
let buff_size = format!(
"{}k",
(config.processing.width * config.processing.height / 16) / 2
);
process_cmd.append(&mut vec_strings![
"-pix_fmt",
"yuv420p",
"-r",
&config.processing.fps,
"-c:v",
"mpeg2video",
"-g",
"1",
"-b:v",
&bitrate,
"-minrate",
&bitrate,
"-maxrate",
&bitrate,
"-bufsize",
&buff_size
]);
}
if config.processing.copy_audio {
process_cmd.append(&mut vec_strings!["-c:a", "copy"]);
} else if advanced_output_cmd.is_none() {
process_cmd.append(&mut pre_audio_codec(
&config.processing.custom_filter,
&config.ingest.custom_filter,
config.processing.audio_channels,
));
}
process_cmd.append(&mut vec_strings!["-f", "mpegts", "-"]);
config.processing.cmd = Some(process_cmd);
config.ingest.input_cmd = split(config.ingest.input_param.as_str());
config.out.output_count = 1;
config.out.output_filter = None;
if config.out.mode == Null {
config.out.output_cmd = Some(vec_strings!["-f", "null", "-"]);
} else if let Some(mut cmd) = split(config.out.output_param.as_str()) {
// get output count according to the var_stream_map value, or by counting output parameters
if let Some(i) = cmd.clone().iter().position(|m| m == "-var_stream_map") {
config.out.output_count = cmd[i + 1].split_whitespace().count();
} else {
config.out.output_count = cmd
.iter()
.enumerate()
.filter(|(i, p)| i > &0 && !p.starts_with('-') && !cmd[i - 1].starts_with('-'))
.count();
}
if let Some(i) = cmd.clone().iter().position(|r| r == "-filter_complex") {
config.out.output_filter = Some(cmd[i + 1].clone());
cmd.remove(i);
cmd.remove(i);
}
config.out.output_cmd = Some(cmd);
}
// when text overlay without text_from_filename is on, turn also the RPC server on,
// to get text messages from it
if config.text.add_text && !config.text.text_from_filename {
config.rpc_server.enable = true;
config.text.zmq_stream_socket = free_tcp_socket(String::new());
config.text.zmq_server_socket =
free_tcp_socket(config.text.zmq_stream_socket.clone().unwrap_or_default());
config.text.node_pos = Some(2);
} else {
config.text.zmq_stream_socket = None;
config.text.zmq_server_socket = None;
config.text.node_pos = None;
}
config
}
}
impl Default for PlayoutConfig {
fn default() -> Self {
Self::new(None, None)
}
}
/// When custom_filter contains loudnorm filter use a different audio encoder,
/// s302m has higher quality, but is experimental
/// and works not well together with the loudnorm filter.
fn pre_audio_codec(proc_filter: &str, ingest_filter: &str, channel_count: u8) -> Vec<String> {
let mut codec = vec_strings![
"-c:a",
"s302m",
"-strict",
"-2",
"-sample_fmt",
"s16",
"-ar",
"48000",
"-ac",
channel_count
];
if proc_filter.contains("loudnorm") || ingest_filter.contains("loudnorm") {
codec = vec_strings![
"-c:a",
"mp2",
"-b:a",
"384k",
"-ar",
"48000",
"-ac",
channel_count
];
}
codec
}

View File

@ -1,226 +0,0 @@
use std::{
fmt,
process::Child,
sync::{
atomic::{AtomicBool, AtomicUsize, Ordering},
Arc, Mutex,
},
};
#[cfg(not(windows))]
use signal_child::Signalable;
use serde::{Deserialize, Serialize};
use simplelog::*;
use crate::utils::Media;
/// Defined process units.
#[derive(Clone, Debug, Default, Copy, Eq, Serialize, Deserialize, PartialEq)]
pub enum ProcessUnit {
#[default]
Decoder,
Encoder,
Ingest,
}
impl fmt::Display for ProcessUnit {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
ProcessUnit::Decoder => write!(f, "Decoder"),
ProcessUnit::Encoder => write!(f, "Encoder"),
ProcessUnit::Ingest => write!(f, "Ingest"),
}
}
}
use ProcessUnit::*;
/// Process Controller
///
/// We save here some global states, about what is running and which processes are alive.
/// This we need for process termination, skipping clip decoder etc.
#[derive(Clone, Debug)]
pub struct ProcessControl {
pub decoder_term: Arc<Mutex<Option<Child>>>,
pub encoder_term: Arc<Mutex<Option<Child>>>,
pub server_term: Arc<Mutex<Option<Child>>>,
pub server_is_running: Arc<AtomicBool>,
pub is_terminated: Arc<AtomicBool>,
pub is_alive: Arc<AtomicBool>,
}
impl ProcessControl {
pub fn new() -> Self {
Self {
decoder_term: Arc::new(Mutex::new(None)),
encoder_term: Arc::new(Mutex::new(None)),
server_term: Arc::new(Mutex::new(None)),
server_is_running: Arc::new(AtomicBool::new(false)),
is_terminated: Arc::new(AtomicBool::new(false)),
is_alive: Arc::new(AtomicBool::new(true)),
}
}
}
impl Default for ProcessControl {
fn default() -> Self {
Self::new()
}
}
impl ProcessControl {
pub fn stop(&self, unit: ProcessUnit) -> Result<(), String> {
match unit {
Decoder => {
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() {
return Err(format!("Decoder {e:?}"));
}
}
}
Encoder => {
if let Some(proc) = self.encoder_term.lock().unwrap().as_mut() {
if let Err(e) = proc.kill() {
return Err(format!("Encoder {e:?}"));
};
}
}
Ingest => {
if let Some(proc) = self.server_term.lock().unwrap().as_mut() {
if let Err(e) = proc.kill() {
return Err(format!("Ingest server {e:?}"));
};
}
}
}
self.wait(unit)?;
Ok(())
}
/// Wait for process to proper close.
/// This prevents orphaned/zombi processes in system
pub fn wait(&self, unit: ProcessUnit) -> Result<(), String> {
match unit {
Decoder => {
if let Some(proc) = self.decoder_term.lock().unwrap().as_mut() {
if let Err(e) = proc.wait() {
return Err(format!("Decoder {e:?}"));
};
}
}
Encoder => {
if let Some(proc) = self.encoder_term.lock().unwrap().as_mut() {
if let Err(e) = proc.wait() {
return Err(format!("Encoder {e:?}"));
};
}
}
Ingest => {
if let Some(proc) = self.server_term.lock().unwrap().as_mut() {
if let Err(e) = proc.wait() {
return Err(format!("Ingest server {e:?}"));
};
}
}
}
Ok(())
}
/// No matter what is running, terminate them all.
pub fn stop_all(&self) {
debug!("Stop all child processes");
self.is_terminated.store(true, Ordering::SeqCst);
self.server_is_running.store(false, Ordering::SeqCst);
if self.is_alive.load(Ordering::SeqCst) {
self.is_alive.store(false, Ordering::SeqCst);
trace!("Playout is alive and processes are terminated");
for unit in [Decoder, Encoder, Ingest] {
if let Err(e) = self.stop(unit) {
if !e.contains("exited process") {
error!("{e}")
}
}
if let Err(e) = self.wait(unit) {
if !e.contains("exited process") {
error!("{e}")
}
}
}
}
}
}
// impl Drop for ProcessControl {
// fn drop(&mut self) {
// self.stop_all()
// }
// }
/// Global player control, to get infos about current clip etc.
#[derive(Clone, Debug)]
pub struct PlayerControl {
pub current_media: Arc<Mutex<Option<Media>>>,
pub current_list: Arc<Mutex<Vec<Media>>>,
pub filler_list: Arc<Mutex<Vec<Media>>>,
pub current_index: Arc<AtomicUsize>,
pub filler_index: Arc<AtomicUsize>,
}
impl PlayerControl {
pub fn new() -> Self {
Self {
current_media: Arc::new(Mutex::new(None)),
current_list: Arc::new(Mutex::new(vec![Media::new(0, "", false)])),
filler_list: Arc::new(Mutex::new(vec![])),
current_index: Arc::new(AtomicUsize::new(0)),
filler_index: Arc::new(AtomicUsize::new(0)),
}
}
}
impl Default for PlayerControl {
fn default() -> Self {
Self::new()
}
}
/// Global playout control, for move forward/backward clip, or resetting playlist/state.
#[derive(Clone, Debug)]
pub struct PlayoutStatus {
pub chain: Option<Arc<Mutex<Vec<String>>>>,
pub current_date: Arc<Mutex<String>>,
pub date: Arc<Mutex<String>>,
pub list_init: Arc<AtomicBool>,
pub time_shift: Arc<Mutex<f64>>,
}
impl PlayoutStatus {
pub fn new() -> Self {
Self {
chain: None,
current_date: Arc::new(Mutex::new(String::new())),
date: Arc::new(Mutex::new(String::new())),
list_init: Arc::new(AtomicBool::new(true)),
time_shift: Arc::new(Mutex::new(0.0)),
}
}
}
impl Default for PlayoutStatus {
fn default() -> Self {
Self::new()
}
}

View File

@ -1,56 +0,0 @@
use std::io;
use derive_more::Display;
use ffprobe::FfProbeError;
#[derive(Debug, Display)]
pub enum ProcError {
#[display(fmt = "Failed to spawn ffmpeg/ffprobe. {}", _0)]
CommandSpawn(io::Error),
#[display(fmt = "IO Error {}", _0)]
IO(io::Error),
#[display(fmt = "{}", _0)]
Custom(String),
#[display(fmt = "{}", _0)]
Ffprobe(FfProbeError),
#[display(fmt = "Regex compile error {}", _0)]
Regex(String),
#[display(fmt = "Thread error {}", _0)]
Thread(String),
}
impl From<std::io::Error> for ProcError {
fn from(err: std::io::Error) -> Self {
Self::CommandSpawn(err)
}
}
impl From<FfProbeError> for ProcError {
fn from(err: FfProbeError) -> Self {
Self::Ffprobe(err)
}
}
impl From<regex::Error> for ProcError {
fn from(err: regex::Error) -> Self {
Self::Regex(err.to_string())
}
}
impl From<log::SetLoggerError> for ProcError {
fn from(err: log::SetLoggerError) -> Self {
Self::Custom(err.to_string())
}
}
impl From<serde_json::Error> for ProcError {
fn from(err: serde_json::Error) -> Self {
Self::Custom(err.to_string())
}
}
impl From<Box<dyn std::any::Any + std::marker::Send>> for ProcError {
fn from(err: Box<dyn std::any::Any + std::marker::Send>) -> Self {
Self::Thread(format!("{err:?}"))
}
}

View File

@ -1,235 +0,0 @@
use std::sync::{
atomic::Ordering,
{Arc, Mutex},
};
use lexical_sort::natural_lexical_cmp;
use rand::{seq::SliceRandom, thread_rng};
use simplelog::*;
use walkdir::WalkDir;
use crate::utils::{
controller::PlayerControl, include_file_extension, time_in_seconds, Media, PlayoutConfig,
};
/// Folder Sources
///
/// Like playlist source, we create here a folder list for iterate over it.
#[derive(Debug, Clone)]
pub struct FolderSource {
config: PlayoutConfig,
filter_chain: Option<Arc<Mutex<Vec<String>>>>,
pub player_control: PlayerControl,
current_node: Media,
}
impl FolderSource {
pub fn new(
config: &PlayoutConfig,
filter_chain: Option<Arc<Mutex<Vec<String>>>>,
player_control: &PlayerControl,
) -> Self {
let mut path_list = vec![];
let mut media_list = vec![];
let mut index: usize = 0;
if config.general.generate.is_some() && !config.storage.paths.is_empty() {
for path in &config.storage.paths {
path_list.push(path)
}
} else {
path_list.push(&config.storage.path)
}
for path in &path_list {
if !path.is_dir() {
error!("Path not exists: <b><magenta>{path:?}</></b>");
}
for entry in WalkDir::new(path)
.into_iter()
.flat_map(|e| e.ok())
.filter(|f| f.path().is_file())
.filter(|f| include_file_extension(config, f.path()))
{
let media = Media::new(0, &entry.path().to_string_lossy(), false);
media_list.push(media);
}
}
if media_list.is_empty() {
error!(
"no playable files found under: <b><magenta>{:?}</></b>",
path_list
);
}
if config.storage.shuffle {
info!("Shuffle files");
let mut rng = thread_rng();
media_list.shuffle(&mut rng);
} else {
media_list.sort_by(|d1, d2| d1.source.cmp(&d2.source));
}
for item in media_list.iter_mut() {
item.index = Some(index);
index += 1;
}
*player_control.current_list.lock().unwrap() = media_list;
Self {
config: config.clone(),
filter_chain,
player_control: player_control.clone(),
current_node: Media::new(0, "", false),
}
}
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) {
let mut rng = thread_rng();
let mut nodes = self.player_control.current_list.lock().unwrap();
nodes.shuffle(&mut rng);
for (index, item) in nodes.iter_mut().enumerate() {
item.index = Some(index);
}
}
fn sort(&mut self) {
let mut nodes = self.player_control.current_list.lock().unwrap();
nodes.sort_by(|d1, d2| d1.source.cmp(&d2.source));
for (index, item) in nodes.iter_mut().enumerate() {
item.index = Some(index);
}
}
}
/// Create iterator for folder source
impl Iterator for FolderSource {
type Item = Media;
fn next(&mut self) -> Option<Self::Item> {
if self.player_control.current_index.load(Ordering::SeqCst)
< self.player_control.current_list.lock().unwrap().len()
{
let i = self.player_control.current_index.load(Ordering::SeqCst);
self.current_node = self.player_control.current_list.lock().unwrap()[i].clone();
let _ = self.current_node.add_probe(false).ok();
self.current_node
.add_filter(&self.config, &self.filter_chain);
self.current_node.begin = Some(time_in_seconds());
self.player_control
.current_index
.fetch_add(1, Ordering::SeqCst);
Some(self.current_node.clone())
} else {
if self.config.storage.shuffle {
if self.config.general.generate.is_none() {
info!("Shuffle files");
}
self.shuffle();
} else {
if self.config.general.generate.is_none() {
info!("Sort files");
}
self.sort();
}
self.current_node = self.player_control.current_list.lock().unwrap()[0].clone();
let _ = self.current_node.add_probe(false).ok();
self.current_node
.add_filter(&self.config, &self.filter_chain);
self.current_node.begin = Some(time_in_seconds());
self.player_control.current_index.store(1, Ordering::SeqCst);
Some(self.current_node.clone())
}
}
}
pub fn fill_filler_list(
config: &PlayoutConfig,
player_control: Option<PlayerControl>,
) -> Vec<Media> {
let mut filler_list = vec![];
let filler_path = &config.storage.filler;
if filler_path.is_dir() {
for (index, entry) in WalkDir::new(&config.storage.filler)
.into_iter()
.flat_map(|e| e.ok())
.filter(|f| f.path().is_file())
.filter(|f| include_file_extension(config, f.path()))
.enumerate()
{
let mut media = Media::new(index, &entry.path().to_string_lossy(), false);
if player_control.is_none() {
if let Err(e) = media.add_probe(false) {
error!("{e:?}");
};
}
filler_list.push(media);
}
if config.storage.shuffle {
let mut rng = thread_rng();
filler_list.shuffle(&mut rng);
} else {
filler_list.sort_by(|d1, d2| natural_lexical_cmp(&d1.source, &d2.source));
}
for (index, item) in filler_list.iter_mut().enumerate() {
item.index = Some(index);
}
if let Some(control) = player_control.as_ref() {
control.filler_list.lock().unwrap().clone_from(&filler_list);
}
} else if filler_path.is_file() {
let mut media = Media::new(0, &config.storage.filler.to_string_lossy(), false);
if player_control.is_none() {
if let Err(e) = media.add_probe(false) {
error!("{e:?}");
};
}
filler_list.push(media);
if let Some(control) = player_control.as_ref() {
control.filler_list.lock().unwrap().clone_from(&filler_list);
}
}
filler_list
}

View File

@ -1,317 +0,0 @@
/// Simple Playlist Generator
///
/// You can call ffplayout[.exe] -g YYYY-mm-dd - YYYY-mm-dd to generate JSON playlists.
///
/// The generator takes the files from storage, which are set in config.
/// It also respect the shuffle/sort mode.
use std::{
fs::{create_dir_all, write},
io::Error,
process::exit,
};
use chrono::Timelike;
use lexical_sort::{natural_lexical_cmp, StringSort};
use rand::{seq::SliceRandom, thread_rng, Rng};
use simplelog::*;
use walkdir::WalkDir;
use super::{folder::FolderSource, PlayerControl};
use crate::utils::{
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
pub fn generate_playlist(
config: &PlayoutConfig,
channel_name: Option<String>,
) -> Result<Vec<JsonPlaylist>, Error> {
let total_length = match config.playlist.length_sec {
Some(length) => length,
None => {
if config.playlist.length.contains(':') {
time_to_sec(&config.playlist.length)
} else {
86400.0
}
}
};
let player_control = PlayerControl::new();
let playlist_root = &config.playlist.path;
let mut playlists = vec![];
let mut date_range = vec![];
let mut from_template = false;
let channel = match channel_name {
Some(name) => name,
None => "Channel 1".to_string(),
};
if !playlist_root.is_dir() {
error!(
"Playlist folder <b><magenta>{:?}</></b> not exists!",
config.playlist.path
);
exit(1);
}
if let Some(range) = config.general.generate.clone() {
date_range = range;
}
if date_range.contains(&"-".to_string()) && date_range.len() == 3 {
date_range = get_date_range(&date_range)
}
// gives an iterator with infinit length
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 {
let d: Vec<&str> = date.split('-').collect();
let year = d[0];
let month = d[1];
let playlist_path = playlist_root.join(year).join(month);
let playlist_file = &playlist_path.join(format!("{date}.json"));
let mut length = 0.0;
let mut round = 0;
create_dir_all(playlist_path)?;
if playlist_file.is_file() {
warn!(
"Playlist exists, skip: <b><magenta>{}</></b>",
playlist_file.display()
);
continue;
}
info!(
"Generate playlist: <b><magenta>{}</></b>",
playlist_file.display()
);
let mut playlist = JsonPlaylist {
channel: channel.clone(),
date,
path: None,
start_sec: None,
length: None,
modified: None,
program: vec![],
};
if from_template {
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 {
playlist.program.push(item);
length += duration;
} else if round == list_length - 1 {
break;
} else {
round += 1;
}
}
let list_duration = sum_durations(&playlist.program);
if config.playlist.length_sec.unwrap() > list_duration {
let time_left = config.playlist.length_sec.unwrap() - list_duration;
let mut fillers = filler_list(config, time_left);
playlist.program.append(&mut fillers);
}
}
let json: String = serde_json::to_string_pretty(&playlist)?;
write(playlist_file, json)?;
playlists.push(playlist);
}
Ok(playlists)
}

View File

@ -1,80 +0,0 @@
/// Import text/m3u file and create a playlist out of it
use std::{
//error::Error,
fs::{create_dir_all, File},
io::{BufRead, BufReader, Error, ErrorKind},
path::Path,
};
use crate::utils::{json_reader, json_serializer::JsonPlaylist, json_writer, Media, PlayoutConfig};
pub fn import_file(
config: &PlayoutConfig,
date: &str,
channel_name: Option<String>,
path: &Path,
) -> Result<String, Error> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut playlist = JsonPlaylist {
channel: channel_name.unwrap_or_else(|| "Channel 1".to_string()),
date: date.to_string(),
path: None,
start_sec: None,
length: None,
modified: None,
program: vec![],
};
let playlist_root = &config.playlist.path;
if !playlist_root.is_dir() {
return Err(Error::new(
ErrorKind::Other,
format!(
"Playlist folder <b><magenta>{:?}</></b> not exists!",
config.playlist.path,
),
));
}
let d: Vec<&str> = date.split('-').collect();
let year = d[0];
let month = d[1];
let playlist_path = playlist_root.join(year).join(month);
let playlist_file = &playlist_path.join(format!("{date}.json"));
create_dir_all(playlist_path)?;
for line in reader.lines() {
let line = line?;
if !line.starts_with('#') {
let item = Media::new(0, &line, true);
if item.duration > 0.0 {
playlist.program.push(item);
}
}
}
let mut file_exists = false;
if playlist_file.is_file() {
file_exists = true;
let mut existing_data = json_reader(playlist_file)?;
existing_data.program.append(&mut playlist.program);
playlist.program = existing_data.program;
};
let mut msg = format!("Write playlist from {date} success!");
if file_exists {
msg = format!("Update playlist from {date} success!");
}
match json_writer(playlist_file, playlist) {
Ok(_) => Ok(msg),
Err(e) => Err(Error::new(ErrorKind::Other, e)),
}
}

View File

@ -1,205 +0,0 @@
use serde::{Deserialize, Serialize};
use std::{
fs::File,
path::Path,
sync::{atomic::AtomicBool, Arc},
thread,
};
use simplelog::*;
use crate::utils::{
get_date, is_remote, modified_time, time_from_header, validate_playlist, Media, PlayerControl,
PlayoutConfig, DUMMY_LEN,
};
/// This is our main playlist object, it holds all necessary information for the current day.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JsonPlaylist {
#[serde(default = "default_channel")]
pub channel: String,
pub date: String,
#[serde(skip_serializing, skip_deserializing)]
pub start_sec: Option<f64>,
#[serde(skip_serializing, skip_deserializing)]
pub length: Option<f64>,
#[serde(skip_serializing, skip_deserializing)]
pub path: Option<String>,
#[serde(skip_serializing, skip_deserializing)]
pub modified: Option<String>,
pub program: Vec<Media>,
}
impl JsonPlaylist {
pub fn new(date: String, start: f64) -> Self {
let mut media = Media::new(0, "", false);
media.begin = Some(start);
media.title = None;
media.duration = DUMMY_LEN;
media.out = DUMMY_LEN;
Self {
channel: "Channel 1".into(),
date,
start_sec: Some(start),
length: Some(86400.0),
path: None,
modified: None,
program: vec![media],
}
}
}
impl PartialEq for JsonPlaylist {
fn eq(&self, other: &Self) -> bool {
self.channel == other.channel && self.date == other.date && self.program == other.program
}
}
impl Eq for JsonPlaylist {}
fn default_channel() -> String {
"Channel 1".to_string()
}
pub fn set_defaults(playlist: &mut JsonPlaylist) {
let mut start_sec = playlist.start_sec.unwrap();
let mut length = 0.0;
// Add extra values to every media clip
for (i, item) in playlist.program.iter_mut().enumerate() {
item.begin = Some(start_sec);
item.index = Some(i);
item.last_ad = false;
item.next_ad = false;
item.process = Some(true);
item.filter = None;
let dur = item.out - item.seek;
start_sec += dur;
length += dur;
}
playlist.length = Some(length)
}
/// Read json playlist file, fills JsonPlaylist struct and set some extra values,
/// which we need to process.
pub fn read_json(
config: &mut PlayoutConfig,
player_control: &PlayerControl,
path: Option<String>,
is_terminated: Arc<AtomicBool>,
seek: bool,
get_next: bool,
) -> JsonPlaylist {
let config_clone = config.clone();
let control_clone = player_control.clone();
let mut playlist_path = config.playlist.path.clone();
let start_sec = config.playlist.start_sec.unwrap();
let date = get_date(seek, start_sec, get_next);
if playlist_path.is_dir() || is_remote(&config.playlist.path.to_string_lossy()) {
let d: Vec<&str> = date.split('-').collect();
playlist_path = playlist_path
.join(d[0])
.join(d[1])
.join(date.clone())
.with_extension("json");
}
let mut current_file = playlist_path.as_path().display().to_string();
if let Some(p) = path {
Path::new(&p).clone_into(&mut playlist_path);
current_file = p
}
if is_remote(&current_file) {
let response = reqwest::blocking::Client::new().get(&current_file).send();
if let Ok(resp) = response {
if resp.status().is_success() {
let headers = resp.headers().clone();
if let Ok(body) = resp.text() {
let mut playlist: JsonPlaylist = match serde_json::from_str(&body) {
Ok(p) => p,
Err(e) => {
error!("Could't read remote json playlist. {e:?}");
JsonPlaylist::new(date.clone(), start_sec)
}
};
playlist.path = Some(current_file);
playlist.start_sec = Some(start_sec);
if let Some(time) = time_from_header(&headers) {
playlist.modified = Some(time.to_string());
}
let list_clone = playlist.clone();
if !config.general.skip_validation {
thread::spawn(move || {
validate_playlist(
config_clone,
control_clone,
list_clone,
is_terminated,
)
});
}
set_defaults(&mut playlist);
return playlist;
}
}
}
} else if playlist_path.is_file() {
let modified = modified_time(&current_file);
let f = File::options()
.read(true)
.write(false)
.open(&current_file)
.expect("Could not open json playlist file.");
let mut playlist: JsonPlaylist = match serde_json::from_reader(f) {
Ok(p) => p,
Err(e) => {
error!("Playlist file not readable! {e}");
JsonPlaylist::new(date.clone(), start_sec)
}
};
// catch empty program list
if playlist.program.is_empty() {
playlist = JsonPlaylist::new(date, start_sec)
}
playlist.path = Some(current_file);
playlist.start_sec = Some(start_sec);
playlist.modified = modified;
let list_clone = playlist.clone();
if !config.general.skip_validation {
thread::spawn(move || {
validate_playlist(config_clone, control_clone, list_clone, is_terminated)
});
}
set_defaults(&mut playlist);
return playlist;
}
error!("Playlist <b><magenta>{current_file}</></b> not exist!");
JsonPlaylist::new(date, start_sec)
}

View File

@ -1,260 +0,0 @@
use std::{
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::{
errors::ProcError, is_close, is_remote, loop_image, sec_to_time, seek_and_length, vec_strings,
JsonPlaylist, Media, OutputMode::Null, PlayerControl, PlayoutConfig, FFMPEG_IGNORE_ERRORS,
IMAGE_FORMAT,
};
/// 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<(), ProcError> {
let mut dec_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 let Some(decoder_input_cmd) = config
.advanced
.as_ref()
.and_then(|a| a.decoder.input_cmd.clone())
{
dec_cmd.append(&mut decoder_input_cmd.clone());
}
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.
dec_cmd.append(&mut vec_strings!["-ss", seek]);
}
// Take care, that no seek and length command is added.
node.seek = 0.0;
node.out = node.duration;
if node
.source
.rsplit_once('.')
.map(|(_, e)| e.to_lowercase())
.filter(|c| IMAGE_FORMAT.contains(&c.as_str()))
.is_some()
{
node.cmd = Some(loop_image(&node));
} else {
node.cmd = Some(seek_and_length(&mut node));
}
node.add_filter(&config, &None);
let mut filter = node.filter.unwrap_or_default();
if filter.cmd().len() > 1 {
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);
dec_cmd.append(&mut node.cmd.unwrap_or_default());
dec_cmd.append(&mut filter.cmd());
dec_cmd.append(&mut filter.map());
dec_cmd.append(&mut vec_strings!["-t", process_length, "-f", "null", "-"]);
let mut enc_proc = Command::new("ffmpeg")
.args(dec_cmd)
.stderr(Stdio::piped())
.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?;
if !FFMPEG_IGNORE_ERRORS.iter().any(|i| line.contains(*i))
&& !config.logging.ignore_lines.iter().any(|i| line.contains(i))
&& (line.contains("[error]") || line.contains("[fatal]"))
{
let log_line = line.replace("[error] ", "").replace("[fatal] ", "");
if !error_list.contains(&log_line) {
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::<f32>().unwrap_or_default();
}
if let Some(end) = re_end.captures(&line).and_then(|c| c.get(2)) {
silence_end = end.as_str().parse::<f32>().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!(
"<bright black>[Validator]</> ffmpeg error on position <yellow>{pos}</> - {}: <b><magenta>{}</></b>: {}",
sec_to_time(begin),
node.source,
error_list.join("\n")
)
}
error_list.clear();
if let Err(e) = enc_proc.wait() {
error!("Validation process: {e:?}");
}
Ok(())
}
/// Validate a given playlist, to check if:
///
/// - the source files are existing
/// - file can be read by ffprobe and metadata exists
/// - total playtime fits target length from config
///
/// This function we run in a thread, to don't block the main function.
pub fn validate_playlist(
mut config: PlayoutConfig,
player_control: PlayerControl,
mut playlist: JsonPlaylist,
is_terminated: Arc<AtomicBool>,
) {
let date = playlist.date;
if config.text.add_text && !config.text.text_from_filename {
// Turn of drawtext filter with zmq, because its port is needed by the decoder instance.
config.text.add_text = false;
}
let mut length = config.playlist.length_sec.unwrap();
let mut begin = config.playlist.start_sec.unwrap();
length += begin;
debug!("Validate playlist from: <yellow>{date}</>");
let timer = Instant::now();
for (index, item) in playlist.program.iter_mut().enumerate() {
if is_terminated.load(Ordering::SeqCst) {
return;
}
let pos = index + 1;
if !is_remote(&item.source) {
if item.audio.is_empty() {
if let Err(e) = item.add_probe(false) {
error!(
"[Validation] Error on position <yellow>{pos:0>3}</> <yellow>{}</>: {e}",
sec_to_time(begin)
);
}
} else if let Err(e) = item.add_probe(true) {
error!(
"[Validation] Error on position <yellow>{pos:0>3}</> <yellow>{}</>: {e}",
sec_to_time(begin)
);
}
}
if item.probe.is_some() {
if let Err(e) = check_media(item.clone(), pos, begin, &config) {
error!("{e}");
} else if config.general.validate {
debug!(
"[Validation] Source at <yellow>{}</>, seems fine: <b><magenta>{}</></b>",
sec_to_time(begin),
item.source
)
} else if let Ok(mut list) = player_control.current_list.try_lock() {
// Filter out same item in current playlist, then add the probe to it.
// Check also if duration differs with playlist value, log error if so and adjust that value.
list.iter_mut().filter(|list_item| list_item.source == item.source).for_each(|o| {
o.probe.clone_from(&item.probe);
if let Some(dur) =
item.probe.as_ref().and_then(|f| f.format.duration.clone())
{
let probe_duration = dur.parse().unwrap_or_default();
if !is_close(o.duration, probe_duration, 1.2) {
error!(
"[Validation] File duration (at: <yellow>{}</>) differs from playlist value. File duration: <yellow>{}</>, playlist value: <yellow>{}</>, source <b><magenta>{}</></b>",
sec_to_time(o.begin.unwrap_or_default()), sec_to_time(probe_duration), sec_to_time(o.duration), o.source
);
o.duration = probe_duration;
}
}
if o.audio == item.audio && item.probe_audio.is_some() {
o.probe_audio.clone_from(&item.probe_audio);
o.duration_audio = item.duration_audio;
}
});
}
}
begin += item.out - item.seek;
}
if !config.playlist.infinit && length > begin + 1.2 {
error!(
"[Validation] Playlist from <yellow>{date}</> not long enough, <yellow>{}</> needed!",
sec_to_time(length - begin),
);
}
if config.general.validate {
info!(
"[Validation] Playlist length: <yellow>{}</>",
sec_to_time(begin - config.playlist.start_sec.unwrap())
);
}
debug!(
"Validation done, in <yellow>{:.3?}</>, playlist length: <yellow>{}</> ...",
timer.elapsed(),
sec_to_time(begin - config.playlist.start_sec.unwrap())
);
}

View File

@ -1,281 +0,0 @@
extern crate log;
extern crate simplelog;
use std::{
path::PathBuf,
sync::{atomic::Ordering, Arc, Mutex},
thread::{self, sleep},
time::Duration,
};
use chrono::prelude::*;
use file_rotate::{
compression::Compression,
suffix::{AppendTimestamp, DateFrom, FileLimit},
ContentLimit, FileRotate, TimeFrequency,
};
use lettre::{
message::header, transport::smtp::authentication::Credentials, Message, SmtpTransport,
Transport,
};
use log::{Level, LevelFilter, Log, Metadata, Record};
use regex::Regex;
use simplelog::*;
use crate::utils::{PlayoutConfig, ProcessControl};
/// send log messages to mail recipient
pub fn send_mail(cfg: &PlayoutConfig, msg: String) {
let recipient = cfg
.mail
.recipient
.split_terminator([',', ';', ' '])
.filter(|s| s.contains('@'))
.map(|s| s.trim())
.collect::<Vec<&str>>();
let mut message = Message::builder()
.from(cfg.mail.sender_addr.parse().unwrap())
.subject(&cfg.mail.subject)
.header(header::ContentType::TEXT_PLAIN);
for r in recipient {
message = message.to(r.parse().unwrap());
}
if let Ok(mail) = message.body(clean_string(&msg)) {
let credentials =
Credentials::new(cfg.mail.sender_addr.clone(), cfg.mail.sender_pass.clone());
let mut transporter = SmtpTransport::relay(cfg.mail.smtp_server.clone().as_str());
if cfg.mail.starttls {
transporter = SmtpTransport::starttls_relay(cfg.mail.smtp_server.clone().as_str());
}
let mailer = transporter.unwrap().credentials(credentials).build();
// Send the mail
if let Err(e) = mailer.send(&mail) {
error!("Could not send mail: {e}");
}
} else {
error!("Mail Message failed!");
}
}
/// Basic Mail Queue
///
/// Check every give seconds for messages and send them.
fn mail_queue(
cfg: PlayoutConfig,
proc_ctl: ProcessControl,
messages: Arc<Mutex<Vec<String>>>,
interval: u64,
) {
while !proc_ctl.is_terminated.load(Ordering::SeqCst) {
let mut msg = messages.lock().unwrap();
if msg.len() > 0 {
send_mail(&cfg, msg.join("\n"));
msg.clear();
}
drop(msg);
sleep(Duration::from_secs(interval));
}
}
/// Self made Mail Log struct, to extend simplelog.
pub struct LogMailer {
level: LevelFilter,
pub config: Config,
messages: Arc<Mutex<Vec<String>>>,
last_messages: Arc<Mutex<Vec<String>>>,
}
impl LogMailer {
pub fn new(
log_level: LevelFilter,
config: Config,
messages: Arc<Mutex<Vec<String>>>,
) -> Box<LogMailer> {
Box::new(LogMailer {
level: log_level,
config,
messages,
last_messages: Arc::new(Mutex::new(vec![String::new()])),
})
}
}
impl Log for LogMailer {
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
metadata.level() <= self.level
}
fn log(&self, record: &Record<'_>) {
if self.enabled(record.metadata()) {
let rec = record.args().to_string();
let mut last_msgs = self.last_messages.lock().unwrap();
// put message only to mail queue when it differs from last message
// this we do to prevent spamming the mail box
// also ignore errors from lettre mail module, because it prevents program from closing
if !last_msgs.contains(&rec) && !rec.contains("lettre") {
if last_msgs.len() > 2 {
last_msgs.clear()
}
last_msgs.push(rec.clone());
let local: DateTime<Local> = Local::now();
let time_stamp = local.format("[%Y-%m-%d %H:%M:%S%.3f]");
let level = record.level().to_string().to_uppercase();
let full_line = format!("{time_stamp} [{level: >5}] {rec}");
self.messages.lock().unwrap().push(full_line);
}
}
}
fn flush(&self) {}
}
impl SharedLogger for LogMailer {
fn level(&self) -> LevelFilter {
self.level
}
fn config(&self) -> Option<&Config> {
Some(&self.config)
}
fn as_log(self: Box<Self>) -> Box<dyn Log> {
Box::new(*self)
}
}
/// Workaround to remove color information from log
fn clean_string(text: &str) -> String {
let regex = Regex::new(r"\x1b\[[0-9;]*[mGKF]").unwrap();
regex.replace_all(text, "").to_string()
}
/// Initialize our logging, to have:
///
/// - console logger
/// - file logger
/// - mail logger
pub fn init_logging(
config: &PlayoutConfig,
proc_ctl: Option<ProcessControl>,
messages: Option<Arc<Mutex<Vec<String>>>>,
) -> Vec<Box<dyn SharedLogger>> {
let config_clone = config.clone();
let app_config = config.logging.clone();
let mut time_level = LevelFilter::Off;
let mut app_logger: Vec<Box<dyn SharedLogger>> = vec![];
if app_config.timestamp {
time_level = LevelFilter::Error;
}
let mut log_config = ConfigBuilder::new()
.set_thread_level(LevelFilter::Off)
.set_target_level(LevelFilter::Off)
.add_filter_ignore_str("hyper")
.add_filter_ignore_str("libc")
.add_filter_ignore_str("neli")
.add_filter_ignore_str("reqwest")
.add_filter_ignore_str("rpc")
.add_filter_ignore_str("rustls")
.add_filter_ignore_str("serial_test")
.add_filter_ignore_str("sqlx")
.add_filter_ignore_str("tiny_http")
.set_level_padding(LevelPadding::Left)
.set_time_level(time_level)
.clone();
if app_config.local_time {
log_config = match log_config.set_time_offset_to_local() {
Ok(local) => local.clone(),
Err(_) => log_config,
};
};
if app_config.log_to_file && app_config.path.exists() {
let file_config = log_config
.clone()
.set_time_format_custom(format_description!(
"[[[year]-[month]-[day] [hour]:[minute]:[second].[subsecond digits:5]]"
))
.build();
let mut log_path = PathBuf::from("logs/ffplayout.log");
if app_config.path.is_dir() {
log_path = app_config.path.join("ffplayout.log");
} else if app_config.path.is_file() {
log_path = app_config.path
} else {
eprintln!("Logging path not exists!")
}
let log_file = FileRotate::new(
log_path,
AppendTimestamp::with_format(
"%Y-%m-%d",
FileLimit::MaxFiles(app_config.backup_count),
DateFrom::DateYesterday,
),
ContentLimit::Time(TimeFrequency::Daily),
Compression::None,
#[cfg(unix)]
None,
);
app_logger.push(WriteLogger::new(app_config.level, file_config, log_file));
} else {
let term_config = log_config
.clone()
.set_level_color(Level::Trace, Some(Color::Ansi256(11)))
.set_level_color(Level::Debug, Some(Color::Ansi256(12)))
.set_level_color(Level::Info, Some(Color::Ansi256(10)))
.set_level_color(Level::Warn, Some(Color::Ansi256(208)))
.set_level_color(Level::Error, Some(Color::Ansi256(9)))
.set_time_format_custom(format_description!(
"\x1b[[30;1m[[[year]-[month]-[day] [hour]:[minute]:[second].[subsecond digits:5]]\x1b[[0m"
))
.build();
app_logger.push(TermLogger::new(
app_config.level,
term_config,
TerminalMode::Mixed,
ColorChoice::Auto,
));
}
// set mail logger only the recipient is set in config
if config.mail.recipient.contains('@') && config.mail.recipient.contains('.') {
let messages_clone = messages.clone().unwrap();
let interval = config.mail.interval;
thread::spawn(move || {
mail_queue(config_clone, proc_ctl.unwrap(), messages_clone, interval)
});
let mail_config = log_config.build();
let filter = match config.mail.mail_level.to_lowercase().as_str() {
"info" => LevelFilter::Info,
"warning" => LevelFilter::Warn,
_ => LevelFilter::Error,
};
app_logger.push(LogMailer::new(filter, mail_config, messages.unwrap()));
}
app_logger
}

File diff suppressed because it is too large Load Diff

View File

@ -8,9 +8,7 @@ edition.workspace = true
publish = false
[dev-dependencies]
ffplayout-engine = { path = "../ffplayout-engine" }
# ffplayout-api = { path = "../ffplayout-api" }
ffplayout-lib = { path = "../lib" }
ffplayout= { path = "../ffplayout" }
chrono = "0.4"
crossbeam-channel = "0.5"
@ -25,8 +23,9 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serial_test = "3.0"
shlex = "1.1"
simplelog = { version = "^0.12", features = ["paris"] }
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] }
time = { version = "0.3", features = ["formatting", "macros"] }
tokio = { version = "1.29", features = ["full"] }
toml_edit = {version ="0.22", features = ["serde"]}
walkdir = "2"

View File

@ -1,23 +1,41 @@
use std::{fs, path::PathBuf};
use std::fs;
use ffplayout_engine::{input::playlist::gen_source, utils::prepare_output_cmd};
use ffplayout_lib::{
utils::{Media, OutputMode::*, PlayerControl, PlayoutConfig, PlayoutStatus, ProcessUnit::*},
vec_strings,
use sqlx::{Pool, Sqlite};
use tokio::runtime::Runtime;
use ffplayout::db::{db_pool, handles};
use ffplayout::player::{
controller::{ChannelManager, ProcessUnit::*},
input::playlist::gen_source,
utils::prepare_output_cmd,
utils::Media,
};
use ffplayout::utils::config::{OutputMode::*, PlayoutConfig};
use ffplayout::vec_strings;
fn get_pool() -> Pool<Sqlite> {
Runtime::new().unwrap().block_on(db_pool()).unwrap()
}
#[test]
fn video_audio_input() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
let player_control = PlayerControl::new();
let playout_stat = PlayoutStatus::new();
config.out.mode = Stream;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
config.output.mode = Stream;
config.processing.add_logo = true;
let logo_path = fs::canonicalize("./assets/logo.png").unwrap();
config.processing.logo = logo_path.to_string_lossy().to_string();
let media_obj = Media::new(0, "./assets/media_mix/with_audio.mp4", true);
let media = gen_source(&config, media_obj, &playout_stat, &player_control, 1);
let media = gen_source(&config, media_obj, &manager, 1);
let test_filter_cmd =
vec_strings![
@ -37,15 +55,22 @@ fn video_audio_input() {
#[test]
fn video_audio_custom_filter1_input() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
let player_control = PlayerControl::new();
let playout_stat = PlayoutStatus::new();
config.out.mode = Stream;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
config.output.mode = Stream;
config.processing.add_logo = false;
config.processing.custom_filter = "[0:v]gblur=2[c_v_out];[0:a]volume=0.2[c_a_out]".to_string();
let media_obj = Media::new(0, "./assets/media_mix/with_audio.mp4", true);
let media = gen_source(&config, media_obj, &playout_stat, &player_control, 1);
let media = gen_source(&config, media_obj, &manager, 1);
let test_filter_cmd = vec_strings![
"-filter_complex",
@ -64,17 +89,23 @@ fn video_audio_custom_filter1_input() {
#[test]
fn video_audio_custom_filter2_input() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
let player_control = PlayerControl::new();
let playout_stat = PlayoutStatus::new();
config.out.mode = Stream;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
config.output.mode = Stream;
config.processing.add_logo = false;
config.processing.custom_filter =
"[0:v]null[v];movie=logo.png[l];[v][l]overlay[c_v_out];[0:a]volume=0.2[c_a_out]"
.to_string();
let media_obj = Media::new(0, "./assets/media_mix/with_audio.mp4", true);
let media = gen_source(&config, media_obj, &playout_stat, &player_control, 1);
let media = gen_source(&config, media_obj, &manager, 1);
let test_filter_cmd = vec_strings![
"-filter_complex",
@ -93,16 +124,22 @@ fn video_audio_custom_filter2_input() {
#[test]
fn video_audio_custom_filter3_input() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
let player_control = PlayerControl::new();
let playout_stat = PlayoutStatus::new();
config.out.mode = Stream;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
config.output.mode = Stream;
config.processing.add_logo = false;
config.processing.custom_filter =
"[v_in];movie=logo.png[l];[v_in][l]overlay[c_v_out];[0:a]volume=0.2[c_a_out]".to_string();
let media_obj = Media::new(0, "./assets/media_mix/with_audio.mp4", true);
let media = gen_source(&config, media_obj, &playout_stat, &player_control, 1);
let media = gen_source(&config, media_obj, &manager, 1);
let test_filter_cmd = vec_strings![
"-filter_complex",
@ -121,15 +158,21 @@ fn video_audio_custom_filter3_input() {
#[test]
fn dual_audio_aevalsrc_input() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
let player_control = PlayerControl::new();
let playout_stat = PlayoutStatus::new();
config.out.mode = Stream;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
config.output.mode = Stream;
config.processing.audio_tracks = 2;
config.processing.add_logo = false;
let media_obj = Media::new(0, "./assets/media_mix/with_audio.mp4", true);
let media = gen_source(&config, media_obj, &playout_stat, &player_control, 1);
let media = gen_source(&config, media_obj, &manager, 1);
let test_filter_cmd =
vec_strings![
@ -149,15 +192,21 @@ fn dual_audio_aevalsrc_input() {
#[test]
fn dual_audio_input() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
let player_control = PlayerControl::new();
let playout_stat = PlayoutStatus::new();
config.out.mode = Stream;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
config.output.mode = Stream;
config.processing.audio_tracks = 2;
config.processing.add_logo = false;
let media_obj = Media::new(0, "./assets/media_mix/dual_audio.mp4", true);
let media = gen_source(&config, media_obj, &playout_stat, &player_control, 1);
let media = gen_source(&config, media_obj, &manager, 1);
let test_filter_cmd = vec_strings![
"-filter_complex",
@ -176,16 +225,22 @@ fn dual_audio_input() {
#[test]
fn video_separate_audio_input() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
let player_control = PlayerControl::new();
let playout_stat = PlayoutStatus::new();
config.out.mode = Stream;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
config.output.mode = Stream;
config.processing.audio_tracks = 1;
config.processing.add_logo = false;
let mut media_obj = Media::new(0, "./assets/media_mix/no_audio.mp4", true);
media_obj.audio = "./assets/media_mix/audio.mp3".to_string();
let media = gen_source(&config, media_obj, &playout_stat, &player_control, 1);
let media = gen_source(&config, media_obj, &manager, 1);
let test_filter_cmd = vec_strings![
"-filter_complex",
@ -213,10 +268,13 @@ fn video_separate_audio_input() {
#[test]
fn video_audio_stream() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
config.out.mode = Stream;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
config.output.mode = Stream;
config.processing.add_logo = false;
config.out.output_cmd = Some(vec_strings![
config.output.output_cmd = Some(vec_strings![
"-c:v",
"libx264",
"-c:a",
@ -272,12 +330,15 @@ fn video_audio_stream() {
#[test]
fn video_audio_filter1_stream() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
config.out.mode = Stream;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
config.output.mode = Stream;
config.processing.add_logo = false;
config.text.add_text = false;
config.out.output_filter = Some("[0:v]gblur=2[vout0];[0:a]volume=0.2[aout0]".to_string());
config.out.output_cmd = Some(vec_strings![
config.output.output_filter = Some("[0:v]gblur=2[vout0];[0:a]volume=0.2[aout0]".to_string());
config.output.output_cmd = Some(vec_strings![
"-map",
"[vout0]",
"-map",
@ -347,13 +408,16 @@ fn video_audio_filter1_stream() {
#[test]
fn video_audio_filter2_stream() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
config.out.mode = Stream;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
config.output.mode = Stream;
config.processing.add_logo = false;
config.text.add_text = true;
config.text.fontfile = String::new();
config.out.output_filter = Some("[0:v]gblur=2[vout0];[0:a]volume=0.2[aout0]".to_string());
config.out.output_cmd = Some(vec_strings![
config.output.output_filter = Some("[0:v]gblur=2[vout0];[0:a]volume=0.2[aout0]".to_string());
config.output.output_cmd = Some(vec_strings![
"-map",
"[vout0]",
"-map",
@ -430,16 +494,19 @@ fn video_audio_filter2_stream() {
#[test]
fn video_audio_filter3_stream() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
config.out.mode = Stream;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
config.output.mode = Stream;
config.processing.add_logo = false;
config.text.add_text = true;
config.text.fontfile = String::new();
config.out.output_filter = Some(
config.output.output_filter = Some(
"[0:v]null[o];movie=/path/to/lower_third.png[l];[o][l]overlay=shortest=1[vout0]"
.to_string(),
);
config.out.output_cmd = Some(vec_strings![
config.output.output_cmd = Some(vec_strings![
"-map",
"[vout0]",
"-map",
@ -516,16 +583,19 @@ fn video_audio_filter3_stream() {
#[test]
fn video_audio_filter4_stream() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
config.out.mode = Stream;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
config.output.mode = Stream;
config.processing.add_logo = false;
config.text.add_text = true;
config.text.fontfile = String::new();
config.out.output_filter = Some(
config.output.output_filter = Some(
"[0:v]null[o];movie=/path/to/lower_third.png[l];[o][l]overlay=shortest=1[vout0];[0:a:0]volume=0.2[aout0]"
.to_string(),
);
config.out.output_cmd = Some(vec_strings![
config.output.output_cmd = Some(vec_strings![
"-map",
"[vout0]",
"-map",
@ -602,12 +672,15 @@ fn video_audio_filter4_stream() {
#[test]
fn video_dual_audio_stream() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
config.out.mode = Stream;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
config.output.mode = Stream;
config.processing.add_logo = false;
config.processing.audio_tracks = 2;
config.text.add_text = false;
config.out.output_cmd = Some(vec_strings![
config.output.output_cmd = Some(vec_strings![
"-c:v",
"libx264",
"-c:a",
@ -673,12 +746,15 @@ fn video_dual_audio_stream() {
#[test]
fn video_dual_audio_filter_stream() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
config.out.mode = Stream;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
config.output.mode = Stream;
config.processing.add_logo = false;
config.processing.audio_tracks = 2;
config.text.fontfile = String::new();
config.out.output_cmd = Some(vec_strings![
config.output.output_cmd = Some(vec_strings![
"-c:v",
"libx264",
"-c:a",
@ -753,10 +829,13 @@ fn video_dual_audio_filter_stream() {
#[test]
fn video_audio_multi_stream() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
config.out.mode = Stream;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
config.output.mode = Stream;
config.processing.add_logo = false;
config.out.output_cmd = Some(vec_strings![
config.output.output_cmd = Some(vec_strings![
"-c:v",
"libx264",
"-c:a",
@ -842,11 +921,14 @@ fn video_audio_multi_stream() {
#[test]
fn video_dual_audio_multi_stream() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
config.out.mode = Stream;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
config.output.mode = Stream;
config.processing.add_logo = false;
config.processing.audio_tracks = 2;
config.out.output_cmd = Some(vec_strings![
config.output.output_cmd = Some(vec_strings![
"-map",
"0:v",
"-map",
@ -956,13 +1038,16 @@ fn video_dual_audio_multi_stream() {
#[test]
fn video_audio_text_multi_stream() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
config.out.mode = Stream;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
config.output.mode = Stream;
config.processing.add_logo = false;
config.text.add_text = true;
config.text.fontfile = String::new();
config.out.output_count = 2;
config.out.output_cmd = Some(vec_strings![
config.output.output_count = 2;
config.output.output_cmd = Some(vec_strings![
"-c:v",
"libx264",
"-c:a",
@ -1069,13 +1154,16 @@ fn video_audio_text_multi_stream() {
#[test]
fn video_dual_audio_multi_filter_stream() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
config.out.mode = Stream;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
config.output.mode = Stream;
config.processing.add_logo = false;
config.processing.audio_tracks = 2;
config.out.output_count = 2;
config.output.output_count = 2;
config.text.fontfile = String::new();
config.out.output_cmd = Some(vec_strings![
config.output.output_cmd = Some(vec_strings![
"-map",
"0:v",
"-map",
@ -1198,14 +1286,17 @@ fn video_dual_audio_multi_filter_stream() {
#[test]
fn video_audio_text_filter_stream() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
config.out.mode = Stream;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
config.output.mode = Stream;
config.processing.add_logo = false;
config.processing.audio_tracks = 1;
config.text.add_text = true;
config.text.fontfile = String::new();
config.out.output_count = 2;
config.out.output_cmd = Some(vec_strings![
config.output.output_count = 2;
config.output.output_cmd = Some(vec_strings![
"-map",
"0:v",
"-map",
@ -1320,13 +1411,19 @@ fn video_audio_text_filter_stream() {
#[test]
fn video_audio_hls() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
let player_control = PlayerControl::new();
let playout_stat = PlayoutStatus::new();
config.out.mode = HLS;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
config.output.mode = HLS;
config.processing.add_logo = false;
config.text.add_text = false;
config.out.output_cmd = Some(vec_strings![
config.output.output_cmd = Some(vec_strings![
"-c:v",
"libx264",
"-c:a",
@ -1351,7 +1448,7 @@ fn video_audio_hls() {
]);
let media_obj = Media::new(0, "./assets/media_mix/with_audio.mp4", true);
let media = gen_source(&config, media_obj, &playout_stat, &player_control, 1);
let media = gen_source(&config, media_obj, &manager, 1);
let enc_prefix = vec_strings![
"-hide_banner",
@ -1407,13 +1504,19 @@ fn video_audio_hls() {
#[test]
fn video_audio_sub_meta_hls() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
let player_control = PlayerControl::new();
let playout_stat = PlayoutStatus::new();
config.out.mode = HLS;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
config.output.mode = HLS;
config.processing.add_logo = false;
config.text.add_text = false;
config.out.output_cmd = Some(vec_strings![
config.output.output_cmd = Some(vec_strings![
"-map",
"0:s:0",
"-map_metadata",
@ -1442,7 +1545,7 @@ fn video_audio_sub_meta_hls() {
]);
let media_obj = Media::new(0, "./assets/media_mix/with_audio.mp4", true);
let media = gen_source(&config, media_obj, &playout_stat, &player_control, 1);
let media = gen_source(&config, media_obj, &manager, 1);
let enc_prefix = vec_strings![
"-hide_banner",
@ -1502,14 +1605,20 @@ fn video_audio_sub_meta_hls() {
#[test]
fn video_multi_audio_hls() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
let player_control = PlayerControl::new();
let playout_stat = PlayoutStatus::new();
config.out.mode = HLS;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
config.output.mode = HLS;
config.processing.add_logo = false;
config.processing.audio_tracks = 2;
config.text.add_text = false;
config.out.output_cmd = Some(vec_strings![
config.output.output_cmd = Some(vec_strings![
"-c:v",
"libx264",
"-c:a",
@ -1534,7 +1643,7 @@ fn video_multi_audio_hls() {
]);
let media_obj = Media::new(0, "./assets/media_mix/dual_audio.mp4", true);
let media = gen_source(&config, media_obj, &playout_stat, &player_control, 1);
let media = gen_source(&config, media_obj, &manager, 1);
let enc_prefix = vec_strings![
"-hide_banner",
@ -1592,17 +1701,23 @@ fn video_multi_audio_hls() {
#[test]
fn multi_video_audio_hls() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
let player_control = PlayerControl::new();
let playout_stat = PlayoutStatus::new();
config.out.mode = HLS;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
config.output.mode = HLS;
config.processing.add_logo = false;
config.text.add_text = false;
config.out.output_count = 2;
config.out.output_filter = Some(
config.output.output_count = 2;
config.output.output_filter = Some(
"[0:v]split=2[v1_out][v2];[v2]scale=w=512:h=288[v2_out];[0:a]asplit=2[a1][a2]".to_string(),
);
config.out.output_cmd = Some(vec_strings![
config.output.output_cmd = Some(vec_strings![
"-map",
"[v1_out]",
"-map",
@ -1641,7 +1756,7 @@ fn multi_video_audio_hls() {
]);
let media_obj = Media::new(0, "./assets/media_mix/with_audio.mp4", true);
let media = gen_source(&config, media_obj, &playout_stat, &player_control, 1);
let media = gen_source(&config, media_obj, &manager, 1);
let enc_prefix = vec_strings![
"-hide_banner",
@ -1707,16 +1822,22 @@ fn multi_video_audio_hls() {
#[test]
fn multi_video_multi_audio_hls() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
let player_control = PlayerControl::new();
let playout_stat = PlayoutStatus::new();
config.out.mode = HLS;
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
config.output.mode = HLS;
config.processing.add_logo = false;
config.processing.audio_tracks = 2;
config.text.add_text = false;
config.out.output_count = 2;
config.out.output_filter = Some("[0:v]split=2[v1_out][v2];[v2]scale=w=512:h=288[v2_out];[0:a:0]asplit=2[a_0_1][a_0_2];[0:a:1]asplit=2[a_1_1][a_1_2]".to_string());
config.out.output_cmd = Some(vec_strings![
config.output.output_count = 2;
config.output.output_filter = Some("[0:v]split=2[v1_out][v2];[v2]scale=w=512:h=288[v2_out];[0:a:0]asplit=2[a_0_1][a_0_2];[0:a:1]asplit=2[a_1_1][a_1_2]".to_string());
config.output.output_cmd = Some(vec_strings![
"-map",
"[v1_out]",
"-map",
@ -1759,7 +1880,7 @@ fn multi_video_multi_audio_hls() {
]);
let media_obj = Media::new(0, "./assets/media_mix/dual_audio.mp4", true);
let media = gen_source(&config, media_obj, &playout_stat, &player_control, 1);
let media = gen_source(&config, media_obj, &manager, 1);
let enc_prefix = vec_strings![
"-hide_banner",

View File

@ -4,14 +4,22 @@ use std::{
};
use chrono::NaiveTime;
use simplelog::*;
use sqlx::{Pool, Sqlite};
use tokio::runtime::Runtime;
use ffplayout_lib::utils::{
config::{Source, Template},
use ffplayout::db::{db_pool, handles};
use ffplayout::player::{controller::ChannelManager, utils::*};
use ffplayout::utils::config::ProcessMode::Playlist;
use ffplayout::utils::playlist::generate_playlist;
use ffplayout::utils::{
config::{PlayoutConfig, Source, Template},
generator::*,
*,
};
fn get_pool() -> Pool<Sqlite> {
Runtime::new().unwrap().block_on(db_pool()).unwrap()
}
#[test]
fn test_random_list() {
let clip_list = vec![
@ -53,7 +61,11 @@ fn test_ordered_list() {
#[test]
#[ignore]
fn test_filler_list() {
let mut config = PlayoutConfig::new(None, None);
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
config.storage.filler = "assets/".into();
let f_list = filler_list(&config, 2440.0);
@ -64,20 +76,23 @@ fn test_filler_list() {
#[test]
#[ignore]
fn test_generate_playlist_from_folder() {
let mut config = PlayoutConfig::new(None, None);
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
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()));
let playlist = generate_playlist(manager);
assert!(playlist.is_ok());
@ -87,7 +102,7 @@ fn test_generate_playlist_from_folder() {
fs::remove_file(playlist_file).unwrap();
let total_duration = sum_durations(&playlist.unwrap()[0].program);
let total_duration = sum_durations(&playlist.unwrap().program);
assert!(
total_duration > 86399.0 && total_duration < 86401.0,
@ -98,7 +113,16 @@ fn test_generate_playlist_from_folder() {
#[test]
#[ignore]
fn test_generate_playlist_from_template() {
let mut config = PlayoutConfig::new(None, None);
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
config.general.generate = Some(vec!["2023-09-12".to_string()]);
config.general.template = Some(Template {
sources: vec![
@ -117,17 +141,11 @@ fn test_generate_playlist_from_template() {
],
});
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()));
let playlist = generate_playlist(manager);
assert!(playlist.is_ok());
@ -137,7 +155,7 @@ fn test_generate_playlist_from_template() {
fs::remove_file(playlist_file).unwrap();
let total_duration = sum_durations(&playlist.unwrap()[0].program);
let total_duration = sum_durations(&playlist.unwrap().program);
assert!(
total_duration > 86399.0 && total_duration < 86401.0,

View File

@ -4,23 +4,42 @@ use std::{
};
use serial_test::serial;
use simplelog::*;
use sqlx::{Pool, Sqlite};
use tokio::runtime::Runtime;
use ffplayout_engine::{input::playlist::gen_source, output::player};
use ffplayout_lib::{utils::*, vec_strings};
use ffplayout::db::{db_pool, handles};
use ffplayout::player::output::player;
use ffplayout::player::utils::*;
use ffplayout::player::{controller::ChannelManager, input::playlist::gen_source, utils::Media};
use ffplayout::utils::config::OutputMode::Null;
use ffplayout::utils::config::{PlayoutConfig, ProcessMode::Playlist};
use ffplayout::vec_strings;
fn timed_stop(sec: u64, proc_ctl: ProcessControl) {
fn get_pool() -> Pool<Sqlite> {
Runtime::new().unwrap().block_on(db_pool()).unwrap()
}
fn timed_stop(sec: u64, manager: ChannelManager) {
sleep(Duration::from_secs(sec));
info!("Timed stop of process");
println!("Timed stop of process");
proc_ctl.stop_all();
manager.stop_all();
}
#[test]
#[ignore]
fn test_gen_source() {
let mut config = PlayoutConfig::new(None, None);
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
config.general.skip_validation = true;
config.mail.recipient = "".into();
config.processing.mode = Playlist;
@ -32,49 +51,23 @@ fn test_gen_source() {
config.playlist.length_sec = Some(86400.0);
config.playlist.path = "assets/playlists".into();
config.storage.filler = "assets/media_filler/filler_0.mp4".into();
config.logging.log_to_file = false;
config.logging.timestamp = false;
config.logging.level = LevelFilter::Trace;
let play_control = PlayerControl::new();
let playout_stat = PlayoutStatus::new();
let logging = init_logging(&config, None, None);
CombinedLogger::init(logging).unwrap_or_default();
let mut valid_source_with_probe = Media::new(0, "assets/media_mix/av_sync.mp4", true);
let valid_media = gen_source(
&config,
valid_source_with_probe.clone(),
&playout_stat,
&play_control,
100,
);
let valid_media = gen_source(&config, valid_source_with_probe.clone(), &manager, 100);
assert_eq!(valid_source_with_probe.source, valid_media.source);
let mut valid_source_without_probe = Media::new(0, "assets/media_mix/av_sync.mp4", false);
valid_source_without_probe.duration = 30.0;
valid_source_without_probe.out = 20.0;
let valid_media = gen_source(
&config,
valid_source_without_probe.clone(),
&playout_stat,
&play_control,
100,
);
let valid_media = gen_source(&config, valid_source_without_probe.clone(), &manager, 100);
assert_eq!(valid_source_without_probe.source, valid_media.source);
assert_eq!(valid_media.out, 20.0);
valid_source_with_probe.out = 0.9;
let valid_media = gen_source(
&config,
valid_source_with_probe.clone(),
&playout_stat,
&play_control,
100,
);
let valid_media = gen_source(&config, valid_source_with_probe.clone(), &manager, 100);
assert_eq!(valid_media.out, 1.2);
@ -82,13 +75,7 @@ fn test_gen_source() {
no_valid_source_with_probe.duration = 30.0;
no_valid_source_with_probe.out = 30.0;
let valid_media = gen_source(
&config,
no_valid_source_with_probe.clone(),
&playout_stat,
&play_control,
100,
);
let valid_media = gen_source(&config, no_valid_source_with_probe.clone(), &manager, 100);
assert_eq!(valid_media.source, "assets/media_filler/filler_0.mp4");
}
@ -97,7 +84,17 @@ fn test_gen_source() {
#[serial]
#[ignore]
fn playlist_missing() {
let mut config = PlayoutConfig::new(None, None);
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
let manager_clone = manager.clone();
config.general.skip_validation = true;
config.mail.recipient = "".into();
config.processing.mode = Playlist;
@ -109,29 +106,18 @@ fn playlist_missing() {
config.playlist.length_sec = Some(86400.0);
config.playlist.path = "assets/playlists".into();
config.storage.filler = "assets/media_filler/filler_0.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();
config.output.mode = Null;
config.output.output_count = 1;
config.output.output_filter = None;
config.output.output_cmd = Some(vec_strings!["-f", "null", "-"]);
mock_time::set_mock_time("2023-02-07T23:59:45");
thread::spawn(move || timed_stop(28, proc_ctl));
thread::spawn(move || timed_stop(28, manager_clone));
player(&config, &play_control, playout_stat.clone(), proc_control);
player(manager.clone()).unwrap();
let playlist_date = &*playout_stat.current_date.lock().unwrap();
let playlist_date = &*manager.current_date.lock().unwrap();
assert_eq!(playlist_date, "2023-02-08");
}
@ -140,7 +126,17 @@ fn playlist_missing() {
#[serial]
#[ignore]
fn playlist_next_missing() {
let mut config = PlayoutConfig::new(None, None);
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
let manager_clone = manager.clone();
config.general.skip_validation = true;
config.mail.recipient = "".into();
config.processing.mode = Playlist;
@ -152,29 +148,18 @@ fn playlist_next_missing() {
config.playlist.length_sec = Some(86400.0);
config.playlist.path = "assets/playlists".into();
config.storage.filler = "assets/media_filler/filler_0.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();
config.output.mode = Null;
config.output.output_count = 1;
config.output.output_filter = None;
config.output.output_cmd = Some(vec_strings!["-f", "null", "-"]);
mock_time::set_mock_time("2023-02-09T23:59:45");
thread::spawn(move || timed_stop(28, proc_ctl));
thread::spawn(move || timed_stop(28, manager_clone));
player(&config, &play_control, playout_stat.clone(), proc_control);
player(manager.clone()).unwrap();
let playlist_date = &*playout_stat.current_date.lock().unwrap();
let playlist_date = &*manager.current_date.lock().unwrap();
assert_eq!(playlist_date, "2023-02-10");
}
@ -183,7 +168,17 @@ fn playlist_next_missing() {
#[serial]
#[ignore]
fn playlist_to_short() {
let mut config = PlayoutConfig::new(None, None);
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
let manager_clone = manager.clone();
config.general.skip_validation = true;
config.mail.recipient = "".into();
config.processing.mode = Playlist;
@ -195,29 +190,18 @@ fn playlist_to_short() {
config.playlist.length_sec = Some(86400.0);
config.playlist.path = "assets/playlists".into();
config.storage.filler = "assets/media_filler/filler_0.mp4".into();
config.logging.log_to_file = false;
config.logging.timestamp = false;
config.logging.level = log::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();
config.output.mode = Null;
config.output.output_count = 1;
config.output.output_filter = None;
config.output.output_cmd = Some(vec_strings!["-f", "null", "-"]);
mock_time::set_mock_time("2024-01-31T05:59:40");
thread::spawn(move || timed_stop(28, proc_ctl));
thread::spawn(move || timed_stop(28, manager_clone));
player(&config, &play_control, playout_stat.clone(), proc_control);
player(manager.clone()).unwrap();
let playlist_date = &*playout_stat.current_date.lock().unwrap();
let playlist_date = &*manager.current_date.lock().unwrap();
assert_eq!(playlist_date, "2024-01-31");
}
@ -226,7 +210,17 @@ fn playlist_to_short() {
#[serial]
#[ignore]
fn playlist_init_after_list_end() {
let mut config = PlayoutConfig::new(None, None);
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
let manager_clone = manager.clone();
config.general.skip_validation = true;
config.mail.recipient = "".into();
config.processing.mode = Playlist;
@ -238,29 +232,18 @@ fn playlist_init_after_list_end() {
config.playlist.length_sec = Some(86400.0);
config.playlist.path = "assets/playlists".into();
config.storage.filler = "assets/media_filler/filler_0.mp4".into();
config.logging.log_to_file = false;
config.logging.timestamp = false;
config.logging.level = log::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();
config.output.mode = Null;
config.output.output_count = 1;
config.output.output_filter = None;
config.output.output_cmd = Some(vec_strings!["-f", "null", "-"]);
mock_time::set_mock_time("2024-01-31T05:59:47");
thread::spawn(move || timed_stop(28, proc_ctl));
thread::spawn(move || timed_stop(28, manager_clone));
player(&config, &play_control, playout_stat.clone(), proc_control);
player(manager.clone()).unwrap();
let playlist_date = &*playout_stat.current_date.lock().unwrap();
let playlist_date = &*manager.current_date.lock().unwrap();
assert_eq!(playlist_date, "2024-01-31");
}
@ -269,7 +252,17 @@ fn playlist_init_after_list_end() {
#[serial]
#[ignore]
fn playlist_change_at_midnight() {
let mut config = PlayoutConfig::new(None, None);
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
let manager_clone = manager.clone();
config.general.skip_validation = true;
config.mail.recipient = "".into();
config.processing.mode = Playlist;
@ -281,29 +274,18 @@ fn playlist_change_at_midnight() {
config.playlist.length_sec = Some(86400.0);
config.playlist.path = "assets/playlists".into();
config.storage.filler = "assets/media_filler/filler_0.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();
config.output.mode = Null;
config.output.output_count = 1;
config.output.output_filter = None;
config.output.output_cmd = Some(vec_strings!["-f", "null", "-"]);
mock_time::set_mock_time("2023-02-08T23:59:45");
thread::spawn(move || timed_stop(28, proc_ctl));
thread::spawn(move || timed_stop(28, manager_clone));
player(&config, &play_control, playout_stat.clone(), proc_control);
player(manager.clone()).unwrap();
let playlist_date = &*playout_stat.current_date.lock().unwrap();
let playlist_date = &*manager.current_date.lock().unwrap();
assert_eq!(playlist_date, "2023-02-09");
}
@ -312,7 +294,17 @@ fn playlist_change_at_midnight() {
#[serial]
#[ignore]
fn playlist_change_before_midnight() {
let mut config = PlayoutConfig::new(None, None);
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
let manager_clone = manager.clone();
config.general.skip_validation = true;
config.mail.recipient = "".into();
config.processing.mode = Playlist;
@ -324,29 +316,18 @@ fn playlist_change_before_midnight() {
config.playlist.length_sec = Some(86400.0);
config.playlist.path = "assets/playlists".into();
config.storage.filler = "assets/media_filler/filler_0.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();
config.output.mode = Null;
config.output.output_count = 1;
config.output.output_filter = None;
config.output.output_cmd = Some(vec_strings!["-f", "null", "-"]);
mock_time::set_mock_time("2023-02-08T23:59:30");
thread::spawn(move || timed_stop(35, proc_ctl));
thread::spawn(move || timed_stop(35, manager_clone));
player(&config, &play_control, playout_stat.clone(), proc_control);
player(manager.clone()).unwrap();
let playlist_date = &*playout_stat.current_date.lock().unwrap();
let playlist_date = &*manager.current_date.lock().unwrap();
assert_eq!(playlist_date, "2023-02-09");
}
@ -355,7 +336,17 @@ fn playlist_change_before_midnight() {
#[serial]
#[ignore]
fn playlist_change_at_six() {
let mut config = PlayoutConfig::new(None, None);
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
let channel = Runtime::new()
.unwrap()
.block_on(handles::select_channel(&pool, &1))
.unwrap();
let manager = ChannelManager::new(Some(pool), channel, config.clone());
let manager_clone = manager.clone();
config.general.skip_validation = true;
config.mail.recipient = "".into();
config.processing.mode = Playlist;
@ -367,28 +358,18 @@ fn playlist_change_at_six() {
config.playlist.length_sec = Some(86400.0);
config.playlist.path = "assets/playlists".into();
config.storage.filler = "assets/media_filler/filler_0.mp4".into();
config.logging.log_to_file = false;
config.logging.timestamp = false;
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();
config.output.mode = Null;
config.output.output_count = 1;
config.output.output_filter = None;
config.output.output_cmd = Some(vec_strings!["-f", "null", "-"]);
mock_time::set_mock_time("2023-02-09T05:59:45");
thread::spawn(move || timed_stop(28, proc_ctl));
thread::spawn(move || timed_stop(28, manager_clone));
player(&config, &play_control, playout_stat.clone(), proc_control);
player(manager.clone()).unwrap();
let playlist_date = &*playout_stat.current_date.lock().unwrap();
let playlist_date = &*manager.current_date.lock().unwrap();
assert_eq!(playlist_date, "2023-02-09");
}

View File

@ -1,10 +1,18 @@
use std::path::PathBuf;
use sqlx::{Pool, Sqlite};
use tokio::runtime::Runtime;
#[cfg(test)]
use chrono::prelude::*;
use ffplayout::db::db_pool;
#[cfg(test)]
use ffplayout_lib::utils::*;
use ffplayout::player::utils::*;
use ffplayout::utils::config::PlayoutConfig;
use ffplayout::utils::config::ProcessMode::Playlist;
fn get_pool() -> Pool<Sqlite> {
Runtime::new().unwrap().block_on(db_pool()).unwrap()
}
#[test]
fn mock_date_time() {
@ -40,12 +48,15 @@ fn get_date_tomorrow() {
#[test]
fn test_delta() {
let mut config = PlayoutConfig::new(Some(PathBuf::from("../assets/ffplayout.toml")), None);
let pool = get_pool();
let mut config = Runtime::new()
.unwrap()
.block_on(PlayoutConfig::new(&pool, 1));
config.mail.recipient = "".into();
config.processing.mode = Playlist;
config.playlist.day_start = "00:00:00".into();
config.playlist.length = "24:00:00".into();
config.logging.log_to_file = false;
mock_time::set_mock_time("2022-05-09T23:59:59");
let (delta, _) = get_delta(&config, &86401.0);