diff --git a/.gitignore b/.gitignore index ae60ba83..d8777381 100644 --- a/.gitignore +++ b/.gitignore @@ -20,9 +20,10 @@ *.deb *.rpm ffplayout.1.gz -ffpapi.1.gz /assets/*.db* /dist/ /public/ tmp/ assets/playlist_template.json +advanced*.toml +ffplayout*.toml diff --git a/.gitmodules b/.gitmodules index 9649a03d..f45d6b2a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "ffplayout-frontend"] - path = ffplayout-frontend +[submodule "frontend"] + path = frontend url = https://github.com/ffplayout/ffplayout-frontend.git diff --git a/.vscode/settings.json b/.vscode/settings.json index ed2e7ef1..84fcfd01 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,7 +19,16 @@ }, "cSpell.words": [ "actix", + "ffpengine", + "flexi", + "lettre", + "libc", + "neli", + "paris", + "reqwest", "rsplit", + "rustls", + "sqlx", "starttls", "tokio", "uuids" diff --git a/Cargo.lock b/Cargo.lock index 0ce08b0b..699c5a1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", "bytes", "futures-core", "futures-sink", @@ -21,15 +21,15 @@ dependencies = [ [[package]] name = "actix-files" -version = "0.6.5" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0bdd6ff79de7c9a021f5d9ea79ce23e108d8bfc9b49b5b4a2cf6fad5a35212" +checksum = "0773d59061dedb49a8aed04c67291b9d8cf2fe0b60130a381aab53c6dd86e9be" dependencies = [ "actix-http", "actix-service", "actix-utils", "actix-web", - "bitflags 2.5.0", + "bitflags 2.6.0", "bytes", "derive_more", "futures-core", @@ -44,17 +44,17 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.6.0" +version = "3.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d223b13fd481fc0d1f83bb12659ae774d9e3601814c68a0bc539731698cca743" +checksum = "3ae682f693a9cd7b058f2b0b5d9a6d7728a8555779bedbbc35dd88528611d020" dependencies = [ "actix-codec", "actix-rt", "actix-service", "actix-utils", "ahash", - "base64 0.21.7", - "bitflags 2.5.0", + "base64 0.22.1", + "bitflags 2.6.0", "brotli", "bytes", "bytestring", @@ -88,14 +88,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn 2.0.63", + "syn 2.0.68", ] [[package]] name = "actix-multipart" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b960e2aea75f49c8f069108063d12a48d329fc8b60b786dfc7552a9d5918d2d" +checksum = "d974dd6c4f78d102d057c672dcf6faa618fafa9df91d44f9c466688fc1275a3a" dependencies = [ "actix-multipart-derive", "actix-utils", @@ -109,6 +109,7 @@ dependencies = [ "log", "memchr", "mime", + "rand", "serde", "serde_json", "serde_plain", @@ -126,27 +127,29 @@ dependencies = [ "parse-size", "proc-macro2", "quote", - "syn 2.0.63", + "syn 2.0.68", ] [[package]] name = "actix-router" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22475596539443685426b6bdadb926ad0ecaefdfc5fb05e5e3441f15463c511" +checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" dependencies = [ "bytestring", + "cfg-if", "http 0.2.12", "regex", + "regex-lite", "serde", "tracing", ] [[package]] name = "actix-rt" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28f32d40287d3f402ae0028a9d54bef51af15c8769492826a69d28f81893151d" +checksum = "24eda4e2a6e042aa4e55ac438a2ae052d3b5da0ecf83d7411e1a368946925208" dependencies = [ "futures-core", "tokio", @@ -154,9 +157,9 @@ dependencies = [ [[package]] name = "actix-server" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb13e7eef0423ea6eab0e59f6c72e7cb46d33691ad56a726b3cd07ddec2c2d4" +checksum = "b02303ce8d4e8be5b855af6cf3c3a08f3eff26880faad82bab679c22d3650cb5" dependencies = [ "actix-rt", "actix-service", @@ -164,7 +167,7 @@ dependencies = [ "futures-core", "futures-util", "mio", - "socket2 0.5.7", + "socket2", "tokio", "tracing", ] @@ -192,9 +195,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.5.1" +version = "4.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a6556ddebb638c2358714d853257ed226ece6023ef9364f23f0c70737ea984" +checksum = "1988c02af8d2b718c05bc4aeb6a66395b7cdf32858c2c71131e5637a8c05a9ff" dependencies = [ "actix-codec", "actix-http", @@ -221,32 +224,33 @@ dependencies = [ "once_cell", "pin-project-lite", "regex", + "regex-lite", "serde", "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.5.7", + "socket2", "time", "url", ] [[package]] name = "actix-web-codegen" -version = "4.2.2" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb1f50ebbb30eca122b188319a4398b3f7bb4a8cdf50ecfb73bfc6a3c3ce54f5" +checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" dependencies = [ "actix-router", "proc-macro2", "quote", - "syn 2.0.63", + "syn 2.0.68", ] [[package]] name = "actix-web-grants" -version = "4.1.0" +version = "4.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9793f592921b44112da3d82f7a6d5e0867cb0b1a7c11c304138bff95f92a5725" +checksum = "080195a204218d5104e1c761172db9128cbcec5b60a76d6bb6b1061be496931b" dependencies = [ "actix-web", "protect-endpoints-proc-macro", @@ -254,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", @@ -313,7 +317,7 @@ checksum = "9aa0b287c8de4a76b691f29dbb5451e8dd5b79d777eaf87350c9b0cbfdb5e968" dependencies = [ "proc-macro2", "quote", - "syn 2.0.63", + "syn 2.0.68", ] [[package]] @@ -330,9 +334,9 @@ dependencies = [ [[package]] name = "addr2line" -version = "0.21.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" dependencies = [ "gimli", ] @@ -433,9 +437,9 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64c907d4e79225ac72e2a354c9ce84d50ebb4586dee56c82b3ee73004f537f5" +checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391" dependencies = [ "windows-sys 0.52.0", ] @@ -474,166 +478,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-attributes" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" -dependencies = [ - "quote", - "syn 1.0.109", -] - -[[package]] -name = "async-channel" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" -dependencies = [ - "concurrent-queue", - "event-listener 2.5.3", - "futures-core", -] - -[[package]] -name = "async-channel" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d4d23bcc79e27423727b36823d86233aad06dfea531837b038394d11e9928" -dependencies = [ - "concurrent-queue", - "event-listener 5.3.0", - "event-listener-strategy 0.5.2", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b10202063978b3351199d68f8b22c4e47e4b1b822f8d43fd862d5ea8c006b29a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand 2.1.0", - "futures-lite 2.3.0", - "slab", -] - -[[package]] -name = "async-global-executor" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" -dependencies = [ - "async-channel 2.2.1", - "async-executor", - "async-io 2.3.2", - "async-lock 3.3.0", - "blocking", - "futures-lite 2.3.0", - "once_cell", -] - -[[package]] -name = "async-io" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" -dependencies = [ - "async-lock 2.8.0", - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-lite 1.13.0", - "log", - "parking", - "polling 2.8.0", - "rustix 0.37.27", - "slab", - "socket2 0.4.10", - "waker-fn", -] - -[[package]] -name = "async-io" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcccb0f599cfa2f8ace422d3555572f47424da5648a4382a9dd0310ff8210884" -dependencies = [ - "async-lock 3.3.0", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite 2.3.0", - "parking", - "polling 3.7.0", - "rustix 0.38.34", - "slab", - "tracing", - "windows-sys 0.52.0", -] - -[[package]] -name = "async-lock" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" -dependencies = [ - "event-listener 2.5.3", -] - -[[package]] -name = "async-lock" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d034b430882f8381900d3fe6f0aaa3ad94f2cb4ac519b429692a1bc2dda4ae7b" -dependencies = [ - "event-listener 4.0.3", - "event-listener-strategy 0.4.0", - "pin-project-lite", -] - -[[package]] -name = "async-std" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62565bb4402e926b29953c785397c6dc0391b7b446e45008b0049eb43cec6f5d" -dependencies = [ - "async-attributes", - "async-channel 1.9.0", - "async-global-executor", - "async-io 1.13.0", - "async-lock 2.8.0", - "crossbeam-utils", - "futures-channel", - "futures-core", - "futures-io", - "futures-lite 1.13.0", - "gloo-timers", - "kv-log-macro", - "log", - "memchr", - "once_cell", - "pin-project-lite", - "pin-utils", - "slab", - "wasm-bindgen-futures", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - [[package]] name = "async-trait" version = "0.1.80" @@ -642,7 +486,7 @@ checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.63", + "syn 2.0.68", ] [[package]] @@ -667,12 +511,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - [[package]] name = "autocfg" version = "1.3.0" @@ -681,9 +519,9 @@ checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] name = "backtrace" -version = "0.3.71" +version = "0.3.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" +checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" dependencies = [ "addr2line", "cc", @@ -720,9 +558,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" dependencies = [ "serde", ] @@ -745,25 +583,11 @@ dependencies = [ "generic-array", ] -[[package]] -name = "blocking" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "495f7104e962b7356f0aeb34247aca1fe7d2e783b346582db7f2904cb5717e88" -dependencies = [ - "async-channel 2.2.1", - "async-lock 3.3.0", - "async-task", - "futures-io", - "futures-lite 2.3.0", - "piper", -] - [[package]] name = "brotli" -version = "3.5.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" +checksum = "74f7971dbd9326d58187408ab83117d8ac1bb9c17b085fdacd1cf2f598719b6b" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -772,9 +596,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "2.5.1" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" +checksum = "9a45bd2e4095a8b518033b128020dd4a55aab1c0a381ba4404a472630f4bc362" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -809,9 +633,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.0.97" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "099a5357d84c4c61eb35fc8eafa9a79a902c2f76911e5747ced4e032edd8d9b4" +checksum = "ac367972e516d45567c7eafc73d24e1c193dcf200a8d94e9db7b3d38b349572d" dependencies = [ "jobserver", "libc", @@ -859,17 +683,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.4" +version = "4.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0" +checksum = "5db83dced34638ad474f39f250d7fea9598bdd239eaced1bdf45d597da0f433f" dependencies = [ "clap_builder", "clap_derive", @@ -877,33 +695,33 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.2" +version = "4.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" +checksum = "f7e204572485eb3fbf28f871612191521df159bc3e15a9f5064c66dba3a8c05f" dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim 0.11.1", + "strsim", ] [[package]] name = "clap_derive" -version = "4.5.4" +version = "4.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" +checksum = "c780290ccf4fb26629baa7a1081e68ced113f1d3ec302fa5948f1c381ebf06c6" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.63", + "syn 2.0.68", ] [[package]] name = "clap_lex" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" +checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70" [[package]] name = "colorchoice" @@ -911,15 +729,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "const-oid" version = "0.9.6" @@ -975,18 +784,18 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "crc32fast" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-channel" -version = "0.5.12" +version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3db02a9c5b5121e1e42fbdb1aeb65f5e02624cc58c43f2884c6ccac0b82f95" +checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" dependencies = [ "crossbeam-utils", ] @@ -1021,9 +830,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.19" +version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" [[package]] name = "crypto-common" @@ -1058,9 +867,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.8" +version = "0.20.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e36fcd13ed84ffdfda6f5be89b31287cbb80c439841fe69e04841435464391" +checksum = "83b2eb4d90d12bdda5ed17de686c2acb4c57914f8f921b8da7e112b5a36f3fe1" dependencies = [ "darling_core", "darling_macro", @@ -1068,27 +877,27 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.8" +version = "0.20.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c2cf1c23a687a1feeb728783b993c4e1ad83d99f351801977dd809b48d0a70f" +checksum = "622687fe0bac72a04e5599029151f5796111b90f1baaa9b544d807a5e31cd120" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", - "strsim 0.10.0", - "syn 2.0.63", + "strsim", + "syn 2.0.68", ] [[package]] name = "darling_macro" -version = "0.20.8" +version = "0.20.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f" +checksum = "733cabb43482b1a1b53eee8583c2b9e8684d592215ea83efd305dd31bc2f0178" dependencies = [ "darling_core", "quote", - "syn 2.0.63", + "syn 2.0.68", ] [[package]] @@ -1127,15 +936,15 @@ dependencies = [ [[package]] name = "derive_more" -version = "0.99.17" +version = "0.99.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" +checksum = "5f33878137e4dafd7fa914ad4e259e18a4e8e532b9617a2d0150262bf53abfce" dependencies = [ "convert_case", "proc-macro2", "quote", "rustc_version", - "syn 1.0.109", + "syn 2.0.68", ] [[package]] @@ -1158,9 +967,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "either" -version = "1.11.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47c1c47d2f5964e29c61246e81db715514cd532db6b5116a25ea3c03d6780a2" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" dependencies = [ "serde", ] @@ -1196,6 +1005,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "erased-serde" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24e2389d65ab4fab27dc2a5de7b191e1f6617d1f1c8855c0dc569c94a4cbb18d" +dependencies = [ + "serde", + "typeid", +] + [[package]] name = "errno" version = "0.3.9" @@ -1223,48 +1042,6 @@ version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" -[[package]] -name = "event-listener" -version = "4.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b215c49b2b248c855fb73579eb1f4f26c38ffdc12973e20e07b91d78d5646e" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9944b8ca13534cdfb2800775f8dd4902ff3fc75a50101466decadfdf322a24" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958e4d70b6d5e81971bebec42271ec641e7ff4e170a6fa605f2b8a8b65cb97d3" -dependencies = [ - "event-listener 4.0.3", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" -dependencies = [ - "event-listener 5.3.0", - "pin-project-lite", -] - [[package]] name = "faccess" version = "0.2.4" @@ -1276,15 +1053,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "fastrand" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" -dependencies = [ - "instant", -] - [[package]] name = "fastrand" version = "2.1.0" @@ -1293,29 +1061,7 @@ checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" [[package]] name = "ffplayout" -version = "0.23.1" -dependencies = [ - "chrono", - "clap", - "crossbeam-channel", - "ffplayout-lib", - "futures", - "itertools", - "notify", - "notify-debouncer-full", - "rand", - "regex", - "reqwest", - "serde", - "serde_json", - "simplelog", - "tiny_http", - "zeromq", -] - -[[package]] -name = "ffplayout-api" -version = "0.23.1" +version = "0.24.0-alpha1" dependencies = [ "actix-files", "actix-multipart", @@ -1327,16 +1073,24 @@ dependencies = [ "argon2", "chrono", "clap", + "crossbeam-channel", "derive_more", "faccess", - "ffplayout-lib", + "ffprobe", + "flexi_logger", "futures-util", "home", "jsonwebtoken", "lazy_static", + "lettre", "lexical-sort", "local-ip-address", + "log", + "notify", + "notify-debouncer-full", + "num-traits", "once_cell", + "paris", "parking_lot", "path-clean", "rand", @@ -1347,44 +1101,19 @@ dependencies = [ "sanitize-filename", "serde", "serde_json", - "simplelog", + "serde_with", + "shlex", + "signal-child", "sqlx", "static-files", "sysinfo", + "time", "tokio", "tokio-stream", "toml_edit", "uuid", -] - -[[package]] -name = "ffplayout-lib" -version = "0.23.1" -dependencies = [ - "chrono", - "crossbeam-channel", - "derive_more", - "ffprobe", - "file-rotate", - "home", - "lazy_static", - "lettre", - "lexical-sort", - "log", - "num-traits", - "rand", - "regex", - "reqwest", - "serde", - "serde_json", - "serde_with", - "shlex", - "signal-child", - "simplelog", - "time", - "toml_edit", "walkdir", - "winapi", + "zeromq", ] [[package]] @@ -1408,9 +1137,9 @@ dependencies = [ [[package]] name = "file-rotate" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddf221ceec4517f3cb764dae3541b2bd87666fc8832e51322fbb97250b468c71" +checksum = "7a3ed82142801f5b1363f7d463963d114db80f467e860b1cd82228eaebc627a0" dependencies = [ "chrono", "flate2", @@ -1428,12 +1157,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "finl_unicode" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6" - [[package]] name = "flate2" version = "1.0.30" @@ -1444,6 +1167,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flexi_logger" +version = "0.28.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cca927478b3747ba47f98af6ba0ac0daea4f12d12f55e9104071b3dc00276310" +dependencies = [ + "chrono", + "glob", + "log", + "nu-ansi-term", + "regex", + "thiserror", +] + [[package]] name = "flume" version = "0.11.0" @@ -1452,7 +1189,7 @@ checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" dependencies = [ "futures-core", "futures-sink", - "spin 0.9.8", + "spin", ] [[package]] @@ -1538,34 +1275,6 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" -[[package]] -name = "futures-lite" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" -dependencies = [ - "fastrand 1.9.0", - "futures-core", - "futures-io", - "memchr", - "parking", - "pin-project-lite", - "waker-fn", -] - -[[package]] -name = "futures-lite" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5" -dependencies = [ - "fastrand 2.1.0", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - [[package]] name = "futures-macro" version = "0.3.30" @@ -1574,7 +1283,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.63", + "syn 2.0.68", ] [[package]] @@ -1632,9 +1341,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.28.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" [[package]] name = "glob" @@ -1642,18 +1351,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" -[[package]] -name = "gloo-timers" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - [[package]] name = "h2" version = "0.3.26" @@ -1786,12 +1483,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", @@ -1805,9 +1502,9 @@ checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" [[package]] name = "httparse" -version = "1.8.0" +version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" +checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" [[package]] name = "httpdate" @@ -1836,26 +1533,27 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.26.0" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" +checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155" dependencies = [ "futures-util", "http 1.1.0", "hyper", "hyper-util", - "rustls 0.22.4", + "rustls", "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] name = "hyper-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa" +checksum = "7b875924a60b96e5d7b9ae7b066540b1dd1cbd90d1828f54c92e02a283351c56" dependencies = [ "bytes", "futures-channel", @@ -1864,7 +1562,7 @@ dependencies = [ "http-body", "hyper", "pin-project-lite", - "socket2 0.5.7", + "socket2", "tokio", "tower", "tower-service", @@ -1958,26 +1656,6 @@ dependencies = [ "libc", ] -[[package]] -name = "instant" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "io-lifetimes" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.48.0", -] - [[package]] name = "ipnet" version = "2.9.0" @@ -2058,15 +1736,6 @@ dependencies = [ "libc", ] -[[package]] -name = "kv-log-macro" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" -dependencies = [ - "log", -] - [[package]] name = "language-tags" version = "0.3.2" @@ -2075,11 +1744,11 @@ checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" [[package]] name = "lazy_static" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.5.2", + "spin", ] [[package]] @@ -2088,21 +1757,25 @@ version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a62049a808f1c4e2356a2a380bd5f2aca3b011b0b482cf3b914ba1731426969" dependencies = [ + "async-trait", "base64 0.22.1", "chumsky", "email-encoding", "email_address", - "fastrand 2.1.0", + "fastrand", + "futures-io", + "futures-util", "httpdate", "idna", "mime", "nom", "percent-encoding", "quoted_printable", - "rustls 0.23.5", + "rustls", "rustls-pemfile", - "socket2 0.5.7", + "socket2", "tokio", + "tokio-rustls", "url", "webpki-roots", ] @@ -2118,9 +1791,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.154" +version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" [[package]] name = "libm" @@ -2141,15 +1814,9 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.3.8" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" - -[[package]] -name = "linux-raw-sys" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "local-channel" @@ -2192,10 +1859,13 @@ dependencies = [ [[package]] name = "log" -version = "0.4.21" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" dependencies = [ + "serde", + "sval", + "sval_ref", "value-bag", ] @@ -2217,9 +1887,9 @@ checksum = "8878cd8d1b3c8c8ae4b2ba0a36652b7cf192f618a599a7fbdfa25cffd4ea72dd" [[package]] name = "memchr" -version = "2.7.2" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "mime" @@ -2245,9 +1915,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.7.2" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" +checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" dependencies = [ "adler", ] @@ -2305,7 +1975,7 @@ version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", "crossbeam-channel", "filetime", "fsevent-sys", @@ -2341,10 +2011,19 @@ dependencies = [ ] [[package]] -name = "num-bigint" -version = "0.4.5" +name = "nu-ansi-term" +version = "0.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c165a9ab64cf766f73521c0dd2cfdff64f488b8f0b3e621face3462d3db536d7" +checksum = "dd2800e1520bdc966782168a627aa5d1ad92e33b984bf7c7615d31280c83ff14" +dependencies = [ + "windows-sys 0.48.0", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", @@ -2413,20 +2092,11 @@ dependencies = [ "libc", ] -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - [[package]] name = "object" -version = "0.32.2" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +checksum = "576dfe1fc8f9df304abb159d767a29d0476f7750fbf8aa7ad07816004a207434" dependencies = [ "memchr", ] @@ -2443,17 +2113,11 @@ version = "1.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fecab3723493c7851f292cb060f3ee1c42f19b8d749345d0d7eaf3fd19aa62d" -[[package]] -name = "parking" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" - [[package]] name = "parking_lot" -version = "0.12.2" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e4af0ca4f6caed20e900d564c242b8e5d4903fdacf31d3daf527b66fe6f42fb" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" dependencies = [ "lock_api", "parking_lot_core", @@ -2467,7 +2131,7 @@ checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.1", + "redox_syscall 0.5.2", "smallvec", "windows-targets 0.52.5", ] @@ -2558,7 +2222,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.63", + "syn 2.0.68", ] [[package]] @@ -2573,17 +2237,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "piper" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668d31b1c4eba19242f2088b2bf3316b82ca31082a8335764db4e083db7485d4" -dependencies = [ - "atomic-waker", - "fastrand 2.1.0", - "futures-io", -] - [[package]] name = "pkcs1" version = "0.7.5" @@ -2611,37 +2264,6 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" -[[package]] -name = "polling" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" -dependencies = [ - "autocfg", - "bitflags 1.3.2", - "cfg-if", - "concurrent-queue", - "libc", - "log", - "pin-project-lite", - "windows-sys 0.48.0", -] - -[[package]] -name = "polling" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645493cf344456ef24219d02a768cf1fb92ddf8c92161679ae3d91b91a637be3" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 0.38.34", - "tracing", - "windows-sys 0.52.0", -] - [[package]] name = "powerfmt" version = "0.2.0" @@ -2656,23 +2278,23 @@ checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] name = "proc-macro2" -version = "1.0.82" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ad3d49ab951a01fbaafe34f2ec74122942fe18a3f9814c3268f1bb72042131b" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" dependencies = [ "unicode-ident", ] [[package]] name = "protect-endpoints-proc-macro" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55af88c3abae61b9f79abd89d823d5e03ae58d4c61f2ca56a8b2c839c933476b" +checksum = "b29997cba9d7a0f5cb3e4b520786e9246cc32f3b3eaa3e23547ad6068631b948" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.63", + "syn 2.0.68", ] [[package]] @@ -2684,6 +2306,53 @@ dependencies = [ "cc", ] +[[package]] +name = "quinn" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4ceeeeabace7857413798eb1ffa1e9c905a9946a57d81fb69b4b71c4d8eb3ad" +dependencies = [ + "bytes", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "quinn-proto" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddf517c03a109db8100448a4be38d498df8a210a99fe0e1b9eaf39e78c640efe" +dependencies = [ + "bytes", + "rand", + "ring", + "rustc-hash", + "rustls", + "slab", + "thiserror", + "tinyvec", + "tracing", +] + +[[package]] +name = "quinn-udp" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9096629c45860fc7fb143e125eb826b5e721e10be3263160c7d60ca832cf8c46" +dependencies = [ + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.36" @@ -2760,18 +2429,18 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e" +checksum = "c82cf8cff14456045f55ec4241383baeff27af886adb72ffb2162f99911de0fd" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", ] [[package]] name = "regex" -version = "1.10.4" +version = "1.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" +checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" dependencies = [ "aho-corasick", "memchr", @@ -2781,9 +2450,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.6" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" +checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" dependencies = [ "aho-corasick", "memchr", @@ -2791,10 +2460,16 @@ dependencies = [ ] [[package]] -name = "regex-syntax" -version = "0.8.3" +name = "regex-lite" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" +checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" + +[[package]] +name = "regex-syntax" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" [[package]] name = "relative-path" @@ -2804,9 +2479,9 @@ checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" [[package]] name = "reqwest" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "566cafdd92868e0939d3fb961bd0dc25fcfaaed179291093b3d43e6b3150ea10" +checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37" dependencies = [ "base64 0.22.1", "bytes", @@ -2826,7 +2501,8 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls 0.22.4", + "quinn", + "rustls", "rustls-pemfile", "rustls-pki-types", "serde", @@ -2854,7 +2530,7 @@ dependencies = [ "cfg-if", "getrandom", "libc", - "spin 0.9.8", + "spin", "untrusted", "windows-sys 0.52.0", ] @@ -2906,6 +2582,12 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc_version" version = "0.4.0" @@ -2915,52 +2597,24 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.37.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fea8ca367a3a01fe35e6943c400addf443c0f57670e6ec51196f71a4b8762dd2" -dependencies = [ - "bitflags 1.3.2", - "errno", - "io-lifetimes", - "libc", - "linux-raw-sys 0.3.8", - "windows-sys 0.48.0", -] - [[package]] name = "rustix" version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", "errno", "libc", - "linux-raw-sys 0.4.13", + "linux-raw-sys", "windows-sys 0.52.0", ] [[package]] name = "rustls" -version = "0.22.4" +version = "0.23.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" -dependencies = [ - "log", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls" -version = "0.23.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afabcee0551bd1aa3e18e5adbf2c0544722014b899adb31bd186ec638d3da97e" +checksum = "05cff451f60db80f490f3c182b77c35260baace73209e9cdbbe526bfe3a4d402" dependencies = [ "log", "once_cell", @@ -2989,9 +2643,9 @@ checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d" [[package]] name = "rustls-webpki" -version = "0.102.3" +version = "0.102.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3bce581c0dd41bce533ce695a1437fa16a7ab5ac3ccfa99fe1a620a7885eabf" +checksum = "ff448f7e92e913c4b7d4c6d8e4540a1724b319b4152b8aef6d4cf8339712b33e" dependencies = [ "ring", "rustls-pki-types", @@ -3052,22 +2706,31 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.201" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "780f1cebed1629e4753a1a38a3c72d30b97ec044f0aef68cb26650a3c5cf363c" +checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.201" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e405930b9796f1c00bee880d03fc7e0bb4b9a11afc776885ffe84320da2865" +checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.63", + "syn 2.0.68", +] + +[[package]] +name = "serde_fmt" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d4ddca14104cd60529e8c7f7ba71a2c8acd8f7f5cfcdc2faf97eeb7c3010a4" +dependencies = [ + "serde", ] [[package]] @@ -3085,9 +2748,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.117" +version = "1.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3" +checksum = "d947f6b3163d8857ea16c4fa0dd4840d52f3041039a85decd46867eb1abef2e4" dependencies = [ "itoa", "ryu", @@ -3105,9 +2768,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.5" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" +checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0" dependencies = [ "serde", ] @@ -3151,7 +2814,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.63", + "syn 2.0.68", ] [[package]] @@ -3176,7 +2839,7 @@ checksum = "82fe9db325bcef1fbcde82e078a5cc4efdf787e96b3b9cf45b50b529f2083d67" dependencies = [ "proc-macro2", "quote", - "syn 2.0.63", + "syn 2.0.68", ] [[package]] @@ -3244,18 +2907,6 @@ dependencies = [ "time", ] -[[package]] -name = "simplelog" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" -dependencies = [ - "log", - "paris", - "termcolor", - "time", -] - [[package]] name = "slab" version = "0.4.9" @@ -3271,16 +2922,6 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" -[[package]] -name = "socket2" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "socket2" version = "0.5.7" @@ -3291,12 +2932,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - [[package]] name = "spin" version = "0.9.8" @@ -3318,11 +2953,10 @@ dependencies = [ [[package]] name = "sqlformat" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce81b7bd7c4493975347ef60d8c7e8b742d4694f4c49f93e0a12ea263938176c" +checksum = "f895e3734318cc55f1fe66258926c9b910c124d47520339efecbb6c59cec7c1f" dependencies = [ - "itertools", "nom", "unicode_categories", ] @@ -3353,7 +2987,7 @@ dependencies = [ "crc", "crossbeam-queue", "either", - "event-listener 2.5.3", + "event-listener", "futures-channel", "futures-core", "futures-intrusive", @@ -3425,7 +3059,7 @@ checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" dependencies = [ "atoi", "base64 0.21.7", - "bitflags 2.5.0", + "bitflags 2.6.0", "byteorder", "bytes", "crc", @@ -3467,7 +3101,7 @@ checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" dependencies = [ "atoi", "base64 0.21.7", - "bitflags 2.5.0", + "bitflags 2.6.0", "byteorder", "crc", "dotenvy", @@ -3546,21 +3180,15 @@ dependencies = [ [[package]] name = "stringprep" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb41d74e231a107a1b4ee36bd1214b11285b77768d2e3824aedafa988fd36ee6" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" dependencies = [ - "finl_unicode", "unicode-bidi", "unicode-normalization", + "unicode-properties", ] -[[package]] -name = "strsim" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" - [[package]] name = "strsim" version = "0.11.1" @@ -3569,9 +3197,87 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subtle" -version = "2.5.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "sval" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53eb957fbc79a55306d5d25d87daf3627bc3800681491cda0709eef36c748bfe" + +[[package]] +name = "sval_buffer" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96e860aef60e9cbf37888d4953a13445abf523c534640d1f6174d310917c410d" +dependencies = [ + "sval", + "sval_ref", +] + +[[package]] +name = "sval_dynamic" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea3f2b07929a1127d204ed7cb3905049381708245727680e9139dac317ed556f" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_fmt" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e188677497de274a1367c4bda15bd2296de4070d91729aac8f0a09c1abf64d" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_json" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f456c07dae652744781f2245d5e3b78e6a9ebad70790ac11eb15dbdbce5282" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_nested" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "886feb24709f0476baaebbf9ac10671a50163caa7e439d7a7beb7f6d81d0a6fb" +dependencies = [ + "sval", + "sval_buffer", + "sval_ref", +] + +[[package]] +name = "sval_ref" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2e7fc517d778f44f8cb64140afa36010999565528d48985f55e64d45f369ce" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_serde" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79bf66549a997ff35cd2114a27ac4b0c2843280f2cfa84b240d169ecaa0add46" +dependencies = [ + "serde", + "sval", + "sval_nested", +] [[package]] name = "syn" @@ -3586,9 +3292,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.63" +version = "2.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf5be731623ca1a1fb7d8be6f261a3be6d3e2337b8a1f97be944d020c8fcb704" +checksum = "901fa70d88b9d6c98022e23b4136f9f3e54e4662c3bc1bd1d84a42a9a0f0c1e9" dependencies = [ "proc-macro2", "quote", @@ -3597,9 +3303,9 @@ dependencies = [ [[package]] name = "sync_wrapper" -version = "0.1.2" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" [[package]] name = "sysinfo" @@ -3623,28 +3329,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" dependencies = [ "cfg-if", - "fastrand 2.1.0", - "rustix 0.38.34", + "fastrand", + "rustix", "windows-sys 0.52.0", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "tests" -version = "0.23.1" +version = "0.24.0-alpha1" dependencies = [ "chrono", "crossbeam-channel", "ffplayout", - "ffplayout-lib", "ffprobe", "file-rotate", "lettre", @@ -3656,30 +3352,31 @@ dependencies = [ "serde_json", "serial_test", "shlex", - "simplelog", + "sqlx", "time", + "tokio", "toml_edit", "walkdir", ] [[package]] name = "thiserror" -version = "1.0.60" +version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "579e9083ca58dd9dcf91a9923bb9054071b9ebbd800b342194c9feb0ee89fc18" +checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.60" +version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2470041c06ec3ac1ab38d0356a6119054dedaea53e12fbefc0de730a1c08524" +checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" dependencies = [ "proc-macro2", "quote", - "syn 2.0.63", + "syn 2.0.68", ] [[package]] @@ -3690,9 +3387,7 @@ checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", "itoa", - "libc", "num-conv", - "num_threads", "powerfmt", "serde", "time-core", @@ -3715,23 +3410,11 @@ dependencies = [ "time-core", ] -[[package]] -name = "tiny_http" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" -dependencies = [ - "ascii", - "chunked_transfer", - "httpdate", - "log", -] - [[package]] name = "tinyvec" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +checksum = "c55115c6fbe2d2bef26eb09ad74bde02d8255476fc0c7b515ef09fbb35742d82" dependencies = [ "tinyvec_macros", ] @@ -3744,9 +3427,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.37.0" +version = "1.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" +checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a" dependencies = [ "backtrace", "bytes", @@ -3756,29 +3439,29 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.5.7", + "socket2", "tokio-macros", "windows-sys 0.48.0", ] [[package]] name = "tokio-macros" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.63", + "syn 2.0.68", ] [[package]] name = "tokio-rustls" -version = "0.25.0" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" dependencies = [ - "rustls 0.22.4", + "rustls", "rustls-pki-types", "tokio", ] @@ -3802,6 +3485,7 @@ checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -3809,18 +3493,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.5" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" +checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.22.12" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3328d4f68a705b2a4498da1d580585d39a6510f98318a2cec3018a7ec61ddef" +checksum = "f21c7aaf97f1bd9ca9d4f9e73b0a6c74bd5afef56f2bc931943a6e1c37e04e38" dependencies = [ "indexmap 2.2.6", "serde", @@ -3842,7 +3526,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -3877,7 +3560,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.63", + "syn 2.0.68", ] [[package]] @@ -3895,6 +3578,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typeid" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059d83cc991e7a42fc37bd50941885db0888e34209f8cfd9aab07ddec03bc9cf" + [[package]] name = "typenum" version = "1.17.0" @@ -3931,6 +3620,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4259d9d4425d9f0661581b804cb85fe66a4c631cadd8f490d1c13a35d5d9291" + [[package]] name = "unicode-segmentation" version = "1.11.0" @@ -3951,9 +3646,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.0" +version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" dependencies = [ "form_urlencoded", "idna", @@ -3968,15 +3663,15 @@ checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "utf8parse" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.8.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" +checksum = "5de17fd2f7da591098415cff336e12965a28061ddace43b59cb3c430179c9439" dependencies = [ "getrandom", ] @@ -3992,6 +3687,36 @@ name = "value-bag" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a84c137d37ab0142f0f2ddfe332651fdbf252e7b7dbb4e67b6c1f1b2e925101" +dependencies = [ + "value-bag-serde1", + "value-bag-sval2", +] + +[[package]] +name = "value-bag-serde1" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccacf50c5cb077a9abb723c5bcb5e0754c1a433f1e1de89edc328e2760b6328b" +dependencies = [ + "erased-serde", + "serde", + "serde_fmt", +] + +[[package]] +name = "value-bag-sval2" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1785bae486022dfb9703915d42287dcb284c1ee37bd1080eeba78cc04721285b" +dependencies = [ + "sval", + "sval_buffer", + "sval_dynamic", + "sval_fmt", + "sval_json", + "sval_ref", + "sval_serde", +] [[package]] name = "vcpkg" @@ -4005,12 +3730,6 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" -[[package]] -name = "waker-fn" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c4517f54858c779bbcbf228f4fca63d121bf85fbecb2dc578cdf4a39395690" - [[package]] name = "walkdir" version = "2.5.0" @@ -4063,7 +3782,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.63", + "syn 2.0.68", "wasm-bindgen-shared", ] @@ -4097,7 +3816,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.63", + "syn 2.0.68", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -4120,9 +3839,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.1" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3de34ae270483955a94f4b21bdaaeb83d508bb84a01435f393818edb0012009" +checksum = "bd7c23921eeb1713a4e851530e9b9756e4fb0e89978582942612524cf09f01cd" dependencies = [ "rustls-pki-types", ] @@ -4328,9 +4047,9 @@ checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" [[package]] name = "winnow" -version = "0.6.8" +version = "0.6.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3c52e9c97a68071b23e836c9380edae937f17b9c4667bd021973efc689f618d" +checksum = "59b5e5f6c299a3c7890b876a2a587f3115162487e704907d9b6cd29473052ba1" dependencies = [ "memchr", ] @@ -4362,22 +4081,21 @@ checksum = "15e934569e47891f7d9411f1a451d947a60e000ab3bd24fbb970f000387d1b3b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.63", + "syn 2.0.68", ] [[package]] name = "zeroize" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" [[package]] name = "zeromq" -version = "0.3.5" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ad3ffd65d6ae06a9eece312a64c3dfa2151a70a5c99051e2080828653cbda45" +checksum = "fb0560d00172817b7f7c2265060783519c475702ae290b154115ca75e976d4d0" dependencies = [ - "async-std", "async-trait", "asynchronous-codec", "bytes", @@ -4394,6 +4112,8 @@ dependencies = [ "rand", "regex", "thiserror", + "tokio", + "tokio-util", "uuid", ] @@ -4417,9 +4137,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.0.10+zstd.1.5.6" +version = "2.0.11+zstd.1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c253a4914af5bafc8fa8c86ee400827e83cf6ec01195ec1f1ed8441bf00d65aa" +checksum = "75652c55c0b6f3e6f12eb786fe1bc960396bf05a1eb3bf1f3691c3610ac2e6d4" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 32e7864e..6c9a5c1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [workspace] -members = ["ffplayout-api", "ffplayout-engine", "lib", "tests"] -default-members = ["ffplayout-api", "ffplayout-engine", "tests"] +members = ["ffplayout", "tests"] +default-members = ["ffplayout", "tests"] resolver = "2" [workspace.package] -version = "0.23.1" +version = "0.24.0-alpha1" license = "GPL-3.0" repository = "https://github.com/ffplayout/ffplayout" authors = ["Jonathan Baecker "] diff --git a/README.md b/README.md index f2eeb1a9..552b6d2a 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,13 @@ Check the [releases](https://github.com/ffplayout/ffplayout/releases/latest) for ### Features -- have all values in a separate config file +- start program with [web based frontend](https://github.com/ffplayout/ffplayout-frontend), or run playout in foreground mode without frontend - dynamic playlist - replace missing playlist or clip with single filler or multiple fillers from folder, if no filler exists, create dummy clip - playing clips in [watched](/docs/folder_mode.md) folder mode - send emails with error message - overlay a logo -- overlay text, controllable through [ffplayout-frontend](https://github.com/ffplayout/ffplayout-frontend) (needs ffmpeg with libzmq and enabled JSON RPC server) +- overlay text, controllable through [web frontend](https://github.com/ffplayout/ffplayout-frontend) (needs ffmpeg with libzmq and enabled JSON RPC server) - loop playlist infinitely - [remote source](/docs/remote_source.md) - trim and fade the last clip, to get full 24 hours @@ -42,7 +42,6 @@ Check the [releases](https://github.com/ffplayout/ffplayout/releases/latest) for - **desktop** - **HLS** - **null** (for debugging) -- JSON RPC server, to get information about what is playing and to control it - [live ingest](/docs/live_ingest.md) - image source (will loop until out duration is reached) - extra audio source, has priority over audio from video (experimental *) @@ -51,23 +50,19 @@ Check the [releases](https://github.com/ffplayout/ffplayout/releases/latest) for - [custom filters](/docs/custom_filters.md) globally in config, or in playlist for specific clips - import playlist from text or m3u file, with CLI or frontend - audio only, for radio mode (experimental *) -- [Piggyback Mode](/ffplayout-api/README.md#piggyback-mode), mostly for non Linux systems (experimental *) - generate playlist based on [template](/docs/playlist_gen.md) (experimental *) - During playlist import, all video clips are validated and, if desired, checked to ensure that the audio track is not completely muted. +- run multiple channels (experimental *) For preview stream, read: [/docs/preview_stream.md](/docs/preview_stream.md) **\* Experimental features do not guarantee the same stability and may fail under unusual circumstances. Code and configuration options may change in the future.** -## **ffplayout-api (ffpapi)** - -ffpapi serves the [frontend](https://github.com/ffplayout/ffplayout-frontend) and it acts as a [REST API](/ffplayout-api/README.md) for controlling the engine, manipulate playlists, add settings etc. - ### Requirements -- RAM and CPU depends on video resolution, minimum 4 threads and 3GB RAM for 720p are recommend +- RAM and CPU depends on video resolution, minimum 4 _dedicated_ threads and 3GB RAM for 720p are recommend - **ffmpeg** v5.0+ and **ffprobe** (**ffplay** if you want to play on desktop) -- if you want to overlay text, ffmpeg needs to have **libzmq** +- if you want to overlay dynamic text, ffmpeg needs to have **libzmq** ### Install @@ -119,6 +114,7 @@ Check [install](docs/install.md) for details about how to install ffplayout. ] } ``` +If you are in playlist mode and move backwards or forwards in time, the time shift is saved so the playlist is still in sync. Bear in mind, however, that this may make your playlist too short. If you do not reset it, it will automatically reset the next day. ## **Warning** @@ -126,72 +122,6 @@ Check [install](docs/install.md) for details about how to install ffplayout. ----- -## HLS output - -For outputting to HLS, output parameters should look like: - -```yaml -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-%09d.ts /var/www/html/live/stream.m3u8 -``` - ------ - -## JSON RPC - -The ffplayout engine can run a simple RPC server. A request looks like: - -```Bash -curl -X POST -H "Content-Type: application/json" -H "Authorization: ---auth-key---" \ - -d '{"control":"next"}' \ - 127.0.0.1:7070 -``` - -At the moment this commends are possible: - -```Bash -'{"media":"current"}' # get infos about current clip -'{"media":"next"}' # get infos about next clip -'{"media":"last"}' # get infos about last clip -'{"control":"next"}' # jump to next clip -'{"control":"back"}' # jump to last clip -'{"control":"reset"}' # reset playlist to old state -'{"control":"text", \ - "message": {"text": "Hello from ffplayout", "x": "(w-text_w)/2", "y": "(h-text_h)/2", \ - "fontsize": 24, "line_spacing": 4, "fontcolor": "#ffffff", "box": 1, \ - "boxcolor": "#000000", "boxborderw": 4, "alpha": 1.0}}' # send text to drawtext filter from ffmpeg -``` - -Output from `{"media":"current"}` show: - -```JSON -{ - "media": { - "category": "", - "duration": 154.2, - "out": 154.2, - "in": 0.0, - "source": "/opt/tv-media/clip.mp4" - }, - "index": 39, - "mode": "playlist", - "ingest": false, - "played": 67.80771999300123, -} -``` - -If you are in playlist mode and move backwards or forwards in time, the time shift is saved so the playlist is still in sync. Bear in mind, however, that this may make your playlist too short. If you do not reset it, it will automatically reset the next day. - ## Founding If you like this project and would like to make a donation, please use one of the options provided. diff --git a/assets/11-ffplayout b/assets/11-ffplayout deleted file mode 100644 index 440b44f9..00000000 --- a/assets/11-ffplayout +++ /dev/null @@ -1,5 +0,0 @@ -# give user ffpu permission to control the ffplayout systemd service - -ffpu ALL = NOPASSWD: /usr/bin/systemctl start ffplayout.service, /usr/bin/systemctl stop ffplayout.service, /usr/bin/systemctl restart ffplayout.service, /usr/bin/systemctl status ffplayout.service, /usr/bin/systemctl is-active ffplayout.service, /usr/bin/systemctl enable ffplayout.service, /usr/bin/systemctl disable ffplayout.service - -ffpu ALL = NOPASSWD: /usr/bin/systemctl start ffplayout@*, /usr/bin/systemctl stop ffplayout@*, /usr/bin/systemctl restart ffplayout@*, /usr/bin/systemctl status ffplayout@*, /usr/bin/systemctl is-active ffplayout@*, /usr/bin/systemctl enable ffplayout@*, /usr/bin/systemctl disable ffplayout@* diff --git a/assets/advanced.toml b/assets/advanced.toml deleted file mode 100644 index 57c5e661..00000000 --- a/assets/advanced.toml +++ /dev/null @@ -1,37 +0,0 @@ -# Changing these settings is for advanced users only! -# There will be no support or guarantee that it will be stable after changing them. - -[decoder] -input_param = "" -# output_param get also applied to ingest instance. -output_param = "" - -[encoder] -input_param = "" - -[filters] -deinterlace = "" # yadif=0:-1:0 -pad_scale_w = "" # scale={}:-1 -pad_scale_h = "" # scale=-1:{} -pad_video = "" # pad=max(iw\\,ih*({0}/{1})):ow/({0}/{1}):(ow-iw)/2:(oh-ih)/2 -fps = "" # fps={} -scale = "" # scale={}:{} -set_dar = "" # setdar=dar={} -fade_in = "" # fade=in:st=0:d=0.5 -fade_out = "" # fade=out:st={}:d=1.0 -overlay_logo_scale = "" # scale={} -overlay_logo_fade_in = "" # fade=in:st=0:d=1.0:alpha=1 -overlay_logo_fade_out = "" # fade=out:st={}:d=1.0:alpha=1 -overlay_logo = "" # null[l];[v][l]overlay={}:shortest=1 -tpad = "" # tpad=stop_mode=add:stop_duration={} -drawtext_from_file = "" # drawtext=text='{}':{}{} -drawtext_from_zmq = "" # zmq=b=tcp\\\\://'{}',drawtext@dyntext={} -aevalsrc = "" # aevalsrc=0:channel_layout=stereo:duration={}:sample_rate=48000 -afade_in = "" # afade=in:st=0:d=0.5 -afade_out = "" # afade=out:st={}:d=1.0 -apad = "" # apad=whole_dur={} -volume = "" # volume={} -split = "" # split={}{} - -[ingest] -input_param = "" diff --git a/assets/ffpapi.service b/assets/ffpapi.service deleted file mode 100644 index ddf8218c..00000000 --- a/assets/ffpapi.service +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -Description=Rest API for ffplayout -After=network.target remote-fs.target - -[Service] -ExecStart=/usr/bin/ffpapi -l 0.0.0.0:8787 -Restart=always -RestartSec=1 -User=ffpu - -[Install] -WantedBy=multi-user.target diff --git a/assets/ffplayout.service b/assets/ffplayout.service index 55ca4aaf..6a9d2fe0 100644 --- a/assets/ffplayout.service +++ b/assets/ffplayout.service @@ -3,7 +3,7 @@ Description=Rust and ffmpeg based playout solution After=network.target remote-fs.target [Service] -ExecStart=/usr/bin/ffplayout +ExecStart=/usr/bin/ffplayout -l 0.0.0.0:8787 Restart=always StartLimitInterval=20 RestartSec=1 diff --git a/assets/ffplayout.toml b/assets/ffplayout.toml deleted file mode 100644 index c7ee82ea..00000000 --- a/assets/ffplayout.toml +++ /dev/null @@ -1,168 +0,0 @@ -[general] -help_text = """Sometimes it can happen, that a file is corrupt but still playable, \ - this can produce an streaming error over all following files. The only way \ - in this case is, to stop ffplayout and start it again. Here we only say when \ - it stops, the starting process is in your hand. Best way is a systemd service \ - on linux. - 'stop_threshold' stop ffplayout, if it is async in time above this \ - value. A number below 3 can cause unexpected errors.""" -stop_threshold = 11 -stat_file = ".ffp_status" - -[rpc_server] -help_text = """Run a JSON RPC server, for getting infos about current playing and for some \ - control functions.""" -enable = true -address = "127.0.0.1:7070" -authorization = "av2Kx8g67lF9qj5wEH3ym1bI4cCs" - -[mail] -help_text = """Send error messages to email address, like missing playlist; invalid \ - json format; missing clip path. Leave recipient blank, if you don't need this. - 'mail_level' can be INFO, WARNING or ERROR. - 'interval' means seconds until a new mail will be sended.""" -subject = "Playout Error" -smtp_server = "mail.example.org" -starttls = true -sender_addr = "ffplayout@example.org" -sender_pass = "abc123" -recipient = "" -mail_level = "ERROR" -interval = 30 - -[logging] -help_text = """If 'log_to_file' is true, log to file, when is false log to console. - 'backup_count' says how long log files will be saved in days. - 'local_time' to false will set log timestamps to UTC. Path to /var/log/ only \ - if you run this program as daemon. - 'level' can be DEBUG, INFO, WARNING, ERROR. - 'ffmpeg_level/ingest_level' can be INFO, WARNING, ERROR. - 'detect_silence' logs an error message if the audio line is silent for 15 \ - seconds during the validation process. - 'ignore_lines' makes logging to ignore strings that contains matched lines, \ - in frontend is a semicolon separated list.""" -log_to_file = true -backup_count = 7 -local_time = true -timestamp = true -path = "/var/log/ffplayout/" -level = "DEBUG" -ffmpeg_level = "ERROR" -ingest_level = "WARNING" -detect_silence = false -ignore_lines = [ - "P sub_mb_type 4 out of range at", - "error while decoding MB", - "negative number of zero coeffs at", - "out of range intra chroma pred mode", - "non-existing SPS 0 referenced in buffering period", -] - -[processing] -help_text = """Default processing for all clips, to have them unique. Mode can be playlist \ - or folder. - 'aspect' must be a float number.'logo' is only used if the path exist. - 'logo_scale' scale the logo to target size, leave it blank when no scaling \ - is needed, format is 'width:height', for example '100:-1' for proportional \ - scaling. With 'logo_opacity' logo can become transparent. - With 'audio_tracks' it is possible to configure how many audio tracks should \ - be processed. 'audio_channels' can be use, if audio has more channels then only stereo. - With 'logo_position' in format 'x:y' you set the logo position. - With 'custom_filter' it is possible, to apply further filters. The filter \ - outputs should end with [c_v_out] for video filter, and [c_a_out] for audio filter.""" -mode = "playlist" -audio_only = false -copy_audio = false -copy_video = false -width = 1024 -height = 576 -aspect = 1.778 -fps = 25 -add_logo = true -logo = "/usr/share/ffplayout/logo.png" -logo_scale = "" -logo_opacity = 0.7 -logo_position = "W-w-12:12" -audio_tracks = 1 -audio_track_index = -1 -audio_channels = 2 -volume = 1 -custom_filter = "" - -[ingest] -help_text = """Run a server for a ingest stream. This stream will override the normal streaming \ - until is done. There is only a very simple authentication mechanism, which check if the \ - stream name is correct. - 'custom_filter' can be used in the same way then the one in the process section.""" -enable = false -input_param = "-f live_flv -listen 1 -i rtmp://127.0.0.1:1936/live/stream" -custom_filter = "" - -[playlist] -help_text = """'path' can be a path to a single file, or a directory. For directory put \ - only the root folder, for example '/playlists', subdirectories are read by the \ - program. Subdirectories needs this structure '/playlists/2018/01'. - 'day_start' means at which time the playlist should start, leave day_start \ - blank when playlist should always start at the begin. 'length' represent the \ - target length from playlist, when is blank real length will not consider. - 'infinit: true' works with single playlist file and loops it infinitely. """ -path = "/var/lib/ffplayout/playlists" -day_start = "05:59:25" -length = "24:00:00" -infinit = false - -[storage] -help_text = """'filler' is for playing instead of a missing file or fill the end to reach 24 \ - hours, can be a file or folder, it will loop when is necessary. - 'extensions' search only files with this extension. Set 'shuffle' to 'true' \ - to pick files randomly.""" -path = "/var/lib/ffplayout/tv-media" -filler = "/var/lib/ffplayout/tv-media/filler/filler.mp4" -extensions = ["mp4", "mkv", "webm"] -shuffle = true - -[text] -help_text = """Overlay text in combination with libzmq for remote text manipulation. \ - On windows fontfile path need to be like this 'C\\:/WINDOWS/fonts/DejaVuSans.ttf'. - 'text_from_filename' activate the extraction from text of a filename. With 'style' \ - you can define the drawtext parameters like position, color, etc. Post Text over \ - API will override this. With 'regex' you can format file names, to get a title from it.""" -add_text = true -text_from_filename = false -fontfile = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" -style = "x=(w-tw)/2:y=(h-line_h)*0.9:fontsize=24:fontcolor=#ffffff:box=1:boxcolor=#000000:boxborderw=4" -regex = "^.+[/\\](.*)(.mp4|.mkv|.webm)$" - -[task] -help_text = """Run an external program with a given media object. The media object is in json format \ - and contains all the information about the current clip. The external program can be a script \ - or a binary, but should only run for a short time.""" -enable = false -path = "" - -[out] -help_text = """The final playout compression. Set the settings to your needs. 'mode' \ - has the options 'desktop', 'hls', 'null', 'stream'. Use 'stream' and adjust \ - 'output_param:' settings when you want to stream to a rtmp/rtsp/srt/... server. - In production don't serve hls playlist with ffpapi, use nginx or another web server!""" -mode = "hls" -output_param = """\ - -c:v libx264 - -crf 23 - -x264-params keyint=50:min-keyint=25:scenecut=-1 - -maxrate 1300k - -bufsize 2600k - -preset faster - -tune zerolatency - -profile:v Main - -level 3.1 - -c:a aac - -ar 44100 - -b:a 128k - -flags +cgop - -f hls - -hls_time 6 - -hls_list_size 600 - -hls_flags append_list+delete_segments+omit_endlist - -hls_segment_filename /usr/share/ffplayout/public/live/stream-%d.ts - /usr/share/ffplayout/public/live/stream.m3u8""" diff --git a/assets/ffplayout@.service b/assets/ffplayout@.service deleted file mode 100644 index 594f699c..00000000 --- a/assets/ffplayout@.service +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=Rust and ffmpeg based multi channel playout solution -After=network.target remote-fs.target - -[Service] -ExecStart=/usr/bin/ffplayout %I -Restart=always -StartLimitInterval=20 -RestartSec=1 -KillMode=mixed -User=ffpu - -[Install] -WantedBy=multi-user.target diff --git a/debian/postinst b/debian/postinst index b93fb820..9fc8005e 100644 --- a/debian/postinst +++ b/debian/postinst @@ -19,11 +19,10 @@ if [ ! -d "/usr/share/ffplayout/db" ]; then IP=$(hostname -I | cut -d ' ' -f1) - /usr/bin/ffpapi -i -d "${IP}:8787" + /usr/bin/ffplayout -i -d "${IP}:8787" chown -R ${sysUser}: "/usr/share/ffplayout" chown -R ${sysUser}: "/var/lib/ffplayout" - chown -R ${sysUser}: "/etc/ffplayout" fi if [ ! -d "/var/log/ffplayout" ]; then diff --git a/debian/postrm b/debian/postrm index f5053657..0a7655f1 100644 --- a/debian/postrm +++ b/debian/postrm @@ -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) diff --git a/docker/Dockerfile b/docker/Dockerfile index fdc9191e..96e9d4a2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -29,12 +29,9 @@ RUN dnf update -y && \ RUN [[ -f /tmp/ffplayout-${FFPLAYOUT_VERSION}-1.x86_64.rpm ]] || wget -q "https://github.com/ffplayout/ffplayout/releases/download/v${FFPLAYOUT_VERSION}/ffplayout-${FFPLAYOUT_VERSION}-1.x86_64.rpm" -P /tmp/ && \ dnf install -y /tmp/ffplayout-${FFPLAYOUT_VERSION}-1.x86_64.rpm && \ rm /tmp/ffplayout-${FFPLAYOUT_VERSION}-1.x86_64.rpm && \ - sed -i "s/User=ffpu/User=root/g" /usr/lib/systemd/system/ffpapi.service && \ sed -i "s/User=ffpu/User=root/g" /usr/lib/systemd/system/ffplayout.service && \ - sed -i "s/User=ffpu/User=root/g" /usr/lib/systemd/system/ffplayout@.service && \ systemctl enable ffplayout && \ - systemctl enable ffpapi && \ - ffpapi -u admin -p admin -m contact@example.com + ffplayout -u admin -p admin -m contact@example.com EXPOSE 8787 diff --git a/docker/README.md b/docker/README.md index 90be9e3f..cdb45f8e 100644 --- a/docker/README.md +++ b/docker/README.md @@ -26,7 +26,7 @@ You can take a look at the [Dockerfile](Dockerfile) ## Storage There are some folders/files that are important for ffplayout to work well such as: - - **/usr/share/ffplayout/db** => where all the data for the `ffpapi` are stored (user/pass etc) + - **/usr/share/ffplayout/db** => where all the data are stored (user/pass etc) - **/var/lib/ffplayout/tv-media** => where the media are stored by default (configurable) - **/var/lib/ffplayout/playlists** => where playlists are stored (configurable) - **/etc/ffplayout/ffplayout.yml** => the core config file diff --git a/docker/fromSource.Dockerfile b/docker/fromSource.Dockerfile index 7a6a1ef2..59a27ea7 100644 --- a/docker/fromSource.Dockerfile +++ b/docker/fromSource.Dockerfile @@ -139,7 +139,6 @@ ENV LD_LIBRARY_PATH=/usr/local/lib64:/usr/local/lib COPY --from=build /usr/local/ /usr/local/ ADD ./overide.conf /etc/systemd/system/ffplayout.service.d/overide.conf -ADD ./overide.conf /etc/systemd/system/ffpapi.service.d/overide.conf RUN \ dnf update -y \ @@ -155,8 +154,7 @@ RUN \ rm /tmp/ffplayout-${FFPLAYOUT_VERSION}-1.x86_64.rpm && \ mkdir -p /home/ffpu && chown -R ffpu: /home/ffpu && \ systemctl enable ffplayout && \ - systemctl enable ffpapi && \ - ffpapi -u admin -p admin -m contact@example.com + ffplayout -u admin -p admin -m contact@example.com EXPOSE 8787 diff --git a/docker/nvidia-centos7.Dockerfile b/docker/nvidia-centos7.Dockerfile index 02a36bcc..f1fd376b 100644 --- a/docker/nvidia-centos7.Dockerfile +++ b/docker/nvidia-centos7.Dockerfile @@ -102,8 +102,7 @@ RUN yum update -y \ && echo 'Docker!' | passwd --stdin root \ && rm /tmp/ffplayout-${FFPLAYOUT_VERSION}-1.x86_64.rpm \ && mkdir -p /home/ffpu && chown -R ffpu: /home/ffpu \ - && systemctl enable ffplayout \ - && systemctl enable ffpapi + && systemctl enable ffplayout EXPOSE 8787 RUN echo "/usr/local/lib" >> /etc/ld.so.conf.d/nvidia.conf diff --git a/docs/advanced_settings.md b/docs/advanced_settings.md index 5aca7a0f..94641836 100644 --- a/docs/advanced_settings.md +++ b/docs/advanced_settings.md @@ -1,6 +1,6 @@ ## Advanced settings -Within **/etc/ffplayout/advanced.yml** you can control all ffmpeg inputs/decoder output and filters. +With **advanced settings** you can control all ffmpeg inputs/decoder output and filters. > **_Note:_** Changing these settings is for advanced users only! There will be no support or guarantee that it will work and be stable after changing them! diff --git a/docs/api.md b/docs/api.md index 531403cc..7cdd0f01 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,9 +1,9 @@ -## Possible endpoints +### Possible endpoints Run the API thru the systemd service, or like: ```BASH -ffpapi -l 127.0.0.1:8787 +ffplayout -l 127.0.0.1:8787 ``` For all endpoints an (Bearer) authentication is required.\ @@ -72,7 +72,7 @@ curl -X GET 'http://127.0.0.1:8787/api/user/2' -H 'Content-Type: application/jso -H 'Authorization: Bearer ' ``` -#### ffpapi Settings +#### Settings **Get Settings from Channel** @@ -87,9 +87,7 @@ curl -X GET http://127.0.0.1:8787/api/channel/1 -H "Authorization: Bearer ```BASH curl -X PATCH http://127.0.0.1:8787/api/channel/1 -H "Content-Type: application/json" \ --d '{ "id": 1, "name": "Channel 1", "preview_url": "http://localhost/live/stream.m3u8", "config_path": "/etc/ffplayout/ffplayout.yml", "extra_extensions": "jpg,jpeg,png"}' \ +-d '{ "id": 1, "name": "Channel 1", "preview_url": "http://localhost/live/stream.m3u8", "extra_extensions": "jpg,jpeg,png"}' \ -H "Authorization: Bearer " ``` @@ -112,7 +110,7 @@ curl -X PATCH http://127.0.0.1:8787/api/channel/1 -H "Content-Type: application/ ```BASH curl -X POST http://127.0.0.1:8787/api/channel/ -H "Content-Type: application/json" \ --d '{ "name": "Channel 2", "preview_url": "http://localhost/live/channel2.m3u8", "config_path": "/etc/ffplayout/channel2.yml", "extra_extensions": "jpg,jpeg,png", "service": "ffplayout@channel2.service" }' \ +-d '{ "name": "Channel 2", "preview_url": "http://localhost/live/channel2.m3u8", "extra_extensions": "jpg,jpeg,png" }' \ -H "Authorization: Bearer " ``` @@ -124,13 +122,28 @@ curl -X DELETE http://127.0.0.1:8787/api/channel/2 -H "Authorization: Bearer ' +``` + +Response is a JSON object + +**Update Advanced Config** + +```BASH +curl -X PUT http://127.0.0.1:8787/api/playout/advanced/1 -H "Content-Type: application/json" \ +-d { } -H 'Authorization: Bearer ' +``` + **Get Config** ```BASH curl -X GET http://127.0.0.1:8787/api/playout/config/1 -H 'Authorization: Bearer ' ``` -Response is a JSON object from the ffplayout.yml +Response is a JSON object **Update Config** @@ -161,7 +174,7 @@ curl -X PUT http://127.0.0.1:8787/api/presets/1 -H 'Content-Type: application/js **Add new Preset** ```BASH -curl -X POST http://127.0.0.1:8787/api/presets/ -H 'Content-Type: application/json' \ +curl -X POST http://127.0.0.1:8787/api/presets/1/ -H 'Content-Type: application/json' \ -d '{ "name": "", "text": "TEXT>", "x": "", "y": "", "fontsize": 24, "line_spacing": 4, "fontcolor": "#ffffff", "box": 1, "boxcolor": "#000000", "boxborderw": 4, "alpha": 1.0, "channel_id": 1 }' \ -H 'Authorization: Bearer ' ``` @@ -210,38 +223,19 @@ curl -X GET http://127.0.0.1:8787/api/control/1/media/current **Response:** ```JSON -{ - "jsonrpc": "2.0", - "result": { - "current_media": { + { + "media": { "category": "", "duration": 154.2, "out": 154.2, - "seek": 0.0, + "in": 0.0, "source": "/opt/tv-media/clip.mp4" }, "index": 39, - "play_mode": "playlist", - "played_sec": 67.80771999300123, - "remaining_sec": 86.39228000699876, - "start_sec": 24713.631999999998, - "start_time": "06:51:53.631" - }, - "id": 1 -} -``` - -**Get next Clip** - -```BASH -curl -X GET http://127.0.0.1:8787/api/control/1/media/next -H 'Authorization: Bearer ' -``` - -**Get last Clip** - -```BASH -curl -X GET http://127.0.0.1:8787/api/control/1/media/last --H 'Content-Type: application/json' -H 'Authorization: Bearer ' + "ingest": false, + "mode": "playlist", + "played": 67.808 + } ``` #### ffplayout Process Control @@ -303,10 +297,10 @@ curl -X DELETE http://127.0.0.1:8787/api/playlist/1/2022-06-20 ### Log file -**Read Log Life** +**Read Log File** ```BASH -curl -X GET http://127.0.0.1:8787/api/log/1 +curl -X GET http://127.0.0.1:8787/api/log/1?date=2022-06-20 -H 'Content-Type: application/json' -H 'Authorization: Bearer ' ``` diff --git a/docs/install.md b/docs/install.md index 1193472d..d28a5296 100644 --- a/docs/install.md +++ b/docs/install.md @@ -7,32 +7,26 @@ ffplayout provides ***.deb** and ***.rpm** packages, which makes it more easy to 3. install ffmpeg/ffprobe, or compile and copy it to **/usr/local/bin/** 4. activate systemd services: - `systemctl enable ffplayout` - - `systemctl enable --now ffpapi` -5. add admin user to ffpapi: - - `ffpapi -a` +5. initial defaults and add global admin user: + - `sudo -u ffpu ffplayout -i` 6. use a revers proxy for SSL, Port is **8787**. 7. login with your browser, address without proxy would be: **http://[IP ADDRESS]:8787** Default location for playlists and media files are: **/var/lib/ffplayout/**. -When you don't need the frontend and API, skip enable the systemd service **ffpapi**. - When playlists are created and the ffplayout output is configured, you can start the process: `systemctl start ffplayout`, or click start in frontend. -If you want to configure ffplayout over terminal, you can edit **/etc/ffplayout/ffplayout.yml**. - ### Manual Install ----- - install ffmpeg/ffprobe, or compile and copy it to **/usr/local/bin/** - download the latest archive from [release](https://github.com/ffplayout/ffplayout/releases/latest) page -- copy the ffplayout and ffpapi binary to `/usr/bin/` +- copy the ffplayout binary to `/usr/bin/` - copy **assets/ffplayout.yml** to `/etc/ffplayout` - create folder `/var/log/ffplayout` - create system user **ffpu** - give ownership from `/etc/ffplayout` and `/var/log/ffplayout` to **ffpu** -- copy **assets/ffpapi.service**, **assets/ffplayout.service** and **assets/ffplayout@.service** to `/etc/systemd/system` -- copy **assets/11-ffplayout** to `/etc/sudoers.d/` -- copy **assets/ffpapi.1.gz** and **assets/ffplayout.1.gz** to `/usr/share/man/man1/` +- copy **assets/ffplayout.service** to `/etc/systemd/system` +- copy **assets/ffplayout.1.gz** to `/usr/share/man/man1/` - copy **public** folder to `/usr/share/ffplayout/` -- activate service and run it: `systemctl enable --now ffpapi ffplayout` +- activate service and run it: `systemctl enable --now ffplayout` diff --git a/docs/output.md b/docs/output.md index ba9874f8..f238b164 100644 --- a/docs/output.md +++ b/docs/output.md @@ -72,7 +72,7 @@ In this mode you can output directly to a hls playlist. The nice thing here is, HLS output is currently the default, mostly because it works out of the box and don't need a streaming target. In default settings it saves the segments to **/usr/share/ffplayout/public/live/**. -**It is recommend to serve the HLS stream with nginx or another web server, and not with ffpapi (which is more meant for previewing).** +**It is recommend to serve the HLS stream with nginx or another web server, and not with ffplayout (which is more meant for previewing).** **HLS multiple outputs example:** diff --git a/ffplayout-api/Cargo.toml b/ffplayout-api/Cargo.toml deleted file mode 100644 index ef2c9855..00000000 --- a/ffplayout-api/Cargo.toml +++ /dev/null @@ -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" diff --git a/ffplayout-api/README.md b/ffplayout-api/README.md deleted file mode 100644 index b62ce785..00000000 --- a/ffplayout-api/README.md +++ /dev/null @@ -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 -p -m -``` - -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 name for initialization - -h, --help Print help information - -i, --init Initialize Database - -l, --listen Listen on IP:PORT, like: 127.0.0.1:8787 - -m, --mail Admin mail address - -p, --password Admin password - -u, --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. diff --git a/ffplayout-api/src/db/handles.rs b/ffplayout-api/src/db/handles.rs deleted file mode 100644 index cb35f5a7..00000000 --- a/ffplayout-api/src/db/handles.rs +++ /dev/null @@ -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) -> Result { - 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) -> Result<&'static str, Box> { - 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) -> Result { - let query = "SELECT secret FROM global WHERE id = 1"; - - sqlx::query_as(query).fetch_one(conn).await -} - -pub async fn select_channel(conn: &Pool, id: &i32) -> Result { - 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) -> Result, sqlx::Error> { - let query = "SELECT * FROM channels"; - let mut results: Vec = 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, - id: i32, - channel: Channel, -) -> Result { - 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, channel: Channel) -> Result { - 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, - id: &i32, -) -> Result { - let query = "DELETE FROM channels WHERE id = $1"; - - sqlx::query(query).bind(id).execute(conn).await -} - -pub async fn select_last_channel(conn: &Pool) -> Result { - 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, id: &i32) -> Result { - 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, user: &str) -> Result { - 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, user: &str) -> Result { - 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, id: i32) -> Result { - 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) -> Result, sqlx::Error> { - let query = "SELECT id, username FROM user"; - - sqlx::query_as(query).fetch_all(conn).await -} - -pub async fn insert_user( - conn: &Pool, - user: User, -) -> Result { - 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, - id: i32, - fields: String, -) -> Result { - 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, - name: &str, -) -> Result { - let query = "DELETE FROM user WHERE username = $1;"; - - sqlx::query(query).bind(name).execute(conn).await -} - -pub async fn select_presets(conn: &Pool, id: i32) -> Result, 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, - id: &i32, - preset: TextPreset, -) -> Result { - 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, - preset: TextPreset, -) -> Result { - 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, - id: &i32, -) -> Result { - let query = "DELETE FROM presets WHERE id = $1;"; - - sqlx::query(query).bind(id).execute(conn).await -} diff --git a/ffplayout-api/src/db/models.rs b/ffplayout-api/src/db/models.rs deleted file mode 100644 index dcacc1df..00000000 --- a/ffplayout-api/src/db/models.rs +++ /dev/null @@ -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, - pub username: String, - #[sqlx(default)] - #[serde(skip_serializing, default = "empty_string")] - pub password: String, - #[sqlx(default)] - #[serde(skip_serializing)] - pub role_id: Option, - #[sqlx(default)] - #[serde(skip_serializing)] - pub channel_id: Option, - #[sqlx(default)] - #[serde(skip_serializing_if = "Option::is_none")] - pub token: Option, -} - -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 -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(self, value: &str) -> Result { - 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(self, value: u64) -> Result { - Ok(value.to_string()) - } - - fn visit_i64(self, value: i64) -> Result { - Ok(value.to_string()) - } - - fn visit_f64(self, value: f64) -> Result { - 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, -} diff --git a/ffplayout-api/src/utils/args_parse.rs b/ffplayout-api/src/utils/args_parse.rs deleted file mode 100644 index f9b8a9cc..00000000 --- a/ffplayout-api/src/utils/args_parse.rs +++ /dev/null @@ -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, - - #[clap(long, help = "path to public files")] - pub public: Option, - - #[clap(short, long, help = "Listen on IP:PORT, like: 127.0.0.1:8787")] - pub listen: Option, - - #[clap(short, long, help = "Initialize Database")] - pub init: bool, - - #[clap(short, long, help = "domain name for initialization")] - pub domain: Option, - - #[clap(short, long, help = "Create admin user")] - pub username: Option, - - #[clap(short, long, help = "Admin mail address")] - pub mail: Option, - - #[clap(short, long, help = "Admin password")] - pub password: Option, -} diff --git a/ffplayout-api/src/utils/channels.rs b/ffplayout-api/src/utils/channels.rs deleted file mode 100644 index 9a684b87..00000000 --- a/ffplayout-api/src/utils/channels.rs +++ /dev/null @@ -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, - target_channel: Channel, -) -> Result { - 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, 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(()) -} diff --git a/ffplayout-api/src/utils/control.rs b/ffplayout-api/src/utils/control.rs deleted file mode 100644 index 19e62d31..00000000 --- a/ffplayout-api/src/utils/control.rs +++ /dev/null @@ -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, -} - -#[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>, - 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 { - #[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 { - 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 { - 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 { - 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 { - 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 { - 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, -} - -impl SystemD { - async fn new(conn: &Pool, id: i32) -> Result { - let channel = select_channel(conn, &id).await?; - - Ok(Self { - service: channel.service, - cmd: vec_strings!["/usr/bin/systemctl"], - }) - } - - fn enable(mut self) -> Result { - 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 { - 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 { - 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 { - 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 { - 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 { - 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(config: &PlayoutConfig, obj: T) -> Result -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, -) -> Result { - let json_obj = TextParams { - control: "text".into(), - message, - }; - - post_request(config, json_obj).await -} - -pub async fn control_state( - config: &PlayoutConfig, - command: &str, -) -> Result { - let json_obj = ControlParams { - control: command.to_owned(), - }; - - post_request(config, json_obj).await -} - -pub async fn media_info(config: &PlayoutConfig, command: String) -> Result { - let json_obj = MediaParams { media: command }; - - post_request(config, json_obj).await -} - -pub async fn control_service( - conn: &Pool, - config: &PlayoutConfig, - id: i32, - command: &ServiceCmd, - engine: Option>, -) -> Result { - 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, - id: i32, - command: &ServiceCmd, -) -> Result { - 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, - } -} diff --git a/ffplayout-api/src/utils/mod.rs b/ffplayout-api/src/utils/mod.rs deleted file mode 100644 index 1c52c6a4..00000000 --- a/ffplayout-api/src/utils/mod.rs +++ /dev/null @@ -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 { - 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: >::ValueRef, - ) -> Result> { - let value = <&str as sqlx::decode::Decode>::decode(value)?; - - Ok(value.parse()?) - } -} - -impl FromRow<'_, SqliteRow> for Role { - fn from_row(row: &SqliteRow) -> sqlx::Result { - 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) -> 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 = OnceCell::new(); - -pub async fn init_config(conn: &Pool) { - let config = GlobalSettings::new(conn).await; - INSTANCE.set(config).unwrap(); -} - -pub fn db_path() -> Result<&'static str, Box> { - 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> { - 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, - 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, - channel_id: &i32, - date: &str, -) -> Result { - 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::().unwrap_or(0) * 60 + f.parse::().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 -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) - } - } - } -} diff --git a/ffplayout-engine/README.md b/ffplayout-engine/README.md deleted file mode 100644 index f8bce14a..00000000 --- a/ffplayout-engine/README.md +++ /dev/null @@ -1,34 +0,0 @@ -**ffplayout-engine** -================ - -Start with Arguments ------ - -ffplayout also allows the passing of parameters: - -``` -OPTIONS: - -c, --config File path to ffplayout.yml - -d, --date Target date (YYYY-MM-DD) for text/m3u to playlist import - -f, --folder Play folder content - --fake-time fake date time, for debugging - -g, --generate ... Generate playlist for dates, like: 2022-01-01 - 2022-01-10 - -h, --help Print help information - -i, --infinit Loop playlist infinitely - --import Import a given text/m3u file and create a playlist from it - -l, --log File path for logging - -m, --play-mode Playing mode: folder, playlist - -o, --output Set output mode: desktop, hls, stream - -p, --playlist Path from playlist - -s, --start Start time in 'hh:mm:ss', 'now' for start with first - -t, --length Set length in 'hh:mm:ss', 'none' for no length check - -v, --volume Set audio volume - -V, --version Print version information - -``` - -You can run the command like: - -```Bash -./ffplayout -l none -p ~/playlist.json -o desktop -``` diff --git a/ffplayout-engine/src/input/mod.rs b/ffplayout-engine/src/input/mod.rs deleted file mode 100644 index d003eb02..00000000 --- a/ffplayout-engine/src/input/mod.rs +++ /dev/null @@ -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, -) -> Box> { - match config.processing.mode { - Folder => { - info!("Playout in folder mode"); - debug!( - "Monitor folder: {:?}", - 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> - } - Playlist => { - info!("Playout in playlist mode"); - let program = CurrentProgram::new(&config, playout_stat, is_terminated, player_control); - - Box::new(program) as Box> - } - } -} diff --git a/ffplayout-engine/src/main.rs b/ffplayout-engine/src/main.rs deleted file mode 100644 index 386194d0..00000000 --- a/ffplayout-engine/src/main.rs +++ /dev/null @@ -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::{ - 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::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: {stat_file}"); - - 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 {stat_file}: {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 = 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: {}", - 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(()) -} diff --git a/ffplayout-engine/src/output/hls.rs b/ffplayout-engine/src/output/hls.rs deleted file mode 100644 index 413dde97..00000000 --- a/ffplayout-engine/src/output/hls.rs +++ /dev/null @@ -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: {url}"); - }; - - 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: \"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 {}: {}", - 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!( - "{:?} 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: \"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(); -} diff --git a/ffplayout-engine/src/rpc/mod.rs b/ffplayout-engine/src/rpc/mod.rs deleted file mode 100644 index 2c09235b..00000000 --- a/ffplayout-engine/src/rpc/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod server; -mod zmq_cmd; - -pub use server::run_server; -pub use zmq_cmd::zmq_send; diff --git a/ffplayout-engine/src/rpc/server.rs b/ffplayout-engine/src/rpc/server.rs deleted file mode 100644 index 0b7441f3..00000000 --- a/ffplayout-engine/src/rpc/server.rs +++ /dev/null @@ -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, - #[serde(default, deserialize_with = "deserialize_number_or_string")] - x: Option, - #[serde(default, deserialize_with = "deserialize_number_or_string")] - y: Option, - #[serde(default, deserialize_with = "deserialize_number_or_string")] - fontsize: Option, - #[serde(default, deserialize_with = "deserialize_number_or_string")] - line_spacing: Option, - fontcolor: Option, - #[serde(default, deserialize_with = "deserialize_number_or_string")] - alpha: Option, - #[serde(default, deserialize_with = "deserialize_number_or_string")] - r#box: Option, - boxcolor: Option, - #[serde(default, deserialize_with = "deserialize_number_or_string")] - boxborderw: Option, -} - -/// Deserialize number or string -pub fn deserialize_number_or_string<'de, D>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - struct StringOrNumberVisitor; - - impl<'de> Visitor<'de> for StringOrNumberVisitor { - type Value = Option; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a string or a number") - } - - fn visit_str(self, value: &str) -> Result { - 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(self, value: u64) -> Result { - Ok(Some(value.to_string())) - } - - fn visit_i64(self, value: i64) -> Result { - Ok(Some(value.to_string())) - } - - fn visit_f64(self, value: f64) -> Result { - 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 { - 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) -> Response>> { - 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>> { - 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>> { - 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(¤t_date); - write_status(config, ¤t_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>> { - 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(¤t_date); - write_status(config, ¤t_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>> { - 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(¤t_date); - playout_stat.list_init.store(true, Ordering::SeqCst); - - write_status(config, ¤t_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>> { - 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, - config: &PlayoutConfig, - playout_stat: &PlayoutStatus, - proc: &ProcessControl, -) -> Response>> { - if data.contains_key("message") { - let filter = filter_from_json(data["message"].clone()); - debug!("Got drawtext command: \"{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>> { - 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>> { - 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>> { - 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::>(&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); - } - } - } -} diff --git a/ffplayout-engine/src/rpc/zmq_cmd.rs b/ffplayout-engine/src/rpc/zmq_cmd.rs deleted file mode 100644 index 9238b3f5..00000000 --- a/ffplayout-engine/src/rpc/zmq_cmd.rs +++ /dev/null @@ -1,14 +0,0 @@ -use std::error::Error; - -use zeromq::Socket; -use zeromq::{SocketRecv, SocketSend, ZmqMessage}; - -pub async fn zmq_send(msg: &str, socket_addr: &str) -> Result> { - 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) -} diff --git a/ffplayout-engine/src/utils/arg_parse.rs b/ffplayout-engine/src/utils/arg_parse.rs deleted file mode 100644 index 5276aac1..00000000 --- a/ffplayout-engine/src/utils/arg_parse.rs +++ /dev/null @@ -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, - - #[clap(index = 1, value_parser, help = "Channel name")] - pub channel: Option, - - #[clap(short, long, help = "File path to ffplayout.toml")] - pub config: Option, - - #[clap(short, long, help = "File path for logging")] - pub log: Option, - - #[clap( - short, - long, - help = "Target date (YYYY-MM-DD) for text/m3u to playlist import" - )] - pub date: Option, - - #[cfg(debug_assertions)] - #[clap(long, help = "fake date time, for debugging")] - pub fake_time: Option, - - #[clap(short, long, help = "Play folder content")] - pub folder: Option, - - #[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>, - - #[clap( - long, - help = "Import a given text/m3u file and create a playlist from it" - )] - pub import: Option, - - #[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, - - #[clap(long, help = "Override logging level")] - pub level: Option, - - #[clap(long, help = "Optional path list for playlist generations", num_args = 1..)] - pub paths: Option>, - - #[clap(short = 'm', long, help = "Playing mode: folder, playlist")] - pub play_mode: Option, - - #[clap(short, long, help = "Path to playlist, or playlist root folder.")] - pub playlist: Option, - - #[clap( - short, - long, - help = "Start time in 'hh:mm:ss', 'now' for start with first" - )] - pub start: Option, - - #[clap(short = 'T', long, help = "JSON Template file for generating playlist")] - pub template: Option, - - #[clap(short, long, help = "Set output mode: desktop, hls, null, stream")] - pub output: Option, - - #[clap(short, long, help = "Set audio volume")] - pub volume: Option, - - #[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()) -} diff --git a/ffplayout-engine/src/utils/mod.rs b/ffplayout-engine/src/utils/mod.rs deleted file mode 100644 index 3a6506da..00000000 --- a/ffplayout-engine/src/utils/mod.rs +++ /dev/null @@ -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 { - 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!("[Server] {}", line.replace("[info] ", "")) - } else if line.contains("[warning]") - && (level.to_lowercase() == "warning" || level.to_lowercase() == "info") - { - warn!( - "[Server] {}", - line.replace("[warning] ", "") - ) - } else if line.contains("[error]") - && !line.contains("Input/output error") - && !line.contains("Broken pipe") - { - error!("[Server] {}", line.replace("[error] ", "")); - } else if line.contains("[fatal]") { - error!("[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, - filters: &Option, -) -> Vec { - 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 { - 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 -} diff --git a/ffplayout-engine/src/utils/task_runner.rs b/ffplayout-engine/src/utils/task_runner.rs deleted file mode 100644 index db18c277..00000000 --- a/ffplayout-engine/src/utils/task_runner.rs +++ /dev/null @@ -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}") - } - } -} diff --git a/ffplayout-frontend b/ffplayout-frontend deleted file mode 160000 index 8d63cc4f..00000000 --- a/ffplayout-frontend +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8d63cc4f85f3cbd530d509d74494b6fefbb9bf2c diff --git a/ffplayout-engine/Cargo.toml b/ffplayout/Cargo.toml similarity index 51% rename from ffplayout-engine/Cargo.toml rename to ffplayout/Cargo.toml index 019e59d9..1e0bfc9c 100644 --- a/ffplayout-engine/Cargo.toml +++ b/ffplayout/Cargo.toml @@ -1,40 +1,83 @@ [package] name = "ffplayout" description = "24/7 playout based on rust and ffmpeg" -readme = "README.md" +readme = "../README.md" version.workspace = true license.workspace = true authors.workspace = true repository.workspace = true edition.workspace = true -default-run = "ffplayout" +[features] +default = ["embed_frontend"] +embed_frontend = [] [dependencies] -ffplayout-lib = { path = "../lib" } -chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } -clap = { version = "4.3", features = ["derive"] } +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", "serde"] } +clap = { version = "4.3", features = ["derive", "env"] } crossbeam-channel = "0.5" -futures = "0.3" -itertools = "0.12" +derive_more = "0.99" +faccess = "0.2" +ffprobe = "0.4" +flexi_logger = { version = "0.28", features = ["kv", "colors"] } +futures-util = { version = "0.3", default-features = false, features = ["std"] } +home = "0.5" +jsonwebtoken = "9" +lazy_static = "1.4" +lettre = { version = "0.11", features = ["builder", "rustls-tls", "smtp-transport", "tokio1", "tokio1-rustls-tls"], default-features = false } +lexical-sort = "0.3" +local-ip-address = "0.6" +log = { version = "0.4", features = ["std", "serde", "kv", "kv_std", "kv_sval", "kv_serde"] } notify = "6.0" notify-debouncer-full = { version = "*", default-features = false } +num-traits = "0.2" +once_cell = "1" +paris = "1.5" +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"] } -tiny_http = { version = "0.12", default-features = false } -zeromq = { version = "0.3", default-features = false, features = [ - "async-std-runtime", +serde_with = "3.8" +shlex = "1.1" +static-files = "0.2" +sysinfo ={ version = "0.30", features = ["linux-netdevs", "linux-tmpfs"] } +sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] } +time = { version = "0.3", features = ["formatting", "macros"] } +tokio = { version = "1.29", features = ["full"] } +tokio-stream = "0.1" +toml_edit = {version ="0.22", features = ["serde"]} +uuid = "1.8" +walkdir = "2" +zeromq = { version = "0.4", default-features = false, features = [ + "tokio-runtime", "tcp-transport", ] } +[target.'cfg(not(target_arch = "windows"))'.dependencies] +signal-child = "1" + +[build-dependencies] +static-files = "0.2" + [[bin]] name = "ffplayout" path = "src/main.rs" + # DEBIAN DEB PACKAGE [package.metadata.deb] name = "ffplayout" @@ -42,61 +85,24 @@ 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", "/etc/ffplayout/advanced.toml"] +copyright = "Copyright (c) 2024, Jonathan Baecker. All rights reserved." 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", @@ -107,11 +113,6 @@ assets = [ "/usr/share/doc/ffplayout/README", "644", ], - [ - "../assets/ffpapi.1.gz", - "/usr/share/man/man1/", - "644", - ], [ "../assets/ffplayout.1.gz", "/usr/share/man/man1/", @@ -123,56 +124,21 @@ 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", @@ -183,11 +149,6 @@ assets = [ "/usr/share/doc/ffplayout/README", "644", ], - [ - "../assets/ffpapi.1.gz", - "/usr/share/man/man1/", - "644", - ], [ "../assets/ffplayout.1.gz", "/usr/share/man/man1/", @@ -200,20 +161,12 @@ assets = [ 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" }, ] diff --git a/ffplayout-api/build.rs b/ffplayout/build.rs similarity index 74% rename from ffplayout-api/build.rs rename to ffplayout/build.rs index df50d511..f8a856e0 100644 --- a/ffplayout-api/build.rs +++ b/ffplayout/build.rs @@ -2,10 +2,10 @@ use static_files::NpmBuild; fn main() -> std::io::Result<()> { if !cfg!(debug_assertions) && cfg!(feature = "embed_frontend") { - NpmBuild::new("../ffplayout-frontend") + NpmBuild::new("../frontend") .install()? .run("generate")? - .target("../ffplayout-frontend/.output/public") + .target("../frontend/.output/public") .change_detection() .to_resource_dir() .build() diff --git a/ffplayout/examples/flexi2.rs b/ffplayout/examples/flexi2.rs new file mode 100644 index 00000000..74cb0844 --- /dev/null +++ b/ffplayout/examples/flexi2.rs @@ -0,0 +1,176 @@ +use log::*; +use std::io::Write; +// use std::io::{Error, ErrorKind}; +// use std::sync::{Arc, Mutex}; + +use flexi_logger::writers::{FileLogWriter, LogWriter}; +use flexi_logger::{Age, Cleanup, Criterion, DeferredNow, FileSpec, Logger, Naming}; +use paris::formatter::colorize_string; + +pub struct LogMailer; + +impl LogWriter for LogMailer { + fn write(&self, now: &mut DeferredNow, record: &Record<'_>) -> std::io::Result<()> { + println!("target: {:?}", record.target()); + println!("key/value: {:?}", record.key_values().get("channel".into())); + println!( + "[{}] [{:>5}] Mail logger: {:?}", + now.now().format("%Y-%m-%d %H:%M:%S"), + record.level(), + record.args() + ); + Ok(()) + } + fn flush(&self) -> std::io::Result<()> { + Ok(()) + } +} + +pub struct LogConsole; + +impl LogWriter for LogConsole { + fn write(&self, now: &mut DeferredNow, record: &Record<'_>) -> std::io::Result<()> { + console_formatter(&mut std::io::stderr(), now, record)?; + + println!(); + Ok(()) + } + fn flush(&self) -> std::io::Result<()> { + Ok(()) + } +} + +pub fn file_logger(to_file: bool) -> Box { + if to_file { + Box::new( + FileLogWriter::builder( + FileSpec::default() + .suppress_timestamp() + // .directory("/var/log") + .basename("ffplayout"), + ) + .append() + .format(file_formatter) + .rotate( + Criterion::Age(Age::Day), + Naming::Timestamps, + Cleanup::KeepLogFiles(7), + ) + .print_message() + .try_build() + .unwrap(), + ) + } else { + Box::new(LogConsole) + } +} + +// struct MyWriter { +// file: Arc>, +// } + +// impl LogWriter for MyWriter { +// fn write( +// &self, +// now: &mut flexi_logger::DeferredNow, +// record: &flexi_logger::Record, +// ) -> std::io::Result<()> { +// let mut file = self +// .file +// .lock() +// .map_err(|e| Error::new(ErrorKind::Other, e.to_string()))?; +// flexi_logger::default_format(&mut *file, now, record) +// } + +// fn flush(&self) -> std::io::Result<()> { +// let mut file = self +// .file +// .lock() +// .map_err(|e| Error::new(ErrorKind::Other, e.to_string()))?; +// file.flush() +// } +// } + +// Define a macro for writing messages to the alert log and to the normal log +#[macro_use] +mod macros { + #[macro_export] + macro_rules! file_error { + ($($arg:tt)*) => ( + error!(target: "{File}", $($arg)*); + ) + } +} + +pub fn console_formatter( + w: &mut dyn Write, + now: &mut DeferredNow, + record: &Record, +) -> std::io::Result<()> { + let timestamp = colorize_string(format!( + "[{}]", + now.now().format("%Y-%m-%d %H:%M:%S%.6f") + )); + + let level = match record.level() { + Level::Debug => colorize_string("[DEBUG]"), + Level::Error => colorize_string("[ERROR]"), + Level::Info => colorize_string("[ INFO]"), + Level::Trace => colorize_string("[TRACE]"), + Level::Warn => colorize_string("[ WARN]"), + }; + + write!( + w, + "{} {} {}", + timestamp, + level, + colorize_string(record.args().to_string()), + ) +} + +pub fn file_formatter( + w: &mut dyn Write, + now: &mut DeferredNow, + record: &Record, +) -> std::io::Result<()> { + let timestamp = format!("[{}]", now.now().format("%Y-%m-%d %H:%M:%S%.6f")); + + let level = match record.level() { + Level::Debug => "[DEBUG]", + Level::Error => "[ERROR]", + Level::Info => "[ INFO]", + Level::Trace => "[TRACE]", + Level::Warn => "[ WARN]", + }; + + write!(w, "{} {} {}", timestamp, level, record.args()) +} + +fn main() { + let to_file = true; + + Logger::try_with_str("WARN") + .expect("LogSpecification String has errors") + .format(console_formatter) + .print_message() + .log_to_stderr() + .add_writer("File", file_logger(to_file)) + .add_writer("Mail", Box::new(LogMailer)) + .start() + .unwrap(); + + // Explicitly send logs to different loggers + info!(target: "{Mail}", "This logs only to Mail"); + warn!(target: "{File,Mail}", channel = 1; "This logs to File and Mail"); + error!(target: "{File}", "This logs only to file"); + error!(target: "{_Default}", "This logs to console"); + + file_error!("This is another file log"); + + error!("This is a normal error message"); + warn!("This is a warning"); + info!("This is an info message"); + debug!("This is an debug message"); + trace!("This is an trace message"); +} diff --git a/ffplayout/examples/flexi3.rs b/ffplayout/examples/flexi3.rs new file mode 100644 index 00000000..10da1055 --- /dev/null +++ b/ffplayout/examples/flexi3.rs @@ -0,0 +1,85 @@ +use flexi_logger::writers::{FileLogWriter, LogWriter}; +use flexi_logger::{Age, Cleanup, Criterion, DeferredNow, FileSpec, Naming, Record}; +use log::{debug, error, info, kv::Value, trace, warn}; +use std::collections::HashMap; +use std::io; +use std::sync::{Arc, Mutex}; + +struct MultiFileLogger { + writers: Arc>>>>, +} + +impl MultiFileLogger { + pub fn new() -> Self { + MultiFileLogger { + writers: Arc::new(Mutex::new(HashMap::new())), + } + } + + fn get_writer(&self, channel: &str) -> io::Result>> { + let mut writers = self.writers.lock().unwrap(); + if !writers.contains_key(channel) { + let writer = FileLogWriter::builder( + FileSpec::default() + .suppress_timestamp() + .basename("ffplayout"), + ) + .append() + .rotate( + Criterion::Age(Age::Day), + Naming::TimestampsCustomFormat { + current_infix: Some(""), + format: "%Y-%m-%d", + }, + Cleanup::KeepLogFiles(7), + ) + .print_message() + .try_build() + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + writers.insert(channel.to_string(), Arc::new(Mutex::new(writer))); + } + Ok(writers.get(channel).unwrap().clone()) + } +} + +impl LogWriter for MultiFileLogger { + fn write(&self, now: &mut DeferredNow, record: &Record) -> io::Result<()> { + let channel = record + .key_values() + .get("channel".into()) + .unwrap_or(Value::null()) + .to_string(); + let writer = self.get_writer(&channel); + let w = writer?.lock().unwrap().write(now, record); + + w + } + + fn flush(&self) -> io::Result<()> { + let writers = self.writers.lock().unwrap(); + for writer in writers.values() { + writer.lock().unwrap().flush()?; + } + Ok(()) + } +} + +fn main() { + let logger = MultiFileLogger::new(); + + flexi_logger::Logger::try_with_str("trace") + .expect("LogSpecification String has errors") + .print_message() + .add_writer("file", Box::new(logger)) + .log_to_stderr() + .start() + .unwrap(); + + trace!(target: "{file}", channel = 1; "This is a trace message for file1"); + trace!("This is a trace message for console"); + debug!(target: "{file}", channel = 2; "This is a debug message for file2"); + info!(target:"{file}", channel = 2; "This is an info message for file2"); + warn!(target: "{file}", channel = 1; "This is a warning for file1"); + error!(target: "{file}", channel = 2; "This is an error message for file2"); + info!("This is a info message for console"); +} diff --git a/ffplayout-api/src/api/auth.rs b/ffplayout/src/api/auth.rs similarity index 63% rename from ffplayout-api/src/api/auth.rs rename to ffplayout/src/api/auth.rs index 9c933a68..b3bb029e 100644 --- a/ffplayout-api/src/api/auth.rs +++ b/ffplayout/src/api/auth.rs @@ -4,7 +4,10 @@ use chrono::{TimeDelta, Utc}; use jsonwebtoken::{self, DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; -use crate::utils::{GlobalSettings, Role}; +use crate::{ + db::models::{GlobalSettings, Role}, + utils::errors::ServiceError, +}; // Token lifetime const JWT_EXPIRATION_DAYS: i64 = 7; @@ -12,15 +15,17 @@ const JWT_EXPIRATION_DAYS: i64 = 7; #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] pub struct Claims { pub id: i32, + pub channels: Vec, pub username: String, pub role: Role, exp: i64, } impl Claims { - pub fn new(id: i32, username: String, role: Role) -> Self { + pub fn new(id: i32, channels: Vec, username: String, role: Role) -> Self { Self { id, + channels, username, role, exp: (Utc::now() + TimeDelta::try_days(JWT_EXPIRATION_DAYS).unwrap()).timestamp(), @@ -29,17 +34,20 @@ impl Claims { } /// Create a json web token (JWT) -pub fn create_jwt(claims: Claims) -> Result { +pub async fn create_jwt(claims: Claims) -> Result { 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())) + let encoding_key = EncodingKey::from_secret(config.secret.clone().unwrap().as_bytes()); + Ok(jsonwebtoken::encode( + &Header::default(), + &claims, + &encoding_key, + )?) } /// Decode a json web token (JWT) pub async fn decode_jwt(token: &str) -> Result { let config = GlobalSettings::global(); - let decoding_key = DecodingKey::from_secret(config.secret.as_bytes()); + let decoding_key = DecodingKey::from_secret(config.secret.clone().unwrap().as_bytes()); jsonwebtoken::decode::(token, &decoding_key, &Validation::default()) .map(|data| data.claims) .map_err(|e| ErrorUnauthorized(e.to_string())) diff --git a/ffplayout-api/src/api/mod.rs b/ffplayout/src/api/mod.rs similarity index 100% rename from ffplayout-api/src/api/mod.rs rename to ffplayout/src/api/mod.rs diff --git a/ffplayout-api/src/api/routes.rs b/ffplayout/src/api/routes.rs similarity index 55% rename from ffplayout-api/src/api/routes.rs rename to ffplayout/src/api/routes.rs index 3eab3073..39fca012 100644 --- a/ffplayout-api/src/api/routes.rs +++ b/ffplayout/src/api/routes.rs @@ -3,12 +3,16 @@ /// Run the API thru the systemd service, or like: /// /// ```BASH -/// ffpapi -l 127.0.0.1:8787 +/// ffplayout -l 127.0.0.1:8787 /// ``` /// /// For all endpoints an (Bearer) authentication is required.\ /// `{id}` represent the channel id, and at default is 1. -use std::{collections::HashMap, env, path::PathBuf}; +use std::{ + env, + path::PathBuf, + sync::{atomic::Ordering, Arc, Mutex}, +}; use actix_files; use actix_multipart::Multipart; @@ -27,20 +31,18 @@ use argon2::{ Argon2, PasswordHasher, PasswordVerifier, }; use chrono::{DateTime, Datelike, Local, NaiveDateTime, TimeDelta, TimeZone, Utc}; +use log::*; use path_clean::PathClean; use regex::Regex; use serde::{Deserialize, Serialize}; -use simplelog::*; use sqlx::{Pool, Sqlite}; -use tokio::{fs, task}; +use tokio::fs; -use crate::db::{ - handles, - models::{Channel, LoginUser, TextPreset, User}, -}; +use crate::db::models::Role; use crate::utils::{ channels::{create_channel, delete_channel}, - control::{control_service, control_state, media_info, send_message, ControlParams, Process}, + config::{PlayoutConfig, Template}, + control::{control_state, send_message, ControlParams, Process, ProcessCtl}, errors::ServiceError, files::{ browser, create_directory, norm_abs_path, remove_file_or_folder, rename_file, upload, @@ -48,18 +50,25 @@ use crate::utils::{ }, naive_date_time_from_str, playlist::{delete_playlist, generate_playlist, read_playlist, write_playlist}, - playout_config, public_path, read_log_file, read_playout_config, system, Role, + public_path, read_log_file, system, TextFilter, }; +use crate::vec_strings; use crate::{ api::auth::{create_jwt, Claims}, - utils::control::ProcessControl, + utils::advanced_config::AdvancedConfig, }; -use ffplayout_lib::{ - utils::{ - get_date_range, import::import_file, sec_to_time, time_to_sec, JsonPlaylist, PlayoutConfig, - Template, +use crate::{ + db::{ + handles, + models::{Channel, TextPreset, User, UserMeta}, }, - vec_strings, + player::controller::ChannelController, +}; +use crate::{ + player::utils::{ + get_data_map, get_date_range, import::import_file, sec_to_time, time_to_sec, JsonPlaylist, + }, + utils::logging::MailQueue, }; #[derive(Serialize)] @@ -152,54 +161,61 @@ struct ProgramItem { /// ``` #[post("/auth/login/")] pub async fn login(pool: web::Data>, credentials: web::Json) -> impl Responder { - let conn = pool.into_inner(); - match handles::select_login(&conn, &credentials.username).await { + let username = credentials.username.clone(); + let password = credentials.password.clone(); + + match handles::select_login(&pool, &username).await { Ok(mut user) => { - let role = handles::select_role(&conn, &user.role_id.unwrap_or_default()) + let role = handles::select_role(&pool, &user.role_id.unwrap_or_default()) .await .unwrap_or(Role::Guest); - task::spawn_blocking(move || { - let pass = user.password.clone(); + let pass = user.password.clone(); + let password_clone = password.clone(); + + user.password = "".into(); + + let verified_password = web::block(move || { let hash = PasswordHash::new(&pass).unwrap(); - user.password = "".into(); - - if Argon2::default() - .verify_password(credentials.password.as_bytes(), &hash) - .is_ok() - { - let claims = Claims::new(user.id, user.username.clone(), role.clone()); - - if let Ok(token) = create_jwt(claims) { - user.token = Some(token); - }; - - info!("user {} login, with role: {role}", credentials.username); - - web::Json(UserObj { - message: "login correct!".into(), - user: Some(user), - }) - .customize() - .with_status(StatusCode::OK) - } else { - error!("Wrong password for {}!", credentials.username); - - web::Json(UserObj { - message: "Wrong password!".into(), - user: None, - }) - .customize() - .with_status(StatusCode::FORBIDDEN) - } + Argon2::default().verify_password(password_clone.as_bytes(), &hash) }) - .await - .unwrap() + .await; + + if verified_password.is_ok() { + let claims = Claims::new( + user.id, + user.channel_ids.clone().unwrap_or_default(), + username.clone(), + role.clone(), + ); + + if let Ok(token) = create_jwt(claims).await { + user.token = Some(token); + }; + + info!("user {} login, with role: {role}", username); + + web::Json(UserObj { + message: "login correct!".into(), + user: Some(user), + }) + .customize() + .with_status(StatusCode::OK) + } else { + error!("Wrong password for {username}!"); + + web::Json(UserObj { + message: "Wrong password!".into(), + user: None, + }) + .customize() + .with_status(StatusCode::FORBIDDEN) + } } Err(e) => { - error!("Login {} failed! {e}", credentials.username); + error!("Login {username} failed! {e}"); web::Json(UserObj { - message: format!("Login {} failed!", credentials.username), + message: format!("Login {username} failed!"), user: None, }) .customize() @@ -218,12 +234,15 @@ pub async fn login(pool: web::Data>, credentials: web::Json) /// -H 'Authorization: Bearer ' /// ``` #[get("/user")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role" +)] async fn get_user( pool: web::Data>, - user: web::ReqData, + user: web::ReqData, ) -> Result { - match handles::select_user(&pool.into_inner(), &user.username).await { + match handles::select_user(&pool, user.id).await { Ok(user) => Ok(web::Json(user)), Err(e) => { error!("{e}"); @@ -238,13 +257,13 @@ async fn get_user( /// curl -X GET 'http://127.0.0.1:8787/api/user/2' -H 'Content-Type: application/json' \ /// -H 'Authorization: Bearer ' /// ``` -#[get("/user/{name}")] -#[protect("Role::Admin", ty = "Role")] +#[get("/user/{id}")] +#[protect("Role::GlobalAdmin", ty = "Role")] async fn get_by_name( pool: web::Data>, - name: web::Path, + id: web::Path, ) -> Result { - match handles::select_user(&pool.into_inner(), &name).await { + match handles::select_user(&pool, *id).await { Ok(user) => Ok(web::Json(user)), Err(e) => { error!("{e}"); @@ -260,9 +279,9 @@ async fn get_by_name( /// -H 'Authorization: Bearer ' /// ``` #[get("/users")] -#[protect("Role::Admin", ty = "Role")] +#[protect("Role::GlobalAdmin", ty = "Role")] async fn get_users(pool: web::Data>) -> Result { - match handles::select_users(&pool.into_inner()).await { + match handles::select_users(&pool).await { Ok(users) => Ok(web::Json(users)), Err(e) => { error!("{e}"); @@ -278,49 +297,68 @@ async fn get_users(pool: web::Data>) -> Result", "password": ""}' -H 'Authorization: Bearer ' /// ``` #[put("/user/{id}")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "*id == user.id || role.has_authority(&Role::GlobalAdmin)" +)] async fn update_user( pool: web::Data>, id: web::Path, - user: web::ReqData, data: web::Json, role: AuthDetails, + user: web::ReqData, ) -> Result { - if *id == user.id || role.has_authority(&Role::Admin) { - let mut fields = String::new(); + let mut fields = String::new(); - if let Some(mail) = data.mail.clone() { - if !fields.is_empty() { - fields.push_str(", "); - } - - fields.push_str(format!("mail = '{mail}'").as_str()); + if let Some(mail) = data.mail.clone() { + if !fields.is_empty() { + fields.push_str(", "); } - if !data.password.is_empty() { - if !fields.is_empty() { - fields.push_str(", "); - } - - let salt = SaltString::generate(&mut OsRng); - let password_hash = Argon2::default() - .hash_password(data.password.clone().as_bytes(), &salt) - .unwrap(); - - fields.push_str(format!("password = '{password_hash}'").as_str()); - } - - if handles::update_user(&pool.into_inner(), *id, fields) - .await - .is_ok() - { - return Ok("Update Success"); - }; - - return Err(ServiceError::InternalServerError); + fields.push_str(&format!("mail = '{mail}'")); } - Err(ServiceError::Unauthorized("No Permission".to_string())) + if let Some(channel_ids) = &data.channel_ids { + if !fields.is_empty() { + fields.push_str(", "); + } + + fields.push_str(&format!( + "channel_ids = '{}'", + channel_ids + .iter() + .map(|i| i.to_string()) + .collect::>() + .join(",") + )); + } + + if !data.password.is_empty() { + if !fields.is_empty() { + fields.push_str(", "); + } + + let password_hash = web::block(move || { + let salt = SaltString::generate(&mut OsRng); + + let argon = Argon2::default() + .hash_password(data.password.clone().as_bytes(), &salt) + .map(|p| p.to_string()); + + argon + }) + .await? + .unwrap(); + + fields.push_str(&format!("password = '{password_hash}'")); + } + + if handles::update_user(&pool, *id, fields).await.is_ok() { + return Ok("Update Success"); + }; + + Err(ServiceError::InternalServerError) } /// **Add User** @@ -331,12 +369,12 @@ async fn update_user( /// -H 'Authorization: Bearer ' /// ``` #[post("/user/")] -#[protect("Role::Admin", ty = "Role")] +#[protect("Role::GlobalAdmin", ty = "Role")] async fn add_user( pool: web::Data>, data: web::Json, ) -> Result { - match handles::insert_user(&pool.into_inner(), data.into_inner()).await { + match handles::insert_user(&pool, data.into_inner()).await { Ok(_) => Ok("Add User Success"), Err(e) => { error!("{e}"); @@ -351,13 +389,13 @@ async fn add_user( /// curl -X GET 'http://127.0.0.1:8787/api/user/2' -H 'Content-Type: application/json' \ /// -H 'Authorization: Bearer ' /// ``` -#[delete("/user/{name}")] -#[protect("Role::Admin", ty = "Role")] +#[delete("/user/{id}")] +#[protect("Role::GlobalAdmin", ty = "Role")] async fn remove_user( pool: web::Data>, - name: web::Path, + id: web::Path, ) -> Result { - match handles::delete_user(&pool.into_inner(), &name).await { + match handles::delete_user(&pool, *id).await { Ok(_) => return Ok("Delete user success"), Err(e) => { error!("{e}"); @@ -366,7 +404,7 @@ async fn remove_user( } } -/// #### ffpapi Settings +/// #### Settings /// /// **Get Settings from Channel** /// @@ -381,19 +419,23 @@ async fn remove_user( /// "id": 1, /// "name": "Channel 1", /// "preview_url": "http://localhost/live/preview.m3u8", -/// "config_path": "/etc/ffplayout/ffplayout.toml", /// "extra_extensions": "jpg,jpeg,png", -/// "service": "ffplayout.service", /// "utc_offset": "+120" /// } /// ``` #[get("/channel/{id}")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] async fn get_channel( pool: web::Data>, id: web::Path, + role: AuthDetails, + user: web::ReqData, ) -> Result { - if let Ok(channel) = handles::select_channel(&pool.into_inner(), &id).await { + if let Ok(channel) = handles::select_channel(&pool, &id).await { return Ok(web::Json(channel)); } @@ -406,9 +448,15 @@ async fn get_channel( /// curl -X GET http://127.0.0.1:8787/api/channels -H "Authorization: Bearer " /// ``` #[get("/channels")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] -async fn get_all_channels(pool: web::Data>) -> Result { - if let Ok(channel) = handles::select_all_channels(&pool.into_inner()).await { +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role" +)] +async fn get_all_channels( + pool: web::Data>, + user: web::ReqData, +) -> Result { + if let Ok(channel) = handles::select_related_channels(&pool, Some(user.id)).await { return Ok(web::Json(channel)); } @@ -419,17 +467,24 @@ async fn get_all_channels(pool: web::Data>) -> Result" /// ``` #[patch("/channel/{id}")] -#[protect("Role::Admin", ty = "Role")] +#[protect( + "Role::GlobalAdmin", + "Role::ChannelAdmin", + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] async fn patch_channel( pool: web::Data>, id: web::Path, data: web::Json, + role: AuthDetails, + user: web::ReqData, ) -> Result { - if handles::update_channel(&pool.into_inner(), *id, data.into_inner()) + if handles::update_channel(&pool, *id, data.into_inner()) .await .is_ok() { @@ -443,16 +498,25 @@ async fn patch_channel( /// /// ```BASH /// curl -X POST http://127.0.0.1:8787/api/channel/ -H "Content-Type: application/json" \ -/// -d '{ "name": "Channel 2", "preview_url": "http://localhost/live/channel2.m3u8", "config_path": "/etc/ffplayout/channel2.toml", "extra_extensions": "jpg,jpeg,png", "service": "ffplayout@channel2.service" }' \ +/// -d '{ "name": "Channel 2", "preview_url": "http://localhost/live/channel2.m3u8", "extra_extensions": "jpg,jpeg,png" }' \ /// -H "Authorization: Bearer " /// ``` #[post("/channel/")] -#[protect("Role::Admin", ty = "Role")] +#[protect("Role::GlobalAdmin", ty = "Role")] async fn add_channel( pool: web::Data>, data: web::Json, + controllers: web::Data>, + queue: web::Data>>>>, ) -> Result { - match create_channel(&pool.into_inner(), data.into_inner()).await { + match create_channel( + &pool, + controllers.into_inner(), + queue.into_inner(), + data.into_inner(), + ) + .await + { Ok(c) => Ok(web::Json(c)), Err(e) => Err(e), } @@ -464,12 +528,17 @@ async fn add_channel( /// curl -X DELETE http://127.0.0.1:8787/api/channel/2 -H "Authorization: Bearer " /// ``` #[delete("/channel/{id}")] -#[protect("Role::Admin", ty = "Role")] +#[protect("Role::GlobalAdmin", ty = "Role")] async fn remove_channel( pool: web::Data>, id: web::Path, + controllers: web::Data>, + queue: web::Data>>>>, ) -> Result { - if delete_channel(&pool.into_inner(), *id).await.is_ok() { + if delete_channel(&pool, *id, controllers.into_inner(), queue.into_inner()) + .await + .is_ok() + { return Ok("Delete Channel Success"); } @@ -478,27 +547,85 @@ async fn remove_channel( /// #### ffplayout Config /// +/// **Get Advanced Config** +/// +/// ```BASH +/// curl -X GET http://127.0.0.1:8787/api/playout/advanced/1 -H 'Authorization: Bearer ' +/// ``` +/// +/// Response is a JSON object +#[get("/playout/advanced/{id}")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] +async fn get_advanced_config( + id: web::Path, + controllers: web::Data>, + role: AuthDetails, + user: web::ReqData, +) -> Result { + let manager = controllers.lock().unwrap().get(*id).unwrap(); + let config = manager.config.lock().unwrap().advanced.clone(); + + Ok(web::Json(config)) +} + +/// **Update Advanced Config** +/// +/// ```BASH +/// curl -X PUT http://127.0.0.1:8787/api/playout/advanced/1 -H "Content-Type: application/json" \ +/// -d { } -H 'Authorization: Bearer ' +/// ``` +#[put("/playout/advanced/{id}")] +#[protect( + "Role::GlobalAdmin", + "Role::ChannelAdmin", + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] +async fn update_advanced_config( + pool: web::Data>, + id: web::Path, + data: web::Json, + controllers: web::Data>, + role: AuthDetails, + user: web::ReqData, +) -> Result { + let manager = controllers.lock().unwrap().get(*id).unwrap(); + + handles::update_advanced_configuration(&pool, *id, data.clone()).await?; + let new_config = PlayoutConfig::new(&pool, *id).await; + + manager.update_config(new_config); + + Ok(web::Json("Update success")) +} + /// **Get Config** /// /// ```BASH /// curl -X GET http://127.0.0.1:8787/api/playout/config/1 -H 'Authorization: Bearer ' /// ``` /// -/// Response is a JSON object from the ffplayout.toml +/// Response is a JSON object #[get("/playout/config/{id}")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] async fn get_playout_config( - pool: web::Data>, id: web::Path, - _details: AuthDetails, + controllers: web::Data>, + role: AuthDetails, + user: web::ReqData, ) -> Result { - if let Ok(channel) = handles::select_channel(&pool.into_inner(), &id).await { - if let Ok(config) = read_playout_config(&channel.config_path) { - return Ok(web::Json(config)); - } - }; + let manager = controllers.lock().unwrap().get(*id).unwrap(); + let config = manager.config.lock().unwrap().clone(); - Err(ServiceError::InternalServerError) + Ok(web::Json(config)) } /// **Update Config** @@ -508,20 +635,28 @@ async fn get_playout_config( /// -d { } -H 'Authorization: Bearer ' /// ``` #[put("/playout/config/{id}")] -#[protect("Role::Admin", ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] async fn update_playout_config( pool: web::Data>, id: web::Path, data: web::Json, + controllers: web::Data>, + role: AuthDetails, + user: web::ReqData, ) -> Result { - if let Ok(channel) = handles::select_channel(&pool.into_inner(), &id).await { - let toml_string = toml_edit::ser::to_string_pretty(&data)?; - fs::write(&channel.config_path, toml_string).await?; + let manager = controllers.lock().unwrap().get(*id).unwrap(); + let config_id = manager.config.lock().unwrap().general.id; - return Ok("Update playout config success."); - }; + handles::update_configuration(&pool, config_id, data.clone()).await?; + let new_config = PlayoutConfig::new(&pool, *id).await; - Err(ServiceError::InternalServerError) + manager.update_config(new_config); + + Ok(web::Json("Update success")) } /// #### Text Presets @@ -535,12 +670,18 @@ async fn update_playout_config( /// -H 'Authorization: Bearer ' /// ``` #[get("/presets/{id}")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] async fn get_presets( pool: web::Data>, id: web::Path, + role: AuthDetails, + user: web::ReqData, ) -> Result { - if let Ok(presets) = handles::select_presets(&pool.into_inner(), *id).await { + if let Ok(presets) = handles::select_presets(&pool, *id).await { return Ok(web::Json(presets)); } @@ -555,13 +696,19 @@ async fn get_presets( /// -H 'Authorization: Bearer ' /// ``` #[put("/presets/{id}")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] async fn update_preset( pool: web::Data>, id: web::Path, data: web::Json, + role: AuthDetails, + user: web::ReqData, ) -> Result { - if handles::update_preset(&pool.into_inner(), &id, data.into_inner()) + if handles::update_preset(&pool, &id, data.into_inner()) .await .is_ok() { @@ -574,17 +721,24 @@ async fn update_preset( /// **Add new Preset** /// /// ```BASH -/// curl -X POST http://127.0.0.1:8787/api/presets/ -H 'Content-Type: application/json' \ +/// curl -X POST http://127.0.0.1:8787/api/presets/1/ -H 'Content-Type: application/json' \ /// -d '{ "name": "", "text": "TEXT>", "x": "", "y": "", "fontsize": 24, "line_spacing": 4, "fontcolor": "#ffffff", "box": 1, "boxcolor": "#000000", "boxborderw": 4, "alpha": 1.0, "channel_id": 1 }' \ /// -H 'Authorization: Bearer ' /// ``` -#[post("/presets/")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[post("/presets/{id}/")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] async fn add_preset( pool: web::Data>, + id: web::Path, data: web::Json, + role: AuthDetails, + user: web::ReqData, ) -> Result { - if handles::insert_preset(&pool.into_inner(), data.into_inner()) + if handles::insert_preset(&pool, data.into_inner()) .await .is_ok() { @@ -601,15 +755,18 @@ async fn add_preset( /// -H 'Authorization: Bearer ' /// ``` #[delete("/presets/{id}")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] async fn delete_preset( pool: web::Data>, id: web::Path, + role: AuthDetails, + user: web::ReqData, ) -> Result { - if handles::delete_preset(&pool.into_inner(), &id) - .await - .is_ok() - { + if handles::delete_preset(&pool, &id).await.is_ok() { return Ok("Delete preset Success"); } @@ -632,16 +789,22 @@ async fn delete_preset( /// -d '{"text": "Hello from ffplayout", "x": "(w-text_w)/2", "y": "(h-text_h)/2", fontsize": "24", "line_spacing": "4", "fontcolor": "#ffffff", "box": "1", "boxcolor": "#000000", "boxborderw": "4", "alpha": "1.0"}' /// ``` #[post("/control/{id}/text/")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] pub async fn send_text_message( - pool: web::Data>, id: web::Path, - data: web::Json>, + data: web::Json, + controllers: web::Data>, + role: AuthDetails, + user: web::ReqData, ) -> Result { - let (config, _) = playout_config(&pool.clone().into_inner(), &id).await?; + let manager = controllers.lock().unwrap().get(*id).unwrap(); - match send_message(&config, data.into_inner()).await { - Ok(res) => Ok(res.text().await.unwrap_or_else(|_| "Success".into())), + match send_message(manager, data.into_inner()).await { + Ok(res) => Ok(web::Json(res)), Err(e) => Err(e), } } @@ -657,16 +820,23 @@ pub async fn send_text_message( /// -d '{ "command": "reset" }' -H 'Authorization: Bearer ' /// ``` #[post("/control/{id}/playout/")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] pub async fn control_playout( pool: web::Data>, id: web::Path, control: web::Json, + controllers: web::Data>, + role: AuthDetails, + user: web::ReqData, ) -> Result { - let (config, _) = playout_config(&pool.clone().into_inner(), &id).await?; + let manager = controllers.lock().unwrap().get(*id).unwrap(); - match control_state(&config, &control.control).await { - Ok(res) => Ok(res.text().await.unwrap_or_else(|_| "Success".into())), + match control_state(&pool, manager, &control.control).await { + Ok(res) => Ok(web::Json(res)), Err(e) => Err(e), } } @@ -696,56 +866,21 @@ pub async fn control_playout( /// } /// ``` #[get("/control/{id}/media/current")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] pub async fn media_current( - pool: web::Data>, id: web::Path, + controllers: web::Data>, + role: AuthDetails, + user: web::ReqData, ) -> Result { - let (config, _) = playout_config(&pool.clone().into_inner(), &id).await?; + let manager = controllers.lock().unwrap().get(*id).unwrap(); + let media_map = get_data_map(&manager); - match media_info(&config, "current".into()).await { - Ok(res) => Ok(res.text().await.unwrap_or_else(|_| "Success".into())), - Err(e) => Err(e), - } -} - -/// **Get next Clip** -/// -/// ```BASH -/// curl -X GET http://127.0.0.1:8787/api/control/1/media/next -H 'Authorization: Bearer ' -/// ``` -#[get("/control/{id}/media/next")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] -pub async fn media_next( - pool: web::Data>, - id: web::Path, -) -> Result { - let (config, _) = playout_config(&pool.clone().into_inner(), &id).await?; - - match media_info(&config, "next".into()).await { - Ok(res) => Ok(res.text().await.unwrap_or_else(|_| "Success".into())), - Err(e) => Err(e), - } -} - -/// **Get last Clip** -/// -/// ```BASH -/// curl -X GET http://127.0.0.1:8787/api/control/1/media/last -/// -H 'Content-Type: application/json' -H 'Authorization: Bearer ' -/// ``` -#[get("/control/{id}/media/last")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] -pub async fn media_last( - pool: web::Data>, - id: web::Path, -) -> Result { - let (config, _) = playout_config(&pool.clone().into_inner(), &id).await?; - - match media_info(&config, "last".into()).await { - Ok(res) => Ok(res.text().await.unwrap_or_else(|_| "Success".into())), - Err(e) => Err(e), - } + Ok(web::Json(media_map)) } /// #### ffplayout Process Control @@ -762,23 +897,43 @@ pub async fn media_last( /// -d '{"command": "start"}' /// ``` #[post("/control/{id}/process/")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] pub async fn process_control( - pool: web::Data>, id: web::Path, proc: web::Json, - engine_process: web::Data, + controllers: web::Data>, + role: AuthDetails, + user: web::ReqData, ) -> Result { - let (config, _) = playout_config(&pool.clone().into_inner(), &id).await?; + let manager = controllers.lock().unwrap().get(*id).unwrap(); + manager.list_init.store(true, Ordering::SeqCst); - control_service( - &pool.into_inner(), - &config, - *id, - &proc.command, - Some(engine_process), - ) - .await + match proc.into_inner().command { + ProcessCtl::Status => { + if manager.is_alive.load(Ordering::SeqCst) { + return Ok(web::Json("active")); + } else { + return Ok(web::Json("not running")); + } + } + 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("Success")) } /// #### ffplayout Playlist Operations @@ -790,13 +945,22 @@ pub async fn process_control( /// -H 'Content-Type: application/json' -H 'Authorization: Bearer ' /// ``` #[get("/playlist/{id}")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] pub async fn get_playlist( - pool: web::Data>, id: web::Path, obj: web::Query, + controllers: web::Data>, + role: AuthDetails, + user: web::ReqData, ) -> Result { - match read_playlist(&pool.into_inner(), *id, obj.date.clone()).await { + let manager = controllers.lock().unwrap().get(*id).unwrap(); + let config = manager.config.lock().unwrap().clone(); + + match read_playlist(&config, obj.date.clone()).await { Ok(playlist) => Ok(web::Json(playlist)), Err(e) => Err(e), } @@ -810,13 +974,22 @@ pub async fn get_playlist( /// --data "{}" /// ``` #[post("/playlist/{id}/")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] pub async fn save_playlist( - pool: web::Data>, id: web::Path, data: web::Json, + controllers: web::Data>, + role: AuthDetails, + user: web::ReqData, ) -> Result { - match write_playlist(&pool.into_inner(), *id, data.into_inner()).await { + let manager = controllers.lock().unwrap().get(*id).unwrap(); + let config = manager.config.lock().unwrap().clone(); + + match write_playlist(&config, data.into_inner()).await { Ok(res) => Ok(web::Json(res)), Err(e) => Err(e), } @@ -841,32 +1014,45 @@ pub async fn save_playlist( /// {"start": "10:00:00", "duration": "14:00:00", "shuffle": false, "paths": ["path/3", "path/4"]}]}}' /// ``` #[post("/playlist/{id}/generate/{date}")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(¶ms.0) || role.has_authority(&Role::GlobalAdmin)" +)] pub async fn gen_playlist( - pool: web::Data>, params: web::Path<(i32, String)>, data: Option>, + controllers: web::Data>, + role: AuthDetails, + user: web::ReqData, ) -> Result { - let (mut config, channel) = playout_config(&pool.into_inner(), ¶ms.0).await?; - config.general.generate = Some(vec![params.1.clone()]); + let manager = controllers.lock().unwrap().get(params.0).unwrap(); + manager.config.lock().unwrap().general.generate = Some(vec![params.1.clone()]); + let storage_path = manager.config.lock().unwrap().global.storage_path.clone(); if let Some(obj) = data { if let Some(paths) = &obj.paths { let mut path_list = vec![]; for path in paths { - let (p, _, _) = norm_abs_path(&config.storage.path, path)?; + let (p, _, _) = norm_abs_path(&storage_path, path)?; path_list.push(p); } - config.storage.paths = path_list; + manager.config.lock().unwrap().storage.paths = path_list; } - config.general.template.clone_from(&obj.template); + manager + .config + .lock() + .unwrap() + .general + .template + .clone_from(&obj.template); } - match generate_playlist(config.to_owned(), channel.name).await { + match generate_playlist(manager) { Ok(playlist) => Ok(web::Json(playlist)), Err(e) => Err(e), } @@ -879,12 +1065,21 @@ pub async fn gen_playlist( /// -H 'Content-Type: application/json' -H 'Authorization: Bearer ' /// ``` #[delete("/playlist/{id}/{date}")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(¶ms.0) || role.has_authority(&Role::GlobalAdmin)" +)] pub async fn del_playlist( - pool: web::Data>, params: web::Path<(i32, String)>, + controllers: web::Data>, + role: AuthDetails, + user: web::ReqData, ) -> Result { - match delete_playlist(&pool.into_inner(), params.0, ¶ms.1).await { + let manager = controllers.lock().unwrap().get(params.0).unwrap(); + let config = manager.config.lock().unwrap().clone(); + + match delete_playlist(&config, ¶ms.1).await { Ok(m) => Ok(web::Json(m)), Err(e) => Err(e), } @@ -892,20 +1087,25 @@ pub async fn del_playlist( /// ### Log file /// -/// **Read Log Life** +/// **Read Log File** /// /// ```BASH -/// curl -X GET http://127.0.0.1:8787/api/log/1 +/// curl -X GET http://127.0.0.1:8787/api/log/1?date=2022-06-20 /// -H 'Content-Type: application/json' -H 'Authorization: Bearer ' /// ``` #[get("/log/{id}")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] pub async fn get_log( - pool: web::Data>, id: web::Path, log: web::Query, + role: AuthDetails, + user: web::ReqData, ) -> Result { - read_log_file(&pool.into_inner(), &id, &log.date).await + read_log_file(&id, &log.date).await } /// ### File Operations @@ -917,13 +1117,23 @@ pub async fn get_log( /// -d '{ "source": "/" }' -H 'Authorization: Bearer ' /// ``` #[post("/file/{id}/browse/")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] pub async fn file_browser( - pool: web::Data>, id: web::Path, data: web::Json, + controllers: web::Data>, + role: AuthDetails, + user: web::ReqData, ) -> Result { - match browser(&pool.into_inner(), *id, &data.into_inner()).await { + let manager = controllers.lock().unwrap().get(*id).unwrap(); + let channel = manager.channel.lock().unwrap().clone(); + let config = manager.config.lock().unwrap().clone(); + + match browser(&config, &channel, &data.into_inner()).await { Ok(obj) => Ok(web::Json(obj)), Err(e) => Err(e), } @@ -936,13 +1146,22 @@ pub async fn file_browser( /// -d '{"source": ""}' -H 'Authorization: Bearer ' /// ``` #[post("/file/{id}/create-folder/")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] pub async fn add_dir( - pool: web::Data>, id: web::Path, data: web::Json, + controllers: web::Data>, + role: AuthDetails, + user: web::ReqData, ) -> Result { - create_directory(&pool.into_inner(), *id, &data.into_inner()).await + let manager = controllers.lock().unwrap().get(*id).unwrap(); + let config = manager.config.lock().unwrap().clone(); + + create_directory(&config, &data.into_inner()).await } /// **Rename File** @@ -952,13 +1171,22 @@ pub async fn add_dir( /// -d '{"source": "", "target": ""}' -H 'Authorization: Bearer ' /// ``` #[post("/file/{id}/rename/")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] pub async fn move_rename( - pool: web::Data>, id: web::Path, data: web::Json, + controllers: web::Data>, + role: AuthDetails, + user: web::ReqData, ) -> Result { - match rename_file(&pool.into_inner(), *id, &data.into_inner()).await { + let manager = controllers.lock().unwrap().get(*id).unwrap(); + let config = manager.config.lock().unwrap().clone(); + + match rename_file(&config, &data.into_inner()).await { Ok(obj) => Ok(web::Json(obj)), Err(e) => Err(e), } @@ -971,13 +1199,22 @@ pub async fn move_rename( /// -d '{"source": ""}' -H 'Authorization: Bearer ' /// ``` #[post("/file/{id}/remove/")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] pub async fn remove( - pool: web::Data>, id: web::Path, data: web::Json, + controllers: web::Data>, + role: AuthDetails, + user: web::ReqData, ) -> Result { - match remove_file_or_folder(&pool.into_inner(), *id, &data.into_inner().source).await { + let manager = controllers.lock().unwrap().get(*id).unwrap(); + let config = manager.config.lock().unwrap().clone(); + + match remove_file_or_folder(&config, &data.into_inner().source).await { Ok(obj) => Ok(web::Json(obj)), Err(e) => Err(e), } @@ -989,15 +1226,25 @@ pub async fn remove( /// curl -X PUT http://127.0.0.1:8787/api/file/1/upload/ -H 'Authorization: Bearer ' /// -F "file=@file.mp4" /// ``` +#[allow(clippy::too_many_arguments)] #[put("/file/{id}/upload/")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] async fn save_file( - pool: web::Data>, id: web::Path, req: HttpRequest, payload: Multipart, obj: web::Query, + controllers: web::Data>, + role: AuthDetails, + user: web::ReqData, ) -> Result { + let manager = controllers.lock().unwrap().get(*id).unwrap(); + let config = manager.config.lock().unwrap().clone(); + let size: u64 = req .headers() .get("content-length") @@ -1005,7 +1252,7 @@ async fn save_file( .and_then(|cls| cls.parse().ok()) .unwrap_or(0); - upload(&pool.into_inner(), *id, size, payload, &obj.path, false).await + upload(&config, size, payload, &obj.path, false).await } /// **Get File** @@ -1017,12 +1264,13 @@ async fn save_file( /// ``` #[get("/file/{id}/{filename:.*}")] async fn get_file( - pool: web::Data>, req: HttpRequest, + controllers: web::Data>, ) -> Result { let id: i32 = req.match_info().query("id").parse()?; - let (config, _) = playout_config(&pool.into_inner(), &id).await?; - let storage_path = config.storage.path; + let manager = controllers.lock().unwrap().get(id).unwrap(); + let config = manager.config.lock().unwrap(); + let storage_path = config.global.storage_path.clone(); let file_path = req.match_info().query("filename"); let (path, _, _) = norm_abs_path(&storage_path, file_path)?; let file = actix_files::NamedFile::open(path)?; @@ -1040,20 +1288,28 @@ async fn get_file( /// Can be used for HLS Playlist and other static files in public folder /// /// ```BASH -/// curl -X GET http://127.0.0.1:8787/live/stream.m3u8 +/// curl -X GET http://127.0.0.1:8787/live/1/stream.m3u8 /// ``` -#[get("/{public:((live|preview|public).*|.*(ts|m3u8))}")] -async fn get_public(public: web::Path) -> Result { +#[get("/{public:live|preview|public}/{id}/{file_stem:.*}")] +async fn get_public( + path: web::Path<(String, i32, String)>, + controllers: web::Data>, +) -> Result { + let (public, id, file_stem) = path.into_inner(); let public_path = public_path(); - let absolute_path = if public_path.is_absolute() { + let absolute_path = if file_stem.ends_with(".ts") || file_stem.ends_with(".m3u8") { + let manager = controllers.lock().unwrap().get(id).unwrap(); + let config = manager.config.lock().unwrap(); + config.global.hls_path.join(public) + } else if public_path.is_absolute() { public_path.to_path_buf() } else { env::current_dir()?.join(public_path) } .clean(); - let path = absolute_path.join(public.as_str()); + let path = absolute_path.join(file_stem.as_str()); let file = actix_files::NamedFile::open(path)?; Ok(file @@ -1073,20 +1329,28 @@ async fn get_public(public: web::Path) -> Result' /// -F "file=@list.m3u" /// ``` +#[allow(clippy::too_many_arguments)] #[put("/file/{id}/import/")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] async fn import_playlist( - pool: web::Data>, id: web::Path, req: HttpRequest, payload: Multipart, obj: web::Query, + controllers: web::Data>, + role: AuthDetails, + user: web::ReqData, ) -> Result { + let manager = controllers.lock().unwrap().get(*id).unwrap(); + let channel_name = manager.channel.lock().unwrap().name.clone(); + let config = manager.config.lock().unwrap().clone(); let file = obj.file.file_name().unwrap_or_default(); let path = env::temp_dir().join(file); let path_clone = path.clone(); - let (config, _) = playout_config(&pool.clone().into_inner(), &id).await?; - let channel = handles::select_channel(&pool.clone().into_inner(), &id).await?; let size: u64 = req .headers() .get("content-length") @@ -1094,12 +1358,11 @@ async fn import_playlist( .and_then(|cls| cls.parse().ok()) .unwrap_or(0); - upload(&pool.into_inner(), *id, size, payload, &path, true).await?; + upload(&config, size, payload, &path, true).await?; - let response = task::spawn_blocking(move || { - import_file(&config, &obj.date, Some(channel.name), &path_clone) - }) - .await??; + let response = + web::block(move || import_file(&config, &obj.date, Some(channel_name), &path_clone)) + .await??; fs::remove_file(path).await?; @@ -1129,13 +1392,21 @@ async fn import_playlist( /// -H 'Authorization: Bearer ' /// ``` #[get("/program/{id}/")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] async fn get_program( - pool: web::Data>, id: web::Path, obj: web::Query, + controllers: web::Data>, + role: AuthDetails, + user: web::ReqData, ) -> Result { - let (config, _) = playout_config(&pool.clone().into_inner(), &id).await?; + let manager = controllers.lock().unwrap().get(*id).unwrap(); + let config = manager.config.lock().unwrap().clone(); + let id = config.general.channel_id; let start_sec = config.playlist.start_sec.unwrap(); let mut days = 0; let mut program = vec![]; @@ -1153,21 +1424,23 @@ async fn get_program( days = 1; } - let date_range = get_date_range(&vec_strings![ - (after - TimeDelta::try_days(days).unwrap_or_default()).format("%Y-%m-%d"), - "-", - before.format("%Y-%m-%d") - ]); + let date_range = get_date_range( + id, + &vec_strings![ + (after - TimeDelta::try_days(days).unwrap_or_default()).format("%Y-%m-%d"), + "-", + before.format("%Y-%m-%d") + ], + ); for date in date_range { - let conn = pool.clone().into_inner(); let mut naive = NaiveDateTime::parse_from_str( &format!("{date} {}", sec_to_time(start_sec)), "%Y-%m-%d %H:%M:%S%.3f", ) .unwrap(); - let playlist = match read_playlist(&conn, *id, date.clone()).await { + let playlist = match read_playlist(&config, date.clone()).await { Ok(p) => p, Err(e) => { error!("Error in Playlist from {date}: {e}"); @@ -1217,12 +1490,19 @@ async fn get_program( /// -H 'Content-Type: application/json' -H 'Authorization: Bearer ' /// ``` #[get("/system/{id}")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role", + expr = "user.channels.contains(&*id) || role.has_authority(&Role::GlobalAdmin)" +)] pub async fn get_system_stat( - pool: web::Data>, id: web::Path, + controllers: web::Data>, + role: AuthDetails, + user: web::ReqData, ) -> Result { - let (config, _) = playout_config(&pool.clone().into_inner(), &id).await?; + let manager = controllers.lock().unwrap().get(*id).unwrap(); + let config = manager.config.lock().unwrap().clone(); let stat = web::block(move || system::stat(config)).await?; diff --git a/ffplayout/src/db/handles.rs b/ffplayout/src/db/handles.rs new file mode 100644 index 00000000..9cfa90b0 --- /dev/null +++ b/ffplayout/src/db/handles.rs @@ -0,0 +1,479 @@ +use argon2::{ + password_hash::{rand_core::OsRng, SaltString}, + Argon2, PasswordHasher, +}; + +use log::*; +use rand::{distributions::Alphanumeric, Rng}; +use sqlx::{sqlite::SqliteQueryResult, Pool, Row, Sqlite}; +use tokio::task; + +use super::models::{AdvancedConfiguration, Configuration}; +use crate::db::models::{Channel, GlobalSettings, Role, TextPreset, User}; +use crate::utils::{advanced_config::AdvancedConfig, config::PlayoutConfig, local_utc_offset}; + +pub async fn db_migrate(conn: &Pool) -> Result<&'static str, Box> { + match sqlx::migrate!("../migrations").run(conn).await { + Ok(_) => info!("Database migration successfully"), + Err(e) => panic!("{e}"), + } + + if select_global(conn).await.is_err() { + let secret: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(80) + .map(char::from) + .collect(); + + 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);"; + + sqlx::query(query).bind(secret).execute(conn).await?; + } + + Ok("Database migrated!") +} + +pub async fn select_global(conn: &Pool) -> Result { + let query = "SELECT id, secret, hls_path, logging_path, playlist_path, storage_path, shared_storage FROM global WHERE id = 1"; + + sqlx::query_as(query).fetch_one(conn).await +} + +pub async fn update_global( + conn: &Pool, + global: GlobalSettings, +) -> Result { + let query = "UPDATE global SET hls_path = $2, playlist_path = $3, storage_path = $4, logging_path = $5, shared_storage = $6 WHERE id = 1"; + + sqlx::query(query) + .bind(global.id) + .bind(global.hls_path) + .bind(global.playlist_path) + .bind(global.storage_path) + .bind(global.logging_path) + .bind(global.shared_storage) + .execute(conn) + .await +} + +pub async fn select_channel(conn: &Pool, id: &i32) -> Result { + 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_related_channels( + conn: &Pool, + user_id: Option, +) -> Result, sqlx::Error> { + let query = match user_id { + Some(id) => format!( + "SELECT c.id, c.name, c.preview_url, c.extra_extensions, c.active, c.last_date, c.time_shift FROM channels c + left join user_channels uc on uc.channel_id = c.id + left join user u on u.id = uc.user_id + WHERE u.id = {id} ORDER BY c.id ASC;" + ), + None => "SELECT * FROM channels ORDER BY id ASC;".to_string(), + }; + + let mut results: Vec = 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, + id: i32, + channel: Channel, +) -> Result { + let query = + "UPDATE channels SET name = $2, preview_url = $3, extra_extensions = $4 WHERE id = $1"; + + sqlx::query(query) + .bind(id) + .bind(channel.name) + .bind(channel.preview_url) + .bind(channel.extra_extensions) + .execute(conn) + .await +} + +pub async fn update_stat( + conn: &Pool, + id: i32, + last_date: String, + time_shift: f64, +) -> Result { + let query = "UPDATE channels SET last_date = $2, time_shift = $3 WHERE id = $1"; + + sqlx::query(query) + .bind(id) + .bind(last_date) + .bind(time_shift) + .execute(conn) + .await +} + +pub async fn update_player( + conn: &Pool, + id: i32, + active: bool, +) -> Result { + 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, channel: Channel) -> Result { + let query = "INSERT INTO channels (name, preview_url, extra_extensions) VALUES($1, $2, $3)"; + let result = sqlx::query(query) + .bind(channel.name) + .bind(channel.preview_url) + .bind(channel.extra_extensions) + .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, + id: &i32, +) -> Result { + let query = "DELETE FROM channels WHERE id = $1"; + + sqlx::query(query).bind(id).execute(conn).await +} + +pub async fn select_last_channel(conn: &Pool) -> Result { + let query = "select seq from sqlite_sequence WHERE name = 'channel';"; + + sqlx::query_scalar(query).fetch_one(conn).await +} + +pub async fn select_configuration( + conn: &Pool, + channel: i32, +) -> Result { + let query = "SELECT * FROM configurations WHERE channel_id = $1"; + + sqlx::query_as(query).bind(channel).fetch_one(conn).await +} + +pub async fn insert_configuration( + conn: &Pool, + channel_id: i32, + output_param: String, +) -> Result { + let query = "INSERT INTO configurations (channel_id, output_param) VALUES($1, $2)"; + + sqlx::query(query) + .bind(channel_id) + .bind(output_param) + .execute(conn) + .await +} + +pub async fn update_configuration( + conn: &Pool, + id: i32, + config: PlayoutConfig, +) -> Result { + let query = "UPDATE configurations SET general_stop_threshold = $2, mail_subject = $3, mail_smtp = $4, mail_addr = $5, mail_pass = $6, mail_recipient = $7, mail_starttls = $8, mail_level = $9, mail_interval = $10, logging_ffmpeg_level = $11, logging_ingest_level = $12, logging_detect_silence = $13, logging_ignore = $14, processing_mode = $15, processing_audio_only = $16, processing_copy_audio = $17, processing_copy_video = $18, processing_width = $19, processing_height = $20, processing_aspect = $21, processing_fps = $22, processing_add_logo = $23, processing_logo = $24, processing_logo_scale = $25, processing_logo_opacity = $26, processing_logo_position = $27, processing_audio_tracks = $28, processing_audio_track_index = $29, processing_audio_channels = $30, processing_volume = $31, processing_filter = $32, ingest_enable = $33, ingest_param = $34, ingest_filter = $35, playlist_day_start = $36, playlist_length = $37, playlist_infinit = $38, storage_filler = $39, storage_extensions = $40, storage_shuffle = $41, text_add = $42, text_from_filename = $43, text_font = $44, text_style = $45, text_regex = $46, task_enable = $47, task_path = $48, output_mode = $49, output_param = $50 WHERE id = $1"; + + sqlx::query(query) + .bind(id) + .bind(config.general.stop_threshold) + .bind(config.mail.subject) + .bind(config.mail.smtp_server) + .bind(config.mail.sender_addr) + .bind(config.mail.sender_pass) + .bind(config.mail.recipient) + .bind(config.mail.starttls) + .bind(config.mail.mail_level.as_str()) + .bind(config.mail.interval) + .bind(config.logging.ffmpeg_level) + .bind(config.logging.ingest_level) + .bind(config.logging.detect_silence) + .bind(config.logging.ignore_lines.join(";")) + .bind(config.processing.mode.to_string()) + .bind(config.processing.audio_only) + .bind(config.processing.copy_audio) + .bind(config.processing.copy_video) + .bind(config.processing.width) + .bind(config.processing.height) + .bind(config.processing.aspect) + .bind(config.processing.fps) + .bind(config.processing.add_logo) + .bind(config.processing.logo) + .bind(config.processing.logo_scale) + .bind(config.processing.logo_opacity) + .bind(config.processing.logo_position) + .bind(config.processing.audio_tracks) + .bind(config.processing.audio_track_index) + .bind(config.processing.audio_channels) + .bind(config.processing.volume) + .bind(config.processing.custom_filter) + .bind(config.ingest.enable) + .bind(config.ingest.input_param) + .bind(config.ingest.custom_filter) + .bind(config.playlist.day_start) + .bind(config.playlist.length) + .bind(config.playlist.infinit) + .bind(config.storage.filler.to_string_lossy().to_string()) + .bind(config.storage.extensions.join(";")) + .bind(config.storage.shuffle) + .bind(config.text.add_text) + .bind(config.text.text_from_filename) + .bind(config.text.fontfile) + .bind(config.text.style) + .bind(config.text.regex) + .bind(config.task.enable) + .bind(config.task.path.to_string_lossy().to_string()) + .bind(config.output.mode.to_string()) + .bind(config.output.output_param) + .execute(conn) + .await +} + +pub async fn insert_advanced_configuration( + conn: &Pool, + channel_id: i32, +) -> Result { + let query = "INSERT INTO advanced_configurations (channel_id) VALUES($1)"; + + sqlx::query(query).bind(channel_id).execute(conn).await +} + +pub async fn update_advanced_configuration( + conn: &Pool, + channel_id: i32, + config: AdvancedConfig, +) -> Result { + let query = "UPDATE advanced_configurations SET decoder_input_param = $2, decoder_output_param = $3, encoder_input_param = $4, ingest_input_param = $5, filter_deinterlace = $6, filter_pad_scale_w = $7, filter_pad_scale_h = $8, filter_pad_video = $9, filter_fps = $10, filter_scale = $11, filter_set_dar = $12, filter_fade_in = $13, filter_fade_out = $14, filter_overlay_logo_scale = $15, filter_overlay_logo_fade_in = $16, filter_overlay_logo_fade_out = $17, filter_overlay_logo = $18, filter_tpad = $19, filter_drawtext_from_file = $20, filter_drawtext_from_zmq = $21, filter_aevalsrc = $22, filter_afade_in = $23, filter_afade_out = $24, filter_apad = $25, filter_volume = $26, filter_split = $27 WHERE channel_id = $1"; + + sqlx::query(query) + .bind(channel_id) + .bind(config.decoder.input_param) + .bind(config.decoder.output_param) + .bind(config.encoder.input_param) + .bind(config.ingest.input_param) + .bind(config.filter.deinterlace) + .bind(config.filter.pad_scale_w) + .bind(config.filter.pad_scale_h) + .bind(config.filter.pad_video) + .bind(config.filter.fps) + .bind(config.filter.scale) + .bind(config.filter.set_dar) + .bind(config.filter.fade_in) + .bind(config.filter.fade_out) + .bind(config.filter.overlay_logo_scale) + .bind(config.filter.overlay_logo_fade_in) + .bind(config.filter.overlay_logo_fade_out) + .bind(config.filter.overlay_logo) + .bind(config.filter.tpad) + .bind(config.filter.drawtext_from_file) + .bind(config.filter.drawtext_from_zmq) + .bind(config.filter.aevalsrc) + .bind(config.filter.afade_in) + .bind(config.filter.afade_out) + .bind(config.filter.apad) + .bind(config.filter.volume) + .bind(config.filter.split) + .execute(conn) + .await +} + +pub async fn select_advanced_configuration( + conn: &Pool, + channel: i32, +) -> Result { + let query = "SELECT * FROM advanced_configurations WHERE channel_id = $1"; + + sqlx::query_as(query).bind(channel).fetch_one(conn).await +} + +pub async fn select_role(conn: &Pool, id: &i32) -> Result { + 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, user: &str) -> Result { + let query = + "SELECT u.id, u.mail, u.username, u.password, u.role_id, group_concat(uc.channel_id, ',') as channel_ids FROM user u + left join user_channels uc on uc.user_id = u.id + WHERE u.username = $1"; + + sqlx::query_as(query).bind(user).fetch_one(conn).await +} + +pub async fn select_user(conn: &Pool, id: i32) -> Result { + let query = "SELECT u.id, u.mail, u.username, u.role_id, group_concat(uc.channel_id, ',') as channel_ids FROM user u + left join user_channels uc on uc.user_id = u.id + WHERE u.id = $1"; + + sqlx::query_as(query).bind(id).fetch_one(conn).await +} + +pub async fn select_global_admins(conn: &Pool) -> Result, sqlx::Error> { + let query = "SELECT u.id, u.mail, u.username, u.role_id, group_concat(uc.channel_id, ',') as channel_ids FROM user u + left join user_channels uc on uc.user_id = u.id + WHERE u.role_id = 1"; + + sqlx::query_as(query).fetch_all(conn).await +} + +pub async fn select_users(conn: &Pool) -> Result, sqlx::Error> { + let query = "SELECT id, username FROM user"; + + sqlx::query_as(query).fetch_all(conn).await +} + +pub async fn insert_user(conn: &Pool, user: User) -> Result<(), 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) RETURNING id"; + + let user_id: i32 = sqlx::query(query) + .bind(user.mail) + .bind(user.username) + .bind(password_hash) + .bind(user.role_id) + .fetch_one(conn) + .await? + .get("id"); + + if let Some(channel_ids) = user.channel_ids { + insert_user_channel(conn, user_id, channel_ids).await?; + } + + Ok(()) +} + +pub async fn update_user( + conn: &Pool, + id: i32, + fields: String, +) -> Result { + let query = format!("UPDATE user SET {fields} WHERE id = $1"); + + sqlx::query(&query).bind(id).execute(conn).await +} + +pub async fn insert_user_channel( + conn: &Pool, + user_id: i32, + channel_ids: Vec, +) -> Result<(), sqlx::Error> { + for channel in &channel_ids { + let query = "INSERT OR IGNORE INTO user_channels (channel_id, user_id) VALUES ($1, $2);"; + + sqlx::query(query) + .bind(channel) + .bind(user_id) + .execute(conn) + .await?; + } + + Ok(()) +} + +pub async fn delete_user(conn: &Pool, id: i32) -> Result { + let query = "DELETE FROM user WHERE id = $1;"; + + sqlx::query(query).bind(id).execute(conn).await +} + +pub async fn select_presets(conn: &Pool, id: i32) -> Result, 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, + id: &i32, + preset: TextPreset, +) -> Result { + 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, + preset: TextPreset, +) -> Result { + 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, + id: &i32, +) -> Result { + let query = "DELETE FROM presets WHERE id = $1;"; + + sqlx::query(query).bind(id).execute(conn).await +} diff --git a/ffplayout-api/src/db/mod.rs b/ffplayout/src/db/mod.rs similarity index 54% rename from ffplayout-api/src/db/mod.rs rename to ffplayout/src/db/mod.rs index fdf6adbd..2f89848b 100644 --- a/ffplayout-api/src/db/mod.rs +++ b/ffplayout/src/db/mod.rs @@ -1,4 +1,4 @@ -use sqlx::{Pool, Sqlite, SqlitePool}; +use sqlx::{migrate::MigrateDatabase, Pool, Sqlite, SqlitePool}; pub mod handles; pub mod models; @@ -7,6 +7,11 @@ use crate::utils::db_path; pub async fn db_pool() -> Result, sqlx::Error> { let db_path = db_path().unwrap(); + + if !Sqlite::database_exists(db_path).await.unwrap_or(false) { + Sqlite::create_database(db_path).await.unwrap(); + } + let conn = SqlitePool::connect(db_path).await?; Ok(conn) diff --git a/ffplayout/src/db/models.rs b/ffplayout/src/db/models.rs new file mode 100644 index 00000000..a9df51c1 --- /dev/null +++ b/ffplayout/src/db/models.rs @@ -0,0 +1,445 @@ +use std::{error::Error, fmt, str::FromStr}; + +use once_cell::sync::OnceCell; +use regex::Regex; +use serde::{ + de::{self, Visitor}, + Deserialize, Serialize, +}; +// use serde_with::{formats::CommaSeparator, serde_as, StringWithSeparator}; +use sqlx::{sqlite::SqliteRow, FromRow, Pool, Row, Sqlite}; + +use crate::db::handles; +use crate::utils::config::PlayoutConfig; + +#[derive(Clone, Debug, Deserialize, Serialize, sqlx::FromRow)] +pub struct GlobalSettings { + pub id: i32, + pub secret: Option, + pub hls_path: String, + pub logging_path: String, + pub playlist_path: String, + pub storage_path: String, + pub shared_storage: bool, +} + +impl GlobalSettings { + pub async fn new(conn: &Pool) -> Self { + let global_settings = handles::select_global(conn); + + match global_settings.await { + Ok(g) => g, + Err(_) => GlobalSettings { + id: 0, + secret: None, + hls_path: String::new(), + logging_path: String::new(), + playlist_path: String::new(), + storage_path: String::new(), + shared_storage: false, + }, + } + } + + pub fn global() -> &'static GlobalSettings { + INSTANCE.get().expect("Config is not initialized") + } +} + +static INSTANCE: OnceCell = OnceCell::new(); + +pub async fn init_globales(conn: &Pool) { + let config = GlobalSettings::new(conn).await; + INSTANCE.set(config).unwrap(); +} + +// #[serde_as] +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct User { + #[serde(skip_deserializing)] + pub id: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub mail: Option, + pub username: String, + #[serde(skip_serializing, default = "empty_string")] + pub password: String, + pub role_id: Option, + // #[serde_as(as = "StringWithSeparator::")] + pub channel_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, +} + +impl FromRow<'_, SqliteRow> for User { + fn from_row(row: &SqliteRow) -> sqlx::Result { + Ok(Self { + id: row.try_get("id").unwrap_or_default(), + mail: row.try_get("mail").unwrap_or_default(), + username: row.try_get("username").unwrap_or_default(), + password: row.try_get("password").unwrap_or_default(), + role_id: row.try_get("role_id").unwrap_or_default(), + channel_ids: Some( + row.try_get::("channel_ids") + .unwrap_or_default() + .split(',') + .map(|i| i.parse::().unwrap_or_default()) + .collect(), + ), + token: None, + }) + } +} + +fn empty_string() -> String { + "".to_string() +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct UserMeta { + pub id: i32, + pub channels: Vec, +} + +impl UserMeta { + pub fn new(id: i32, channels: Vec) -> Self { + Self { id, channels } + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub enum Role { + GlobalAdmin, + ChannelAdmin, + User, + Guest, +} + +impl Role { + pub fn set_role(role: &str) -> Self { + match role { + "global_admin" => Role::GlobalAdmin, + "channel_admin" => Role::ChannelAdmin, + "user" => Role::User, + _ => Role::Guest, + } + } +} + +impl FromStr for Role { + type Err = String; + + fn from_str(input: &str) -> Result { + match input { + "global_admin" => Ok(Self::GlobalAdmin), + "channel_admin" => Ok(Self::ChannelAdmin), + "user" => Ok(Self::User), + _ => Ok(Self::Guest), + } + } +} + +impl fmt::Display for Role { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + Self::GlobalAdmin => write!(f, "global_admin"), + Self::ChannelAdmin => write!(f, "channel_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: >::ValueRef, + ) -> Result> { + let value = <&str as sqlx::decode::Decode>::decode(value)?; + + Ok(value.parse()?) + } +} + +impl FromRow<'_, SqliteRow> for Role { + fn from_row(row: &SqliteRow) -> sqlx::Result { + match row.get("name") { + "global_admin" => Ok(Self::GlobalAdmin), + "channel_admin" => Ok(Self::ChannelAdmin), + "user" => Ok(Self::User), + _ => Ok(Self::Guest), + } + } +} + +#[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 +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(self, value: &str) -> Result { + 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(self, value: u64) -> Result { + Ok(value.to_string()) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(value.to_string()) + } + + fn visit_f64(self, value: f64) -> Result { + Ok(value.to_string()) + } + } + + deserializer.deserialize_any(StringOrNumberVisitor) +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, sqlx::FromRow)] +pub struct Channel { + #[serde(default = "default_id", skip_deserializing)] + pub id: i32, + pub name: String, + pub preview_url: String, + pub extra_extensions: String, + pub active: bool, + pub last_date: Option, + pub time_shift: f64, + + #[sqlx(default)] + #[serde(default)] + pub utc_offset: i32, +} + +fn default_id() -> i32 { + 1 +} + +#[derive(Clone, Debug, Deserialize, Serialize, sqlx::FromRow)] +pub struct Configuration { + pub id: i32, + pub channel_id: i32, + pub general_help: String, + pub general_stop_threshold: f64, + + pub mail_help: String, + pub mail_subject: String, + pub mail_smtp: String, + pub mail_addr: String, + pub mail_pass: String, + pub mail_recipient: String, + pub mail_starttls: bool, + pub mail_level: String, + pub mail_interval: i64, + + pub logging_help: String, + pub logging_ffmpeg_level: String, + pub logging_ingest_level: String, + pub logging_detect_silence: bool, + #[serde(default)] + pub logging_ignore: String, + + pub processing_help: String, + pub processing_mode: String, + pub processing_audio_only: bool, + pub processing_copy_audio: bool, + pub processing_copy_video: bool, + pub processing_width: i64, + pub processing_height: i64, + pub processing_aspect: f64, + pub processing_fps: f64, + pub processing_add_logo: bool, + pub processing_logo: String, + pub processing_logo_scale: String, + pub processing_logo_opacity: f64, + pub processing_logo_position: String, + #[serde(default = "default_tracks")] + pub processing_audio_tracks: i32, + #[serde(default = "default_track_index")] + pub processing_audio_track_index: i32, + #[serde(default = "default_channels")] + pub processing_audio_channels: u8, + pub processing_volume: f64, + #[serde(default)] + pub processing_filter: String, + + pub ingest_help: String, + pub ingest_enable: bool, + pub ingest_param: String, + #[serde(default)] + pub ingest_filter: String, + + pub playlist_help: String, + pub playlist_day_start: String, + pub playlist_length: String, + pub playlist_infinit: bool, + + pub storage_help: String, + pub storage_filler: String, + pub storage_extensions: String, + pub storage_shuffle: bool, + + pub text_help: String, + pub text_add: bool, + pub text_from_filename: bool, + pub text_font: String, + pub text_style: String, + pub text_regex: String, + + pub task_help: String, + pub task_enable: bool, + pub task_path: String, + + pub output_help: String, + pub output_mode: String, + pub output_param: String, +} + +impl Configuration { + pub fn from(id: i32, channel_id: i32, config: PlayoutConfig) -> Self { + Self { + id, + channel_id, + general_help: config.general.help_text, + general_stop_threshold: config.general.stop_threshold, + mail_help: config.mail.help_text, + mail_subject: config.mail.subject, + mail_smtp: config.mail.smtp_server, + mail_starttls: config.mail.starttls, + mail_addr: config.mail.sender_addr, + mail_pass: config.mail.sender_pass, + mail_recipient: config.mail.recipient, + mail_level: config.mail.mail_level.to_string(), + mail_interval: config.mail.interval, + logging_help: config.logging.help_text, + logging_ffmpeg_level: config.logging.ffmpeg_level, + logging_ingest_level: config.logging.ingest_level, + logging_detect_silence: config.logging.detect_silence, + logging_ignore: config.logging.ignore_lines.join(";"), + processing_help: config.processing.help_text, + processing_mode: config.processing.mode.to_string(), + processing_audio_only: config.processing.audio_only, + processing_audio_track_index: config.processing.audio_track_index, + processing_copy_audio: config.processing.copy_audio, + processing_copy_video: config.processing.copy_video, + processing_width: config.processing.width, + processing_height: config.processing.height, + processing_aspect: config.processing.aspect, + processing_fps: config.processing.fps, + processing_add_logo: config.processing.add_logo, + processing_logo: config.processing.logo, + processing_logo_scale: config.processing.logo_scale, + processing_logo_opacity: config.processing.logo_opacity, + processing_logo_position: config.processing.logo_position, + processing_audio_tracks: config.processing.audio_tracks, + processing_audio_channels: config.processing.audio_channels, + processing_volume: config.processing.volume, + processing_filter: config.processing.custom_filter, + ingest_help: config.ingest.help_text, + ingest_enable: config.ingest.enable, + ingest_param: config.ingest.input_param, + ingest_filter: config.ingest.custom_filter, + playlist_help: config.playlist.help_text, + playlist_day_start: config.playlist.day_start, + playlist_length: config.playlist.length, + playlist_infinit: config.playlist.infinit, + storage_help: config.storage.help_text, + storage_filler: config.storage.filler.to_string_lossy().to_string(), + storage_extensions: config.storage.extensions.join(";"), + storage_shuffle: config.storage.shuffle, + text_help: config.text.help_text, + text_add: config.text.add_text, + text_font: config.text.fontfile, + text_from_filename: config.text.text_from_filename, + text_style: config.text.style, + text_regex: config.text.regex, + task_help: config.task.help_text, + task_enable: config.task.enable, + task_path: config.task.path.to_string_lossy().to_string(), + output_help: config.output.help_text, + output_mode: config.output.mode.to_string(), + output_param: config.output.output_param, + } + } +} + +fn default_track_index() -> i32 { + -1 +} + +fn default_tracks() -> i32 { + 1 +} + +fn default_channels() -> u8 { + 2 +} + +#[derive(Clone, Debug, Deserialize, Serialize, sqlx::FromRow)] +pub struct AdvancedConfiguration { + pub id: i32, + pub channel_id: i32, + pub decoder_input_param: Option, + pub decoder_output_param: Option, + pub encoder_input_param: Option, + pub ingest_input_param: Option, + pub filter_deinterlace: Option, + pub filter_pad_scale_w: Option, + pub filter_pad_scale_h: Option, + pub filter_pad_video: Option, + pub filter_fps: Option, + pub filter_scale: Option, + pub filter_set_dar: Option, + pub filter_fade_in: Option, + pub filter_fade_out: Option, + pub filter_overlay_logo_scale: Option, + pub filter_overlay_logo_fade_in: Option, + pub filter_overlay_logo_fade_out: Option, + pub filter_overlay_logo: Option, + pub filter_tpad: Option, + pub filter_drawtext_from_file: Option, + pub filter_drawtext_from_zmq: Option, + pub filter_aevalsrc: Option, + pub filter_afade_in: Option, + pub filter_afade_out: Option, + pub filter_apad: Option, + pub filter_volume: Option, + pub filter_split: Option, +} diff --git a/ffplayout-api/src/lib.rs b/ffplayout/src/lib.rs similarity index 88% rename from ffplayout-api/src/lib.rs rename to ffplayout/src/lib.rs index 3809055f..525e151a 100644 --- a/ffplayout-api/src/lib.rs +++ b/ffplayout/src/lib.rs @@ -6,9 +6,12 @@ use sysinfo::{Disks, Networks, System}; pub mod api; pub mod db; +pub mod macros; +pub mod player; pub mod sse; pub mod utils; +use utils::advanced_config::AdvancedConfig; use utils::args_parse::Args; lazy_static! { diff --git a/lib/src/macros/mod.rs b/ffplayout/src/macros/mod.rs similarity index 100% rename from lib/src/macros/mod.rs rename to ffplayout/src/macros/mod.rs diff --git a/ffplayout-api/src/main.rs b/ffplayout/src/main.rs similarity index 51% rename from ffplayout-api/src/main.rs rename to ffplayout/src/main.rs index c3e52737..1cec61ec 100644 --- a/ffplayout-api/src/main.rs +++ b/ffplayout/src/main.rs @@ -1,4 +1,12 @@ -use std::{collections::HashSet, env, process::exit, sync::Arc}; +use std::{ + collections::HashSet, + env, + fs::File, + io, + process::exit, + sync::{atomic::AtomicBool, Arc, Mutex}, + thread, +}; use actix_files::Files; use actix_web::{ @@ -10,26 +18,43 @@ use actix_web_httpauth::{extractors::bearer::BearerAuth, middleware::HttpAuthent #[cfg(all(not(debug_assertions), feature = "embed_frontend"))] use actix_web_static_files::ResourceFiles; +use log::*; use path_clean::PathClean; -use simplelog::*; -use tokio::sync::Mutex; -use ffplayout_api::{ +use ffplayout::{ api::{auth, routes::*}, - db::{db_pool, models::LoginUser}, - sse::{broadcast::Broadcaster, routes::*, AuthState}, - utils::{control::ProcessControl, db_path, init_config, run_args}, + db::{ + db_pool, handles, + models::{init_globales, UserMeta}, + }, + player::{ + controller::{ChannelController, ChannelManager}, + utils::{get_date, is_remote, json_validate::validate_playlist, JsonPlaylist}, + }, + sse::{broadcast::Broadcaster, routes::*, SseAuthState}, + utils::{ + args_parse::run_args, + config::get_config, + logging::{init_logging, MailQueue}, + playlist::generate_playlist, + }, ARGS, }; #[cfg(any(debug_assertions, not(feature = "embed_frontend")))] -use ffplayout_api::utils::public_path; - -use ffplayout_lib::utils::{init_logging, PlayoutConfig}; +use ffplayout::utils::public_path; #[cfg(all(not(debug_assertions), feature = "embed_frontend"))] include!(concat!(env!("OUT_DIR"), "/generated.rs")); +fn thread_counter() -> usize { + let available_threads = thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(1); + + (available_threads / 2).max(2) +} + async fn validator( req: ServiceRequest, credentials: BearerAuth, @@ -40,7 +65,7 @@ async fn validator( req.attach(vec![claims.role]); req.extensions_mut() - .insert(LoginUser::new(claims.id, claims.username)); + .insert(UserMeta::new(claims.id, claims.channels)); Ok(req) } @@ -50,45 +75,67 @@ async fn validator( #[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 mail_queues = Arc::new(Mutex::new(vec![])); - let logging = init_logging(&config, None, None); - CombinedLogger::init(logging).unwrap(); + let pool = db_pool() + .await + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; - if let Err(c) = run_args().await { + if ARGS.dump_advanced.is_none() && ARGS.dump_config.is_none() { + if let Err(e) = handles::db_migrate(&pool).await { + panic!("{e}"); + }; + } + + if let Err(c) = run_args(&pool).await { exit(c); } - let pool = match db_pool().await { - Ok(p) => p, - Err(e) => { - error!("{e}"); - exit(1); - } - }; + init_globales(&pool).await; + init_logging(mail_queues.clone())?; + + let channel_controllers = Arc::new(Mutex::new(ChannelController::new())); 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); + let channels = handles::select_related_channels(&pool, None) + .await + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + + for channel in channels.iter() { + let config = get_config(&pool, channel.id).await?; + let manager = ChannelManager::new(Some(pool.clone()), channel.clone(), config.clone()); + let m_queue = Arc::new(Mutex::new(MailQueue::new(channel.id, config.mail))); + + channel_controllers + .lock() + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))? + .add(manager.clone()); + + if let Ok(mut mqs) = mail_queues.lock() { + mqs.push(m_queue.clone()); + } + + if channel.active { + manager.async_start().await; + } } - init_config(&pool).await; + let ip_port = conn.split(':').collect::>(); let addr = ip_port[0]; let port = ip_port[1].parse::().unwrap(); - let engine_process = web::Data::new(ProcessControl::new()); - let auth_state = web::Data::new(AuthState { - uuids: Mutex::new(HashSet::new()), + let controllers = web::Data::from(channel_controllers.clone()); + let auth_state = web::Data::new(SseAuthState { + uuids: tokio::sync::Mutex::new(HashSet::new()), }); let broadcast_data = Broadcaster::create(); + let thread_count = thread_counter(); - info!("running ffplayout API, listen on http://{conn}"); + info!("Running ffplayout API, listen on http://{conn}"); // no 'allow origin' here, give it to the reverse proxy HttpServer::new(move || { + let queues = mail_queues.clone(); + let auth = HttpAuthentication::bearer(validator); let db_pool = web::Data::new(pool.clone()); // Customize logging format to get IP though proxies. @@ -97,7 +144,8 @@ async fn main() -> std::io::Result<()> { let mut web_app = App::new() .app_data(db_pool) - .app_data(engine_process.clone()) + .app_data(web::Data::from(queues)) + .app_data(controllers.clone()) .app_data(auth_state.clone()) .app_data(web::Data::from(Arc::clone(&broadcast_data))) .wrap(logger) @@ -110,6 +158,8 @@ async fn main() -> std::io::Result<()> { .service(get_by_name) .service(get_users) .service(remove_user) + .service(get_advanced_config) + .service(update_advanced_config) .service(get_playout_config) .service(update_playout_config) .service(add_preset) @@ -125,8 +175,6 @@ async fn main() -> std::io::Result<()> { .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) @@ -184,11 +232,72 @@ async fn main() -> std::io::Result<()> { web_app }) .bind((addr, port))? + .workers(thread_count) .run() - .await + .await?; } else { - error!("Run ffpapi with listen parameter!"); + let channels = ARGS.channels.clone().unwrap_or_else(|| vec![1]); - Ok(()) + for (index, channel_id) in channels.iter().enumerate() { + let config = get_config(&pool, *channel_id).await?; + let channel = handles::select_channel(&pool, channel_id).await.unwrap(); + let manager = ChannelManager::new(Some(pool.clone()), channel.clone(), config.clone()); + + if ARGS.foreground { + let m_queue = Arc::new(Mutex::new(MailQueue::new(*channel_id, config.mail))); + + channel_controllers + .lock() + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))? + .add(manager.clone()); + + if let Ok(mut mqs) = mail_queues.lock() { + mqs.push(m_queue.clone()); + } + + manager.foreground_start(index).await; + } else if ARGS.generate.is_some() { + // run a simple playlist generator and save them to disk + if let Err(e) = generate_playlist(manager) { + error!("{e}"); + exit(1); + }; + } else if ARGS.validate { + let mut playlist_path = config.global.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, + Arc::new(Mutex::new(Vec::new())), + playlist, + Arc::new(AtomicBool::new(false)), + ); + } else { + error!("Run ffplayout with parameters! Run ffplayout -h for more information."); + } + } } + + for channel in &channel_controllers.lock().unwrap().channels { + channel.stop_all(); + } + + Ok(()) } diff --git a/ffplayout/src/player/controller.rs b/ffplayout/src/player/controller.rs new file mode 100644 index 00000000..7fc7a272 --- /dev/null +++ b/ffplayout/src/player/controller.rs @@ -0,0 +1,410 @@ +use std::{ + fmt, fs, io, + path::Path, + process::Child, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Mutex, + }, + thread, +}; + +#[cfg(not(windows))] +use signal_child::Signalable; + +use log::*; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use sqlx::{Pool, Sqlite}; +use sysinfo::Disks; +use walkdir::WalkDir; + +use crate::player::{ + output::{player, write_hls}, + utils::{folder::fill_filler_list, Media}, +}; +use crate::utils::{ + config::{OutputMode::*, PlayoutConfig}, + errors::ProcessError, +}; +use crate::ARGS; +use crate::{ + db::{handles, models::Channel}, + utils::logging::Target, +}; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// 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::*; + +#[derive(Clone, Debug, Default)] +pub struct ChannelManager { + pub db_pool: Option>, + pub config: Arc>, + pub channel: Arc>, + pub decoder: Arc>>, + pub encoder: Arc>>, + pub ingest: Arc>>, + pub ingest_is_running: Arc, + pub is_terminated: Arc, + pub is_alive: Arc, + pub filter_chain: Option>>>, + pub current_date: Arc>, + pub list_init: Arc, + pub current_media: Arc>>, + pub current_list: Arc>>, + pub filler_list: Arc>>, + pub current_index: Arc, + pub filler_index: Arc, + pub run_count: Arc, +} + +impl ChannelManager { + pub fn new(db_pool: Option>, channel: Channel, config: PlayoutConfig) -> Self { + Self { + db_pool, + is_alive: Arc::new(AtomicBool::new(false)), + channel: Arc::new(Mutex::new(channel)), + config: Arc::new(Mutex::new(config)), + list_init: Arc::new(AtomicBool::new(true)), + 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)), + run_count: Arc::new(AtomicUsize::new(0)), + ..Default::default() + } + } + + pub fn update_channel(self, other: &Channel) { + let mut channel = self.channel.lock().unwrap(); + + channel.name.clone_from(&other.name); + channel.preview_url.clone_from(&other.preview_url); + channel.extra_extensions.clone_from(&other.extra_extensions); + channel.active.clone_from(&other.active); + channel.last_date.clone_from(&other.last_date); + channel.time_shift.clone_from(&other.time_shift); + channel.utc_offset.clone_from(&other.utc_offset); + } + + pub fn update_config(&self, new_config: PlayoutConfig) { + let mut config = self.config.lock().unwrap(); + *config = new_config; + } + + pub async fn async_start(&self) { + if !self.is_alive.load(Ordering::SeqCst) { + self.run_count.fetch_add(1, Ordering::SeqCst); + self.is_alive.store(true, Ordering::SeqCst); + self.is_terminated.store(false, Ordering::SeqCst); + self.list_init.store(true, 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 async fn foreground_start(&self, index: usize) { + if !self.is_alive.load(Ordering::SeqCst) { + self.run_count.fetch_add(1, Ordering::SeqCst); + self.is_alive.store(true, Ordering::SeqCst); + self.is_terminated.store(false, Ordering::SeqCst); + self.list_init.store(true, 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}"); + }; + + if index + 1 == ARGS.channels.clone().unwrap_or_default().len() { + let run_count = self_clone.run_count.clone(); + + tokio::task::spawn_blocking(move || { + if let Err(e) = start_channel(self_clone) { + run_count.fetch_sub(1, Ordering::SeqCst); + error!("{e}"); + } + }) + .await + .unwrap(); + } else { + 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()?; + + match unit { + Decoder => { + if let Some(proc) = self.decoder.lock()?.as_mut() { + #[cfg(not(windows))] + proc.term() + .map_err(|e| ProcessError::Custom(format!("Decoder: {e}")))?; + + #[cfg(windows)] + proc.kill() + .map_err(|e| ProcessError::Custom(format!("Decoder: {e}")))?; + } + } + Encoder => { + if let Some(proc) = self.encoder.lock()?.as_mut() { + proc.kill() + .map_err(|e| ProcessError::Custom(format!("Encoder: {e}")))?; + } + } + Ingest => { + if let Some(proc) = self.ingest.lock()?.as_mut() { + proc.kill() + .map_err(|e| ProcessError::Custom(format!("Ingest: {e}")))?; + } + } + } + + channel.active = false; + + self.wait(unit)?; + + Ok(()) + } + + /// Wait for process to proper close. + /// This prevents orphaned/zombi processes in system + pub fn wait(&self, unit: ProcessUnit) -> Result<(), ProcessError> { + match unit { + Decoder => { + if let Some(proc) = self.decoder.lock().unwrap().as_mut() { + proc.wait() + .map_err(|e| ProcessError::Custom(format!("Decoder: {e}")))?; + } + } + Encoder => { + if let Some(proc) = self.encoder.lock().unwrap().as_mut() { + proc.wait() + .map_err(|e| ProcessError::Custom(format!("Encoder: {e}")))?; + } + } + Ingest => { + if let Some(proc) = self.ingest.lock().unwrap().as_mut() { + proc.wait() + .map_err(|e| ProcessError::Custom(format!("Ingest: {e}")))?; + } + } + } + + Ok(()) + } + + pub async fn async_stop(&self) { + debug!("Stop all child processes"); + self.is_terminated.store(true, Ordering::SeqCst); + self.is_alive.store(false, 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}"); + }; + + for unit in [Decoder, Encoder, Ingest] { + if let Err(e) = self.stop(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); + + 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}") + } + } + } + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct ChannelController { + pub channels: Vec, +} + +impl ChannelController { + pub fn new() -> Self { + Self { channels: vec![] } + } + + pub fn add(&mut self, manager: ChannelManager) { + self.channels.push(manager); + } + + pub fn get(&self, id: i32) -> Option { + for manager in self.channels.iter() { + if manager.channel.lock().unwrap().id == id { + return Some(manager.clone()); + } + } + + None + } + + pub fn remove(&mut self, channel_id: i32) { + self.channels.retain(|manager| { + let channel = manager.channel.lock().unwrap(); + channel.id != channel_id + }); + } + + pub fn run_count(&self) -> usize { + self.channels + .iter() + .filter(|manager| manager.is_alive.load(Ordering::SeqCst)) + .count() + } +} + +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(); + let channel_id = config.general.channel_id; + + drain_hls_path( + &config.global.hls_path, + &config.output.output_cmd.clone().unwrap_or_default(), + channel_id, + )?; + + debug!(target: Target::all(), channel = channel_id; "Start ffplayout v{VERSION}, channel: {channel_id}"); + + // Fill filler list, can also be a single file. + thread::spawn(move || { + fill_filler_list(&config, Some(filler_list)); + }); + + match mode { + // write files/playlist to HLS m3u8 playlist + HLS => write_hls(manager), + // play on desktop or stream to a remote target + _ => player(manager), + } +} + +pub fn drain_hls_path(path: &Path, params: &[String], channel_id: i32) -> io::Result<()> { + let disks = Disks::new_with_refreshed_list(); + + for disk in &disks { + if disk.mount_point().to_string_lossy().len() > 1 + && path.starts_with(disk.mount_point()) + && disk.available_space() < 1073741824 + && path.is_dir() + { + warn!(target: Target::file_mail(), channel = channel_id; "HLS storage space is less then 1GB, drain TS files..."); + delete_ts(path, params)? + } + } + + Ok(()) +} + +fn delete_ts + Clone + std::fmt::Debug>( + path: P, + params: &[String], +) -> io::Result<()> { + let ts_file = params + .iter() + .filter(|f| f.to_lowercase().ends_with(".ts") || f.to_lowercase().ends_with(".m3u8")) + .collect::>(); + + for entry in WalkDir::new(path.clone()) + .into_iter() + .flat_map(|e| e.ok()) + .filter(|f| f.path().is_file()) + .filter(|f| paths_match(&ts_file, &f.path().to_string_lossy())) + .map(|p| p.path().to_string_lossy().to_string()) + { + fs::remove_file(entry)?; + } + + Ok(()) +} + +fn paths_match(patterns: &Vec<&String>, actual_path: &str) -> bool { + for pattern in patterns { + let pattern_escaped = regex::escape(pattern); + let pattern_regex = pattern_escaped.replace(r"%d", r"\d+"); + let re = Regex::new(&pattern_regex).unwrap(); + + if re.is_match(actual_path) { + return true; + } + } + false +} diff --git a/lib/src/filter/custom.rs b/ffplayout/src/player/filter/custom.rs similarity index 82% rename from lib/src/filter/custom.rs rename to ffplayout/src/player/filter/custom.rs index 851d83ef..e9a39724 100644 --- a/lib/src/filter/custom.rs +++ b/ffplayout/src/player/filter/custom.rs @@ -1,8 +1,10 @@ +use log::*; use regex::Regex; -use simplelog::*; + +use crate::utils::logging::Target; /// Apply custom filters -pub fn filter_node(filter: &str) -> (String, String) { +pub fn filter_node(id: i32, 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(); @@ -32,7 +34,7 @@ pub fn filter_node(filter: &str) -> (String, 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!") + error!(target: Target::file_mail(), channel = id; "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) diff --git a/lib/src/filter/mod.rs b/ffplayout/src/player/filter/mod.rs similarity index 82% rename from lib/src/filter/mod.rs rename to ffplayout/src/player/filter/mod.rs index 0f232c69..ff066dd9 100644 --- a/lib/src/filter/mod.rs +++ b/ffplayout/src/player/filter/mod.rs @@ -4,18 +4,21 @@ use std::{ sync::{Arc, Mutex}, }; +use log::*; 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 crate::player::{ + controller::ProcessUnit::*, + utils::{custom_format, fps_calc, is_close, Media}, }; - -use super::vec_strings; +use crate::utils::{ + config::{OutputMode::*, PlayoutConfig}, + logging::Target, +}; +use crate::vec_strings; #[derive(Clone, Debug, Copy, Eq, PartialEq)] pub enum FilterType { @@ -179,18 +182,14 @@ impl Filters { impl Default for Filters { fn default() -> Self { - Self::new(PlayoutConfig::new(None, None), 0) + Self::new(PlayoutConfig::default(), 0) } } fn deinterlace(field_order: &Option, 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.filters.deinterlace.clone()) - { + let deinterlace = match config.advanced.filter.deinterlace.clone() { Some(deinterlace) => deinterlace, None => "yadif=0:-1:0".to_string(), }; @@ -206,22 +205,14 @@ fn pad(aspect: f64, chain: &mut Filters, v_stream: &ffprobe::Stream, config: &Pl 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.filters.pad_scale_w.clone()) - { + scale = match config.advanced.filter.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.filters.pad_scale_h.clone()) - { + scale = match config.advanced.filter.pad_scale_h.clone() { Some(pad_scale_h) => { custom_format(&format!("{pad_scale_h},"), &[&config.processing.width]) } @@ -230,11 +221,7 @@ fn pad(aspect: f64, chain: &mut Filters, v_stream: &ffprobe::Stream, config: &Pl } } - let pad = match config - .advanced - .as_ref() - .and_then(|a| a.filters.pad_video.clone()) - { + let pad = match config.advanced.filter.pad_video.clone() { Some(pad_video) => custom_format( &format!("{scale}{pad_video}"), &[ @@ -254,7 +241,7 @@ fn pad(aspect: f64, chain: &mut Filters, v_stream: &ffprobe::Stream, config: &Pl 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.filters.fps.clone()) { + let fps_filter = match config.advanced.filter.fps.clone() { Some(fps) => custom_format(&fps, &[&config.processing.fps]), None => format!("fps={}", config.processing.fps), }; @@ -273,11 +260,7 @@ fn scale( // 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.filters.scale.clone()) - { + let scale = match config.advanced.filter.scale.clone() { Some(scale) => custom_format( &scale, &[&config.processing.width, &config.processing.height], @@ -294,11 +277,7 @@ fn scale( } if !is_close(aspect, config.processing.aspect, 0.03) { - let dar = match config - .advanced - .as_ref() - .and_then(|a| a.filters.set_dar.clone()) - { + let dar = match config.advanced.filter.set_dar.clone() { Some(set_dar) => custom_format(&set_dar, &[&config.processing.aspect]), None => format!("setdar=dar={}", config.processing.aspect), }; @@ -306,11 +285,7 @@ fn scale( chain.add_filter(&dar, 0, Video); } } else { - let scale = match config - .advanced - .as_ref() - .and_then(|a| a.filters.scale.clone()) - { + let scale = match config.advanced.filter.scale.clone() { Some(scale) => custom_format( &scale, &[&config.processing.width, &config.processing.height], @@ -322,11 +297,7 @@ fn scale( }; chain.add_filter(&scale, 0, Video); - let dar = match config - .advanced - .as_ref() - .and_then(|a| a.filters.set_dar.clone()) - { + let dar = match config.advanced.filter.set_dar.clone() { Some(set_dar) => custom_format(&set_dar, &[&config.processing.aspect]), None => format!("setdar=dar={}", config.processing.aspect), }; @@ -357,18 +328,10 @@ fn fade( 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.filters.afade_in.clone()) - { + if let Some(fade) = config.advanced.filter.afade_in.clone() { fade_in = custom_format(&fade, &[t]); } - } else if let Some(fade) = config - .advanced - .as_ref() - .and_then(|a| a.filters.fade_in.clone()) - { + } else if let Some(fade) = config.advanced.filter.fade_in.clone() { fade_in = custom_format(&fade, &[t]); }; @@ -379,19 +342,10 @@ fn fade( 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.filters.afade_out.clone()) - { + if let Some(fade) = config.advanced.filter.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.filters.fade_out.clone()) - .clone() - { + } else if let Some(fade) = config.advanced.filter.fade_out.clone().clone() { fade_out = custom_format(&fade, &[node.out - node.seek - 1.0]); }; @@ -415,11 +369,7 @@ fn overlay(node: &mut Media, chain: &mut Filters, config: &PlayoutConfig) { ); if node.last_ad { - match config - .advanced - .as_ref() - .and_then(|a| a.filters.overlay_logo_fade_in.clone()) - { + match config.advanced.filter.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"), }; @@ -428,11 +378,7 @@ fn overlay(node: &mut Media, chain: &mut Filters, config: &PlayoutConfig) { if node.next_ad { let length = node.out - node.seek - 1.0; - match config - .advanced - .as_ref() - .and_then(|a| a.filters.overlay_logo_fade_out.clone()) - { + match config.advanced.filter.overlay_logo_fade_out.clone() { Some(fade_out) => { logo_chain.push_str(&custom_format(&format!(",{fade_out}"), &[length])) } @@ -441,11 +387,7 @@ fn overlay(node: &mut Media, chain: &mut Filters, config: &PlayoutConfig) { } if !config.processing.logo_scale.is_empty() { - match &config - .advanced - .as_ref() - .and_then(|a| a.filters.overlay_logo_scale.clone()) - { + match &config.advanced.filter.overlay_logo_scale.clone() { Some(logo_scale) => logo_chain.push_str(&custom_format( &format!(",{logo_scale}"), &[&config.processing.logo_scale], @@ -454,11 +396,7 @@ fn overlay(node: &mut Media, chain: &mut Filters, config: &PlayoutConfig) { } } - match config - .advanced - .as_ref() - .and_then(|a| a.filters.overlay_logo.clone()) - { + match config.advanced.filter.overlay_logo.clone() { Some(overlay) => { if !overlay.starts_with(',') { logo_chain.push(','); @@ -490,11 +428,7 @@ fn extend_video(node: &mut Media, chain: &mut Filters, config: &PlayoutConfig) { 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.filters.tpad.clone()) - { + let tpad = match config.advanced.filter.tpad.clone() { Some(pad) => custom_format(&pad, &[duration]), None => format!("tpad=stop_mode=add:stop_duration={duration}"), }; @@ -512,7 +446,7 @@ fn add_text( filter_chain: &Option>>>, ) { if config.text.add_text - && (config.text.text_from_filename || config.out.mode == HLS || node.unit == Encoder) + && (config.text.text_from_filename || config.output.mode == HLS || node.unit == Encoder) { let filter = v_drawtext::filter_node(config, Some(node), filter_chain); @@ -521,11 +455,7 @@ fn add_text( } fn add_audio(node: &Media, chain: &mut Filters, nr: i32, config: &PlayoutConfig) { - let audio = match config - .advanced - .as_ref() - .and_then(|a| a.filters.aevalsrc.clone()) - { + let audio = match config.advanced.filter.aevalsrc.clone() { Some(aevalsrc) => custom_format(&aevalsrc, &[node.out - node.seek]), None => format!( "aevalsrc=0:channel_layout=stereo:duration={}:sample_rate=48000", @@ -547,11 +477,7 @@ fn extend_audio(node: &mut Media, chain: &mut Filters, nr: i32, config: &Playout { 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.filters.apad.clone()) - { + let apad = match config.advanced.filter.apad.clone() { Some(apad) => custom_format(&apad, &[node.out - node.seek]), None => format!("apad=whole_dur={}", node.out - node.seek), }; @@ -564,11 +490,7 @@ fn extend_audio(node: &mut Media, chain: &mut Filters, nr: i32, config: &Playout 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.filters.volume.clone()) - { + let volume = match config.advanced.filter.volume.clone() { Some(volume) => custom_format(&volume, &[config.processing.volume]), None => format!("volume={}", config.processing.volume), }; @@ -610,11 +532,7 @@ pub fn split_filter( } } - let split = match config - .advanced - .as_ref() - .and_then(|a| a.filters.split.clone()) - { + let split = match config.advanced.filter.split.clone() { Some(split) => custom_format(&split, &[count.to_string(), out_link.join("")]), None => format!("split={count}{}", out_link.join("")), }; @@ -626,7 +544,7 @@ pub fn split_filter( /// 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 { + if (config.text.add_text && !config.text.text_from_filename) || config.output.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(); @@ -679,10 +597,10 @@ pub fn filter_chains( add_text(node, &mut filters, config, filter_chain); } - if let Some(f) = config.out.output_filter.clone() { + if let Some(f) = config.output.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); + } else if config.output.output_count > 1 && !config.processing.audio_only { + split_filter(&mut filters, config.output.output_count, 0, Video, config); } return filters; @@ -722,12 +640,12 @@ pub fn filter_chains( } let (proc_vf, proc_af) = if node.unit == Ingest { - custom::filter_node(&config.ingest.custom_filter) + custom::filter_node(config.general.channel_id, &config.ingest.custom_filter) } else { - custom::filter_node(&config.processing.custom_filter) + custom::filter_node(config.general.channel_id, &config.processing.custom_filter) }; - let (list_vf, list_af) = custom::filter_node(&node.custom_filter); + let (list_vf, list_af) = custom::filter_node(config.general.channel_id, &node.custom_filter); if !config.processing.copy_video { custom(&proc_vf, &mut filters, 0, Video); @@ -756,7 +674,7 @@ pub fn filter_chains( extend_audio(node, &mut filters, i, config); } else if node.unit == Decoder { if !node.source.contains("color=c=") { - warn!( + warn!(target: Target::file_mail(), channel = config.general.channel_id; "Missing audio track (id {i}) from {}", node.source ); @@ -776,11 +694,11 @@ pub fn filter_chains( 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!") + error!(target: Target::file_mail(), channel = config.general.channel_id; "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() { + if config.output.mode == HLS { + if let Some(f) = config.output.output_filter.clone() { process_output_filters(config, &mut filters, &f) } } diff --git a/lib/src/filter/v_drawtext.rs b/ffplayout/src/player/filter/v_drawtext.rs similarity index 84% rename from lib/src/filter/v_drawtext.rs rename to ffplayout/src/player/filter/v_drawtext.rs index 87dd4eed..58835135 100644 --- a/lib/src/filter/v_drawtext.rs +++ b/ffplayout/src/player/filter/v_drawtext.rs @@ -6,7 +6,11 @@ use std::{ use regex::Regex; -use crate::utils::{controller::ProcessUnit::*, custom_format, Media, PlayoutConfig}; +use crate::player::{ + controller::ProcessUnit::*, + utils::{custom_format, Media}, +}; +use crate::utils::config::PlayoutConfig; pub fn filter_node( config: &PlayoutConfig, @@ -44,11 +48,7 @@ pub fn filter_node( .replace('%', "\\\\\\%") .replace(':', "\\:"); - filter = match &config - .advanced - .clone() - .and_then(|a| a.filters.drawtext_from_file) - { + filter = match &config.advanced.filter.drawtext_from_file { Some(drawtext) => custom_format(drawtext, &[&escaped_text, &config.text.style, &font]), None => format!("drawtext=text='{escaped_text}':{}{font}", config.text.style), }; @@ -61,11 +61,7 @@ pub fn filter_node( } } - filter = match config - .advanced - .as_ref() - .and_then(|a| a.filters.drawtext_from_zmq.clone()) - { + filter = match config.advanced.filter.drawtext_from_zmq.clone() { Some(drawtext) => custom_format(&drawtext, &[&socket.replace(':', "\\:"), &filter_cmd]), None => format!( "zmq=b=tcp\\\\://'{}',drawtext@dyntext={filter_cmd}", diff --git a/ffplayout-engine/src/input/folder.rs b/ffplayout/src/player/input/folder.rs similarity index 79% rename from ffplayout-engine/src/input/folder.rs rename to ffplayout/src/player/input/folder.rs index ebf28bad..4355e255 100644 --- a/ffplayout-engine/src/input/folder.rs +++ b/ffplayout/src/player/input/folder.rs @@ -9,15 +9,16 @@ use std::{ time::Duration, }; +use log::*; 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}; +use crate::player::utils::{include_file_extension, Media}; +use crate::utils::{config::PlayoutConfig, logging::Target}; /// Create a watcher, which monitor file changes. /// When a change is register, update the current file list. @@ -27,7 +28,8 @@ pub fn watchman( is_terminated: Arc, sources: Arc>>, ) { - let path = Path::new(&config.storage.path); + let id = config.general.channel_id; + let path = Path::new(&config.global.storage_path); if !path.exists() { error!("Folder path not exists: '{path:?}'"); @@ -57,7 +59,7 @@ pub fn watchman( let media = Media::new(index, &new_path.to_string_lossy(), false); sources.lock().unwrap().push(media); - info!("Create new file: {new_path:?}"); + info!(target: Target::file_mail(), channel = id; "Create new file: {new_path:?}"); } } Remove(RemoveKind::File) | Modify(ModifyKind::Name(RenameMode::From)) => { @@ -68,7 +70,7 @@ pub fn watchman( .lock() .unwrap() .retain(|x| x.source != old_path.to_string_lossy()); - info!("Remove file: {old_path:?}"); + info!(target: Target::file_mail(), channel = id; "Remove file: {old_path:?}"); } } Modify(ModifyKind::Name(RenameMode::Both)) => { @@ -82,16 +84,16 @@ pub fn watchman( .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: {old_path:?} to {new_path:?}"); + info!(target: Target::file_mail(), channel = id; "Move file: {old_path:?} to {new_path:?}"); } 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: {new_path:?}"); + info!(target: Target::file_mail(), channel = id; "Create new file: {new_path:?}"); } } - _ => debug!("Not tracked file event: {event:?}") + _ => debug!(target: Target::file_mail(), channel = id; "Not tracked file event: {event:?}") }), Err(errors) => errors.iter().for_each(|error| error!("{error:?}")), } diff --git a/ffplayout-engine/src/input/ingest.rs b/ffplayout/src/player/input/ingest.rs similarity index 57% rename from ffplayout-engine/src/input/ingest.rs rename to ffplayout/src/player/input/ingest.rs index 139272a5..a574cab9 100644 --- a/ffplayout-engine/src/input/ingest.rs +++ b/ffplayout/src/player/input/ingest.rs @@ -1,28 +1,33 @@ use std::{ - io::{BufRead, BufReader, Error, Read}, - process::{exit, ChildStderr, Command, Stdio}, + io::{BufRead, BufReader, Read}, + process::{ChildStderr, Command, Stdio}, sync::atomic::Ordering, thread, }; use crossbeam_channel::Sender; -use simplelog::*; +use log::*; -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, +use crate::utils::{ + config::{PlayoutConfig, FFMPEG_IGNORE_ERRORS, FFMPEG_UNRECOVERABLE_ERRORS}, + logging::{log_line, Target}, +}; +use crate::vec_strings; +use crate::{ + player::{ + controller::{ChannelManager, ProcessUnit::*}, + utils::{test_tcp_port, valid_stream, Media}, }, - vec_strings, + utils::errors::ProcessError, }; fn server_monitor( + id: i32, level: &str, ignore: Vec, buffer: BufReader, - proc_ctl: ProcessControl, -) -> Result<(), Error> { + channel_mgr: ChannelManager, +) -> Result<(), ProcessError> { for line in buffer.lines() { let line = line?; @@ -33,8 +38,8 @@ fn server_monitor( } if line.contains("rtmp") && line.contains("Unexpected stream") && !valid_stream(&line) { - if let Err(e) = proc_ctl.stop(Ingest) { - error!("{e}"); + if let Err(e) = channel_mgr.stop(Ingest) { + error!(target: Target::file_mail(), channel = id; "{e}"); }; } @@ -42,7 +47,7 @@ fn server_monitor( .iter() .any(|i| line.contains(*i)) { - proc_ctl.stop_all(); + channel_mgr.stop_all(); } } @@ -55,20 +60,19 @@ fn server_monitor( pub fn ingest_server( config: PlayoutConfig, ingest_sender: Sender<(usize, [u8; 65088])>, - proc_control: ProcessControl, -) -> Result<(), Error> { + channel_mgr: ChannelManager, +) -> Result<(), ProcessError> { + let id = config.general.channel_id; 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); + let is_terminated = channel_mgr.is_terminated.clone(); + let ingest_is_running = channel_mgr.ingest_is_running.clone(); - if let Some(ingest_input_cmd) = config - .advanced - .as_ref() - .and_then(|a| a.ingest.input_cmd.clone()) - { + if let Some(ingest_input_cmd) = config.advanced.ingest.input_cmd { server_cmd.append(&mut ingest_input_cmd.clone()); } @@ -86,22 +90,21 @@ pub fn ingest_server( 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); + if !test_tcp_port(id, url) { + channel_mgr.stop_all(); } - info!("Start ingest server, listening on: {url}",); + info!(target: Target::file_mail(), channel = id; "Start ingest server, listening on: {url}",); }; - debug!( + debug!(target: Target::file_mail(), channel = id; "Server CMD: \"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(); + while !is_terminated.load(Ordering::SeqCst) { + let proc_ctl = channel_mgr.clone(); + let level = config.logging.ingest_level.clone(); let ignore = config.logging.ignore_lines.clone(); let mut server_proc = match Command::new("ffmpeg") .args(server_cmd.clone()) @@ -110,7 +113,7 @@ pub fn ingest_server( .spawn() { Err(e) => { - error!("couldn't spawn ingest server: {e}"); + error!(target: Target::file_mail(), channel = id; "couldn't spawn ingest server: {e}"); panic!("couldn't spawn ingest server: {e}") } Ok(proc) => proc, @@ -118,30 +121,30 @@ pub fn ingest_server( 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)); + thread::spawn(move || server_monitor(id, &level, ignore, server_err, proc_ctl)); - *proc_control.server_term.lock().unwrap() = Some(server_proc); + *channel_mgr.ingest.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:?}"); + debug!(target: Target::file_mail(), channel = id; "Ingest server read {e:?}"); break; } }; if !is_running { - proc_control.server_is_running.store(true, Ordering::SeqCst); + ingest_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:?}"); + error!(target: Target::file_mail(), channel = id; "Ingest server write error: {e:?}"); - proc_control.is_terminated.store(true, Ordering::SeqCst); + is_terminated.store(true, Ordering::SeqCst); break; } } else { @@ -150,16 +153,14 @@ pub fn ingest_server( } drop(ingest_reader); - proc_control - .server_is_running - .store(false, Ordering::SeqCst); + ingest_is_running.store(false, Ordering::SeqCst); - if let Err(e) = proc_control.wait(Ingest) { - error!("{e}") + if let Err(e) = channel_mgr.wait(Ingest) { + error!(target: Target::file_mail(), channel = id; "{e}") } if let Err(e) = error_reader_thread.join() { - error!("{e:?}"); + error!(target: Target::file_mail(), channel = id; "{e:?}"); }; } diff --git a/ffplayout/src/player/input/mod.rs b/ffplayout/src/player/input/mod.rs new file mode 100644 index 00000000..d59c7871 --- /dev/null +++ b/ffplayout/src/player/input/mod.rs @@ -0,0 +1,50 @@ +use std::thread; + +use log::*; + +pub mod folder; +pub mod ingest; +pub mod playlist; + +pub use folder::watchman; +pub use ingest::ingest_server; +pub use playlist::CurrentProgram; + +use crate::player::{ + controller::ChannelManager, + utils::{folder::FolderSource, Media}, +}; +use crate::utils::{config::ProcessMode::*, logging::Target}; + +/// Create a source iterator from playlist, or from folder. +pub fn source_generator(manager: ChannelManager) -> Box> { + let config = manager.config.lock().unwrap().clone(); + let id = config.general.channel_id; + let is_terminated = manager.is_terminated.clone(); + let current_list = manager.current_list.clone(); + + match config.processing.mode { + Folder => { + info!(target: Target::file_mail(), channel = id; "Playout in folder mode"); + debug!(target: Target::file_mail(), channel = id; + "Monitor folder: {:?}", + config.global.storage_path + ); + + let config_clone = config.clone(); + let folder_source = FolderSource::new(&config, manager); + let list_clone = current_list.clone(); + + // Spawn a thread to monitor folder for file changes. + thread::spawn(move || watchman(config_clone, is_terminated.clone(), list_clone)); + + Box::new(folder_source) as Box> + } + Playlist => { + info!(target: Target::file_mail(), channel = id; "Playout in playlist mode"); + let program = CurrentProgram::new(manager); + + Box::new(program) as Box> + } + } +} diff --git a/ffplayout-engine/src/input/playlist.rs b/ffplayout/src/player/input/playlist.rs similarity index 65% rename from ffplayout-engine/src/input/playlist.rs rename to ffplayout/src/player/input/playlist.rs index f91fb249..79e615a4 100644 --- a/ffplayout-engine/src/input/playlist.rs +++ b/ffplayout/src/player/input/playlist.rs @@ -1,21 +1,26 @@ use std::{ - fs, path::Path, sync::{ atomic::{AtomicBool, Ordering}, - Arc, + Arc, Mutex, }, }; -use serde_json::json; -use simplelog::*; +use log::*; -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, +use crate::db::handles; +use crate::player::{ + controller::ChannelManager, + utils::{ + 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, + }, +}; +use crate::utils::{ + config::{PlayoutConfig, IMAGE_FORMAT}, + logging::Target, }; /// Struct for current playlist. @@ -23,38 +28,36 @@ use ffplayout_lib::utils::{ /// Here we prepare the init clip and build a iterator where we pull our clips. #[derive(Debug)] pub struct CurrentProgram { + id: i32, config: PlayoutConfig, + manager: ChannelManager, start_sec: f64, end_sec: f64, json_playlist: JsonPlaylist, - player_control: PlayerControl, current_node: Media, is_terminated: Arc, - playout_stat: PlayoutStatus, last_json_path: Option, last_node_ad: bool, } /// Prepare a playlist iterator. impl CurrentProgram { - pub fn new( - config: &PlayoutConfig, - playout_stat: PlayoutStatus, - is_terminated: Arc, - player_control: &PlayerControl, - ) -> Self { + pub fn new(manager: ChannelManager) -> Self { + let config = manager.config.lock().unwrap().clone(); + let is_terminated = manager.is_terminated.clone(); + Self { + id: config.general.channel_id, config: config.clone(), + manager, 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, } @@ -70,8 +73,8 @@ impl CurrentProgram { if (Path::new(&path).is_file() || is_remote(&path)) && self.json_playlist.modified != modified_time(&path) { - info!("Reload playlist {path}"); - self.playout_stat.list_init.store(true, Ordering::SeqCst); + info!(target: Target::file_mail(), channel = self.id; "Reload playlist {path}"); + self.manager.list_init.store(true, Ordering::SeqCst); get_current = true; reload = true; } @@ -82,7 +85,7 @@ impl CurrentProgram { if get_current { self.json_playlist = read_json( &mut self.config, - &self.player_control, + self.manager.current_list.clone(), self.json_playlist.path.clone(), self.is_terminated.clone(), seek, @@ -91,21 +94,30 @@ impl CurrentProgram { if !reload { if let Some(file) = &self.json_playlist.path { - info!("Read playlist: {file}"); + info!(target: Target::file_mail(), channel = self.id; "Read playlist: {file}"); } - if *self.playout_stat.date.lock().unwrap() != self.json_playlist.date { + if *self + .manager + .channel + .lock() + .unwrap() + .last_date + .clone() + .unwrap_or_default() + != self.json_playlist.date + { self.set_status(self.json_playlist.date.clone()); } - self.playout_stat + self.manager .current_date .lock() .unwrap() .clone_from(&self.json_playlist.date); } - self.player_control + self.manager .current_list .lock() .unwrap() @@ -115,8 +127,8 @@ impl CurrentProgram { 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); + self.manager.list_init.store(true, Ordering::SeqCst); + self.manager.current_index.store(0, Ordering::SeqCst); } } } @@ -138,14 +150,12 @@ impl CurrentProgram { 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 - { + if node_index > 0 && node_index == self.manager.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 {}", + "delta: {delta} | total_delta: {total_delta}, index: {node_index} \n next_start: {next_start} | end_sec: {} | source {}", self.end_sec, self.current_node.source ); @@ -161,7 +171,7 @@ impl CurrentProgram { self.json_playlist = read_json( &mut self.config, - &self.player_control, + self.manager.current_list.clone(), None, self.is_terminated.clone(), false, @@ -169,18 +179,18 @@ impl CurrentProgram { ); if let Some(file) = &self.json_playlist.path { - info!("Read next playlist: {file}"); + info!(target: Target::file_mail(), channel = self.id; "Read next playlist: {file}"); } - self.playout_stat.list_init.store(false, Ordering::SeqCst); + self.manager.list_init.store(false, Ordering::SeqCst); self.set_status(self.json_playlist.date.clone()); - self.player_control + self.manager .current_list .lock() .unwrap() .clone_from(&self.json_playlist.program); - self.player_control.current_index.store(0, Ordering::SeqCst); + self.manager.current_index.store(0, Ordering::SeqCst); } else { self.load_or_update_playlist(seek) } @@ -189,35 +199,39 @@ impl CurrentProgram { } fn set_status(&mut self, date: String) { - if *self.playout_stat.date.lock().unwrap() != date - && *self.playout_stat.time_shift.lock().unwrap() != 0.0 + if self.manager.channel.lock().unwrap().last_date != Some(date.clone()) + && self.manager.channel.lock().unwrap().time_shift != 0.0 { - info!("Reset playout status"); + info!(target: Target::file_mail(), channel = self.id; "Reset playout status"); } - self.playout_stat - .current_date + self.manager.current_date.lock().unwrap().clone_from(&date); + self.manager + .channel .lock() .unwrap() - .clone_from(&date); - *self.playout_stat.time_shift.lock().unwrap() = 0.0; + .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) = 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}"); + if let Err(e) = tokio::runtime::Runtime::new() + .unwrap() + .block_on(handles::update_stat( + &db_pool, + self.config.general.channel_id, + date, + 0.0, + )) + { + error!(target: Target::file_mail(), channel = self.id; "Unable to write status: {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(); + let index = self.manager.current_index.load(Ordering::SeqCst); + let current_list = self.manager.current_list.lock().unwrap(); if index + 1 < current_list.len() && ¤t_list[index + 1].category == "advertisement" { node.next_ad = true; @@ -246,10 +260,10 @@ impl CurrentProgram { // 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(); + let shift = self.manager.channel.lock().unwrap().time_shift; if shift != 0.0 { - info!("Shift playlist start for {shift:.3} seconds"); + info!(target: Target::file_mail(), channel = self.id; "Shift playlist start for {shift:.3} seconds"); time_sec += shift; } @@ -260,17 +274,10 @@ impl CurrentProgram { self.recalculate_begin(true) } - for (i, item) in self - .player_control - .current_list - .lock() - .unwrap() - .iter() - .enumerate() - { + for (i, item) in self.manager.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); + self.manager.list_init.store(false, Ordering::SeqCst); + self.manager.current_index.store(i, Ordering::SeqCst); break; } @@ -283,10 +290,10 @@ impl CurrentProgram { self.get_current_clip(); let mut is_filler = false; - if !self.playout_stat.list_init.load(Ordering::SeqCst) { + if !self.manager.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 index = self.manager.current_index.load(Ordering::SeqCst); + let nodes = self.manager.current_list.lock().unwrap(); let last_index = nodes.len() - 1; // de-instance node to preserve original values in list @@ -298,27 +305,23 @@ impl CurrentProgram { trace!("Clip from init: {}", node_clone.source); node_clone.seek += time_sec - - (node_clone.begin.unwrap() - *self.playout_stat.time_shift.lock().unwrap()); + - (node_clone.begin.unwrap() - self.manager.channel.lock().unwrap().time_shift); self.last_next_ad(&mut node_clone); - self.player_control - .current_index - .fetch_add(1, Ordering::SeqCst); + self.manager.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, - ); + self.current_node = + handle_list_init(&self.config, node_clone, &self.manager, 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") + if self.current_node.source.contains( + &self + .config + .global + .storage_path + .to_string_lossy() + .to_string(), + ) || self.current_node.source.contains("color=c=#121212") { is_filler = true; } @@ -329,7 +332,7 @@ impl CurrentProgram { fn fill_end(&mut self, total_delta: f64) { // Fill end from playlist - let index = self.player_control.current_index.load(Ordering::SeqCst); + let index = self.manager.current_index.load(Ordering::SeqCst); let mut media = Media::new(index, "", false); media.begin = Some(time_in_seconds()); media.duration = total_delta; @@ -337,15 +340,9 @@ impl CurrentProgram { self.last_next_ad(&mut media); - self.current_node = gen_source( - &self.config, - media, - &self.playout_stat, - &self.player_control, - 0, - ); + self.current_node = gen_source(&self.config, media, &self.manager, 0); - self.player_control + self.manager .current_list .lock() .unwrap() @@ -353,15 +350,13 @@ impl CurrentProgram { self.current_node.last_ad = self.last_node_ad; self.current_node - .add_filter(&self.config, &self.playout_stat.chain); + .add_filter(&self.config, &self.manager.filter_chain); - self.player_control - .current_index - .fetch_add(1, Ordering::SeqCst); + self.manager.current_index.fetch_add(1, Ordering::SeqCst); } fn recalculate_begin(&mut self, extend: bool) { - debug!("Infinit playlist reaches end, recalculate clip begins."); + debug!(target: Target::file_mail(), channel = self.id; "Infinit playlist reaches end, recalculate clip begins."); let mut time_sec = time_in_seconds(); @@ -371,7 +366,7 @@ impl CurrentProgram { self.json_playlist.start_sec = Some(time_sec); set_defaults(&mut self.json_playlist); - self.player_control + self.manager .current_list .lock() .unwrap() @@ -386,9 +381,9 @@ impl Iterator for CurrentProgram { fn next(&mut self) -> Option { 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)); + self.check_for_playlist(self.manager.list_init.load(Ordering::SeqCst)); - if self.playout_stat.list_init.load(Ordering::SeqCst) { + if self.manager.list_init.load(Ordering::SeqCst) { trace!("Init playlist, from next iterator"); let mut init_clip_is_filler = false; @@ -396,7 +391,7 @@ impl Iterator for CurrentProgram { init_clip_is_filler = self.init_clip(); } - if self.playout_stat.list_init.load(Ordering::SeqCst) && !init_clip_is_filler { + if self.manager.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"); @@ -409,7 +404,7 @@ impl Iterator for CurrentProgram { } let mut last_index = 0; - let length = self.player_control.current_list.lock().unwrap().len(); + let length = self.manager.current_list.lock().unwrap().len(); if length > 0 { last_index = length - 1; @@ -422,26 +417,20 @@ impl Iterator for CurrentProgram { self.last_next_ad(&mut media); - self.current_node = gen_source( - &self.config, - media, - &self.playout_stat, - &self.player_control, - last_index, - ); + self.current_node = gen_source(&self.config, media, &self.manager, 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() + if self.manager.current_index.load(Ordering::SeqCst) + < self.manager.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 index = self.manager.current_index.load(Ordering::SeqCst); + let node_list = self.manager.current_list.lock().unwrap(); let mut node = node_list[index].clone(); let last_index = node_list.len() - 1; @@ -453,18 +442,10 @@ impl Iterator for CurrentProgram { 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.current_node = + timed_source(node, &self.config, is_last, &self.manager, last_index); - self.player_control - .current_index - .fetch_add(1, Ordering::SeqCst); + self.manager.current_index.fetch_add(1, Ordering::SeqCst); Some(self.current_node.clone()) } else { @@ -484,7 +465,7 @@ impl Iterator for CurrentProgram { } // Get first clip from next playlist. - let c_list = self.player_control.current_list.lock().unwrap(); + let c_list = self.manager.current_list.lock().unwrap(); let mut first_node = c_list[0].clone(); drop(c_list); @@ -493,19 +474,13 @@ impl Iterator for CurrentProgram { self.recalculate_begin(false) } - self.player_control.current_index.store(0, Ordering::SeqCst); + self.manager.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.current_node = gen_source(&self.config, first_node, &self.manager, 0); - self.player_control.current_index.store(1, Ordering::SeqCst); + self.manager.current_index.store(1, Ordering::SeqCst); Some(self.current_node.clone()) } @@ -520,35 +495,40 @@ fn timed_source( node: Media, config: &PlayoutConfig, last: bool, - playout_stat: &PlayoutStatus, - player_control: &PlayerControl, + manager: &ChannelManager, last_index: usize, ) -> Media { + let id = config.general.channel_id; + let time_shift = manager.channel.lock().unwrap().time_shift; + let current_date = manager.current_date.lock().unwrap().clone(); + let last_date = manager.channel.lock().unwrap().last_date.clone(); 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}"); + trace!( + "Node - begin: {} | source: {}", + node.begin.unwrap(), + node.source + ); + trace!( + "timed source is last: {last} | current_date: {current_date} | last_date: {last_date:?} | time_shift: {time_shift}" + ); if config.playlist.length.contains(':') { - let time_shift = playout_stat.time_shift.lock().unwrap(); + if Some(current_date) == last_date && time_shift != 0.0 { + shifted_delta = delta - time_shift; - if *playout_stat.current_date.lock().unwrap() == *playout_stat.date.lock().unwrap() - && *time_shift != 0.0 - { - shifted_delta = delta - *time_shift; - - debug!("Delta: {shifted_delta:.3}, shifted: {delta:.3}"); + debug!(target: Target::file_mail(), channel = id; "Delta: {shifted_delta:.3}, shifted: {delta:.3}"); } else { - debug!("Delta: {shifted_delta:.3}"); + debug!(target: Target::file_mail(), channel = id; "Delta: {shifted_delta:.3}"); } if config.general.stop_threshold > 0.0 && shifted_delta.abs() > config.general.stop_threshold { - error!("Clip begin out of sync for {delta:.3} seconds."); + error!(target: Target::file_mail(), channel = id; "Clip begin out of sync for {delta:.3} seconds."); new_node.cmd = None; @@ -563,26 +543,18 @@ fn timed_source( { // 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); + new_node = gen_source(config, node, manager, last_index); } else if total_delta <= 0.0 { - info!("Begin is over play time, skip: {}", node.source); + info!(target: Target::file_mail(), channel = id; "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 = handle_list_end(config, node, total_delta, manager, 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(); +fn duplicate_for_seek_and_loop(node: &mut Media, current_list: &Arc>>) { + let mut nodes = current_list.lock().unwrap(); let index = node.index.unwrap_or_default(); let mut node_duplicate = node.clone(); @@ -617,15 +589,17 @@ fn duplicate_for_seek_and_loop(node: &mut Media, player_control: &PlayerControl) pub fn gen_source( config: &PlayoutConfig, mut node: Media, - playout_stat: &PlayoutStatus, - player_control: &PlayerControl, + manager: &ChannelManager, 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 ({duration:.3}), adjust length."); + warn!( + target: Target::file_mail(), channel = config.general.channel_id; + "Clip is less then 1 second long ({duration:.3}), adjust length." + ); duration = 1.2; @@ -658,7 +632,8 @@ pub fn gen_source( 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); + warn!(target: Target::file_mail(), channel = config.general.channel_id; "Clip loops and has seek value: duplicate clip to separate loop and seek."); + duplicate_for_seek_and_loop(&mut node, &manager.current_list); } node.cmd = Some(seek_and_length(&mut node)); @@ -668,33 +643,35 @@ pub fn gen_source( // Last index is the index from the last item from the node list. if node_index < last_index { - error!("Source not found: {}", node.source); + error!(target: Target::file_mail(), channel = config.general.channel_id; "Source not found: {}", node.source); } - let mut filler_list = vec![]; + let mut fillers = vec![]; - match player_control.filler_list.try_lock() { - Ok(list) => filler_list = list.to_vec(), - Err(e) => error!("Lock filler list error: {e}"), + match manager.filler_list.try_lock() { + Ok(list) => fillers = list.to_vec(), + Err(e) => { + error!(target: Target::file_mail(), channel = config.general.channel_id; "Lock filler list error: {e}") + } } // Set list_init to true, to stay in sync. - playout_stat.list_init.store(true, Ordering::SeqCst); + manager.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(); + if config.storage.filler.is_dir() && !fillers.is_empty() { + let index = manager.filler_index.fetch_add(1, Ordering::SeqCst); + let mut filler_media = fillers[index].clone(); trace!("take filler: {}", filler_media.source); - if filler_index == filler_list.len() - 1 { + if index == fillers.len() - 1 { // reset index for next round - player_control.filler_index.store(0, Ordering::SeqCst) + manager.filler_index.store(0, Ordering::SeqCst) } if filler_media.probe.is_none() { if let Err(e) = filler_media.add_probe(false) { - error!("{e:?}"); + error!(target: Target::file_mail(), channel = config.general.channel_id; "{e:?}"); }; } @@ -752,7 +729,7 @@ pub fn gen_source( } Err(e) => { // Create colored placeholder. - error!("Filler error: {e}"); + error!(target: Target::file_mail(), channel = config.general.channel_id; "Filler error: {e}"); let mut dummy_duration = 60.0; @@ -771,12 +748,13 @@ pub fn gen_source( } warn!( + target: Target::file_mail(), channel = config.general.channel_id; "Generate filler with {:.2} seconds length!", node.out ); } - node.add_filter(config, &playout_stat.chain); + node.add_filter(config, &manager.filter_chain.clone()); trace!( "return gen_source: {}, seek: {}, out: {}", @@ -793,18 +771,17 @@ pub fn gen_source( fn handle_list_init( config: &PlayoutConfig, mut node: Media, - playout_stat: &PlayoutStatus, - player_control: &PlayerControl, + manager: &ChannelManager, last_index: usize, ) -> Media { - debug!("Playlist init"); + debug!(target: Target::file_mail(), channel = config.general.channel_id; "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) + gen_source(config, node, manager, last_index) } /// when we come to last clip in playlist, @@ -814,17 +791,16 @@ fn handle_list_end( config: &PlayoutConfig, mut node: Media, total_delta: f64, - playout_stat: &PlayoutStatus, - player_control: &PlayerControl, + manager: &ChannelManager, last_index: usize, ) -> Media { - debug!("Last clip from day"); + debug!(target: Target::file_mail(), channel = config.general.channel_id; "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: {total_delta:.2}"); + warn!(target: Target::file_mail(), channel = config.general.channel_id; "Adjust clip duration to: {total_delta:.2}"); } total_delta @@ -839,10 +815,10 @@ fn handle_list_end( { node.out = out; } else { - warn!("Playlist is not long enough: {total_delta:.2} seconds needed"); + warn!(target: Target::file_mail(), channel = config.general.channel_id; "Playlist is not long enough: {total_delta:.2} seconds needed"); } node.process = Some(true); - gen_source(config, node, playout_stat, player_control, last_index) + gen_source(config, node, manager, last_index) } diff --git a/ffplayout-engine/src/lib.rs b/ffplayout/src/player/mod.rs similarity index 56% rename from ffplayout-engine/src/lib.rs rename to ffplayout/src/player/mod.rs index 6defb246..5447c332 100644 --- a/ffplayout-engine/src/lib.rs +++ b/ffplayout/src/player/mod.rs @@ -1,4 +1,5 @@ +pub mod controller; +pub mod filter; pub mod input; pub mod output; -pub mod rpc; pub mod utils; diff --git a/ffplayout-engine/src/output/desktop.rs b/ffplayout/src/player/output/desktop.rs similarity index 73% rename from ffplayout-engine/src/output/desktop.rs rename to ffplayout/src/player/output/desktop.rs index b76054d2..085cdccc 100644 --- a/ffplayout-engine/src/output/desktop.rs +++ b/ffplayout/src/player/output/desktop.rs @@ -1,8 +1,10 @@ use std::process::{self, Command, Stdio}; -use simplelog::*; +use log::*; -use ffplayout_lib::{filter::v_drawtext, utils::PlayoutConfig, vec_strings}; +use crate::player::filter::v_drawtext; +use crate::utils::{config::PlayoutConfig, logging::Target}; +use crate::vec_strings; /// Desktop Output /// @@ -12,11 +14,7 @@ pub fn output(config: &PlayoutConfig, log_format: &str) -> process::Child { 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()) - { + if let Some(encoder_input_cmd) = &config.advanced.encoder.input_cmd { enc_cmd.append(&mut encoder_input_cmd.clone()); } @@ -28,7 +26,7 @@ pub fn output(config: &PlayoutConfig, log_format: &str) -> process::Child { "ffplayout" ]); - if let Some(mut cmd) = config.out.output_cmd.clone() { + if let Some(mut cmd) = config.output.output_cmd.clone() { if !cmd.iter().any(|i| { [ "-c", @@ -47,13 +45,13 @@ pub fn output(config: &PlayoutConfig, log_format: &str) -> process::Child { }) { enc_cmd.append(&mut cmd); } else { - warn!("ffplay doesn't support given output parameters, they will be skipped!"); + warn!(target: Target::file_mail(), channel = config.general.channel_id; "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!( + debug!(target: Target::file_mail(), channel = config.general.channel_id; "Using drawtext filter, listening on address: {}", socket ); @@ -66,7 +64,7 @@ pub fn output(config: &PlayoutConfig, log_format: &str) -> process::Child { enc_cmd.append(&mut enc_filter); - debug!( + debug!(target: Target::file_mail(), channel = config.general.channel_id; "Encoder CMD: \"ffplay {}\"", enc_cmd.join(" ") ); @@ -78,7 +76,7 @@ pub fn output(config: &PlayoutConfig, log_format: &str) -> process::Child { .spawn() { Err(e) => { - error!("couldn't spawn encoder process: {e}"); + error!(target: Target::file_mail(), channel = config.general.channel_id; "couldn't spawn encoder process: {e}"); panic!("couldn't spawn encoder process: {e}") } Ok(proc) => proc, diff --git a/ffplayout/src/player/output/hls.rs b/ffplayout/src/player/output/hls.rs new file mode 100644 index 00000000..3621b7f7 --- /dev/null +++ b/ffplayout/src/player/output/hls.rs @@ -0,0 +1,284 @@ +/* +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}, + process::{Command, Stdio}, + sync::atomic::Ordering, + thread::{self, sleep}, + time::{Duration, SystemTime}, +}; + +use log::*; + +use crate::utils::{logging::log_line, task_runner}; +use crate::vec_strings; +use crate::{ + player::{ + controller::{ChannelManager, ProcessUnit::*}, + input::source_generator, + utils::{ + get_delta, prepare_output_cmd, sec_to_time, stderr_reader, test_tcp_port, valid_stream, + Media, + }, + }, + utils::{errors::ProcessError, logging::Target}, +}; + +/// Ingest Server for HLS +fn ingest_to_hls_server(manager: ChannelManager) -> Result<(), ProcessError> { + let config = manager.config.lock().unwrap(); + let id = config.general.channel_id; + let playlist_init = manager.list_init.clone(); + let chain = manager.filter_chain.clone(); + + 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; + + let is_terminated = manager.is_terminated.clone(); + let ingest_is_running = manager.ingest_is_running.clone(); + + if let Some(ingest_input_cmd) = &config.advanced.ingest.input_cmd { + 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(id, url) { + manager.stop_all(); + } + + info!(target: Target::file_mail(), channel = id; "Start ingest server, listening on: {url}"); + }; + + drop(config); + + loop { + let config = manager.config.lock().unwrap().clone(); + dummy_media.add_filter(&config, &chain); + let server_cmd = prepare_output_cmd(&config, server_prefix.clone(), &dummy_media.filter); + + debug!(target: Target::file_mail(), channel = id; + "Server CMD: \"ffmpeg {}\"", + server_cmd.join(" ") + ); + + let proc_ctl = manager.clone(); + let mut server_proc = match Command::new("ffmpeg") + .args(server_cmd.clone()) + .stderr(Stdio::piped()) + .spawn() + { + Err(e) => { + error!(target: Target::file_mail(), channel = id; "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()); + *manager.ingest.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!(target: Target::file_mail(), channel = id; "{e}"); + }; + } + + if !is_running { + ingest_is_running.store(true, Ordering::SeqCst); + playlist_init.store(true, Ordering::SeqCst); + is_running = true; + + info!(target: Target::file_mail(), channel = id; "Switch from {} to live ingest", config.processing.mode); + + if let Err(e) = manager.stop(Decoder) { + error!(target: Target::file_mail(), channel = id; "{e}"); + } + } + + log_line(&line, &config.logging.ffmpeg_level); + } + + if ingest_is_running.load(Ordering::SeqCst) { + info!(target: Target::file_mail(), channel = id; "Switch from live ingest to {}", config.processing.mode); + } + + ingest_is_running.store(false, Ordering::SeqCst); + + if let Err(e) = manager.wait(Ingest) { + error!(target: Target::file_mail(), channel = id; "{e}") + } + + if is_terminated.load(Ordering::SeqCst) { + break; + } + } + + Ok(()) +} + +/// HLS Writer +/// +/// Write with single ffmpeg instance directly to a HLS playlist. +pub fn write_hls(manager: ChannelManager) -> Result<(), ProcessError> { + let config = manager.config.lock()?.clone(); + let id = config.general.channel_id; + let current_media = manager.current_media.clone(); + let is_terminated = manager.is_terminated.clone(); + + let ff_log_format = format!("level+{}", config.logging.ffmpeg_level.to_lowercase()); + + let channel_mgr_2 = manager.clone(); + let ingest_is_running = manager.ingest_is_running.clone(); + + 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 { + thread::spawn(move || ingest_to_hls_server(channel_mgr_2)); + } + + let mut error_count = 0; + + for node in get_source { + *current_media.lock().unwrap() = Some(node.clone()); + let ignore = config.logging.ignore_lines.clone(); + let timer = SystemTime::now(); + + if is_terminated.load(Ordering::SeqCst) { + break; + } + + let mut cmd = match &node.cmd { + Some(cmd) => cmd.clone(), + None => break, + }; + + if !node.process.unwrap() { + continue; + } + + info!(target: Target::file_mail(), channel = id; + "Play for {}: {}", + sec_to_time(node.out - node.seek), + node.source + ); + + if config.task.enable { + if config.task.path.is_file() { + let channel_mgr_3 = manager.clone(); + + thread::spawn(move || task_runner::run(channel_mgr_3)); + } else { + error!(target: Target::file_mail(), channel = id; + "{:?} executable not exists!", + config.task.path + ); + } + } + + let mut dec_prefix = vec_strings!["-hide_banner", "-nostats", "-v", &ff_log_format]; + + if let Some(decoder_input_cmd) = &config.advanced.decoder.input_cmd { + dec_prefix.append(&mut decoder_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; + } + } + + dec_prefix.append(&mut vec_strings!["-readrate", read_rate]); + + dec_prefix.append(&mut cmd); + let dec_cmd = prepare_output_cmd(&config, dec_prefix, &node.filter); + + debug!(target: Target::file_mail(), channel = id; + "HLS writer CMD: \"ffmpeg {}\"", + dec_cmd.join(" ") + ); + + let mut dec_proc = match Command::new("ffmpeg") + .args(dec_cmd) + .stderr(Stdio::piped()) + .spawn() + { + Ok(proc) => proc, + Err(e) => { + error!(target: Target::file_mail(), channel = id; "couldn't spawn ffmpeg process: {e}"); + panic!("couldn't spawn ffmpeg process: {e}") + } + }; + + let dec_err = BufReader::new(dec_proc.stderr.take().unwrap()); + *manager.decoder.lock().unwrap() = Some(dec_proc); + + if let Err(e) = stderr_reader(dec_err, ignore, Decoder, manager.clone()) { + error!(target: Target::file_mail(), channel = id; "{e:?}") + }; + + if let Err(e) = manager.wait(Decoder) { + error!(target: Target::file_mail(), channel = id; "{e}"); + } + + while ingest_is_running.load(Ordering::SeqCst) { + sleep(Duration::from_secs(1)); + } + + if let Ok(elapsed) = timer.elapsed() { + if elapsed.as_millis() < 300 { + error_count += 1; + + if error_count > 10 { + error!(target: Target::file_mail(), channel = id; "Reach fatal error count, terminate channel!"); + break; + } + } else { + error_count = 0; + } + } + } + + sleep(Duration::from_secs(1)); + + manager.stop_all(); + + Ok(()) +} diff --git a/ffplayout-engine/src/output/mod.rs b/ffplayout/src/player/output/mod.rs similarity index 60% rename from ffplayout-engine/src/output/mod.rs rename to ffplayout/src/player/output/mod.rs index 552a728b..368b7e5b 100644 --- a/ffplayout-engine/src/output/mod.rs +++ b/ffplayout/src/player/output/mod.rs @@ -3,11 +3,11 @@ use std::{ process::{Command, Stdio}, sync::atomic::Ordering, thread::{self, sleep}, - time::Duration, + time::{Duration, SystemTime}, }; use crossbeam_channel::bounded; -use simplelog::*; +use log::*; mod desktop; mod hls; @@ -16,14 +16,13 @@ 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 crate::player::{ + controller::{ChannelManager, ProcessUnit::*}, + input::{ingest_server, source_generator}, + utils::{sec_to_time, stderr_reader}, }; -use ffplayout_lib::vec_strings; +use crate::utils::{config::OutputMode::*, errors::ProcessError, logging::Target, task_runner}; +use crate::vec_strings; /// Player /// @@ -34,62 +33,63 @@ use ffplayout_lib::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( - config: &PlayoutConfig, - play_control: &PlayerControl, - playout_stat: PlayoutStatus, - proc_control: ProcessControl, -) { +pub fn player(manager: ChannelManager) -> Result<(), ProcessError> { + let config = manager.config.lock()?.clone(); + let id = config.general.channel_id; 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(); + let playlist_init = manager.list_init.clone(); + + let is_terminated = manager.is_terminated.clone(); + let ingest_is_running = manager.ingest_is_running.clone(); // get source iterator - let node_sources = source_generator( - config.clone(), - play_control, - playout_stat, - proc_control.is_terminated.clone(), - ); + let node_sources = source_generator(manager.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), + let mut enc_proc = match config.output.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(); + *manager.encoder.lock().unwrap() = Some(enc_proc); + let enc_p_ctl = manager.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 channel_mgr_2 = manager.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)); + thread::spawn(move || ingest_server(config_clone, ingest_sender, channel_mgr_2)); } - 'source_iter: for node in node_sources { - *play_control.current_media.lock().unwrap() = Some(node.clone()); - let ignore_dec = config.logging.ignore_lines.clone(); + drop(config); - if proc_control.is_terminated.load(Ordering::SeqCst) { - debug!("Playout is terminated, break out from source loop"); + let mut error_count = 0; + + 'source_iter: for node in node_sources { + let config = manager.config.lock()?.clone(); + + *manager.current_media.lock().unwrap() = Some(node.clone()); + let ignore_dec = config.logging.ignore_lines.clone(); + let timer = SystemTime::now(); + + if is_terminated.load(Ordering::SeqCst) { + debug!(target: Target::file_mail(), channel = id; "Playout is terminated, break out from source loop"); break; } @@ -111,13 +111,13 @@ pub fn player( format!( " ({}/{})", node.index.unwrap() + 1, - play_control.current_list.lock().unwrap().len() + manager.current_list.lock().unwrap().len() ) } else { String::new() }; - info!( + info!(target: Target::file_mail(), channel = id; "Play for {}{c_index}: {} {}", sec_to_time(node.out - node.seek), node.source, @@ -126,16 +126,11 @@ pub fn player( 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(); + let channel_mgr_3 = manager.clone(); - thread::spawn(move || { - task_runner::run(task_config, task_node, stat, server_running) - }); + thread::spawn(move || task_runner::run(channel_mgr_3)); } else { - error!( + error!(target: Target::file_mail(), channel = id; "{:?} executable not exists!", config.task.path ); @@ -144,11 +139,7 @@ pub fn player( 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()) - { + if let Some(decoder_input_cmd) = &config.advanced.decoder.input_cmd { dec_cmd.append(&mut decoder_input_cmd.clone()); } @@ -163,7 +154,7 @@ pub fn player( dec_cmd.append(&mut cmd); } - debug!( + debug!(target: Target::file_mail(), channel = id; "Decoder CMD: \"ffmpeg {}\"", dec_cmd.join(" ") ); @@ -177,7 +168,7 @@ pub fn player( { Ok(proc) => proc, Err(e) => { - error!("couldn't spawn decoder process: {e}"); + error!(target: Target::file_mail(), channel = id; "couldn't spawn decoder process: {e}"); panic!("couldn't spawn decoder process: {e}") } }; @@ -185,20 +176,20 @@ pub fn player( 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(); + *manager.clone().decoder.lock().unwrap() = Some(dec_proc); + let channel_mgr_c = manager.clone(); let error_decoder_thread = - thread::spawn(move || stderr_reader(dec_err, ignore_dec, Decoder, dec_p_ctl)); + thread::spawn(move || stderr_reader(dec_err, ignore_dec, Decoder, channel_mgr_c)); loop { // when server is running, read from it - if proc_control.server_is_running.load(Ordering::SeqCst) { + if ingest_is_running.load(Ordering::SeqCst) { if !live_on { - info!("Switch from {} to live ingest", config.processing.mode); + info!(target: Target::file_mail(), channel = id; "Switch from {} to live ingest", config.processing.mode); - if let Err(e) = proc_control.stop(Decoder) { - error!("{e}") + if let Err(e) = manager.stop(Decoder) { + error!(target: Target::file_mail(), channel = id; "{e}") } live_on = true; @@ -207,7 +198,7 @@ pub fn player( 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); + error!(target: Target::file_mail(), channel = id; "Error from Ingest: {:?}", e); break 'source_iter; }; @@ -215,7 +206,7 @@ pub fn player( // read from decoder instance } else { if live_on { - info!("Switch from live ingest to {}", config.processing.mode); + info!(target: Target::file_mail(), channel = id; "Switch from live ingest to {}", config.processing.mode); live_on = false; break; @@ -224,7 +215,7 @@ pub fn player( let dec_bytes_len = match dec_reader.read(&mut buffer[..]) { Ok(length) => length, Err(e) => { - error!("Reading error from decoder: {e:?}"); + error!(target: Target::file_mail(), channel = id; "Reading error from decoder: {e:?}"); break 'source_iter; } @@ -232,7 +223,7 @@ pub fn player( if dec_bytes_len > 0 { if let Err(e) = enc_writer.write(&buffer[..dec_bytes_len]) { - error!("Encoder write error: {}", e.kind()); + error!(target: Target::file_mail(), channel = id; "Encoder write error: {}", e.kind()); break 'source_iter; }; @@ -242,22 +233,37 @@ pub fn player( } } - if let Err(e) = proc_control.wait(Decoder) { - error!("{e}") + if let Err(e) = manager.wait(Decoder) { + error!(target: Target::file_mail(), channel = id; "{e}") } if let Err(e) = error_decoder_thread.join() { - error!("{e:?}"); + error!(target: Target::file_mail(), channel = id; "{e:?}"); }; + + if let Ok(elapsed) = timer.elapsed() { + if elapsed.as_millis() < 300 { + error_count += 1; + + if error_count > 10 { + error!(target: Target::file_mail(), channel = id; "Reach fatal error count, terminate channel!"); + break; + } + } else { + error_count = 0; + } + } } trace!("Out of source loop"); sleep(Duration::from_secs(1)); - proc_control.stop_all(); + manager.stop_all(); if let Err(e) = error_encoder_thread.join() { - error!("{e:?}"); + error!(target: Target::file_mail(), channel = id; "{e:?}"); }; + + Ok(()) } diff --git a/ffplayout-engine/src/output/null.rs b/ffplayout/src/player/output/null.rs similarity index 68% rename from ffplayout-engine/src/output/null.rs rename to ffplayout/src/player/output/null.rs index 7dfaefba..42c2d26e 100644 --- a/ffplayout-engine/src/output/null.rs +++ b/ffplayout/src/player/output/null.rs @@ -1,28 +1,26 @@ use std::process::{self, Command, Stdio}; -use simplelog::*; +use log::*; -use crate::utils::prepare_output_cmd; -use ffplayout_lib::{ - utils::{Media, PlayoutConfig, ProcessUnit::*}, - vec_strings, +use crate::player::{ + controller::ProcessUnit::*, + utils::{prepare_output_cmd, Media}, }; +use crate::utils::{config::PlayoutConfig, logging::Target}; +use crate::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); + let id = config.general.channel_id; 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()) - { + if let Some(input_cmd) = &config.advanced.encoder.input_cmd { enc_prefix.append(&mut input_cmd.clone()); } @@ -30,7 +28,7 @@ pub fn output(config: &PlayoutConfig, log_format: &str) -> process::Child { let enc_cmd = prepare_output_cmd(config, enc_prefix, &media.filter); - debug!( + debug!(target: Target::file_mail(), channel = id; "Encoder CMD: \"ffmpeg {}\"", enc_cmd.join(" ") ); @@ -42,7 +40,7 @@ pub fn output(config: &PlayoutConfig, log_format: &str) -> process::Child { .spawn() { Err(e) => { - error!("couldn't spawn encoder process: {e}"); + error!(target: Target::file_mail(), channel = id; "couldn't spawn encoder process: {e}"); panic!("couldn't spawn encoder process: {e}") } Ok(proc) => proc, diff --git a/ffplayout-engine/src/output/stream.rs b/ffplayout/src/player/output/stream.rs similarity index 68% rename from ffplayout-engine/src/output/stream.rs rename to ffplayout/src/player/output/stream.rs index dda5060d..2061b103 100644 --- a/ffplayout-engine/src/output/stream.rs +++ b/ffplayout/src/player/output/stream.rs @@ -1,28 +1,26 @@ use std::process::{self, Command, Stdio}; -use simplelog::*; +use log::*; -use crate::utils::prepare_output_cmd; -use ffplayout_lib::{ - utils::{Media, PlayoutConfig, ProcessUnit::*}, - vec_strings, +use crate::player::{ + controller::ProcessUnit::*, + utils::{prepare_output_cmd, Media}, }; +use crate::utils::{config::PlayoutConfig, logging::Target}; +use crate::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); + let id = config.general.channel_id; 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()) - { + if let Some(input_cmd) = &config.advanced.encoder.input_cmd { enc_prefix.append(&mut input_cmd.clone()); } @@ -30,7 +28,7 @@ pub fn output(config: &PlayoutConfig, log_format: &str) -> process::Child { let enc_cmd = prepare_output_cmd(config, enc_prefix, &media.filter); - debug!( + debug!(target: Target::file_mail(), channel = id; "Encoder CMD: \"ffmpeg {}\"", enc_cmd.join(" ") ); @@ -42,7 +40,7 @@ pub fn output(config: &PlayoutConfig, log_format: &str) -> process::Child { .spawn() { Err(e) => { - error!("couldn't spawn encoder process: {e}"); + error!(target: Target::file_mail(), channel = id; "couldn't spawn encoder process: {e}"); panic!("couldn't spawn encoder process: {e}") } Ok(proc) => proc, diff --git a/lib/src/utils/folder.rs b/ffplayout/src/player/utils/folder.rs similarity index 60% rename from lib/src/utils/folder.rs rename to ffplayout/src/player/utils/folder.rs index 4cb616bd..e50af350 100644 --- a/lib/src/utils/folder.rs +++ b/ffplayout/src/player/utils/folder.rs @@ -4,46 +4,48 @@ use std::sync::{ }; use lexical_sort::natural_lexical_cmp; +use log::*; use rand::{seq::SliceRandom, thread_rng}; -use simplelog::*; use walkdir::WalkDir; -use crate::utils::{ - controller::PlayerControl, include_file_extension, time_in_seconds, Media, PlayoutConfig, +use crate::player::{ + controller::ChannelManager, + utils::{include_file_extension, time_in_seconds, Media, PlayoutConfig}, }; +use crate::utils::logging::Target; /// 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>>>, - pub player_control: PlayerControl, + manager: ChannelManager, current_node: Media, } impl FolderSource { - pub fn new( - config: &PlayoutConfig, - filter_chain: Option>>>, - player_control: &PlayerControl, - ) -> Self { + pub fn new(config: &PlayoutConfig, manager: ChannelManager) -> Self { + let id = config.general.channel_id; let mut path_list = vec![]; let mut media_list = vec![]; let mut index: usize = 0; + debug!(target: Target::file_mail(), channel = id; + "generate: {:?}, paths: {:?}", + config.general.generate, config.storage.paths + ); + 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) + path_list.push(&config.global.storage_path) } for path in &path_list { if !path.is_dir() { - error!("Path not exists: {path:?}"); + error!(target: Target::file_mail(), channel = id; "Path not exists: {path:?}"); } for entry in WalkDir::new(path) @@ -58,14 +60,14 @@ impl FolderSource { } if media_list.is_empty() { - error!( + error!(target: Target::file_mail(), channel = id; "no playable files found under: {:?}", path_list ); } if config.storage.shuffle { - info!("Shuffle files"); + info!(target: Target::file_mail(), channel = id; "Shuffle files"); let mut rng = thread_rng(); media_list.shuffle(&mut rng); } else { @@ -78,35 +80,26 @@ impl FolderSource { index += 1; } - *player_control.current_list.lock().unwrap() = media_list; + *manager.current_list.lock().unwrap() = media_list; Self { - config: config.clone(), - filter_chain, - player_control: player_control.clone(), + manager, current_node: Media::new(0, "", false), } } - pub fn from_list( - config: &PlayoutConfig, - filter_chain: Option>>>, - player_control: &PlayerControl, - list: Vec, - ) -> Self { - *player_control.current_list.lock().unwrap() = list; + pub fn from_list(manager: &ChannelManager, list: Vec) -> Self { + *manager.current_list.lock().unwrap() = list; Self { - config: config.clone(), - filter_chain, - player_control: player_control.clone(), + manager: manager.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(); + let mut nodes = self.manager.current_list.lock().unwrap(); nodes.shuffle(&mut rng); @@ -116,7 +109,7 @@ impl FolderSource { } fn sort(&mut self) { - let mut nodes = self.player_control.current_list.lock().unwrap(); + let mut nodes = self.manager.current_list.lock().unwrap(); nodes.sort_by(|d1, d2| d1.source.cmp(&d2.source)); @@ -131,43 +124,44 @@ impl Iterator for FolderSource { type Item = Media; fn next(&mut self) -> Option { - if self.player_control.current_index.load(Ordering::SeqCst) - < self.player_control.current_list.lock().unwrap().len() + let config = self.manager.config.lock().unwrap().clone(); + let id = config.general.id; + + if self.manager.current_index.load(Ordering::SeqCst) + < self.manager.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 i = self.manager.current_index.load(Ordering::SeqCst); + self.current_node = self.manager.current_list.lock().unwrap()[i].clone(); let _ = self.current_node.add_probe(false).ok(); self.current_node - .add_filter(&self.config, &self.filter_chain); + .add_filter(&config, &self.manager.filter_chain); self.current_node.begin = Some(time_in_seconds()); - self.player_control - .current_index - .fetch_add(1, Ordering::SeqCst); + self.manager.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"); + if config.storage.shuffle { + if config.general.generate.is_none() { + info!(target: Target::file_mail(), channel = id; "Shuffle files"); } self.shuffle(); } else { - if self.config.general.generate.is_none() { - info!("Sort files"); + if config.general.generate.is_none() { + info!(target: Target::file_mail(), channel = id; "Sort files"); } self.sort(); } - self.current_node = self.player_control.current_list.lock().unwrap()[0].clone(); + self.current_node = self.manager.current_list.lock().unwrap()[0].clone(); let _ = self.current_node.add_probe(false).ok(); self.current_node - .add_filter(&self.config, &self.filter_chain); + .add_filter(&config, &self.manager.filter_chain); self.current_node.begin = Some(time_in_seconds()); - self.player_control.current_index.store(1, Ordering::SeqCst); + self.manager.current_index.store(1, Ordering::SeqCst); Some(self.current_node.clone()) } @@ -176,8 +170,9 @@ impl Iterator for FolderSource { pub fn fill_filler_list( config: &PlayoutConfig, - player_control: Option, + fillers: Option>>>, ) -> Vec { + let id = config.general.channel_id; let mut filler_list = vec![]; let filler_path = &config.storage.filler; @@ -191,9 +186,9 @@ pub fn fill_filler_list( { let mut media = Media::new(index, &entry.path().to_string_lossy(), false); - if player_control.is_none() { + if fillers.is_none() { if let Err(e) = media.add_probe(false) { - error!("{e:?}"); + error!(target: Target::file_mail(), channel = id; "{e:?}"); }; } @@ -212,22 +207,22 @@ pub fn fill_filler_list( item.index = Some(index); } - if let Some(control) = player_control.as_ref() { - control.filler_list.lock().unwrap().clone_from(&filler_list); + if let Some(f) = fillers.as_ref() { + f.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 fillers.is_none() { if let Err(e) = media.add_probe(false) { - error!("{e:?}"); + error!(target: Target::file_mail(), channel = id; "{e:?}"); }; } filler_list.push(media); - if let Some(control) = player_control.as_ref() { - control.filler_list.lock().unwrap().clone_from(&filler_list); + if let Some(f) = fillers.as_ref() { + f.lock().unwrap().clone_from(&filler_list); } } diff --git a/lib/src/utils/import.rs b/ffplayout/src/player/utils/import.rs similarity index 90% rename from lib/src/utils/import.rs rename to ffplayout/src/player/utils/import.rs index b8eee867..3f86eb4b 100644 --- a/lib/src/utils/import.rs +++ b/ffplayout/src/player/utils/import.rs @@ -6,7 +6,9 @@ use std::{ path::Path, }; -use crate::utils::{json_reader, json_serializer::JsonPlaylist, json_writer, Media, PlayoutConfig}; +use crate::player::utils::{ + json_reader, json_serializer::JsonPlaylist, json_writer, Media, PlayoutConfig, +}; pub fn import_file( config: &PlayoutConfig, @@ -26,13 +28,13 @@ pub fn import_file( program: vec![], }; - let playlist_root = &config.playlist.path; + let playlist_root = &config.global.playlist_path; if !playlist_root.is_dir() { return Err(Error::new( ErrorKind::Other, format!( "Playlist folder {:?} not exists!", - config.playlist.path, + config.global.playlist_path, ), )); } diff --git a/lib/src/utils/json_serializer.rs b/ffplayout/src/player/utils/json_serializer.rs similarity index 83% rename from lib/src/utils/json_serializer.rs rename to ffplayout/src/player/utils/json_serializer.rs index 3e0878c6..2eed648f 100644 --- a/lib/src/utils/json_serializer.rs +++ b/ffplayout/src/player/utils/json_serializer.rs @@ -2,16 +2,17 @@ use serde::{Deserialize, Serialize}; use std::{ fs::File, path::Path, - sync::{atomic::AtomicBool, Arc}, + sync::{atomic::AtomicBool, Arc, Mutex}, thread, }; -use simplelog::*; +use log::*; -use crate::utils::{ - get_date, is_remote, modified_time, time_from_header, validate_playlist, Media, PlayerControl, - PlayoutConfig, DUMMY_LEN, +use crate::player::utils::{ + get_date, is_remote, json_validate::validate_playlist, modified_time, time_from_header, Media, + PlayoutConfig, }; +use crate::utils::{config::DUMMY_LEN, logging::Target}; /// This is our main playlist object, it holds all necessary information for the current day. #[derive(Debug, Serialize, Deserialize, Clone)] @@ -91,19 +92,19 @@ pub fn set_defaults(playlist: &mut JsonPlaylist) { /// which we need to process. pub fn read_json( config: &mut PlayoutConfig, - player_control: &PlayerControl, + current_list: Arc>>, path: Option, is_terminated: Arc, seek: bool, get_next: bool, ) -> JsonPlaylist { + let id = config.general.channel_id; let config_clone = config.clone(); - let control_clone = player_control.clone(); - let mut playlist_path = config.playlist.path.clone(); + let mut playlist_path = config.global.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()) { + if playlist_path.is_dir() || is_remote(&config.global.playlist_path.to_string_lossy()) { let d: Vec<&str> = date.split('-').collect(); playlist_path = playlist_path .join(d[0]) @@ -130,7 +131,7 @@ pub fn read_json( let mut playlist: JsonPlaylist = match serde_json::from_str(&body) { Ok(p) => p, Err(e) => { - error!("Could't read remote json playlist. {e:?}"); + error!(target: Target::file_mail(), channel = id; "Could't read remote json playlist. {e:?}"); JsonPlaylist::new(date.clone(), start_sec) } }; @@ -146,12 +147,7 @@ pub fn read_json( if !config.general.skip_validation { thread::spawn(move || { - validate_playlist( - config_clone, - control_clone, - list_clone, - is_terminated, - ) + validate_playlist(config_clone, current_list, list_clone, is_terminated) }); } @@ -172,7 +168,7 @@ pub fn read_json( let mut playlist: JsonPlaylist = match serde_json::from_reader(f) { Ok(p) => p, Err(e) => { - error!("Playlist file not readable! {e}"); + error!(target: Target::file_mail(), channel = id; "Playlist file not readable! {e}"); JsonPlaylist::new(date.clone(), start_sec) } }; @@ -190,7 +186,7 @@ pub fn read_json( if !config.general.skip_validation { thread::spawn(move || { - validate_playlist(config_clone, control_clone, list_clone, is_terminated) + validate_playlist(config_clone, current_list, list_clone, is_terminated) }); } @@ -199,7 +195,7 @@ pub fn read_json( return playlist; } - error!("Playlist {current_file} not exist!"); + error!(target: Target::file_mail(), channel = id; "Playlist {current_file} not exist!"); JsonPlaylist::new(date, start_sec) } diff --git a/lib/src/utils/json_validate.rs b/ffplayout/src/player/utils/json_validate.rs similarity index 84% rename from lib/src/utils/json_validate.rs rename to ffplayout/src/player/utils/json_validate.rs index fb94f00e..9b5ca69e 100644 --- a/lib/src/utils/json_validate.rs +++ b/ffplayout/src/player/utils/json_validate.rs @@ -3,20 +3,24 @@ use std::{ process::{Command, Stdio}, sync::{ atomic::{AtomicBool, Ordering}, - Arc, + Arc, Mutex, }, time::Instant, }; +use log::*; 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, +use crate::player::filter::FilterType::Audio; +use crate::player::utils::{ + is_close, is_remote, loop_image, sec_to_time, seek_and_length, JsonPlaylist, Media, }; +use crate::utils::{ + config::{OutputMode::Null, PlayoutConfig, FFMPEG_IGNORE_ERRORS, IMAGE_FORMAT}, + errors::ProcessError, + logging::Target, +}; +use crate::vec_strings; /// Validate a single media file. /// @@ -29,19 +33,16 @@ fn check_media( pos: usize, begin: f64, config: &PlayoutConfig, -) -> Result<(), ProcError> { +) -> Result<(), ProcessError> { + let id = config.general.channel_id; 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; + config.output.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()) - { + if let Some(decoder_input_cmd) = &config.advanced.decoder.input_cmd { dec_cmd.append(&mut decoder_input_cmd.clone()); } @@ -129,7 +130,7 @@ fn check_media( } if !error_list.is_empty() { - error!( + error!(target: Target::file_mail(), channel = id; "[Validator] ffmpeg error on position {pos} - {}: {}: {}", sec_to_time(begin), node.source, @@ -140,7 +141,7 @@ fn check_media( error_list.clear(); if let Err(e) = enc_proc.wait() { - error!("Validation process: {e:?}"); + error!(target: Target::file_mail(), channel = id; "Validation process: {e:?}"); } Ok(()) @@ -155,10 +156,11 @@ fn check_media( /// This function we run in a thread, to don't block the main function. pub fn validate_playlist( mut config: PlayoutConfig, - player_control: PlayerControl, + current_list: Arc>>, mut playlist: JsonPlaylist, is_terminated: Arc, ) { + let id = config.general.channel_id; let date = playlist.date; if config.text.add_text && !config.text.text_from_filename { @@ -171,7 +173,7 @@ pub fn validate_playlist( length += begin; - debug!("Validate playlist from: {date}"); + debug!(target: Target::file_mail(), channel = id; "Validate playlist from: {date}"); let timer = Instant::now(); for (index, item) in playlist.program.iter_mut().enumerate() { @@ -184,13 +186,13 @@ pub fn validate_playlist( if !is_remote(&item.source) { if item.audio.is_empty() { if let Err(e) = item.add_probe(false) { - error!( + error!(target: Target::file_mail(), channel = id; "[Validation] Error on position {pos:0>3} {}: {e}", sec_to_time(begin) ); } } else if let Err(e) = item.add_probe(true) { - error!( + error!(target: Target::file_mail(), channel = id; "[Validation] Error on position {pos:0>3} {}: {e}", sec_to_time(begin) ); @@ -199,14 +201,14 @@ pub fn validate_playlist( if item.probe.is_some() { if let Err(e) = check_media(item.clone(), pos, begin, &config) { - error!("{e}"); + error!(target: Target::file_mail(), channel = id; "{e}"); } else if config.general.validate { - debug!( + debug!(target: Target::file_mail(), channel = id; "[Validation] Source at {}, seems fine: {}", sec_to_time(begin), item.source ) - } else if let Ok(mut list) = player_control.current_list.try_lock() { + } else if let Ok(mut list) = 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| { @@ -218,7 +220,7 @@ pub fn validate_playlist( let probe_duration = dur.parse().unwrap_or_default(); if !is_close(o.duration, probe_duration, 1.2) { - error!( + error!(target: Target::file_mail(), channel = id; "[Validation] File duration (at: {}) differs from playlist value. File duration: {}, playlist value: {}, source {}", sec_to_time(o.begin.unwrap_or_default()), sec_to_time(probe_duration), sec_to_time(o.duration), o.source ); @@ -239,20 +241,20 @@ pub fn validate_playlist( } if !config.playlist.infinit && length > begin + 1.2 { - error!( + error!(target: Target::file_mail(), channel = id; "[Validation] Playlist from {date} not long enough, {} needed!", sec_to_time(length - begin), ); } if config.general.validate { - info!( + info!(target: Target::file_mail(), channel = id; "[Validation] Playlist length: {}", sec_to_time(begin - config.playlist.start_sec.unwrap()) ); } - debug!( + debug!(target: Target::file_mail(), channel = id; "Validation done, in {:.3?}, playlist length: {} ...", timer.elapsed(), sec_to_time(begin - config.playlist.start_sec.unwrap()) diff --git a/lib/src/utils/mod.rs b/ffplayout/src/player/utils/mod.rs similarity index 81% rename from lib/src/utils/mod.rs rename to ffplayout/src/player/utils/mod.rs index cdae30d9..89ea3704 100644 --- a/lib/src/utils/mod.rs +++ b/ffplayout/src/player/utils/mod.rs @@ -1,56 +1,181 @@ use std::{ ffi::OsStr, fmt, - fs::{self, metadata, File}, + fs::{metadata, File}, io::{BufRead, BufReader, Error}, net::TcpListener, path::{Path, PathBuf}, process::{exit, ChildStderr, Command, Stdio}, str::FromStr, - sync::{Arc, Mutex}, + sync::{atomic::Ordering, Arc, Mutex}, }; use chrono::{prelude::*, TimeDelta}; use ffprobe::{ffprobe, Stream as FFStream}; +use log::*; use rand::prelude::*; use regex::Regex; use reqwest::header; use serde::{de::Deserializer, Deserialize, Serialize}; -use serde_json::json; -use simplelog::*; +use serde_json::{json, Map, Value}; -pub mod advanced_config; -pub mod config; -pub mod controller; -pub mod errors; pub mod folder; -pub mod generator; pub mod import; pub mod json_serializer; -mod json_validate; -mod logging; +pub mod json_validate; -pub use config::{ - self as playout_config, - OutputMode::{self, *}, - PlayoutConfig, - ProcessMode::{self, *}, - Template, DUMMY_LEN, FFMPEG_IGNORE_ERRORS, FFMPEG_UNRECOVERABLE_ERRORS, IMAGE_FORMAT, -}; -pub use controller::{ - PlayerControl, PlayoutStatus, ProcessControl, - ProcessUnit::{self, *}, -}; -use errors::ProcError; -pub use generator::generate_playlist; -pub use json_serializer::{read_json, JsonPlaylist}; -pub use json_validate::validate_playlist; -pub use logging::{init_logging, send_mail}; - -use crate::{ +use crate::player::{ + controller::{ + ChannelManager, + ProcessUnit::{self, *}, + }, filter::{filter_chains, Filters}, - vec_strings, }; +use crate::utils::{ + config::{OutputMode::*, PlayoutConfig, FFMPEG_IGNORE_ERRORS, FFMPEG_UNRECOVERABLE_ERRORS}, + errors::ProcessError, + logging::Target, +}; +pub use json_serializer::{read_json, JsonPlaylist}; + +use crate::vec_strings; + +/// 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, + filters: &Option, +) -> Vec { + let mut output_params = config.output.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(manager: &ChannelManager) -> Map { + let media = manager + .current_media + .lock() + .unwrap() + .clone() + .unwrap_or(Media::new(0, "", false)); + let channel = manager.channel.lock().unwrap().clone(); + let config = manager.config.lock().unwrap().processing.clone(); + let ingest_is_running = manager.ingest_is_running.load(Ordering::SeqCst); + + let mut data_map = Map::new(); + let current_time = time_in_seconds(); + let shift = channel.time_shift; + 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!(ingest_is_running)); + data_map.insert("mode".to_string(), json!(config.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 +} /// Video clip struct to hold some important states and comments for current media. #[derive(Debug, Serialize, Deserialize, Clone)] @@ -250,7 +375,7 @@ pub struct MediaProbe { } impl MediaProbe { - pub fn new(input: &str) -> Result { + pub fn new(input: &str) -> Result { let probe = ffprobe(input); let mut a_stream = vec![]; let mut v_stream = vec![]; @@ -279,11 +404,11 @@ impl MediaProbe { } Err(e) => { if !Path::new(input).is_file() && !is_remote(input) { - Err(ProcError::Custom(format!( + Err(ProcessError::Custom(format!( "File {input} not exist!" ))) } else { - Err(ProcError::Ffprobe(e)) + Err(ProcessError::Ffprobe(e)) } } } @@ -319,34 +444,6 @@ pub fn json_writer(path: &PathBuf, data: JsonPlaylist) -> Result<(), Error> { Ok(()) } -/// Write current status to status file in temp folder. -/// -/// The status file is init in main function and mostly modified in RPC server. -pub fn write_status(config: &PlayoutConfig, date: &str, shift: f64) { - let data = json!({ - "time_shift": shift, - "date": date, - }); - - match serde_json::to_string(&data) { - Ok(status) => { - if let Err(e) = fs::write(&config.general.stat_file, status) { - error!( - "Unable to write to status file {}: {e}", - config.general.stat_file - ) - }; - } - Err(e) => error!("Serialize status data failed: {e}"), - }; -} - -// pub fn get_timestamp() -> i32 { -// let local: DateTime = time_now(); - -// local.timestamp_millis() as i32 -// } - /// Get current time in seconds. pub fn time_in_seconds() -> f64 { let local: DateTime = time_now(); @@ -639,9 +736,9 @@ pub fn include_file_extension(config: &PlayoutConfig, file_path: &Path) -> bool } } - if config.out.mode == HLS { + if config.output.mode == HLS { if let Some(ts_path) = config - .out + .output .output_cmd .clone() .unwrap_or_else(|| vec![String::new()]) @@ -656,7 +753,7 @@ pub fn include_file_extension(config: &PlayoutConfig, file_path: &Path) -> bool } if let Some(m3u8_path) = config - .out + .output .output_cmd .clone() .unwrap_or_else(|| vec![String::new()]) @@ -680,8 +777,9 @@ pub fn stderr_reader( buffer: BufReader, ignore: Vec, suffix: ProcessUnit, - proc_control: ProcessControl, -) -> Result<(), Error> { + manager: ChannelManager, +) -> Result<(), ProcessError> { + let id = manager.channel.lock().unwrap().id; for line in buffer.lines() { let line = line?; @@ -692,17 +790,17 @@ pub fn stderr_reader( } if line.contains("[info]") { - info!( + info!(target: Target::file_mail(), channel = id; "[{suffix}] {}", line.replace("[info] ", "") ) } else if line.contains("[warning]") { - warn!( + warn!(target: Target::file_mail(), channel = id; "[{suffix}] {}", line.replace("[warning] ", "") ) } else if line.contains("[error]") || line.contains("[fatal]") { - error!( + error!(target: Target::file_mail(), channel = id; "[{suffix}] {}", line.replace("[error] ", "").replace("[fatal] ", "") ); @@ -713,8 +811,7 @@ pub fn stderr_reader( || (line.contains("No such file or directory") && !line.contains("failed to delete old segment")) { - proc_control.stop_all(); - exit(1); + manager.stop_all(); } } } @@ -741,6 +838,7 @@ fn is_in_system(name: &str) -> Result<(), String> { } fn ffmpeg_filter_and_libs(config: &mut PlayoutConfig) -> Result<(), String> { + let id = config.general.channel_id; let ignore_flags = [ "--enable-gpl", "--enable-version3", @@ -800,7 +898,7 @@ fn ffmpeg_filter_and_libs(config: &mut PlayoutConfig) -> Result<(), String> { } if let Err(e) = ff_proc.wait() { - error!("{e}") + error!(target: Target::file_mail(), channel = id; "{e}") }; Ok(()) @@ -813,14 +911,14 @@ pub fn validate_ffmpeg(config: &mut PlayoutConfig) -> Result<(), String> { is_in_system("ffmpeg")?; is_in_system("ffprobe")?; - if config.out.mode == Desktop { + if config.output.mode == Desktop { is_in_system("ffplay")?; } ffmpeg_filter_and_libs(config)?; if config - .out + .output .output_cmd .as_ref() .unwrap() @@ -841,7 +939,7 @@ pub fn validate_ffmpeg(config: &mut PlayoutConfig) -> Result<(), String> { } if config - .out + .output .output_cmd .as_ref() .unwrap() @@ -872,7 +970,7 @@ pub fn free_tcp_socket(exclude_socket: String) -> Option { } /// check if tcp port is free -pub fn test_tcp_port(url: &str) -> bool { +pub fn test_tcp_port(id: i32, url: &str) -> bool { let re = Regex::new(r"^[\w]+\://").unwrap(); let mut addr = url.to_string(); @@ -891,19 +989,19 @@ pub fn test_tcp_port(url: &str) -> bool { } }; - error!("Address {url} already in use!"); + error!(target: Target::file_mail(), channel = id; "Address {url} already in use!"); false } /// Generate a vector with dates, from given range. -pub fn get_date_range(date_range: &[String]) -> Vec { +pub fn get_date_range(id: i32, date_range: &[String]) -> Vec { let mut range = vec![]; let start = match NaiveDate::parse_from_str(&date_range[0], "%Y-%m-%d") { Ok(s) => s, Err(_) => { - error!("date format error in: {:?}", date_range[0]); + error!(target: Target::file_mail(), channel = id; "date format error in: {:?}", date_range[0]); exit(1); } }; @@ -911,7 +1009,7 @@ pub fn get_date_range(date_range: &[String]) -> Vec { let end = match NaiveDate::parse_from_str(&date_range[2], "%Y-%m-%d") { Ok(e) => e, Err(_) => { - error!("date format error in: {:?}", date_range[2]); + error!(target: Target::file_mail(), channel = id; "date format error in: {:?}", date_range[2]); exit(1); } }; diff --git a/ffplayout-api/src/sse/broadcast.rs b/ffplayout/src/sse/broadcast.rs similarity index 73% rename from ffplayout-api/src/sse/broadcast.rs rename to ffplayout/src/sse/broadcast.rs index 5ec9e853..88b4984b 100644 --- a/ffplayout-api/src/sse/broadcast.rs +++ b/ffplayout/src/sse/broadcast.rs @@ -1,4 +1,7 @@ -use std::{sync::Arc, time::Duration}; +use std::{ + sync::{atomic::Ordering, Arc}, + time::Duration, +}; use actix_web::{rt::time::interval, web}; use actix_web_lab::{ @@ -6,31 +9,24 @@ use actix_web_lab::{ 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}; +use crate::player::{controller::ChannelManager, utils::get_data_map}; +use crate::utils::system; #[derive(Debug, Clone)] struct Client { - _channel: i32, - config: PlayoutConfig, + manager: ChannelManager, endpoint: String, sender: mpsc::Sender, } impl Client { - fn new( - _channel: i32, - config: PlayoutConfig, - endpoint: String, - sender: mpsc::Sender, - ) -> Self { + fn new(manager: ChannelManager, endpoint: String, sender: mpsc::Sender) -> Self { Self { - _channel, - config, + manager, endpoint, sender, } @@ -103,8 +99,7 @@ impl Broadcaster { /// Registers client with broadcaster, returning an SSE response body. pub async fn new_client( &self, - channel: i32, - config: PlayoutConfig, + manager: ChannelManager, endpoint: String, ) -> Sse>> { let (tx, rx) = mpsc::channel(10); @@ -114,7 +109,7 @@ impl Broadcaster { self.inner .lock() .clients - .push(Client::new(channel, config, endpoint, tx)); + .push(Client::new(manager, endpoint, tx)); Sse::from_infallible_receiver(rx) } @@ -124,23 +119,22 @@ impl Broadcaster { 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; - } - }; + let media_map = get_data_map(&client.manager); + + if client.manager.is_alive.load(Ordering::SeqCst) { + let _ = client + .sender + .send( + sse::Data::new(serde_json::to_string(&media_map).unwrap_or_default()) + .into(), + ) + .await; + } else { + let _ = client + .sender + .send(sse::Data::new("not running").into()) + .await; + } } } @@ -150,7 +144,8 @@ impl Broadcaster { for client in clients { if &client.endpoint == "system" { - if let Ok(stat) = web::block(move || system::stat(client.config.clone())).await { + let config = client.manager.config.lock().unwrap().clone(); + if let Ok(stat) = web::block(move || system::stat(config.clone())).await { let stat_string = stat.to_string(); let _ = client.sender.send(sse::Data::new(stat_string).into()).await; }; diff --git a/ffplayout-api/src/sse/mod.rs b/ffplayout/src/sse/mod.rs similarity index 97% rename from ffplayout-api/src/sse/mod.rs rename to ffplayout/src/sse/mod.rs index 834573ea..2ef1e1f8 100644 --- a/ffplayout-api/src/sse/mod.rs +++ b/ffplayout/src/sse/mod.rs @@ -32,7 +32,7 @@ impl Default for UuidData { } } -pub struct AuthState { +pub struct SseAuthState { pub uuids: Mutex>, } diff --git a/ffplayout-api/src/sse/routes.rs b/ffplayout/src/sse/routes.rs similarity index 71% rename from ffplayout-api/src/sse/routes.rs rename to ffplayout/src/sse/routes.rs index a33bf02b..cce4862d 100644 --- a/ffplayout-api/src/sse/routes.rs +++ b/ffplayout/src/sse/routes.rs @@ -1,11 +1,14 @@ +use std::sync::Mutex; + 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 super::{check_uuid, prune_uuids, SseAuthState, UuidData}; +use crate::db::models::Role; +use crate::player::controller::ChannelController; use crate::sse::broadcast::Broadcaster; -use crate::utils::{errors::ServiceError, playout_config, Role}; +use crate::utils::errors::ServiceError; #[derive(Deserialize, Serialize)] struct User { @@ -26,8 +29,11 @@ impl User { /// curl -X GET 'http://127.0.0.1:8787/api/generate-uuid' -H 'Authorization: Bearer ' /// ``` #[post("/generate-uuid")] -#[protect(any("Role::Admin", "Role::User"), ty = "Role")] -async fn generate_uuid(data: web::Data) -> Result { +#[protect( + any("Role::GlobalAdmin", "Role::ChannelAdmin", "Role::User"), + ty = "Role" +)] +async fn generate_uuid(data: web::Data) -> Result { let mut uuids = data.uuids.lock().await; let new_uuid = UuidData::new(); let user_auth = User::new(String::new(), new_uuid.uuid.to_string()); @@ -46,7 +52,7 @@ async fn generate_uuid(data: web::Data) -> Result, + data: web::Data, user: web::Query, ) -> Result { let mut uuids = data.uuids.lock().await; @@ -62,21 +68,21 @@ async fn validate_uuid( /// ```BASH /// curl -X GET 'http://127.0.0.1:8787/data/event/1?endpoint=system&uuid=f2f8c29b-712a-48c5-8919-b535d3a05a3a' /// ``` -#[get("/event/{channel}")] +#[get("/event/{id}")] async fn event_stream( - pool: web::Data>, broadcaster: web::Data, - data: web::Data, + data: web::Data, id: web::Path, user: web::Query, + controllers: web::Data>, ) -> Result { 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?; + let manager = controllers.lock().unwrap().get(*id).unwrap(); Ok(broadcaster - .new_client(*id, config, user.endpoint.clone()) + .new_client(manager.clone(), user.endpoint.clone()) .await) } diff --git a/ffplayout/src/utils/advanced_config.rs b/ffplayout/src/utils/advanced_config.rs new file mode 100644 index 00000000..b23bb0ce --- /dev/null +++ b/ffplayout/src/utils/advanced_config.rs @@ -0,0 +1,273 @@ +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, NoneAsEmptyString}; +use shlex::split; +use sqlx::{Pool, Sqlite}; +use tokio::io::AsyncReadExt; + +use crate::db::{handles, models::AdvancedConfiguration}; +use crate::utils::ServiceError; + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct AdvancedConfig { + pub decoder: DecoderConfig, + pub encoder: EncoderConfig, + pub filter: FilterConfig, + pub ingest: IngestConfig, +} + +#[serde_as] +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct DecoderConfig { + #[serde_as(as = "NoneAsEmptyString")] + pub input_param: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub output_param: Option, + #[serde(skip_serializing, skip_deserializing)] + pub input_cmd: Option>, + #[serde(skip_serializing, skip_deserializing)] + pub output_cmd: Option>, +} + +#[serde_as] +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct EncoderConfig { + #[serde_as(as = "NoneAsEmptyString")] + pub input_param: Option, + #[serde(skip_serializing, skip_deserializing)] + pub input_cmd: Option>, +} + +#[serde_as] +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct IngestConfig { + #[serde_as(as = "NoneAsEmptyString")] + pub input_param: Option, + #[serde(skip_serializing, skip_deserializing)] + pub input_cmd: Option>, +} + +#[serde_as] +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct FilterConfig { + #[serde_as(as = "NoneAsEmptyString")] + pub deinterlace: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub pad_scale_w: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub pad_scale_h: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub pad_video: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub fps: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub scale: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub set_dar: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub fade_in: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub fade_out: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub overlay_logo_scale: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub overlay_logo_fade_in: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub overlay_logo_fade_out: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub overlay_logo: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub tpad: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub drawtext_from_file: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub drawtext_from_zmq: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub aevalsrc: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub afade_in: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub afade_out: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub apad: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub volume: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub split: Option, +} + +impl AdvancedConfig { + pub fn new(config: AdvancedConfiguration) -> Self { + Self { + decoder: DecoderConfig { + input_param: config.decoder_input_param.clone(), + output_param: config.decoder_output_param.clone(), + input_cmd: match config.decoder_input_param { + Some(input_param) => split(&input_param), + None => None, + }, + output_cmd: match config.decoder_output_param { + Some(output_param) => split(&output_param), + None => None, + }, + }, + encoder: EncoderConfig { + input_param: config.encoder_input_param.clone(), + input_cmd: match config.encoder_input_param { + Some(input_param) => split(&input_param), + None => None, + }, + }, + filter: FilterConfig { + deinterlace: config.filter_deinterlace, + pad_scale_w: config.filter_pad_scale_w, + pad_scale_h: config.filter_pad_scale_h, + pad_video: config.filter_pad_video, + fps: config.filter_fps, + scale: config.filter_scale, + set_dar: config.filter_set_dar, + fade_in: config.filter_fade_in, + fade_out: config.filter_fade_out, + overlay_logo_scale: config.filter_overlay_logo_scale, + overlay_logo_fade_in: config.filter_overlay_logo_fade_in, + overlay_logo_fade_out: config.filter_overlay_logo_fade_out, + overlay_logo: config.filter_overlay_logo, + tpad: config.filter_tpad, + drawtext_from_file: config.filter_drawtext_from_file, + drawtext_from_zmq: config.filter_drawtext_from_zmq, + aevalsrc: config.filter_aevalsrc, + afade_in: config.filter_afade_in, + afade_out: config.filter_afade_out, + apad: config.filter_apad, + volume: config.filter_volume, + split: config.filter_split, + }, + ingest: IngestConfig { + input_param: config.ingest_input_param.clone(), + input_cmd: match config.ingest_input_param { + Some(input_param) => split(&input_param), + None => None, + }, + }, + } + } + + pub async fn dump(pool: &Pool, id: i32) -> Result<(), ServiceError> { + let config = Self::new(handles::select_advanced_configuration(pool, id).await?); + let f_keys = [ + "deinterlace", + "pad_scale_w", + "pad_scale_h", + "pad_video", + "fps", + "scale", + "set_dar", + "fade_in", + "fade_out", + "overlay_logo_scale", + "overlay_logo_fade_in", + "overlay_logo_fade_out", + "overlay_logo", + "tpad", + "drawtext_from_file", + "drawtext_from_zmq", + "aevalsrc", + "afade_in", + "afade_out", + "apad", + "volume", + "split", + ]; + + let toml_string = toml_edit::ser::to_string_pretty(&config)?; + let mut doc = toml_string.parse::()?; + + if let Some(decoder) = doc.get_mut("decoder").and_then(|o| o.as_table_mut()) { + decoder + .decor_mut() + .set_prefix("# Changing these settings is for advanced users only!\n# There will be no support or guarantee that it will be stable after changing them.\n\n"); + } + + if let Some(output_param) = doc + .get_mut("decoder") + .and_then(|d| d.get_mut("output_param")) + .and_then(|o| o.as_value_mut()) + { + output_param + .decor_mut() + .set_suffix(" # get also applied to ingest instance."); + } + + if let Some(filter) = doc.get_mut("filter") { + for key in &f_keys { + if let Some(item) = filter.get_mut(*key).and_then(|o| o.as_value_mut()) { + match *key { + "deinterlace" => item.decor_mut().set_suffix(" # yadif=0:-1:0"), + "pad_scale_w" => item.decor_mut().set_suffix(" # scale={}:-1"), + "pad_scale_h" => item.decor_mut().set_suffix(" # scale=-1:{}"), + "pad_video" => item.decor_mut().set_suffix( + " # pad=max(iw\\,ih*({0}/{1})):ow/({0}/{1}):(ow-iw)/2:(oh-ih)/2", + ), + "fps" => item.decor_mut().set_suffix(" # fps={}"), + "scale" => item.decor_mut().set_suffix(" # scale={}:{}"), + "set_dar" => item.decor_mut().set_suffix(" # setdar=dar={}"), + "fade_in" => item.decor_mut().set_suffix(" # fade=in:st=0:d=0.5"), + "fade_out" => item.decor_mut().set_suffix(" # fade=out:st={}:d=1.0"), + "overlay_logo_scale" => item.decor_mut().set_suffix(" # scale={}"), + "overlay_logo_fade_in" => { + item.decor_mut().set_suffix(" # fade=in:st=0:d=1.0:alpha=1") + } + "overlay_logo_fade_out" => item + .decor_mut() + .set_suffix(" # fade=out:st={}:d=1.0:alpha=1"), + "overlay_logo" => item + .decor_mut() + .set_suffix(" # null[l];[v][l]overlay={}:shortest=1"), + "tpad" => item + .decor_mut() + .set_suffix(" # tpad=stop_mode=add:stop_duration={}"), + "drawtext_from_file" => { + item.decor_mut().set_suffix(" # drawtext=text='{}':{}{}") + } + "drawtext_from_zmq" => item + .decor_mut() + .set_suffix(" # zmq=b=tcp\\\\://'{}',drawtext@dyntext={}"), + "aevalsrc" => item.decor_mut().set_suffix( + " # aevalsrc=0:channel_layout=stereo:duration={}:sample_rate=48000", + ), + "afade_in" => item.decor_mut().set_suffix(" # afade=in:st=0:d=0.5"), + "afade_out" => item.decor_mut().set_suffix(" # afade=out:st={}:d=1.0"), + "apad" => item.decor_mut().set_suffix(" # apad=whole_dur={}"), + "volume" => item.decor_mut().set_suffix(" # volume={}"), + "split" => item.decor_mut().set_suffix(" # split={}{}"), + _ => (), + } + } + } + }; + + tokio::fs::write(&format!("advanced_{id}.toml"), doc.to_string()).await?; + + Ok(()) + } + + pub async fn import(pool: &Pool, import: Vec) -> Result<(), ServiceError> { + let id = import[0].parse::()?; + let path = Path::new(&import[1]); + + if path.is_file() { + let mut file = tokio::fs::File::open(path).await?; + let mut contents = String::new(); + file.read_to_string(&mut contents).await?; + + let config: Self = toml_edit::de::from_str(&contents).unwrap(); + + handles::update_advanced_configuration(pool, id, config).await?; + } else { + return Err(ServiceError::BadRequest("Path not exists!".to_string())); + } + + Ok(()) + } +} diff --git a/ffplayout/src/utils/args_parse.rs b/ffplayout/src/utils/args_parse.rs new file mode 100644 index 00000000..387c50c2 --- /dev/null +++ b/ffplayout/src/utils/args_parse.rs @@ -0,0 +1,437 @@ +use std::{ + io::{stdin, stdout, Write}, + path::PathBuf, + process::exit, +}; + +use clap::Parser; +use rpassword::read_password; +use sqlx::{Pool, Sqlite}; + +use crate::db::{ + handles::{self, insert_user}, + models::{Channel, GlobalSettings, User}, +}; +use crate::utils::{ + advanced_config::AdvancedConfig, + config::{OutputMode, PlayoutConfig}, +}; +use crate::ARGS; + +#[derive(Parser, Debug, Clone)] +#[clap(version, + about = "ffplayout - 24/7 broadcasting solution", + long_about = None)] +pub struct Args { + #[clap( + short, + long, + help = "Initialize defaults: global admin, paths, settings, etc." + )] + pub init: bool, + + #[clap(short, long, help = "Add a global admin user")] + pub add: bool, + + #[clap(long, env, help = "path to database file")] + pub db: Option, + + #[clap( + short, + long, + env, + help = "Channels by ids to process (for foreground, etc.)", + num_args = 1.., + )] + pub channels: Option>, + + #[clap(long, env, help = "Run playout without webserver and frontend.")] + pub foreground: bool, + + #[clap( + long, + help = "Dump advanced channel configuration to advanced_{channel}.toml" + )] + pub dump_advanced: Option, + + #[clap(long, help = "Dump channel configuration to ffplayout_{channel}.toml")] + pub dump_config: Option, + + #[clap( + long, + help = "import advanced channel configuration from file. Input must be `{channel id} {path to toml}`", + num_args = 2 + )] + pub import_advanced: Option>, + + #[clap( + long, + help = "import channel configuration from file. Input must be `{channel id} {path to toml}`", + num_args = 2 + )] + pub import_config: Option>, + + #[clap(long, help = "List available channel ids")] + pub list_channels: bool, + + #[clap(long, env, help = "path to public files")] + pub public: Option, + + #[clap(short, env, long, help = "Listen on IP:PORT, like: 127.0.0.1:8787")] + pub listen: Option, + + #[clap(short, long, help = "Play folder content")] + pub folder: Option, + + #[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>, + + #[clap(long, help = "Optional folder path list for playlist generations", num_args = 1..)] + pub gen_paths: Option>, + + #[clap(long, env, help = "Keep log file for given days")] + pub log_backup_count: Option, + + #[clap( + long, + env, + help = "Override logging level: trace, debug, println, warn, eprintln" + )] + pub log_level: Option, + + #[clap(long, env, help = "Logging path")] + pub log_path: Option, + + #[clap(long, env, help = "Log to console")] + pub log_to_console: bool, + + #[clap(long, env, help = "HLS output path")] + pub hls_path: Option, + + #[clap(long, env, help = "Playlist root path")] + pub playlist_path: Option, + + #[clap(long, env, help = "Storage root path")] + pub storage_path: Option, + + #[clap(long, env, help = "Share storage across channels")] + pub shared_storage: bool, + + #[clap(short, long, help = "Create admin user")] + pub username: Option, + + #[clap(short, long, help = "Admin mail address")] + pub mail: Option, + + #[clap(short, long, help = "Admin password")] + pub password: Option, + + #[clap(long, help = "Path to playlist, or playlist root folder.")] + pub playlist: Option, + + #[clap( + short, + long, + help = "Start time in 'hh:mm:ss', 'now' for start with first" + )] + pub start: Option, + + #[clap(short = 'T', long, help = "JSON Template file for generating playlist")] + pub template: Option, + + #[clap(short, long, help = "Set output mode: desktop, hls, null, stream")] + pub output: Option, + + #[clap(short, long, help = "Set audio volume")] + pub volume: Option, + + #[clap(long, help = "Skip validation process")] + pub skip_validation: bool, + + #[clap(long, help = "Only validate given playlist")] + pub validate: bool, +} + +fn global_user(args: &mut Args) { + let mut user = String::new(); + let mut mail = String::new(); + + print!("Global admin: "); + stdout().flush().unwrap(); + + stdin() + .read_line(&mut user) + .expect("Did not enter a correct name?"); + + args.username = Some(user.trim().to_string()); + + print!("Password: "); + stdout().flush().unwrap(); + let password = read_password(); + + args.password = password.ok(); + + print!("Mail: "); + stdout().flush().unwrap(); + + stdin() + .read_line(&mut mail) + .expect("Did not enter a correct name?"); + + args.mail = Some(mail.trim().to_string()); +} + +pub async fn run_args(pool: &Pool) -> Result<(), i32> { + let channels = handles::select_related_channels(pool, None).await; + let mut args = ARGS.clone(); + + if args.init { + let check_user = handles::select_users(pool).await; + + let mut storage = String::new(); + let mut playlist = String::new(); + let mut logging = String::new(); + let mut hls = String::new(); + let mut shared_store = String::new(); + let mut global = GlobalSettings { + id: 0, + secret: None, + hls_path: String::new(), + playlist_path: String::new(), + storage_path: String::new(), + logging_path: String::new(), + shared_storage: false, + }; + + if check_user.unwrap_or_default().is_empty() { + global_user(&mut args); + } + + print!("Storage path [/var/lib/ffplayout/tv-media]: "); + stdout().flush().unwrap(); + + stdin() + .read_line(&mut storage) + .expect("Did not enter a correct path?"); + + if storage.trim().is_empty() { + global.storage_path = "/var/lib/ffplayout/tv-media".to_string(); + } else { + global.storage_path = storage + .trim() + .trim_matches(|c| c == '"' || c == '\'') + .to_string(); + } + + print!("Playlist path [/var/lib/ffplayout/playlists]: "); + stdout().flush().unwrap(); + + stdin() + .read_line(&mut playlist) + .expect("Did not enter a correct path?"); + + if playlist.trim().is_empty() { + global.playlist_path = "/var/lib/ffplayout/playlists".to_string(); + } else { + global.playlist_path = playlist + .trim() + .trim_matches(|c| c == '"' || c == '\'') + .to_string(); + } + + print!("Logging path [/var/log/ffplayout]: "); + stdout().flush().unwrap(); + + stdin() + .read_line(&mut logging) + .expect("Did not enter a correct path?"); + + if logging.trim().is_empty() { + global.logging_path = "/var/log/ffplayout".to_string(); + } else { + global.logging_path = logging + .trim() + .trim_matches(|c| c == '"' || c == '\'') + .to_string(); + } + + print!("HLS path [/usr/share/ffplayout/public]: "); + stdout().flush().unwrap(); + + stdin() + .read_line(&mut hls) + .expect("Did not enter a correct path?"); + + if hls.trim().is_empty() { + global.hls_path = "/usr/share/ffplayout/public".to_string(); + } else { + global.hls_path = hls + .trim() + .trim_matches(|c| c == '"' || c == '\'') + .to_string(); + } + + print!("Shared storage [Y/n]: "); + stdout().flush().unwrap(); + + stdin() + .read_line(&mut shared_store) + .expect("Did not enter a correct path?"); + + global.shared_storage = shared_store.trim().to_lowercase().starts_with('y'); + + if let Err(e) = handles::update_global(pool, global.clone()).await { + eprintln!("{e}"); + return Err(1); + }; + + if !global.shared_storage { + let mut channel = handles::select_channel(pool, &1).await.unwrap(); + channel.preview_url = "http://127.0.0.1:8787/1/stream.m3u8".to_string(); + + handles::update_channel(pool, 1, channel).await.unwrap(); + }; + + println!("Set global settings..."); + } + + if args.add { + global_user(&mut args); + } + + if let Some(username) = args.username { + if args.mail.is_none() || args.password.is_none() { + eprintln!("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_ids: Some( + channels + .unwrap_or(vec![Channel::default()]) + .iter() + .map(|c| c.id) + .collect(), + ), + token: None, + }; + + if let Err(e) = insert_user(pool, user).await { + eprintln!("{e}"); + return Err(1); + }; + + println!("Create global admin user \"{username}\" done..."); + + return Err(0); + } + + if ARGS.list_channels { + match channels { + Ok(channels) => { + let chl = channels + .iter() + .map(|c| (c.id, c.name.clone())) + .collect::>(); + + println!( + "Available channels:\n{}", + chl.iter() + .map(|(i, t)| format!(" {i}: '{t}'")) + .collect::>() + .join("\n") + ); + + return Err(0); + } + Err(e) => { + eprintln!("List channels: {e}"); + + exit(1); + } + } + } + + if let Some(id) = ARGS.dump_config { + match PlayoutConfig::dump(pool, id).await { + Ok(_) => { + println!("Dump config to: ffplayout_{id}.toml"); + exit(0); + } + Err(e) => { + eprintln!("Dump config: {e}"); + + exit(1); + } + }; + } + + if let Some(id) = ARGS.dump_config { + match PlayoutConfig::dump(pool, id).await { + Ok(_) => { + println!("Dump config to: ffplayout_{id}.toml"); + exit(0); + } + Err(e) => { + eprintln!("Dump config: {e}"); + + exit(1); + } + }; + } + + if let Some(id) = ARGS.dump_advanced { + match AdvancedConfig::dump(pool, id).await { + Ok(_) => { + println!("Dump config to: advanced_{id}.toml"); + exit(0); + } + Err(e) => { + eprintln!("Dump config: {e}"); + + exit(1); + } + }; + } + + if let Some(import) = &ARGS.import_config { + match PlayoutConfig::import(pool, import.clone()).await { + Ok(_) => { + println!("Import config done..."); + exit(0); + } + Err(e) => { + eprintln!("{e}"); + + exit(1); + } + }; + } + + if let Some(import) = &ARGS.import_advanced { + match AdvancedConfig::import(pool, import.clone()).await { + Ok(_) => { + println!("Import config done..."); + exit(0); + } + Err(e) => { + eprintln!("{e}"); + + exit(1); + } + }; + } + + Ok(()) +} diff --git a/ffplayout/src/utils/channels.rs b/ffplayout/src/utils/channels.rs new file mode 100644 index 00000000..1d61a728 --- /dev/null +++ b/ffplayout/src/utils/channels.rs @@ -0,0 +1,102 @@ +use std::{ + io, + path::Path, + sync::{Arc, Mutex}, +}; + +use log::*; +use sqlx::{Pool, Sqlite}; + +use super::logging::MailQueue; +use crate::db::{handles, models::Channel}; +use crate::player::controller::{ChannelController, ChannelManager}; +use crate::utils::{config::PlayoutConfig, errors::ServiceError}; + +async fn map_global_admins(conn: &Pool) -> Result<(), ServiceError> { + let channels = handles::select_related_channels(conn, None).await?; + let admins = handles::select_global_admins(conn).await?; + + for admin in admins { + if let Err(e) = + handles::insert_user_channel(conn, admin.id, channels.iter().map(|c| c.id).collect()) + .await + { + error!("Update global admin: {e}"); + }; + } + + Ok(()) +} + +fn preview_url(url: &str, id: i32) -> String { + let url_path = Path::new(url); + + if let Some(parent) = url_path.parent() { + if let Some(filename) = url_path.file_name() { + let new_path = parent.join(id.to_string()).join(filename); + + if let Some(new_url) = new_path.to_str() { + return new_url.to_string(); + } + } + } + url.to_string() +} + +pub async fn create_channel( + conn: &Pool, + controllers: Arc>, + queue: Arc>>>>, + target_channel: Channel, +) -> Result { + let mut channel = handles::insert_channel(conn, target_channel).await?; + + channel.preview_url = preview_url(&channel.preview_url, channel.id); + + handles::update_channel(conn, channel.id, channel.clone()).await?; + + let output_param = format!("-c:v libx264 -crf 23 -x264-params keyint=50:min-keyint=25:scenecut=-1 -maxrate 1300k -bufsize 2600k -preset faster -tune zerolatency -profile:v Main -level 3.1 -c:a aac -ar 44100 -b:a 128k -flags +cgop -f hls -hls_time 6 -hls_list_size 600 -hls_flags append_list+delete_segments+omit_endlist -hls_segment_filename {0}/stream-%d.ts {0}/stream.m3u8", channel.id); + + handles::insert_advanced_configuration(conn, channel.id).await?; + handles::insert_configuration(conn, channel.id, output_param).await?; + + let config = PlayoutConfig::new(conn, channel.id).await; + let m_queue = Arc::new(Mutex::new(MailQueue::new(channel.id, config.mail.clone()))); + let manager = ChannelManager::new(Some(conn.clone()), channel.clone(), config); + + controllers + .lock() + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))? + .add(manager); + + if let Ok(mut mqs) = queue.lock() { + mqs.push(m_queue.clone()); + } + + map_global_admins(conn).await?; + + Ok(channel) +} + +pub async fn delete_channel( + conn: &Pool, + id: i32, + controllers: Arc>, + queue: Arc>>>>, +) -> Result<(), ServiceError> { + let channel = handles::select_channel(conn, &id).await?; + handles::delete_channel(conn, &channel.id).await?; + + controllers + .lock() + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))? + .remove(id); + + if let Ok(mut mqs) = queue.lock() { + mqs.retain(|q| q.lock().unwrap().id != id); + } + + map_global_admins(conn).await?; + + Ok(()) +} diff --git a/ffplayout/src/utils/config.rs b/ffplayout/src/utils/config.rs new file mode 100644 index 00000000..9fe3d7b5 --- /dev/null +++ b/ffplayout/src/utils/config.rs @@ -0,0 +1,864 @@ +use std::{ + fmt, io, + path::{Path, PathBuf}, + str::FromStr, +}; + +use chrono::NaiveTime; +use flexi_logger::Level; +use serde::{Deserialize, Serialize}; +use shlex::split; +use sqlx::{Pool, Sqlite}; +use tokio::{fs, io::AsyncReadExt}; + +use crate::db::{handles, models}; +use crate::utils::{files::norm_abs_path, free_tcp_socket, time_to_sec}; +use crate::vec_strings; +use crate::AdvancedConfig; +use crate::ARGS; + +use super::errors::ServiceError; + +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; 12] = [ + "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", + "timestamp discontinuity", + "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, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum OutputMode { + Desktop, + HLS, + Null, + Stream, +} + +impl OutputMode { + fn new(s: &str) -> Self { + match s { + "desktop" => Self::Desktop, + "null" => Self::Null, + "stream" => Self::Stream, + _ => Self::HLS, + } + } +} + +impl Default for OutputMode { + fn default() -> Self { + Self::HLS + } +} + +impl FromStr for OutputMode { + type Err = String; + + fn from_str(input: &str) -> Result { + 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()), + } + } +} + +impl fmt::Display for OutputMode { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + OutputMode::Desktop => write!(f, "desktop"), + OutputMode::HLS => write!(f, "hls"), + OutputMode::Null => write!(f, "null"), + OutputMode::Stream => write!(f, "stream"), + } + } +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ProcessMode { + Folder, + #[default] + Playlist, +} + +impl ProcessMode { + fn new(s: &str) -> Self { + match s { + "folder" => Self::Folder, + _ => Self::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 { + match input { + "folder" => Ok(Self::Folder), + "playlist" => Ok(Self::Playlist), + _ => Err("Use 'folder' or 'playlist'".to_string()), + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct Template { + pub sources: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct Source { + pub start: NaiveTime, + pub duration: NaiveTime, + pub shuffle: bool, + pub paths: Vec, +} + +/// Global Config +/// +/// This we init ones, when ffplayout is starting and use them globally in the hole program. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct PlayoutConfig { + #[serde(skip_serializing, skip_deserializing)] + pub global: Global, + #[serde(skip_serializing, skip_deserializing)] + pub advanced: AdvancedConfig, + pub general: General, + pub mail: Mail, + pub logging: Logging, + pub processing: Processing, + pub ingest: Ingest, + pub playlist: Playlist, + pub storage: Storage, + pub text: Text, + pub task: Task, + #[serde(alias = "out")] + pub output: Output, +} + +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct Global { + pub hls_path: PathBuf, + pub playlist_path: PathBuf, + pub storage_path: PathBuf, + pub logging_path: PathBuf, + pub shared_storage: bool, +} + +impl Global { + pub fn new(config: &models::GlobalSettings) -> Self { + Self { + hls_path: PathBuf::from(config.hls_path.clone()), + playlist_path: PathBuf::from(config.playlist_path.clone()), + storage_path: PathBuf::from(config.storage_path.clone()), + logging_path: PathBuf::from(config.logging_path.clone()), + shared_storage: config.shared_storage, + } + } +} + +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct General { + pub help_text: String, + #[serde(skip_serializing, skip_deserializing)] + pub id: i32, + #[serde(skip_serializing, skip_deserializing)] + pub channel_id: i32, + pub stop_threshold: f64, + #[serde(skip_serializing, skip_deserializing)] + pub generate: Option>, + #[serde(skip_serializing, skip_deserializing)] + pub ffmpeg_filters: Vec, + #[serde(skip_serializing, skip_deserializing)] + pub ffmpeg_libs: Vec, + #[serde(skip_serializing, skip_deserializing)] + pub template: Option