Merge pull request #146 from jb-alvarado/master

support frontend v5.0.0
This commit is contained in:
jb-alvarado 2022-07-06 17:11:14 +02:00 committed by GitHub
commit 0632e862e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1000 additions and 499 deletions

82
Cargo.lock generated
View File

@ -32,9 +32,9 @@ dependencies = [
[[package]]
name = "actix-http"
version = "3.1.0"
version = "3.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd2e9f6794b5826aff6df65e3a0d0127b271d1c03629c774238f3582e903d4e4"
checksum = "6f9ffb6db08c1c3a1f4aef540f1a63193adc73c4fbd40b75a95fc8c5258f6e51"
dependencies = [
"actix-codec",
"actix-rt",
@ -626,7 +626,7 @@ dependencies = [
[[package]]
name = "chrono"
version = "0.4.20-beta.1"
source = "git+https://github.com/chronotope/chrono.git#39ac80a6a51b2a5081750c451feb261c1cb43960"
source = "git+https://github.com/chronotope/chrono.git#686f72038e5cfa41c312cefa8d4569744cbff887"
dependencies = [
"num-integer",
"num-traits",
@ -636,9 +636,9 @@ dependencies = [
[[package]]
name = "clap"
version = "3.2.7"
version = "3.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b7b16274bb247b45177db843202209b12191b631a14a9d06e41b3777d6ecf14"
checksum = "190814073e85d238f31ff738fcb0bf6910cedeb73376c87cd69291028966fd83"
dependencies = [
"atty",
"bitflags",
@ -880,9 +880,9 @@ checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7"
[[package]]
name = "crypto-common"
version = "0.1.3"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57952ca27b5e3606ff4dd79b0020231aaf9d6aa76dc05fd30137538c50bd3ce8"
checksum = "5999502d32b9c48d492abe66392408144895020ec4709e549e840799f3bb74c0"
dependencies = [
"generic-array",
"typenum",
@ -941,15 +941,15 @@ checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
[[package]]
name = "either"
version = "1.6.1"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457"
checksum = "3f107b87b6afc2a64fd13cac55fe06d6c8859f12d4b14cbcdd2c67d0976781be"
[[package]]
name = "email-encoding"
version = "0.1.2"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "827e1fb86d24d558ab0454ca3fa084f8a6144ade1e3e6982f697c586bf96b41b"
checksum = "34dd14c63662e0206599796cd5e1ad0268ab2b9d19b868d6050d688eba2bbf98"
dependencies = [
"base64",
"memchr",
@ -1027,7 +1027,7 @@ dependencies = [
[[package]]
name = "ffplayout-api"
version = "0.3.2"
version = "0.4.0"
dependencies = [
"actix-multipart",
"actix-web",
@ -1100,14 +1100,14 @@ dependencies = [
[[package]]
name = "filetime"
version = "0.2.16"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0408e2626025178a6a7f7ffc05a25bc47103229f19c113755de7bf63816290c"
checksum = "e94a7bbaa59354bc20dd75b67f23e2797b4490e9d6928203fb105c79e448c86c"
dependencies = [
"cfg-if 1.0.0",
"libc",
"redox_syscall",
"winapi 0.3.9",
"windows-sys",
]
[[package]]
@ -1725,9 +1725,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
[[package]]
name = "lettre"
version = "0.10.0-rc.7"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f7e87d9d44162eea7abd87b1a7540fcb10d5e58e8bb4f173178f3dc6e453944"
checksum = "5677c78c7c7ede1dd68e8a7078012bc625449fb304e7b509b917eaaedfe6e849"
dependencies = [
"base64",
"email-encoding",
@ -2023,9 +2023,9 @@ dependencies = [
[[package]]
name = "once_cell"
version = "1.12.0"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7709cef83f0c1f58f666e746a08b21e0085f7440fa6a29cc194d68aac97a4225"
checksum = "18a6dbe30758c9f83eb00cbea4ac95966305f5a7772f3f42ebfc7fc7eddbd8e1"
[[package]]
name = "openssl"
@ -2061,9 +2061,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-src"
version = "111.21.0+1.1.1p"
version = "111.22.0+1.1.1q"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d0a8313729211913936f1b95ca47a5fc7f2e04cd658c115388287f8a8361008"
checksum = "8f31f0d509d1c1ae9cada2f9539ff8f37933831fd5098879e482aa687d659853"
dependencies = [
"cc",
]
@ -2182,18 +2182,18 @@ checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e"
[[package]]
name = "pin-project"
version = "1.0.10"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58ad3879ad3baf4e44784bc6a718a8698867bb991f8ce24d1bcbe2cfb4c3a75e"
checksum = "78203e83c48cffbe01e4a2d35d566ca4de445d79a85372fc64e378bfc812a260"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.0.10"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "744b6f092ba29c3650faf274db506afd39944f48420f6c86b17cfe0ee1cb36bb"
checksum = "710faf75e1b33345361201d36d04e98ac1ed8909151a017ed384700836104c74"
dependencies = [
"proc-macro2",
"quote",
@ -2373,9 +2373,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.5.6"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83f127d94bdbcda4c8cc2e50f6f84f4b611f69c902699ca385a39c3a75f9ff1"
checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b"
dependencies = [
"aho-corasick",
"memchr",
@ -2384,9 +2384,9 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.6.26"
version = "0.6.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49b3de9ec5dc0a3417da371aab17d729997c15010e7fd24ff707773a33bddb64"
checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244"
[[package]]
name = "relative-path"
@ -2542,24 +2542,24 @@ dependencies = [
[[package]]
name = "semver"
version = "1.0.11"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d92beeab217753479be2f74e54187a6aed4c125ff0703a866c3147a02f0c6dd"
checksum = "a2333e6df6d6598f2b1974829f853c2b4c5f4a6e503c10af918081aa6f8564e1"
[[package]]
name = "serde"
version = "1.0.137"
version = "1.0.138"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61ea8d54c77f8315140a05f4c7237403bf38b72704d031543aa1d16abbf517d1"
checksum = "1578c6245786b9d168c5447eeacfb96856573ca56c9d68fdcf394be134882a47"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.137"
version = "1.0.138"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f26faba0c3959972377d3b2d306ee9f71faee9714294e41bb777f83f88578be"
checksum = "023e9b1467aef8a10fb88f25611870ada9800ef7e22afce356bb0d2387b6f27c"
dependencies = [
"proc-macro2",
"quote",
@ -2568,9 +2568,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.81"
version = "1.0.82"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b7ce2b32a1aed03c558dc61a5cd328f15aff2dbc17daad8fb8af04d2100e15c"
checksum = "82c2c1fdcd807d1098552c5b9a36e425e42e9fbd7c6a37a8425f390f781f7fa7"
dependencies = [
"itoa",
"ryu",
@ -2670,9 +2670,9 @@ checksum = "eb703cfe953bccee95685111adeedb76fabe4e97549a58d16f03ea7b9367bb32"
[[package]]
name = "smallvec"
version = "1.8.1"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc88c725d61fc6c3132893370cac4a0200e3fedf5da8331c570664b1987f5ca2"
checksum = "2fd0db749597d91ff862fd1d55ea87f7855a744a8425a64695b6fca237d1dad1"
[[package]]
name = "socket2"
@ -3076,9 +3076,9 @@ checksum = "5bd2fe26506023ed7b5e1e315add59d6f584c621d037f9368fea9cfb988f368c"
[[package]]
name = "unicode-normalization"
version = "0.1.20"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81dee68f85cab8cf68dec42158baf3a79a1cdc065a8b103025965d6ccb7f6cbd"
checksum = "854cbdc4f7bc6ae19c820d44abdc3277ac3e1b2b93db20a636825d9322fb60e6"
dependencies = [
"tinyvec",
]

View File

@ -1,15 +1,6 @@
[workspace]
members = [
"ffplayout-api",
"ffplayout-engine",
"lib",
]
default-members = [
"ffplayout-api",
"ffplayout-engine",
]
members = ["ffplayout-api", "ffplayout-engine", "lib"]
default-members = ["ffplayout-api", "ffplayout-engine"]
[profile.release]
opt-level = 3

View File

@ -3,7 +3,7 @@ Description=Rest API for ffplayout
After=network.target remote-fs.target
[Service]
ExecStart=/usr/bin/ffpapi -l 127.0.0.1:8080
ExecStart=/usr/bin/ffpapi -l 127.0.0.1:8000
ExecReload=/bin/kill -1 $MAINPID
Restart=always
RestartSec=1

View File

@ -3,7 +3,7 @@ Description=Rust and ffmpeg based playout solution
After=network.target remote-fs.target
[Service]
ExecStart= /usr/bin/ffplayout
ExecStart=/usr/bin/ffplayout
ExecReload=/bin/kill -1 $MAINPID
Restart=always
RestartSec=1

View File

@ -10,7 +10,7 @@ general:
rpc_server:
help_text: Run a JSON RPC server, for getting infos about current playing, and
control for some functions.
enable: false
enable: true
address: 127.0.0.1:7070
authorization: av2Kx8g67lF9qj5wEH3ym1bI4cCs
@ -108,7 +108,7 @@ text:
'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
add_text: true
text_from_filename: false
fontfile: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
style: "x=(w-tw)/2:y=(h-line_h)*0.9:fontsize=24:fontcolor=#ffffff:box=1:boxcolor=#000000:boxborderw=4"

View File

@ -21,6 +21,9 @@ The different output modes.
Setup and use a preview stream.
### **[Remove Sources](/docs/remote_source.md)**
### **[Remote Sources](/docs/remote_source.md)**
Use of remote sources, like https://example.org/video.mp4
### **[ffplayout API](/docs/api.md)**
Control the engine, playlist and config with a ~REST API

View File

@ -1,175 +1,313 @@
#### Possible endpoints
### Possible endpoints
Run the API thru the systemd service, or like:
```BASH
ffpapi -l 127.0.0.1:8080
ffpapi -l 127.0.0.1:8000
```
For all endpoints an (Bearer) authentication is required.\
`{id}` represent the channel id, and at default is 1.
#### Login is
#### User Handling
**Login**
```BASH
curl -X POST http://127.0.0.1:8000/auth/login/ -H "Content-Type: application/json" \
-d '{ "username": "<USER>", "password": "<PASS>" }'
```
**Response:**
- **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>"
}
"id": 1,
"mail": "user@example.org",
"username": "<USER>",
"token": "<TOKEN>"
}
```
From here on all request **must** contain the authorization header:\
`"Authorization: Bearer <TOKEN>"`
#### User
**Get current User**
- **PUT** `/api/user/{user id}`\
JSON Data: `{"email": "<EMAIL>", "password": "<PASS>"}`
```BASH
curl -X GET 'http://localhost:8000/api/user' -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <TOKEN>'
```
**Update current User**
```BASH
curl -X PUT http://localhost:8000/api/user/1 -H 'Content-Type: application/json' \
-d '{"mail": "<MAIL>", "password": "<PASS>"}' -H 'Authorization: <TOKEN>'
```
**Add User**
```BASH
curl -X POST 'http://localhost:8000/api/user/' -H 'Content-Type: application/json' \
-d '{"mail": "<MAIL>", "username": "<USER>", "password": "<PASS>", "role_id": 1, "channel_id": 1}' \
-H 'Authorization: Bearer <TOKEN>'
```
#### ffpapi Settings
**Get Settings**
```BASH
curl -X GET http://127.0.0.1:8000/api/settings/1 -H "Authorization: Bearer <TOKEN>"
```
**Response:**
- **POST** `/api/user/`\
JSON Data:
```JSON
{
"email": "<EMAIL>",
"username": "<USER>",
"password": "<PASS>",
"role_id": 1
"id": 1,
"channel_name": "Channel 1",
"preview_url": "http://localhost/live/preview.m3u8",
"config_path": "/etc/ffplayout/ffplayout.yml",
"extra_extensions": "jpg,jpeg,png",
"timezone": "UTC",
"service": "ffplayout.service"
}
```
#### API Settings
**Get all 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"
```BASH
curl -X GET http://127.0.0.1:8000/api/settings -H "Authorization: Bearer <TOKEN>"
```
#### Playout Config
**Update Settings**
- **GET** `/api/playout/config/{id}`\
Response is in JSON format
```BASH
curl -X PATCH http://127.0.0.1:8000/api/settings/1 -H "Content-Type: application/json" \
-d '{ "id": 1, "channel_name": "Channel 1", "preview_url": "http://localhost/live/stream.m3u8", \
"config_path": "/etc/ffplayout/ffplayout.yml", "extra_extensions": "jpg,jpeg,png",
"role_id": 1, "channel_id": 1 }' \
-H "Authorization: Bearer <TOKEN>"
```
- **PUT** `/api/playout/config/{id}`\
JSON Data: `{ <CONFIG DATA> }`\
Response is in TEXT format
#### ffplayout Config
**Get Config**
```BASH
curl -X GET http://localhost:8000/api/playout/config/1 -H 'Authorization: <TOKEN>'
```
Response is a JSON object from the ffplayout.yml
**Update Config**
```BASH
curl -X PUT http://localhost:8000/api/playout/config/1 -H "Content-Type: application/json" \
-d { <CONFIG DATA> } -H 'Authorization: <TOKEN>'
```
#### Text Presets
- **GET** `/api/presets/`\
Response is in JSON format
Text presets are made for sending text messages to the ffplayout engine, to overlay them as a lower third.
**Get all Presets**
```BASH
curl -X GET http://localhost:8000/api/presets/ -H 'Content-Type: application/json' \
-H 'Authorization: <TOKEN>'
```
**Update Preset**
```BASH
curl -X PUT http://localhost:8000/api/presets/1 -H 'Content-Type: application/json' \
-d '{"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}' \
-H 'Authorization: <TOKEN>'
```
**Ad new Preset**
```BASH
curl -X POST http://localhost:8000/api/presets/ -H 'Content-Type: application/json' \
-d '{"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}}' \
-H 'Authorization: <TOKEN>'
```
### ffplayout 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 to the engine, for overlaying it (as lower third etc.)
**Send Text to ffplayout**
```BASH
curl -X POST http://localhost:8000/api/control/1/text/ \
-H 'Content-Type: application/json' -H 'Authorization: <TOKEN>' \
-d '{"text": "Hello from ffplayout", "x": "(w-text_w)/2", "y": "(h-text_h)/2", \
"fontsize": "24", "line_spacing": "4", "fontcolor": "#ffffff", "box": "1", \
"boxcolor": "#000000", "boxborderw": "4", "alpha": "1.0"}'
```
**Jump to next Clip**
```BASH
curl -X POST http://localhost:8000/api/control/1/playout/next/ -H 'Authorization: <TOKEN>'
```
**Jump to last Clip**
```BASH
curl -X POST http://localhost:8000/api/control/1/playout/back/ -H 'Authorization: <TOKEN>'
```
**Reset ffplayout State**
When before was jumped to next, or last clips, here we go back to the original clip.
```BASH
curl -X POST http://localhost:8000/api/control/1/playout/reset/ -H 'Authorization: <TOKEN>'
```
**Get current Clip**
```BASH
curl -X GET http://localhost:8000/api/control/1/media/current/
-H 'Content-Type: application/json' -H 'Authorization: <TOKEN>'
```
**Response:**
- **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"
"jsonrpc": "2.0",
"result": {
"current_media": {
"category": "",
"duration": 154.2,
"out": 154.2,
"seek": 0.0,
"source": "/opt/tv-media/clip.mp4"
},
"index": 39,
"play_mode": "playlist",
"played_sec": 67.80771999300123,
"remaining_sec": 86.39228000699876,
"start_sec": 24713.631999999998,
"start_time": "06:51:53.631"
},
"id": 1
}
```
Response is in TEXT format
- **POST** `api/control/{id}/playout/next/`\
Response is in TEXT format
**Get next Clip**
- **POST** `api/control/{id}/playout/back/`\
Response is in TEXT format
```BASH
curl -X GET http://localhost:8000/api/control/1/media/next/ -H 'Authorization: <TOKEN>'
```
- **POST** `api/control/{id}/playout/reset/`\
Response is in TEXT format
**Get last Clip**
- **GET** `/api/control/{id}/media/current`\
Response is in JSON format
```BASH
curl -X GET http://localhost:8000/api/control/1/media/last/
-H 'Content-Type: application/json' -H 'Authorization: <TOKEN>'
```
- **GET** `/api/control/{id}/media/next`\
Response is in JSON format
#### ffplayout Process Control
- **GET** `/api/control/{id}/media/last`\
Response is in JSON format
Control ffplayout process, like:
- start
- stop
- restart
- status
- **POST** `/api/control/{id}/process/`\
JSON Data: `{"command": "<start/stop/restart/status>"}`
Response is in TEXT format
```BASH
curl -X POST http://localhost:8000/api/control/1/process/
-H 'Content-Type: application/json' -H 'Authorization: <TOKEN>'
-d '{"command": "start"}'
```
#### Playlist Operations
#### ffplayout Playlist Operations
- **GET** `/api/playlist/{id}/2022-06-20`\
Response is in JSON format
**Get playlist**
- **POST** `/api/playlist/1/`\
JSON Data: `{ <PLAYLIST DATA> }`\
Response is in TEXT format
```BASH
curl -X GET http://localhost:8000/api/playlist/1?date=2022-06-20
-H 'Content-Type: application/json' -H 'Authorization: <TOKEN>'
```
- **GET** `/api/playlist/{id}/generate/2022-06-20`\
Response is in JSON format
**Save playlist**
- **DELETE** `/api/playlist/{id}/2022-06-20`\
Response is in TEXT format
```BASH
curl -X POST http://localhost:8000/api/playlist/1/
-H 'Content-Type: application/json' -H 'Authorization: <TOKEN>'
-- data "{<JSON playlist data>}"
```
#### File Operations
**Generate Playlist**
- **GET** `/api/file/{id}/browse/`\
Response is in JSON format
A new playlist will be generated and response.
- **POST** `/api/file/{id}/move/`\
JSON Data: `{"source": "<SOURCE>", "target": "<TARGET>"}`\
Response is in JSON format
```BASH
curl -X GET http://localhost:8000/api/playlist/1/generate/2022-06-20
-H 'Content-Type: application/json' -H 'Authorization: <TOKEN>'
```
- **DELETE** `/api/file/{id}/remove/`\
JSON Data: `{"source": "<SOURCE>"}`\
Response is in JSON format
**Delete Playlist**
- **POST** `/file/{id}/upload/`\
Multipart Form: `name=<TARGET PATH>, filename=<FILENAME>`\
Response is in TEXT format
```BASH
curl -X DELETE http://localhost:8000/api/playlist/1/2022-06-20
-H 'Content-Type: application/json' -H 'Authorization: <TOKEN>'
```
### Log file
**Read Log Life**
```BASH
curl -X Get http://localhost:8000/api/log/1
-H 'Content-Type: application/json' -H 'Authorization: <TOKEN>'
```
### File Operations
**Get File/Folder List**
```BASH
curl -X POST http://localhost:8000/api/file/1/browse/ -H 'Content-Type: application/json'
-d '{ "source": "/" }' -H 'Authorization: <TOKEN>'
```
**Create Folder**
```BASH
curl -X POST http://localhost:8000/api/file/1/create-folder/ -H 'Content-Type: application/json'
-d '{"source": "<FOLDER PATH>"}' -H 'Authorization: <TOKEN>'
```
**Rename File**
```BASH
curl -X POST http://localhost:8000/api/file/1/rename/ -H 'Content-Type: application/json'
-d '{"source": "<SOURCE>", "target": "<TARGET>"}' -H 'Authorization: <TOKEN>'
```
**Remove File/Folder**
```BASH
curl -X POST http://localhost:8000/api/file/1/remove/ -H 'Content-Type: application/json'
-d '{"source": "<SOURCE>"}' -H 'Authorization: <TOKEN>'
```
**Upload File**
```BASH
curl -X POST http://localhost:8000/api/file/1/upload/ -H 'Authorization: <TOKEN>'
-F "file=@file.mp4"
```

View File

@ -4,7 +4,7 @@ description = "Rest API for ffplayout"
license = "GPL-3.0"
authors = ["Jonathan Baecker jonbae77@gmail.com"]
readme = "README.md"
version = "0.3.2"
version = "0.4.0"
edition = "2021"
[dependencies]

View File

@ -12,7 +12,7 @@ ffpapi -i
Then add an admin user:
```BASH
ffpapi -u <USERNAME> -p <PASSWORD> -e <EMAIL ADDRESS>
ffpapi -u <USERNAME> -p <PASSWORD> -m <MAIL ADDRESS>
```
Then run the API thru the systemd service, or like:

View File

@ -15,11 +15,11 @@ use utils::{
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, process_control,
remove, reset_playout, save_file, save_playlist, send_text_message, update_playout_config,
update_preset, update_user,
add_dir, add_preset, add_user, del_playlist, file_browser, gen_playlist, get_all_settings,
get_log, get_playlist, get_playout_config, get_presets, get_settings, get_user,
control_playout, login, media_current, media_last, media_next, move_rename,
patch_settings, process_control, remove, save_file, save_playlist,
send_text_message, update_playout_config, update_preset, update_user, delete_preset,
},
run_args, Role,
};
@ -77,18 +77,19 @@ async fn main() -> std::io::Result<()> {
web::scope("/api")
.wrap(auth)
.service(add_user)
.service(get_user)
.service(get_playout_config)
.service(update_playout_config)
.service(add_preset)
.service(get_presets)
.service(update_preset)
.service(delete_preset)
.service(get_settings)
.service(get_all_settings)
.service(patch_settings)
.service(update_user)
.service(send_text_message)
.service(jump_to_next)
.service(jump_to_last)
.service(reset_playout)
.service(control_playout)
.service(media_current)
.service(media_next)
.service(media_last)
@ -97,7 +98,9 @@ async fn main() -> std::io::Result<()> {
.service(save_playlist)
.service(gen_playlist)
.service(del_playlist)
.service(get_log)
.service(file_browser)
.service(add_dir)
.service(move_rename)
.service(remove)
.service(save_file),

View File

@ -17,8 +17,8 @@ pub struct Args {
#[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 mail address")]
pub mail: Option<String>,
#[clap(short, long, help = "Admin password")]
pub password: Option<String>,

View File

@ -97,7 +97,7 @@ impl SystemD {
let output = Command::new("sudo").args(self.cmd).output()?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
}

View File

@ -14,6 +14,9 @@ pub enum ServiceError {
#[display(fmt = "Unauthorized")]
Unauthorized,
#[display(fmt = "NoContent: {}", _0)]
NoContent(String),
}
// impl ResponseError trait allows to convert our errors into http responses with appropriate data
@ -26,6 +29,7 @@ impl ResponseError for ServiceError {
ServiceError::BadRequest(ref message) => HttpResponse::BadRequest().json(message),
ServiceError::Conflict(ref message) => HttpResponse::Conflict().json(message),
ServiceError::Unauthorized => HttpResponse::Unauthorized().json("No Permission!"),
ServiceError::NoContent(ref message) => HttpResponse::NoContent().json(message),
}
}
}

View File

@ -1,8 +1,4 @@
use std::{
fs,
io::Write,
path::{Path, PathBuf},
};
use std::{fs, io::Write, path::PathBuf};
use actix_multipart::Multipart;
use actix_web::{web, HttpResponse};
@ -14,19 +10,21 @@ use serde::{Deserialize, Serialize};
use simplelog::*;
use crate::utils::{errors::ServiceError, playout_config};
use ffplayout_lib::utils::file_extension;
use ffplayout_lib::utils::{file_extension, MediaProbe};
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct PathObject {
pub source: String,
parent: Option<String>,
folders: Option<Vec<String>>,
files: Option<Vec<String>>,
files: Option<Vec<VideoFile>>,
}
impl PathObject {
fn new(source: String) -> Self {
fn new(source: String, parent: Option<String>) -> Self {
Self {
source,
parent,
folders: Some(vec![]),
files: Some(vec![]),
}
@ -39,54 +37,123 @@ pub struct MoveObject {
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)
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct VideoFile {
name: String,
duration: f64,
}
/// Normalize absolut path
///
/// This function takes care, that it is not possible to break out from root_path.
/// It also gives alway a relative path back.
fn norm_abs_path(root_path: &String, input_path: &String) -> (PathBuf, String, String) {
let mut path = PathBuf::from(root_path.clone());
let path_relative = RelativePath::new(&root_path)
.normalize()
.to_string()
.replace("../", "");
let path = path.join(path_component.clone());
let mut obj = PathObject::new(path_component.clone());
let mut source_relative = RelativePath::new(input_path)
.normalize()
.to_string()
.replace("../", "");
let path_suffix = path.file_name().unwrap().to_string_lossy().to_string();
if input_path.starts_with(root_path) || source_relative.starts_with(&path_relative) {
source_relative = source_relative
.strip_prefix(&path_relative)
.and_then(|s| s.strip_prefix('/'))
.unwrap_or_default()
.to_string();
} else {
source_relative = source_relative
.strip_prefix(&path_suffix)
.and_then(|s| s.strip_prefix('/'))
.unwrap_or(&source_relative)
.to_string();
}
path = path.join(&source_relative);
(path, path_suffix, source_relative)
}
/// File Browser
///
/// Take input path and give file and folder list from it back.
/// Input should be a relative path segment, but when it is a absolut path, the norm_abs_path function
/// will take care, that user can not break out from given storage path in config.
pub async fn browser(id: i64, path_obj: &PathObject) -> Result<PathObject, ServiceError> {
let (config, _) = playout_config(&id).await?;
let extensions = config.storage.extensions;
let (path, parent, path_component) = norm_abs_path(&config.storage.path, &path_obj.source);
let mut obj = PathObject::new(path_component, Some(parent));
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}");
error!("{e} in {}", path_obj.source);
return Err(ServiceError::InternalServerError);
}
};
paths.sort_by_key(|dir| dir.path());
paths.sort_by_key(|dir| dir.path().display().to_string().to_lowercase());
let mut files = vec![];
let mut folders = vec![];
for path in paths {
let file_path = path.path().to_owned();
let path_str = file_path.display().to_string();
let path = file_path.clone();
// ignore hidden files/folders on unix
if path_str.contains("/.") {
if path.display().to_string().contains("/.") {
continue;
}
if file_path.is_dir() {
if let Some(ref mut folders) = obj.folders {
folders.push(path_str);
}
folders.push(path.file_name().unwrap().to_string_lossy().to_string());
} 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);
let media = MediaProbe::new(&path.display().to_string());
let mut duration = 0.0;
if let Some(dur) = media.format.and_then(|f| f.duration) {
duration = dur.parse().unwrap_or(0.0)
}
let video = VideoFile {
name: path.file_name().unwrap().to_string_lossy().to_string(),
duration,
};
files.push(video);
}
}
}
}
obj.folders = Some(folders);
obj.files = Some(files);
Ok(obj)
}
pub async fn create_directory(
id: i64,
path_obj: &PathObject,
) -> Result<HttpResponse, ServiceError> {
let (config, _) = playout_config(&id).await?;
let (path, _, _) = norm_abs_path(&config.storage.path, &path_obj.source);
if let Err(e) = fs::create_dir_all(&path) {
return Err(ServiceError::BadRequest(e.to_string()));
}
info!("create folder: <b><magenta>{}</></b>", path.display());
Ok(HttpResponse::Ok().into())
}
// fn copy_and_delete(source: &PathBuf, target: &PathBuf) -> Result<PathObject, ServiceError> {
// match fs::copy(&source, &target) {
// Ok(_) => {
@ -109,8 +176,16 @@ pub async fn browser(id: i64, path_obj: &PathObject) -> Result<PathObject, Servi
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(),
source: source
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
target: target
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
}),
Err(e) => {
error!("{e}");
@ -121,32 +196,8 @@ fn rename(source: &PathBuf, target: &PathBuf) -> Result<MoveObject, ServiceError
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();
source_path = match source_path.starts_with(&relativ_path) {
true => path.join(source_path.strip_prefix(&relativ_path).unwrap()),
false => path.join(source),
};
target_path = match target_path.starts_with(&relativ_path) {
true => path.join(target_path.strip_prefix(relativ_path).unwrap()),
false => path.join(target),
};
let (source_path, _, _) = norm_abs_path(&config.storage.path, &move_object.source);
let (mut target_path, _, _) = norm_abs_path(&config.storage.path, &move_object.target);
if !source_path.exists() {
return Err(ServiceError::BadRequest("Source file not exist!".into()));
@ -174,24 +225,9 @@ pub async fn rename_file(id: i64, move_object: &MoveObject) -> Result<MoveObject
Err(ServiceError::InternalServerError)
}
pub async fn remove_file_or_folder(id: i64, source_path: &str) -> Result<(), ServiceError> {
pub async fn remove_file_or_folder(id: i64, source_path: &String) -> 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(),
));
}
let (source, _, _) = norm_abs_path(&config.storage.path, source_path);
if !source.exists() {
return Err(ServiceError::BadRequest("Source does not exists!".into()));
@ -222,32 +258,22 @@ pub async fn remove_file_or_folder(id: i64, source_path: &str) -> Result<(), Ser
Err(ServiceError::InternalServerError)
}
async fn valid_path(id: i64, path: &str) -> Result<(), ServiceError> {
async fn valid_path(id: i64, path: &String) -> Result<PathBuf, ServiceError> {
let (config, _) = playout_config(&id).await?;
let (test_path, _, _) = norm_abs_path(&config.storage.path, path);
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() {
if !test_path.is_dir() {
return Err(ServiceError::BadRequest("Target folder not exists!".into()));
}
Ok(())
Ok(test_path)
}
pub async fn upload(id: i64, mut payload: Multipart) -> Result<HttpResponse, ServiceError> {
pub async fn upload(
id: i64,
mut payload: Multipart,
path: &String,
) -> Result<HttpResponse, ServiceError> {
while let Some(mut field) = payload.try_next().await? {
let content_disposition = field.content_disposition();
debug!("{content_disposition}");
@ -256,16 +282,12 @@ pub async fn upload(id: i64, mut payload: Multipart) -> Result<HttpResponse, Ser
.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);
let target_path = valid_path(id, path).await?;
let filepath = target_path.join(filename);
if filepath.is_file() {
return Err(ServiceError::BadRequest("Target already exists!".into()));

View File

@ -23,52 +23,57 @@ async fn create_schema() -> Result<SqliteQueryResult, sqlx::Error> {
let query = "PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS global
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
secret TEXT NOT NULL,
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,
id INTEGER PRIMARY KEY AUTOINCREMENT,
name 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,
service TEXT NOT NULL,
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,
timezone TEXT NOT NULL,
service TEXT NOT NULL,
UNIQUE(channel_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,
channel_id INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (channel_id) REFERENCES settings (id) ON UPDATE SET NULL ON DELETE SET NULL,
UNIQUE(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)
id INTEGER PRIMARY KEY AUTOINCREMENT,
mail TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
salt TEXT NOT NULL,
role_id INTEGER NOT NULL DEFAULT 2,
channel_id INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (role_id) REFERENCES roles (id) ON UPDATE SET NULL ON DELETE SET NULL,
FOREIGN KEY (channel_id) REFERENCES settings (id) ON UPDATE SET NULL ON DELETE SET NULL,
UNIQUE(mail, username)
);";
let result = sqlx::query(query).execute(&conn).await;
conn.close().await;
@ -98,20 +103,20 @@ pub async fn db_init() -> Result<&'static str, Box<dyn std::error::Error>> {
BEFORE INSERT ON global
WHEN (SELECT COUNT(*) FROM global) >= 1
BEGIN
SELECT RAISE(FAIL, 'Database is already init!');
SELECT RAISE(FAIL, 'Database is already initialized!');
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, service)
INSERT INTO settings(channel_name, preview_url, config_path, extra_extensions, timezone, service)
VALUES('Channel 1', 'http://localhost/live/preview.m3u8',
'/etc/ffplayout/ffplayout.yml', '.jpg,.jpeg,.png', 'ffplayout.service');";
'/etc/ffplayout/ffplayout.yml', 'jpg,jpeg,png', 'UTC', 'ffplayout.service');
INSERT INTO roles(name) VALUES('admin'), ('user'), ('guest');
INSERT INTO presets(name, text, x, y, fontsize, line_spacing, fontcolor, box, boxcolor, boxborderw, alpha, channel_id)
VALUES('Default', 'Wellcome to ffplayout messenger!', '(w-text_w)/2', '(h-text_h)/2', '24', '4', '#ffffff@0xff', '0', '#000000@0x80', '4', '1.0', '1'),
('Empty Text', '', '0', '0', '24', '4', '#000000', '0', '#000000', '0', '0', '1'),
('Bottom Text fade in', 'The upcoming event will be delayed by a few minutes.', '(w-text_w)/2', '(h-line_h)*0.9', '24', '4', '#ffffff',
'1', '#000000@0x80', '4', 'ifnot(ld(1),st(1,t));if(lt(t,ld(1)+1),0,if(lt(t,ld(1)+2),(t-(ld(1)+1))/1,if(lt(t,ld(1)+8),1,if(lt(t,ld(1)+9),(1-(t-(ld(1)+8)))/1,0))))', '1'),
('Scrolling Text', 'We have a very important announcement to make.', 'ifnot(ld(1),st(1,t));if(lt(t,ld(1)+1),w+4,w-w/12*mod(t-ld(1),12*(w+tw)/w))', '(h-line_h)*0.9',
'24', '4', '#ffffff', '1', '#000000@0x80', '4', '1.0', '1');";
sqlx::query(query).bind(secret).execute(&instances).await?;
instances.close().await;
@ -143,6 +148,15 @@ pub async fn db_get_settings(id: &i64) -> Result<Settings, sqlx::Error> {
Ok(result)
}
pub async fn db_get_all_settings() -> Result<Vec<Settings>, sqlx::Error> {
let conn = db_connection().await?;
let query = "SELECT * FROM settings";
let result: Vec<Settings> = sqlx::query_as(query).fetch_all(&conn).await?;
conn.close().await;
Ok(result)
}
pub async fn db_update_settings(
id: i64,
settings: Settings,
@ -174,7 +188,16 @@ pub async fn db_role(id: &i64) -> Result<String, sqlx::Error> {
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 query = "SELECT id, mail, 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_get_user(user: &str) -> Result<User, sqlx::Error> {
let conn = db_connection().await?;
let query = "SELECT id, mail, username, role_id FROM user WHERE username = $1";
let result: User = sqlx::query_as(query).bind(user).fetch_one(&conn).await?;
conn.close().await;
@ -189,9 +212,9 @@ pub async fn db_add_user(user: User) -> Result<SqliteQueryResult, sqlx::Error> {
.unwrap();
let query =
"INSERT INTO user (email, username, password, salt, role_id) VALUES($1, $2, $3, $4, $5)";
"INSERT INTO user (mail, username, password, salt, role_id) VALUES($1, $2, $3, $4, $5)";
let result = sqlx::query(query)
.bind(user.email)
.bind(user.mail)
.bind(user.username)
.bind(password_hash.to_string())
.bind(salt.to_string())
@ -212,10 +235,10 @@ pub async fn db_update_user(id: i64, fields: String) -> Result<SqliteQueryResult
Ok(result)
}
pub async fn db_get_presets() -> Result<Vec<TextPreset>, sqlx::Error> {
pub async fn db_get_presets(id: i64) -> 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?;
let query = "SELECT * FROM presets WHERE channel_id = $1";
let result: Vec<TextPreset> = sqlx::query_as(query).bind(id).fetch_all(&conn).await?;
conn.close().await;
Ok(result)
@ -252,9 +275,10 @@ pub async fn db_update_preset(
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)";
"INSERT INTO presets (channel_id, name, text, x, y, fontsize, line_spacing, fontcolor, alpha, box, boxcolor, boxborderw)
VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)";
let result: SqliteQueryResult = sqlx::query(query)
.bind(preset.channel_id)
.bind(preset.name)
.bind(preset.text)
.bind(preset.x)
@ -272,3 +296,12 @@ pub async fn db_add_preset(preset: TextPreset) -> Result<SqliteQueryResult, sqlx
Ok(result)
}
pub async fn db_delete_preset(id: &i64) -> Result<SqliteQueryResult, sqlx::Error> {
let conn = db_connection().await?;
let query = "DELETE FROM presets WHERE id = $1;";
let result: SqliteQueryResult = sqlx::query(query).bind(id).execute(&conn).await?;
conn.close().await;
Ok(result)
}

View File

@ -1,6 +1,6 @@
use std::{
error::Error,
fs::File,
fs::{self, File},
io::{stdin, stdout, Write},
path::Path,
};
@ -129,36 +129,37 @@ pub async fn run_args(mut args: Args) -> Result<(), i32> {
args.password = password.ok();
let mut email = String::new();
print!("EMail: ");
let mut mail = String::new();
print!("Mail: ");
stdout().flush().unwrap();
stdin()
.read_line(&mut email)
.read_line(&mut mail)
.expect("Did not enter a correct name?");
if let Some('\n') = email.chars().next_back() {
email.pop();
if let Some('\n') = mail.chars().next_back() {
mail.pop();
}
if let Some('\r') = email.chars().next_back() {
email.pop();
if let Some('\r') = mail.chars().next_back() {
mail.pop();
}
args.email = Some(email);
args.mail = Some(mail);
}
if let Some(username) = args.username {
if args.email.is_none() || args.password.is_none() {
error!("Email/password missing!");
if args.mail.is_none() || args.password.is_none() {
error!("Mail/password missing!");
return Err(1);
}
let user = User {
id: 0,
email: Some(args.email.unwrap()),
mail: Some(args.mail.unwrap()),
username: username.clone(),
password: args.password.unwrap(),
salt: None,
role_id: Some(1),
channel_id: Some(1),
token: None,
};
@ -193,3 +194,30 @@ pub async fn playout_config(channel_id: &i64) -> Result<(PlayoutConfig, Settings
"Error in getting config!".to_string(),
))
}
pub async fn read_log_file(channel_id: &i64, date: &str) -> Result<String, ServiceError> {
if let Ok(settings) = db_get_settings(channel_id).await {
let mut date_str = "".to_string();
if !date.is_empty() {
date_str.push('.');
date_str.push_str(date);
}
if let Ok(config) = read_playout_config(&settings.config_path) {
let mut log_path = Path::new(&config.logging.log_path)
.join("ffplayout.log")
.display()
.to_string();
log_path.push_str(&date_str);
let file = fs::read_to_string(log_path)?;
return Ok(file);
}
}
Err(ServiceError::BadRequest(
"Requested log file not exists, or not readable.".to_string(),
))
}

View File

@ -6,7 +6,7 @@ pub struct User {
#[serde(skip_deserializing)]
pub id: i64,
#[sqlx(default)]
pub email: Option<String>,
pub mail: Option<String>,
pub username: String,
#[sqlx(default)]
#[serde(skip_serializing, default = "empty_string")]
@ -18,6 +18,9 @@ pub struct User {
#[serde(skip_serializing)]
pub role_id: Option<i64>,
#[sqlx(default)]
#[serde(skip_serializing)]
pub channel_id: Option<i64>,
#[sqlx(default)]
pub token: Option<String>,
}
@ -41,7 +44,7 @@ pub struct TextPreset {
#[sqlx(default)]
#[serde(skip_deserializing)]
pub id: i64,
#[serde(skip_deserializing)]
pub channel_id: i64,
pub name: String,
pub text: String,
pub x: String,
@ -63,6 +66,7 @@ pub struct Settings {
pub preview_url: String,
pub config_path: String,
pub extra_extensions: String,
pub timezone: String,
#[sqlx(default)]
#[serde(skip_serializing, skip_deserializing)]
pub secret: String,

View File

@ -37,11 +37,10 @@ pub async fn read_playlist(id: i64, date: String) -> Result<JsonPlaylist, Servic
.join(date.clone())
.with_extension("json");
if let Ok(p) = json_reader(&playlist_path) {
return Ok(p);
};
Err(ServiceError::InternalServerError)
match json_reader(&playlist_path) {
Ok(p) => Ok(p),
Err(e) => Err(ServiceError::NoContent(e.to_string())),
}
}
pub async fn write_playlist(id: i64, json_data: JsonPlaylist) -> Result<String, ServiceError> {
@ -76,9 +75,10 @@ pub async fn write_playlist(id: i64, json_data: JsonPlaylist) -> Result<String,
}
pub async fn generate_playlist(id: i64, date: String) -> Result<JsonPlaylist, ServiceError> {
let (config, settings) = playout_config(&id).await?;
let (mut config, settings) = playout_config(&id).await?;
config.general.generate = Some(vec![date.clone()]);
match playlist_generator(&config, vec![date], Some(settings.channel_name)) {
match playlist_generator(&config, Some(settings.channel_name)) {
Ok(playlists) => {
if !playlists.is_empty() {
Ok(playlists[0].clone())

View File

@ -1,3 +1,14 @@
/// ### Possible endpoints
///
/// Run the API thru the systemd service, or like:
///
/// ```BASH
/// ffpapi -l 127.0.0.1:8000
/// ```
///
/// For all endpoints an (Bearer) authentication is required.\
/// `{id}` represent the channel id, and at default is 1.
use std::collections::HashMap;
use actix_multipart::Multipart;
@ -7,21 +18,25 @@ use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, SaltString},
Argon2, PasswordHasher, PasswordVerifier,
};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use simplelog::*;
use crate::utils::{
auth::{create_jwt, Claims},
control::{control_service, control_state, media_info, send_message, Process},
errors::ServiceError,
files::{browser, remove_file_or_folder, rename_file, upload, MoveObject, PathObject},
files::{
browser, create_directory, 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,
db_add_preset, db_add_user, db_get_all_settings, db_get_presets, db_get_settings,
db_get_user, db_login, db_role, db_update_preset, db_update_settings, db_update_user,
db_delete_preset,
},
models::{LoginUser, Settings, TextPreset, User},
playlist::{delete_playlist, generate_playlist, read_playlist, write_playlist},
read_playout_config, Role,
read_log_file, read_playout_config, Role,
};
use ffplayout_lib::utils::{JsonPlaylist, PlayoutConfig};
@ -32,8 +47,42 @@ struct ResponseObj<T> {
data: Option<T>,
}
/// curl -X POST http://127.0.0.1:8080/auth/login/ -H "Content-Type: application/json" \
/// -d '{"username": "<USER>", "password": "<PASS>" }'
#[derive(Serialize)]
struct UserObj<T> {
message: String,
user: Option<T>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct DateObj {
#[serde(default)]
date: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct FileObj {
#[serde(default)]
path: String,
}
/// #### User Handling
///
/// **Login**
///
/// ```BASH
/// curl -X POST http://127.0.0.1:8000/auth/login/ -H "Content-Type: application/json" \
/// -d '{ "username": "<USER>", "password": "<PASS>" }'
/// ```
/// **Response:**
///
/// ```JSON
/// {
/// "id": 1,
/// "mail": "user@example.org",
/// "username": "<USER>",
/// "token": "<TOKEN>"
/// }
/// ```
#[post("/auth/login/")]
pub async fn login(credentials: web::Json<User>) -> impl Responder {
match db_login(&credentials.username).await {
@ -58,19 +107,17 @@ pub async fn login(credentials: web::Json<User>) -> impl Responder {
info!("user {} login, with role: {role}", credentials.username);
web::Json(ResponseObj {
web::Json(UserObj {
message: "login correct!".into(),
status: 200,
data: Some(user),
user: Some(user),
})
.customize()
.with_status(StatusCode::OK)
} else {
error!("Wrong password for {}!", credentials.username);
web::Json(ResponseObj {
web::Json(UserObj {
message: "Wrong password!".into(),
status: 403,
data: None,
user: None,
})
.customize()
.with_status(StatusCode::FORBIDDEN)
@ -78,10 +125,9 @@ pub async fn login(credentials: web::Json<User>) -> impl Responder {
}
Err(e) => {
error!("Login {} failed! {e}", credentials.username);
return web::Json(ResponseObj {
return web::Json(UserObj {
message: format!("Login {} failed!", credentials.username),
status: 400,
data: None,
user: None,
})
.customize()
.with_status(StatusCode::BAD_REQUEST);
@ -89,8 +135,33 @@ pub async fn login(credentials: web::Json<User>) -> impl Responder {
}
}
/// curl -X PUT http://localhost:8080/api/user/1 --header 'Content-Type: application/json' \
/// --data '{"email": "<EMAIL>", "password": "<PASS>"}' --header 'Authorization: <TOKEN>'
/// From here on all request **must** contain the authorization header:\
/// `"Authorization: Bearer <TOKEN>"`
/// **Get current User**
///
/// ```BASH
/// curl -X GET 'http://localhost:8000/api/user' -H 'Content-Type: application/json' \
/// -H 'Authorization: Bearer <TOKEN>'
/// ```
#[get("/user")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
async fn get_user(user: web::ReqData<LoginUser>) -> Result<impl Responder, ServiceError> {
match db_get_user(&user.username).await {
Ok(user) => Ok(web::Json(user)),
Err(e) => {
error!("{e}");
Err(ServiceError::InternalServerError)
}
}
}
/// **Update current User**
///
/// ```BASH
/// curl -X PUT http://localhost:8000/api/user/1 -H 'Content-Type: application/json' \
/// -d '{"mail": "<MAIL>", "password": "<PASS>"}' -H 'Authorization: <TOKEN>'
/// ```
#[put("/user/{id}")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
async fn update_user(
@ -101,8 +172,8 @@ async fn update_user(
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 let Some(mail) = data.mail.clone() {
fields.push_str(format!("mail = '{mail}'").as_str());
}
if !data.password.is_empty() {
@ -128,9 +199,13 @@ async fn update_user(
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>'
/// **Add User**
///
/// ```BASH
/// curl -X POST 'http://localhost:8000/api/user/' -H 'Content-Type: application/json' \
/// -d '{"mail": "<MAIL>", "username": "<USER>", "password": "<PASS>", "role_id": 1, "channel_id": 1}' \
/// -H 'Authorization: Bearer <TOKEN>'
/// ```
#[post("/user/")]
#[has_any_role("Role::Admin", type = "Role")]
async fn add_user(data: web::Json<User>) -> Result<impl Responder, ServiceError> {
@ -143,25 +218,61 @@ async fn add_user(data: web::Json<User>) -> Result<impl Responder, ServiceError>
}
}
/// curl -X GET http://127.0.0.1:8080/api/settings/1 -H "Authorization: Bearer <TOKEN>"
/// #### ffpapi Settings
///
/// **Get Settings**
///
/// ```BASH
/// curl -X GET http://127.0.0.1:8000/api/settings/1 -H "Authorization: Bearer <TOKEN>"
/// ```
///
/// **Response:**
///
/// ```JSON
/// {
/// "id": 1,
/// "channel_name": "Channel 1",
/// "preview_url": "http://localhost/live/preview.m3u8",
/// "config_path": "/etc/ffplayout/ffplayout.yml",
/// "extra_extensions": "jpg,jpeg,png",
/// "timezone": "UTC",
/// "service": "ffplayout.service"
/// }
/// ```
#[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),
}));
return Ok(web::Json(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"}' \
/// **Get all Settings**
///
/// ```BASH
/// curl -X GET http://127.0.0.1:8000/api/settings -H "Authorization: Bearer <TOKEN>"
/// ```
#[get("/settings")]
#[has_any_role("Role::Admin", type = "Role")]
async fn get_all_settings() -> Result<impl Responder, ServiceError> {
if let Ok(settings) = db_get_all_settings().await {
return Ok(web::Json(settings));
}
Err(ServiceError::InternalServerError)
}
/// **Update Settings**
///
/// ```BASH
/// curl -X PATCH http://127.0.0.1:8000/api/settings/1 -H "Content-Type: application/json" \
/// -d '{ "id": 1, "channel_name": "Channel 1", "preview_url": "http://localhost/live/stream.m3u8", \
/// "config_path": "/etc/ffplayout/ffplayout.yml", "extra_extensions": "jpg,jpeg,png",
/// "role_id": 1, "channel_id": 1 }' \
/// -H "Authorization: Bearer <TOKEN>"
/// ```
#[patch("/settings/{id}")]
#[has_any_role("Role::Admin", type = "Role")]
async fn patch_settings(
@ -175,7 +286,15 @@ async fn patch_settings(
Err(ServiceError::InternalServerError)
}
/// curl -X GET http://localhost:8080/api/playout/config/1 --header 'Authorization: <TOKEN>'
/// #### ffplayout Config
///
/// **Get Config**
///
/// ```BASH
/// curl -X GET http://localhost:8000/api/playout/config/1 -H 'Authorization: <TOKEN>'
/// ```
///
/// Response is a JSON object from the ffplayout.yml
#[get("/playout/config/{id}")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
async fn get_playout_config(
@ -191,8 +310,12 @@ async fn get_playout_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>'
/// **Update Config**
///
/// ```BASH
/// curl -X PUT http://localhost:8000/api/playout/config/1 -H "Content-Type: application/json" \
/// -d { <CONFIG DATA> } -H 'Authorization: <TOKEN>'
/// ```
#[put("/playout/config/{id}")]
#[has_any_role("Role::Admin", type = "Role")]
async fn update_playout_config(
@ -216,22 +339,34 @@ async fn update_playout_config(
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/")]
/// #### Text Presets
///
/// Text presets are made for sending text messages to the ffplayout engine, to overlay them as a lower third.
///
/// **Get all Presets**
///
/// ```BASH
/// curl -X GET http://localhost:8000/api/presets/ -H 'Content-Type: application/json' \
/// -H 'Authorization: <TOKEN>'
/// ```
#[get("/presets/{id}")]
#[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 {
async fn get_presets(id: web::Path<i64>) -> Result<impl Responder, ServiceError> {
if let Ok(presets) = db_get_presets(*id).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>'
/// **Update Preset**
///
/// ```BASH
/// curl -X PUT http://localhost:8000/api/presets/1 -H 'Content-Type: application/json' \
/// -d '{ "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, "channel_id": 1 }' \
/// -H 'Authorization: <TOKEN>'
/// ```
#[put("/presets/{id}")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
async fn update_preset(
@ -245,10 +380,14 @@ async fn update_preset(
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>'
/// **Add new Preset**
///
/// ```BASH
/// curl -X POST http://localhost:8000/api/presets/ -H 'Content-Type: application/json' \
/// -d '{ "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, "channel_id": 1 }' \
/// -H '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> {
@ -259,21 +398,39 @@ async fn add_preset(data: web::Json<TextPreset>) -> Result<impl Responder, Servi
Err(ServiceError::InternalServerError)
}
/// ----------------------------------------------------------------------------
/// ffplayout process controlling
/// **Delete Preset**
///
/// ```BASH
/// curl -X DELETE http://localhost:8000/api/presets/1 -H 'Content-Type: application/json' \
/// -H 'Authorization: <TOKEN>'
/// ```
#[delete("/presets/{id}")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
async fn delete_preset(id: web::Path<i64>) -> Result<impl Responder, ServiceError> {
if db_delete_preset(&id).await.is_ok() {
return Ok("Delete preset Success");
}
Err(ServiceError::InternalServerError)
}
/// ### ffplayout 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", \
/// - send text to the engine, for overlaying it (as lower third etc.)
///
/// **Send Text to ffplayout**
///
/// ```BASH
/// curl -X POST http://localhost:8000/api/control/1/text/ \
/// -H 'Content-Type: application/json' -H 'Authorization: <TOKEN>' \
/// -d '{"text": "Hello from ffplayout", "x": "(w-text_w)/2", "y": "(h-text_h)/2", \
/// "fontsize": "24", "line_spacing": "4", "fontcolor": "#ffffff", "box": "1", \
/// "boxcolor": "#000000", "boxborderw": "4", "alpha": "1.0"}'
/// ```
#[post("/control/{id}/text/")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn send_text_message(
@ -286,41 +443,55 @@ pub async fn send_text_message(
}
}
/// curl -X POST http://localhost:8080/api/control/1/playout/next/
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>'
#[post("/control/{id}/playout/next/")]
/// **Control Playout**
///
/// - next
/// - back
/// - reset
///
/// ```BASH
/// curl -X POST http://localhost:8000/api/control/1/playout/next/ -H 'Content-Type: application/json'
/// -d '{ "command": "reset" }' -H 'Authorization: <TOKEN>'
/// ```
#[post("/control/{id}/playout/")]
#[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 {
pub async fn control_playout(id: web::Path<i64>, control: web::Json<Process>) -> Result<impl Responder, ServiceError> {
match control_state(*id, control.command.clone()).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 current Clip**
///
/// ```BASH
/// curl -X GET http://localhost:8000/api/control/1/media/current
/// -H 'Content-Type: application/json' -H 'Authorization: <TOKEN>'
/// ```
///
/// **Response:**
///
/// ```JSON
/// {
/// "jsonrpc": "2.0",
/// "result": {
/// "current_media": {
/// "category": "",
/// "duration": 154.2,
/// "out": 154.2,
/// "seek": 0.0,
/// "source": "/opt/tv-media/clip.mp4"
/// },
/// "index": 39,
/// "play_mode": "playlist",
/// "played_sec": 67.80771999300123,
/// "remaining_sec": 86.39228000699876,
/// "start_sec": 24713.631999999998,
/// "start_time": "06:51:53.631"
/// },
/// "id": 1
/// }
/// ```
#[get("/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> {
@ -330,8 +501,11 @@ pub async fn media_current(id: web::Path<i64>) -> Result<impl Responder, Service
}
}
/// curl -X GET http://localhost:8080/api/control/1/media/next/
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>'
/// **Get next Clip**
///
/// ```BASH
/// curl -X GET http://localhost:8000/api/control/1/media/next/ -H '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> {
@ -341,8 +515,12 @@ pub async fn media_next(id: web::Path<i64>) -> Result<impl Responder, ServiceErr
}
}
/// curl -X GET http://localhost:8080/api/control/1/media/last/
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>'
/// **Get last Clip**
///
/// ```BASH
/// curl -X GET http://localhost:8000/api/control/1/media/last/
/// -H 'Content-Type: application/json' -H '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> {
@ -352,9 +530,19 @@ pub async fn media_last(id: web::Path<i64>) -> Result<impl Responder, ServiceErr
}
}
/// curl -X GET http://localhost:8080/api/control/1/process/
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>'
/// #### ffplayout Process Control
///
/// Control ffplayout process, like:
/// - start
/// - stop
/// - restart
/// - status
///
/// ```BASH
/// curl -X POST http://localhost:8000/api/control/1/process/
/// -H 'Content-Type: application/json' -H 'Authorization: <TOKEN>'
/// -d '{"command": "start"}'
/// ```
#[post("/control/{id}/process/")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn process_control(
@ -364,27 +552,33 @@ pub async fn process_control(
control_service(*id, &proc.command).await
}
/// ----------------------------------------------------------------------------
/// ffplayout playlist operations
/// #### 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}")]
/// **Get playlist**
///
/// ```BASH
/// curl -X GET http://localhost:8000/api/playlist/1?date=2022-06-20
/// -H 'Content-Type: application/json' -H 'Authorization: <TOKEN>'
/// ```
#[get("/playlist/{id}")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn get_playlist(
params: web::Path<(i64, String)>,
id: web::Path<i64>,
obj: web::Query<DateObj>,
) -> Result<impl Responder, ServiceError> {
match read_playlist(params.0, params.1.clone()).await {
match read_playlist(*id, obj.date.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>'
/// **Save playlist**
///
/// ```BASH
/// curl -X POST http://localhost:8000/api/playlist/1/
/// -H 'Content-Type: application/json' -H 'Authorization: <TOKEN>'
/// -- data "{<JSON playlist data>}"
/// ```
#[post("/playlist/{id}/")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn save_playlist(
@ -397,8 +591,14 @@ pub async fn save_playlist(
}
}
/// curl -X GET http://localhost:8080/api/playlist/1/generate/2022-06-20
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>'
/// **Generate Playlist**
///
/// A new playlist will be generated and response.
///
/// ```BASH
/// curl -X GET http://localhost:8000/api/playlist/1/generate/2022-06-20
/// -H 'Content-Type: application/json' -H 'Authorization: <TOKEN>'
/// ```
#[get("/playlist/{id}/generate/{date}")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn gen_playlist(
@ -410,8 +610,12 @@ pub async fn gen_playlist(
}
}
/// curl -X DELETE http://localhost:8080/api/playlist/1/2022-06-20
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>'
/// **Delete Playlist**
///
/// ```BASH
/// curl -X DELETE http://localhost:8000/api/playlist/1/2022-06-20
/// -H 'Content-Type: application/json' -H 'Authorization: <TOKEN>'
/// ```
#[delete("/playlist/{id}/{date}")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn del_playlist(
@ -423,13 +627,31 @@ pub async fn del_playlist(
}
}
/// ----------------------------------------------------------------------------
/// file operations
/// ### Log file
///
/// ----------------------------------------------------------------------------
/// **Read Log Life**
///
/// ```BASH
/// curl -X Get http://localhost:8000/api/log/1
/// -H 'Content-Type: application/json' -H 'Authorization: <TOKEN>'
/// ```
#[get("/log/{id}")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn get_log(
id: web::Path<i64>,
log: web::Query<DateObj>,
) -> Result<impl Responder, ServiceError> {
read_log_file(&id, &log.date).await
}
/// curl -X GET http://localhost:8080/api/file/1/browse/
/// --header 'Content-Type: application/json' --header 'Authorization: <TOKEN>'
/// ### File Operations
///
/// **Get File/Folder List**
///
/// ```BASH
/// curl -X POST http://localhost:8000/api/file/1/browse/ -H 'Content-Type: application/json'
/// -d '{ "source": "/" }' -H 'Authorization: <TOKEN>'
/// ```
#[post("/file/{id}/browse/")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn file_browser(
@ -442,10 +664,28 @@ pub async fn file_browser(
}
}
/// 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/")]
/// **Create Folder**
///
/// ```BASH
/// curl -X POST http://localhost:8000/api/file/1/create-folder/ -H 'Content-Type: application/json'
/// -d '{"source": "<FOLDER PATH>"}' -H 'Authorization: <TOKEN>'
/// ```
#[post("/file/{id}/create-folder/")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn add_dir(
id: web::Path<i64>,
data: web::Json<PathObject>,
) -> Result<HttpResponse, ServiceError> {
create_directory(*id, &data.into_inner()).await
}
/// **Rename File**
///
/// ```BASH
/// curl -X POST http://localhost:8000/api/file/1/rename/ -H 'Content-Type: application/json'
/// -d '{"source": "<SOURCE>", "target": "<TARGET>"}' -H 'Authorization: <TOKEN>'
/// ```
#[post("/file/{id}/rename/")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn move_rename(
id: web::Path<i64>,
@ -457,10 +697,13 @@ pub async fn move_rename(
}
}
/// 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/")]
/// **Remove File/Folder**
///
/// ```BASH
/// curl -X POST http://localhost:8000/api/file/1/remove/ -H 'Content-Type: application/json'
/// -d '{"source": "<SOURCE>"}' -H 'Authorization: <TOKEN>'
/// ```
#[post("/file/{id}/remove/")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn remove(
id: web::Path<i64>,
@ -472,8 +715,18 @@ pub async fn remove(
}
}
#[post("/file/{id}/upload/")]
/// **Upload File**
///
/// ```BASH
/// curl -X POST http://localhost:8000/api/file/1/upload/ -H 'Authorization: <TOKEN>'
/// -F "file=@file.mp4"
/// ```
#[put("/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
async fn save_file(
id: web::Path<i64>,
payload: Multipart,
obj: web::Query<FileObj>,
) -> Result<HttpResponse, ServiceError> {
upload(*id, payload, &obj.path).await
}

View File

@ -85,9 +85,9 @@ fn main() {
validate_ffmpeg(&config);
if let Some(range) = config.general.generate.clone() {
if config.general.generate.is_some() {
// run a simple playlist generator and save them to disk
if let Err(e) = generate_playlist(&config, range, None) {
if let Err(e) = generate_playlist(&config, None) {
error!("{e}");
exit(1);
};

View File

@ -149,6 +149,10 @@ pub fn write_hls(
proc_control.is_terminated.clone(),
);
if config.out.preview {
warn!("Preview in HLS mode is not supported!");
}
// spawn a thread for ffmpeg ingest server and create a channel for package sending
if config.ingest.enable {
thread::spawn(move || ingest_to_hls_server(config_clone, play_stat, proc_control_c));

View File

@ -13,7 +13,7 @@ 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"
lettre = "0.10"
log = "0.4"
notify = "4.0"
rand = "0.8"

View File

@ -190,15 +190,6 @@ fn add_text(node: &mut Media, chain: &mut Filters, config: &PlayoutConfig) {
let filter = v_drawtext::filter_node(config, node);
chain.add_filter(&filter, "video");
if let Some(filters) = &chain.video_chain {
for (i, f) in filters.split(',').enumerate() {
if f.contains("drawtext") && !config.text.text_from_filename {
debug!("drawtext node is on index: <yellow>{i}</>");
break;
}
}
}
}
}
@ -290,7 +281,7 @@ fn realtime_filter(
t = "a"
}
if &config.out.mode.to_lowercase() == "hls" {
if config.general.generate.is_none() && &config.out.mode.to_lowercase() == "hls" {
let mut speed_filter = format!("{t}realtime=speed=1");
let (delta, _) = get_delta(config, &node.begin.unwrap());
let duration = node.out - node.seek;

View File

@ -53,7 +53,6 @@ fn get_date_range(date_range: &[String]) -> Vec<String> {
/// Generate playlists
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 {
@ -70,6 +69,7 @@ pub fn generate_playlist(
let index = Arc::new(AtomicUsize::new(0));
let playlist_root = Path::new(&config.playlist.path);
let mut playlists = vec![];
let mut date_range = vec![];
let channel = match channel_name {
Some(name) => name,
@ -85,6 +85,10 @@ pub fn generate_playlist(
exit(1);
}
if let Some(range) = config.general.generate.clone() {
date_range = range;
}
if date_range.contains(&"-".to_string()) && date_range.len() == 3 {
date_range = get_date_range(&date_range)
}

View File

@ -43,7 +43,7 @@ pub fn send_mail(cfg: &PlayoutConfig, msg: String) {
message = message.to(r.parse().unwrap());
}
if let Ok(email) = message.body(clean_string(&msg)) {
if let Ok(mail) = message.body(clean_string(&msg)) {
let credentials =
Credentials::new(cfg.mail.sender_addr.clone(), cfg.mail.sender_pass.clone());
@ -55,9 +55,9 @@ pub fn send_mail(cfg: &PlayoutConfig, msg: String) {
let mailer = transporter.unwrap().credentials(credentials).build();
// Send the email
if let Err(e) = mailer.send(&email) {
error!("Could not send email: {:?}", e);
// Send the mail
if let Err(e) = mailer.send(&mail) {
error!("Could not send mail: {:?}", e);
}
} else {
error!("Mail Message failed!");

View File

@ -152,7 +152,7 @@ pub struct MediaProbe {
}
impl MediaProbe {
fn new(input: &str) -> Self {
pub fn new(input: &str) -> Self {
let probe = ffprobe(input);
let mut a_stream = vec![];
let mut v_stream = vec![];

23
scripts/gen_doc.sh Executable file
View File

@ -0,0 +1,23 @@
#!/usr/bin/bash
input=$1
output=$2
print_block=false
if [ ! "$input" ] || [ ! "$output" ]; then
echo "Run script like: den_doc.sh input.rs output.md"
fi
:> "$output"
while IFS= read -r line; do
if echo $line | grep -Eq "^///"; then
echo "$line" | sed -E "s|^/// ?||g" >> "$output"
print_block=true
fi
if [ -z "$line" ] && [[ $print_block == true ]]; then
echo "" >> "$output"
print_block=false
fi
done < "$input"