commit
2043c7fe72
1
.gitignore
vendored
1
.gitignore
vendored
@ -18,5 +18,6 @@
|
||||
*tar.gz
|
||||
*.deb
|
||||
*.rpm
|
||||
/assets/*.db*
|
||||
|
||||
.vscode/
|
||||
|
1726
Cargo.lock
generated
1726
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
77
Cargo.toml
77
Cargo.toml
@ -1,77 +1,12 @@
|
||||
[package]
|
||||
name = "ffplayout-engine"
|
||||
description = "24/7 playout based on rust and ffmpeg"
|
||||
license = "GPL-3.0"
|
||||
authors = ["Jonathan Baecker jonbae77@gmail.com"]
|
||||
readme = "README.md"
|
||||
version = "0.9.8"
|
||||
edition = "2021"
|
||||
default-run = "ffplayout"
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
chrono = { git = "https://github.com/sbrocket/chrono", branch = "parse-error-kind-public" }
|
||||
clap = { version = "3.1", features = ["derive"] }
|
||||
crossbeam-channel = "0.5"
|
||||
ffprobe = "0.3"
|
||||
file-rotate = { git = "https://github.com/Ploppz/file-rotate.git", branch = "timestamp-parse-fix" }
|
||||
jsonrpc-http-server = "18.0"
|
||||
lettre = "0.10.0-rc.7"
|
||||
log = "0.4"
|
||||
notify = "4.0"
|
||||
rand = "0.8"
|
||||
regex = "1"
|
||||
reqwest = { version = "0.11", features = ["blocking"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.8"
|
||||
shlex = "1.1"
|
||||
simplelog = { version = "^0.12", features = ["paris"] }
|
||||
time = { version = "0.3", features = ["formatting", "macros"] }
|
||||
walkdir = "2"
|
||||
|
||||
[target.x86_64-unknown-linux-musl.dependencies]
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
|
||||
[[bin]]
|
||||
name = "ffplayout"
|
||||
path = "src/main.rs"
|
||||
members = [
|
||||
"ffplayout-api",
|
||||
"ffplayout-engine",
|
||||
"lib",
|
||||
]
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
strip = true
|
||||
lto = true
|
||||
|
||||
# DEBIAN DEB PACKAGE
|
||||
[package.metadata.deb]
|
||||
name = "ffplayout-engine"
|
||||
priority = "optional"
|
||||
section = "net"
|
||||
license-file = ["LICENSE", "0"]
|
||||
depends = ""
|
||||
suggests = "ffmpeg"
|
||||
copyright = "Copyright (c) 2022, Jonathan Baecker. All rights reserved."
|
||||
conf-files = ["/etc/ffplayout/ffplayout.yml"]
|
||||
assets = [
|
||||
[
|
||||
"target/x86_64-unknown-linux-musl/release/ffplayout",
|
||||
"/usr/bin/ffplayout",
|
||||
"755"
|
||||
],
|
||||
["assets/ffplayout.yml", "/etc/ffplayout/ffplayout.yml", "644"],
|
||||
["assets/logo.png", "/usr/share/ffplayout/logo.png", "644"],
|
||||
["README.md", "/usr/share/doc/ffplayout-engine/README", "644"],
|
||||
]
|
||||
systemd-units = { unit-name = "ffplayout-engine", unit-scripts = "assets", enable = false }
|
||||
|
||||
# REHL RPM PACKAGE
|
||||
[package.metadata.generate-rpm]
|
||||
name = "ffplayout-engine"
|
||||
license = "GPL-3.0"
|
||||
assets = [
|
||||
{ source = "target/x86_64-unknown-linux-musl/release/ffplayout", dest = "/usr/bin/ffplayout", mode = "755" },
|
||||
{ source = "assets/ffplayout.yml", dest = "/etc/ffplayout/ffplayout.yml", mode = "644", config = true },
|
||||
{ source = "assets/ffplayout-engine.service", dest = "/lib/systemd/system/ffplayout-engine.service", mode = "644" },
|
||||
{ source = "README.md", dest = "/usr/share/doc/ffplayout-engine/README", mode = "644", doc = true },
|
||||
{ source = "LICENSE", dest = "/usr/share/doc/ffplayout-engine/LICENSE", mode = "644" },
|
||||
{ source = "assets/logo.png", dest = "/usr/share/ffplayout/logo.png", mode = "644" },
|
||||
]
|
||||
|
78
README.md
78
README.md
@ -1,22 +1,26 @@
|
||||
**ffplayout-engine**
|
||||
**ffplayout**
|
||||
================
|
||||
|
||||
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
|
||||
|
||||
The main purpose of ffplayout is to provide a 24/7 broadcasting solution that plays a *json* playlist for every day, while keeping the current playlist editable.
|
||||
The ffplayout apps are mostly made to run on Linux as system services. But in general they should run on all platforms which are supported by Rust. At the moment the cross compiled version from *ffpapi* runs on Windows and Linux, and not on Mac. If it is needed there, it should be compile natively.
|
||||
|
||||
**Check [ffplayout-frontend](https://github.com/ffplayout/ffplayout-frontend): web-based GUI for ffplayout**
|
||||
Check the [releases](https://github.com/ffplayout/ffplayout-engine/releases/latest) for pre compiled version.
|
||||
|
||||
**Features**
|
||||
**ffplayout-engine (ffplayout)**
|
||||
-----
|
||||
|
||||
[ffplayout](/ffplayout-engine/README.md) is 24/7 broadcasting solution. It can playout a folder with containing video clips, or play for every day a *JSON* playlist, while keeping the current playlist editable.
|
||||
|
||||
### Features
|
||||
|
||||
- have all values in a separate config file
|
||||
- dynamic playlist
|
||||
- replace missing playlist or clip with a dummy clip
|
||||
- playing clips in [watched](/docs/folder_mode.md) folder mode
|
||||
- send emails with error message
|
||||
- overlay a logo
|
||||
- overlay text, controllable through [messenger](https://github.com/ffplayout/messenger) or [ffplayout-frontend](https://github.com/ffplayout/ffplayout-frontend) (needs ffmpeg with libzmq)
|
||||
- overlay text, controllable through [messenger](https://github.com/ffplayout/messenger) or [ffplayout-frontend](https://github.com/ffplayout/ffplayout-frontend) (needs ffmpeg with libzmq and enabled JSON RPC server)
|
||||
- EBU R128 loudness normalization (single pass)
|
||||
- loop playlist infinitely
|
||||
- [remote source](/docs/remote_source.md)
|
||||
@ -43,6 +47,10 @@ The main purpose of ffplayout is to provide a 24/7 broadcasting solution that pl
|
||||
- JSON RPC server, for getting infos about current playing and controlling
|
||||
- [live ingest](/docs/live_ingest.md)
|
||||
|
||||
**ffplayout-api (ffpapi)**
|
||||
-----
|
||||
ffpapi is an [REST API](/ffplayout-api/README.md) for controlling the engine, manipulate playlists, add settings etc.
|
||||
|
||||
Requirements
|
||||
-----
|
||||
|
||||
@ -122,20 +130,24 @@ The ffplayout engine can run a JSON RPC server. A request show look like:
|
||||
|
||||
```Bash
|
||||
curl -X POST -H "Content-Type: application/json" -H "Authorization: ---auth-key---" \
|
||||
-d '{"jsonrpc": "2.0", "method": "player", "params":{"control":"next"}, "id":1 }' \
|
||||
-d '{"jsonrpc": "2.0", "id":1, "method": "player", "params":{"control":"next"}}' \
|
||||
127.0.0.1:7070
|
||||
```
|
||||
|
||||
At the moment this comments are possible:
|
||||
|
||||
```Bash
|
||||
'{"jsonrpc": "2.0", "method": "player", "params":{"media":"current"}, "id":1 }' # get infos about current clip
|
||||
'{"jsonrpc": "2.0", "method": "player", "params":{"media":"next"}, "id":2 }' # get infos about next clip
|
||||
'{"jsonrpc": "2.0", "method": "player", "params":{"media":"last"}, "id":3 }' # get infos about last clip
|
||||
'{"jsonrpc": "2.0", "method": "player", "params":{"control":"next"}, "id":4 }' # jump to next clip
|
||||
'{"jsonrpc": "2.0", "method": "player", "params":{"control":"back"}, "id":5 }' # jump to last clip
|
||||
'{"jsonrpc": "2.0", "method": "player", "params":{"control":"reset"}, "id":6 }' # reset playlist to old state
|
||||
'{"jsonrpc": "2.0", "id":1, "method": "player", "params":{"media":"current"}}' # get infos about current clip
|
||||
'{"jsonrpc": "2.0", "id":2, "method": "player", "params":{"media":"next"}}' # get infos about next clip
|
||||
'{"jsonrpc": "2.0", "id":3, "method": "player", "params":{"media":"last"}}' # get infos about last clip
|
||||
'{"jsonrpc": "2.0", "id":4, "method": "player", "params":{"control":"next"}}' # jump to next clip
|
||||
'{"jsonrpc": "2.0", "id":5, "method": "player", "params":{"control":"back"}}' # jump to last clip
|
||||
'{"jsonrpc": "2.0", "id":6, "method": "player", "params":{"control":"reset"}}' # reset playlist to old state
|
||||
|
||||
'{"jsonrpc": "2.0", "id":7, "method": "player", "params":{"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:
|
||||
@ -162,45 +174,3 @@ Output from `{"media":"current"}` show:
|
||||
}
|
||||
```
|
||||
When you are in playlist mode and jumping forward or backwards in time, the time shift will be saved so the playlist is still in sync. But have in mind, that then maybe your playlist gets to short. When you are not resetting the state, it will reset on the next day automatically.
|
||||
|
||||
-----
|
||||
|
||||
Installation under Linux
|
||||
-----
|
||||
|
||||
- copy the binary to `/usr/bin/`
|
||||
- copy **assets/ffplayout.yml** to `/etc/ffplayout`
|
||||
- copy **assets/ffplayout-engine.service** to `/etc/systemd/system`
|
||||
- activate service and run it: `systemctl enable --now ffplayout-engine`
|
||||
|
||||
You can also install the released ***.deb** or ***.rpm** package.
|
||||
|
||||
Start with Arguments
|
||||
-----
|
||||
|
||||
ffplayout also allows the passing of parameters:
|
||||
|
||||
```
|
||||
OPTIONS:
|
||||
-c, --config <CONFIG> File path to ffplayout.conf
|
||||
-f, --folder <FOLDER> Play folder content
|
||||
-g, --generate <YYYY-MM-DD>... Generate playlist for date or date-range, like: 2022-01-01 - 2022-01-10:
|
||||
-h, --help Print help information
|
||||
-i, --infinit Loop playlist infinitely
|
||||
-l, --log <LOG> File path for logging
|
||||
-m, --play-mode <PLAY_MODE> Playing mode: folder, playlist
|
||||
-o, --output <OUTPUT> Set output mode: desktop, hls, stream
|
||||
-p, --playlist <PLAYLIST> Path from playlist
|
||||
-s, --start <START> Start time in 'hh:mm:ss', 'now' for start with first
|
||||
-t, --length <LENGTH> Set length in 'hh:mm:ss', 'none' for no length check
|
||||
-v, --volume <VOLUME> Set audio volume
|
||||
-V, --version Print version information
|
||||
|
||||
```
|
||||
|
||||
|
||||
You can run the command like:
|
||||
|
||||
```Bash
|
||||
./ffplayout -l none -p ~/playlist.json -o desktop
|
||||
```
|
||||
|
14
assets/ffpapi.service
Normal file
14
assets/ffpapi.service
Normal file
@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Rest API for ffplayout
|
||||
After=network.target remote-fs.target
|
||||
|
||||
[Service]
|
||||
ExecStart= /usr/bin/ffpapi
|
||||
ExecReload=/bin/kill -1 $MAINPID
|
||||
Restart=always
|
||||
RestartSec=1
|
||||
User=www-data
|
||||
Group=www-data
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
@ -105,16 +105,12 @@ 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'.
|
||||
In a standard environment the filter drawtext node is Parsed_drawtext_2.
|
||||
'over_pre' if True text will be overlay in pre processing. Continue same text
|
||||
over multiple files is in that mode not possible. '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.
|
||||
'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: false
|
||||
over_pre: false
|
||||
bind_address: "127.0.0.1:5555"
|
||||
fontfile: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||||
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)$
|
||||
|
||||
|
60
build_all.sh
Executable file
60
build_all.sh
Executable file
@ -0,0 +1,60 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
|
||||
targets=("x86_64-unknown-linux-musl" "x86_64-pc-windows-gnu" "x86_64-apple-darwin" "aarch64-apple-darwin")
|
||||
|
||||
IFS="= "
|
||||
while read -r name value; do
|
||||
if [[ $name == "version" ]]; then
|
||||
version=${value//\"/}
|
||||
fi
|
||||
done < ffplayout-engine/Cargo.toml
|
||||
|
||||
echo "Compile ffplayout version is: \"$version\""
|
||||
echo ""
|
||||
|
||||
for target in "${targets[@]}"; do
|
||||
echo "compile static for $target"
|
||||
echo ""
|
||||
|
||||
if [[ $target == "x86_64-pc-windows-gnu" ]]; then
|
||||
if [[ -f "ffplayout-v${version}_${target}.zip" ]]; then
|
||||
rm -f "ffplayout-v${version}_${target}.zip"
|
||||
fi
|
||||
|
||||
cargo build --release --target=$target
|
||||
|
||||
cp ./target/${target}/release/ffpapi.exe .
|
||||
cp ./target/${target}/release/ffplayout.exe .
|
||||
zip -r "ffplayout-v${version}_${target}.zip" assets docs LICENSE README.md ffplayout.exe ffpapi.exe -x *.db
|
||||
rm -f ffplayout.exe ffpapi.exe
|
||||
elif [[ $target == "x86_64-apple-darwin" ]] || [[ $target == "aarch64-apple-darwin" ]]; then
|
||||
if [[ -f "ffplayout-v${version}_${target}.tar.gz" ]]; then
|
||||
rm -f "ffplayout-v${version}_${target}.tar.gz"
|
||||
fi
|
||||
|
||||
cargo build --release --target=$target --bin ffplayout
|
||||
|
||||
cp ./target/${target}/release/ffplayout .
|
||||
tar -czvf "ffplayout-v${version}_${target}.tar.gz" --exclude='*.db' assets docs LICENSE README.md ffplayout
|
||||
rm -f ffplayout
|
||||
else
|
||||
if [[ -f "ffplayout-v${version}_${target}.tar.gz" ]]; then
|
||||
rm -f "ffplayout-v${version}_${target}.tar.gz"
|
||||
fi
|
||||
|
||||
cargo build --release --target=$target
|
||||
|
||||
cp ./target/${target}/release/ffpapi .
|
||||
cp ./target/${target}/release/ffplayout .
|
||||
tar -czvf "ffplayout-v${version}_${target}.tar.gz" --exclude='*.db' assets docs LICENSE README.md ffplayout ffpapi
|
||||
rm -f ffplayout ffpapi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
cargo deb --target=x86_64-unknown-linux-musl -p ffplayout --manifest-path=ffplayout-engine/Cargo.toml -o ffplayout_${version}_amd64.deb
|
||||
|
||||
cargo generate-rpm --target=x86_64-unknown-linux-musl -p ffplayout-engine -o ffplayout-${version}-1.x86_64.rpm
|
||||
|
@ -1,54 +0,0 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
|
||||
targets=("x86_64-unknown-linux-musl" "x86_64-pc-windows-gnu" "x86_64-apple-darwin" "aarch64-apple-darwin")
|
||||
|
||||
IFS="= "
|
||||
while read -r name value; do
|
||||
if [[ $name == "version" ]]; then
|
||||
version=${value//\"/}
|
||||
fi
|
||||
done < Cargo.toml
|
||||
|
||||
echo "Compile ffplayout-engine version is: \"$version\""
|
||||
echo ""
|
||||
|
||||
for target in "${targets[@]}"; do
|
||||
echo "compile static for $target"
|
||||
echo ""
|
||||
|
||||
cargo build --release --target=$target
|
||||
|
||||
if [[ $target == "x86_64-pc-windows-gnu" ]]; then
|
||||
if [[ -f "ffplayout-engine-v${version}_${target}.zip" ]]; then
|
||||
rm -f "ffplayout-engine-v${version}_${target}.zip"
|
||||
fi
|
||||
|
||||
cp ./target/${target}/release/ffplayout.exe .
|
||||
zip -r "ffplayout-engine-v${version}_${target}.zip" assets docs LICENSE README.md ffplayout.exe
|
||||
rm -f ffplayout.exe
|
||||
else
|
||||
if [[ -f "ffplayout-engine-v${version}_${target}.tar.gz" ]]; then
|
||||
rm -f "ffplayout-engine-v${version}_${target}.tar.gz"
|
||||
fi
|
||||
|
||||
cp ./target/${target}/release/ffplayout .
|
||||
tar -czvf "ffplayout-engine-v${version}_${target}.tar.gz" assets docs LICENSE README.md ffplayout
|
||||
rm -f ffplayout
|
||||
fi
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo "Create debian package"
|
||||
echo ""
|
||||
|
||||
cargo deb --target=x86_64-unknown-linux-musl
|
||||
mv ./target/x86_64-unknown-linux-musl/debian/ffplayout-engine_${version}_amd64.deb .
|
||||
|
||||
echo ""
|
||||
echo "Create rhel package"
|
||||
echo ""
|
||||
|
||||
cargo generate-rpm --target=x86_64-unknown-linux-musl
|
||||
mv ./target/x86_64-unknown-linux-musl/generate-rpm/ffplayout-engine-${version}-1.x86_64.rpm .
|
0
debian/.gitkeep
vendored
Normal file
0
debian/.gitkeep
vendored
Normal file
171
docs/api.md
Normal file
171
docs/api.md
Normal file
@ -0,0 +1,171 @@
|
||||
#### Possible endpoints
|
||||
|
||||
Run the API thru the systemd service, or like:
|
||||
|
||||
```BASH
|
||||
ffpapi -l 127.0.0.1:8080
|
||||
```
|
||||
|
||||
For all endpoints an (Bearer) authentication is required.\
|
||||
`{id}` represent the channel id, and at default is 1.
|
||||
|
||||
#### Login is
|
||||
|
||||
- **POST** `/auth/login/`\
|
||||
JSON Data: `{"username": "<USER>", "password": "<PASS>"}`\
|
||||
JSON Response:
|
||||
```JSON
|
||||
{
|
||||
"message": "login correct!",
|
||||
"status": 200,
|
||||
"data": {
|
||||
"id": 1,
|
||||
"email": "user@example.org",
|
||||
"username": "user",
|
||||
"token": "<TOKEN>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
From here on all request **must** contain the authorization header:\
|
||||
`"Authorization: Bearer <TOKEN>"`
|
||||
|
||||
#### User
|
||||
|
||||
- **PUT** `/api/user/{user id}`\
|
||||
JSON Data: `{"email": "<EMAIL>", "password": "<PASS>"}`
|
||||
|
||||
- **POST** `/api/user/`\
|
||||
JSON Data:
|
||||
```JSON
|
||||
{
|
||||
"email": "<EMAIL>",
|
||||
"username": "<USER>",
|
||||
"password": "<PASS>",
|
||||
"role_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
#### API Settings
|
||||
|
||||
- **GET** `/api/settings/{id}`\
|
||||
HEADER:
|
||||
Response is in JSON format
|
||||
|
||||
- **PATCH** `/api/settings/{id}`\
|
||||
JSON Data:
|
||||
```JSON
|
||||
"id": 1,
|
||||
"channel_name": "Channel 1",
|
||||
"preview_url": "http://localhost/live/stream.m3u8",
|
||||
"config_path": "/etc/ffplayout/ffplayout.yml",
|
||||
"extra_extensions": ".jpg,.jpeg,.png"
|
||||
```
|
||||
|
||||
#### Playout Config
|
||||
|
||||
- **GET** `/api/playout/config/{id}`\
|
||||
Response is in JSON format
|
||||
|
||||
- **PUT** `/api/playout/config/{id}`\
|
||||
JSON Data: `{ <CONFIG DATA> }`\
|
||||
Response is in TEXT format
|
||||
|
||||
#### Text Presets
|
||||
|
||||
- **GET** `/api/presets/`\
|
||||
Response is in JSON format
|
||||
|
||||
- **PUT** `/api/playout/presets/{id}`\
|
||||
JSON Data:
|
||||
```JSON
|
||||
{
|
||||
"name": "<PRESET NAME>",
|
||||
"text": "<TEXT>",
|
||||
"x": "<X>",
|
||||
"y": "<Y>",
|
||||
"fontsize": 24,
|
||||
"line_spacing": 4,
|
||||
"fontcolor": "#ffffff",
|
||||
"box": 1,
|
||||
"boxcolor": "#000000",
|
||||
"boxborderw": 4,
|
||||
"alpha": "<alpha>"
|
||||
}
|
||||
|
||||
```
|
||||
Response is in TEXT format
|
||||
|
||||
- **POST** `/api/playout/presets/`\
|
||||
JSON Data: `{ <PRESET DATA> }`\
|
||||
Response is in TEXT format
|
||||
|
||||
#### Playout Process Control
|
||||
|
||||
- **POST** `/api/control/{id}/text/`¸
|
||||
JSON Data:
|
||||
```JSON
|
||||
{
|
||||
"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"
|
||||
}
|
||||
```
|
||||
Response is in TEXT format
|
||||
|
||||
- **POST** `api/control/{id}/playout/next/`\
|
||||
Response is in TEXT format
|
||||
|
||||
- **POST** `api/control/{id}/playout/back/`\
|
||||
Response is in TEXT format
|
||||
|
||||
- **POST** `api/control/{id}/playout/reset/`\
|
||||
Response is in TEXT format
|
||||
|
||||
- **GET** `/api/control/{id}/media/current/`\
|
||||
Response is in JSON format
|
||||
|
||||
- **GET** `/api/control/{id}/media/next/`\
|
||||
Response is in JSON format
|
||||
|
||||
- **GET** `/api/control/{id}/media/last/`\
|
||||
Response is in JSON format
|
||||
|
||||
#### Playlist Operations
|
||||
|
||||
- **GET** `/api/playlist/{id}/2022-06-20`\
|
||||
Response is in JSON format
|
||||
|
||||
- **POST** `/api/playlist/1/`\
|
||||
JSON Data: `{ <PLAYLIST DATA> }`\
|
||||
Response is in TEXT format
|
||||
|
||||
- **GET** `/api/playlist/{id}/generate/2022-06-20`\
|
||||
Response is in JSON format
|
||||
|
||||
- **DELETE** `/api/playlist/{id}/2022-06-20`\
|
||||
Response is in TEXT format
|
||||
|
||||
#### File Operations
|
||||
|
||||
- **GET** `/api/file/{id}/browse/`\
|
||||
Response is in JSON format
|
||||
|
||||
- **POST** `/api/file/{id}/move/`\
|
||||
JSON Data: `{"source": "<SOURCE>", "target": "<TARGET>"}`\
|
||||
Response is in JSON format
|
||||
|
||||
- **DELETE** `/api/file/{id}/remove/`\
|
||||
JSON Data: `{"source": "<SOURCE>"}`\
|
||||
Response is in JSON format
|
||||
|
||||
- **POST** `/file/{id}/upload/`\
|
||||
Multipart Form: `name=<TARGET PATH>, filename=<FILENAME>`\
|
||||
Response is in TEXT format
|
40
ffplayout-api/Cargo.toml
Normal file
40
ffplayout-api/Cargo.toml
Normal file
@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "ffplayout-api"
|
||||
description = "Rest API for ffplayout"
|
||||
license = "GPL-3.0"
|
||||
authors = ["Jonathan Baecker jonbae77@gmail.com"]
|
||||
readme = "README.md"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
ffplayout-lib = { path = "../lib" }
|
||||
actix-multipart = "0.4"
|
||||
actix-web = "4"
|
||||
actix-web-grants = "3"
|
||||
actix-web-httpauth = "0.6"
|
||||
argon2 = "0.4"
|
||||
chrono = "0.4"
|
||||
clap = { version = "3.2", features = ["derive"] }
|
||||
derive_more = "0.99"
|
||||
faccess = "0.2"
|
||||
ffprobe = "0.3"
|
||||
futures-util = { version = "0.3", default-features = false, features = ["std"] }
|
||||
jsonwebtoken = "8"
|
||||
log = "0.4"
|
||||
once_cell = "1.10"
|
||||
rand = "0.8"
|
||||
rand_core = { version = "0.6", features = ["std"] }
|
||||
relative-path = "1.6"
|
||||
regex = "1"
|
||||
reqwest = { version = "0.11", features = ["blocking", "json"] }
|
||||
sanitize-filename = "0.3"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.8"
|
||||
simplelog = { version = "^0.12", features = ["paris"] }
|
||||
sqlx = { version = "0.5", features = [
|
||||
"chrono",
|
||||
"runtime-actix-native-tls",
|
||||
"sqlite"
|
||||
] }
|
24
ffplayout-api/README.md
Normal file
24
ffplayout-api/README.md
Normal file
@ -0,0 +1,24 @@
|
||||
**ffplayout-api**
|
||||
================
|
||||
|
||||
ffplayout-api (ffpapi) is a non strict REST API for ffplayout. It makes it possible to control the engine, read and manipulate the config, save playlist, etc.
|
||||
|
||||
To be able to use the API it is necessary to initialize the settings database first. To do that, run:
|
||||
|
||||
```BASH
|
||||
ffpapi -i
|
||||
```
|
||||
|
||||
Then add an admin user:
|
||||
|
||||
```BASH
|
||||
ffpapi -u <USERNAME> -p <PASSWORD> -e <EMAIL ADDRESS>
|
||||
```
|
||||
|
||||
Then run the API thru the systemd service, or like:
|
||||
|
||||
```BASH
|
||||
ffpapi -l 127.0.0.1:8080
|
||||
```
|
||||
|
||||
**For possible endpoints read: [api endpoints](/docs/api.md)**
|
113
ffplayout-api/src/main.rs
Normal file
113
ffplayout-api/src/main.rs
Normal file
@ -0,0 +1,113 @@
|
||||
use std::{path::Path, process::exit};
|
||||
|
||||
use actix_web::{dev::ServiceRequest, middleware, web, App, Error, HttpMessage, HttpServer};
|
||||
use actix_web_grants::permissions::AttachPermissions;
|
||||
use actix_web_httpauth::extractors::bearer::BearerAuth;
|
||||
use actix_web_httpauth::middleware::HttpAuthentication;
|
||||
|
||||
use clap::Parser;
|
||||
use simplelog::*;
|
||||
|
||||
pub mod utils;
|
||||
|
||||
use utils::{
|
||||
args_parse::Args,
|
||||
auth, db_path, init_config,
|
||||
models::LoginUser,
|
||||
routes::{
|
||||
add_preset, add_user, del_playlist, file_browser, gen_playlist, get_playlist,
|
||||
get_playout_config, get_presets, get_settings, jump_to_last, jump_to_next, login,
|
||||
media_current, media_last, media_next, move_rename, patch_settings, remove, reset_playout,
|
||||
save_file, save_playlist, send_text_message, update_playout_config, update_preset,
|
||||
update_user,
|
||||
},
|
||||
run_args, Role,
|
||||
};
|
||||
|
||||
use ffplayout_lib::utils::{init_logging, PlayoutConfig};
|
||||
|
||||
async fn validator(req: ServiceRequest, credentials: BearerAuth) -> Result<ServiceRequest, Error> {
|
||||
// We just get permissions from JWT
|
||||
let claims = auth::decode_jwt(credentials.token()).await?;
|
||||
req.attach(vec![Role::set_role(&claims.role)]);
|
||||
|
||||
req.extensions_mut()
|
||||
.insert(LoginUser::new(claims.id, claims.username));
|
||||
|
||||
Ok(req)
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
let mut config = PlayoutConfig::new(None);
|
||||
config.mail.recipient = String::new();
|
||||
config.logging.log_to_file = false;
|
||||
config.logging.timestamp = false;
|
||||
|
||||
let logging = init_logging(&config, None, None);
|
||||
CombinedLogger::init(logging).unwrap();
|
||||
|
||||
if let Err(c) = run_args(args.clone()).await {
|
||||
exit(c);
|
||||
}
|
||||
|
||||
if let Some(conn) = args.listen {
|
||||
if let Ok(p) = db_path() {
|
||||
if !Path::new(&p).is_file() {
|
||||
error!("Database is not initialized! Init DB first and add admin user.");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
init_config().await;
|
||||
let ip_port = conn.split(':').collect::<Vec<&str>>();
|
||||
let addr = ip_port[0];
|
||||
let port = ip_port[1].parse::<u16>().unwrap();
|
||||
|
||||
info!("running ffplayout API, listen on {conn}");
|
||||
|
||||
// TODO: add allow origin (or give it to the proxy)
|
||||
HttpServer::new(move || {
|
||||
let auth = HttpAuthentication::bearer(validator);
|
||||
App::new()
|
||||
.wrap(middleware::Logger::default())
|
||||
.service(login)
|
||||
.service(
|
||||
web::scope("/api")
|
||||
.wrap(auth)
|
||||
.service(add_user)
|
||||
.service(get_playout_config)
|
||||
.service(update_playout_config)
|
||||
.service(add_preset)
|
||||
.service(get_presets)
|
||||
.service(update_preset)
|
||||
.service(get_settings)
|
||||
.service(patch_settings)
|
||||
.service(update_user)
|
||||
.service(send_text_message)
|
||||
.service(jump_to_next)
|
||||
.service(jump_to_last)
|
||||
.service(reset_playout)
|
||||
.service(media_current)
|
||||
.service(media_next)
|
||||
.service(media_last)
|
||||
.service(get_playlist)
|
||||
.service(save_playlist)
|
||||
.service(gen_playlist)
|
||||
.service(del_playlist)
|
||||
.service(file_browser)
|
||||
.service(move_rename)
|
||||
.service(remove)
|
||||
.service(save_file),
|
||||
)
|
||||
})
|
||||
.bind((addr, port))?
|
||||
.run()
|
||||
.await
|
||||
} else {
|
||||
error!("Run ffpapi with listen parameter!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
22
ffplayout-api/src/utils/args_parse.rs
Normal file
22
ffplayout-api/src/utils/args_parse.rs
Normal file
@ -0,0 +1,22 @@
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[clap(version,
|
||||
about = "REST API for ffplayout",
|
||||
long_about = None)]
|
||||
pub struct Args {
|
||||
#[clap(short, long, help = "Listen on IP:PORT, like: 127.0.0.1:8080")]
|
||||
pub listen: Option<String>,
|
||||
|
||||
#[clap(short, long, help = "Initialize Database")]
|
||||
pub init: bool,
|
||||
|
||||
#[clap(short, long, help = "Create admin user")]
|
||||
pub username: Option<String>,
|
||||
|
||||
#[clap(short, long, help = "Admin email")]
|
||||
pub email: Option<String>,
|
||||
|
||||
#[clap(short, long, help = "Admin password")]
|
||||
pub password: Option<String>,
|
||||
}
|
46
ffplayout-api/src/utils/auth.rs
Normal file
46
ffplayout-api/src/utils/auth.rs
Normal file
@ -0,0 +1,46 @@
|
||||
use actix_web::error::ErrorUnauthorized;
|
||||
use actix_web::Error;
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{self, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::utils::GlobalSettings;
|
||||
|
||||
// Token lifetime
|
||||
const JWT_EXPIRATION_DAYS: i64 = 7;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||
pub struct Claims {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
exp: i64,
|
||||
}
|
||||
|
||||
impl Claims {
|
||||
pub fn new(id: i64, username: String, role: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
username,
|
||||
role,
|
||||
exp: (Utc::now() + Duration::days(JWT_EXPIRATION_DAYS)).timestamp(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a json web token (JWT)
|
||||
pub fn create_jwt(claims: Claims) -> Result<String, Error> {
|
||||
let config = GlobalSettings::global();
|
||||
let encoding_key = EncodingKey::from_secret(config.secret.as_bytes());
|
||||
jsonwebtoken::encode(&Header::default(), &claims, &encoding_key)
|
||||
.map_err(|e| ErrorUnauthorized(e.to_string()))
|
||||
}
|
||||
|
||||
/// Decode a json web token (JWT)
|
||||
pub async fn decode_jwt(token: &str) -> Result<Claims, Error> {
|
||||
let config = GlobalSettings::global();
|
||||
let decoding_key = DecodingKey::from_secret(config.secret.as_bytes());
|
||||
jsonwebtoken::decode::<Claims>(token, &decoding_key, &Validation::default())
|
||||
.map(|data| data.claims)
|
||||
.map_err(|e| ErrorUnauthorized(e.to_string()))
|
||||
}
|
107
ffplayout-api/src/utils/control.rs
Normal file
107
ffplayout-api/src/utils/control.rs
Normal file
@ -0,0 +1,107 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use reqwest::{
|
||||
header::{HeaderMap, AUTHORIZATION, CONTENT_TYPE},
|
||||
Client, Response,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use simplelog::*;
|
||||
|
||||
use crate::utils::{errors::ServiceError, playout_config};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct RpcObj<T> {
|
||||
jsonrpc: String,
|
||||
id: i64,
|
||||
method: String,
|
||||
params: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct TextParams {
|
||||
control: String,
|
||||
message: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct ControlParams {
|
||||
control: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct MediaParams {
|
||||
media: String,
|
||||
}
|
||||
|
||||
impl<T> RpcObj<T> {
|
||||
fn new(id: i64, method: String, params: T) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".into(),
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_header(auth: &str) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
CONTENT_TYPE,
|
||||
"Content-Type: application/json".parse().unwrap(),
|
||||
);
|
||||
headers.insert(AUTHORIZATION, auth.parse().unwrap());
|
||||
|
||||
headers
|
||||
}
|
||||
|
||||
async fn post_request<T>(id: i64, obj: RpcObj<T>) -> Result<Response, ServiceError>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
let (config, _) = playout_config(&id).await?;
|
||||
let url = format!("http://{}", config.rpc_server.address);
|
||||
let client = Client::new();
|
||||
|
||||
match client
|
||||
.post(&url)
|
||||
.headers(create_header(&config.rpc_server.authorization))
|
||||
.json(&obj)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(result) => Ok(result),
|
||||
Err(e) => {
|
||||
error!("{e:?}");
|
||||
Err(ServiceError::BadRequest(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_message(
|
||||
id: i64,
|
||||
message: HashMap<String, String>,
|
||||
) -> Result<Response, ServiceError> {
|
||||
let json_obj = RpcObj::new(
|
||||
id,
|
||||
"player".into(),
|
||||
TextParams {
|
||||
control: "text".into(),
|
||||
message,
|
||||
},
|
||||
);
|
||||
|
||||
post_request(id, json_obj).await
|
||||
}
|
||||
|
||||
pub async fn control_state(id: i64, command: String) -> Result<Response, ServiceError> {
|
||||
let json_obj = RpcObj::new(id, "player".into(), ControlParams { control: command });
|
||||
|
||||
post_request(id, json_obj).await
|
||||
}
|
||||
|
||||
pub async fn media_info(id: i64, command: String) -> Result<Response, ServiceError> {
|
||||
let json_obj = RpcObj::new(id, "player".into(), MediaParams { media: command });
|
||||
|
||||
post_request(id, json_obj).await
|
||||
}
|
61
ffplayout-api/src/utils/errors.rs
Normal file
61
ffplayout-api/src/utils/errors.rs
Normal file
@ -0,0 +1,61 @@
|
||||
use actix_web::{error::ResponseError, Error, HttpResponse};
|
||||
use derive_more::Display;
|
||||
|
||||
#[derive(Debug, Display)]
|
||||
pub enum ServiceError {
|
||||
#[display(fmt = "Internal Server Error")]
|
||||
InternalServerError,
|
||||
|
||||
#[display(fmt = "BadRequest: {}", _0)]
|
||||
BadRequest(String),
|
||||
|
||||
#[display(fmt = "Conflict: {}", _0)]
|
||||
Conflict(String),
|
||||
|
||||
#[display(fmt = "Unauthorized")]
|
||||
Unauthorized,
|
||||
}
|
||||
|
||||
// impl ResponseError trait allows to convert our errors into http responses with appropriate data
|
||||
impl ResponseError for ServiceError {
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
match self {
|
||||
ServiceError::InternalServerError => {
|
||||
HttpResponse::InternalServerError().json("Internal Server Error. Please try later.")
|
||||
}
|
||||
ServiceError::BadRequest(ref message) => HttpResponse::BadRequest().json(message),
|
||||
ServiceError::Conflict(ref message) => HttpResponse::Conflict().json(message),
|
||||
ServiceError::Unauthorized => HttpResponse::Unauthorized().json("No Permission!"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ServiceError {
|
||||
fn from(err: String) -> ServiceError {
|
||||
ServiceError::BadRequest(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Error> for ServiceError {
|
||||
fn from(err: Error) -> ServiceError {
|
||||
ServiceError::BadRequest(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<actix_multipart::MultipartError> for ServiceError {
|
||||
fn from(err: actix_multipart::MultipartError) -> ServiceError {
|
||||
ServiceError::BadRequest(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ServiceError {
|
||||
fn from(err: std::io::Error) -> ServiceError {
|
||||
ServiceError::BadRequest(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<actix_web::error::BlockingError> for ServiceError {
|
||||
fn from(err: actix_web::error::BlockingError) -> ServiceError {
|
||||
ServiceError::BadRequest(err.to_string())
|
||||
}
|
||||
}
|
285
ffplayout-api/src/utils/files.rs
Normal file
285
ffplayout-api/src/utils/files.rs
Normal file
@ -0,0 +1,285 @@
|
||||
use std::{
|
||||
fs,
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use actix_multipart::Multipart;
|
||||
use actix_web::{web, HttpResponse};
|
||||
use futures_util::TryStreamExt as _;
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
use relative_path::RelativePath;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use simplelog::*;
|
||||
|
||||
use crate::utils::{errors::ServiceError, playout_config};
|
||||
use ffplayout_lib::utils::file_extension;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct PathObject {
|
||||
pub source: String,
|
||||
folders: Option<Vec<String>>,
|
||||
files: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl PathObject {
|
||||
fn new(source: String) -> Self {
|
||||
Self {
|
||||
source,
|
||||
folders: Some(vec![]),
|
||||
files: Some(vec![]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct MoveObject {
|
||||
source: String,
|
||||
target: String,
|
||||
}
|
||||
|
||||
pub async fn browser(id: i64, path_obj: &PathObject) -> Result<PathObject, ServiceError> {
|
||||
let (config, _) = playout_config(&id).await?;
|
||||
let path = PathBuf::from(config.storage.path);
|
||||
let extensions = config.storage.extensions;
|
||||
let path_component = RelativePath::new(&path_obj.source)
|
||||
.normalize()
|
||||
.to_string()
|
||||
.replace("../", "");
|
||||
let path = path.join(path_component.clone());
|
||||
let mut obj = PathObject::new(path_component.clone());
|
||||
|
||||
let mut paths: Vec<_> = match fs::read_dir(path) {
|
||||
Ok(p) => p.filter_map(|r| r.ok()).collect(),
|
||||
Err(e) => {
|
||||
error!("{e} in {path_component}");
|
||||
return Err(ServiceError::InternalServerError);
|
||||
}
|
||||
};
|
||||
|
||||
paths.sort_by_key(|dir| dir.path());
|
||||
|
||||
for path in paths {
|
||||
let file_path = path.path().to_owned();
|
||||
let path_str = file_path.display().to_string();
|
||||
|
||||
// ignore hidden files/folders on unix
|
||||
if path_str.contains("/.") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if file_path.is_dir() {
|
||||
if let Some(ref mut folders) = obj.folders {
|
||||
folders.push(path_str);
|
||||
}
|
||||
} else if file_path.is_file() {
|
||||
if let Some(ext) = file_extension(&file_path) {
|
||||
if extensions.contains(&ext.to_string().to_lowercase()) {
|
||||
if let Some(ref mut files) = obj.files {
|
||||
files.push(path_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(obj)
|
||||
}
|
||||
|
||||
// fn copy_and_delete(source: &PathBuf, target: &PathBuf) -> Result<PathObject, ServiceError> {
|
||||
// match fs::copy(&source, &target) {
|
||||
// Ok(_) => {
|
||||
// if let Err(e) = fs::remove_file(source) {
|
||||
// error!("{e}");
|
||||
// return Err(ServiceError::BadRequest(
|
||||
// "Removing File not possible!".into(),
|
||||
// ));
|
||||
// };
|
||||
|
||||
// return Ok(PathObject::new(target.display().to_string()));
|
||||
// }
|
||||
// Err(e) => {
|
||||
// error!("{e}");
|
||||
// Err(ServiceError::BadRequest("Error in file copy!".into()))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
fn rename(source: &PathBuf, target: &PathBuf) -> Result<MoveObject, ServiceError> {
|
||||
match fs::rename(&source, &target) {
|
||||
Ok(_) => Ok(MoveObject {
|
||||
source: source.display().to_string(),
|
||||
target: target.display().to_string(),
|
||||
}),
|
||||
Err(e) => {
|
||||
error!("{e}");
|
||||
Err(ServiceError::BadRequest("Rename failed!".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn rename_file(id: i64, move_object: &MoveObject) -> Result<MoveObject, ServiceError> {
|
||||
let (config, _) = playout_config(&id).await?;
|
||||
let path = PathBuf::from(&config.storage.path);
|
||||
let source = RelativePath::new(&move_object.source)
|
||||
.normalize()
|
||||
.to_string()
|
||||
.replace("../", "");
|
||||
let target = RelativePath::new(&move_object.target)
|
||||
.normalize()
|
||||
.to_string()
|
||||
.replace("../", "");
|
||||
|
||||
let mut source_path = PathBuf::from(source.clone());
|
||||
let mut target_path = PathBuf::from(target.clone());
|
||||
|
||||
let relativ_path = RelativePath::new(&config.storage.path)
|
||||
.normalize()
|
||||
.to_string();
|
||||
|
||||
if !source_path.starts_with(&relativ_path) {
|
||||
source_path = path.join(source);
|
||||
} else {
|
||||
source_path = path.join(source_path.strip_prefix(&relativ_path).unwrap());
|
||||
}
|
||||
|
||||
if !target_path.starts_with(&relativ_path) {
|
||||
target_path = path.join(target);
|
||||
} else {
|
||||
target_path = path.join(target_path.strip_prefix(relativ_path).unwrap());
|
||||
}
|
||||
|
||||
if !source_path.exists() {
|
||||
return Err(ServiceError::BadRequest("Source file not exist!".into()));
|
||||
}
|
||||
|
||||
if (source_path.is_dir() || source_path.is_file()) && source_path.parent() == Some(&target_path)
|
||||
{
|
||||
return rename(&source_path, &target_path);
|
||||
}
|
||||
|
||||
if target_path.is_dir() {
|
||||
target_path = target_path.join(source_path.file_name().unwrap());
|
||||
}
|
||||
|
||||
if target_path.is_file() {
|
||||
return Err(ServiceError::BadRequest(
|
||||
"Target file already exists!".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if source_path.is_file() && target_path.parent().is_some() {
|
||||
return rename(&source_path, &target_path);
|
||||
}
|
||||
|
||||
Err(ServiceError::InternalServerError)
|
||||
}
|
||||
|
||||
pub async fn remove_file_or_folder(id: i64, source_path: &str) -> Result<(), ServiceError> {
|
||||
let (config, _) = playout_config(&id).await?;
|
||||
let source = PathBuf::from(source_path);
|
||||
|
||||
let test_source = RelativePath::new(&source_path)
|
||||
.normalize()
|
||||
.to_string()
|
||||
.replace("../", "");
|
||||
|
||||
let test_path = RelativePath::new(&config.storage.path)
|
||||
.normalize()
|
||||
.to_string();
|
||||
|
||||
if !test_source.starts_with(&test_path) {
|
||||
return Err(ServiceError::BadRequest(
|
||||
"Source file is not in storage!".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if !source.exists() {
|
||||
return Err(ServiceError::BadRequest("Source does not exists!".into()));
|
||||
}
|
||||
|
||||
if source.is_dir() {
|
||||
match fs::remove_dir(source) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) => {
|
||||
error!("{e}");
|
||||
return Err(ServiceError::BadRequest(
|
||||
"Delete folder failed! (Folder must be empty)".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if source.is_file() {
|
||||
match fs::remove_file(source) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) => {
|
||||
error!("{e}");
|
||||
return Err(ServiceError::BadRequest("Delete file failed!".into()));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Err(ServiceError::InternalServerError)
|
||||
}
|
||||
|
||||
async fn valid_path(id: i64, path: &str) -> Result<(), ServiceError> {
|
||||
let (config, _) = playout_config(&id).await?;
|
||||
|
||||
let test_target = RelativePath::new(&path)
|
||||
.normalize()
|
||||
.to_string()
|
||||
.replace("../", "");
|
||||
|
||||
let test_path = RelativePath::new(&config.storage.path)
|
||||
.normalize()
|
||||
.to_string();
|
||||
|
||||
if !test_target.starts_with(&test_path) {
|
||||
return Err(ServiceError::BadRequest(
|
||||
"Target folder is not in storage!".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if !Path::new(path).is_dir() {
|
||||
return Err(ServiceError::BadRequest("Target folder not exists!".into()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn upload(id: i64, mut payload: Multipart) -> Result<HttpResponse, ServiceError> {
|
||||
while let Some(mut field) = payload.try_next().await? {
|
||||
let content_disposition = field.content_disposition();
|
||||
debug!("{content_disposition}");
|
||||
let rand_string: String = rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(20)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
let path_name = content_disposition.get_name().unwrap_or(&rand_string);
|
||||
let filename = content_disposition
|
||||
.get_filename()
|
||||
.map_or_else(|| rand_string.to_string(), sanitize_filename::sanitize);
|
||||
|
||||
if let Err(e) = valid_path(id, path_name).await {
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
let filepath = PathBuf::from(path_name).join(filename);
|
||||
|
||||
if filepath.is_file() {
|
||||
return Err(ServiceError::BadRequest("Target already exists!".into()));
|
||||
}
|
||||
|
||||
// File::create is blocking operation, use threadpool
|
||||
let mut f = web::block(|| std::fs::File::create(filepath)).await??;
|
||||
|
||||
while let Some(chunk) = field.try_next().await? {
|
||||
f = web::block(move || f.write_all(&chunk).map(|_| f)).await??;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HttpResponse::Ok().into())
|
||||
}
|
273
ffplayout-api/src/utils/handles.rs
Normal file
273
ffplayout-api/src/utils/handles.rs
Normal file
@ -0,0 +1,273 @@
|
||||
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, SqlitePool};
|
||||
|
||||
use crate::utils::{
|
||||
db_path,
|
||||
models::{Settings, TextPreset, User},
|
||||
GlobalSettings,
|
||||
};
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct Role {
|
||||
name: String,
|
||||
}
|
||||
|
||||
async fn create_schema() -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
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 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,
|
||||
UNIQUE(name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channel_name TEXT NOT NULL,
|
||||
preview_url TEXT NOT NULL,
|
||||
config_path TEXT NOT NULL,
|
||||
extra_extensions TEXT NOT NULL,
|
||||
UNIQUE(channel_name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
salt TEXT NOT NULL,
|
||||
role_id INTEGER NOT NULL DEFAULT 2,
|
||||
FOREIGN KEY (role_id) REFERENCES roles (id) ON UPDATE SET NULL ON DELETE SET NULL,
|
||||
UNIQUE(email, username)
|
||||
);";
|
||||
let result = sqlx::query(query).execute(&conn).await;
|
||||
conn.close().await;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn db_init() -> Result<&'static str, Box<dyn std::error::Error>> {
|
||||
let db_path = db_path()?;
|
||||
|
||||
if !Sqlite::database_exists(&db_path).await.unwrap_or(false) {
|
||||
Sqlite::create_database(&db_path).await.unwrap();
|
||||
match create_schema().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 instances = db_connection().await?;
|
||||
|
||||
let query = "CREATE TRIGGER global_row_count
|
||||
BEFORE INSERT ON global
|
||||
WHEN (SELECT COUNT(*) FROM global) >= 1
|
||||
BEGIN
|
||||
SELECT RAISE(FAIL, 'Database is already init!');
|
||||
END;
|
||||
INSERT INTO global(secret) VALUES($1);
|
||||
INSERT INTO presets(name, text, x, y, fontsize, line_spacing, fontcolor, alpha, box, boxcolor, boxborderw)
|
||||
VALUES('Default', 'Wellcome to ffplayout messenger!', '(w-text_w)/2', '(h-text_h)/2', '24', '4', '#ffffff@0xff', '1.0', '0', '#000000@0x80', '4'),
|
||||
('Empty Text', '', '0', '0', '24', '4', '#000000', '0', '0', '#000000', '0'),
|
||||
('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',
|
||||
'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', '#000000@0x80', '4'),
|
||||
('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.0', '1', '#000000@0x80', '4');
|
||||
INSERT INTO roles(name) VALUES('admin'), ('user'), ('guest');
|
||||
INSERT INTO settings(channel_name, preview_url, config_path, extra_extensions)
|
||||
VALUES('Channel 1', 'http://localhost/live/preview.m3u8',
|
||||
'/etc/ffplayout/ffplayout.yml', '.jpg,.jpeg,.png');";
|
||||
sqlx::query(query).bind(secret).execute(&instances).await?;
|
||||
instances.close().await;
|
||||
|
||||
Ok("Database initialized!")
|
||||
}
|
||||
|
||||
pub async fn db_connection() -> Result<Pool<Sqlite>, sqlx::Error> {
|
||||
let db_path = db_path().unwrap();
|
||||
let conn = SqlitePool::connect(&db_path).await?;
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
pub async fn db_global() -> Result<GlobalSettings, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
let query = "SELECT secret FROM global WHERE id = 1";
|
||||
let result: GlobalSettings = sqlx::query_as(query).fetch_one(&conn).await?;
|
||||
conn.close().await;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn db_get_settings(id: &i64) -> Result<Settings, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
let query = "SELECT * FROM settings WHERE id = $1";
|
||||
let result: Settings = sqlx::query_as(query).bind(id).fetch_one(&conn).await?;
|
||||
conn.close().await;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn db_update_settings(
|
||||
id: i64,
|
||||
settings: Settings,
|
||||
) -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
|
||||
let query = "UPDATE settings SET channel_name = $2, preview_url = $3, config_path = $4, extra_extensions = $5 WHERE id = $1";
|
||||
let result: SqliteQueryResult = sqlx::query(query)
|
||||
.bind(id)
|
||||
.bind(settings.channel_name.clone())
|
||||
.bind(settings.preview_url.clone())
|
||||
.bind(settings.config_path.clone())
|
||||
.bind(settings.extra_extensions.clone())
|
||||
.execute(&conn)
|
||||
.await?;
|
||||
conn.close().await;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn db_role(id: &i64) -> Result<String, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
let query = "SELECT name FROM roles WHERE id = $1";
|
||||
let result: Role = sqlx::query_as(query).bind(id).fetch_one(&conn).await?;
|
||||
conn.close().await;
|
||||
|
||||
Ok(result.name)
|
||||
}
|
||||
|
||||
pub async fn db_login(user: &str) -> Result<User, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
let query = "SELECT id, email, username, password, salt, role_id FROM user WHERE username = $1";
|
||||
let result: User = sqlx::query_as(query).bind(user).fetch_one(&conn).await?;
|
||||
conn.close().await;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn db_add_user(user: User) -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let password_hash = Argon2::default()
|
||||
.hash_password(user.password.clone().as_bytes(), &salt)
|
||||
.unwrap();
|
||||
|
||||
let query =
|
||||
"INSERT INTO user (email, username, password, salt, role_id) VALUES($1, $2, $3, $4, $5)";
|
||||
let result = sqlx::query(query)
|
||||
.bind(user.email)
|
||||
.bind(user.username)
|
||||
.bind(password_hash.to_string())
|
||||
.bind(salt.to_string())
|
||||
.bind(user.role_id)
|
||||
.execute(&conn)
|
||||
.await?;
|
||||
conn.close().await;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn db_update_user(id: i64, fields: String) -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
let query = format!("UPDATE user SET {fields} WHERE id = $1");
|
||||
let result: SqliteQueryResult = sqlx::query(&query).bind(id).execute(&conn).await?;
|
||||
conn.close().await;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn db_get_presets() -> Result<Vec<TextPreset>, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
let query = "SELECT * FROM presets";
|
||||
let result: Vec<TextPreset> = sqlx::query_as(query).fetch_all(&conn).await?;
|
||||
conn.close().await;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn db_update_preset(
|
||||
id: &i64,
|
||||
preset: TextPreset,
|
||||
) -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
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";
|
||||
let result: SqliteQueryResult = 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?;
|
||||
conn.close().await;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn db_add_preset(preset: TextPreset) -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
let query =
|
||||
"INSERT INTO presets (name, text, x, y, fontsize, line_spacing, fontcolor, alpha, box, boxcolor, boxborderw)
|
||||
VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)";
|
||||
let result: SqliteQueryResult = 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)
|
||||
.execute(&conn)
|
||||
.await?;
|
||||
conn.close().await;
|
||||
|
||||
Ok(result)
|
||||
}
|
145
ffplayout-api/src/utils/mod.rs
Normal file
145
ffplayout-api/src/utils/mod.rs
Normal file
@ -0,0 +1,145 @@
|
||||
use std::{error::Error, fs::File, path::Path};
|
||||
|
||||
use faccess::PathExt;
|
||||
use once_cell::sync::OnceCell;
|
||||
use simplelog::*;
|
||||
|
||||
pub mod args_parse;
|
||||
pub mod auth;
|
||||
pub mod control;
|
||||
pub mod errors;
|
||||
pub mod files;
|
||||
pub mod handles;
|
||||
pub mod models;
|
||||
pub mod playlist;
|
||||
pub mod routes;
|
||||
|
||||
use crate::utils::{
|
||||
args_parse::Args,
|
||||
errors::ServiceError,
|
||||
handles::{db_add_user, db_get_settings, db_global, db_init},
|
||||
models::{Settings, User},
|
||||
};
|
||||
use ffplayout_lib::utils::PlayoutConfig;
|
||||
|
||||
#[derive(PartialEq, Clone)]
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct GlobalSettings {
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
impl GlobalSettings {
|
||||
async fn new() -> Self {
|
||||
let global_settings = db_global();
|
||||
|
||||
match global_settings.await {
|
||||
Ok(g) => g,
|
||||
Err(_) => GlobalSettings {
|
||||
secret: String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn global() -> &'static GlobalSettings {
|
||||
INSTANCE.get().expect("Config is not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
static INSTANCE: OnceCell<GlobalSettings> = OnceCell::new();
|
||||
|
||||
pub async fn init_config() {
|
||||
let config = GlobalSettings::new().await;
|
||||
INSTANCE.set(config).unwrap();
|
||||
}
|
||||
|
||||
pub fn db_path() -> Result<String, Box<dyn std::error::Error>> {
|
||||
let sys_path = Path::new("/usr/share/ffplayout");
|
||||
let mut db_path = String::from("./ffplayout.db");
|
||||
|
||||
if sys_path.is_dir() && sys_path.writable() {
|
||||
db_path = String::from("/usr/share/ffplayout/ffplayout.db");
|
||||
} else if Path::new("./assets").is_dir() {
|
||||
db_path = String::from("./assets/ffplayout.db");
|
||||
}
|
||||
|
||||
Ok(db_path)
|
||||
}
|
||||
|
||||
pub async fn run_args(args: Args) -> Result<(), i32> {
|
||||
if !args.init && args.listen.is_none() && 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().await {
|
||||
panic!("{e}");
|
||||
};
|
||||
|
||||
return Err(0);
|
||||
}
|
||||
|
||||
if let Some(username) = args.username {
|
||||
if args.email.is_none() || args.password.is_none() {
|
||||
error!("Email/password missing!");
|
||||
return Err(1);
|
||||
}
|
||||
|
||||
let user = User {
|
||||
id: 0,
|
||||
email: Some(args.email.unwrap()),
|
||||
username: username.clone(),
|
||||
password: args.password.unwrap(),
|
||||
salt: None,
|
||||
role_id: Some(1),
|
||||
token: None,
|
||||
};
|
||||
|
||||
if let Err(e) = db_add_user(user).await {
|
||||
error!("{e}");
|
||||
return Err(1);
|
||||
};
|
||||
|
||||
info!("Create admin user \"{username}\" done...");
|
||||
|
||||
return Err(0);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_playout_config(path: &str) -> Result<PlayoutConfig, Box<dyn Error>> {
|
||||
let file = File::open(path)?;
|
||||
let config: PlayoutConfig = serde_yaml::from_reader(file)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub async fn playout_config(channel_id: &i64) -> Result<(PlayoutConfig, Settings), ServiceError> {
|
||||
if let Ok(settings) = db_get_settings(channel_id).await {
|
||||
if let Ok(config) = read_playout_config(&settings.config_path.clone()) {
|
||||
return Ok((config, settings));
|
||||
}
|
||||
}
|
||||
|
||||
Err(ServiceError::BadRequest(
|
||||
"Error in getting config!".to_string(),
|
||||
))
|
||||
}
|
69
ffplayout-api/src/utils/models.rs
Normal file
69
ffplayout-api/src/utils/models.rs
Normal file
@ -0,0 +1,69 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, sqlx::FromRow)]
|
||||
pub struct User {
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_deserializing)]
|
||||
pub id: i64,
|
||||
#[sqlx(default)]
|
||||
pub email: Option<String>,
|
||||
pub username: String,
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing, default = "empty_string")]
|
||||
pub password: String,
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing)]
|
||||
pub salt: Option<String>,
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing)]
|
||||
pub role_id: Option<i64>,
|
||||
#[sqlx(default)]
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
fn empty_string() -> String {
|
||||
"".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct LoginUser {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
impl LoginUser {
|
||||
pub fn new(id: i64, username: String) -> Self {
|
||||
Self { id, username }
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, sqlx::FromRow)]
|
||||
pub struct TextPreset {
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_deserializing)]
|
||||
pub id: i64,
|
||||
#[serde(skip_deserializing)]
|
||||
pub name: String,
|
||||
pub text: String,
|
||||
pub x: String,
|
||||
pub y: String,
|
||||
pub fontsize: String,
|
||||
pub line_spacing: String,
|
||||
pub fontcolor: String,
|
||||
pub r#box: String,
|
||||
pub boxcolor: String,
|
||||
pub boxborderw: String,
|
||||
pub alpha: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, sqlx::FromRow)]
|
||||
pub struct Settings {
|
||||
#[serde(skip_deserializing)]
|
||||
pub id: i64,
|
||||
pub channel_name: String,
|
||||
pub preview_url: String,
|
||||
pub config_path: String,
|
||||
pub extra_extensions: String,
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
pub secret: String,
|
||||
}
|
116
ffplayout-api/src/utils/playlist.rs
Normal file
116
ffplayout-api/src/utils/playlist.rs
Normal file
@ -0,0 +1,116 @@
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
io::Error,
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use simplelog::*;
|
||||
|
||||
use crate::utils::{errors::ServiceError, playout_config};
|
||||
use ffplayout_lib::utils::{generate_playlist as playlist_generator, JsonPlaylist};
|
||||
|
||||
fn json_reader(path: &PathBuf) -> Result<JsonPlaylist, Error> {
|
||||
let f = File::options().read(true).write(false).open(&path)?;
|
||||
let p = serde_json::from_reader(f)?;
|
||||
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn json_writer(path: &PathBuf, data: JsonPlaylist) -> Result<(), Error> {
|
||||
let f = File::options()
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.create(true)
|
||||
.open(&path)?;
|
||||
serde_json::to_writer_pretty(f, &data)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn read_playlist(id: i64, date: String) -> Result<JsonPlaylist, ServiceError> {
|
||||
let (config, _) = playout_config(&id).await?;
|
||||
let mut playlist_path = PathBuf::from(&config.playlist.path);
|
||||
let d: Vec<&str> = date.split('-').collect();
|
||||
playlist_path = playlist_path
|
||||
.join(d[0])
|
||||
.join(d[1])
|
||||
.join(date.clone())
|
||||
.with_extension("json");
|
||||
|
||||
if let Ok(p) = json_reader(&playlist_path) {
|
||||
return Ok(p);
|
||||
};
|
||||
|
||||
Err(ServiceError::InternalServerError)
|
||||
}
|
||||
|
||||
pub async fn write_playlist(id: i64, json_data: JsonPlaylist) -> Result<String, ServiceError> {
|
||||
let (config, _) = playout_config(&id).await?;
|
||||
let date = json_data.date.clone();
|
||||
let mut playlist_path = PathBuf::from(&config.playlist.path);
|
||||
let d: Vec<&str> = date.split('-').collect();
|
||||
playlist_path = playlist_path
|
||||
.join(d[0])
|
||||
.join(d[1])
|
||||
.join(date.clone())
|
||||
.with_extension("json");
|
||||
|
||||
if playlist_path.is_file() {
|
||||
if let Ok(existing_data) = json_reader(&playlist_path) {
|
||||
if json_data == existing_data {
|
||||
return Err(ServiceError::Conflict(format!(
|
||||
"Playlist from {date}, already exists!"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match json_writer(&playlist_path, json_data) {
|
||||
Ok(_) => return Ok(format!("Write playlist from {date} success!")),
|
||||
Err(e) => {
|
||||
error!("{e}");
|
||||
}
|
||||
}
|
||||
|
||||
Err(ServiceError::InternalServerError)
|
||||
}
|
||||
|
||||
pub async fn generate_playlist(id: i64, date: String) -> Result<JsonPlaylist, ServiceError> {
|
||||
let (config, settings) = playout_config(&id).await?;
|
||||
|
||||
match playlist_generator(&config, vec![date], Some(settings.channel_name)) {
|
||||
Ok(playlists) => {
|
||||
if !playlists.is_empty() {
|
||||
Ok(playlists[0].clone())
|
||||
} else {
|
||||
Err(ServiceError::Conflict(
|
||||
"Playlist could not be written, possible already exists!".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("{e}");
|
||||
Err(ServiceError::InternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_playlist(id: i64, date: &str) -> Result<(), ServiceError> {
|
||||
let (config, _) = playout_config(&id).await?;
|
||||
let mut playlist_path = PathBuf::from(&config.playlist.path);
|
||||
let d: Vec<&str> = date.split('-').collect();
|
||||
playlist_path = playlist_path
|
||||
.join(d[0])
|
||||
.join(d[1])
|
||||
.join(date)
|
||||
.with_extension("json");
|
||||
|
||||
if playlist_path.is_file() {
|
||||
if let Err(e) = fs::remove_file(playlist_path) {
|
||||
error!("{e}");
|
||||
return Err(ServiceError::InternalServerError);
|
||||
};
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
467
ffplayout-api/src/utils/routes.rs
Normal file
467
ffplayout-api/src/utils/routes.rs
Normal file
@ -0,0 +1,467 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use actix_multipart::Multipart;
|
||||
use actix_web::{delete, get, http::StatusCode, patch, post, put, web, HttpResponse, Responder};
|
||||
use actix_web_grants::{permissions::AuthDetails, proc_macro::has_any_role};
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHash, SaltString},
|
||||
Argon2, PasswordHasher, PasswordVerifier,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use simplelog::*;
|
||||
|
||||
use crate::utils::{
|
||||
auth::{create_jwt, Claims},
|
||||
control::{control_state, media_info, send_message},
|
||||
errors::ServiceError,
|
||||
files::{browser, remove_file_or_folder, rename_file, upload, MoveObject, PathObject},
|
||||
handles::{
|
||||
db_add_preset, db_add_user, db_get_presets, db_get_settings, db_login, db_role,
|
||||
db_update_preset, db_update_settings, db_update_user,
|
||||
},
|
||||
models::{LoginUser, Settings, TextPreset, User},
|
||||
playlist::{delete_playlist, generate_playlist, read_playlist, write_playlist},
|
||||
read_playout_config, Role,
|
||||
};
|
||||
use ffplayout_lib::utils::{JsonPlaylist, PlayoutConfig};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ResponseObj<T> {
|
||||
message: String,
|
||||
status: i32,
|
||||
data: Option<T>,
|
||||
}
|
||||
|
||||
/// curl -X POST http://127.0.0.1:8080/auth/login/ -H "Content-Type: application/json" \
|
||||
/// -d '{"username": "<USER>", "password": "<PASS>" }'
|
||||
#[post("/auth/login/")]
|
||||
pub async fn login(credentials: web::Json<User>) -> impl Responder {
|
||||
match db_login(&credentials.username).await {
|
||||
Ok(mut user) => {
|
||||
let pass = user.password.clone();
|
||||
let hash = PasswordHash::new(&pass).unwrap();
|
||||
user.password = "".into();
|
||||
user.salt = None;
|
||||
|
||||
if Argon2::default()
|
||||
.verify_password(credentials.password.as_bytes(), &hash)
|
||||
.is_ok()
|
||||
{
|
||||
let role = db_role(&user.role_id.unwrap_or_default())
|
||||
.await
|
||||
.unwrap_or_else(|_| "guest".to_string());
|
||||
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(ResponseObj {
|
||||
message: "login correct!".into(),
|
||||
status: 200,
|
||||
data: Some(user),
|
||||
})
|
||||
.customize()
|
||||
.with_status(StatusCode::OK)
|
||||
} else {
|
||||
error!("Wrong password for {}!", credentials.username);
|
||||
web::Json(ResponseObj {
|
||||
message: "Wrong password!".into(),
|
||||
status: 403,
|
||||
data: None,
|
||||
})
|
||||
.customize()
|
||||
.with_status(StatusCode::FORBIDDEN)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Login {} failed! {e}", credentials.username);
|
||||
return web::Json(ResponseObj {
|
||||
message: format!("Login {} failed!", credentials.username),
|
||||
status: 400,
|
||||
data: None,
|
||||
})
|
||||
.customize()
|
||||
.with_status(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// curl -X PUT http://localhost:8080/api/user/1 --header 'Content-Type: application/json' \
|
||||
/// --data '{"email": "<EMAIL>", "password": "<PASS>"}' --header 'Authorization: <TOKEN>'
|
||||
#[put("/user/{id}")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
async fn update_user(
|
||||
id: web::Path<i64>,
|
||||
user: web::ReqData<LoginUser>,
|
||||
data: web::Json<User>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
if id.into_inner() == user.id {
|
||||
let mut fields = String::new();
|
||||
|
||||
if let Some(email) = data.email.clone() {
|
||||
fields.push_str(format!("email = '{email}'").as_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 = '{}', salt = '{salt}'", password_hash).as_str());
|
||||
}
|
||||
|
||||
if db_update_user(user.id, fields).await.is_ok() {
|
||||
return Ok("Update Success");
|
||||
};
|
||||
|
||||
return Err(ServiceError::InternalServerError);
|
||||
}
|
||||
|
||||
Err(ServiceError::Unauthorized)
|
||||
}
|
||||
|
||||
/// curl -X POST 'http://localhost:8080/api/user/' --header 'Content-Type: application/json' \
|
||||
/// -d '{"email": "<EMAIL>", "username": "<USER>", "password": "<PASS>", "role_id": 1}' \
|
||||
/// --header 'Authorization: Bearer <TOKEN>'
|
||||
#[post("/user/")]
|
||||
#[has_any_role("Role::Admin", type = "Role")]
|
||||
async fn add_user(data: web::Json<User>) -> Result<impl Responder, ServiceError> {
|
||||
match db_add_user(data.into_inner()).await {
|
||||
Ok(_) => Ok("Add User Success"),
|
||||
Err(e) => {
|
||||
error!("{e}");
|
||||
Err(ServiceError::InternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// curl -X GET http://127.0.0.1:8080/api/settings/1 -H "Authorization: Bearer <TOKEN>"
|
||||
#[get("/settings/{id}")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
async fn get_settings(id: web::Path<i64>) -> Result<impl Responder, ServiceError> {
|
||||
if let Ok(settings) = db_get_settings(&id).await {
|
||||
return Ok(web::Json(ResponseObj {
|
||||
message: format!("Settings from {}", settings.channel_name),
|
||||
status: 200,
|
||||
data: Some(settings),
|
||||
}));
|
||||
}
|
||||
|
||||
Err(ServiceError::InternalServerError)
|
||||
}
|
||||
|
||||
/// curl -X PATCH http://127.0.0.1:8080/api/settings/1 -H "Content-Type: application/json" \
|
||||
/// --data '{"id":1,"channel_name":"Channel 1","preview_url":"http://localhost/live/stream.m3u8", \
|
||||
/// "config_path":"/etc/ffplayout/ffplayout.yml","extra_extensions":".jpg,.jpeg,.png"}' \
|
||||
/// -H "Authorization: Bearer <TOKEN>"
|
||||
#[patch("/settings/{id}")]
|
||||
#[has_any_role("Role::Admin", type = "Role")]
|
||||
async fn patch_settings(
|
||||
id: web::Path<i64>,
|
||||
data: web::Json<Settings>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
if db_update_settings(*id, data.into_inner()).await.is_ok() {
|
||||
return Ok("Update Success");
|
||||
};
|
||||
|
||||
Err(ServiceError::InternalServerError)
|
||||
}
|
||||
|
||||
/// curl -X GET http://localhost:8080/api/playout/config/1 --header 'Authorization: <TOKEN>'
|
||||
#[get("/playout/config/{id}")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
async fn get_playout_config(
|
||||
id: web::Path<i64>,
|
||||
_details: AuthDetails<Role>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
if let Ok(settings) = db_get_settings(&id).await {
|
||||
if let Ok(config) = read_playout_config(&settings.config_path) {
|
||||
return Ok(web::Json(config));
|
||||
}
|
||||
};
|
||||
|
||||
Err(ServiceError::InternalServerError)
|
||||
}
|
||||
|
||||
/// curl -X PUT http://localhost:8080/api/playout/config/1 -H "Content-Type: application/json" \
|
||||
/// --data { <CONFIG DATA> } --header 'Authorization: <TOKEN>'
|
||||
#[put("/playout/config/{id}")]
|
||||
#[has_any_role("Role::Admin", type = "Role")]
|
||||
async fn update_playout_config(
|
||||
id: web::Path<i64>,
|
||||
data: web::Json<PlayoutConfig>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
if let Ok(settings) = db_get_settings(&id).await {
|
||||
if let Ok(f) = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&settings.config_path)
|
||||
{
|
||||
serde_yaml::to_writer(f, &data).unwrap();
|
||||
|
||||
return Ok("Update playout config success.");
|
||||
} else {
|
||||
return Err(ServiceError::InternalServerError);
|
||||
};
|
||||
};
|
||||
|
||||
Err(ServiceError::InternalServerError)
|
||||
}
|
||||
|
||||
/// curl -X GET http://localhost:8080/api/presets/ --header 'Content-Type: application/json' \
|
||||
/// --data '{"email": "<EMAIL>", "password": "<PASS>"}' --header 'Authorization: <TOKEN>'
|
||||
#[get("/presets/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
async fn get_presets() -> Result<impl Responder, ServiceError> {
|
||||
if let Ok(presets) = db_get_presets().await {
|
||||
return Ok(web::Json(presets));
|
||||
}
|
||||
|
||||
Err(ServiceError::InternalServerError)
|
||||
}
|
||||
|
||||
/// curl -X PUT http://localhost:8080/api/presets/1 --header 'Content-Type: application/json' \
|
||||
/// --data '{"name": "<PRESET NAME>", "text": "<TEXT>", "x": "<X>", "y": "<Y>", "fontsize": 24, \
|
||||
/// "line_spacing": 4, "fontcolor": "#ffffff", "box": 1, "boxcolor": "#000000", "boxborderw": 4, "alpha": 1.0}' \
|
||||
/// --header 'Authorization: <TOKEN>'
|
||||
#[put("/presets/{id}")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
async fn update_preset(
|
||||
id: web::Path<i64>,
|
||||
data: web::Json<TextPreset>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
if db_update_preset(&id, data.into_inner()).await.is_ok() {
|
||||
return Ok("Update Success");
|
||||
}
|
||||
|
||||
Err(ServiceError::InternalServerError)
|
||||
}
|
||||
|
||||
/// curl -X POST http://localhost:8080/api/presets/ --header 'Content-Type: application/json' \
|
||||
/// --data '{"name": "<PRESET NAME>", "text": "TEXT>", "x": "<X>", "y": "<Y>", "fontsize": 24, \
|
||||
/// "line_spacing": 4, "fontcolor": "#ffffff", "box": 1, "boxcolor": "#000000", "boxborderw": 4, "alpha": 1.0}}' \
|
||||
/// --header 'Authorization: <TOKEN>'
|
||||
#[post("/presets/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
async fn add_preset(data: web::Json<TextPreset>) -> Result<impl Responder, ServiceError> {
|
||||
if db_add_preset(data.into_inner()).await.is_ok() {
|
||||
return Ok("Add preset Success");
|
||||
}
|
||||
|
||||
Err(ServiceError::InternalServerError)
|
||||
}
|
||||
|
||||
/// ----------------------------------------------------------------------------
|
||||
/// ffplayout process controlling
|
||||
///
|
||||
/// here we communicate with the engine for:
|
||||
/// - jump to last or next clip
|
||||
/// - reset playlist state
|
||||
/// - get infos about current, next, last clip
|
||||
/// - send text the the engine, for overlaying it (as lower third etc.)
|
||||
/// ----------------------------------------------------------------------------
|
||||
|
||||
/// curl -X POST http://localhost:8080/api/control/1/text/ \
|
||||
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>' \
|
||||
/// --data '{"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/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn send_text_message(
|
||||
id: web::Path<i64>,
|
||||
data: web::Json<HashMap<String, String>>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
match send_message(*id, data.into_inner()).await {
|
||||
Ok(res) => return Ok(res.text().await.unwrap_or_else(|_| "Success".into())),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// curl -X POST http://localhost:8080/api/control/1/playout/next/
|
||||
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>'
|
||||
#[post("/control/{id}/playout/next/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn jump_to_next(id: web::Path<i64>) -> Result<impl Responder, ServiceError> {
|
||||
match control_state(*id, "next".into()).await {
|
||||
Ok(res) => return Ok(res.text().await.unwrap_or_else(|_| "Success".into())),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// curl -X POST http://localhost:8080/api/control/1/playout/back/
|
||||
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>'
|
||||
#[post("/control/{id}/playout/back/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn jump_to_last(id: web::Path<i64>) -> Result<impl Responder, ServiceError> {
|
||||
match control_state(*id, "back".into()).await {
|
||||
Ok(res) => return Ok(res.text().await.unwrap_or_else(|_| "Success".into())),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// curl -X POST http://localhost:8080/api/control/1/playout/reset/
|
||||
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>'
|
||||
#[post("/control/{id}/playout/reset/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn reset_playout(id: web::Path<i64>) -> Result<impl Responder, ServiceError> {
|
||||
match control_state(*id, "reset".into()).await {
|
||||
Ok(res) => return Ok(res.text().await.unwrap_or_else(|_| "Success".into())),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// curl -X GET http://localhost:8080/api/control/1/media/current/
|
||||
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>'
|
||||
#[get("/control/{id}/media/current")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn media_current(id: web::Path<i64>) -> Result<impl Responder, ServiceError> {
|
||||
match media_info(*id, "current".into()).await {
|
||||
Ok(res) => return Ok(res.text().await.unwrap_or_else(|_| "Success".into())),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// curl -X GET http://localhost:8080/api/control/1/media/next/
|
||||
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>'
|
||||
#[get("/control/{id}/media/next")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn media_next(id: web::Path<i64>) -> Result<impl Responder, ServiceError> {
|
||||
match media_info(*id, "next".into()).await {
|
||||
Ok(res) => return Ok(res.text().await.unwrap_or_else(|_| "Success".into())),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// curl -X GET http://localhost:8080/api/control/1/media/last/
|
||||
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>'
|
||||
#[get("/control/{id}/media/last")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn media_last(id: web::Path<i64>) -> Result<impl Responder, ServiceError> {
|
||||
match media_info(*id, "last".into()).await {
|
||||
Ok(res) => return Ok(res.text().await.unwrap_or_else(|_| "Success".into())),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// ----------------------------------------------------------------------------
|
||||
/// ffplayout playlist operations
|
||||
///
|
||||
/// ----------------------------------------------------------------------------
|
||||
|
||||
/// curl -X GET http://localhost:8080/api/playlist/1/2022-06-20
|
||||
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>'
|
||||
#[get("/playlist/{id}/{date}")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn get_playlist(
|
||||
params: web::Path<(i64, String)>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
match read_playlist(params.0, params.1.clone()).await {
|
||||
Ok(playlist) => Ok(web::Json(playlist)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// curl -X POST http://localhost:8080/api/playlist/1/
|
||||
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>'
|
||||
/// -- data "{<JSON playlist data>}"
|
||||
#[post("/playlist/{id}/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn save_playlist(
|
||||
id: web::Path<i64>,
|
||||
data: web::Json<JsonPlaylist>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
match write_playlist(*id, data.into_inner()).await {
|
||||
Ok(res) => Ok(res),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// curl -X GET http://localhost:8080/api/playlist/1/generate/2022-06-20
|
||||
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>'
|
||||
#[get("/playlist/{id}/generate/{date}")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn gen_playlist(
|
||||
params: web::Path<(i64, String)>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
match generate_playlist(params.0, params.1.clone()).await {
|
||||
Ok(playlist) => Ok(web::Json(playlist)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// curl -X DELETE http://localhost:8080/api/playlist/1/2022-06-20
|
||||
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>'
|
||||
#[delete("/playlist/{id}/{date}")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn del_playlist(
|
||||
params: web::Path<(i64, String)>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
match delete_playlist(params.0, ¶ms.1).await {
|
||||
Ok(_) => Ok(format!("Delete playlist from {} success!", params.1)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// ----------------------------------------------------------------------------
|
||||
/// file operations
|
||||
///
|
||||
/// ----------------------------------------------------------------------------
|
||||
|
||||
/// curl -X GET http://localhost:8080/api/file/1/browse/
|
||||
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>'
|
||||
#[post("/file/{id}/browse/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn file_browser(
|
||||
id: web::Path<i64>,
|
||||
data: web::Json<PathObject>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
match browser(*id, &data.into_inner()).await {
|
||||
Ok(obj) => Ok(web::Json(obj)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// curl -X POST http://localhost:8080/api/file/1/move/
|
||||
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>'
|
||||
/// -d '{"source": "<SOURCE>", "target": "<TARGET>"}'
|
||||
#[post("/file/{id}/move/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn move_rename(
|
||||
id: web::Path<i64>,
|
||||
data: web::Json<MoveObject>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
match rename_file(*id, &data.into_inner()).await {
|
||||
Ok(obj) => Ok(web::Json(obj)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// curl -X DELETE http://localhost:8080/api/file/1/remove/
|
||||
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>'
|
||||
/// -d '{"source": "<SOURCE>"}'
|
||||
#[delete("/file/{id}/remove/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn remove(
|
||||
id: web::Path<i64>,
|
||||
data: web::Json<PathObject>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
match remove_file_or_folder(*id, &data.into_inner().source).await {
|
||||
Ok(obj) => Ok(web::Json(obj)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/file/{id}/upload/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
async fn save_file(id: web::Path<i64>, payload: Multipart) -> Result<HttpResponse, ServiceError> {
|
||||
upload(*id, payload).await
|
||||
}
|
87
ffplayout-engine/Cargo.toml
Normal file
87
ffplayout-engine/Cargo.toml
Normal file
@ -0,0 +1,87 @@
|
||||
[package]
|
||||
name = "ffplayout"
|
||||
description = "24/7 playout based on rust and ffmpeg"
|
||||
license = "GPL-3.0"
|
||||
authors = ["Jonathan Baecker jonbae77@gmail.com"]
|
||||
readme = "README.md"
|
||||
version = "0.10.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
ffplayout-lib = { path = "../lib" }
|
||||
chrono = { git = "https://github.com/chronotope/chrono.git" }
|
||||
clap = { version = "3.2", features = ["derive"] }
|
||||
crossbeam-channel = "0.5"
|
||||
faccess = "0.2"
|
||||
ffprobe = "0.3"
|
||||
file-rotate = { git = "https://github.com/Ploppz/file-rotate.git", branch = "timestamp-parse-fix" }
|
||||
futures = "0.3"
|
||||
jsonrpc-http-server = "18.0"
|
||||
lettre = "0.10.0-rc.7"
|
||||
log = "0.4"
|
||||
notify = "4.0"
|
||||
rand = "0.8"
|
||||
regex = "1"
|
||||
reqwest = { version = "0.11", features = ["blocking", "json"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.8"
|
||||
shlex = "1.1"
|
||||
simplelog = { version = "^0.12", features = ["paris"] }
|
||||
time = { version = "0.3", features = ["formatting", "macros"] }
|
||||
walkdir = "2"
|
||||
zeromq = { git = "https://github.com/zeromq/zmq.rs.git", default-features = false, features = [
|
||||
"async-std-runtime",
|
||||
"tcp-transport"
|
||||
] }
|
||||
|
||||
[target.x86_64-unknown-linux-musl.dependencies]
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
|
||||
[[bin]]
|
||||
name = "ffplayout"
|
||||
path = "src/main.rs"
|
||||
|
||||
# DEBIAN DEB PACKAGE
|
||||
[package.metadata.deb]
|
||||
name = "ffplayout"
|
||||
priority = "optional"
|
||||
section = "net"
|
||||
license-file = ["../LICENSE", "0"]
|
||||
depends = ""
|
||||
suggests = "ffmpeg"
|
||||
copyright = "Copyright (c) 2022, Jonathan Baecker. All rights reserved."
|
||||
conf-files = ["/etc/ffplayout/ffplayout.yml"]
|
||||
assets = [
|
||||
[
|
||||
"../target/x86_64-unknown-linux-musl/release/ffpapi",
|
||||
"/usr/bin/ffpapi",
|
||||
"755"
|
||||
],
|
||||
[
|
||||
"../target/x86_64-unknown-linux-musl/release/ffplayout",
|
||||
"/usr/bin/ffplayout",
|
||||
"755"
|
||||
],
|
||||
["../assets/ffpapi.service", "/lib/systemd/system/ffpapi.service", "644"],
|
||||
["../assets/ffplayout.yml", "/etc/ffplayout/ffplayout.yml", "644"],
|
||||
["../assets/logo.png", "/usr/share/ffplayout/logo.png", "644"],
|
||||
["../README.md", "/usr/share/doc/ffplayout/README", "644"],
|
||||
]
|
||||
maintainer-scripts = "../debian/"
|
||||
systemd-units = { enable = false, unit-scripts = "../assets" }
|
||||
|
||||
# REHL RPM PACKAGE
|
||||
[package.metadata.generate-rpm]
|
||||
name = "ffplayout"
|
||||
license = "GPL-3.0"
|
||||
assets = [
|
||||
{ source = "../target/x86_64-unknown-linux-musl/release/ffpapi", dest = "/usr/bin/ffpapi", mode = "755" },
|
||||
{ source = "../target/x86_64-unknown-linux-musl/release/ffplayout", dest = "/usr/bin/ffplayout", mode = "755" },
|
||||
{ source = "../assets/ffplayout.yml", dest = "/etc/ffplayout/ffplayout.yml", 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 = "../README.md", dest = "/usr/share/doc/ffplayout/README", 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" },
|
||||
]
|
42
ffplayout-engine/README.md
Normal file
42
ffplayout-engine/README.md
Normal file
@ -0,0 +1,42 @@
|
||||
**ffplayout-engine**
|
||||
================
|
||||
|
||||
Installation under Linux
|
||||
-----
|
||||
|
||||
- copy the binary to `/usr/bin/`
|
||||
- copy **assets/ffplayout.yml** to `/etc/ffplayout`
|
||||
- copy **assets/ffplayout-engine.service** to `/etc/systemd/system`
|
||||
- activate service and run it: `systemctl enable --now ffplayout-engine`
|
||||
|
||||
You can also install the [released](https://github.com/ffplayout/ffplayout-engine/releases/latest) ***.deb** or ***.rpm** package.
|
||||
|
||||
Start with Arguments
|
||||
-----
|
||||
|
||||
ffplayout also allows the passing of parameters:
|
||||
|
||||
```
|
||||
OPTIONS:
|
||||
-c, --config <CONFIG> File path to ffplayout.conf
|
||||
-f, --folder <FOLDER> Play folder content
|
||||
-g, --generate <YYYY-MM-DD>... Generate playlist for date or date-range, like: 2022-01-01 - 2022-01-10:
|
||||
-h, --help Print help information
|
||||
-i, --infinit Loop playlist infinitely
|
||||
-l, --log <LOG> File path for logging
|
||||
-m, --play-mode <PLAY_MODE> Playing mode: folder, playlist
|
||||
-o, --output <OUTPUT> Set output mode: desktop, hls, stream
|
||||
-p, --playlist <PLAYLIST> Path from playlist
|
||||
-s, --start <START> Start time in 'hh:mm:ss', 'now' for start with first
|
||||
-t, --length <LENGTH> Set length in 'hh:mm:ss', 'none' for no length check
|
||||
-v, --volume <VOLUME> Set audio volume
|
||||
-V, --version Print version information
|
||||
|
||||
```
|
||||
|
||||
|
||||
You can run the command like:
|
||||
|
||||
```Bash
|
||||
./ffplayout -l none -p ~/playlist.json -o desktop
|
||||
```
|
76
ffplayout-engine/src/input/folder.rs
Normal file
76
ffplayout-engine/src/input/folder.rs
Normal file
@ -0,0 +1,76 @@
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc::channel,
|
||||
{Arc, Mutex},
|
||||
},
|
||||
thread::sleep,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use notify::{
|
||||
DebouncedEvent::{Create, Remove, Rename},
|
||||
{watcher, RecursiveMode, Watcher},
|
||||
};
|
||||
use simplelog::*;
|
||||
|
||||
use ffplayout_lib::utils::{Media, PlayoutConfig};
|
||||
|
||||
/// Create a watcher, which monitor file changes.
|
||||
/// When a change is register, update the current file list.
|
||||
/// This makes it possible, to play infinitely and and always new files to it.
|
||||
pub fn watchman(
|
||||
config: PlayoutConfig,
|
||||
is_terminated: Arc<AtomicBool>,
|
||||
sources: Arc<Mutex<Vec<Media>>>,
|
||||
) {
|
||||
let (tx, rx) = channel();
|
||||
|
||||
let path = config.storage.path;
|
||||
|
||||
if !Path::new(&path).exists() {
|
||||
error!("Folder path not exists: '{path}'");
|
||||
panic!("Folder path not exists: '{path}'");
|
||||
}
|
||||
|
||||
let mut watcher = watcher(tx, Duration::from_secs(1)).unwrap();
|
||||
watcher.watch(path, RecursiveMode::Recursive).unwrap();
|
||||
|
||||
while !is_terminated.load(Ordering::SeqCst) {
|
||||
if let Ok(res) = rx.try_recv() {
|
||||
match res {
|
||||
Create(new_path) => {
|
||||
let index = sources.lock().unwrap().len();
|
||||
let media = Media::new(index, new_path.display().to_string(), false);
|
||||
|
||||
sources.lock().unwrap().push(media);
|
||||
info!("Create new file: <b><magenta>{new_path:?}</></b>");
|
||||
}
|
||||
Remove(old_path) => {
|
||||
sources
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|x| x.source != old_path.display().to_string());
|
||||
info!("Remove file: <b><magenta>{old_path:?}</></b>");
|
||||
}
|
||||
Rename(old_path, new_path) => {
|
||||
let index = sources
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.position(|x| *x.source == old_path.display().to_string())
|
||||
.unwrap();
|
||||
|
||||
let media = Media::new(index, new_path.display().to_string(), false);
|
||||
sources.lock().unwrap()[index] = media;
|
||||
|
||||
info!("Rename file: <b><magenta>{old_path:?}</></b> to <b><magenta>{new_path:?}</></b>");
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
sleep(Duration::from_secs(5));
|
||||
}
|
||||
}
|
@ -8,9 +8,9 @@ use std::{
|
||||
use crossbeam_channel::Sender;
|
||||
use simplelog::*;
|
||||
|
||||
use crate::filter::ingest_filter::filter_cmd;
|
||||
use crate::utils::{format_log_line, GlobalConfig, Ingest, ProcessControl};
|
||||
use crate::vec_strings;
|
||||
use ffplayout_lib::filter::ingest_filter::filter_cmd;
|
||||
use ffplayout_lib::utils::{format_log_line, Ingest, PlayoutConfig, ProcessControl};
|
||||
use ffplayout_lib::vec_strings;
|
||||
|
||||
pub fn log_line(line: String, level: &str) {
|
||||
if line.contains("[info]") && level.to_lowercase() == "info" {
|
||||
@ -55,6 +55,10 @@ fn server_monitor(
|
||||
);
|
||||
}
|
||||
|
||||
if line.contains("Address already in use") {
|
||||
proc_ctl.kill_all();
|
||||
}
|
||||
|
||||
log_line(line, level);
|
||||
}
|
||||
|
||||
@ -65,7 +69,7 @@ fn server_monitor(
|
||||
///
|
||||
/// Start ffmpeg in listen mode, and wait for input.
|
||||
pub fn ingest_server(
|
||||
config: GlobalConfig,
|
||||
config: PlayoutConfig,
|
||||
ingest_sender: Sender<(usize, [u8; 65088])>,
|
||||
mut proc_control: ProcessControl,
|
||||
) -> Result<(), Error> {
|
@ -9,19 +9,21 @@ use std::{
|
||||
|
||||
use simplelog::*;
|
||||
|
||||
use crate::utils::{GlobalConfig, Media, PlayoutStatus};
|
||||
use ffplayout_lib::utils::{Media, PlayoutConfig, PlayoutStatus};
|
||||
|
||||
pub mod folder;
|
||||
pub mod ingest;
|
||||
pub mod playlist;
|
||||
|
||||
pub use folder::{watchman, FolderSource};
|
||||
pub use folder::watchman;
|
||||
pub use ingest::ingest_server;
|
||||
pub use playlist::CurrentProgram;
|
||||
|
||||
use ffplayout_lib::utils::folder::FolderSource;
|
||||
|
||||
/// Create a source iterator from playlist, or from folder.
|
||||
pub fn source_generator(
|
||||
config: GlobalConfig,
|
||||
config: PlayoutConfig,
|
||||
current_list: Arc<Mutex<Vec<Media>>>,
|
||||
index: Arc<AtomicUsize>,
|
||||
playout_stat: PlayoutStatus,
|
@ -10,9 +10,9 @@ use std::{
|
||||
use serde_json::json;
|
||||
use simplelog::*;
|
||||
|
||||
use crate::utils::{
|
||||
use ffplayout_lib::utils::{
|
||||
check_sync, gen_dummy, get_delta, get_sec, is_close, is_remote, json_serializer::read_json,
|
||||
modified_time, seek_and_length, valid_source, GlobalConfig, Media, PlayoutStatus, DUMMY_LEN,
|
||||
modified_time, seek_and_length, valid_source, Media, PlayoutConfig, PlayoutStatus, DUMMY_LEN,
|
||||
};
|
||||
|
||||
/// Struct for current playlist.
|
||||
@ -20,7 +20,7 @@ use crate::utils::{
|
||||
/// Here we prepare the init clip and build a iterator where we pull our clips.
|
||||
#[derive(Debug)]
|
||||
pub struct CurrentProgram {
|
||||
config: GlobalConfig,
|
||||
config: PlayoutConfig,
|
||||
start_sec: f64,
|
||||
json_mod: Option<String>,
|
||||
json_path: Option<String>,
|
||||
@ -34,7 +34,7 @@ pub struct CurrentProgram {
|
||||
|
||||
impl CurrentProgram {
|
||||
pub fn new(
|
||||
config: &GlobalConfig,
|
||||
config: &PlayoutConfig,
|
||||
playout_stat: PlayoutStatus,
|
||||
is_terminated: Arc<AtomicBool>,
|
||||
current_list: Arc<Mutex<Vec<Media>>>,
|
||||
@ -56,7 +56,9 @@ impl CurrentProgram {
|
||||
});
|
||||
|
||||
let json: String = serde_json::to_string(&data).expect("Serialize status data failed");
|
||||
fs::write(config.general.stat_file.clone(), &json).expect("Unable to write file");
|
||||
if let Err(e) = fs::write(config.general.stat_file.clone(), &json) {
|
||||
error!("Unable to write status file: {e}");
|
||||
};
|
||||
}
|
||||
|
||||
Self {
|
||||
@ -171,8 +173,10 @@ impl CurrentProgram {
|
||||
*self.playout_stat.time_shift.lock().unwrap() = 0.0;
|
||||
let status_data: String =
|
||||
serde_json::to_string(&data).expect("Serialize status data failed");
|
||||
fs::write(self.config.general.stat_file.clone(), &status_data)
|
||||
.expect("Unable to write file");
|
||||
|
||||
if let Err(e) = fs::write(self.config.general.stat_file.clone(), &status_data) {
|
||||
error!("Unable to write status file: {e}");
|
||||
};
|
||||
|
||||
self.json_path = json.current_file.clone();
|
||||
self.json_mod = json.modified;
|
||||
@ -191,15 +195,13 @@ impl CurrentProgram {
|
||||
let index = self.index.load(Ordering::SeqCst);
|
||||
let current_list = self.nodes.lock().unwrap();
|
||||
|
||||
if index + 1 < current_list.len()
|
||||
&& ¤t_list[index + 1].category.clone().unwrap_or_default() == "advertisement"
|
||||
{
|
||||
if index + 1 < current_list.len() && ¤t_list[index + 1].category == "advertisement" {
|
||||
self.current_node.next_ad = Some(true);
|
||||
}
|
||||
|
||||
if index > 0
|
||||
&& index < current_list.len()
|
||||
&& ¤t_list[index - 1].category.clone().unwrap_or_default() == "advertisement"
|
||||
&& ¤t_list[index - 1].category == "advertisement"
|
||||
{
|
||||
self.current_node.last_ad = Some(true);
|
||||
}
|
||||
@ -390,7 +392,7 @@ impl Iterator for CurrentProgram {
|
||||
/// - return clip only if we are in 24 hours time range
|
||||
fn timed_source(
|
||||
node: Media,
|
||||
config: &GlobalConfig,
|
||||
config: &PlayoutConfig,
|
||||
last: bool,
|
||||
playout_stat: &PlayoutStatus,
|
||||
) -> Media {
|
||||
@ -440,7 +442,7 @@ fn timed_source(
|
||||
}
|
||||
|
||||
/// Generate the source CMD, or when clip not exist, get a dummy.
|
||||
fn gen_source(config: &GlobalConfig, mut node: Media) -> Media {
|
||||
fn gen_source(config: &PlayoutConfig, mut node: Media) -> Media {
|
||||
if valid_source(&node.source) {
|
||||
node.add_probe();
|
||||
node.cmd = Some(seek_and_length(
|
||||
@ -470,7 +472,7 @@ fn gen_source(config: &GlobalConfig, mut node: Media) -> Media {
|
||||
|
||||
/// Handle init clip, but this clip can be the last one in playlist,
|
||||
/// this we have to figure out and calculate the right length.
|
||||
fn handle_list_init(config: &GlobalConfig, mut node: Media) -> Media {
|
||||
fn handle_list_init(config: &PlayoutConfig, mut node: Media) -> Media {
|
||||
debug!("Playlist init");
|
||||
let (_, total_delta) = get_delta(config, &node.begin.unwrap());
|
||||
let mut out = node.out;
|
@ -10,13 +10,23 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use simplelog::*;
|
||||
|
||||
use ffplayout_engine::{
|
||||
pub mod input;
|
||||
pub mod output;
|
||||
pub mod rpc;
|
||||
// #[cfg(test)]
|
||||
// mod tests;
|
||||
pub mod utils;
|
||||
|
||||
use utils::{arg_parse::get_args, get_config};
|
||||
|
||||
use crate::{
|
||||
output::{player, write_hls},
|
||||
rpc::json_rpc_server,
|
||||
utils::{
|
||||
generate_playlist, init_logging, send_mail, validate_ffmpeg, GlobalConfig, PlayerControl,
|
||||
PlayoutStatus, ProcessControl,
|
||||
},
|
||||
};
|
||||
|
||||
use ffplayout_lib::utils::{
|
||||
generate_playlist, init_logging, send_mail, validate_ffmpeg, PlayerControl, PlayoutStatus,
|
||||
ProcessControl,
|
||||
};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@ -39,7 +49,9 @@ fn status_file(stat_file: &str, playout_stat: &PlayoutStatus) {
|
||||
});
|
||||
|
||||
let json: String = serde_json::to_string(&data).expect("Serialize status data failed");
|
||||
fs::write(stat_file, &json).expect("Unable to write file");
|
||||
if let Err(e) = fs::write(stat_file, &json) {
|
||||
error!("Unable to write status file: {e}");
|
||||
};
|
||||
} else {
|
||||
let stat_file = File::options()
|
||||
.read(true)
|
||||
@ -56,7 +68,8 @@ fn status_file(stat_file: &str, playout_stat: &PlayoutStatus) {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let config = GlobalConfig::new();
|
||||
let args = get_args();
|
||||
let config = get_config(args);
|
||||
let config_clone = config.clone();
|
||||
let play_control = PlayerControl::new();
|
||||
let playout_stat = PlayoutStatus::new();
|
||||
@ -67,14 +80,17 @@ fn main() {
|
||||
let proc_ctl2 = proc_control.clone();
|
||||
let messages = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let logging = init_logging(&config, proc_ctl1, messages.clone());
|
||||
let logging = init_logging(&config, Some(proc_ctl1), Some(messages.clone()));
|
||||
CombinedLogger::init(logging).unwrap();
|
||||
|
||||
validate_ffmpeg(&config);
|
||||
|
||||
if let Some(range) = config.general.generate.clone() {
|
||||
// run a simple playlist generator and save them to disk
|
||||
generate_playlist(&config, range);
|
||||
if let Err(e) = generate_playlist(&config, range, None) {
|
||||
error!("{e}");
|
||||
exit(1);
|
||||
};
|
||||
|
||||
exit(0);
|
||||
}
|
@ -2,29 +2,31 @@ use std::process::{self, Command, Stdio};
|
||||
|
||||
use simplelog::*;
|
||||
|
||||
use crate::filter::v_drawtext;
|
||||
use crate::utils::{GlobalConfig, Media};
|
||||
use crate::vec_strings;
|
||||
use ffplayout_lib::filter::v_drawtext;
|
||||
use ffplayout_lib::utils::{Media, PlayoutConfig};
|
||||
use ffplayout_lib::vec_strings;
|
||||
|
||||
/// Desktop Output
|
||||
///
|
||||
/// Instead of streaming, we run a ffplay instance and play on desktop.
|
||||
pub fn output(config: &GlobalConfig, log_format: &str) -> process::Child {
|
||||
pub fn output(config: &PlayoutConfig, log_format: &str) -> process::Child {
|
||||
let mut enc_filter: Vec<String> = vec![];
|
||||
|
||||
let mut enc_cmd = vec_strings!["-hide_banner", "-nostats", "-v", log_format, "-i", "pipe:0"];
|
||||
|
||||
if config.text.add_text && !config.text.over_pre {
|
||||
info!(
|
||||
"Using drawtext filter, listening on address: <yellow>{}</>",
|
||||
config.text.bind_address
|
||||
);
|
||||
if config.text.add_text && !config.text.text_from_filename {
|
||||
if let Some(socket) = config.text.bind_address.clone() {
|
||||
debug!(
|
||||
"Using drawtext filter, listening on address: <yellow>{}</>",
|
||||
socket
|
||||
);
|
||||
|
||||
let mut filter: String = "null,".to_string();
|
||||
filter.push_str(
|
||||
v_drawtext::filter_node(config, &mut Media::new(0, String::new(), false)).as_str(),
|
||||
);
|
||||
enc_filter = vec!["-vf".to_string(), filter];
|
||||
let mut filter: String = "null,".to_string();
|
||||
filter.push_str(
|
||||
v_drawtext::filter_node(config, &Media::new(0, String::new(), false)).as_str(),
|
||||
);
|
||||
enc_filter = vec!["-vf".to_string(), filter];
|
||||
}
|
||||
}
|
||||
|
||||
enc_cmd.append(&mut enc_filter);
|
@ -13,7 +13,7 @@ out:
|
||||
-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
|
||||
-hls_segment_filename /var/www/html/live/stream-%d.ts /var/www/html/live/stream.m3u8
|
||||
|
||||
*/
|
||||
|
||||
@ -27,17 +27,17 @@ use std::{
|
||||
|
||||
use simplelog::*;
|
||||
|
||||
use crate::filter::ingest_filter::filter_cmd;
|
||||
use crate::input::{ingest::log_line, source_generator};
|
||||
use crate::utils::{
|
||||
prepare_output_cmd, sec_to_time, stderr_reader, Decoder, GlobalConfig, Ingest, PlayerControl,
|
||||
use ffplayout_lib::filter::ingest_filter::filter_cmd;
|
||||
use ffplayout_lib::utils::{
|
||||
prepare_output_cmd, sec_to_time, stderr_reader, Decoder, Ingest, PlayerControl, PlayoutConfig,
|
||||
PlayoutStatus, ProcessControl,
|
||||
};
|
||||
use crate::vec_strings;
|
||||
use ffplayout_lib::vec_strings;
|
||||
|
||||
/// Ingest Server for HLS
|
||||
fn ingest_to_hls_server(
|
||||
config: GlobalConfig,
|
||||
config: PlayoutConfig,
|
||||
playout_stat: PlayoutStatus,
|
||||
mut proc_control: ProcessControl,
|
||||
) -> Result<(), Error> {
|
||||
@ -131,7 +131,7 @@ fn ingest_to_hls_server(
|
||||
///
|
||||
/// Write with single ffmpeg instance directly to a HLS playlist.
|
||||
pub fn write_hls(
|
||||
config: &GlobalConfig,
|
||||
config: &PlayoutConfig,
|
||||
play_control: PlayerControl,
|
||||
playout_stat: PlayoutStatus,
|
||||
mut proc_control: ProcessControl,
|
@ -16,10 +16,11 @@ mod stream;
|
||||
pub use hls::write_hls;
|
||||
|
||||
use crate::input::{ingest_server, source_generator};
|
||||
use crate::utils::{
|
||||
sec_to_time, stderr_reader, Decoder, GlobalConfig, PlayerControl, PlayoutStatus, ProcessControl,
|
||||
use ffplayout_lib::utils::{
|
||||
sec_to_time, stderr_reader, Decoder, PlayerControl, PlayoutConfig, PlayoutStatus,
|
||||
ProcessControl,
|
||||
};
|
||||
use crate::vec_strings;
|
||||
use ffplayout_lib::vec_strings;
|
||||
|
||||
/// Player
|
||||
///
|
||||
@ -31,7 +32,7 @@ use crate::vec_strings;
|
||||
/// 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: &GlobalConfig,
|
||||
config: &PlayoutConfig,
|
||||
play_control: PlayerControl,
|
||||
playout_stat: PlayoutStatus,
|
||||
mut proc_control: ProcessControl,
|
@ -2,14 +2,14 @@ use std::process::{self, Command, Stdio};
|
||||
|
||||
use simplelog::*;
|
||||
|
||||
use crate::filter::v_drawtext;
|
||||
use crate::utils::{prepare_output_cmd, GlobalConfig, Media};
|
||||
use crate::vec_strings;
|
||||
use ffplayout_lib::filter::v_drawtext;
|
||||
use ffplayout_lib::utils::{prepare_output_cmd, Media, PlayoutConfig};
|
||||
use ffplayout_lib::vec_strings;
|
||||
|
||||
/// Streaming Output
|
||||
///
|
||||
/// Prepare the ffmpeg command for streaming output
|
||||
pub fn output(config: &GlobalConfig, log_format: &str) -> process::Child {
|
||||
pub fn output(config: &PlayoutConfig, log_format: &str) -> process::Child {
|
||||
let mut enc_cmd = vec![];
|
||||
let mut enc_filter = vec![];
|
||||
let mut preview_cmd = config.out.preview_cmd.as_ref().unwrap().clone();
|
||||
@ -25,27 +25,27 @@ pub fn output(config: &GlobalConfig, log_format: &str) -> process::Child {
|
||||
"pipe:0"
|
||||
];
|
||||
|
||||
if config.text.add_text && !config.text.over_pre {
|
||||
info!(
|
||||
"Using drawtext filter, listening on address: <yellow>{}</>",
|
||||
config.text.bind_address
|
||||
);
|
||||
if config.text.add_text && !config.text.text_from_filename {
|
||||
if let Some(socket) = config.text.bind_address.clone() {
|
||||
debug!(
|
||||
"Using drawtext filter, listening on address: <yellow>{}</>",
|
||||
socket
|
||||
);
|
||||
|
||||
let mut filter = "[0:v]null,".to_string();
|
||||
let mut filter = "[0:v]null,".to_string();
|
||||
|
||||
filter.push_str(
|
||||
v_drawtext::filter_node(config, &mut Media::new(0, String::new(), false)).as_str(),
|
||||
);
|
||||
filter.push_str(
|
||||
v_drawtext::filter_node(config, &Media::new(0, String::new(), false)).as_str(),
|
||||
);
|
||||
|
||||
enc_filter = vec!["-filter_complex".to_string(), filter];
|
||||
enc_filter = vec!["-filter_complex".to_string(), filter];
|
||||
}
|
||||
}
|
||||
|
||||
if config.out.preview {
|
||||
enc_cmd.append(&mut preview_cmd);
|
||||
}
|
||||
|
||||
println!("{enc_filter:?}");
|
||||
|
||||
enc_cmd.append(&mut output_cmd);
|
||||
|
||||
let enc_cmd = prepare_output_cmd(enc_prefix, enc_filter, enc_cmd, &config.out.mode);
|
@ -1,5 +1,8 @@
|
||||
use futures::executor;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
mod zmq_cmd;
|
||||
|
||||
use jsonrpc_http_server::{
|
||||
hyper,
|
||||
jsonrpc_core::{IoHandler, Params, Value},
|
||||
@ -8,11 +11,13 @@ use jsonrpc_http_server::{
|
||||
use serde_json::{json, Map};
|
||||
use simplelog::*;
|
||||
|
||||
use crate::utils::{
|
||||
get_delta, get_sec, sec_to_time, write_status, GlobalConfig, Media, PlayerControl,
|
||||
PlayoutStatus, ProcessControl,
|
||||
use ffplayout_lib::utils::{
|
||||
get_delta, get_filter_from_json, get_sec, sec_to_time, write_status, Media, PlayerControl,
|
||||
PlayoutConfig, PlayoutStatus, ProcessControl,
|
||||
};
|
||||
|
||||
use zmq_cmd::zmq_send;
|
||||
|
||||
/// map media struct to json object
|
||||
fn get_media_map(media: Media) -> Value {
|
||||
json!({
|
||||
@ -25,7 +30,7 @@ fn get_media_map(media: Media) -> Value {
|
||||
}
|
||||
|
||||
/// prepare json object for response
|
||||
fn get_data_map(config: &GlobalConfig, media: Media) -> Map<String, Value> {
|
||||
fn get_data_map(config: &PlayoutConfig, media: Media) -> Map<String, Value> {
|
||||
let mut data_map = Map::new();
|
||||
let begin = media.begin.unwrap_or(0.0);
|
||||
|
||||
@ -56,7 +61,7 @@ fn get_data_map(config: &GlobalConfig, media: Media) -> Map<String, Value> {
|
||||
/// - get last clip
|
||||
/// - reset player state to original clip
|
||||
pub fn json_rpc_server(
|
||||
config: GlobalConfig,
|
||||
config: PlayoutConfig,
|
||||
play_control: PlayerControl,
|
||||
playout_stat: PlayoutStatus,
|
||||
proc_control: ProcessControl,
|
||||
@ -73,6 +78,24 @@ pub fn json_rpc_server(
|
||||
let mut date = playout_stat.date.lock().unwrap();
|
||||
let current_list = play_control.current_list.lock().unwrap();
|
||||
|
||||
// forward text message to ffmpeg
|
||||
if map.contains_key("control")
|
||||
&& &map["control"] == "text"
|
||||
&& map.contains_key("message")
|
||||
{
|
||||
let mut filter = get_filter_from_json(map["message"].to_string());
|
||||
let socket = config.text.bind_address.clone();
|
||||
|
||||
if !filter.is_empty() && config.text.bind_address.is_some() {
|
||||
filter = format!("Parsed_drawtext_2 reinit {filter}");
|
||||
if let Ok(reply) = executor::block_on(zmq_send(&filter, &socket.unwrap())) {
|
||||
return Ok(Value::String(reply));
|
||||
};
|
||||
}
|
||||
|
||||
return Ok(Value::String("Last clip can not be skipped".to_string()));
|
||||
}
|
||||
|
||||
// get next clip
|
||||
if map.contains_key("control") && &map["control"] == "next" {
|
||||
let index = play_control.index.load(Ordering::SeqCst);
|
14
ffplayout-engine/src/rpc/zmq_cmd.rs
Normal file
14
ffplayout-engine/src/rpc/zmq_cmd.rs
Normal file
@ -0,0 +1,14 @@
|
||||
use std::error::Error;
|
||||
|
||||
use zeromq::Socket;
|
||||
use zeromq::{SocketRecv, SocketSend, ZmqMessage};
|
||||
|
||||
pub async fn zmq_send(msg: &str, socket_addr: &str) -> Result<String, Box<dyn Error>> {
|
||||
let mut socket = zeromq::ReqSocket::new();
|
||||
socket.connect(&format!("tcp://{socket_addr}")).await?;
|
||||
socket.send(msg.into()).await?;
|
||||
let repl: ZmqMessage = socket.recv().await?;
|
||||
let response = String::from_utf8(repl.into_vec()[0].to_vec())?;
|
||||
|
||||
Ok(response)
|
||||
}
|
@ -1,15 +1,12 @@
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
thread::{self, sleep},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
mod utils;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::output::player;
|
||||
#[cfg(test)]
|
||||
use crate::utils::*;
|
||||
use ffplayout_lib::utils::*;
|
||||
#[cfg(test)]
|
||||
use simplelog::*;
|
||||
|
||||
@ -22,26 +19,24 @@ fn timed_kill(sec: u64, mut proc_ctl: ProcessControl) {
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn playlist_change_at_midnight() {
|
||||
let mut config = GlobalConfig::new();
|
||||
let mut config = PlayoutConfig::new(None);
|
||||
config.mail.recipient = "".into();
|
||||
config.processing.mode = "playlist".into();
|
||||
config.playlist.day_start = "00:00:00".into();
|
||||
config.playlist.length = "24:00:00".into();
|
||||
config.logging.log_to_file = false;
|
||||
let messages = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let play_control = PlayerControl::new();
|
||||
let playout_stat = PlayoutStatus::new();
|
||||
let proc_control = ProcessControl::new();
|
||||
let proc_ctl = proc_control.clone();
|
||||
let proc_ctl2 = proc_control.clone();
|
||||
|
||||
let logging = init_logging(&config, proc_ctl, messages);
|
||||
let logging = init_logging(&config, None, None);
|
||||
CombinedLogger::init(logging).unwrap();
|
||||
|
||||
mock_time::set_mock_time("2022-05-09T23:59:45");
|
||||
|
||||
thread::spawn(move || timed_kill(30, proc_ctl2));
|
||||
thread::spawn(move || timed_kill(30, proc_ctl));
|
||||
|
||||
player(&config, play_control, playout_stat, proc_control);
|
||||
}
|
||||
@ -49,26 +44,24 @@ fn playlist_change_at_midnight() {
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn playlist_change_at_six() {
|
||||
let mut config = GlobalConfig::new();
|
||||
let mut config = PlayoutConfig::new(None);
|
||||
config.mail.recipient = "".into();
|
||||
config.processing.mode = "playlist".into();
|
||||
config.playlist.day_start = "06:00:00".into();
|
||||
config.playlist.length = "24:00:00".into();
|
||||
config.logging.log_to_file = false;
|
||||
let messages = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let play_control = PlayerControl::new();
|
||||
let playout_stat = PlayoutStatus::new();
|
||||
let proc_control = ProcessControl::new();
|
||||
let proc_ctl = proc_control.clone();
|
||||
let proc_ctl2 = proc_control.clone();
|
||||
|
||||
let logging = init_logging(&config, proc_ctl, messages);
|
||||
let logging = init_logging(&config, None, None);
|
||||
CombinedLogger::init(logging).unwrap();
|
||||
|
||||
mock_time::set_mock_time("2022-05-09T05:59:45");
|
||||
|
||||
thread::spawn(move || timed_kill(30, proc_ctl2));
|
||||
thread::spawn(move || timed_kill(30, proc_ctl));
|
||||
|
||||
player(&config, play_control, playout_stat, proc_control);
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[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\n ffplayout [OPTIONS]",
|
64
ffplayout-engine/src/utils/mod.rs
Normal file
64
ffplayout-engine/src/utils/mod.rs
Normal file
@ -0,0 +1,64 @@
|
||||
use std::path::Path;
|
||||
|
||||
pub mod arg_parse;
|
||||
|
||||
pub use arg_parse::Args;
|
||||
use ffplayout_lib::utils::{time_to_sec, PlayoutConfig};
|
||||
|
||||
pub fn get_config(args: Args) -> PlayoutConfig {
|
||||
let mut config = PlayoutConfig::new(args.config);
|
||||
|
||||
if let Some(gen) = args.generate {
|
||||
config.general.generate = Some(gen);
|
||||
}
|
||||
|
||||
if let Some(log_path) = args.log {
|
||||
if Path::new(&log_path).is_dir() {
|
||||
config.logging.log_to_file = true;
|
||||
}
|
||||
config.logging.log_path = log_path;
|
||||
}
|
||||
|
||||
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".into();
|
||||
}
|
||||
|
||||
if let Some(start) = args.start {
|
||||
config.playlist.day_start = start.clone();
|
||||
config.playlist.start_sec = Some(time_to_sec(&start));
|
||||
}
|
||||
|
||||
if let Some(length) = args.length {
|
||||
config.playlist.length = length.clone();
|
||||
|
||||
if length.contains(':') {
|
||||
config.playlist.length_sec = Some(time_to_sec(&length));
|
||||
} else {
|
||||
config.playlist.length_sec = Some(86400.0);
|
||||
}
|
||||
}
|
||||
|
||||
if args.infinit {
|
||||
config.playlist.infinit = args.infinit;
|
||||
}
|
||||
|
||||
if let Some(output) = args.output {
|
||||
config.out.mode = output;
|
||||
}
|
||||
|
||||
if let Some(volume) = args.volume {
|
||||
config.processing.volume = volume;
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
// Read command line arguments, and override the config with them.
|
34
lib/Cargo.toml
Normal file
34
lib/Cargo.toml
Normal file
@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "ffplayout-lib"
|
||||
description = "Library for ffplayout"
|
||||
license = "GPL-3.0"
|
||||
authors = ["Jonathan Baecker jonbae77@gmail.com"]
|
||||
readme = "README.md"
|
||||
version = "0.9.9"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
chrono = { git = "https://github.com/sbrocket/chrono", branch = "parse-error-kind-public" }
|
||||
crossbeam-channel = "0.5"
|
||||
faccess = "0.2"
|
||||
ffprobe = "0.3"
|
||||
file-rotate = { git = "https://github.com/Ploppz/file-rotate.git", branch = "timestamp-parse-fix" }
|
||||
futures = "0.3"
|
||||
jsonrpc-http-server = "18.0"
|
||||
lettre = "0.10.0-rc.7"
|
||||
log = "0.4"
|
||||
notify = "4.0"
|
||||
once_cell = "1.10"
|
||||
rand = "0.8"
|
||||
regex = "1"
|
||||
reqwest = { version = "0.11", features = ["blocking", "json"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.8"
|
||||
shlex = "1.1"
|
||||
simplelog = { version = "^0.12", features = ["paris"] }
|
||||
time = { version = "0.3", features = ["formatting", "macros"] }
|
||||
walkdir = "2"
|
||||
|
||||
[target.x86_64-unknown-linux-musl.dependencies]
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
@ -1,9 +1,9 @@
|
||||
use crate::utils::GlobalConfig;
|
||||
use crate::utils::PlayoutConfig;
|
||||
|
||||
/// Loudnorm Audio Filter
|
||||
///
|
||||
/// Add loudness normalization.
|
||||
pub fn filter_node(config: &GlobalConfig) -> String {
|
||||
pub fn filter_node(config: &PlayoutConfig) -> String {
|
||||
format!(
|
||||
"loudnorm=I={}:TP={}:LRA={}",
|
||||
config.processing.loud_i, config.processing.loud_tp, config.processing.loud_lra
|
@ -1,10 +1,10 @@
|
||||
use crate::filter::{a_loudnorm, v_overlay};
|
||||
use crate::utils::GlobalConfig;
|
||||
use crate::utils::PlayoutConfig;
|
||||
|
||||
/// Audio Filter
|
||||
///
|
||||
/// If needed we add audio filters to the server instance.
|
||||
fn audio_filter(config: &GlobalConfig) -> String {
|
||||
fn audio_filter(config: &PlayoutConfig) -> String {
|
||||
let mut audio_chain = ";[0:a]afade=in:st=0:d=0.5".to_string();
|
||||
|
||||
if config.processing.loudnorm_ingest {
|
||||
@ -22,7 +22,7 @@ fn audio_filter(config: &GlobalConfig) -> String {
|
||||
}
|
||||
|
||||
/// Create filter nodes for ingest live stream.
|
||||
pub fn filter_cmd(config: &GlobalConfig) -> Vec<String> {
|
||||
pub fn filter_cmd(config: &PlayoutConfig) -> Vec<String> {
|
||||
let mut filter = format!(
|
||||
"[0:v]fps={},scale={}:{},setdar=dar={},fade=in:st=0:d=0.5",
|
||||
config.processing.fps,
|
@ -7,7 +7,7 @@ pub mod ingest_filter;
|
||||
pub mod v_drawtext;
|
||||
pub mod v_overlay;
|
||||
|
||||
use crate::utils::{get_delta, is_close, GlobalConfig, Media};
|
||||
use crate::utils::{get_delta, is_close, Media, PlayoutConfig};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Filters {
|
||||
@ -72,7 +72,7 @@ fn deinterlace(field_order: &Option<String>, chain: &mut Filters) {
|
||||
}
|
||||
}
|
||||
|
||||
fn pad(aspect: f64, chain: &mut Filters, config: &GlobalConfig) {
|
||||
fn pad(aspect: f64, chain: &mut Filters, config: &PlayoutConfig) {
|
||||
if !is_close(aspect, config.processing.aspect, 0.03) {
|
||||
chain.add_filter(
|
||||
&format!(
|
||||
@ -84,13 +84,13 @@ fn pad(aspect: f64, chain: &mut Filters, config: &GlobalConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
fn fps(fps: f64, chain: &mut Filters, config: &GlobalConfig) {
|
||||
fn fps(fps: f64, chain: &mut Filters, config: &PlayoutConfig) {
|
||||
if fps != config.processing.fps {
|
||||
chain.add_filter(&format!("fps={}", config.processing.fps), "video")
|
||||
}
|
||||
}
|
||||
|
||||
fn scale(v_stream: &ffprobe::Stream, aspect: f64, chain: &mut Filters, config: &GlobalConfig) {
|
||||
fn scale(v_stream: &ffprobe::Stream, aspect: f64, chain: &mut Filters, config: &PlayoutConfig) {
|
||||
// width: i64, height: i64
|
||||
if let (Some(w), Some(h)) = (v_stream.width, v_stream.height) {
|
||||
if w != config.processing.width || h != config.processing.height {
|
||||
@ -137,10 +137,10 @@ fn fade(node: &mut Media, chain: &mut Filters, codec_type: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay(node: &mut Media, chain: &mut Filters, config: &GlobalConfig) {
|
||||
fn overlay(node: &mut Media, chain: &mut Filters, config: &PlayoutConfig) {
|
||||
if config.processing.add_logo
|
||||
&& Path::new(&config.processing.logo).is_file()
|
||||
&& &node.category.clone().unwrap_or_default() != "advertisement"
|
||||
&& &node.category != "advertisement"
|
||||
{
|
||||
let mut logo_chain = v_overlay::filter_node(config, false);
|
||||
|
||||
@ -183,8 +183,10 @@ fn extend_video(node: &mut Media, chain: &mut Filters) {
|
||||
}
|
||||
|
||||
/// add drawtext filter for lower thirds messages
|
||||
fn add_text(node: &mut Media, chain: &mut Filters, config: &GlobalConfig) {
|
||||
if config.text.add_text && config.text.over_pre {
|
||||
fn add_text(node: &mut Media, chain: &mut Filters, config: &PlayoutConfig) {
|
||||
if config.text.add_text
|
||||
&& (config.text.text_from_filename || config.out.mode.to_lowercase() == "hls")
|
||||
{
|
||||
let filter = v_drawtext::filter_node(config, node);
|
||||
|
||||
chain.add_filter(&filter, "video");
|
||||
@ -208,7 +210,7 @@ fn add_audio(node: &mut Media, chain: &mut Filters) {
|
||||
.unwrap_or(&vec![])
|
||||
.is_empty()
|
||||
{
|
||||
warn!("Clip: '{}' has no audio!", node.source);
|
||||
warn!("Clip <b><magenta>{}</></b> has no audio!", node.source);
|
||||
let audio = format!(
|
||||
"aevalsrc=0:channel_layout=stereo:duration={}:sample_rate=48000",
|
||||
node.out - node.seek
|
||||
@ -233,7 +235,7 @@ fn extend_audio(node: &mut Media, chain: &mut Filters) {
|
||||
}
|
||||
|
||||
/// Add single pass loudnorm filter to audio line.
|
||||
fn add_loudnorm(node: &mut Media, chain: &mut Filters, config: &GlobalConfig) {
|
||||
fn add_loudnorm(node: &mut Media, chain: &mut Filters, config: &PlayoutConfig) {
|
||||
if config.processing.add_loudnorm
|
||||
&& !node
|
||||
.probe
|
||||
@ -247,13 +249,13 @@ fn add_loudnorm(node: &mut Media, chain: &mut Filters, config: &GlobalConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
fn audio_volume(chain: &mut Filters, config: &GlobalConfig) {
|
||||
fn audio_volume(chain: &mut Filters, config: &PlayoutConfig) {
|
||||
if config.processing.volume != 1.0 {
|
||||
chain.add_filter(&format!("volume={}", config.processing.volume), "audio")
|
||||
}
|
||||
}
|
||||
|
||||
fn aspect_calc(aspect_string: &Option<String>, config: &GlobalConfig) -> f64 {
|
||||
fn aspect_calc(aspect_string: &Option<String>, config: &PlayoutConfig) -> f64 {
|
||||
let mut source_aspect = config.processing.aspect;
|
||||
|
||||
if let Some(aspect) = aspect_string {
|
||||
@ -276,7 +278,12 @@ fn fps_calc(r_frame_rate: &str) -> f64 {
|
||||
}
|
||||
|
||||
/// This realtime filter is important for HLS output to stay in sync.
|
||||
fn realtime_filter(node: &mut Media, chain: &mut Filters, config: &GlobalConfig, codec_type: &str) {
|
||||
fn realtime_filter(
|
||||
node: &mut Media,
|
||||
chain: &mut Filters,
|
||||
config: &PlayoutConfig,
|
||||
codec_type: &str,
|
||||
) {
|
||||
let mut t = "";
|
||||
|
||||
if codec_type == "audio" {
|
||||
@ -300,7 +307,7 @@ fn realtime_filter(node: &mut Media, chain: &mut Filters, config: &GlobalConfig,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filter_chains(config: &GlobalConfig, node: &mut Media) -> Vec<String> {
|
||||
pub fn filter_chains(config: &PlayoutConfig, node: &mut Media) -> Vec<String> {
|
||||
let mut filters = Filters::new();
|
||||
|
||||
if let Some(probe) = node.probe.as_ref() {
|
@ -2,9 +2,9 @@ use std::path::Path;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::utils::{GlobalConfig, Media};
|
||||
use crate::utils::{Media, PlayoutConfig};
|
||||
|
||||
pub fn filter_node(config: &GlobalConfig, node: &mut Media) -> String {
|
||||
pub fn filter_node(config: &PlayoutConfig, node: &Media) -> String {
|
||||
let mut filter = String::new();
|
||||
let mut font = String::new();
|
||||
|
||||
@ -13,7 +13,7 @@ pub fn filter_node(config: &GlobalConfig, node: &mut Media) -> String {
|
||||
font = format!(":fontfile='{}'", config.text.fontfile)
|
||||
}
|
||||
|
||||
if config.text.over_pre && config.text.text_from_filename {
|
||||
if config.text.text_from_filename {
|
||||
let source = node.source.clone();
|
||||
let regex: Regex = Regex::new(&config.text.regex).unwrap();
|
||||
|
||||
@ -27,10 +27,10 @@ pub fn filter_node(config: &GlobalConfig, node: &mut Media) -> String {
|
||||
.replace('%', "\\\\\\%")
|
||||
.replace(':', "\\:");
|
||||
filter = format!("drawtext=text='{escape}':{}{font}", config.text.style)
|
||||
} else {
|
||||
} else if let Some(socket) = config.text.bind_address.clone() {
|
||||
filter = format!(
|
||||
"zmq=b=tcp\\\\://'{}',drawtext=text=''{font}",
|
||||
config.text.bind_address.replace(':', "\\:")
|
||||
socket.replace(':', "\\:")
|
||||
)
|
||||
}
|
||||
}
|
@ -1,11 +1,11 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::utils::GlobalConfig;
|
||||
use crate::utils::PlayoutConfig;
|
||||
|
||||
/// Overlay Filter
|
||||
///
|
||||
/// When a logo is set, we create here the filter for the server.
|
||||
pub fn filter_node(config: &GlobalConfig, add_tail: bool) -> String {
|
||||
pub fn filter_node(config: &PlayoutConfig, add_tail: bool) -> String {
|
||||
let mut logo_chain = String::new();
|
||||
|
||||
if config.processing.add_logo && Path::new(&config.processing.logo).is_file() {
|
@ -2,10 +2,8 @@ extern crate log;
|
||||
extern crate simplelog;
|
||||
|
||||
pub mod filter;
|
||||
pub mod input;
|
||||
pub mod macros;
|
||||
pub mod output;
|
||||
pub mod rpc;
|
||||
pub mod utils;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub mod utils;
|
@ -39,7 +39,7 @@ fn get_date_tomorrow() {
|
||||
|
||||
#[test]
|
||||
fn test_delta() {
|
||||
let mut config = GlobalConfig::new();
|
||||
let mut config = PlayoutConfig::new(None);
|
||||
config.mail.recipient = "".into();
|
||||
config.processing.mode = "playlist".into();
|
||||
config.playlist.day_start = "00:00:00".into();
|
@ -8,14 +8,14 @@ use std::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shlex::split;
|
||||
|
||||
use crate::utils::{get_args, time_to_sec};
|
||||
use crate::utils::{free_tcp_socket, time_to_sec};
|
||||
use crate::vec_strings;
|
||||
|
||||
/// Global Config
|
||||
///
|
||||
/// This we init ones, when ffplayout is starting and use them globally in the hole program.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct GlobalConfig {
|
||||
pub struct PlayoutConfig {
|
||||
pub general: General,
|
||||
pub rpc_server: RpcServer,
|
||||
pub mail: Mail,
|
||||
@ -30,7 +30,10 @@ pub struct GlobalConfig {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct General {
|
||||
pub help_text: String,
|
||||
pub stop_threshold: f64,
|
||||
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
pub generate: Option<Vec<String>>,
|
||||
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
@ -39,6 +42,7 @@ pub struct General {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct RpcServer {
|
||||
pub help_text: String,
|
||||
pub enable: bool,
|
||||
pub address: String,
|
||||
pub authorization: String,
|
||||
@ -46,6 +50,7 @@ pub struct RpcServer {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Mail {
|
||||
pub help_text: String,
|
||||
pub subject: String,
|
||||
pub smtp_server: String,
|
||||
pub starttls: bool,
|
||||
@ -58,6 +63,7 @@ pub struct Mail {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Logging {
|
||||
pub help_text: String,
|
||||
pub log_to_file: bool,
|
||||
pub backup_count: usize,
|
||||
pub local_time: bool,
|
||||
@ -69,6 +75,7 @@ pub struct Logging {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Processing {
|
||||
pub help_text: String,
|
||||
pub mode: String,
|
||||
pub width: i64,
|
||||
pub height: i64,
|
||||
@ -85,28 +92,41 @@ pub struct Processing {
|
||||
pub loud_tp: f32,
|
||||
pub loud_lra: f32,
|
||||
pub volume: f64,
|
||||
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
pub settings: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Ingest {
|
||||
pub help_text: String,
|
||||
pub enable: bool,
|
||||
input_param: String,
|
||||
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
pub input_cmd: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Playlist {
|
||||
pub help_text: String,
|
||||
pub path: String,
|
||||
pub day_start: String,
|
||||
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
pub start_sec: Option<f64>,
|
||||
|
||||
pub length: String,
|
||||
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
pub length_sec: Option<f64>,
|
||||
|
||||
pub infinit: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Storage {
|
||||
pub help_text: String,
|
||||
pub path: String,
|
||||
pub filler_clip: String,
|
||||
pub extensions: Vec<String>,
|
||||
@ -115,9 +135,15 @@ pub struct Storage {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Text {
|
||||
pub help_text: String,
|
||||
pub add_text: bool,
|
||||
pub over_pre: bool,
|
||||
pub bind_address: String,
|
||||
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
pub bind_address: Option<String>,
|
||||
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
pub node_pos: Option<usize>,
|
||||
|
||||
pub fontfile: String,
|
||||
pub text_from_filename: bool,
|
||||
pub style: String,
|
||||
@ -126,21 +152,26 @@ pub struct Text {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Out {
|
||||
pub help_text: String,
|
||||
pub mode: String,
|
||||
pub preview: bool,
|
||||
preview_param: String,
|
||||
pub preview_param: String,
|
||||
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
pub preview_cmd: Option<Vec<String>>,
|
||||
output_param: String,
|
||||
|
||||
pub output_param: String,
|
||||
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
pub output_cmd: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl GlobalConfig {
|
||||
impl PlayoutConfig {
|
||||
/// Read config from YAML file, and set some extra config values.
|
||||
pub fn new() -> Self {
|
||||
let args = get_args();
|
||||
pub fn new(cfg_path: Option<String>) -> Self {
|
||||
let mut config_path = PathBuf::from("/etc/ffplayout/ffplayout.yml");
|
||||
|
||||
if let Some(cfg) = args.config {
|
||||
if let Some(cfg) = cfg_path {
|
||||
config_path = PathBuf::from(cfg);
|
||||
}
|
||||
|
||||
@ -162,7 +193,7 @@ impl GlobalConfig {
|
||||
}
|
||||
};
|
||||
|
||||
let mut config: GlobalConfig =
|
||||
let mut config: PlayoutConfig =
|
||||
serde_yaml::from_reader(f).expect("Could not read config file.");
|
||||
config.general.generate = None;
|
||||
config.general.stat_file = env::temp_dir()
|
||||
@ -217,66 +248,24 @@ impl GlobalConfig {
|
||||
config.out.preview_cmd = split(config.out.preview_param.as_str());
|
||||
config.out.output_cmd = split(config.out.output_param.as_str());
|
||||
|
||||
// Read command line arguments, and override the config with them.
|
||||
|
||||
if let Some(gen) = args.generate {
|
||||
config.general.generate = Some(gen);
|
||||
}
|
||||
|
||||
if let Some(log_path) = args.log {
|
||||
if Path::new(&log_path).is_dir() {
|
||||
config.logging.log_to_file = true;
|
||||
}
|
||||
config.logging.log_path = log_path;
|
||||
}
|
||||
|
||||
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".into();
|
||||
}
|
||||
|
||||
if let Some(start) = args.start {
|
||||
config.playlist.day_start = start.clone();
|
||||
config.playlist.start_sec = Some(time_to_sec(&start));
|
||||
}
|
||||
|
||||
if let Some(length) = args.length {
|
||||
config.playlist.length = length.clone();
|
||||
|
||||
if length.contains(':') {
|
||||
config.playlist.length_sec = Some(time_to_sec(&length));
|
||||
} else {
|
||||
config.playlist.length_sec = Some(86400.0);
|
||||
}
|
||||
}
|
||||
|
||||
if args.infinit {
|
||||
config.playlist.infinit = args.infinit;
|
||||
}
|
||||
|
||||
if let Some(output) = args.output {
|
||||
config.out.mode = output;
|
||||
}
|
||||
|
||||
if let Some(volume) = args.volume {
|
||||
config.processing.volume = volume;
|
||||
// when text overlay without text_from_filename is on, turn also the RPC server on,
|
||||
// to get text messages from it
|
||||
if config.text.add_text && !config.text.text_from_filename {
|
||||
config.rpc_server.enable = true;
|
||||
config.text.bind_address = free_tcp_socket();
|
||||
config.text.node_pos = Some(2);
|
||||
} else {
|
||||
config.text.bind_address = None;
|
||||
config.text.node_pos = None;
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GlobalConfig {
|
||||
impl Default for PlayoutConfig {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
Self::new(None)
|
||||
}
|
||||
}
|
||||
|
@ -1,32 +1,24 @@
|
||||
use std::{
|
||||
ffi::OsStr,
|
||||
path::Path,
|
||||
process::exit,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
mpsc::channel,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
{Arc, Mutex},
|
||||
},
|
||||
thread::sleep,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use notify::{
|
||||
DebouncedEvent::{Create, Remove, Rename},
|
||||
{watcher, RecursiveMode, Watcher},
|
||||
};
|
||||
use rand::{seq::SliceRandom, thread_rng};
|
||||
use simplelog::*;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::utils::{get_sec, GlobalConfig, Media};
|
||||
use crate::utils::{file_extension, get_sec, Media, PlayoutConfig};
|
||||
|
||||
/// Folder Sources
|
||||
///
|
||||
/// Like playlist source, we create here a folder list for iterate over it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FolderSource {
|
||||
config: GlobalConfig,
|
||||
config: PlayoutConfig,
|
||||
pub nodes: Arc<Mutex<Vec<Media>>>,
|
||||
current_node: Media,
|
||||
index: Arc<AtomicUsize>,
|
||||
@ -34,7 +26,7 @@ pub struct FolderSource {
|
||||
|
||||
impl FolderSource {
|
||||
pub fn new(
|
||||
config: &GlobalConfig,
|
||||
config: &PlayoutConfig,
|
||||
current_list: Arc<Mutex<Vec<Media>>>,
|
||||
global_index: Arc<AtomicUsize>,
|
||||
) -> Self {
|
||||
@ -154,65 +146,3 @@ impl Iterator for FolderSource {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn file_extension(filename: &Path) -> Option<&str> {
|
||||
filename.extension().and_then(OsStr::to_str)
|
||||
}
|
||||
|
||||
/// Create a watcher, which monitor file changes.
|
||||
/// When a change is register, update the current file list.
|
||||
/// This makes it possible, to play infinitely and and always new files to it.
|
||||
pub fn watchman(
|
||||
config: GlobalConfig,
|
||||
is_terminated: Arc<AtomicBool>,
|
||||
sources: Arc<Mutex<Vec<Media>>>,
|
||||
) {
|
||||
let (tx, rx) = channel();
|
||||
|
||||
let path = config.storage.path;
|
||||
|
||||
if !Path::new(&path).exists() {
|
||||
error!("Folder path not exists: '{path}'");
|
||||
panic!("Folder path not exists: '{path}'");
|
||||
}
|
||||
|
||||
let mut watcher = watcher(tx, Duration::from_secs(1)).unwrap();
|
||||
watcher.watch(path, RecursiveMode::Recursive).unwrap();
|
||||
|
||||
while !is_terminated.load(Ordering::SeqCst) {
|
||||
if let Ok(res) = rx.try_recv() {
|
||||
match res {
|
||||
Create(new_path) => {
|
||||
let index = sources.lock().unwrap().len();
|
||||
let media = Media::new(index, new_path.display().to_string(), false);
|
||||
|
||||
sources.lock().unwrap().push(media);
|
||||
info!("Create new file: <b><magenta>{new_path:?}</></b>");
|
||||
}
|
||||
Remove(old_path) => {
|
||||
sources
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|x| x.source != old_path.display().to_string());
|
||||
info!("Remove file: <b><magenta>{old_path:?}</></b>");
|
||||
}
|
||||
Rename(old_path, new_path) => {
|
||||
let index = sources
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.position(|x| *x.source == old_path.display().to_string())
|
||||
.unwrap();
|
||||
|
||||
let media = Media::new(index, new_path.display().to_string(), false);
|
||||
sources.lock().unwrap()[index] = media;
|
||||
|
||||
info!("Rename file: <b><magenta>{old_path:?}</></b> to <b><magenta>{new_path:?}</></b>");
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
sleep(Duration::from_secs(5));
|
||||
}
|
||||
}
|
@ -8,6 +8,7 @@
|
||||
/// Beside that it is really very basic, without any logic.
|
||||
use std::{
|
||||
fs::{create_dir_all, write},
|
||||
io::Error,
|
||||
path::Path,
|
||||
process::exit,
|
||||
sync::{atomic::AtomicUsize, Arc, Mutex},
|
||||
@ -16,8 +17,8 @@ use std::{
|
||||
use chrono::{Duration, NaiveDate};
|
||||
use simplelog::*;
|
||||
|
||||
use crate::input::FolderSource;
|
||||
use crate::utils::{json_serializer::Playlist, GlobalConfig, Media};
|
||||
use super::folder::FolderSource;
|
||||
use crate::utils::{json_serializer::JsonPlaylist, time_to_sec, Media, PlayoutConfig};
|
||||
|
||||
/// Generate a vector with dates, from given range.
|
||||
fn get_date_range(date_range: &[String]) -> Vec<String> {
|
||||
@ -50,11 +51,30 @@ fn get_date_range(date_range: &[String]) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// Generate playlists
|
||||
pub fn generate_playlist(config: &GlobalConfig, mut date_range: Vec<String>) {
|
||||
let total_length = config.playlist.length_sec.unwrap();
|
||||
pub fn generate_playlist(
|
||||
config: &PlayoutConfig,
|
||||
mut date_range: Vec<String>,
|
||||
channel_name: Option<String>,
|
||||
) -> Result<Vec<JsonPlaylist>, Error> {
|
||||
let total_length = match config.playlist.length_sec {
|
||||
Some(length) => length,
|
||||
None => {
|
||||
if config.playlist.length.contains(':') {
|
||||
time_to_sec(&config.playlist.length)
|
||||
} else {
|
||||
86400.0
|
||||
}
|
||||
}
|
||||
};
|
||||
let current_list = Arc::new(Mutex::new(vec![Media::new(0, "".to_string(), false)]));
|
||||
let index = Arc::new(AtomicUsize::new(0));
|
||||
let playlist_root = Path::new(&config.playlist.path);
|
||||
let mut playlists = vec![];
|
||||
|
||||
let channel = match channel_name {
|
||||
Some(name) => name,
|
||||
None => "Channel 1".to_string(),
|
||||
};
|
||||
|
||||
if !playlist_root.is_dir() {
|
||||
error!(
|
||||
@ -79,10 +99,7 @@ pub fn generate_playlist(config: &GlobalConfig, mut date_range: Vec<String>) {
|
||||
let playlist_path = playlist_root.join(year).join(month);
|
||||
let playlist_file = &playlist_path.join(format!("{date}.json"));
|
||||
|
||||
if let Err(e) = create_dir_all(playlist_path) {
|
||||
error!("Create folder failed: {e:?}");
|
||||
exit(1);
|
||||
}
|
||||
create_dir_all(playlist_path)?;
|
||||
|
||||
if playlist_file.is_file() {
|
||||
warn!(
|
||||
@ -103,7 +120,8 @@ pub fn generate_playlist(config: &GlobalConfig, mut date_range: Vec<String>) {
|
||||
let mut length = 0.0;
|
||||
let mut round = 0;
|
||||
|
||||
let mut playlist = Playlist {
|
||||
let mut playlist = JsonPlaylist {
|
||||
channel: channel.clone(),
|
||||
date,
|
||||
current_file: None,
|
||||
start_sec: None,
|
||||
@ -130,17 +148,12 @@ pub fn generate_playlist(config: &GlobalConfig, mut date_range: Vec<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
let json: String = match serde_json::to_string_pretty(&playlist) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
error!("Unable to serialize data: {e:?}");
|
||||
exit(0);
|
||||
}
|
||||
};
|
||||
playlists.push(playlist.clone());
|
||||
|
||||
if let Err(e) = write(playlist_file, &json) {
|
||||
error!("Unable to write playlist: {e:?}");
|
||||
exit(1)
|
||||
};
|
||||
let json: String = serde_json::to_string_pretty(&playlist)?;
|
||||
|
||||
write(playlist_file, &json)?;
|
||||
}
|
||||
|
||||
Ok(playlists)
|
||||
}
|
@ -9,14 +9,15 @@ use std::{
|
||||
use simplelog::*;
|
||||
|
||||
use crate::utils::{
|
||||
get_date, is_remote, modified_time, time_from_header, validate_playlist, GlobalConfig, Media,
|
||||
get_date, is_remote, modified_time, time_from_header, validate_playlist, Media, PlayoutConfig,
|
||||
};
|
||||
|
||||
pub const DUMMY_LEN: f64 = 60.0;
|
||||
|
||||
/// This is our main playlist object, it holds all necessary information for the current day.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Playlist {
|
||||
pub struct JsonPlaylist {
|
||||
pub channel: String,
|
||||
pub date: String,
|
||||
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
@ -31,13 +32,14 @@ pub struct Playlist {
|
||||
pub program: Vec<Media>,
|
||||
}
|
||||
|
||||
impl Playlist {
|
||||
impl JsonPlaylist {
|
||||
fn new(date: String, start: f64) -> Self {
|
||||
let mut media = Media::new(0, String::new(), false);
|
||||
media.begin = Some(start);
|
||||
media.duration = DUMMY_LEN;
|
||||
media.out = DUMMY_LEN;
|
||||
Self {
|
||||
channel: "Channel 1".into(),
|
||||
date,
|
||||
start_sec: Some(start),
|
||||
current_file: None,
|
||||
@ -47,7 +49,19 @@ impl Playlist {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_defaults(mut playlist: Playlist, current_file: String, mut start_sec: f64) -> Playlist {
|
||||
impl PartialEq for JsonPlaylist {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.channel == other.channel && self.date == other.date && self.program == other.program
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for JsonPlaylist {}
|
||||
|
||||
fn set_defaults(
|
||||
mut playlist: JsonPlaylist,
|
||||
current_file: String,
|
||||
mut start_sec: f64,
|
||||
) -> JsonPlaylist {
|
||||
playlist.current_file = Some(current_file);
|
||||
playlist.start_sec = Some(start_sec);
|
||||
|
||||
@ -66,15 +80,15 @@ fn set_defaults(mut playlist: Playlist, current_file: String, mut start_sec: f64
|
||||
playlist
|
||||
}
|
||||
|
||||
/// Read json playlist file, fills Playlist struct and set some extra values,
|
||||
/// Read json playlist file, fills JsonPlaylist struct and set some extra values,
|
||||
/// which we need to process.
|
||||
pub fn read_json(
|
||||
config: &GlobalConfig,
|
||||
config: &PlayoutConfig,
|
||||
path: Option<String>,
|
||||
is_terminated: Arc<AtomicBool>,
|
||||
seek: bool,
|
||||
next_start: f64,
|
||||
) -> Playlist {
|
||||
) -> JsonPlaylist {
|
||||
let config_clone = config.clone();
|
||||
let mut playlist_path = Path::new(&config.playlist.path).to_owned();
|
||||
let start_sec = config.playlist.start_sec.unwrap();
|
||||
@ -104,7 +118,7 @@ pub fn read_json(
|
||||
let headers = resp.headers().clone();
|
||||
|
||||
if let Ok(body) = resp.text() {
|
||||
let mut playlist: Playlist =
|
||||
let mut playlist: JsonPlaylist =
|
||||
serde_json::from_str(&body).expect("Could't read remote json playlist.");
|
||||
|
||||
if let Some(time) = time_from_header(&headers) {
|
||||
@ -127,7 +141,7 @@ pub fn read_json(
|
||||
.write(false)
|
||||
.open(¤t_file)
|
||||
.expect("Could not open json playlist file.");
|
||||
let mut playlist: Playlist =
|
||||
let mut playlist: JsonPlaylist =
|
||||
serde_json::from_reader(f).expect("Could't read json playlist file.");
|
||||
playlist.modified = modified_time(¤t_file);
|
||||
|
||||
@ -138,7 +152,7 @@ pub fn read_json(
|
||||
return set_defaults(playlist, current_file, start_sec);
|
||||
}
|
||||
|
||||
error!("Read playlist error, on: <b><magenta>{current_file}</></b>!");
|
||||
error!("Read playlist error, on: <b><magenta>{current_file}</></b>");
|
||||
|
||||
Playlist::new(date, start_sec)
|
||||
JsonPlaylist::new(date, start_sec)
|
||||
}
|
@ -5,7 +5,7 @@ use std::sync::{
|
||||
|
||||
use simplelog::*;
|
||||
|
||||
use crate::utils::{sec_to_time, valid_source, GlobalConfig, MediaProbe, Playlist};
|
||||
use crate::utils::{sec_to_time, valid_source, JsonPlaylist, MediaProbe, PlayoutConfig};
|
||||
|
||||
/// Validate a given playlist, to check if:
|
||||
///
|
||||
@ -14,7 +14,11 @@ use crate::utils::{sec_to_time, valid_source, GlobalConfig, MediaProbe, Playlist
|
||||
/// - total playtime fits target length from config
|
||||
///
|
||||
/// This function we run in a thread, to don't block the main function.
|
||||
pub fn validate_playlist(playlist: Playlist, is_terminated: Arc<AtomicBool>, config: GlobalConfig) {
|
||||
pub fn validate_playlist(
|
||||
playlist: JsonPlaylist,
|
||||
is_terminated: Arc<AtomicBool>,
|
||||
config: PlayoutConfig,
|
||||
) {
|
||||
let date = playlist.date;
|
||||
let mut length = config.playlist.length_sec.unwrap();
|
||||
let mut begin = config.playlist.start_sec.unwrap();
|
@ -22,10 +22,10 @@ use log::{Level, LevelFilter, Log, Metadata, Record};
|
||||
use regex::Regex;
|
||||
use simplelog::*;
|
||||
|
||||
use crate::utils::{GlobalConfig, ProcessControl};
|
||||
use crate::utils::{PlayoutConfig, ProcessControl};
|
||||
|
||||
/// send log messages to mail recipient
|
||||
pub fn send_mail(cfg: &GlobalConfig, msg: String) {
|
||||
pub fn send_mail(cfg: &PlayoutConfig, msg: String) {
|
||||
let recip = cfg
|
||||
.mail
|
||||
.recipient
|
||||
@ -68,7 +68,7 @@ pub fn send_mail(cfg: &GlobalConfig, msg: String) {
|
||||
///
|
||||
/// Check every give seconds for messages and send them.
|
||||
fn mail_queue(
|
||||
cfg: GlobalConfig,
|
||||
cfg: PlayoutConfig,
|
||||
proc_ctl: ProcessControl,
|
||||
messages: Arc<Mutex<Vec<String>>>,
|
||||
interval: u64,
|
||||
@ -92,7 +92,7 @@ pub struct LogMailer {
|
||||
level: LevelFilter,
|
||||
pub config: Config,
|
||||
messages: Arc<Mutex<Vec<String>>>,
|
||||
last_message: Arc<Mutex<String>>,
|
||||
last_messages: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl LogMailer {
|
||||
@ -105,7 +105,7 @@ impl LogMailer {
|
||||
level: log_level,
|
||||
config,
|
||||
messages,
|
||||
last_message: Arc::new(Mutex::new(String::new())),
|
||||
last_messages: Arc::new(Mutex::new(vec![String::new()])),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -118,12 +118,15 @@ impl Log for LogMailer {
|
||||
fn log(&self, record: &Record<'_>) {
|
||||
if self.enabled(record.metadata()) {
|
||||
let rec = record.args().to_string();
|
||||
let mut last_msg = self.last_message.lock().unwrap();
|
||||
let mut last_msgs = self.last_messages.lock().unwrap();
|
||||
|
||||
// put message only to mail queue when it differs from last message
|
||||
// this we do to prevent spamming the mail box
|
||||
if *last_msg != rec {
|
||||
*last_msg = rec.clone();
|
||||
if !last_msgs.contains(&rec) {
|
||||
if last_msgs.len() > 2 {
|
||||
last_msgs.clear()
|
||||
}
|
||||
last_msgs.push(rec.clone());
|
||||
let local: DateTime<Local> = Local::now();
|
||||
let time_stamp = local.format("[%Y-%m-%d %H:%M:%S%.3f]");
|
||||
let level = record.level().to_string().to_uppercase();
|
||||
@ -166,9 +169,9 @@ fn clean_string(text: &str) -> String {
|
||||
/// - file logger
|
||||
/// - mail logger
|
||||
pub fn init_logging(
|
||||
config: &GlobalConfig,
|
||||
proc_ctl: ProcessControl,
|
||||
messages: Arc<Mutex<Vec<String>>>,
|
||||
config: &PlayoutConfig,
|
||||
proc_ctl: Option<ProcessControl>,
|
||||
messages: Option<Arc<Mutex<Vec<String>>>>,
|
||||
) -> Vec<Box<dyn SharedLogger>> {
|
||||
let config_clone = config.clone();
|
||||
let app_config = config.logging.clone();
|
||||
@ -182,6 +185,9 @@ pub fn init_logging(
|
||||
let mut log_config = ConfigBuilder::new()
|
||||
.set_thread_level(LevelFilter::Off)
|
||||
.set_target_level(LevelFilter::Off)
|
||||
.add_filter_ignore_str("hyper")
|
||||
.add_filter_ignore_str("sqlx")
|
||||
.add_filter_ignore_str("reqwest")
|
||||
.set_level_padding(LevelPadding::Left)
|
||||
.set_time_level(time_level)
|
||||
.clone();
|
||||
@ -193,7 +199,7 @@ pub fn init_logging(
|
||||
};
|
||||
};
|
||||
|
||||
if app_config.log_to_file {
|
||||
if app_config.log_to_file && &app_config.log_path != "none" {
|
||||
let file_config = log_config
|
||||
.clone()
|
||||
.set_time_format_custom(format_description!(
|
||||
@ -247,10 +253,12 @@ pub fn init_logging(
|
||||
|
||||
// set mail logger only the recipient is set in config
|
||||
if config.mail.recipient.contains('@') && config.mail.recipient.contains('.') {
|
||||
let messages_clone = messages.clone();
|
||||
let messages_clone = messages.clone().unwrap();
|
||||
let interval = config.mail.interval;
|
||||
|
||||
thread::spawn(move || mail_queue(config_clone, proc_ctl, messages_clone, interval));
|
||||
thread::spawn(move || {
|
||||
mail_queue(config_clone, proc_ctl.unwrap(), messages_clone, interval)
|
||||
});
|
||||
|
||||
let mail_config = log_config.build();
|
||||
|
||||
@ -260,7 +268,7 @@ pub fn init_logging(
|
||||
_ => LevelFilter::Error,
|
||||
};
|
||||
|
||||
app_logger.push(LogMailer::new(filter, mail_config, messages));
|
||||
app_logger.push(LogMailer::new(filter, mail_config, messages.unwrap()));
|
||||
}
|
||||
|
||||
app_logger
|
@ -1,6 +1,8 @@
|
||||
use std::{
|
||||
ffi::OsStr,
|
||||
fs::{self, metadata},
|
||||
io::{BufRead, BufReader, Error},
|
||||
net::TcpListener,
|
||||
path::Path,
|
||||
process::{exit, ChildStderr, Command, Stdio},
|
||||
time::{self, UNIX_EPOCH},
|
||||
@ -9,25 +11,25 @@ use std::{
|
||||
use chrono::{prelude::*, Duration};
|
||||
use ffprobe::{ffprobe, Format, Stream};
|
||||
use jsonrpc_http_server::hyper::HeaderMap;
|
||||
use rand::prelude::*;
|
||||
use regex::Regex;
|
||||
use reqwest::header;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use simplelog::*;
|
||||
|
||||
mod arg_parse;
|
||||
mod config;
|
||||
pub mod config;
|
||||
pub mod controller;
|
||||
pub mod folder;
|
||||
mod generator;
|
||||
pub mod json_serializer;
|
||||
mod json_validate;
|
||||
mod logging;
|
||||
|
||||
pub use arg_parse::get_args;
|
||||
pub use config::GlobalConfig;
|
||||
pub use config::{self as playout_config, PlayoutConfig};
|
||||
pub use controller::{PlayerControl, PlayoutStatus, ProcessControl, ProcessUnit::*};
|
||||
pub use generator::generate_playlist;
|
||||
pub use json_serializer::{read_json, Playlist, DUMMY_LEN};
|
||||
pub use json_serializer::{read_json, JsonPlaylist, DUMMY_LEN};
|
||||
pub use json_validate::validate_playlist;
|
||||
pub use logging::{init_logging, send_mail};
|
||||
|
||||
@ -46,8 +48,7 @@ pub struct Media {
|
||||
pub out: f64,
|
||||
pub duration: f64,
|
||||
|
||||
#[serde(skip_serializing)]
|
||||
pub category: Option<String>,
|
||||
pub category: String,
|
||||
pub source: String,
|
||||
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
@ -92,7 +93,7 @@ impl Media {
|
||||
seek: 0.0,
|
||||
out: duration,
|
||||
duration,
|
||||
category: None,
|
||||
category: String::new(),
|
||||
source: src.clone(),
|
||||
cmd: Some(vec!["-i".to_string(), src]),
|
||||
filter: Some(vec![]),
|
||||
@ -123,14 +124,26 @@ impl Media {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_filter(&mut self, config: &GlobalConfig) {
|
||||
pub fn add_filter(&mut self, config: &PlayoutConfig) {
|
||||
let mut node = self.clone();
|
||||
self.filter = Some(filter_chains(config, &mut node))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Media {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.seek == other.seek
|
||||
&& self.out == other.out
|
||||
&& self.duration == other.duration
|
||||
&& self.source == other.source
|
||||
&& self.category == other.category
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Media {}
|
||||
|
||||
/// We use the ffprobe crate, but we map the metadata to our needs.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct MediaProbe {
|
||||
pub format: Option<Format>,
|
||||
pub audio_streams: Option<Vec<Stream>>,
|
||||
@ -188,10 +201,25 @@ impl MediaProbe {
|
||||
}
|
||||
}
|
||||
|
||||
/// Covert JSON string to ffmpeg filter command.
|
||||
pub fn get_filter_from_json(raw_text: String) -> String {
|
||||
let re1 = Regex::new(r#""|}|\{"#).unwrap();
|
||||
let re2 = Regex::new(r#"id:[0-9]+,?|name:[^,]?,?"#).unwrap();
|
||||
let re3 = Regex::new(r#"text:([^,]*)"#).unwrap();
|
||||
let text = re1.replace_all(&raw_text, "");
|
||||
let text = re2.replace_all(&text, "").clone();
|
||||
let filter = re3
|
||||
.replace_all(&text, "text:'$1'")
|
||||
.replace(':', "=")
|
||||
.replace(',', ":");
|
||||
|
||||
filter
|
||||
}
|
||||
|
||||
/// 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: &GlobalConfig, date: &str, shift: f64) {
|
||||
pub fn write_status(config: &PlayoutConfig, date: &str, shift: f64) {
|
||||
let data = json!({
|
||||
"time_shift": shift,
|
||||
"date": date,
|
||||
@ -199,7 +227,7 @@ pub fn write_status(config: &GlobalConfig, date: &str, shift: f64) {
|
||||
|
||||
let status_data: String = serde_json::to_string(&data).expect("Serialize status data failed");
|
||||
if let Err(e) = fs::write(&config.general.stat_file, &status_data) {
|
||||
error!("Unable to write file: {e:?}")
|
||||
error!("Unable to write status file: {e:?}")
|
||||
};
|
||||
}
|
||||
|
||||
@ -294,6 +322,11 @@ pub fn sec_to_time(sec: f64) -> String {
|
||||
date_time.format("%H:%M:%S%.3f").to_string()
|
||||
}
|
||||
|
||||
/// get file extension
|
||||
pub fn file_extension(filename: &Path) -> Option<&str> {
|
||||
filename.extension().and_then(OsStr::to_str)
|
||||
}
|
||||
|
||||
/// Test if given numbers are close to each other,
|
||||
/// with a third number for setting the maximum range.
|
||||
pub fn is_close(a: f64, b: f64, to: f64) -> bool {
|
||||
@ -308,7 +341,7 @@ pub fn is_close(a: f64, b: f64, to: f64) -> bool {
|
||||
/// if we still in sync.
|
||||
///
|
||||
/// We also get here the global delta between clip start and time when a new playlist should start.
|
||||
pub fn get_delta(config: &GlobalConfig, begin: &f64) -> (f64, f64) {
|
||||
pub fn get_delta(config: &PlayoutConfig, begin: &f64) -> (f64, f64) {
|
||||
let mut current_time = get_sec();
|
||||
let start = config.playlist.start_sec.unwrap();
|
||||
let length = time_to_sec(&config.playlist.length);
|
||||
@ -339,7 +372,7 @@ pub fn get_delta(config: &GlobalConfig, begin: &f64) -> (f64, f64) {
|
||||
}
|
||||
|
||||
/// Check if clip in playlist is in sync with global time.
|
||||
pub fn check_sync(config: &GlobalConfig, delta: f64) -> bool {
|
||||
pub fn check_sync(config: &PlayoutConfig, delta: f64) -> bool {
|
||||
if delta.abs() > config.general.stop_threshold && config.general.stop_threshold > 0.0 {
|
||||
error!("Clip begin out of sync for <yellow>{delta:.3}</> seconds. Stop playout!");
|
||||
return false;
|
||||
@ -349,7 +382,7 @@ pub fn check_sync(config: &GlobalConfig, delta: f64) -> bool {
|
||||
}
|
||||
|
||||
/// Create a dummy clip as a placeholder for missing video files.
|
||||
pub fn gen_dummy(config: &GlobalConfig, duration: f64) -> (String, Vec<String>) {
|
||||
pub fn gen_dummy(config: &PlayoutConfig, duration: f64) -> (String, Vec<String>) {
|
||||
let color = "#121212";
|
||||
let source = format!(
|
||||
"color=c={color}:s={}x{}:d={duration}",
|
||||
@ -567,7 +600,7 @@ fn ffmpeg_libs_and_filter() -> (Vec<String>, Vec<String>) {
|
||||
/// Validate ffmpeg/ffprobe/ffplay.
|
||||
///
|
||||
/// Check if they are in system and has all filters and codecs we need.
|
||||
pub fn validate_ffmpeg(config: &GlobalConfig) {
|
||||
pub fn validate_ffmpeg(config: &PlayoutConfig) {
|
||||
is_in_system("ffmpeg");
|
||||
is_in_system("ffprobe");
|
||||
|
||||
@ -596,6 +629,19 @@ pub fn validate_ffmpeg(config: &GlobalConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
/// get a free tcp socket
|
||||
pub fn free_tcp_socket() -> Option<String> {
|
||||
for _ in 0..100 {
|
||||
let port = rand::thread_rng().gen_range(45321..54268);
|
||||
|
||||
if TcpListener::bind(("127.0.0.1", port)).is_ok() {
|
||||
return Some(format!("127.0.0.1:{port}"));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Get system time, in non test case.
|
||||
#[cfg(not(test))]
|
||||
pub fn time_now() -> DateTime<Local> {
|
Loading…
Reference in New Issue
Block a user