diff --git a/Cargo.lock b/Cargo.lock index 3fb97eca..ec92e906 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index d5dd726e..c325fc1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/assets/ffpapi.service b/assets/ffpapi.service index 0b7476f9..a00aff53 100644 --- a/assets/ffpapi.service +++ b/assets/ffpapi.service @@ -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 diff --git a/assets/ffplayout.service b/assets/ffplayout.service index a65eb9d8..474d7ee2 100644 --- a/assets/ffplayout.service +++ b/assets/ffplayout.service @@ -3,7 +3,7 @@ Description=Rust and ffmpeg based playout solution After=network.target remote-fs.target [Service] -ExecStart= /usr/bin/ffplayout +ExecStart=/usr/bin/ffplayout ExecReload=/bin/kill -1 $MAINPID Restart=always RestartSec=1 diff --git a/assets/ffplayout.yml b/assets/ffplayout.yml index d483d7ec..0a1ffefc 100644 --- a/assets/ffplayout.yml +++ b/assets/ffplayout.yml @@ -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" diff --git a/docs/README.md b/docs/README.md index 272d85c6..a405c588 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/api.md b/docs/api.md index 1e4f14e2..aa2ac104 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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": "", "password": "" }' +``` +**Response:** -- **POST** `/auth/login/`\ -JSON Data: `{"username": "", "password": ""}`\ -JSON Response: ```JSON { - "message": "login correct!", - "status": 200, - "data": { - "id": 1, - "email": "user@example.org", - "username": "user", - "token": "" - } + "id": 1, + "mail": "user@example.org", + "username": "", + "token": "" } ``` From here on all request **must** contain the authorization header:\ `"Authorization: Bearer "` -#### User +**Get current User** -- **PUT** `/api/user/{user id}`\ -JSON Data: `{"email": "", "password": ""}` +```BASH +curl -X GET 'http://localhost:8000/api/user' -H 'Content-Type: application/json' \ +-H 'Authorization: Bearer ' +``` + +**Update current User** + +```BASH +curl -X PUT http://localhost:8000/api/user/1 -H 'Content-Type: application/json' \ +-d '{"mail": "", "password": ""}' -H 'Authorization: ' +``` + +**Add User** + +```BASH +curl -X POST 'http://localhost:8000/api/user/' -H 'Content-Type: application/json' \ +-d '{"mail": "", "username": "", "password": "", "role_id": 1, "channel_id": 1}' \ +-H 'Authorization: Bearer ' +``` + +#### ffpapi Settings + +**Get Settings** + +```BASH +curl -X GET http://127.0.0.1:8000/api/settings/1 -H "Authorization: Bearer " +``` + +**Response:** -- **POST** `/api/user/`\ -JSON Data: ```JSON { - "email": "", - "username": "", - "password": "", - "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 " ``` -#### 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 " +``` -- **PUT** `/api/playout/config/{id}`\ -JSON Data: `{ }`\ -Response is in TEXT format +#### ffplayout Config + +**Get Config** + +```BASH +curl -X GET http://localhost:8000/api/playout/config/1 -H 'Authorization: ' +``` + +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 { } -H 'Authorization: ' +``` #### 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: ' +``` + +**Update Preset** + +```BASH +curl -X PUT http://localhost:8000/api/presets/1 -H 'Content-Type: application/json' \ +-d '{"name": "", "text": "", "x": "", "y": "", "fontsize": 24, \ +"line_spacing": 4, "fontcolor": "#ffffff", "box": 1, "boxcolor": "#000000", "boxborderw": 4, "alpha": 1.0}' \ +-H 'Authorization: ' +``` + +**Ad new Preset** + +```BASH +curl -X POST http://localhost:8000/api/presets/ -H 'Content-Type: application/json' \ +-d '{"name": "", "text": "TEXT>", "x": "", "y": "", "fontsize": 24, \ +"line_spacing": 4, "fontcolor": "#ffffff", "box": 1, "boxcolor": "#000000", "boxborderw": 4, "alpha": 1.0}}' \ +-H 'Authorization: ' +``` + +### 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: ' \ +-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: ' +``` + +**Jump to last Clip** + +```BASH +curl -X POST http://localhost:8000/api/control/1/playout/back/ -H 'Authorization: ' +``` + +**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: ' +``` + +**Get current Clip** + +```BASH +curl -X GET http://localhost:8000/api/control/1/media/current/ +-H 'Content-Type: application/json' -H 'Authorization: ' +``` + +**Response:** -- **PUT** `/api/playout/presets/{id}`\ -JSON Data: ```JSON { - "name": "", - "text": "", - "x": "", - "y": "", - "fontsize": 24, - "line_spacing": 4, - "fontcolor": "#ffffff", - "box": 1, - "boxcolor": "#000000", - "boxborderw": 4, - "alpha": "" -} - -``` -Response is in TEXT format - -- **POST** `/api/playout/presets/`\ -JSON 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: ' +``` -- **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: ' +``` -- **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": ""}` -Response is in TEXT format +```BASH +curl -X POST http://localhost:8000/api/control/1/process/ +-H 'Content-Type: application/json' -H 'Authorization: ' +-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: `{ }`\ -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: ' +``` -- **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: ' +-- 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": "", "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: ' +``` -- **DELETE** `/api/file/{id}/remove/`\ -JSON Data: `{"source": ""}`\ -Response is in JSON format +**Delete Playlist** -- **POST** `/file/{id}/upload/`\ -Multipart Form: `name=, 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: ' +``` + +### Log file + +**Read Log Life** + +```BASH +curl -X Get http://localhost:8000/api/log/1 +-H 'Content-Type: application/json' -H 'Authorization: ' +``` + +### 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: ' +``` + +**Create Folder** + +```BASH +curl -X POST http://localhost:8000/api/file/1/create-folder/ -H 'Content-Type: application/json' +-d '{"source": ""}' -H 'Authorization: ' +``` + +**Rename File** + +```BASH +curl -X POST http://localhost:8000/api/file/1/rename/ -H 'Content-Type: application/json' +-d '{"source": "", "target": ""}' -H 'Authorization: ' +``` + +**Remove File/Folder** + +```BASH +curl -X POST http://localhost:8000/api/file/1/remove/ -H 'Content-Type: application/json' +-d '{"source": ""}' -H 'Authorization: ' +``` + +**Upload File** + +```BASH +curl -X POST http://localhost:8000/api/file/1/upload/ -H 'Authorization: ' +-F "file=@file.mp4" +``` diff --git a/ffplayout-api/Cargo.toml b/ffplayout-api/Cargo.toml index a1288475..4ef87a66 100644 --- a/ffplayout-api/Cargo.toml +++ b/ffplayout-api/Cargo.toml @@ -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] diff --git a/ffplayout-api/README.md b/ffplayout-api/README.md index 546256a6..83c2eecc 100644 --- a/ffplayout-api/README.md +++ b/ffplayout-api/README.md @@ -12,7 +12,7 @@ ffpapi -i Then add an admin user: ```BASH -ffpapi -u -p -e +ffpapi -u -p -m ``` Then run the API thru the systemd service, or like: diff --git a/ffplayout-api/src/main.rs b/ffplayout-api/src/main.rs index 27e59124..84794f38 100644 --- a/ffplayout-api/src/main.rs +++ b/ffplayout-api/src/main.rs @@ -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), diff --git a/ffplayout-api/src/utils/args_parse.rs b/ffplayout-api/src/utils/args_parse.rs index 2fe65ab4..9a7e1d7d 100644 --- a/ffplayout-api/src/utils/args_parse.rs +++ b/ffplayout-api/src/utils/args_parse.rs @@ -17,8 +17,8 @@ pub struct Args { #[clap(short, long, help = "Create admin user")] pub username: Option, - #[clap(short, long, help = "Admin email")] - pub email: Option, + #[clap(short, long, help = "Admin mail address")] + pub mail: Option, #[clap(short, long, help = "Admin password")] pub password: Option, diff --git a/ffplayout-api/src/utils/control.rs b/ffplayout-api/src/utils/control.rs index 8802b50f..60d4949a 100644 --- a/ffplayout-api/src/utils/control.rs +++ b/ffplayout-api/src/utils/control.rs @@ -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()) } } diff --git a/ffplayout-api/src/utils/errors.rs b/ffplayout-api/src/utils/errors.rs index 65294a62..a559ad75 100644 --- a/ffplayout-api/src/utils/errors.rs +++ b/ffplayout-api/src/utils/errors.rs @@ -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), } } } diff --git a/ffplayout-api/src/utils/files.rs b/ffplayout-api/src/utils/files.rs index d63db296..1c831cbd 100644 --- a/ffplayout-api/src/utils/files.rs +++ b/ffplayout-api/src/utils/files.rs @@ -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, folders: Option>, - files: Option>, + files: Option>, } impl PathObject { - fn new(source: String) -> Self { + fn new(source: String, parent: Option) -> 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 { - 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 { + 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 { + 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: {}", path.display()); + + Ok(HttpResponse::Ok().into()) +} + // fn copy_and_delete(source: &PathBuf, target: &PathBuf) -> Result { // match fs::copy(&source, &target) { // Ok(_) => { @@ -109,8 +176,16 @@ pub async fn browser(id: i64, path_obj: &PathObject) -> Result Result { 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 Result { 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 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 { 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 { +pub async fn upload( + id: i64, + mut payload: Multipart, + path: &String, +) -> Result { 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 Result { 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> { 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 { Ok(result) } +pub async fn db_get_all_settings() -> Result, sqlx::Error> { + let conn = db_connection().await?; + let query = "SELECT * FROM settings"; + let result: Vec = 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 { pub async fn db_login(user: &str) -> Result { 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 { + 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 { .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 Result, sqlx::Error> { +pub async fn db_get_presets(id: i64) -> Result, sqlx::Error> { let conn = db_connection().await?; - let query = "SELECT * FROM presets"; - let result: Vec = sqlx::query_as(query).fetch_all(&conn).await?; + let query = "SELECT * FROM presets WHERE channel_id = $1"; + let result: Vec = 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 { 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 Result { + 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) +} diff --git a/ffplayout-api/src/utils/mod.rs b/ffplayout-api/src/utils/mod.rs index 1fe1eaa3..22ab49b1 100644 --- a/ffplayout-api/src/utils/mod.rs +++ b/ffplayout-api/src/utils/mod.rs @@ -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 { + 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(), + )) +} diff --git a/ffplayout-api/src/utils/models.rs b/ffplayout-api/src/utils/models.rs index 31cc5e4f..5791c61f 100644 --- a/ffplayout-api/src/utils/models.rs +++ b/ffplayout-api/src/utils/models.rs @@ -6,7 +6,7 @@ pub struct User { #[serde(skip_deserializing)] pub id: i64, #[sqlx(default)] - pub email: Option, + pub mail: Option, 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, #[sqlx(default)] + #[serde(skip_serializing)] + pub channel_id: Option, + #[sqlx(default)] pub token: Option, } @@ -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, diff --git a/ffplayout-api/src/utils/playlist.rs b/ffplayout-api/src/utils/playlist.rs index 9f828024..11022044 100644 --- a/ffplayout-api/src/utils/playlist.rs +++ b/ffplayout-api/src/utils/playlist.rs @@ -37,11 +37,10 @@ pub async fn read_playlist(id: i64, date: String) -> Result Ok(p), + Err(e) => Err(ServiceError::NoContent(e.to_string())), + } } pub async fn write_playlist(id: i64, json_data: JsonPlaylist) -> Result { @@ -76,9 +75,10 @@ pub async fn write_playlist(id: i64, json_data: JsonPlaylist) -> Result Result { - 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()) diff --git a/ffplayout-api/src/utils/routes.rs b/ffplayout-api/src/utils/routes.rs index e4d15c86..3bb3d562 100644 --- a/ffplayout-api/src/utils/routes.rs +++ b/ffplayout-api/src/utils/routes.rs @@ -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 { data: Option, } -/// curl -X POST http://127.0.0.1:8080/auth/login/ -H "Content-Type: application/json" \ -/// -d '{"username": "", "password": "" }' +#[derive(Serialize)] +struct UserObj { + message: String, + user: Option, +} + +#[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": "", "password": "" }' +/// ``` +/// **Response:** +/// +/// ```JSON +/// { +/// "id": 1, +/// "mail": "user@example.org", +/// "username": "", +/// "token": "" +/// } +/// ``` #[post("/auth/login/")] pub async fn login(credentials: web::Json) -> impl Responder { match db_login(&credentials.username).await { @@ -58,19 +107,17 @@ pub async fn login(credentials: web::Json) -> 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) -> 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) -> impl Responder { } } -/// curl -X PUT http://localhost:8080/api/user/1 --header 'Content-Type: application/json' \ -/// --data '{"email": "", "password": ""}' --header 'Authorization: ' +/// From here on all request **must** contain the authorization header:\ +/// `"Authorization: Bearer "` + +/// **Get current User** +/// +/// ```BASH +/// curl -X GET 'http://localhost:8000/api/user' -H 'Content-Type: application/json' \ +/// -H 'Authorization: Bearer ' +/// ``` +#[get("/user")] +#[has_any_role("Role::Admin", "Role::User", type = "Role")] +async fn get_user(user: web::ReqData) -> Result { + 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": "", "password": ""}' -H 'Authorization: ' +/// ``` #[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": "", "username": "", "password": "", "role_id": 1}' \ -/// --header 'Authorization: Bearer ' +/// **Add User** +/// +/// ```BASH +/// curl -X POST 'http://localhost:8000/api/user/' -H 'Content-Type: application/json' \ +/// -d '{"mail": "", "username": "", "password": "", "role_id": 1, "channel_id": 1}' \ +/// -H 'Authorization: Bearer ' +/// ``` #[post("/user/")] #[has_any_role("Role::Admin", type = "Role")] async fn add_user(data: web::Json) -> Result { @@ -143,25 +218,61 @@ async fn add_user(data: web::Json) -> Result } } -/// curl -X GET http://127.0.0.1:8080/api/settings/1 -H "Authorization: Bearer " +/// #### ffpapi Settings +/// +/// **Get Settings** +/// +/// ```BASH +/// curl -X GET http://127.0.0.1:8000/api/settings/1 -H "Authorization: Bearer " +/// ``` +/// +/// **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) -> Result { 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 " +/// ``` +#[get("/settings")] +#[has_any_role("Role::Admin", type = "Role")] +async fn get_all_settings() -> Result { + 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 " +/// ``` #[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: ' +/// #### ffplayout Config +/// +/// **Get Config** +/// +/// ```BASH +/// curl -X GET http://localhost:8000/api/playout/config/1 -H 'Authorization: ' +/// ``` +/// +/// 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 { } --header 'Authorization: ' +/// **Update Config** +/// +/// ```BASH +/// curl -X PUT http://localhost:8000/api/playout/config/1 -H "Content-Type: application/json" \ +/// -d { } -H 'Authorization: ' +/// ``` #[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": "", "password": ""}' --header 'Authorization: ' -#[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: ' +/// ``` +#[get("/presets/{id}")] #[has_any_role("Role::Admin", "Role::User", type = "Role")] -async fn get_presets() -> Result { - if let Ok(presets) = db_get_presets().await { +async fn get_presets(id: web::Path) -> Result { + 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": "", "text": "", "x": "", "y": "", "fontsize": 24, \ -/// "line_spacing": 4, "fontcolor": "#ffffff", "box": 1, "boxcolor": "#000000", "boxborderw": 4, "alpha": 1.0}' \ -/// --header 'Authorization: ' +/// **Update Preset** +/// +/// ```BASH +/// curl -X PUT http://localhost:8000/api/presets/1 -H 'Content-Type: application/json' \ +/// -d '{ "name": "", "text": "", "x": "", "y": "", "fontsize": 24, \ +/// "line_spacing": 4, "fontcolor": "#ffffff", "box": 1, "boxcolor": "#000000", "boxborderw": 4, "alpha": 1.0, "channel_id": 1 }' \ +/// -H 'Authorization: ' +/// ``` #[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": "", "text": "TEXT>", "x": "", "y": "", "fontsize": 24, \ -/// "line_spacing": 4, "fontcolor": "#ffffff", "box": 1, "boxcolor": "#000000", "boxborderw": 4, "alpha": 1.0}}' \ -/// --header 'Authorization: ' +/// **Add new Preset** +/// +/// ```BASH +/// curl -X POST http://localhost:8000/api/presets/ -H 'Content-Type: application/json' \ +/// -d '{ "name": "", "text": "TEXT>", "x": "", "y": "", "fontsize": 24, \ +/// "line_spacing": 4, "fontcolor": "#ffffff", "box": 1, "boxcolor": "#000000", "boxborderw": 4, "alpha": 1.0, "channel_id": 1 }' \ +/// -H 'Authorization: ' +/// ``` #[post("/presets/")] #[has_any_role("Role::Admin", "Role::User", type = "Role")] async fn add_preset(data: web::Json) -> Result { @@ -259,21 +398,39 @@ async fn add_preset(data: web::Json) -> Result' +/// ``` +#[delete("/presets/{id}")] +#[has_any_role("Role::Admin", "Role::User", type = "Role")] +async fn delete_preset(id: web::Path) -> Result { + 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: ' \ -/// --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: ' \ +/// -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: ' -#[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: ' +/// ``` +#[post("/control/{id}/playout/")] #[has_any_role("Role::Admin", "Role::User", type = "Role")] -pub async fn jump_to_next(id: web::Path) -> Result { - match control_state(*id, "next".into()).await { +pub async fn control_playout(id: web::Path, control: web::Json) -> Result { + 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: ' -#[post("/control/{id}/playout/back/")] -#[has_any_role("Role::Admin", "Role::User", type = "Role")] -pub async fn jump_to_last(id: web::Path) -> Result { - 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: ' -#[post("/control/{id}/playout/reset/")] -#[has_any_role("Role::Admin", "Role::User", type = "Role")] -pub async fn reset_playout(id: web::Path) -> Result { - 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: ' +/// **Get current Clip** +/// +/// ```BASH +/// curl -X GET http://localhost:8000/api/control/1/media/current +/// -H 'Content-Type: application/json' -H 'Authorization: ' +/// ``` +/// +/// **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) -> Result { @@ -330,8 +501,11 @@ pub async fn media_current(id: web::Path) -> Result' +/// **Get next Clip** +/// +/// ```BASH +/// curl -X GET http://localhost:8000/api/control/1/media/next/ -H 'Authorization: ' +/// ``` #[get("/control/{id}/media/next")] #[has_any_role("Role::Admin", "Role::User", type = "Role")] pub async fn media_next(id: web::Path) -> Result { @@ -341,8 +515,12 @@ pub async fn media_next(id: web::Path) -> Result' +/// **Get last Clip** +/// +/// ```BASH +/// curl -X GET http://localhost:8000/api/control/1/media/last/ +/// -H 'Content-Type: application/json' -H 'Authorization: ' +/// ``` #[get("/control/{id}/media/last")] #[has_any_role("Role::Admin", "Role::User", type = "Role")] pub async fn media_last(id: web::Path) -> Result { @@ -352,9 +530,19 @@ pub async fn media_last(id: web::Path) -> Result' +/// #### 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: ' /// -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: ' -#[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: ' +/// ``` +#[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, + obj: web::Query, ) -> Result { - 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: ' +/// **Save playlist** +/// +/// ```BASH +/// curl -X POST http://localhost:8000/api/playlist/1/ +/// -H 'Content-Type: application/json' -H 'Authorization: ' /// -- 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: ' +/// **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: ' +/// ``` #[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: ' +/// **Delete Playlist** +/// +/// ```BASH +/// curl -X DELETE http://localhost:8000/api/playlist/1/2022-06-20 +/// -H 'Content-Type: application/json' -H 'Authorization: ' +/// ``` #[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: ' +/// ``` +#[get("/log/{id}")] +#[has_any_role("Role::Admin", "Role::User", type = "Role")] +pub async fn get_log( + id: web::Path, + log: web::Query, +) -> Result { + read_log_file(&id, &log.date).await +} -/// curl -X GET http://localhost:8080/api/file/1/browse/ -/// --header 'Content-Type: application/json' --header 'Authorization: ' +/// ### 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: ' +/// ``` #[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: ' -/// -d '{"source": "", "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": ""}' -H 'Authorization: ' +/// ``` +#[post("/file/{id}/create-folder/")] +#[has_any_role("Role::Admin", "Role::User", type = "Role")] +pub async fn add_dir( + id: web::Path, + data: web::Json, +) -> Result { + 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": "", "target": ""}' -H 'Authorization: ' +/// ``` +#[post("/file/{id}/rename/")] #[has_any_role("Role::Admin", "Role::User", type = "Role")] pub async fn move_rename( id: web::Path, @@ -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: ' -/// -d '{"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": ""}' -H 'Authorization: ' +/// ``` +#[post("/file/{id}/remove/")] #[has_any_role("Role::Admin", "Role::User", type = "Role")] pub async fn remove( id: web::Path, @@ -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: ' +/// -F "file=@file.mp4" +/// ``` +#[put("/file/{id}/upload/")] #[has_any_role("Role::Admin", "Role::User", type = "Role")] -async fn save_file(id: web::Path, payload: Multipart) -> Result { - upload(*id, payload).await +async fn save_file( + id: web::Path, + payload: Multipart, + obj: web::Query, +) -> Result { + upload(*id, payload, &obj.path).await } diff --git a/ffplayout-engine/src/main.rs b/ffplayout-engine/src/main.rs index 4e82e1d5..9d2b835f 100644 --- a/ffplayout-engine/src/main.rs +++ b/ffplayout-engine/src/main.rs @@ -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); }; diff --git a/ffplayout-engine/src/output/hls.rs b/ffplayout-engine/src/output/hls.rs index c583625d..9f9d7ede 100644 --- a/ffplayout-engine/src/output/hls.rs +++ b/ffplayout-engine/src/output/hls.rs @@ -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)); diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 93f4ad70..57fe07cf 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -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" diff --git a/lib/src/filter/mod.rs b/lib/src/filter/mod.rs index c2878839..b4722b48 100644 --- a/lib/src/filter/mod.rs +++ b/lib/src/filter/mod.rs @@ -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: {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; diff --git a/lib/src/utils/generator.rs b/lib/src/utils/generator.rs index c81bcb20..4baf6a25 100644 --- a/lib/src/utils/generator.rs +++ b/lib/src/utils/generator.rs @@ -53,7 +53,6 @@ fn get_date_range(date_range: &[String]) -> Vec { /// Generate playlists pub fn generate_playlist( config: &PlayoutConfig, - mut date_range: Vec, channel_name: Option, ) -> Result, 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) } diff --git a/lib/src/utils/logging.rs b/lib/src/utils/logging.rs index 035faf43..b2f74f60 100644 --- a/lib/src/utils/logging.rs +++ b/lib/src/utils/logging.rs @@ -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!"); diff --git a/lib/src/utils/mod.rs b/lib/src/utils/mod.rs index 03be3040..df468eff 100644 --- a/lib/src/utils/mod.rs +++ b/lib/src/utils/mod.rs @@ -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![]; diff --git a/build_all.sh b/scripts/build_all.sh similarity index 100% rename from build_all.sh rename to scripts/build_all.sh diff --git a/scripts/gen_doc.sh b/scripts/gen_doc.sh new file mode 100755 index 00000000..01024f5c --- /dev/null +++ b/scripts/gen_doc.sh @@ -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"