diff --git a/.vscode/settings.json b/.vscode/settings.json index b05144c0..8922a3ab 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -48,19 +48,23 @@ }, "cSpell.words": [ "actix", + "canonicalize", "ffpengine", "flexi", + "httpauth", "lettre", "libc", - "nuxt", "neli", + "nuxt", "paris", + "Referer", "reqwest", "rsplit", "rustls", "sqlx", "starttls", "tokio", + "unistd", "uuids" ] } diff --git a/Cargo.lock b/Cargo.lock index 909a5d42..98daf9fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -549,9 +549,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.82" +version = "0.1.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a27b8a3a6e1a44fa4c8baf1f653e4172e81486d4941f2237e20dc2d0cf4ddff1" +checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", @@ -582,9 +582,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "awc" @@ -793,9 +793,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.17" +version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e5a21b8495e732f1b3c364c9949b201ca7bae518c502c80256c96ad79eaf6ac" +checksum = "b0956a43b323ac1afaffc053ed5c4b7c1f1800bacd1683c353aabbb752515dd3" dependencies = [ "clap_builder", "clap_derive", @@ -803,9 +803,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.17" +version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf2dd12af7a047ad9d6da2b6b249759a22a7abc0f474c1dae1777afa4b21a73" +checksum = "4d72166dd41634086d5803a47eb71ae740e61d84709c36f3c34110173db3961b" dependencies = [ "anstream", "anstyle", @@ -815,9 +815,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.13" +version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" dependencies = [ "heck", "proc-macro2", @@ -1215,7 +1215,7 @@ checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" [[package]] name = "ffplayout" -version = "0.24.0-rc1" +version = "0.24.0-beta5" dependencies = [ "actix-files", "actix-multipart", @@ -1314,9 +1314,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.33" +version = "1.0.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253" +checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" dependencies = [ "crc32fast", "miniz_oxide", @@ -1697,9 +1697,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da62f120a8a37763efb0cf8fdf264b884c7b8b9ac8660b900c8661030c00e6ba" +checksum = "41296eb09f183ac68eec06e03cdbea2e759633d4067b2f6552fc2e009bcad08b" dependencies = [ "bytes", "futures-channel", @@ -1710,7 +1710,6 @@ dependencies = [ "pin-project-lite", "socket2", "tokio", - "tower", "tower-service", "tracing", ] @@ -2068,9 +2067,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.158" +version = "0.2.159" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" [[package]] name = "libm" @@ -2520,26 +2519,6 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" -[[package]] -name = "pin-project" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.77", -] - [[package]] name = "pin-project-lite" version = "0.2.14" @@ -2575,9 +2554,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" [[package]] name = "powerfmt" @@ -2739,9 +2718,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.4" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0884ad60e090bf1345b93da0a5de8923c93884cd03f40dfcfddd3b4bee661853" +checksum = "355ae415ccd3a04315d3f8246e86d67689ea74d88d915576e1589a351062a13b" dependencies = [ "bitflags 2.6.0", ] @@ -3089,9 +3068,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb5b1b31579f3811bf615c144393417496f152e12ac8b7663bf664f4a815306d" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" dependencies = [ "serde", ] @@ -3681,7 +3660,7 @@ dependencies = [ [[package]] name = "tests" -version = "0.24.0-rc1" +version = "0.24.0-beta5" dependencies = [ "actix-rt", "actix-test", @@ -3711,18 +3690,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", @@ -3861,9 +3840,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.21" +version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b072cee73c449a636ffd6f32bd8de3a9f7119139aff882f44943ce2986dc5cf" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ "indexmap 2.5.0", "serde", @@ -3872,27 +3851,6 @@ dependencies = [ "winnow", ] -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "pin-project", - "pin-project-lite", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - [[package]] name = "tower-service" version = "0.3.3" @@ -4495,9 +4453,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.6.18" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index 723fff30..7299d4a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["engine", "tests"] resolver = "2" [workspace.package] -version = "0.24.0-rc1" +version = "0.24.0-beta5" license = "GPL-3.0" repository = "https://github.com/ffplayout/ffplayout" authors = ["Jonathan Baecker "] diff --git a/README.md b/README.md index 98e83604..f88f70e5 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) -## **ffplayout-engine (ffplayout)** +![player](/docs/images/player.png) [ffplayout](/ffplayout-engine/README.md) is a 24/7 broadcasting solution. It can playout a folder containing audio or video clips, or play a *JSON* playlist for each day, keeping the current playlist editable. @@ -120,6 +120,9 @@ If you are in playlist mode and move backwards or forwards in time, the time shi (Endless) streaming over multiple days will only work if config has a **day_start** value and the **length** value is **24 hours**. If you only need a few hours for each day, use a *cron* job or something similar. +## Note +This project includes the DejaVu font, which are licensed under the [Bitstream Vera Fonts License](/assets/FONT_LICENSE.txt). + ----- ## Sponsoring diff --git a/assets/DejaVuSans.ttf b/assets/DejaVuSans.ttf new file mode 100644 index 00000000..e5f7eecc Binary files /dev/null and b/assets/DejaVuSans.ttf differ diff --git a/assets/FONT_LICENSE.txt b/assets/FONT_LICENSE.txt new file mode 100644 index 00000000..8d719586 --- /dev/null +++ b/assets/FONT_LICENSE.txt @@ -0,0 +1,187 @@ +Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. +Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below) + + +Bitstream Vera Fonts Copyright +------------------------------ + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is +a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of the fonts accompanying this license ("Fonts") and associated +documentation files (the "Font Software"), to reproduce and distribute the +Font Software, including without limitation the rights to use, copy, merge, +publish, distribute, and/or sell copies of the Font Software, and to permit +persons to whom the Font Software is furnished to do so, subject to the +following conditions: + +The above copyright and trademark notices and this permission notice shall +be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular +the designs of glyphs or characters in the Fonts may be modified and +additional glyphs or characters may be added to the Fonts, only if the fonts +are renamed to names not containing either the words "Bitstream" or the word +"Vera". + +This License becomes null and void to the extent applicable to Fonts or Font +Software that has been modified and is distributed under the "Bitstream +Vera" names. + +The Font Software may be sold as part of a larger software package but no +copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, +TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME +FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING +ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE +FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome +Foundation, and Bitstream Inc., shall not be used in advertising or +otherwise to promote the sale, use or other dealings in this Font Software +without prior written authorization from the Gnome Foundation or Bitstream +Inc., respectively. For further information, contact: fonts at gnome dot +org. + +Arev Fonts Copyright +------------------------------ + +Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the fonts accompanying this license ("Fonts") and +associated documentation files (the "Font Software"), to reproduce +and distribute the modifications to the Bitstream Vera Font Software, +including without limitation the rights to use, copy, merge, publish, +distribute, and/or sell copies of the Font Software, and to permit +persons to whom the Font Software is furnished to do so, subject to +the following conditions: + +The above copyright and trademark notices and this permission notice +shall be included in all copies of one or more of the Font Software +typefaces. + +The Font Software may be modified, altered, or added to, and in +particular the designs of glyphs or characters in the Fonts may be +modified and additional glyphs or characters may be added to the +Fonts, only if the fonts are renamed to names not containing either +the words "Tavmjong Bah" or the word "Arev". + +This License becomes null and void to the extent applicable to Fonts +or Font Software that has been modified and is distributed under the +"Tavmjong Bah Arev" names. + +The Font Software may be sold as part of a larger software package but +no copy of one or more of the Font Software typefaces may be sold by +itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL +TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the name of Tavmjong Bah shall not +be used in advertising or otherwise to promote the sale, use or other +dealings in this Font Software without prior written authorization +from Tavmjong Bah. For further information, contact: tavmjong @ free +. fr. + +TeX Gyre DJV Math +----------------- +Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. + +Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski +(on behalf of TeX users groups) are in public domain. + +Letters imported from Euler Fraktur from AMSfonts are (c) American +Mathematical Society (see below). +Bitstream Vera Fonts Copyright +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera +is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of the fonts accompanying this license (“Fonts”) and associated +documentation +files (the “Font Software”), to reproduce and distribute the Font Software, +including without limitation the rights to use, copy, merge, publish, +distribute, +and/or sell copies of the Font Software, and to permit persons to whom +the Font Software is furnished to do so, subject to the following +conditions: + +The above copyright and trademark notices and this permission notice +shall be +included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular +the designs of glyphs or characters in the Fonts may be modified and +additional +glyphs or characters may be added to the Fonts, only if the fonts are +renamed +to names not containing either the words “Bitstream” or the word “Vera”. + +This License becomes null and void to the extent applicable to Fonts or +Font Software +that has been modified and is distributed under the “Bitstream Vera” +names. + +The Font Software may be sold as part of a larger software package but +no copy +of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, +TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME +FOUNDATION +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, +SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN +ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR +INABILITY TO USE +THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. +Except as contained in this notice, the names of GNOME, the GNOME +Foundation, +and Bitstream Inc., shall not be used in advertising or otherwise to promote +the sale, use or other dealings in this Font Software without prior written +authorization from the GNOME Foundation or Bitstream Inc., respectively. +For further information, contact: fonts at gnome dot org. + +AMSFonts (v. 2.2) copyright + +The PostScript Type 1 implementation of the AMSFonts produced by and +previously distributed by Blue Sky Research and Y&Y, Inc. are now freely +available for general use. This has been accomplished through the +cooperation +of a consortium of scientific publishers with Blue Sky Research and Y&Y. +Members of this consortium include: + +Elsevier Science IBM Corporation Society for Industrial and Applied +Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS) + +In order to assure the authenticity of these fonts, copyright will be +held by +the American Mathematical Society. This is not meant to restrict in any way +the legitimate use of the fonts, such as (but not limited to) electronic +distribution of documents containing these fonts, inclusion of these fonts +into other public domain or commercial font collections or computer +applications, use of the outline data to create derivative fonts and/or +faces, etc. However, the AMS does require that the AMS copyright notice be +removed from any derivative versions of the fonts which have been altered in +any way. In addition, to ensure the fidelity of TeX documents using Computer +Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces, +has requested that any alterations which yield different font metrics be +given a different name. + +$Id$ diff --git a/assets/dummy.vtt b/assets/dummy.vtt new file mode 100644 index 00000000..5ea46166 --- /dev/null +++ b/assets/dummy.vtt @@ -0,0 +1 @@ +WEBVTT diff --git a/docker/Dockerfile b/docker/Dockerfile index 745a5ab6..a02a77cb 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,6 +1,6 @@ FROM alpine:latest -ARG FFPLAYOUT_VERSION=0.24.0-rc1 +ARG FFPLAYOUT_VERSION=0.24.0-beta5 ARG SHARED_STORAGE=false ENV DB=/db diff --git a/docker/nonfree.Dockerfile b/docker/nonfree.Dockerfile index 702b18e8..2afd61fd 100644 --- a/docker/nonfree.Dockerfile +++ b/docker/nonfree.Dockerfile @@ -1,6 +1,6 @@ FROM alpine:latest -ARG FFPLAYOUT_VERSION=0.24.0-rc1 +ARG FFPLAYOUT_VERSION=0.24.0-beta5 ARG SHARED_STORAGE=false ENV DB=/db diff --git a/docker/nvidia.Dockerfile b/docker/nvidia.Dockerfile index 59c0dcc3..eddfac0c 100644 --- a/docker/nvidia.Dockerfile +++ b/docker/nvidia.Dockerfile @@ -1,6 +1,6 @@ FROM nvidia/cuda:12.5.0-runtime-rockylinux9 -ARG FFPLAYOUT_VERSION=0.24.0-rc1 +ARG FFPLAYOUT_VERSION=0.24.0-beta5 ARG SHARED_STORAGE=false ENV DB=/db diff --git a/docs/closed_captions.md b/docs/closed_captions.md new file mode 100644 index 00000000..c9cbf5e0 --- /dev/null +++ b/docs/closed_captions.md @@ -0,0 +1,23 @@ +## Closed Captions + +#### Note: +**This is only an _experimental feature_. Please be aware that bugs and unexpected behavior may occur. To utilize this feature, a [special patched](https://github.com/jb-alvarado/compile-ffmpeg-osx-linux) version of FFmpeg is required. Importantly, there is currently no official support for this functionality.** + +### Usage +**ffplayout** can handle closed captions in WebVTT format for HLS streaming. + +The captions can be embedded in the file, such as in a [Matroska](https://www.matroska.org/technical/subtitles.html) file, or they can be a separate *.vtt file that shares the same filename as the video file. In either case, the processing option **vtt_enable** must be enabled, and the path to the **vtt_dummy** file must exist. + +To encode the closed captions, the **hls** mode needs to be enabled, and specific output parameters must be provided. Here’s an example: + +``` +-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 \ +-muxpreload 0 -muxdelay 0 -f hls -hls_time 6 -hls_list_size 600 \ +-hls_flags append_list+delete_segments+omit_endlist \ +-var_stream_map v:0,a:0,s:0,sgroup:subs,name:English,language:en-US,default:YES \ +-master_pl_name master.m3u8 \ +-hls_segment_filename \ +live/stream-%d.ts live/stream.m3u8 +``` diff --git a/docs/developer.md b/docs/developer.md index 46d83ce0..2087074e 100644 --- a/docs/developer.md +++ b/docs/developer.md @@ -107,3 +107,13 @@ npm run preview ``` Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. + +### Experimental Frontend Features + +To use experimental frontend features, add `NUXT_BUILD_EXPERIMENTAL=true` tu run and build command, like: + +``` +NUXT_BUILD_EXPERIMENTAL=true npm run dev +``` + +**Note:** This function is only for developers and testers who can do without support. diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 42aa9ce5..57a4d848 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -78,7 +78,6 @@ static-files = "0.2" name = "ffplayout" path = "src/main.rs" - # DEBIAN DEB PACKAGE [package.metadata.deb] name = "ffplayout" @@ -99,6 +98,21 @@ assets = [ "/lib/systemd/system/", "644", ], + [ + "../assets/dummy.vtt", + "/usr/share/ffplayout/", + "644", + ], + [ + "../assets/DejaVuSans.ttf", + "/usr/share/ffplayout/", + "644", + ], + [ + "../assets/FONT_LICENSE.txt", + "/usr/share/ffplayout/", + "644", + ], [ "../assets/logo.png", "/usr/share/ffplayout/", @@ -135,6 +149,21 @@ assets = [ "/lib/systemd/system/", "644", ], + [ + "../assets/dummy.vtt", + "/usr/share/ffplayout/", + "644", + ], + [ + "../assets/DejaVuSans.ttf", + "/usr/share/ffplayout/", + "644", + ], + [ + "../assets/FONT_LICENSE.txt", + "/usr/share/ffplayout/", + "644", + ], [ "../assets/logo.png", "/usr/share/ffplayout/", @@ -157,7 +186,7 @@ assets = [ ], ] -# REHL RPM PACKAGE +# RHEL RPM PACKAGE [package.metadata.generate-rpm] name = "ffplayout" license = "GPL-3.0" @@ -167,6 +196,9 @@ assets = [ { source = "../README.md", dest = "/usr/share/doc/ffplayout/README", mode = "644" }, { source = "../assets/ffplayout.1.gz", dest = "/usr/share/man/man1/ffplayout.1.gz", mode = "644", doc = true }, { source = "../LICENSE", dest = "/usr/share/doc/ffplayout/LICENSE", mode = "644" }, + { source = "../assets/dummy.vtt", dest = "/usr/share/ffplayout/dummy.vtt", mode = "644" }, + { source = "../assets/DejaVuSans.ttf", dest = "/usr/share/ffplayout/DejaVuSans.ttf", mode = "644" }, + { source = "../assets/FONT_LICENSE.txt", dest = "/usr/share/ffplayout/FONT_LICENSE.txt", mode = "644" }, { source = "../assets/logo.png", dest = "/usr/share/ffplayout/logo.png", mode = "644" }, { source = "../assets/ffplayout.conf", dest = "/usr/share/ffplayout/ffplayout.conf.example", mode = "644" }, { source = "../debian/postinst", dest = "/usr/share/ffplayout/postinst", mode = "755" }, diff --git a/engine/src/api/routes.rs b/engine/src/api/routes.rs index ed4745bf..f3bfbdf7 100644 --- a/engine/src/api/routes.rs +++ b/engine/src/api/routes.rs @@ -480,7 +480,11 @@ async fn patch_channel( role: AuthDetails, user: web::ReqData, ) -> Result { - let manager = controllers.lock().unwrap().get(*id).unwrap(); + let manager = controllers + .lock() + .unwrap() + .get(*id) + .ok_or(format!("Channel {id} not found!"))?; let mut data = data.into_inner(); if !role.has_authority(&Role::GlobalAdmin) { @@ -1328,7 +1332,10 @@ async fn get_public( ) -> Result { let (id, public, file_stem) = path.into_inner(); - let absolute_path = if file_stem.ends_with(".ts") || file_stem.ends_with(".m3u8") { + let absolute_path = if file_stem.ends_with(".ts") + || file_stem.ends_with(".m3u8") + || file_stem.ends_with(".vtt") + { let manager = controllers.lock().unwrap().get(id).unwrap(); let config = manager.config.lock().unwrap(); config.channel.hls_path.join(public) diff --git a/engine/src/db/handles.rs b/engine/src/db/handles.rs index c85cf642..5b1997c5 100644 --- a/engine/src/db/handles.rs +++ b/engine/src/db/handles.rs @@ -211,7 +211,7 @@ pub async fn update_configuration( 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"; + 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, processing_vtt_enable = $33, processing_vtt_dummy = $34, ingest_enable = $35, ingest_param = $36, ingest_filter = $37, playlist_day_start = $38, playlist_length = $39, playlist_infinit = $40, storage_filler = $41, storage_extensions = $42, storage_shuffle = $43, text_add = $44, text_from_filename = $45, text_font = $46, text_style = $47, text_regex = $48, task_enable = $49, task_path = $50, output_mode = $51, output_param = $52 WHERE id = $1"; sqlx::query(query) .bind(id) @@ -246,6 +246,8 @@ pub async fn update_configuration( .bind(config.processing.audio_channels) .bind(config.processing.volume) .bind(config.processing.custom_filter) + .bind(config.processing.vtt_enable) + .bind(config.processing.vtt_dummy) .bind(config.ingest.enable) .bind(config.ingest.input_param) .bind(config.ingest.custom_filter) diff --git a/engine/src/db/models.rs b/engine/src/db/models.rs index 88ba5eb0..ca97b726 100644 --- a/engine/src/db/models.rs +++ b/engine/src/db/models.rs @@ -302,6 +302,10 @@ pub struct Configuration { pub processing_volume: f64, #[serde(default)] pub processing_filter: String, + #[serde(default)] + pub processing_vtt_enable: bool, + #[serde(default)] + pub processing_vtt_dummy: Option, pub ingest_help: String, pub ingest_enable: bool, @@ -375,6 +379,8 @@ impl Configuration { processing_audio_channels: config.processing.audio_channels, processing_volume: config.processing.volume, processing_filter: config.processing.custom_filter, + processing_vtt_enable: config.processing.vtt_enable, + processing_vtt_dummy: config.processing.vtt_dummy, ingest_help: config.ingest.help_text, ingest_enable: config.ingest.enable, ingest_param: config.ingest.input_param, diff --git a/engine/src/main.rs b/engine/src/main.rs index 003db364..812cf7b8 100644 --- a/engine/src/main.rs +++ b/engine/src/main.rs @@ -199,6 +199,8 @@ async fn main() -> std::io::Result<()> { .workers(thread_count) .run() .await?; + } else if ARGS.drop_db { + db_drop().await; } else { let channels = ARGS.channels.clone().unwrap_or_else(|| vec![1]); @@ -267,8 +269,6 @@ async fn main() -> std::io::Result<()> { playlist, Arc::new(AtomicBool::new(false)), ); - } else if ARGS.drop_db { - db_drop().await; } else if !ARGS.init { error!("Run ffplayout with parameters! Run ffplayout -h for more information."); } diff --git a/engine/src/player/input/ingest.rs b/engine/src/player/input/ingest.rs index 1008515c..2b1649aa 100644 --- a/engine/src/player/input/ingest.rs +++ b/engine/src/player/input/ingest.rs @@ -72,6 +72,10 @@ pub fn ingest_server( dummy_media.add_filter(&config, &None); let is_terminated = channel_mgr.is_terminated.clone(); let ingest_is_running = channel_mgr.ingest_is_running.clone(); + let vtt_dummy = config + .channel + .storage_path + .join(config.processing.vtt_dummy.clone().unwrap_or_default()); if let Some(ingest_input_cmd) = config.advanced.ingest.input_cmd { server_cmd.append(&mut ingest_input_cmd.clone()); @@ -79,11 +83,19 @@ pub fn ingest_server( server_cmd.append(&mut stream_input.clone()); + if config.processing.vtt_enable && vtt_dummy.is_file() { + server_cmd.append(&mut vec_strings!["-i", vtt_dummy.to_string_lossy()]); + } + if let Some(mut filter) = dummy_media.filter { server_cmd.append(&mut filter.cmd()); server_cmd.append(&mut filter.map()); } + if config.processing.vtt_enable && vtt_dummy.is_file() { + server_cmd.append(&mut vec_strings!("-map", "1:s")); + } + if let Some(mut cmd) = config.processing.cmd { server_cmd.append(&mut cmd); } diff --git a/engine/src/player/input/playlist.rs b/engine/src/player/input/playlist.rs index 1378134c..f0261fff 100644 --- a/engine/src/player/input/playlist.rs +++ b/engine/src/player/input/playlist.rs @@ -640,14 +640,14 @@ pub fn gen_source( .filter(|c| IMAGE_FORMAT.contains(&c.as_str())) .is_some() { - node.cmd = Some(loop_image(&node)); + node.cmd = Some(loop_image(config, &node)); } else { if node.seek > 0.0 && node.out > node.duration { 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)); + node.cmd = Some(seek_and_length(config, &mut node)); } } else { trace!("clip index: {node_index} | last index: {last_index}"); @@ -694,7 +694,7 @@ pub fn gen_source( node.seek = 0.0; node.out = filler_media.out; node.duration = filler_media.duration; - node.cmd = Some(loop_filler(&node)); + node.cmd = Some(loop_filler(config, &node)); node.probe = filler_media.probe; } else { match MediaProbe::new(&config.storage.filler_path.to_string_lossy()) { @@ -715,7 +715,7 @@ pub fn gen_source( .clone() .to_string_lossy() .to_string(); - node.cmd = Some(loop_image(&node)); + node.cmd = Some(loop_image(config, &node)); node.probe = Some(probe); } else if let Some(filler_duration) = probe .clone() @@ -739,7 +739,7 @@ pub fn gen_source( node.seek = 0.0; node.out = filler_out; node.duration = filler_duration; - node.cmd = Some(loop_filler(&node)); + node.cmd = Some(loop_filler(config, &node)); node.probe = Some(probe); } else { // Create colored placeholder. diff --git a/engine/src/player/output/hls.rs b/engine/src/player/output/hls.rs index 66761965..23743cbb 100644 --- a/engine/src/player/output/hls.rs +++ b/engine/src/player/output/hls.rs @@ -62,6 +62,17 @@ fn ingest_to_hls_server(manager: ChannelManager) -> Result<(), ProcessError> { server_prefix.append(&mut stream_input.clone()); + if config.processing.vtt_enable { + let vtt_dummy = config + .channel + .storage_path + .join(config.processing.vtt_dummy.clone().unwrap_or_default()); + + if vtt_dummy.is_file() { + server_prefix.append(&mut vec_strings!["-i", vtt_dummy.to_string_lossy()]); + } + } + let mut is_running; if let Some(url) = stream_input.iter().find(|s| s.contains("://")) { diff --git a/engine/src/player/output/mod.rs b/engine/src/player/output/mod.rs index 368b7e5b..ac8af22f 100644 --- a/engine/src/player/output/mod.rs +++ b/engine/src/player/output/mod.rs @@ -150,6 +150,16 @@ pub fn player(manager: ChannelManager) -> Result<(), ProcessError> { dec_cmd.append(&mut filter.map()); } + if config.processing.vtt_enable && dec_cmd.iter().any(|s| s.ends_with(".vtt")) { + let i = dec_cmd + .iter() + .filter(|&n| n == "-i") + .count() + .saturating_sub(1); + + dec_cmd.append(&mut vec_strings!("-map", format!("{i}:s"), "-c:s", "copy")); + } + if let Some(mut cmd) = config.processing.cmd.clone() { dec_cmd.append(&mut cmd); } diff --git a/engine/src/player/utils/json_validate.rs b/engine/src/player/utils/json_validate.rs index 9b5ca69e..f60cc98c 100644 --- a/engine/src/player/utils/json_validate.rs +++ b/engine/src/player/utils/json_validate.rs @@ -65,9 +65,9 @@ fn check_media( .filter(|c| IMAGE_FORMAT.contains(&c.as_str())) .is_some() { - node.cmd = Some(loop_image(&node)); + node.cmd = Some(loop_image(&config, &node)); } else { - node.cmd = Some(seek_and_length(&mut node)); + node.cmd = Some(seek_and_length(&config, &mut node)); } node.add_filter(&config, &None); diff --git a/engine/src/player/utils/mod.rs b/engine/src/player/utils/mod.rs index 3766074e..ae16356e 100644 --- a/engine/src/player/utils/mod.rs +++ b/engine/src/player/utils/mod.rs @@ -67,6 +67,10 @@ pub fn prepare_output_cmd( let mut new_params = vec![]; let mut count = 0; let re_v = Regex::new(r"\[?0:v(:0)?\]?").unwrap(); + let vtt_dummy = config + .channel + .storage_path + .join(config.processing.vtt_dummy.clone().unwrap_or_default()); if let Some(mut filter) = filters.clone() { for (i, param) in output_params.iter().enumerate() { @@ -119,6 +123,12 @@ pub fn prepare_output_cmd( } } + if config.processing.vtt_enable && vtt_dummy.is_file() { + let i = cmd.iter().filter(|&n| n == "-i").count().saturating_sub(1); + + cmd.append(&mut vec_strings!("-map", format!("{i}:s?"))); + } + cmd.append(&mut output_params); cmd @@ -589,7 +599,7 @@ pub fn get_delta(config: &PlayoutConfig, begin: &f64) -> (f64, f64) { } /// Loop image until target duration is reached. -pub fn loop_image(node: &Media) -> Vec { +pub fn loop_image(config: &PlayoutConfig, node: &Media) -> Vec { let duration = node.out - node.seek; let mut source_cmd: Vec = vec_strings!["-loop", "1", "-i", node.source.clone()]; @@ -608,11 +618,34 @@ pub fn loop_image(node: &Media) -> Vec { source_cmd.append(&mut vec_strings!["-t", duration]); + if config.processing.vtt_enable { + let vtt_file = Path::new(&node.source).with_extension("vtt"); + let vtt_dummy = config + .channel + .storage_path + .join(config.processing.vtt_dummy.clone().unwrap_or_default()); + + if node.seek > 0.5 { + source_cmd.append(&mut vec_strings!["-ss", node.seek]); + } + + if vtt_file.is_file() { + source_cmd.append(&mut vec_strings![ + "-i", + vtt_file.to_string_lossy(), + "-t", + node.out + ]); + } else if vtt_dummy.is_file() { + source_cmd.append(&mut vec_strings!["-i", vtt_dummy.to_string_lossy()]); + } + } + source_cmd } /// Loop filler until target duration is reached. -pub fn loop_filler(node: &Media) -> Vec { +pub fn loop_filler(config: &PlayoutConfig, node: &Media) -> Vec { let loop_count = (node.out / node.duration).ceil() as i32; let mut source_cmd = vec![]; @@ -624,11 +657,34 @@ pub fn loop_filler(node: &Media) -> Vec { source_cmd.append(&mut vec_strings!["-i", node.source, "-t", node.out]); + if config.processing.vtt_enable { + let vtt_file = Path::new(&node.source).with_extension("vtt"); + let vtt_dummy = config + .channel + .storage_path + .join(config.processing.vtt_dummy.clone().unwrap_or_default()); + + if vtt_file.is_file() { + if loop_count > 1 { + source_cmd.append(&mut vec_strings!["-stream_loop", loop_count]); + } + + source_cmd.append(&mut vec_strings![ + "-i", + vtt_file.to_string_lossy(), + "-t", + node.out + ]); + } else if vtt_dummy.is_file() { + source_cmd.append(&mut vec_strings!["-i", vtt_dummy.to_string_lossy()]); + } + } + source_cmd } /// Set clip seek in and length value. -pub fn seek_and_length(node: &mut Media) -> Vec { +pub fn seek_and_length(config: &PlayoutConfig, node: &mut Media) -> Vec { let loop_count = (node.out / node.duration).ceil() as i32; let mut source_cmd = vec![]; let mut cut_audio = false; @@ -673,6 +729,34 @@ pub fn seek_and_length(node: &mut Media) -> Vec { } } + if config.processing.vtt_enable { + let vtt_file = Path::new(&node.source).with_extension("vtt"); + let vtt_dummy = config + .channel + .storage_path + .join(config.processing.vtt_dummy.clone().unwrap_or_default()); + + if node.seek > 0.5 { + source_cmd.append(&mut vec_strings!["-ss", node.seek]); + } + + if vtt_file.is_file() { + if loop_count > 1 { + source_cmd.append(&mut vec_strings!["-stream_loop", loop_count]); + } + + source_cmd.append(&mut vec_strings!["-i", vtt_file.to_string_lossy()]); + + if node.duration > node.out || remote_source || loop_count > 1 { + source_cmd.append(&mut vec_strings!["-t", node.out - node.seek]); + } + } else if vtt_dummy.is_file() { + source_cmd.append(&mut vec_strings!["-i", vtt_dummy.to_string_lossy()]); + } else { + error!("{:?} not found!", vtt_dummy) + } + } + source_cmd } @@ -683,7 +767,7 @@ pub fn gen_dummy(config: &PlayoutConfig, duration: f64) -> (String, Vec) "color=c={color}:s={}x{}:d={duration}", config.processing.width, config.processing.height ); - let cmd: Vec = vec_strings![ + let mut source_cmd: Vec = vec_strings![ "-f", "lavfi", "-i", @@ -697,7 +781,18 @@ pub fn gen_dummy(config: &PlayoutConfig, duration: f64) -> (String, Vec) format!("anoisesrc=d={duration}:c=pink:r=48000:a=0.3") ]; - (source, cmd) + if config.processing.vtt_enable { + let vtt_dummy = config + .channel + .storage_path + .join(config.processing.vtt_dummy.clone().unwrap_or_default()); + + if vtt_dummy.is_file() { + source_cmd.append(&mut vec_strings!["-i", vtt_dummy.to_string_lossy()]); + } + } + + (source, source_cmd) } // fn get_output_count(cmd: &[String]) -> i32 { diff --git a/engine/src/utils/args_parse.rs b/engine/src/utils/args_parse.rs index 5d7d7908..47ce053a 100644 --- a/engine/src/utils/args_parse.rs +++ b/engine/src/utils/args_parse.rs @@ -4,12 +4,15 @@ use std::{ }; #[cfg(target_family = "unix")] -use std::{fs, process::exit}; +use std::os::unix::fs::MetadataExt; use clap::Parser; use rpassword::read_password; use sqlx::{Pool, Sqlite}; +#[cfg(target_family = "unix")] +use tokio::fs; + use crate::db::{ handles, models::{Channel, GlobalSettings, User}, @@ -17,6 +20,7 @@ use crate::db::{ use crate::utils::{ advanced_config::AdvancedConfig, config::{OutputMode, PlayoutConfig}, + copy_assets, }; use crate::ARGS; @@ -26,145 +30,154 @@ use crate::utils::db_path; #[derive(Parser, Debug, Clone)] #[clap(version, about = "ffplayout - 24/7 broadcasting solution", - long_about = None)] + long_about = Some("ffplayout - 24/7 broadcasting solution\n +Stream dynamic playlists or folder contents with the power of ffmpeg. +The target can be an HLS playlist, rtmp/srt/udp server, desktop player +or any other output supported by ffmpeg.\n +ffplayout also provides a web frontend and API to control streaming, +manage config, files, text overlay, etc. "))] pub struct Args { #[clap( short, long, + help_heading = Some("Initial Setup"), help = "Initialize defaults: global admin, paths, settings, etc." )] pub init: bool, - #[clap(short, long, help = "Add a global admin user")] + #[clap(short, long, help_heading = Some("Initial Setup"), help = "Add a global admin user")] pub add: bool, - #[clap(long, env, help = "Path to database file")] - pub db: Option, + #[clap(short, long, help_heading = Some("Initial Setup"), help = "Create admin user")] + pub username: Option, + + #[clap(short, long, help_heading = Some("Initial Setup"), help = "Admin mail address")] + pub mail: Option, + + #[clap(short, long, help_heading = Some("Initial Setup"), help = "Admin password")] + pub password: Option, + + #[clap(long, env, help_heading = Some("Initial Setup"), help = "Storage root path")] + pub storage: Option, #[clap( long, env, + help_heading = Some("Initial Setup"), + help = "Share storage across channels, important for running in Containers" + )] + pub shared_storage: bool, + + #[clap(long, env, help_heading = Some("Initial Setup / General"), help = "Logging path")] + pub log_path: Option, + + #[clap(long, env, help_heading = Some("Initial Setup / General"), help = "Path to public files, also HLS playlists")] + pub public: Option, + + #[clap(long, help_heading = Some("Initial Setup / Playlist"), help = "Path to playlist, or playlist root folder.")] + pub playlist: Option, + + #[clap(long, env, help_heading = Some("General"), help = "Path to database file")] + pub db: Option, + + #[clap( + long, + help_heading = Some("General"), help = "Drop database. WARNING: this will delete all configurations!" )] pub drop_db: bool, - #[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_heading = Some("General"), help = "Dump advanced channel configuration to advanced_{channel}.toml" )] pub dump_advanced: bool, - #[clap(long, help = "Dump channel configuration to ffplayout_{channel}.toml")] + #[clap(long, help_heading = Some("General"), help = "Dump channel configuration to ffplayout_{channel}.toml")] pub dump_config: bool, #[clap( long, - help = "import advanced channel configuration from file.", - num_args = 2 + help_heading = Some("General"), + help = "import advanced channel configuration from file." )] pub import_advanced: Option, - #[clap(long, help = "import channel configuration from file.", num_args = 2)] + #[clap(long, help_heading = Some("General"), help = "import channel configuration from file.")] pub import_config: Option, - #[clap(long, help = "List available channel ids")] + #[clap(long, help_heading = Some("General"), help = "List available channel ids")] pub list_channels: bool, - #[clap(short, env, long, help = "Listen on IP:PORT, like: 127.0.0.1:8787")] + #[clap(short, env, long, help_heading = Some("General"), 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( + long, + env, + help_heading = Some("General"), + help = "Override logging level: trace, debug, println, warn, eprintln" + )] + pub log_level: Option, + + #[clap(long, env, help_heading = Some("General"), help = "Log to console")] + pub log_to_console: bool, #[clap( short, long, + env, + help_heading = Some("General / Playout"), + help = "Channels by ids to process (for export config, foreground running, etc.)", + num_args = 1.., + )] + pub channels: Option>, + + #[clap( + short, + long, + help_heading = Some("Playlist"), 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 path list for playlist generations", num_args = 1..)] + #[clap(long, help_heading = Some("Playlist"), help = "Optional path list for playlist generations", num_args = 1..)] pub 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 = "Path to public files, also HLS playlists")] - pub public: Option, - - #[clap(long, env, help = "Playlist root path")] - pub playlist_root: Option, - - #[clap(long, env, help = "Storage root path")] - pub storage_root: Option, - - #[clap( - long, - env, - help = "Share storage root across channels, important for running in Container" - )] - 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_heading = Some("Playlist"), 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")] + #[clap(short = 'T', long, help_heading = Some("Playlist"), help = "JSON template file for generating playlist")] pub template: Option, - #[clap(short, long, help = "Set output mode: desktop, hls, null, stream")] + #[clap(long, help_heading = Some("Playlist"), help = "Only validate given playlist")] + pub validate: bool, + + #[clap(long, env, help_heading = Some("Playout"), help = "Run playout without webserver and frontend.")] + pub foreground: bool, + + #[clap(short, long, help_heading = Some("Playout"), help = "Play folder content")] + pub folder: Option, + + #[clap(long, env, help_heading = Some("Playout"), help = "Keep log file for given days")] + pub log_backup_count: Option, + + #[clap(short, long, help_heading = Some("Playout"), help = "Set output mode: desktop, hls, null, stream")] pub output: Option, - #[clap(short, long, help = "Set audio volume")] + #[clap(short, long, help_heading = Some("Playout"), help = "Set audio volume")] pub volume: Option, - #[clap(long, help = "Skip validation process")] + #[clap(long, help_heading = Some("Playout"), help = "Skip validation process")] pub skip_validation: bool, - - #[clap(long, help = "Only validate given playlist")] - pub validate: bool, } fn global_user(args: &mut Args) { @@ -212,43 +225,6 @@ pub async fn run_args(pool: &Pool) -> Result<(), i32> { let mut error_code = -1; if args.init { - #[cfg(target_family = "unix")] - let process_user = nix::unistd::User::from_name("ffpu").unwrap_or_default(); - - #[cfg(target_family = "unix")] - let mut fix_permission = false; - - #[cfg(target_family = "unix")] - { - let uid = nix::unistd::Uid::current(); - let current_user = nix::unistd::User::from_uid(uid).unwrap_or_default(); - - if current_user != process_user { - let user_name = current_user.unwrap().name; - let mut fix_perm = String::new(); - - println!( - "\nYou run the initialization as user {}.\nFix permissions after initialization?\n", - user_name - ); - - print!("Fix permission [Y/n]: "); - stdout().flush().unwrap(); - - stdin() - .read_line(&mut fix_perm) - .expect("Did not enter a yes or no?"); - - fix_permission = fix_perm.trim().to_lowercase().starts_with('y'); - - if fix_permission && user_name != "root" { - println!("\nYou do not have permission to change DB file ownership!\nRun as proper process user or root."); - - exit(1); - } - } - } - let check_user = handles::select_users(pool).await; let mut storage = String::new(); @@ -353,7 +329,11 @@ pub async fn run_args(pool: &Pool) -> Result<(), i32> { channel.playlist_path = global.playlist_root; channel.storage_path = global.storage_root; + let mut storage_path = PathBuf::from(channel.storage_path.clone()); + if global.shared_storage { + storage_path = storage_path.join("1"); + channel.preview_url = "http://127.0.0.1:8787/1/stream.m3u8".to_string(); channel.hls_path = Path::new(&channel.hls_path) .join("1") @@ -363,34 +343,18 @@ pub async fn run_args(pool: &Pool) -> Result<(), i32> { .join("1") .to_string_lossy() .to_string(); - channel.storage_path = Path::new(&channel.storage_path) - .join("1") - .to_string_lossy() - .to_string(); + channel.storage_path = storage_path.to_string_lossy().to_string(); + }; + + if let Err(e) = copy_assets(&storage_path).await { + eprintln!("{e}"); }; handles::update_channel(pool, 1, channel).await.unwrap(); #[cfg(target_family = "unix")] - if fix_permission { - let db_path = Path::new(db_path().unwrap()).with_extension(""); - let user = process_user.unwrap(); - - let db = fs::canonicalize(db_path.with_extension("db")).unwrap(); - let shm = fs::canonicalize(db_path.with_extension("db-shm")).unwrap(); - let wal = fs::canonicalize(db_path.with_extension("db-wal")).unwrap(); - - nix::unistd::chown(&db, Some(user.uid), Some(user.gid)).expect("Change DB owner"); - - if shm.is_file() { - nix::unistd::chown(&shm, Some(user.uid), Some(user.gid)) - .expect("Change DB-SHM owner"); - } - - if wal.is_file() { - nix::unistd::chown(&wal, Some(user.uid), Some(user.gid)) - .expect("Change DB-WAL owner"); - } + { + update_permissions().await; } println!("\nSet global settings done..."); @@ -410,7 +374,7 @@ pub async fn run_args(pool: &Pool) -> Result<(), i32> { let chl: Vec = channels.clone().iter().map(|c| c.id).collect(); - let user = User { + let ff_user = User { id: 0, mail: Some(args.mail.unwrap()), username: username.clone(), @@ -420,7 +384,7 @@ pub async fn run_args(pool: &Pool) -> Result<(), i32> { token: None, }; - if let Err(e) = handles::insert_user(pool, user).await { + if let Err(e) = handles::insert_user(pool, ff_user).await { eprintln!("{e}"); error_code = 1; }; @@ -429,8 +393,8 @@ pub async fn run_args(pool: &Pool) -> Result<(), i32> { } if !args.init - && args.storage_root.is_some() - && args.playlist_root.is_some() + && args.storage.is_some() + && args.playlist.is_some() && args.public.is_some() && args.log_path.is_some() { @@ -440,17 +404,20 @@ pub async fn run_args(pool: &Pool) -> Result<(), i32> { id: 0, secret: None, logging_path: args.log_path.unwrap().to_string_lossy().to_string(), - playlist_root: args.playlist_root.unwrap(), + playlist_root: args.playlist.unwrap(), public_root: args.public.unwrap(), - storage_root: args.storage_root.unwrap(), + storage_root: args.storage.unwrap(), shared_storage: args.shared_storage, }; let mut channel = handles::select_channel(pool, &1) .await .expect("Select Channel 1"); + let mut storage_path = PathBuf::from(global.storage_root.clone()); if args.shared_storage { + storage_path = storage_path.join("1"); + channel.hls_path = Path::new(&global.public_root) .join("1") .to_string_lossy() @@ -459,16 +426,17 @@ pub async fn run_args(pool: &Pool) -> Result<(), i32> { .join("1") .to_string_lossy() .to_string(); - channel.storage_path = Path::new(&global.storage_root) - .join("1") - .to_string_lossy() - .to_string(); + channel.storage_path = storage_path.to_string_lossy().to_string(); } else { channel.hls_path = global.public_root.clone(); channel.playlist_path = global.playlist_root.clone(); channel.storage_path = global.storage_root.clone(); } + if let Err(e) = copy_assets(&storage_path).await { + eprintln!("{e}"); + }; + match handles::update_global(pool, global.clone()).await { Ok(_) => println!("Update globals done..."), Err(e) => { @@ -484,6 +452,11 @@ pub async fn run_args(pool: &Pool) -> Result<(), i32> { error_code = 1; } }; + + #[cfg(target_family = "unix")] + { + update_permissions().await; + } } if ARGS.list_channels { @@ -589,3 +562,35 @@ pub async fn run_args(pool: &Pool) -> Result<(), i32> { Ok(()) } } + +#[cfg(target_family = "unix")] +async fn update_permissions() { + let db_path = Path::new(db_path().unwrap()); + let uid = nix::unistd::Uid::current(); + let parent_owner = db_path.parent().unwrap().metadata().unwrap().uid(); + let user = nix::unistd::User::from_uid(parent_owner.into()) + .unwrap_or_default() + .unwrap(); + + if uid.is_root() && uid.to_string() != parent_owner.to_string() { + println!("Adjust DB permission..."); + + let db = fs::canonicalize(db_path).await.unwrap(); + let shm = fs::canonicalize(db_path.with_extension("db-shm")) + .await + .unwrap(); + let wal = fs::canonicalize(db_path.with_extension("db-wal")) + .await + .unwrap(); + + nix::unistd::chown(&db, Some(user.uid), Some(user.gid)).expect("Change DB owner"); + + if shm.is_file() { + nix::unistd::chown(&shm, Some(user.uid), Some(user.gid)).expect("Change DB-SHM owner"); + } + + if wal.is_file() { + nix::unistd::chown(&wal, Some(user.uid), Some(user.gid)).expect("Change DB-WAL owner"); + } + } +} diff --git a/engine/src/utils/channels.rs b/engine/src/utils/channels.rs index ac48a924..d5289b69 100644 --- a/engine/src/utils/channels.rs +++ b/engine/src/utils/channels.rs @@ -1,7 +1,6 @@ use std::{ - ffi::OsStr, io, - path::Path, + path::PathBuf, sync::{Arc, Mutex}, }; @@ -11,7 +10,7 @@ use sqlx::{Pool, Sqlite}; use super::logging::MailQueue; use crate::db::{handles, models::Channel}; use crate::player::controller::{ChannelController, ChannelManager}; -use crate::utils::{config::get_config, errors::ServiceError}; +use crate::utils::{config::get_config, copy_assets, errors::ServiceError}; async fn map_global_admins(conn: &Pool) -> Result<(), ServiceError> { let channels = handles::select_related_channels(conn, None).await?; @@ -29,58 +28,20 @@ async fn map_global_admins(conn: &Pool) -> Result<(), ServiceError> { 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 = if parent - .file_name() - .unwrap_or_else(|| OsStr::new("0")) - .to_string_lossy() - .to_string() - .parse::() - .is_ok() - { - parent.join(filename) - } else { - 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 global = handles::select_global(conn).await?; - let mut channel = handles::insert_channel(conn, target_channel).await?; + let channel = handles::insert_channel(conn, target_channel).await?; + let storage_path = PathBuf::from(channel.storage_path.clone()); + handles::new_channel_presets(conn, channel.id).await?; - channel.preview_url = preview_url(&channel.preview_url, channel.id); - - if global.shared_storage { - channel.hls_path = Path::new(&global.public_root) - .join(channel.id.to_string()) - .to_string_lossy() - .to_string(); - channel.playlist_path = Path::new(&global.playlist_root) - .join(channel.id.to_string()) - .to_string_lossy() - .to_string(); - channel.storage_path = Path::new(&global.storage_root) - .join(channel.id.to_string()) - .to_string_lossy() - .to_string(); - } + if let Err(e) = copy_assets(&storage_path).await { + error!("{e}"); + }; handles::update_channel(conn, channel.id, channel.clone()).await?; diff --git a/engine/src/utils/config.rs b/engine/src/utils/config.rs index 03e21427..3992df6e 100644 --- a/engine/src/utils/config.rs +++ b/engine/src/utils/config.rs @@ -328,6 +328,10 @@ pub struct Processing { pub audio_channels: u8, pub volume: f64, pub custom_filter: String, + #[serde(default)] + pub vtt_enable: bool, + #[serde(default)] + pub vtt_dummy: Option, #[serde(skip_serializing, skip_deserializing)] pub cmd: Option>, } @@ -355,6 +359,8 @@ impl Processing { audio_channels: config.processing_audio_channels, volume: config.processing_volume, custom_filter: config.processing_filter.clone(), + vtt_enable: config.processing_vtt_enable, + vtt_dummy: config.processing_vtt_dummy.clone(), cmd: None, } } @@ -452,6 +458,7 @@ pub struct Text { pub zmq_stream_socket: Option, #[serde(skip_serializing, skip_deserializing)] pub zmq_server_socket: Option, + #[serde(alias = "fontfile")] pub font: String, #[serde(skip_serializing, skip_deserializing)] pub font_path: String, @@ -655,7 +662,9 @@ impl PlayoutConfig { "-maxrate", &bitrate, "-bufsize", - &buff_size + &buff_size, + "-mpegts_flags", + "initial_discontinuity" ]); } @@ -845,7 +854,7 @@ pub async fn get_config( } if let Some(playlist) = args.playlist { - config.channel.playlist_path = playlist; + config.channel.playlist_path = PathBuf::from(&playlist); } if let Some(folder) = args.folder { diff --git a/engine/src/utils/mod.rs b/engine/src/utils/mod.rs index 3c6d3d69..a637a980 100644 --- a/engine/src/utils/mod.rs +++ b/engine/src/utils/mod.rs @@ -4,6 +4,9 @@ use std::{ path::{Path, PathBuf}, }; +#[cfg(target_family = "unix")] +use std::os::unix::fs::MetadataExt; + use chrono::{format::ParseErrorKind, prelude::*}; use faccess::PathExt; use log::*; @@ -310,3 +313,59 @@ pub fn round_to_nearest_ten(num: i64) -> i64 { (num / 10) * 10 } } + +pub async fn copy_assets(storage_path: &Path) -> Result<(), std::io::Error> { + if storage_path.is_dir() { + let target = storage_path.join("00-assets"); + let mut dummy_source = Path::new("/usr/share/ffplayout/dummy.vtt"); + let mut font_source = Path::new("/usr/share/ffplayout/DejaVuSans.ttf"); + let mut logo_source = Path::new("/usr/share/ffplayout/logo.png"); + + if !dummy_source.is_file() { + dummy_source = Path::new("./assets/dummy.vtt") + } + if !font_source.is_file() { + font_source = Path::new("./assets/DejaVuSans.ttf") + } + if !logo_source.is_file() { + logo_source = Path::new("./assets/logo.png") + } + + if !target.is_dir() { + let dummy_target = target.join("dummy.vtt"); + let font_target = target.join("DejaVuSans.ttf"); + let logo_target = target.join("logo.png"); + + fs::create_dir(&target).await?; + fs::copy(&dummy_source, &dummy_target).await?; + fs::copy(&font_source, &font_target).await?; + fs::copy(&logo_source, &logo_target).await?; + + #[cfg(target_family = "unix")] + { + let uid = nix::unistd::Uid::current(); + let parent_owner = storage_path.metadata().unwrap().uid(); + + if uid.is_root() && uid.to_string() != parent_owner.to_string() { + let user = nix::unistd::User::from_uid(parent_owner.into()) + .unwrap_or_default() + .unwrap(); + + nix::unistd::chown(&target, Some(user.uid), Some(user.gid))?; + + if dummy_target.is_file() { + nix::unistd::chown(&dummy_target, Some(user.uid), Some(user.gid))?; + } + if font_target.is_file() { + nix::unistd::chown(&font_target, Some(user.uid), Some(user.gid))?; + } + if logo_target.is_file() { + nix::unistd::chown(&logo_target, Some(user.uid), Some(user.gid))?; + } + } + } + } + } + + Ok(()) +} diff --git a/frontend/components/ConfigChannel.vue b/frontend/components/ConfigChannel.vue index 7a67d0e5..60c0c90d 100644 --- a/frontend/components/ConfigChannel.vue +++ b/frontend/components/ConfigChannel.vue @@ -120,7 +120,7 @@ function newChannel() { newChannel.id = channels.length + 1 newChannel.name = `Channel ${newChannel.id}` - newChannel.preview_url = `${window.location.protocol}//${window.location.host}/live/${newChannel.id}/stream.m3u8` + newChannel.preview_url = `${window.location.protocol}//${window.location.host}/${newChannel.id}/live/stream.m3u8` newChannel.hls_path = `${rmId(newChannel.hls_path)}/${newChannel.id}` newChannel.playlist_path = `${rmId(newChannel.playlist_path)}/${newChannel.id}` newChannel.storage_path = `${rmId(newChannel.storage_path)}/${newChannel.id}` @@ -158,6 +158,7 @@ async function deleteChannel() { }) config.splice(configStore.id, 1) + configStore.channelsRaw.splice(configStore.id, 1) configStore.channels = config configStore.id = configStore.channels.length - 1 diff --git a/frontend/components/ConfigPlayout.vue b/frontend/components/ConfigPlayout.vue index 9f034c38..f4733530 100644 --- a/frontend/components/ConfigPlayout.vue +++ b/frontend/components/ConfigPlayout.vue @@ -15,7 +15,14 @@ class="form-control w-full" :class="[typeof prop === 'boolean' && 'flex-row', name.toString() !== 'help_text' && 'mt-2']" > -