Merge pull request #206 from jb-alvarado/master

v0.16.0
This commit is contained in:
jb-alvarado 2022-10-14 14:25:07 +02:00 committed by GitHub
commit 0d5d47e8b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
68 changed files with 25057 additions and 933 deletions

View File

@ -8,10 +8,22 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- run: rustup update stable - name: On all Systems
- run: rustup component add rustfmt run: |
- run: rustup component add clippy rustup update stable
- run: cargo test --all-features rustup component add rustfmt
- run: cargo fmt --all -- --check rustup component add clippy
- run: cargo clippy --all-features --all-targets -- --deny warnings
- run: cargo build --all-features - name: Use ffmpeg on Linux
if: ${{ matrix.os == 'ubuntu-latest' }}
uses: FedericoCarboni/setup-ffmpeg@v1
- name: Tests on Linux
if: ${{ matrix.os == 'ubuntu-latest' }}
run: |
cargo test --all-features
cargo clippy --all-features --all-targets -- --deny warnings
cargo fmt --all -- --check
- name: Run build on all Systems
run: cargo build --all-features

469
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -46,11 +46,15 @@ Check the [releases](https://github.com/ffplayout/ffplayout/releases/latest) for
- JSON RPC server, for getting infos about current playing and controlling - JSON RPC server, for getting infos about current playing and controlling
- [live ingest](/docs/live_ingest.md) - [live ingest](/docs/live_ingest.md)
- image source (will loop until out duration is reached) - image source (will loop until out duration is reached)
- extra audio source (experimental) (has priority over audio from video source) - extra audio source (experimental *) (has priority over audio from video source)
- [multiple audio tracks](/docs/multi_audio.md) (experimental *)
- [custom filter](/docs/custom_filters.md) globally in config, or in playlist for specific clips - [custom filter](/docs/custom_filters.md) globally in config, or in playlist for specific clips
- import playlist from text or m3u file, with CLI or frontend
For preview stream, read: [/docs/preview_stream.md](/docs/preview_stream.md) For preview stream, read: [/docs/preview_stream.md](/docs/preview_stream.md)
**\* Experimental functions do not guarantee the same stability, also they can fail in unusual circumstances. The code and configuration options may change in the future.**
## **ffplayout-api (ffpapi)** ## **ffplayout-api (ffpapi)**
ffpapi serves the [frontend](https://github.com/ffplayout/ffplayout-frontend) and it acts as a [REST API](/ffplayout-api/README.md) for controlling the engine, manipulate playlists, add settings etc. ffpapi serves the [frontend](https://github.com/ffplayout/ffplayout-frontend) and it acts as a [REST API](/ffplayout-api/README.md) for controlling the engine, manipulate playlists, add settings etc.

View File

@ -47,7 +47,8 @@ processing:
or folder. 'aspect' must be a float number. 'logo' is only used if the path exist. or folder. 'aspect' must be a float number. 'logo' is only used if the path exist.
'logo_scale' scale the logo to target size, leave it blank when no scaling 'logo_scale' scale the logo to target size, leave it blank when no scaling
is needed, format is 'number:number', for example '100:-1' for proportional is needed, format is 'number:number', for example '100:-1' for proportional
scaling. With 'logo_opacity' logo can become transparent. With 'logo_filter' scaling. With 'logo_opacity' logo can become transparent. With 'audio_tracks' it
is possible to configure how many audio tracks should be processed. With 'logo_filter'
'overlay=W-w-12:12' you can modify the logo position. With 'use_loudnorm' 'overlay=W-w-12:12' you can modify the logo position. With 'use_loudnorm'
you can activate single pass EBU R128 loudness normalization. 'loud_*' can you can activate single pass EBU R128 loudness normalization. 'loud_*' can
adjust the loudnorm filter. With 'custom_filter' it is possible, to apply further adjust the loudnorm filter. With 'custom_filter' it is possible, to apply further
@ -63,6 +64,7 @@ processing:
logo_scale: logo_scale:
logo_opacity: 0.7 logo_opacity: 0.7
logo_filter: overlay=W-w-12:12 logo_filter: overlay=W-w-12:12
audio_tracks: 1
add_loudnorm: false add_loudnorm: false
loudnorm_ingest: false loudnorm_ingest: false
loud_i: -18 loud_i: -18
@ -120,7 +122,7 @@ out:
has the options 'desktop', 'hls', 'null', 'stream'. Use 'stream' and adjust has the options 'desktop', 'hls', 'null', 'stream'. Use 'stream' and adjust
'output_param:' settings when you want to stream to a rtmp/rtsp/srt/... server. 'output_param:' settings when you want to stream to a rtmp/rtsp/srt/... server.
In production don't serve hls playlist with ffpapi, use nginx or another web server! In production don't serve hls playlist with ffpapi, use nginx or another web server!
mode: desktop mode: hls
output_param: >- output_param: >-
-c:v libx264 -c:v libx264
-crf 23 -crf 23

View File

@ -21,6 +21,10 @@ Using live ingest to inject a live stream.
The different output modes. The different output modes.
### **[Multi Audio Tracks](/docs/multi_audio.md)**
Output multiple audio tracks.
### **[Custom Filter](/docs/custom_filters.md)** ### **[Custom Filter](/docs/custom_filters.md)**
Apply self defined audio/video filters. Apply self defined audio/video filters.

View File

@ -324,3 +324,14 @@ curl -X POST http://127.0.0.1:8787/api/file/1/remove/ -H 'Content-Type: applicat
curl -X POST http://127.0.0.1:8787/api/file/1/upload/ -H 'Authorization: <TOKEN>' curl -X POST http://127.0.0.1:8787/api/file/1/upload/ -H 'Authorization: <TOKEN>'
-F "file=@file.mp4" -F "file=@file.mp4"
``` ```
**Import playlist**
Import text/m3u file and convert it to a playlist
lines with leading "#" will be ignore
```BASH
curl -X POST http://127.0.0.1:8787/api/file/1/import/ -H 'Authorization: <TOKEN>'
-F "file=@list.m3u"
```

73
docs/multi_audio.md Normal file
View File

@ -0,0 +1,73 @@
## Multiple Audio Tracks
**\* This is an experimental feature and more intended for advanced users. Use it with caution!**
With _ffplayout_ you can output streams with multiple audio tracks, with some limitations:
* Not all formats support multiple audio tracks. For example _flv/rtmp_ doesn't support it.
* In your output parameters you need to set the correct mapping.
ffmpeg filter usage and encoding parameters can become very complex, so it can happen that not every combination works out of the box.
To get e better idea of what works, you can examine [engin_cmd](../tests/src/engine_cmd.rs).
If you just output a single video stream with multiple audio tracks, let's say with `srt://` protocol, you only need to set in you config under `processing:` the correct `audio_tracks:` count.
For multiple video resolutions and multiple audio tracks, the parameters could look like:
```YAML
out:
...
mode: stream
output_param: >-
-map 0:v
-map 0:a:0
-map 0:a:1
-c:v libx264
-c:a aac
-ar 44100
-b:a 128k
-flags +global_header
-f mpegts
srt://127.0.0.1:40051
-map 0:v
-map 0:a:0
-map 0:a:1
-s 512x288
-c:v libx264
-c:a aac
-ar 44100
-b:a 128k
-flags +global_header
-f mpegts
srt://127.0.0.1:40052
```
If you need HLS output with multiple resolutions and audio tracks, you can try something like:
```YAML
out:
...
mode: hls
output_param: >-
-filter_complex [0:v]split=2[v1_out][v2];[v2]scale=w=512:h=288[v2_out];[0:a:0]asplit=2[a_0_1][a_0_2];[0:a:1]asplit=2[a_1_1][a_1_2]
-map [v1_out]
-map [a_0_1]
-map [a_1_1]
-c:v libx264
-flags +cgop
-c:a aac
-map [v2_out]
-map [a_0_2]
-map [a_1_2]
-c:v:1 libx264
-flags +cgop
-c:a:1 aac
-f hls
-hls_time 6
-hls_list_size 600
-hls_flags append_list+delete_segments+omit_endlist
-hls_segment_filename /usr/share/ffplayout/public/live/stream_%v-%d.ts
-master_pl_name master.m3u8
-var_stream_map "v:0,a:0,a:1,name:720p v:1,a:2,a:3,name:288p"
/usr/share/ffplayout/public/live/stream_%v.m3u8
```

View File

@ -126,7 +126,7 @@ HLS output is currently the default, mostly because it works out of the box and
-f hls -f hls
-hls_time 6 -hls_time 6
-hls_list_size 600 -hls_list_size 600
-hls_flags append_list+delete_segments+omit_endlist+program_date_time -hls_flags append_list+delete_segments+omit_endlist
-hls_segment_filename /var/www/html/live/stream_%v-%d.ts -hls_segment_filename /var/www/html/live/stream_%v-%d.ts
-master_pl_name master.m3u8 -master_pl_name master.m3u8
-var_stream_map "v:0,a:0,name:720p v:1,a:1,name:540p v:2,a:2,name:360p" -var_stream_map "v:0,a:0,name:720p v:1,a:1,name:540p v:2,a:2,name:360p"

View File

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

View File

@ -11,14 +11,14 @@ const JWT_EXPIRATION_DAYS: i64 = 7;
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
pub struct Claims { pub struct Claims {
pub id: i64, pub id: i32,
pub username: String, pub username: String,
pub role: String, pub role: String,
exp: i64, exp: i64,
} }
impl Claims { impl Claims {
pub fn new(id: i64, username: String, role: String) -> Self { pub fn new(id: i32, username: String, role: String) -> Self {
Self { Self {
id, id,
username, username,

View File

@ -0,0 +1,2 @@
pub mod auth;
pub mod routes;

View File

@ -8,7 +8,7 @@
/// ///
/// For all endpoints an (Bearer) authentication is required.\ /// For all endpoints an (Bearer) authentication is required.\
/// `{id}` represent the channel id, and at default is 1. /// `{id}` represent the channel id, and at default is 1.
use std::collections::HashMap; use std::{collections::HashMap, env, fs, path::Path};
use actix_multipart::Multipart; use actix_multipart::Multipart;
use actix_web::{delete, get, http::StatusCode, patch, post, put, web, HttpResponse, Responder}; use actix_web::{delete, get, http::StatusCode, patch, post, put, web, HttpResponse, Responder};
@ -20,8 +20,12 @@ use argon2::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use simplelog::*; use simplelog::*;
use crate::auth::{create_jwt, Claims};
use crate::db::{
handles,
models::{Channel, LoginUser, TextPreset, User},
};
use crate::utils::{ use crate::utils::{
auth::{create_jwt, Claims},
channels::{create_channel, delete_channel}, channels::{create_channel, delete_channel},
control::{control_service, control_state, media_info, send_message, Process}, control::{control_service, control_state, media_info, send_message, Process},
errors::ServiceError, errors::ServiceError,
@ -29,16 +33,10 @@ use crate::utils::{
browser, create_directory, remove_file_or_folder, rename_file, upload, MoveObject, browser, create_directory, remove_file_or_folder, rename_file, upload, MoveObject,
PathObject, PathObject,
}, },
handles::{
db_add_preset, db_add_user, db_delete_preset, db_get_all_channels, db_get_channel,
db_get_presets, db_get_user, db_login, db_role, db_update_channel, db_update_preset,
db_update_user,
},
models::{Channel, LoginUser, TextPreset, User},
playlist::{delete_playlist, generate_playlist, read_playlist, write_playlist}, playlist::{delete_playlist, generate_playlist, read_playlist, write_playlist},
read_log_file, read_playout_config, Role, playout_config, read_log_file, read_playout_config, Role,
}; };
use ffplayout_lib::utils::{JsonPlaylist, PlayoutConfig}; use ffplayout_lib::utils::{import::import_file, JsonPlaylist, PlayoutConfig};
#[derive(Serialize)] #[derive(Serialize)]
struct ResponseObj<T> { struct ResponseObj<T> {
@ -60,11 +58,19 @@ pub struct DateObj {
} }
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
pub struct FileObj { struct FileObj {
#[serde(default)] #[serde(default)]
path: String, path: String,
} }
#[derive(Debug, Deserialize, Serialize)]
pub struct ImportObj {
#[serde(default)]
file: String,
#[serde(default)]
date: String,
}
/// #### User Handling /// #### User Handling
/// ///
/// **Login** /// **Login**
@ -85,7 +91,7 @@ pub struct FileObj {
/// ``` /// ```
#[post("/auth/login/")] #[post("/auth/login/")]
pub async fn login(credentials: web::Json<User>) -> impl Responder { pub async fn login(credentials: web::Json<User>) -> impl Responder {
match db_login(&credentials.username).await { match handles::select_login(&credentials.username).await {
Ok(mut user) => { Ok(mut user) => {
let pass = user.password.clone(); let pass = user.password.clone();
let hash = PasswordHash::new(&pass).unwrap(); let hash = PasswordHash::new(&pass).unwrap();
@ -96,7 +102,7 @@ pub async fn login(credentials: web::Json<User>) -> impl Responder {
.verify_password(credentials.password.as_bytes(), &hash) .verify_password(credentials.password.as_bytes(), &hash)
.is_ok() .is_ok()
{ {
let role = db_role(&user.role_id.unwrap_or_default()) let role = handles::select_role(&user.role_id.unwrap_or_default())
.await .await
.unwrap_or_else(|_| "guest".to_string()); .unwrap_or_else(|_| "guest".to_string());
let claims = Claims::new(user.id, user.username.clone(), role.clone()); let claims = Claims::new(user.id, user.username.clone(), role.clone());
@ -147,7 +153,7 @@ pub async fn login(credentials: web::Json<User>) -> impl Responder {
#[get("/user")] #[get("/user")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
async fn get_user(user: web::ReqData<LoginUser>) -> Result<impl Responder, ServiceError> { async fn get_user(user: web::ReqData<LoginUser>) -> Result<impl Responder, ServiceError> {
match db_get_user(&user.username).await { match handles::select_user(&user.username).await {
Ok(user) => Ok(web::Json(user)), Ok(user) => Ok(web::Json(user)),
Err(e) => { Err(e) => {
error!("{e}"); error!("{e}");
@ -165,7 +171,7 @@ async fn get_user(user: web::ReqData<LoginUser>) -> Result<impl Responder, Servi
#[put("/user/{id}")] #[put("/user/{id}")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
async fn update_user( async fn update_user(
id: web::Path<i64>, id: web::Path<i32>,
user: web::ReqData<LoginUser>, user: web::ReqData<LoginUser>,
data: web::Json<User>, data: web::Json<User>,
) -> Result<impl Responder, ServiceError> { ) -> Result<impl Responder, ServiceError> {
@ -189,7 +195,7 @@ async fn update_user(
fields.push_str(format!("password = '{}', salt = '{salt}'", password_hash).as_str()); fields.push_str(format!("password = '{}', salt = '{salt}'", password_hash).as_str());
} }
if db_update_user(user.id, fields).await.is_ok() { if handles::update_user(user.id, fields).await.is_ok() {
return Ok("Update Success"); return Ok("Update Success");
}; };
@ -209,7 +215,7 @@ async fn update_user(
#[post("/user/")] #[post("/user/")]
#[has_any_role("Role::Admin", type = "Role")] #[has_any_role("Role::Admin", type = "Role")]
async fn add_user(data: web::Json<User>) -> Result<impl Responder, ServiceError> { async fn add_user(data: web::Json<User>) -> Result<impl Responder, ServiceError> {
match db_add_user(data.into_inner()).await { match handles::insert_user(data.into_inner()).await {
Ok(_) => Ok("Add User Success"), Ok(_) => Ok("Add User Success"),
Err(e) => { Err(e) => {
error!("{e}"); error!("{e}");
@ -241,8 +247,8 @@ async fn add_user(data: web::Json<User>) -> Result<impl Responder, ServiceError>
/// ``` /// ```
#[get("/channel/{id}")] #[get("/channel/{id}")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
async fn get_channel(id: web::Path<i64>) -> Result<impl Responder, ServiceError> { async fn get_channel(id: web::Path<i32>) -> Result<impl Responder, ServiceError> {
if let Ok(channel) = db_get_channel(&id).await { if let Ok(channel) = handles::select_channel(&id).await {
return Ok(web::Json(channel)); return Ok(web::Json(channel));
} }
@ -257,7 +263,7 @@ async fn get_channel(id: web::Path<i64>) -> Result<impl Responder, ServiceError>
#[get("/channels")] #[get("/channels")]
#[has_any_role("Role::Admin", type = "Role")] #[has_any_role("Role::Admin", type = "Role")]
async fn get_all_channels() -> Result<impl Responder, ServiceError> { async fn get_all_channels() -> Result<impl Responder, ServiceError> {
if let Ok(channel) = db_get_all_channels().await { if let Ok(channel) = handles::select_all_channels().await {
return Ok(web::Json(channel)); return Ok(web::Json(channel));
} }
@ -275,10 +281,13 @@ async fn get_all_channels() -> Result<impl Responder, ServiceError> {
#[patch("/channel/{id}")] #[patch("/channel/{id}")]
#[has_any_role("Role::Admin", type = "Role")] #[has_any_role("Role::Admin", type = "Role")]
async fn patch_channel( async fn patch_channel(
id: web::Path<i64>, id: web::Path<i32>,
data: web::Json<Channel>, data: web::Json<Channel>,
) -> Result<impl Responder, ServiceError> { ) -> Result<impl Responder, ServiceError> {
if db_update_channel(*id, data.into_inner()).await.is_ok() { if handles::update_channel(*id, data.into_inner())
.await
.is_ok()
{
return Ok("Update Success"); return Ok("Update Success");
}; };
@ -310,7 +319,7 @@ async fn add_channel(data: web::Json<Channel>) -> Result<impl Responder, Service
/// ``` /// ```
#[delete("/channel/{id}")] #[delete("/channel/{id}")]
#[has_any_role("Role::Admin", type = "Role")] #[has_any_role("Role::Admin", type = "Role")]
async fn remove_channel(id: web::Path<i64>) -> Result<impl Responder, ServiceError> { async fn remove_channel(id: web::Path<i32>) -> Result<impl Responder, ServiceError> {
if delete_channel(*id).await.is_ok() { if delete_channel(*id).await.is_ok() {
return Ok("Delete Channel Success"); return Ok("Delete Channel Success");
} }
@ -330,10 +339,10 @@ async fn remove_channel(id: web::Path<i64>) -> Result<impl Responder, ServiceErr
#[get("/playout/config/{id}")] #[get("/playout/config/{id}")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
async fn get_playout_config( async fn get_playout_config(
id: web::Path<i64>, id: web::Path<i32>,
_details: AuthDetails<Role>, _details: AuthDetails<Role>,
) -> Result<impl Responder, ServiceError> { ) -> Result<impl Responder, ServiceError> {
if let Ok(channel) = db_get_channel(&id).await { if let Ok(channel) = handles::select_channel(&id).await {
if let Ok(config) = read_playout_config(&channel.config_path) { if let Ok(config) = read_playout_config(&channel.config_path) {
return Ok(web::Json(config)); return Ok(web::Json(config));
} }
@ -351,10 +360,10 @@ async fn get_playout_config(
#[put("/playout/config/{id}")] #[put("/playout/config/{id}")]
#[has_any_role("Role::Admin", type = "Role")] #[has_any_role("Role::Admin", type = "Role")]
async fn update_playout_config( async fn update_playout_config(
id: web::Path<i64>, id: web::Path<i32>,
data: web::Json<PlayoutConfig>, data: web::Json<PlayoutConfig>,
) -> Result<impl Responder, ServiceError> { ) -> Result<impl Responder, ServiceError> {
if let Ok(channel) = db_get_channel(&id).await { if let Ok(channel) = handles::select_channel(&id).await {
if let Ok(f) = std::fs::OpenOptions::new() if let Ok(f) = std::fs::OpenOptions::new()
.write(true) .write(true)
.truncate(true) .truncate(true)
@ -383,8 +392,8 @@ async fn update_playout_config(
/// ``` /// ```
#[get("/presets/{id}")] #[get("/presets/{id}")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
async fn get_presets(id: web::Path<i64>) -> Result<impl Responder, ServiceError> { async fn get_presets(id: web::Path<i32>) -> Result<impl Responder, ServiceError> {
if let Ok(presets) = db_get_presets(*id).await { if let Ok(presets) = handles::select_presets(*id).await {
return Ok(web::Json(presets)); return Ok(web::Json(presets));
} }
@ -402,10 +411,10 @@ async fn get_presets(id: web::Path<i64>) -> Result<impl Responder, ServiceError>
#[put("/presets/{id}")] #[put("/presets/{id}")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
async fn update_preset( async fn update_preset(
id: web::Path<i64>, id: web::Path<i32>,
data: web::Json<TextPreset>, data: web::Json<TextPreset>,
) -> Result<impl Responder, ServiceError> { ) -> Result<impl Responder, ServiceError> {
if db_update_preset(&id, data.into_inner()).await.is_ok() { if handles::update_preset(&id, data.into_inner()).await.is_ok() {
return Ok("Update Success"); return Ok("Update Success");
} }
@ -423,7 +432,7 @@ async fn update_preset(
#[post("/presets/")] #[post("/presets/")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
async fn add_preset(data: web::Json<TextPreset>) -> Result<impl Responder, ServiceError> { async fn add_preset(data: web::Json<TextPreset>) -> Result<impl Responder, ServiceError> {
if db_add_preset(data.into_inner()).await.is_ok() { if handles::insert_preset(data.into_inner()).await.is_ok() {
return Ok("Add preset Success"); return Ok("Add preset Success");
} }
@ -438,8 +447,8 @@ async fn add_preset(data: web::Json<TextPreset>) -> Result<impl Responder, Servi
/// ``` /// ```
#[delete("/presets/{id}")] #[delete("/presets/{id}")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
async fn delete_preset(id: web::Path<i64>) -> Result<impl Responder, ServiceError> { async fn delete_preset(id: web::Path<i32>) -> Result<impl Responder, ServiceError> {
if db_delete_preset(&id).await.is_ok() { if handles::delete_preset(&id).await.is_ok() {
return Ok("Delete preset Success"); return Ok("Delete preset Success");
} }
@ -466,7 +475,7 @@ async fn delete_preset(id: web::Path<i64>) -> Result<impl Responder, ServiceErro
#[post("/control/{id}/text/")] #[post("/control/{id}/text/")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn send_text_message( pub async fn send_text_message(
id: web::Path<i64>, id: web::Path<i32>,
data: web::Json<HashMap<String, String>>, data: web::Json<HashMap<String, String>>,
) -> Result<impl Responder, ServiceError> { ) -> Result<impl Responder, ServiceError> {
match send_message(*id, data.into_inner()).await { match send_message(*id, data.into_inner()).await {
@ -488,7 +497,7 @@ pub async fn send_text_message(
#[post("/control/{id}/playout/")] #[post("/control/{id}/playout/")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn control_playout( pub async fn control_playout(
id: web::Path<i64>, id: web::Path<i32>,
control: web::Json<Process>, control: web::Json<Process>,
) -> Result<impl Responder, ServiceError> { ) -> Result<impl Responder, ServiceError> {
match control_state(*id, control.command.clone()).await { match control_state(*id, control.command.clone()).await {
@ -529,7 +538,7 @@ pub async fn control_playout(
/// ``` /// ```
#[get("/control/{id}/media/current")] #[get("/control/{id}/media/current")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn media_current(id: web::Path<i64>) -> Result<impl Responder, ServiceError> { pub async fn media_current(id: web::Path<i32>) -> Result<impl Responder, ServiceError> {
match media_info(*id, "current".into()).await { match media_info(*id, "current".into()).await {
Ok(res) => Ok(res.text().await.unwrap_or_else(|_| "Success".into())), Ok(res) => Ok(res.text().await.unwrap_or_else(|_| "Success".into())),
Err(e) => Err(e), Err(e) => Err(e),
@ -543,7 +552,7 @@ pub async fn media_current(id: web::Path<i64>) -> Result<impl Responder, Service
/// ``` /// ```
#[get("/control/{id}/media/next")] #[get("/control/{id}/media/next")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn media_next(id: web::Path<i64>) -> Result<impl Responder, ServiceError> { pub async fn media_next(id: web::Path<i32>) -> Result<impl Responder, ServiceError> {
match media_info(*id, "next".into()).await { match media_info(*id, "next".into()).await {
Ok(res) => Ok(res.text().await.unwrap_or_else(|_| "Success".into())), Ok(res) => Ok(res.text().await.unwrap_or_else(|_| "Success".into())),
Err(e) => Err(e), Err(e) => Err(e),
@ -558,7 +567,7 @@ pub async fn media_next(id: web::Path<i64>) -> Result<impl Responder, ServiceErr
/// ``` /// ```
#[get("/control/{id}/media/last")] #[get("/control/{id}/media/last")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn media_last(id: web::Path<i64>) -> Result<impl Responder, ServiceError> { pub async fn media_last(id: web::Path<i32>) -> Result<impl Responder, ServiceError> {
match media_info(*id, "last".into()).await { match media_info(*id, "last".into()).await {
Ok(res) => Ok(res.text().await.unwrap_or_else(|_| "Success".into())), Ok(res) => Ok(res.text().await.unwrap_or_else(|_| "Success".into())),
Err(e) => Err(e), Err(e) => Err(e),
@ -581,7 +590,7 @@ pub async fn media_last(id: web::Path<i64>) -> Result<impl Responder, ServiceErr
#[post("/control/{id}/process/")] #[post("/control/{id}/process/")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn process_control( pub async fn process_control(
id: web::Path<i64>, id: web::Path<i32>,
proc: web::Json<Process>, proc: web::Json<Process>,
) -> Result<impl Responder, ServiceError> { ) -> Result<impl Responder, ServiceError> {
control_service(*id, &proc.command).await control_service(*id, &proc.command).await
@ -598,7 +607,7 @@ pub async fn process_control(
#[get("/playlist/{id}")] #[get("/playlist/{id}")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn get_playlist( pub async fn get_playlist(
id: web::Path<i64>, id: web::Path<i32>,
obj: web::Query<DateObj>, obj: web::Query<DateObj>,
) -> Result<impl Responder, ServiceError> { ) -> Result<impl Responder, ServiceError> {
match read_playlist(*id, obj.date.clone()).await { match read_playlist(*id, obj.date.clone()).await {
@ -617,7 +626,7 @@ pub async fn get_playlist(
#[post("/playlist/{id}/")] #[post("/playlist/{id}/")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn save_playlist( pub async fn save_playlist(
id: web::Path<i64>, id: web::Path<i32>,
data: web::Json<JsonPlaylist>, data: web::Json<JsonPlaylist>,
) -> Result<impl Responder, ServiceError> { ) -> Result<impl Responder, ServiceError> {
match write_playlist(*id, data.into_inner()).await { match write_playlist(*id, data.into_inner()).await {
@ -637,7 +646,7 @@ pub async fn save_playlist(
#[get("/playlist/{id}/generate/{date}")] #[get("/playlist/{id}/generate/{date}")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn gen_playlist( pub async fn gen_playlist(
params: web::Path<(i64, String)>, params: web::Path<(i32, String)>,
) -> Result<impl Responder, ServiceError> { ) -> Result<impl Responder, ServiceError> {
match generate_playlist(params.0, params.1.clone()).await { match generate_playlist(params.0, params.1.clone()).await {
Ok(playlist) => Ok(web::Json(playlist)), Ok(playlist) => Ok(web::Json(playlist)),
@ -654,7 +663,7 @@ pub async fn gen_playlist(
#[delete("/playlist/{id}/{date}")] #[delete("/playlist/{id}/{date}")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn del_playlist( pub async fn del_playlist(
params: web::Path<(i64, String)>, params: web::Path<(i32, String)>,
) -> Result<impl Responder, ServiceError> { ) -> Result<impl Responder, ServiceError> {
match delete_playlist(params.0, &params.1).await { match delete_playlist(params.0, &params.1).await {
Ok(_) => Ok(format!("Delete playlist from {} success!", params.1)), Ok(_) => Ok(format!("Delete playlist from {} success!", params.1)),
@ -673,7 +682,7 @@ pub async fn del_playlist(
#[get("/log/{id}")] #[get("/log/{id}")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn get_log( pub async fn get_log(
id: web::Path<i64>, id: web::Path<i32>,
log: web::Query<DateObj>, log: web::Query<DateObj>,
) -> Result<impl Responder, ServiceError> { ) -> Result<impl Responder, ServiceError> {
read_log_file(&id, &log.date).await read_log_file(&id, &log.date).await
@ -690,7 +699,7 @@ pub async fn get_log(
#[post("/file/{id}/browse/")] #[post("/file/{id}/browse/")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn file_browser( pub async fn file_browser(
id: web::Path<i64>, id: web::Path<i32>,
data: web::Json<PathObject>, data: web::Json<PathObject>,
) -> Result<impl Responder, ServiceError> { ) -> Result<impl Responder, ServiceError> {
match browser(*id, &data.into_inner()).await { match browser(*id, &data.into_inner()).await {
@ -708,7 +717,7 @@ pub async fn file_browser(
#[post("/file/{id}/create-folder/")] #[post("/file/{id}/create-folder/")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn add_dir( pub async fn add_dir(
id: web::Path<i64>, id: web::Path<i32>,
data: web::Json<PathObject>, data: web::Json<PathObject>,
) -> Result<HttpResponse, ServiceError> { ) -> Result<HttpResponse, ServiceError> {
create_directory(*id, &data.into_inner()).await create_directory(*id, &data.into_inner()).await
@ -723,7 +732,7 @@ pub async fn add_dir(
#[post("/file/{id}/rename/")] #[post("/file/{id}/rename/")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn move_rename( pub async fn move_rename(
id: web::Path<i64>, id: web::Path<i32>,
data: web::Json<MoveObject>, data: web::Json<MoveObject>,
) -> Result<impl Responder, ServiceError> { ) -> Result<impl Responder, ServiceError> {
match rename_file(*id, &data.into_inner()).await { match rename_file(*id, &data.into_inner()).await {
@ -741,7 +750,7 @@ pub async fn move_rename(
#[post("/file/{id}/remove/")] #[post("/file/{id}/remove/")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
pub async fn remove( pub async fn remove(
id: web::Path<i64>, id: web::Path<i32>,
data: web::Json<PathObject>, data: web::Json<PathObject>,
) -> Result<impl Responder, ServiceError> { ) -> Result<impl Responder, ServiceError> {
match remove_file_or_folder(*id, &data.into_inner().source).await { match remove_file_or_folder(*id, &data.into_inner().source).await {
@ -759,9 +768,38 @@ pub async fn remove(
#[put("/file/{id}/upload/")] #[put("/file/{id}/upload/")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")] #[has_any_role("Role::Admin", "Role::User", type = "Role")]
async fn save_file( async fn save_file(
id: web::Path<i64>, id: web::Path<i32>,
payload: Multipart, payload: Multipart,
obj: web::Query<FileObj>, obj: web::Query<FileObj>,
) -> Result<HttpResponse, ServiceError> { ) -> Result<HttpResponse, ServiceError> {
upload(*id, payload, &obj.path).await upload(*id, payload, &obj.path, false).await
}
/// **Import playlist**
///
/// Import text/m3u file and convert it to a playlist
/// lines with leading "#" will be ignore
///
/// ```BASH
/// curl -X POST http://127.0.0.1:8787/api/file/1/import/ -H 'Authorization: <TOKEN>'
/// -F "file=@list.m3u"
/// ```
#[put("/file/{id}/import/")]
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
async fn import_playlist(
id: web::Path<i32>,
payload: Multipart,
obj: web::Query<ImportObj>,
) -> Result<HttpResponse, ServiceError> {
let file = Path::new(&obj.file).file_name().unwrap_or_default();
let path = env::temp_dir().join(&file).to_string_lossy().to_string();
let (config, _) = playout_config(&id).await?;
let channel = handles::select_channel(&id).await?;
upload(*id, payload, &path, true).await?;
import_file(&config, &obj.date, Some(channel.name), &path)?;
fs::remove_file(path)?;
Ok(HttpResponse::Ok().into())
} }

View File

@ -7,11 +7,8 @@ use rand::{distributions::Alphanumeric, Rng};
use simplelog::*; use simplelog::*;
use sqlx::{migrate::MigrateDatabase, sqlite::SqliteQueryResult, Pool, Sqlite, SqlitePool}; use sqlx::{migrate::MigrateDatabase, sqlite::SqliteQueryResult, Pool, Sqlite, SqlitePool};
use crate::utils::{ use crate::db::models::{Channel, TextPreset, User};
db_path, local_utc_offset, use crate::utils::{db_path, local_utc_offset, GlobalSettings};
models::{Channel, TextPreset, User},
GlobalSettings,
};
#[derive(Debug, sqlx::FromRow)] #[derive(Debug, sqlx::FromRow)]
struct Role { struct Role {
@ -19,7 +16,7 @@ struct Role {
} }
async fn create_schema() -> Result<SqliteQueryResult, sqlx::Error> { async fn create_schema() -> Result<SqliteQueryResult, sqlx::Error> {
let conn = db_connection().await?; let conn = connection().await?;
let query = "PRAGMA foreign_keys = ON; let query = "PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS global CREATE TABLE IF NOT EXISTS global
( (
@ -96,7 +93,7 @@ pub async fn db_init(domain: Option<String>) -> Result<&'static str, Box<dyn std
.map(char::from) .map(char::from)
.collect(); .collect();
let instances = db_connection().await?; let instances = connection().await?;
let url = match domain { let url = match domain {
Some(d) => format!("http://{d}/live/stream.m3u8"), Some(d) => format!("http://{d}/live/stream.m3u8"),
@ -130,15 +127,15 @@ pub async fn db_init(domain: Option<String>) -> Result<&'static str, Box<dyn std
Ok("Database initialized!") Ok("Database initialized!")
} }
pub async fn db_connection() -> Result<Pool<Sqlite>, sqlx::Error> { pub async fn connection() -> Result<Pool<Sqlite>, sqlx::Error> {
let db_path = db_path().unwrap(); let db_path = db_path().unwrap();
let conn = SqlitePool::connect(&db_path).await?; let conn = SqlitePool::connect(&db_path).await?;
Ok(conn) Ok(conn)
} }
pub async fn db_global() -> Result<GlobalSettings, sqlx::Error> { pub async fn select_global() -> Result<GlobalSettings, sqlx::Error> {
let conn = db_connection().await?; let conn = connection().await?;
let query = "SELECT secret FROM global WHERE id = 1"; let query = "SELECT secret FROM global WHERE id = 1";
let result: GlobalSettings = sqlx::query_as(query).fetch_one(&conn).await?; let result: GlobalSettings = sqlx::query_as(query).fetch_one(&conn).await?;
conn.close().await; conn.close().await;
@ -146,8 +143,8 @@ pub async fn db_global() -> Result<GlobalSettings, sqlx::Error> {
Ok(result) Ok(result)
} }
pub async fn db_get_channel(id: &i64) -> Result<Channel, sqlx::Error> { pub async fn select_channel(id: &i32) -> Result<Channel, sqlx::Error> {
let conn = db_connection().await?; let conn = connection().await?;
let query = "SELECT * FROM channels WHERE id = $1"; let query = "SELECT * FROM channels WHERE id = $1";
let mut result: Channel = sqlx::query_as(query).bind(id).fetch_one(&conn).await?; let mut result: Channel = sqlx::query_as(query).bind(id).fetch_one(&conn).await?;
conn.close().await; conn.close().await;
@ -157,8 +154,8 @@ pub async fn db_get_channel(id: &i64) -> Result<Channel, sqlx::Error> {
Ok(result) Ok(result)
} }
pub async fn db_get_all_channels() -> Result<Vec<Channel>, sqlx::Error> { pub async fn select_all_channels() -> Result<Vec<Channel>, sqlx::Error> {
let conn = db_connection().await?; let conn = connection().await?;
let query = "SELECT * FROM channels"; let query = "SELECT * FROM channels";
let mut results: Vec<Channel> = sqlx::query_as(query).fetch_all(&conn).await?; let mut results: Vec<Channel> = sqlx::query_as(query).fetch_all(&conn).await?;
conn.close().await; conn.close().await;
@ -170,11 +167,8 @@ pub async fn db_get_all_channels() -> Result<Vec<Channel>, sqlx::Error> {
Ok(results) Ok(results)
} }
pub async fn db_update_channel( pub async fn update_channel(id: i32, channel: Channel) -> Result<SqliteQueryResult, sqlx::Error> {
id: i64, let conn = connection().await?;
channel: Channel,
) -> Result<SqliteQueryResult, sqlx::Error> {
let conn = db_connection().await?;
let query = "UPDATE channels SET name = $2, preview_url = $3, config_path = $4, extra_extensions = $5 WHERE id = $1"; let query = "UPDATE channels SET name = $2, preview_url = $3, config_path = $4, extra_extensions = $5 WHERE id = $1";
let result: SqliteQueryResult = sqlx::query(query) let result: SqliteQueryResult = sqlx::query(query)
@ -190,8 +184,8 @@ pub async fn db_update_channel(
Ok(result) Ok(result)
} }
pub async fn db_add_channel(channel: Channel) -> Result<Channel, sqlx::Error> { pub async fn insert_channel(channel: Channel) -> Result<Channel, sqlx::Error> {
let conn = db_connection().await?; let conn = connection().await?;
let query = "INSERT INTO channels (name, preview_url, config_path, extra_extensions, service) VALUES($1, $2, $3, $4, $5)"; let query = "INSERT INTO channels (name, preview_url, config_path, extra_extensions, service) VALUES($1, $2, $3, $4, $5)";
let result = sqlx::query(query) let result = sqlx::query(query)
@ -211,8 +205,8 @@ pub async fn db_add_channel(channel: Channel) -> Result<Channel, sqlx::Error> {
Ok(new_channel) Ok(new_channel)
} }
pub async fn db_delete_channel(id: &i64) -> Result<SqliteQueryResult, sqlx::Error> { pub async fn delete_channel(id: &i32) -> Result<SqliteQueryResult, sqlx::Error> {
let conn = db_connection().await?; let conn = connection().await?;
let query = "DELETE FROM channels WHERE id = $1"; let query = "DELETE FROM channels WHERE id = $1";
let result: SqliteQueryResult = sqlx::query(query).bind(id).execute(&conn).await?; let result: SqliteQueryResult = sqlx::query(query).bind(id).execute(&conn).await?;
@ -221,8 +215,8 @@ pub async fn db_delete_channel(id: &i64) -> Result<SqliteQueryResult, sqlx::Erro
Ok(result) Ok(result)
} }
pub async fn db_role(id: &i64) -> Result<String, sqlx::Error> { pub async fn select_role(id: &i32) -> Result<String, sqlx::Error> {
let conn = db_connection().await?; let conn = connection().await?;
let query = "SELECT name FROM roles WHERE id = $1"; let query = "SELECT name FROM roles WHERE id = $1";
let result: Role = sqlx::query_as(query).bind(id).fetch_one(&conn).await?; let result: Role = sqlx::query_as(query).bind(id).fetch_one(&conn).await?;
conn.close().await; conn.close().await;
@ -230,8 +224,8 @@ pub async fn db_role(id: &i64) -> Result<String, sqlx::Error> {
Ok(result.name) Ok(result.name)
} }
pub async fn db_login(user: &str) -> Result<User, sqlx::Error> { pub async fn select_login(user: &str) -> Result<User, sqlx::Error> {
let conn = db_connection().await?; let conn = connection().await?;
let query = "SELECT id, mail, 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?; let result: User = sqlx::query_as(query).bind(user).fetch_one(&conn).await?;
conn.close().await; conn.close().await;
@ -239,8 +233,8 @@ pub async fn db_login(user: &str) -> Result<User, sqlx::Error> {
Ok(result) Ok(result)
} }
pub async fn db_get_user(user: &str) -> Result<User, sqlx::Error> { pub async fn select_user(user: &str) -> Result<User, sqlx::Error> {
let conn = db_connection().await?; let conn = connection().await?;
let query = "SELECT id, mail, username, role_id FROM user WHERE username = $1"; 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?; let result: User = sqlx::query_as(query).bind(user).fetch_one(&conn).await?;
conn.close().await; conn.close().await;
@ -248,8 +242,8 @@ pub async fn db_get_user(user: &str) -> Result<User, sqlx::Error> {
Ok(result) Ok(result)
} }
pub async fn db_add_user(user: User) -> Result<SqliteQueryResult, sqlx::Error> { pub async fn insert_user(user: User) -> Result<SqliteQueryResult, sqlx::Error> {
let conn = db_connection().await?; let conn = connection().await?;
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
let password_hash = Argon2::default() let password_hash = Argon2::default()
.hash_password(user.password.clone().as_bytes(), &salt) .hash_password(user.password.clone().as_bytes(), &salt)
@ -270,8 +264,8 @@ pub async fn db_add_user(user: User) -> Result<SqliteQueryResult, sqlx::Error> {
Ok(result) Ok(result)
} }
pub async fn db_update_user(id: i64, fields: String) -> Result<SqliteQueryResult, sqlx::Error> { pub async fn update_user(id: i32, fields: String) -> Result<SqliteQueryResult, sqlx::Error> {
let conn = db_connection().await?; let conn = connection().await?;
let query = format!("UPDATE user SET {fields} WHERE id = $1"); let query = format!("UPDATE user SET {fields} WHERE id = $1");
let result: SqliteQueryResult = sqlx::query(&query).bind(id).execute(&conn).await?; let result: SqliteQueryResult = sqlx::query(&query).bind(id).execute(&conn).await?;
conn.close().await; conn.close().await;
@ -279,8 +273,8 @@ pub async fn db_update_user(id: i64, fields: String) -> Result<SqliteQueryResult
Ok(result) Ok(result)
} }
pub async fn db_get_presets(id: i64) -> Result<Vec<TextPreset>, sqlx::Error> { pub async fn select_presets(id: i32) -> Result<Vec<TextPreset>, sqlx::Error> {
let conn = db_connection().await?; let conn = connection().await?;
let query = "SELECT * FROM presets WHERE channel_id = $1"; let query = "SELECT * FROM presets WHERE channel_id = $1";
let result: Vec<TextPreset> = sqlx::query_as(query).bind(id).fetch_all(&conn).await?; let result: Vec<TextPreset> = sqlx::query_as(query).bind(id).fetch_all(&conn).await?;
conn.close().await; conn.close().await;
@ -288,11 +282,8 @@ pub async fn db_get_presets(id: i64) -> Result<Vec<TextPreset>, sqlx::Error> {
Ok(result) Ok(result)
} }
pub async fn db_update_preset( pub async fn update_preset(id: &i32, preset: TextPreset) -> Result<SqliteQueryResult, sqlx::Error> {
id: &i64, let conn = connection().await?;
preset: TextPreset,
) -> Result<SqliteQueryResult, sqlx::Error> {
let conn = db_connection().await?;
let query = let query =
"UPDATE presets SET name = $1, text = $2, x = $3, y = $4, fontsize = $5, line_spacing = $6, "UPDATE presets SET name = $1, text = $2, x = $3, y = $4, fontsize = $5, line_spacing = $6,
fontcolor = $7, alpha = $8, box = $9, boxcolor = $10, boxborderw = 11 WHERE id = $12"; fontcolor = $7, alpha = $8, box = $9, boxcolor = $10, boxborderw = 11 WHERE id = $12";
@ -316,8 +307,8 @@ pub async fn db_update_preset(
Ok(result) Ok(result)
} }
pub async fn db_add_preset(preset: TextPreset) -> Result<SqliteQueryResult, sqlx::Error> { pub async fn insert_preset(preset: TextPreset) -> Result<SqliteQueryResult, sqlx::Error> {
let conn = db_connection().await?; let conn = connection().await?;
let query = let query =
"INSERT INTO presets (channel_id, name, text, x, y, fontsize, line_spacing, fontcolor, alpha, box, boxcolor, boxborderw) "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)"; VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)";
@ -341,8 +332,8 @@ pub async fn db_add_preset(preset: TextPreset) -> Result<SqliteQueryResult, sqlx
Ok(result) Ok(result)
} }
pub async fn db_delete_preset(id: &i64) -> Result<SqliteQueryResult, sqlx::Error> { pub async fn delete_preset(id: &i32) -> Result<SqliteQueryResult, sqlx::Error> {
let conn = db_connection().await?; let conn = connection().await?;
let query = "DELETE FROM presets WHERE id = $1;"; let query = "DELETE FROM presets WHERE id = $1;";
let result: SqliteQueryResult = sqlx::query(query).bind(id).execute(&conn).await?; let result: SqliteQueryResult = sqlx::query(query).bind(id).execute(&conn).await?;
conn.close().await; conn.close().await;

View File

@ -0,0 +1,2 @@
pub mod handles;
pub mod models;

View File

@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
pub struct User { pub struct User {
#[sqlx(default)] #[sqlx(default)]
#[serde(skip_deserializing)] #[serde(skip_deserializing)]
pub id: i64, pub id: i32,
#[sqlx(default)] #[sqlx(default)]
pub mail: Option<String>, pub mail: Option<String>,
pub username: String, pub username: String,
@ -16,10 +16,10 @@ pub struct User {
pub salt: Option<String>, pub salt: Option<String>,
#[sqlx(default)] #[sqlx(default)]
#[serde(skip_serializing)] #[serde(skip_serializing)]
pub role_id: Option<i64>, pub role_id: Option<i32>,
#[sqlx(default)] #[sqlx(default)]
#[serde(skip_serializing)] #[serde(skip_serializing)]
pub channel_id: Option<i64>, pub channel_id: Option<i32>,
#[sqlx(default)] #[sqlx(default)]
pub token: Option<String>, pub token: Option<String>,
} }
@ -30,12 +30,12 @@ fn empty_string() -> String {
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
pub struct LoginUser { pub struct LoginUser {
pub id: i64, pub id: i32,
pub username: String, pub username: String,
} }
impl LoginUser { impl LoginUser {
pub fn new(id: i64, username: String) -> Self { pub fn new(id: i32, username: String) -> Self {
Self { id, username } Self { id, username }
} }
} }
@ -43,8 +43,8 @@ impl LoginUser {
pub struct TextPreset { pub struct TextPreset {
#[sqlx(default)] #[sqlx(default)]
#[serde(skip_deserializing)] #[serde(skip_deserializing)]
pub id: i64, pub id: i32,
pub channel_id: i64, pub channel_id: i32,
pub name: String, pub name: String,
pub text: String, pub text: String,
pub x: String, pub x: String,
@ -61,7 +61,7 @@ pub struct TextPreset {
#[derive(Debug, Deserialize, Serialize, sqlx::FromRow)] #[derive(Debug, Deserialize, Serialize, sqlx::FromRow)]
pub struct Channel { pub struct Channel {
#[serde(skip_deserializing)] #[serde(skip_deserializing)]
pub id: i64, pub id: i32,
pub name: String, pub name: String,
pub preview_url: String, pub preview_url: String,
pub config_path: String, pub config_path: String,

View File

@ -9,21 +9,23 @@ use actix_web_httpauth::middleware::HttpAuthentication;
use clap::Parser; use clap::Parser;
use simplelog::*; use simplelog::*;
pub mod api;
pub mod db;
pub mod utils; pub mod utils;
use utils::{ use api::{
args_parse::Args, auth,
auth, db_path, init_config,
models::LoginUser,
routes::{ routes::{
add_channel, add_dir, add_preset, add_user, control_playout, del_playlist, delete_preset, add_channel, add_dir, add_preset, add_user, control_playout, del_playlist, delete_preset,
file_browser, gen_playlist, get_all_channels, get_channel, get_log, get_playlist, file_browser, gen_playlist, get_all_channels, get_channel, get_log, get_playlist,
get_playout_config, get_presets, get_user, login, media_current, media_last, media_next, get_playout_config, get_presets, get_user, import_playlist, login, media_current,
move_rename, patch_channel, process_control, remove, remove_channel, save_file, media_last, media_next, move_rename, patch_channel, process_control, remove,
save_playlist, send_text_message, update_playout_config, update_preset, update_user, remove_channel, save_file, save_playlist, send_text_message, update_playout_config,
update_preset, update_user,
}, },
run_args, Role,
}; };
use db::models::LoginUser;
use utils::{args_parse::Args, db_path, init_config, run_args, Role};
use ffplayout_lib::utils::{init_logging, PlayoutConfig}; use ffplayout_lib::utils::{init_logging, PlayoutConfig};
@ -118,7 +120,8 @@ async fn main() -> std::io::Result<()> {
.service(add_dir) .service(add_dir)
.service(move_rename) .service(move_rename)
.service(remove) .service(remove)
.service(save_file), .service(save_file)
.service(import_playlist),
) )
.service(Files::new("/", public_path()).index_file("index.html")) .service(Files::new("/", public_path()).index_file("index.html"))
}) })

View File

@ -2,12 +2,9 @@ use std::fs;
use simplelog::*; use simplelog::*;
use crate::utils::{ use crate::utils::{control::control_service, errors::ServiceError};
control::control_service,
errors::ServiceError, use crate::db::{handles, models::Channel};
handles::{db_add_channel, db_delete_channel, db_get_channel},
models::Channel,
};
pub async fn create_channel(target_channel: Channel) -> Result<Channel, ServiceError> { pub async fn create_channel(target_channel: Channel) -> Result<Channel, ServiceError> {
if !target_channel.service.starts_with("ffplayout@") { if !target_channel.service.starts_with("ffplayout@") {
@ -23,14 +20,14 @@ pub async fn create_channel(target_channel: Channel) -> Result<Channel, ServiceE
&target_channel.config_path, &target_channel.config_path,
)?; )?;
let new_channel = db_add_channel(target_channel).await?; let new_channel = handles::insert_channel(target_channel).await?;
control_service(new_channel.id, "enable").await?; control_service(new_channel.id, "enable").await?;
Ok(new_channel) Ok(new_channel)
} }
pub async fn delete_channel(id: i64) -> Result<(), ServiceError> { pub async fn delete_channel(id: i32) -> Result<(), ServiceError> {
let channel = db_get_channel(&id).await?; let channel = handles::select_channel(&id).await?;
control_service(channel.id, "stop").await?; control_service(channel.id, "stop").await?;
control_service(channel.id, "disable").await?; control_service(channel.id, "disable").await?;
@ -38,7 +35,7 @@ pub async fn delete_channel(id: i64) -> Result<(), ServiceError> {
error!("{e}"); error!("{e}");
}; };
db_delete_channel(&id).await?; handles::delete_channel(&id).await?;
Ok(()) Ok(())
} }

View File

@ -6,13 +6,14 @@ use reqwest::{
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::utils::{errors::ServiceError, handles::db_get_channel, playout_config}; use crate::db::handles::select_channel;
use crate::utils::{errors::ServiceError, playout_config};
use ffplayout_lib::vec_strings; use ffplayout_lib::vec_strings;
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
struct RpcObj<T> { struct RpcObj<T> {
jsonrpc: String, jsonrpc: String,
id: i64, id: i32,
method: String, method: String,
params: T, params: T,
} }
@ -34,7 +35,7 @@ struct MediaParams {
} }
impl<T> RpcObj<T> { impl<T> RpcObj<T> {
fn new(id: i64, method: String, params: T) -> Self { fn new(id: i32, method: String, params: T) -> Self {
Self { Self {
jsonrpc: "2.0".into(), jsonrpc: "2.0".into(),
id, id,
@ -55,8 +56,8 @@ struct SystemD {
} }
impl SystemD { impl SystemD {
async fn new(id: i64) -> Result<Self, ServiceError> { async fn new(id: i32) -> Result<Self, ServiceError> {
let channel = db_get_channel(&id).await?; let channel = select_channel(&id).await?;
Ok(Self { Ok(Self {
service: channel.service, service: channel.service,
@ -129,7 +130,7 @@ fn create_header(auth: &str) -> HeaderMap {
headers headers
} }
async fn post_request<T>(id: i64, obj: RpcObj<T>) -> Result<Response, ServiceError> async fn post_request<T>(id: i32, obj: RpcObj<T>) -> Result<Response, ServiceError>
where where
T: Serialize, T: Serialize,
{ {
@ -150,7 +151,7 @@ where
} }
pub async fn send_message( pub async fn send_message(
id: i64, id: i32,
message: HashMap<String, String>, message: HashMap<String, String>,
) -> Result<Response, ServiceError> { ) -> Result<Response, ServiceError> {
let json_obj = RpcObj::new( let json_obj = RpcObj::new(
@ -165,19 +166,19 @@ pub async fn send_message(
post_request(id, json_obj).await post_request(id, json_obj).await
} }
pub async fn control_state(id: i64, command: String) -> Result<Response, ServiceError> { pub async fn control_state(id: i32, command: String) -> Result<Response, ServiceError> {
let json_obj = RpcObj::new(id, "player".into(), ControlParams { control: command }); let json_obj = RpcObj::new(id, "player".into(), ControlParams { control: command });
post_request(id, json_obj).await post_request(id, json_obj).await
} }
pub async fn media_info(id: i64, command: String) -> Result<Response, ServiceError> { pub async fn media_info(id: i32, command: String) -> Result<Response, ServiceError> {
let json_obj = RpcObj::new(id, "player".into(), MediaParams { media: command }); let json_obj = RpcObj::new(id, "player".into(), MediaParams { media: command });
post_request(id, json_obj).await post_request(id, json_obj).await
} }
pub async fn control_service(id: i64, command: &str) -> Result<String, ServiceError> { pub async fn control_service(id: i32, command: &str) -> Result<String, ServiceError> {
let system_d = SystemD::new(id).await?; let system_d = SystemD::new(id).await?;
match command { match command {

View File

@ -47,9 +47,9 @@ pub struct VideoFile {
/// ///
/// This function takes care, that it is not possible to break out from root_path. /// This function takes care, that it is not possible to break out from root_path.
/// It also gives alway a relative path back. /// It also gives alway a relative path back.
fn norm_abs_path(root_path: &String, input_path: &String) -> (PathBuf, String, String) { fn norm_abs_path(root_path: &str, input_path: &str) -> (PathBuf, String, String) {
let mut path = PathBuf::from(root_path.clone()); let mut path = PathBuf::from(root_path);
let path_relative = RelativePath::new(&root_path) let path_relative = RelativePath::new(root_path)
.normalize() .normalize()
.to_string() .to_string()
.replace("../", ""); .replace("../", "");
@ -57,7 +57,11 @@ fn norm_abs_path(root_path: &String, input_path: &String) -> (PathBuf, String, S
.normalize() .normalize()
.to_string() .to_string()
.replace("../", ""); .replace("../", "");
let path_suffix = path.file_name().unwrap().to_string_lossy().to_string(); let path_suffix = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if input_path.starts_with(root_path) || source_relative.starts_with(&path_relative) { if input_path.starts_with(root_path) || source_relative.starts_with(&path_relative) {
source_relative = source_relative source_relative = source_relative
@ -83,7 +87,7 @@ fn norm_abs_path(root_path: &String, input_path: &String) -> (PathBuf, String, S
/// Take input path and give file and folder list from it back. /// 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 /// 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. /// 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> { pub async fn browser(id: i32, path_obj: &PathObject) -> Result<PathObject, ServiceError> {
let (config, _) = playout_config(&id).await?; let (config, _) = playout_config(&id).await?;
let extensions = config.storage.extensions; let extensions = config.storage.extensions;
let (path, parent, path_component) = norm_abs_path(&config.storage.path, &path_obj.source); let (path, parent, path_component) = norm_abs_path(&config.storage.path, &path_obj.source);
@ -139,7 +143,7 @@ pub async fn browser(id: i64, path_obj: &PathObject) -> Result<PathObject, Servi
} }
pub async fn create_directory( pub async fn create_directory(
id: i64, id: i32,
path_obj: &PathObject, path_obj: &PathObject,
) -> Result<HttpResponse, ServiceError> { ) -> Result<HttpResponse, ServiceError> {
let (config, _) = playout_config(&id).await?; let (config, _) = playout_config(&id).await?;
@ -194,7 +198,7 @@ fn rename(source: &PathBuf, target: &PathBuf) -> Result<MoveObject, ServiceError
} }
} }
pub async fn rename_file(id: i64, move_object: &MoveObject) -> Result<MoveObject, ServiceError> { pub async fn rename_file(id: i32, move_object: &MoveObject) -> Result<MoveObject, ServiceError> {
let (config, _) = playout_config(&id).await?; let (config, _) = playout_config(&id).await?;
let (source_path, _, _) = norm_abs_path(&config.storage.path, &move_object.source); 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); let (mut target_path, _, _) = norm_abs_path(&config.storage.path, &move_object.target);
@ -225,7 +229,7 @@ pub async fn rename_file(id: i64, move_object: &MoveObject) -> Result<MoveObject
Err(ServiceError::InternalServerError) Err(ServiceError::InternalServerError)
} }
pub async fn remove_file_or_folder(id: i64, source_path: &String) -> Result<(), ServiceError> { pub async fn remove_file_or_folder(id: i32, source_path: &str) -> Result<(), ServiceError> {
let (config, _) = playout_config(&id).await?; let (config, _) = playout_config(&id).await?;
let (source, _, _) = norm_abs_path(&config.storage.path, source_path); let (source, _, _) = norm_abs_path(&config.storage.path, source_path);
@ -258,7 +262,7 @@ pub async fn remove_file_or_folder(id: i64, source_path: &String) -> Result<(),
Err(ServiceError::InternalServerError) Err(ServiceError::InternalServerError)
} }
async fn valid_path(id: i64, path: &String) -> Result<PathBuf, ServiceError> { async fn valid_path(id: i32, path: &str) -> Result<PathBuf, ServiceError> {
let (config, _) = playout_config(&id).await?; let (config, _) = playout_config(&id).await?;
let (test_path, _, _) = norm_abs_path(&config.storage.path, path); let (test_path, _, _) = norm_abs_path(&config.storage.path, path);
@ -270,9 +274,10 @@ async fn valid_path(id: i64, path: &String) -> Result<PathBuf, ServiceError> {
} }
pub async fn upload( pub async fn upload(
id: i64, id: i32,
mut payload: Multipart, mut payload: Multipart,
path: &String, path: &str,
abs_path: bool,
) -> Result<HttpResponse, ServiceError> { ) -> Result<HttpResponse, ServiceError> {
while let Some(mut field) = payload.try_next().await? { while let Some(mut field) = payload.try_next().await? {
let content_disposition = field.content_disposition(); let content_disposition = field.content_disposition();
@ -286,8 +291,14 @@ pub async fn upload(
.get_filename() .get_filename()
.map_or_else(|| rand_string.to_string(), sanitize_filename::sanitize); .map_or_else(|| rand_string.to_string(), sanitize_filename::sanitize);
let target_path = valid_path(id, path).await?; let filepath;
let filepath = target_path.join(filename);
if abs_path {
filepath = PathBuf::from(path);
} else {
let target_path = valid_path(id, path).await?;
filepath = target_path.join(filename);
}
if filepath.is_file() { if filepath.is_file() {
return Err(ServiceError::BadRequest("Target already exists!".into())); return Err(ServiceError::BadRequest("Target already exists!".into()));

View File

@ -12,22 +12,17 @@ use rpassword::read_password;
use simplelog::*; use simplelog::*;
pub mod args_parse; pub mod args_parse;
pub mod auth;
pub mod channels; pub mod channels;
pub mod control; pub mod control;
pub mod errors; pub mod errors;
pub mod files; pub mod files;
pub mod handles;
pub mod models;
pub mod playlist; pub mod playlist;
pub mod routes;
use crate::utils::{ use crate::db::{
args_parse::Args, handles::{db_init, insert_user, select_channel, select_global},
errors::ServiceError,
handles::{db_add_user, db_get_channel, db_global, db_init},
models::{Channel, User}, models::{Channel, User},
}; };
use crate::utils::{args_parse::Args, errors::ServiceError};
use ffplayout_lib::utils::PlayoutConfig; use ffplayout_lib::utils::PlayoutConfig;
#[derive(Clone, Eq, PartialEq)] #[derive(Clone, Eq, PartialEq)]
@ -54,7 +49,7 @@ pub struct GlobalSettings {
impl GlobalSettings { impl GlobalSettings {
async fn new() -> Self { async fn new() -> Self {
let global_settings = db_global(); let global_settings = select_global();
match global_settings.await { match global_settings.await {
Ok(g) => g, Ok(g) => g,
@ -165,7 +160,7 @@ pub async fn run_args(mut args: Args) -> Result<(), i32> {
token: None, token: None,
}; };
if let Err(e) = db_add_user(user).await { if let Err(e) = insert_user(user).await {
error!("{e}"); error!("{e}");
return Err(1); return Err(1);
}; };
@ -185,8 +180,8 @@ pub fn read_playout_config(path: &str) -> Result<PlayoutConfig, Box<dyn Error>>
Ok(config) Ok(config)
} }
pub async fn playout_config(channel_id: &i64) -> Result<(PlayoutConfig, Channel), ServiceError> { pub async fn playout_config(channel_id: &i32) -> Result<(PlayoutConfig, Channel), ServiceError> {
if let Ok(channel) = db_get_channel(channel_id).await { if let Ok(channel) = select_channel(channel_id).await {
if let Ok(config) = read_playout_config(&channel.config_path.clone()) { if let Ok(config) = read_playout_config(&channel.config_path.clone()) {
return Ok((config, channel)); return Ok((config, channel));
} }
@ -197,8 +192,8 @@ pub async fn playout_config(channel_id: &i64) -> Result<(PlayoutConfig, Channel)
)) ))
} }
pub async fn read_log_file(channel_id: &i64, date: &str) -> Result<String, ServiceError> { pub async fn read_log_file(channel_id: &i32, date: &str) -> Result<String, ServiceError> {
if let Ok(channel) = db_get_channel(channel_id).await { if let Ok(channel) = select_channel(channel_id).await {
let mut date_str = "".to_string(); let mut date_str = "".to_string();
if !date.is_empty() { if !date.is_empty() {

View File

@ -1,33 +1,13 @@
use std::{ use std::{fs, path::PathBuf};
fs::{self, File},
io::Error,
path::PathBuf,
};
use simplelog::*; use simplelog::*;
use crate::utils::{errors::ServiceError, playout_config}; use crate::utils::{errors::ServiceError, playout_config};
use ffplayout_lib::utils::{generate_playlist as playlist_generator, JsonPlaylist}; use ffplayout_lib::utils::{
generate_playlist as playlist_generator, json_reader, json_writer, JsonPlaylist,
};
fn json_reader(path: &PathBuf) -> Result<JsonPlaylist, Error> { pub async fn read_playlist(id: i32, date: String) -> Result<JsonPlaylist, ServiceError> {
let f = File::options().read(true).write(false).open(&path)?;
let p = serde_json::from_reader(f)?;
Ok(p)
}
fn json_writer(path: &PathBuf, data: JsonPlaylist) -> Result<(), Error> {
let f = File::options()
.write(true)
.truncate(true)
.create(true)
.open(&path)?;
serde_json::to_writer_pretty(f, &data)?;
Ok(())
}
pub async fn read_playlist(id: i64, date: String) -> Result<JsonPlaylist, ServiceError> {
let (config, _) = playout_config(&id).await?; let (config, _) = playout_config(&id).await?;
let mut playlist_path = PathBuf::from(&config.playlist.path); let mut playlist_path = PathBuf::from(&config.playlist.path);
let d: Vec<&str> = date.split('-').collect(); let d: Vec<&str> = date.split('-').collect();
@ -43,7 +23,7 @@ pub async fn read_playlist(id: i64, date: String) -> Result<JsonPlaylist, Servic
} }
} }
pub async fn write_playlist(id: i64, json_data: JsonPlaylist) -> Result<String, ServiceError> { pub async fn write_playlist(id: i32, json_data: JsonPlaylist) -> Result<String, ServiceError> {
let (config, _) = playout_config(&id).await?; let (config, _) = playout_config(&id).await?;
let date = json_data.date.clone(); let date = json_data.date.clone();
let mut playlist_path = PathBuf::from(&config.playlist.path); let mut playlist_path = PathBuf::from(&config.playlist.path);
@ -88,7 +68,7 @@ pub async fn write_playlist(id: i64, json_data: JsonPlaylist) -> Result<String,
Err(ServiceError::InternalServerError) Err(ServiceError::InternalServerError)
} }
pub async fn generate_playlist(id: i64, date: String) -> Result<JsonPlaylist, ServiceError> { pub async fn generate_playlist(id: i32, date: String) -> Result<JsonPlaylist, ServiceError> {
let (mut config, channel) = playout_config(&id).await?; let (mut config, channel) = playout_config(&id).await?;
config.general.generate = Some(vec![date.clone()]); config.general.generate = Some(vec![date.clone()]);
@ -109,7 +89,7 @@ pub async fn generate_playlist(id: i64, date: String) -> Result<JsonPlaylist, Se
} }
} }
pub async fn delete_playlist(id: i64, date: &str) -> Result<(), ServiceError> { pub async fn delete_playlist(id: i32, date: &str) -> Result<(), ServiceError> {
let (config, _) = playout_config(&id).await?; let (config, _) = playout_config(&id).await?;
let mut playlist_path = PathBuf::from(&config.playlist.path); let mut playlist_path = PathBuf::from(&config.playlist.path);
let d: Vec<&str> = date.split('-').collect(); let d: Vec<&str> = date.split('-').collect();

View File

@ -4,8 +4,9 @@ description = "24/7 playout based on rust and ffmpeg"
license = "GPL-3.0" license = "GPL-3.0"
authors = ["Jonathan Baecker jonbae77@gmail.com"] authors = ["Jonathan Baecker jonbae77@gmail.com"]
readme = "README.md" readme = "README.md"
version = "0.15.2" version = "0.16.0"
edition = "2021" edition = "2021"
default-run = "ffplayout"
[dependencies] [dependencies]
ffplayout-lib = { path = "../lib" } ffplayout-lib = { path = "../lib" }
@ -15,6 +16,7 @@ crossbeam-channel = "0.5"
futures = "0.3" futures = "0.3"
jsonrpc-http-server = "18.0" jsonrpc-http-server = "18.0"
notify = "4.0" notify = "4.0"
regex = "1"
reqwest = { version = "0.11", features = ["blocking", "json"] } reqwest = { version = "0.11", features = ["blocking", "json"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"

View File

@ -8,11 +8,14 @@ ffplayout also allows the passing of parameters:
``` ```
OPTIONS: OPTIONS:
-c, --config <CONFIG> File path to ffplayout.conf -c, --config <CONFIG> File path to ffplayout.yml
-d, --date <DATE> Target date (YYYY-MM-DD) for text/m3u to playlist import
-f, --folder <FOLDER> Play folder content -f, --folder <FOLDER> Play folder content
-g, --generate <YYYY-MM-DD>... Generate playlist for date or date-range, like: 2022-01-01 - 2022-01-10: --fake-time <FAKE_TIME> fake date time, for debugging
-g, --generate <YYYY-MM-DD>... Generate playlist for dates, like: 2022-01-01 - 2022-01-10
-h, --help Print help information -h, --help Print help information
-i, --infinit Loop playlist infinitely -i, --infinit Loop playlist infinitely
--import <IMPORT> Import a given text/m3u file and create a playlist from it
-l, --log <LOG> File path for logging -l, --log <LOG> File path for logging
-m, --play-mode <PLAY_MODE> Playing mode: folder, playlist -m, --play-mode <PLAY_MODE> Playing mode: folder, playlist
-o, --output <OUTPUT> Set output mode: desktop, hls, stream -o, --output <OUTPUT> Set output mode: desktop, hls, stream

View File

@ -42,7 +42,7 @@ pub fn watchman(
match res { match res {
Create(new_path) => { Create(new_path) => {
let index = sources.lock().unwrap().len(); let index = sources.lock().unwrap().len();
let media = Media::new(index, new_path.display().to_string(), false); let media = Media::new(index, &new_path.to_string_lossy(), false);
if include_file(config.clone(), &new_path) { if include_file(config.clone(), &new_path) {
sources.lock().unwrap().push(media); sources.lock().unwrap().push(media);
@ -66,7 +66,7 @@ pub fn watchman(
.position(|x| *x.source == old_path.display().to_string()) .position(|x| *x.source == old_path.display().to_string())
.unwrap(); .unwrap();
let media = Media::new(index, new_path.display().to_string(), false); let media = Media::new(index, &new_path.to_string_lossy(), false);
sources.lock().unwrap()[index] = media; sources.lock().unwrap()[index] = media;
info!("Rename file: <b><magenta>{old_path:?}</></b> to <b><magenta>{new_path:?}</></b>"); info!("Rename file: <b><magenta>{old_path:?}</></b> to <b><magenta>{new_path:?}</></b>");

View File

@ -84,7 +84,7 @@ pub fn ingest_server(
let mut buffer: [u8; 65088] = [0; 65088]; let mut buffer: [u8; 65088] = [0; 65088];
let mut server_cmd = vec_strings!["-hide_banner", "-nostats", "-v", "level+info"]; let mut server_cmd = vec_strings!["-hide_banner", "-nostats", "-v", "level+info"];
let stream_input = config.ingest.input_cmd.clone().unwrap(); let stream_input = config.ingest.input_cmd.clone().unwrap();
let mut dummy_media = Media::new(0, "Live Stream".to_string(), false); let mut dummy_media = Media::new(0, "Live Stream", false);
dummy_media.is_live = Some(true); dummy_media.is_live = Some(true);
let mut filters = filter_chains(&config, &mut dummy_media, &Arc::new(Mutex::new(vec![]))); let mut filters = filter_chains(&config, &mut dummy_media, &Arc::new(Mutex::new(vec![])));

View File

@ -1,5 +1,4 @@
use std::{ use std::{
process,
sync::{ sync::{
atomic::{AtomicBool, AtomicUsize}, atomic::{AtomicBool, AtomicUsize},
Arc, Mutex, Arc, Mutex,
@ -9,7 +8,7 @@ use std::{
use simplelog::*; use simplelog::*;
use ffplayout_lib::utils::{Media, PlayoutConfig, PlayoutStatus}; use ffplayout_lib::utils::{Media, PlayoutConfig, PlayoutStatus, ProcessMode::*};
pub mod folder; pub mod folder;
pub mod ingest; pub mod ingest;
@ -29,8 +28,8 @@ pub fn source_generator(
playout_stat: PlayoutStatus, playout_stat: PlayoutStatus,
is_terminated: Arc<AtomicBool>, is_terminated: Arc<AtomicBool>,
) -> Box<dyn Iterator<Item = Media>> { ) -> Box<dyn Iterator<Item = Media>> {
let get_source = match config.processing.mode.as_str() { match config.processing.mode {
"folder" => { Folder => {
info!("Playout in folder mode"); info!("Playout in folder mode");
debug!( debug!(
"Monitor folder: <b><magenta>{}</></b>", "Monitor folder: <b><magenta>{}</></b>",
@ -46,18 +45,12 @@ pub fn source_generator(
Box::new(folder_source) as Box<dyn Iterator<Item = Media>> Box::new(folder_source) as Box<dyn Iterator<Item = Media>>
} }
"playlist" => { Playlist => {
info!("Playout in playlist mode"); info!("Playout in playlist mode");
let program = let program =
CurrentProgram::new(&config, playout_stat, is_terminated, current_list, index); CurrentProgram::new(&config, playout_stat, is_terminated, current_list, index);
Box::new(program) as Box<dyn Iterator<Item = Media>> Box::new(program) as Box<dyn Iterator<Item = Media>>
} }
_ => { }
error!("Process Mode not exists!");
process::exit(1);
}
};
get_source
} }

View File

@ -69,7 +69,7 @@ impl CurrentProgram {
json_path: json.current_file, json_path: json.current_file,
json_date: json.date, json_date: json.date,
nodes: current_list, nodes: current_list,
current_node: Media::new(0, String::new(), false), current_node: Media::new(0, "", false),
index: global_index, index: global_index,
is_terminated, is_terminated,
playout_stat, playout_stat,
@ -118,7 +118,7 @@ impl CurrentProgram {
"Playlist <b><magenta>{}</></b> not exists!", "Playlist <b><magenta>{}</></b> not exists!",
self.json_path.clone().unwrap() self.json_path.clone().unwrap()
); );
let mut media = Media::new(0, String::new(), false); let mut media = Media::new(0, "", false);
media.begin = Some(get_sec()); media.begin = Some(get_sec());
media.duration = DUMMY_LEN; media.duration = DUMMY_LEN;
media.out = DUMMY_LEN; media.out = DUMMY_LEN;
@ -304,7 +304,7 @@ impl Iterator for CurrentProgram {
current_time += self.config.playlist.length_sec.unwrap() + 1.0; current_time += self.config.playlist.length_sec.unwrap() + 1.0;
} }
let mut media = Media::new(0, String::new(), false); let mut media = Media::new(0, "", false);
media.begin = Some(current_time); media.begin = Some(current_time);
media.duration = duration; media.duration = duration;
media.out = duration; media.out = duration;
@ -357,7 +357,7 @@ impl Iterator for CurrentProgram {
// Test if playlist is to early finish, // Test if playlist is to early finish,
// and if we have to fill it with a placeholder. // and if we have to fill it with a placeholder.
let index = self.index.load(Ordering::SeqCst); let index = self.index.load(Ordering::SeqCst);
self.current_node = Media::new(index, String::new(), false); self.current_node = Media::new(index, "", false);
self.current_node.begin = Some(get_sec()); self.current_node.begin = Some(get_sec());
let mut duration = total_delta.abs(); let mut duration = total_delta.abs();
@ -454,7 +454,7 @@ fn timed_source(
} }
/// Generate the source CMD, or when clip not exist, get a dummy. /// Generate the source CMD, or when clip not exist, get a dummy.
fn gen_source( pub fn gen_source(
config: &PlayoutConfig, config: &PlayoutConfig,
mut node: Media, mut node: Media,
filter_chain: &Arc<Mutex<Vec<String>>>, filter_chain: &Arc<Mutex<Vec<String>>>,

View File

@ -0,0 +1,4 @@
pub mod input;
pub mod output;
pub mod rpc;
pub mod utils;

View File

@ -12,27 +12,19 @@ use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use simplelog::*; use simplelog::*;
pub mod input; use ffplayout::{
pub mod output;
pub mod rpc;
// #[cfg(test)]
// mod tests;
pub mod utils;
use utils::{arg_parse::get_args, get_config};
use crate::{
output::{player, write_hls}, output::{player, write_hls},
rpc::json_rpc_server, rpc::json_rpc_server,
utils::{arg_parse::get_args, get_config},
}; };
use ffplayout_lib::utils::{ use ffplayout_lib::utils::{
generate_playlist, init_logging, send_mail, validate_ffmpeg, PlayerControl, PlayoutStatus, generate_playlist, import::import_file, init_logging, send_mail, validate_ffmpeg,
ProcessControl, OutputMode::*, PlayerControl, PlayoutStatus, ProcessControl,
}; };
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
use utils::Args; use ffplayout::utils::Args;
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
use ffplayout_lib::utils::{mock_time, time_now}; use ffplayout_lib::utils::{mock_time, time_now};
@ -90,10 +82,11 @@ fn fake_time(args: &Args) {
fn main() { fn main() {
let args = get_args(); let args = get_args();
// use fake time function only in debugging mode
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
fake_time(&args); fake_time(&args);
let config = get_config(args); let config = get_config(args.clone());
let config_clone = config.clone(); let config_clone = config.clone();
let play_control = PlayerControl::new(); let play_control = PlayerControl::new();
let playout_stat = PlayoutStatus::new(); let playout_stat = PlayoutStatus::new();
@ -122,6 +115,26 @@ fn main() {
exit(0); exit(0);
} }
if let Some(path) = args.import {
if args.date.is_none() {
error!("Import needs date parameter!");
exit(1);
}
// convert text/m3u file to playlist
match import_file(&config, &args.date.unwrap(), None, &path) {
Ok(m) => {
info!("{m}");
exit(0);
}
Err(e) => {
error!("{e}");
exit(1);
}
}
}
if config.rpc_server.enable { if config.rpc_server.enable {
// If RPC server is enable we also fire up a JSON RPC server. // If RPC server is enable we also fire up a JSON RPC server.
thread::spawn(move || json_rpc_server(config_clone, play_ctl, play_stat, proc_ctl2)); thread::spawn(move || json_rpc_server(config_clone, play_ctl, play_stat, proc_ctl2));
@ -129,9 +142,9 @@ fn main() {
status_file(&config.general.stat_file, &playout_stat); status_file(&config.general.stat_file, &playout_stat);
match config.out.mode.to_lowercase().as_str() { match config.out.mode {
// write files/playlist to HLS m3u8 playlist // write files/playlist to HLS m3u8 playlist
"hls" => write_hls(&config, play_control, playout_stat, proc_control), HLS => write_hls(&config, play_control, playout_stat, proc_control),
// play on desktop or stream to a remote target // play on desktop or stream to a remote target
_ => player(&config, play_control, playout_stat, proc_control), _ => player(&config, play_control, playout_stat, proc_control),
} }

View File

@ -28,10 +28,11 @@ use std::{
use simplelog::*; use simplelog::*;
use crate::input::{ingest::log_line, source_generator}; use crate::input::{ingest::log_line, source_generator};
use crate::utils::prepare_output_cmd;
use ffplayout_lib::filter::filter_chains; use ffplayout_lib::filter::filter_chains;
use ffplayout_lib::utils::{ use ffplayout_lib::utils::{
prepare_output_cmd, sec_to_time, stderr_reader, test_tcp_port, Decoder, Ingest, Media, sec_to_time, stderr_reader, test_tcp_port, Encoder, Ingest, Media, PlayerControl,
PlayerControl, PlayoutConfig, PlayoutStatus, ProcessControl, PlayoutConfig, PlayoutStatus, ProcessControl,
}; };
use ffplayout_lib::vec_strings; use ffplayout_lib::vec_strings;
@ -47,7 +48,7 @@ fn ingest_to_hls_server(
let mut server_prefix = vec_strings!["-hide_banner", "-nostats", "-v", "level+info"]; let mut server_prefix = vec_strings!["-hide_banner", "-nostats", "-v", "level+info"];
let stream_input = config.ingest.input_cmd.clone().unwrap(); let stream_input = config.ingest.input_cmd.clone().unwrap();
server_prefix.append(&mut stream_input.clone()); server_prefix.append(&mut stream_input.clone());
let mut dummy_media = Media::new(0, "Live Stream".to_string(), false); let mut dummy_media = Media::new(0, "Live Stream", false);
dummy_media.is_live = Some(true); dummy_media.is_live = Some(true);
let mut is_running; let mut is_running;
@ -79,12 +80,7 @@ fn ingest_to_hls_server(
} }
} }
let server_cmd = prepare_output_cmd( let server_cmd = prepare_output_cmd(server_prefix.clone(), filters, &config);
server_prefix.clone(),
filters,
config.out.clone().output_cmd.unwrap(),
"hls",
);
debug!( debug!(
"Server CMD: <bright-blue>\"ffmpeg {}\"</>", "Server CMD: <bright-blue>\"ffmpeg {}\"</>",
@ -124,7 +120,7 @@ fn ingest_to_hls_server(
info!("Switch from {} to live ingest", config.processing.mode); info!("Switch from {} to live ingest", config.processing.mode);
if let Err(e) = proc_control.kill(Decoder) { if let Err(e) = proc_control.kill(Encoder) {
error!("{e}"); error!("{e}");
} }
} }
@ -200,12 +196,7 @@ pub fn write_hls(
let mut enc_prefix = vec_strings!["-hide_banner", "-nostats", "-v", &ff_log_format]; let mut enc_prefix = vec_strings!["-hide_banner", "-nostats", "-v", &ff_log_format];
enc_prefix.append(&mut cmd); enc_prefix.append(&mut cmd);
let enc_filter = node.filter.unwrap(); let enc_filter = node.filter.unwrap();
let enc_cmd = prepare_output_cmd( let enc_cmd = prepare_output_cmd(enc_prefix, enc_filter, config);
enc_prefix,
enc_filter,
config.out.clone().output_cmd.unwrap(),
&config.out.mode,
);
debug!( debug!(
"HLS writer CMD: <bright-blue>\"ffmpeg {}\"</>", "HLS writer CMD: <bright-blue>\"ffmpeg {}\"</>",
@ -218,20 +209,20 @@ pub fn write_hls(
.spawn() .spawn()
{ {
Err(e) => { Err(e) => {
error!("couldn't spawn decoder process: {e}"); error!("couldn't spawn encoder process: {e}");
panic!("couldn't spawn decoder process: {e}") panic!("couldn't spawn encoder process: {e}")
} }
Ok(proc) => proc, Ok(proc) => proc,
}; };
let dec_err = BufReader::new(enc_proc.stderr.take().unwrap()); let enc_err = BufReader::new(enc_proc.stderr.take().unwrap());
*proc_control.decoder_term.lock().unwrap() = Some(enc_proc); *proc_control.encoder_term.lock().unwrap() = Some(enc_proc);
if let Err(e) = stderr_reader(dec_err, "Writer", proc_control.clone()) { if let Err(e) = stderr_reader(enc_err, "Writer", proc_control.clone()) {
error!("{e:?}") error!("{e:?}")
}; };
if let Err(e) = proc_control.wait(Decoder) { if let Err(e) = proc_control.wait(Encoder) {
error!("{e}"); error!("{e}");
} }

View File

@ -18,8 +18,8 @@ pub use hls::write_hls;
use crate::input::{ingest_server, source_generator}; use crate::input::{ingest_server, source_generator};
use ffplayout_lib::utils::{ use ffplayout_lib::utils::{
sec_to_time, stderr_reader, Decoder, PlayerControl, PlayoutConfig, PlayoutStatus, sec_to_time, stderr_reader, Decoder, OutputMode::*, PlayerControl, PlayoutConfig,
ProcessControl, PlayoutStatus, ProcessControl,
}; };
use ffplayout_lib::vec_strings; use ffplayout_lib::vec_strings;
@ -54,10 +54,10 @@ pub fn player(
); );
// get ffmpeg output instance // get ffmpeg output instance
let mut enc_proc = match config.out.mode.as_str() { let mut enc_proc = match config.out.mode {
"desktop" => desktop::output(config, &ff_log_format), Desktop => desktop::output(config, &ff_log_format),
"null" => null::output(config, &ff_log_format), Null => null::output(config, &ff_log_format),
"stream" => stream::output(config, &ff_log_format), Stream => stream::output(config, &ff_log_format),
_ => panic!("Output mode doesn't exists!"), _ => panic!("Output mode doesn't exists!"),
}; };

View File

@ -5,8 +5,9 @@ use std::{
use simplelog::*; use simplelog::*;
use crate::utils::prepare_output_cmd;
use ffplayout_lib::filter::v_drawtext; use ffplayout_lib::filter::v_drawtext;
use ffplayout_lib::utils::{prepare_output_cmd, PlayoutConfig}; use ffplayout_lib::utils::PlayoutConfig;
use ffplayout_lib::vec_strings; use ffplayout_lib::vec_strings;
/// Streaming Output /// Streaming Output
@ -46,7 +47,7 @@ pub fn output(config: &PlayoutConfig, log_format: &str) -> process::Child {
enc_cmd.append(&mut output_cmd); enc_cmd.append(&mut output_cmd);
let enc_cmd = prepare_output_cmd(enc_prefix, enc_filter, enc_cmd, &config.out.mode); let enc_cmd = prepare_output_cmd(enc_prefix, enc_filter, config);
debug!( debug!(
"Encoder CMD: <bright-blue>\"ffmpeg {}\"</>", "Encoder CMD: <bright-blue>\"ffmpeg {}\"</>",

View File

@ -13,7 +13,7 @@ use simplelog::*;
use ffplayout_lib::utils::{ use ffplayout_lib::utils::{
get_delta, get_filter_from_json, get_sec, sec_to_time, write_status, Ingest, Media, get_delta, get_filter_from_json, get_sec, sec_to_time, write_status, Ingest, Media,
PlayerControl, PlayoutConfig, PlayoutStatus, ProcessControl, OutputMode::*, PlayerControl, PlayoutConfig, PlayoutStatus, ProcessControl,
}; };
use zmq_cmd::zmq_send; use zmq_cmd::zmq_send;
@ -85,12 +85,12 @@ pub fn json_rpc_server(
{ {
let filter = get_filter_from_json(map["message"].to_string()); let filter = get_filter_from_json(map["message"].to_string());
// TODO: in Rust 1.64 use let_chains instead // TODO: in Rust 1.65 use let_chains instead
if !filter.is_empty() && config.text.zmq_stream_socket.is_some() { if !filter.is_empty() && config.text.zmq_stream_socket.is_some() {
let mut clips_filter = playout_stat.chain.lock().unwrap(); let mut clips_filter = playout_stat.chain.lock().unwrap();
*clips_filter = vec![filter.clone()]; *clips_filter = vec![filter.clone()];
if config.out.mode == "hls" { if config.out.mode == HLS {
if proc.server_is_running.load(Ordering::SeqCst) { if proc.server_is_running.load(Ordering::SeqCst) {
let filter_server = format!( let filter_server = format!(
"Parsed_drawtext_{} reinit {filter}", "Parsed_drawtext_{} reinit {filter}",
@ -108,7 +108,7 @@ pub fn json_rpc_server(
} }
} }
if config.out.mode != "hls" || !proc.server_is_running.load(Ordering::SeqCst) { if config.out.mode != HLS || !proc.server_is_running.load(Ordering::SeqCst) {
let filter_stream = format!( let filter_stream = format!(
"Parsed_drawtext_{} reinit {filter}", "Parsed_drawtext_{} reinit {filter}",
playout_stat.drawtext_stream_index.load(Ordering::SeqCst) playout_stat.drawtext_stream_index.load(Ordering::SeqCst)

View File

@ -1,5 +1,7 @@
use clap::Parser; use clap::Parser;
use ffplayout_lib::utils::{OutputMode, ProcessMode};
#[derive(Parser, Debug, Clone)] #[derive(Parser, Debug, Clone)]
#[clap(version, #[clap(version,
about = "ffplayout, Rust based 24/7 playout solution.", about = "ffplayout, Rust based 24/7 playout solution.",
@ -26,11 +28,24 @@ pub struct Args {
pub generate: Option<Vec<String>>, pub generate: Option<Vec<String>>,
#[clap(short = 'm', long, help = "Playing mode: folder, playlist")] #[clap(short = 'm', long, help = "Playing mode: folder, playlist")]
pub play_mode: Option<String>, pub play_mode: Option<ProcessMode>,
#[clap(short, long, help = "Play folder content")] #[clap(short, long, help = "Play folder content")]
pub folder: Option<String>, pub folder: Option<String>,
#[clap(
short,
long,
help = "Target date (YYYY-MM-DD) for text/m3u to playlist import"
)]
pub date: Option<String>,
#[clap(
long,
help = "Import a given text/m3u file and create a playlist from it"
)]
pub import: Option<String>,
#[clap(short, long, help = "Path from playlist")] #[clap(short, long, help = "Path from playlist")]
pub playlist: Option<String>, pub playlist: Option<String>,
@ -51,8 +66,8 @@ pub struct Args {
#[clap(short, long, help = "Loop playlist infinitely")] #[clap(short, long, help = "Loop playlist infinitely")]
pub infinit: bool, pub infinit: bool,
#[clap(short, long, help = "Set output mode: desktop, hls, stream")] #[clap(short, long, help = "Set output mode: desktop, hls, null, stream")]
pub output: Option<String>, pub output: Option<OutputMode>,
#[clap(short, long, help = "Set audio volume")] #[clap(short, long, help = "Set audio volume")]
pub volume: Option<f64>, pub volume: Option<f64>,

View File

@ -3,11 +3,17 @@ use std::{
process::exit, process::exit,
}; };
use regex::Regex;
pub mod arg_parse; pub mod arg_parse;
pub use arg_parse::Args; pub use arg_parse::Args;
use ffplayout_lib::utils::{time_to_sec, PlayoutConfig}; use ffplayout_lib::{
utils::{time_to_sec, OutputMode::*, PlayoutConfig, ProcessMode::*},
vec_strings,
};
/// Read command line arguments, and override the config with them.
pub fn get_config(args: Args) -> PlayoutConfig { pub fn get_config(args: Args) -> PlayoutConfig {
let cfg_path = match args.channel { let cfg_path = match args.channel {
Some(c) => { Some(c) => {
@ -48,7 +54,7 @@ pub fn get_config(args: Args) -> PlayoutConfig {
if let Some(folder) = args.folder { if let Some(folder) = args.folder {
config.storage.path = folder; config.storage.path = folder;
config.processing.mode = "folder".into(); config.processing.mode = Folder;
} }
if let Some(start) = args.start { if let Some(start) = args.start {
@ -80,4 +86,130 @@ pub fn get_config(args: Args) -> PlayoutConfig {
config config
} }
// Read command line arguments, and override the config with them.
/// Prepare output parameters
///
/// seek for multiple outputs and add mapping for it
pub fn prepare_output_cmd(
mut cmd: Vec<String>,
mut filter: Vec<String>,
config: &PlayoutConfig,
) -> Vec<String> {
let mut output_params = config.out.clone().output_cmd.unwrap();
let mut new_params = vec![];
let params_len = output_params.len();
let mut output_a_map = "[a_out1]".to_string();
let mut output_v_map = "[v_out1]".to_string();
let mut out_count = 1;
let mut output_filter = String::new();
let mut next_is_filter = false;
let re_audio_map = Regex::new(r"\[0:a:(?P<num>[0-9]+)\]").unwrap();
// Loop over output parameters
//
// Check if it contains a filtergraph, count its outputs and set correct mapping values.
for (i, p) in output_params.iter().enumerate() {
let mut param = p.clone();
param = param.replace("[0:v]", "[vout0]");
param = param.replace("[0:a]", "[aout0]");
param = re_audio_map.replace_all(&param, "[aout$num]").to_string();
// Skip filter command, to concat existing filters with new ones.
if param != "-filter_complex" {
if next_is_filter {
output_filter = param.clone();
next_is_filter = false;
} else {
new_params.push(param.clone());
}
} else {
next_is_filter = true;
}
// Check if parameter is a output
if i > 0
&& !param.starts_with('-')
&& !output_params[i - 1].starts_with('-')
&& i < params_len - 1
{
out_count += 1;
let mut a_map = "0:a".to_string();
let v_map = format!("[v_out{out_count}]");
output_v_map.push_str(&v_map);
if config.out.mode == HLS {
a_map = format!("[a_out{out_count}]");
}
output_a_map.push_str(&a_map);
if !output_params.contains(&"-map".to_string()) {
let mut map = vec_strings!["-map", v_map, "-map", a_map];
new_params.append(&mut map);
}
}
}
if !filter.is_empty() {
output_params = new_params;
// Process A/V mapping
//
// Check if there is multiple outputs, and/or multiple audio tracks
// and add the correct mapping for it.
if out_count > 1 && config.processing.audio_tracks == 1 && config.out.mode == HLS {
filter[1].push_str(&format!(";[vout0]split={out_count}{output_v_map}"));
filter[1].push_str(&format!(";[aout0]asplit={out_count}{output_a_map}"));
filter.drain(2..);
cmd.append(&mut filter);
cmd.append(&mut vec_strings!["-map", "[v_out1]", "-map", "[a_out1]"]);
} else if !output_filter.is_empty() && config.out.mode == HLS {
filter[1].push_str(&format!(";{output_filter}"));
filter.drain(2..);
cmd.append(&mut filter);
} else if out_count == 1
&& config.processing.audio_tracks == 1
&& config.out.mode == HLS
&& output_params[0].contains("split")
{
let out_filter = output_params.remove(0);
filter[1].push_str(&format!(";{out_filter}"));
filter.drain(2..);
cmd.append(&mut filter);
} else if out_count > 1 && config.processing.audio_tracks == 1 && config.out.mode == Stream
{
filter[1].push_str(&format!(",split={out_count}{output_v_map}"));
cmd.append(&mut filter);
cmd.append(&mut vec_strings!["-map", "[v_out1]", "-map", "0:a"]);
} else if config.processing.audio_tracks > 1 && config.out.mode == Stream {
filter[1].push_str("[v_out1]");
cmd.append(&mut filter);
output_params = output_params
.iter()
.map(|p| p.replace("0:v", "[v_out1]"))
.collect();
if out_count == 1 {
cmd.append(&mut vec_strings!["-map", "[v_out1]"]);
for i in 0..config.processing.audio_tracks {
cmd.append(&mut vec_strings!["-map", format!("0:a:{i}")]);
}
}
} else {
cmd.append(&mut filter);
}
} else if out_count == 1 && config.processing.audio_tracks > 1 && config.out.mode == Stream {
cmd.append(&mut vec_strings!["-map", "0:v"]);
for i in 0..config.processing.audio_tracks {
cmd.append(&mut vec_strings!["-map", format!("0:a:{i}")]);
}
}
cmd.append(&mut output_params);
cmd
}

@ -1 +1 @@
Subproject commit 51719eb9c03d26da8ffac6a677d1c41b756bdf83 Subproject commit 0994fd00d16354b3d3059e8e3fae2ed256264460

View File

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

View File

@ -1,85 +1,140 @@
use std::{ use std::{
fmt,
path::Path, path::Path,
sync::{Arc, Mutex}, sync::{Arc, Mutex},
}; };
use simplelog::*; use simplelog::*;
pub mod a_loudnorm; mod a_loudnorm;
pub mod custom_filter; mod custom_filter;
pub mod v_drawtext; pub mod v_drawtext;
pub mod v_overlay;
use crate::utils::{fps_calc, get_delta, is_close, Media, MediaProbe, PlayoutConfig}; // get_delta
use self::custom_filter::custom_filter;
use crate::utils::{
fps_calc, get_delta, is_close, Media, MediaProbe, OutputMode::*, PlayoutConfig,
};
#[derive(Clone, Copy, PartialEq)] #[derive(Clone, Debug, Copy, PartialEq)]
enum FilterType { enum FilterType {
Audio, Audio,
Video, Video,
} }
use FilterType::*; impl fmt::Display for FilterType {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
FilterType::Audio => write!(f, "a"),
FilterType::Video => write!(f, "v"),
}
}
}
use self::custom_filter::custom_filter; use FilterType::*;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct Filters { struct Filters {
audio_chain: Option<String>, audio_chain: String,
video_chain: Option<String>, video_chain: String,
audio_map: String, final_chain: String,
video_map: String, audio_map: Vec<String>,
video_map: Vec<String>,
output_map: Vec<String>,
audio_position: i32,
video_position: i32,
audio_last: i32,
video_last: i32,
cmd: Vec<String>,
} }
impl Filters { impl Filters {
fn new() -> Self { fn new(position: i32) -> Self {
Filters { Self {
audio_chain: None, audio_chain: String::new(),
video_chain: None, video_chain: String::new(),
audio_map: "0:a".to_string(), final_chain: String::new(),
video_map: "0:v".to_string(), audio_map: vec![],
video_map: vec![],
output_map: vec![],
audio_position: position,
video_position: position,
audio_last: -1,
video_last: -1,
cmd: vec![],
} }
} }
fn add_filter(&mut self, filter: &str, codec_type: FilterType) { fn add_filter(&mut self, filter: &str, track_nr: i32, filter_type: FilterType) {
match codec_type { let (map, chain, position, last) = match filter_type {
Audio => match &self.audio_chain { Audio => (
Some(ac) => { &mut self.audio_map,
if filter.starts_with(';') || filter.starts_with('[') { &mut self.audio_chain,
self.audio_chain = Some(format!("{ac}{filter}")) self.audio_position,
} else { &mut self.audio_last,
self.audio_chain = Some(format!("{ac},{filter}")) ),
} Video => (
} &mut self.video_map,
None => { &mut self.video_chain,
if filter.contains("aevalsrc") || filter.contains("anoisesrc") { self.video_position,
self.audio_chain = Some(filter.to_string()); &mut self.video_last,
} else { ),
self.audio_chain = Some(format!("[{}]{filter}", self.audio_map.clone())); };
}
self.audio_map = "[aout1]".to_string(); if *last != track_nr {
} // start new filter chain
}, let mut selector = String::new();
Video => match &self.video_chain { let mut sep = String::new();
Some(vc) => { if !chain.is_empty() {
if filter.starts_with(';') || filter.starts_with('[') { selector = format!("[{}out{}]", filter_type, last);
self.video_chain = Some(format!("{vc}{filter}")) sep = ";".to_string()
} else { }
self.video_chain = Some(format!("{vc},{filter}"))
} chain.push_str(&selector);
}
None => { if filter.starts_with("aevalsrc") || filter.starts_with("movie") {
self.video_chain = Some(format!("[0:v]{filter}")); chain.push_str(&format!("{sep}{filter}"));
self.video_map = "[vout1]".to_string(); } else {
} chain.push_str(&format!(
}, "{sep}[{}:{}:{track_nr}]{filter}",
position, filter_type
));
}
let m = format!("[{}out{track_nr}]", filter_type);
map.push(m.clone());
self.output_map.append(&mut vec!["-map".to_string(), m]);
*last = track_nr;
} else if filter.starts_with(';') || filter.starts_with('[') {
chain.push_str(filter);
} else {
chain.push_str(&format!(",{filter}"))
} }
} }
fn close_chains(&mut self) {
// add final output selector
self.audio_chain
.push_str(&format!("[aout{}]", self.audio_last));
self.video_chain
.push_str(&format!("[vout{}]", self.video_last));
}
fn build_final_chain(&mut self) {
self.final_chain.push_str(&self.video_chain);
self.final_chain.push(';');
self.final_chain.push_str(&self.audio_chain);
self.cmd.push("-filter_complex".to_string());
self.cmd.push(self.final_chain.clone());
self.cmd.append(&mut self.output_map);
}
} }
fn deinterlace(field_order: &Option<String>, chain: &mut Filters) { fn deinterlace(field_order: &Option<String>, chain: &mut Filters) {
if let Some(order) = field_order { if let Some(order) = field_order {
if order != "progressive" { if order != "progressive" {
chain.add_filter("yadif=0:-1:0", Video) chain.add_filter("yadif=0:-1:0", 0, Video)
} }
} }
} }
@ -100,6 +155,7 @@ fn pad(aspect: f64, chain: &mut Filters, v_stream: &ffprobe::Stream, config: &Pl
"{scale}pad=max(iw\\,ih*({0}/{1})):ow/({0}/{1}):(ow-iw)/2:(oh-ih)/2", "{scale}pad=max(iw\\,ih*({0}/{1})):ow/({0}/{1}):(ow-iw)/2:(oh-ih)/2",
config.processing.width, config.processing.height config.processing.width, config.processing.height
), ),
0,
Video, Video,
) )
} }
@ -107,7 +163,7 @@ fn pad(aspect: f64, chain: &mut Filters, v_stream: &ffprobe::Stream, config: &Pl
fn fps(fps: f64, chain: &mut Filters, config: &PlayoutConfig) { fn fps(fps: f64, chain: &mut Filters, config: &PlayoutConfig) {
if fps != config.processing.fps { if fps != config.processing.fps {
chain.add_filter(&format!("fps={}", config.processing.fps), Video) chain.add_filter(&format!("fps={}", config.processing.fps), 0, Video)
} }
} }
@ -126,14 +182,19 @@ fn scale(
"scale={}:{}", "scale={}:{}",
config.processing.width, config.processing.height config.processing.width, config.processing.height
), ),
0,
Video, Video,
); );
} else { } else {
chain.add_filter("null", Video); chain.add_filter("null", 0, Video);
} }
if !is_close(aspect, config.processing.aspect, 0.03) { if !is_close(aspect, config.processing.aspect, 0.03) {
chain.add_filter(&format!("setdar=dar={}", config.processing.aspect), Video) chain.add_filter(
&format!("setdar=dar={}", config.processing.aspect),
0,
Video,
)
} }
} else { } else {
chain.add_filter( chain.add_filter(
@ -141,27 +202,33 @@ fn scale(
"scale={}:{}", "scale={}:{}",
config.processing.width, config.processing.height config.processing.width, config.processing.height
), ),
0,
Video, Video,
); );
chain.add_filter(&format!("setdar=dar={}", config.processing.aspect), Video) chain.add_filter(
&format!("setdar=dar={}", config.processing.aspect),
0,
Video,
)
} }
} }
fn fade(node: &mut Media, chain: &mut Filters, codec_type: FilterType) { fn fade(node: &mut Media, chain: &mut Filters, nr: i32, filter_type: FilterType) {
let mut t = ""; let mut t = "";
if codec_type == Audio { if filter_type == Audio {
t = "a" t = "a"
} }
if node.seek > 0.0 || node.is_live == Some(true) { if node.seek > 0.0 || node.is_live == Some(true) {
chain.add_filter(&format!("{t}fade=in:st=0:d=0.5"), codec_type) chain.add_filter(&format!("{t}fade=in:st=0:d=0.5"), nr, filter_type)
} }
if node.out != node.duration && node.out - node.seek - 1.0 > 0.0 { if node.out != node.duration && node.out - node.seek - 1.0 > 0.0 {
chain.add_filter( chain.add_filter(
&format!("{t}fade=out:st={}:d=1.0", (node.out - node.seek - 1.0)), &format!("{t}fade=out:st={}:d=1.0", (node.out - node.seek - 1.0)),
codec_type, nr,
filter_type,
) )
} }
} }
@ -171,7 +238,10 @@ fn overlay(node: &mut Media, chain: &mut Filters, config: &PlayoutConfig) {
&& Path::new(&config.processing.logo).is_file() && Path::new(&config.processing.logo).is_file()
&& &node.category != "advertisement" && &node.category != "advertisement"
{ {
let mut logo_chain = v_overlay::filter_node(config, false); let mut logo_chain = format!(
"null[v];movie={}:loop=0,setpts=N/(FRAME_RATE*TB),format=rgba,colorchannelmixer=aa={}[l];[v][l]{}:shortest=1",
config.processing.logo, config.processing.logo_opacity, config.processing.logo_filter
);
if node.last_ad.unwrap_or(false) { if node.last_ad.unwrap_or(false) {
logo_chain.push_str(",fade=in:st=0:d=1.0:alpha=1") logo_chain.push_str(",fade=in:st=0:d=1.0:alpha=1")
@ -183,10 +253,7 @@ fn overlay(node: &mut Media, chain: &mut Filters, config: &PlayoutConfig) {
) )
} }
logo_chain chain.add_filter(&logo_chain, 0, Video);
.push_str(format!("[l];[v][l]{}:shortest=1", config.processing.logo_filter).as_str());
chain.add_filter(&logo_chain, Video);
} }
} }
@ -204,6 +271,7 @@ fn extend_video(node: &mut Media, chain: &mut Filters) {
"tpad=stop_mode=add:stop_duration={}", "tpad=stop_mode=add:stop_duration={}",
(node.out - node.seek) - (video_duration - node.seek) (node.out - node.seek) - (video_duration - node.seek)
), ),
0,
Video, Video,
) )
} }
@ -217,33 +285,22 @@ fn add_text(
config: &PlayoutConfig, config: &PlayoutConfig,
filter_chain: &Arc<Mutex<Vec<String>>>, filter_chain: &Arc<Mutex<Vec<String>>>,
) { ) {
if config.text.add_text if config.text.add_text && (config.text.text_from_filename || config.out.mode == HLS) {
&& (config.text.text_from_filename || config.out.mode.to_lowercase() == "hls")
{
let filter = v_drawtext::filter_node(config, Some(node), filter_chain); let filter = v_drawtext::filter_node(config, Some(node), filter_chain);
chain.add_filter(&filter, Video); chain.add_filter(&filter, 0, Video);
} }
} }
fn add_audio(node: &mut Media, chain: &mut Filters) { fn add_audio(node: &Media, chain: &mut Filters, nr: i32) {
if node let audio = format!(
.probe "aevalsrc=0:channel_layout=stereo:duration={}:sample_rate=48000",
.as_ref() node.out - node.seek
.and_then(|p| p.audio_streams.get(0)) );
.is_none() chain.add_filter(&audio, nr, Audio);
&& !Path::new(&node.audio).is_file()
{
warn!("Clip <b><magenta>{}</></b> has no audio!", node.source);
let audio = format!(
"aevalsrc=0:channel_layout=stereo:duration={}:sample_rate=48000",
node.out - node.seek
);
chain.add_filter(&audio, Audio);
}
} }
fn extend_audio(node: &mut Media, chain: &mut Filters) { fn extend_audio(node: &mut Media, chain: &mut Filters, nr: i32) {
let probe = if Path::new(&node.audio).is_file() { let probe = if Path::new(&node.audio).is_file() {
Some(MediaProbe::new(&node.audio)) Some(MediaProbe::new(&node.audio))
} else { } else {
@ -257,22 +314,26 @@ fn extend_audio(node: &mut Media, chain: &mut Filters) {
.and_then(|a| a.parse::<f64>().ok()) .and_then(|a| a.parse::<f64>().ok())
{ {
if node.out - node.seek > audio_duration - node.seek + 0.1 && node.duration >= node.out { if node.out - node.seek > audio_duration - node.seek + 0.1 && node.duration >= node.out {
chain.add_filter(&format!("apad=whole_dur={}", node.out - node.seek), Audio) chain.add_filter(
&format!("apad=whole_dur={}", node.out - node.seek),
nr,
Audio,
)
} }
} }
} }
/// Add single pass loudnorm filter to audio line. /// Add single pass loudnorm filter to audio line.
fn add_loudnorm(chain: &mut Filters, config: &PlayoutConfig) { fn add_loudnorm(chain: &mut Filters, config: &PlayoutConfig, nr: i32) {
if config.processing.add_loudnorm { if config.processing.add_loudnorm {
let loud_filter = a_loudnorm::filter_node(config); let loud_filter = a_loudnorm::filter_node(config);
chain.add_filter(&loud_filter, Audio); chain.add_filter(&loud_filter, nr, Audio);
} }
} }
fn audio_volume(chain: &mut Filters, config: &PlayoutConfig) { fn audio_volume(chain: &mut Filters, config: &PlayoutConfig, nr: i32) {
if config.processing.volume != 1.0 { if config.processing.volume != 1.0 {
chain.add_filter(&format!("volume={}", config.processing.volume), Audio) chain.add_filter(&format!("volume={}", config.processing.volume), nr, Audio)
} }
} }
@ -290,20 +351,9 @@ fn aspect_calc(aspect_string: &Option<String>, config: &PlayoutConfig) -> f64 {
} }
/// This realtime filter is important for HLS output to stay in sync. /// This realtime filter is important for HLS output to stay in sync.
fn realtime_filter( fn realtime(node: &mut Media, chain: &mut Filters, config: &PlayoutConfig) {
node: &mut Media, if config.general.generate.is_none() && config.out.mode == HLS {
chain: &mut Filters, let mut speed_filter = "realtime=speed=1".to_string();
config: &PlayoutConfig,
codec_type: FilterType,
) {
if config.general.generate.is_none() && &config.out.mode.to_lowercase() == "hls" {
let mut t = "";
if codec_type == Audio {
t = "a"
}
let mut speed_filter = format!("{t}realtime=speed=1");
if let Some(begin) = &node.begin { if let Some(begin) = &node.begin {
let (delta, _) = get_delta(config, begin); let (delta, _) = get_delta(config, begin);
@ -313,24 +363,18 @@ fn realtime_filter(
let speed = duration / (duration + delta); let speed = duration / (duration + delta);
if speed > 0.0 && speed < 1.1 && delta < config.general.stop_threshold { if speed > 0.0 && speed < 1.1 && delta < config.general.stop_threshold {
speed_filter = format!("{t}realtime=speed={speed}"); speed_filter = format!("realtime=speed={speed}");
} }
} }
} }
chain.add_filter(&speed_filter, codec_type); chain.add_filter(&speed_filter, 0, Video);
} }
} }
fn custom(filter: &str, chain: &mut Filters) { fn custom(filter: &str, chain: &mut Filters, nr: i32, filter_type: FilterType) {
let (video_filter, audio_filter) = custom_filter(filter); if !filter.is_empty() {
chain.add_filter(filter, nr, filter_type);
if !video_filter.is_empty() {
chain.add_filter(&video_filter, Video);
}
if !audio_filter.is_empty() {
chain.add_filter(&audio_filter, Audio);
} }
} }
@ -339,11 +383,11 @@ pub fn filter_chains(
node: &mut Media, node: &mut Media,
filter_chain: &Arc<Mutex<Vec<String>>>, filter_chain: &Arc<Mutex<Vec<String>>>,
) -> Vec<String> { ) -> Vec<String> {
let mut filters = Filters::new(); let mut filters = Filters::new(0);
if let Some(probe) = node.probe.as_ref() { if let Some(probe) = node.probe.as_ref() {
if probe.audio_streams.get(0).is_none() || Path::new(&node.audio).is_file() { if Path::new(&node.audio).is_file() {
filters.audio_map = "1:a".to_string(); filters.audio_position = 1;
} }
if let Some(v_stream) = &probe.video_streams.get(0) { if let Some(v_stream) = &probe.video_streams.get(0) {
@ -363,56 +407,51 @@ pub fn filter_chains(
} }
extend_video(node, &mut filters); extend_video(node, &mut filters);
add_audio(node, &mut filters);
extend_audio(node, &mut filters);
} else { } else {
fps(0.0, &mut filters, config); fps(0.0, &mut filters, config);
scale(None, None, 1.0, &mut filters, config); scale(None, None, 1.0, &mut filters, config);
} }
add_text(node, &mut filters, config, filter_chain); add_text(node, &mut filters, config, filter_chain);
fade(node, &mut filters, Video); fade(node, &mut filters, 0, Video);
overlay(node, &mut filters, config); overlay(node, &mut filters, config);
realtime_filter(node, &mut filters, config, Video); realtime(node, &mut filters, config);
add_loudnorm(&mut filters, config); let (proc_vf, proc_af) = custom_filter(&config.processing.custom_filter);
fade(node, &mut filters, Audio); let (list_vf, list_af) = custom_filter(&node.custom_filter);
audio_volume(&mut filters, config);
realtime_filter(node, &mut filters, config, Audio);
custom(&config.processing.custom_filter, &mut filters); custom(&proc_vf, &mut filters, 0, Video);
custom(&node.custom_filter, &mut filters); custom(&list_vf, &mut filters, 0, Video);
let mut filter_cmd = vec![]; for i in 0..config.processing.audio_tracks {
let mut filter_str: String = String::new(); if node
let mut filter_map: Vec<String> = vec![]; .probe
.as_ref()
if let Some(v_filters) = filters.video_chain { .and_then(|p| p.audio_streams.get(i as usize))
filter_str.push_str(v_filters.as_str()); .is_some()
filter_str.push_str(filters.video_map.clone().as_str()); {
filter_map.append(&mut vec!["-map".to_string(), filters.video_map]); extend_audio(node, &mut filters, i);
} else { } else if !node.is_live.unwrap_or(false) {
filter_map.append(&mut vec!["-map".to_string(), "0:v".to_string()]); warn!(
} "Missing audio track (id {i}) from <b><magenta>{}</></b>",
node.source
if let Some(a_filters) = filters.audio_chain { );
if filter_str.len() > 10 { add_audio(node, &mut filters, i);
filter_str.push(';')
} }
filter_str.push_str(a_filters.as_str()); // add at least anull filter, for correct filter construction,
filter_str.push_str(filters.audio_map.clone().as_str()); // is important for split filter in HLS mode
filter_map.append(&mut vec!["-map".to_string(), filters.audio_map]); filters.add_filter("anull", i, Audio);
} else {
filter_map.append(&mut vec!["-map".to_string(), filters.audio_map]); add_loudnorm(&mut filters, config, i);
fade(node, &mut filters, i, Audio);
audio_volume(&mut filters, config, i);
custom(&proc_af, &mut filters, i, Audio);
custom(&list_af, &mut filters, i, Audio);
} }
if filter_str.len() > 10 { filters.close_chains();
filter_cmd.push("-filter_complex".to_string()); filters.build_final_chain();
filter_cmd.push(filter_str);
}
filter_cmd.append(&mut filter_map); filters.cmd
filter_cmd
} }

View File

@ -26,12 +26,9 @@ pub fn filter_node(
None => config.text.zmq_stream_socket.clone(), None => config.text.zmq_stream_socket.clone(),
}; };
// TODO: in Rust 1.64 use let_chains instead // TODO: in Rust 1.65 use let_chains instead
if config.text.text_from_filename && node.is_some() { if config.text.text_from_filename && node.is_some() {
let source = node let source = node.unwrap_or(&Media::new(0, "", false)).source.clone();
.unwrap_or(&Media::new(0, String::new(), false))
.source
.clone();
let regex: Regex = Regex::new(&config.text.regex).unwrap(); let regex: Regex = Regex::new(&config.text.regex).unwrap();
let text: String = match regex.captures(&source) { let text: String = match regex.captures(&source) {

View File

@ -1,32 +0,0 @@
use crate::utils::PlayoutConfig;
/// Overlay Filter
///
/// When a logo is set, we create here the filter for the server.
pub fn filter_node(config: &PlayoutConfig, add_tail: bool) -> String {
let mut logo_chain = String::new();
if !config.processing.add_logo {
return logo_chain;
}
if let Some(fps) = config.processing.logo_fps {
let opacity = format!(
"format=rgba,colorchannelmixer=aa={}",
config.processing.logo_opacity
);
let pts = format!("setpts=N/({fps}*TB)");
logo_chain = format!(
"null[v];movie={}:loop=0,{pts},{opacity}",
config.processing.logo
);
if add_tail {
logo_chain.push_str(
format!("[l];[v][l]{}:shortest=1", config.processing.logo_filter).as_str(),
);
}
};
logo_chain
}

View File

@ -4,6 +4,3 @@ extern crate simplelog;
pub mod filter; pub mod filter;
pub mod macros; pub mod macros;
pub mod utils; pub mod utils;
#[cfg(test)]
mod tests;

View File

@ -1,116 +0,0 @@
#[cfg(test)]
use chrono::prelude::*;
#[cfg(test)]
use crate::utils::*;
use crate::vec_strings;
#[test]
fn mock_date_time() {
let time_str = "2022-05-20T06:00:00";
let date_obj = NaiveDateTime::parse_from_str(time_str, "%Y-%m-%dT%H:%M:%S");
let time = Local.from_local_datetime(&date_obj.unwrap()).unwrap();
mock_time::set_mock_time(time_str);
assert_eq!(
time.format("%Y-%m-%dT%H:%M:%S.2f").to_string(),
time_now().format("%Y-%m-%dT%H:%M:%S.2f").to_string()
);
}
#[test]
fn get_date_yesterday() {
mock_time::set_mock_time("2022-05-20T05:59:24");
let date = get_date(true, 21600.0, 86400.0);
assert_eq!("2022-05-19".to_string(), date);
}
#[test]
fn get_date_tomorrow() {
mock_time::set_mock_time("2022-05-20T23:59:30");
let date = get_date(false, 0.0, 86400.01);
assert_eq!("2022-05-21".to_string(), date);
}
#[test]
fn test_delta() {
let mut config = PlayoutConfig::new(None);
config.mail.recipient = "".into();
config.processing.mode = "playlist".into();
config.playlist.day_start = "00:00:00".into();
config.playlist.length = "24:00:00".into();
config.logging.log_to_file = false;
mock_time::set_mock_time("2022-05-09T23:59:59");
let (delta, _) = get_delta(&config, &86401.0);
assert!(delta < 2.0);
}
#[test]
fn test_prepare_output_cmd() {
let enc_prefix = vec_strings![
"-hide_banner",
"-nostats",
"-v",
"level+error",
"-re",
"-i",
"pipe:0"
];
let filter = vec_strings![
"-filter_complex",
"[0:v]null,zmq=b=tcp\\\\://'127.0.0.1\\:5555',drawtext=text=''"
];
let params = vec_strings![
"-c:v",
"libx264",
"-flags",
"+global_header",
"-f",
"flv",
"rtmp://localhost/live/stream",
"-s",
"512x288",
"-c:v",
"libx264",
"-flags",
"+global_header",
"-f",
"flv",
"rtmp://localhost:1937/live/stream"
];
let mut t1_params = enc_prefix.clone();
t1_params.append(&mut params.clone());
let cmd_two_outs =
prepare_output_cmd(enc_prefix.clone(), vec_strings![], params.clone(), "stream");
assert_eq!(cmd_two_outs, t1_params);
let mut test_cmd = enc_prefix.clone();
let mut test_params = params.clone();
let mut t2_filter = filter.clone();
t2_filter[1].push_str(",split=2[v_out1][v_out2]");
test_cmd.append(&mut t2_filter);
test_params.insert(0, "-map".to_string());
test_params.insert(1, "[v_out1]".to_string());
test_params.insert(2, "-map".to_string());
test_params.insert(3, "0:a".to_string());
test_params.insert(11, "-map".to_string());
test_params.insert(12, "[v_out2]".to_string());
test_params.insert(13, "-map".to_string());
test_params.insert(14, "0:a".to_string());
test_cmd.append(&mut test_params);
let cmd_two_outs_with_filter = prepare_output_cmd(enc_prefix, filter, params, "stream");
assert_eq!(cmd_two_outs_with_filter, test_cmd);
}

View File

@ -1,14 +1,15 @@
use std::{ use std::{
env, env, fmt,
fs::File, fs::File,
path::{Path, PathBuf}, path::{Path, PathBuf},
process, process,
str::FromStr,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use shlex::split; use shlex::split;
use crate::utils::{fps_calc, free_tcp_socket, home_dir, time_to_sec, MediaProbe}; use crate::utils::{free_tcp_socket, home_dir, time_to_sec};
use crate::vec_strings; use crate::vec_strings;
pub const DUMMY_LEN: f64 = 60.0; pub const DUMMY_LEN: f64 = 60.0;
@ -17,6 +18,64 @@ pub const IMAGE_FORMAT: [&str; 21] = [
"png", "psd", "ppm", "sgi", "svg", "tga", "tif", "webp", "png", "psd", "ppm", "sgi", "svg", "tga", "tif", "webp",
]; ];
// Some well known errors can be safely ignore
pub const FFMPEG_IGNORE_ERRORS: [&str; 3] = [
"Referenced QT chapter track not found",
"ac-tex damaged",
"Warning MVs not available",
];
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum OutputMode {
Desktop,
HLS,
Null,
Stream,
}
impl FromStr for OutputMode {
type Err = String;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match input {
"desktop" => Ok(Self::Desktop),
"hls" => Ok(Self::HLS),
"null" => Ok(Self::Null),
"stream" => Ok(Self::Stream),
_ => Err(String::new()),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ProcessMode {
Folder,
Playlist,
}
impl fmt::Display for ProcessMode {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
ProcessMode::Folder => write!(f, "folder"),
ProcessMode::Playlist => write!(f, "playlist"),
}
}
}
impl FromStr for ProcessMode {
type Err = String;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match input {
"folder" => Ok(Self::Folder),
"playlist" => Ok(Self::Playlist),
_ => Err(String::new()),
}
}
}
/// Global Config /// Global Config
/// ///
/// This we init ones, when ffplayout is starting and use them globally in the hole program. /// This we init ones, when ffplayout is starting and use them globally in the hole program.
@ -82,20 +141,18 @@ pub struct Logging {
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Processing { pub struct Processing {
pub help_text: String, pub help_text: String,
pub mode: String, pub mode: ProcessMode,
pub width: i64, pub width: i64,
pub height: i64, pub height: i64,
pub aspect: f64, pub aspect: f64,
pub fps: f64, pub fps: f64,
pub add_logo: bool, pub add_logo: bool,
pub logo: String, pub logo: String,
#[serde(skip_serializing, skip_deserializing)]
pub logo_fps: Option<f64>,
pub logo_scale: String, pub logo_scale: String,
pub logo_opacity: f32, pub logo_opacity: f32,
pub logo_filter: String, pub logo_filter: String,
#[serde(default)]
pub audio_tracks: i32,
pub add_loudnorm: bool, pub add_loudnorm: bool,
pub loudnorm_ingest: bool, pub loudnorm_ingest: bool,
pub loud_i: f32, pub loud_i: f32,
@ -168,7 +225,7 @@ pub struct Text {
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Out { pub struct Out {
pub help_text: String, pub help_text: String,
pub mode: String, pub mode: OutputMode,
pub output_param: String, pub output_param: String,
#[serde(skip_serializing, skip_deserializing)] #[serde(skip_serializing, skip_deserializing)]
@ -227,17 +284,12 @@ impl PlayoutConfig {
config.playlist.length_sec = Some(86400.0); config.playlist.length_sec = Some(86400.0);
} }
config.processing.logo_fps = None; if config.processing.add_logo && !Path::new(&config.processing.logo).is_file() {
config.processing.add_logo = false;
}
if Path::new(&config.processing.logo).is_file() { if config.processing.audio_tracks < 1 {
if let Some(v_stream) = MediaProbe::new(&config.processing.logo) config.processing.audio_tracks = 1
.video_streams
.get(0)
{
let fps = fps_calc(&v_stream.r_frame_rate, config.processing.fps);
config.processing.logo_fps = Some(fps);
};
} }
// We set the decoder settings here, so we only define them ones. // We set the decoder settings here, so we only define them ones.

View File

@ -92,9 +92,7 @@ impl ProcessControl {
} }
} }
if let Err(e) = self.wait(unit) { self.wait(unit)?;
return Err(e);
};
Ok(()) Ok(())
} }
@ -169,7 +167,7 @@ impl PlayerControl {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
current_media: Arc::new(Mutex::new(None)), current_media: Arc::new(Mutex::new(None)),
current_list: Arc::new(Mutex::new(vec![Media::new(0, String::new(), false)])), current_list: Arc::new(Mutex::new(vec![Media::new(0, "", false)])),
index: Arc::new(AtomicUsize::new(0)), index: Arc::new(AtomicUsize::new(0)),
} }
} }

View File

@ -49,7 +49,7 @@ impl FolderSource {
.filter(|f| f.path().is_file()) .filter(|f| f.path().is_file())
{ {
if include_file(config.clone(), entry.path()) { if include_file(config.clone(), entry.path()) {
let media = Media::new(0, entry.path().display().to_string(), false); let media = Media::new(0, &entry.path().to_string_lossy(), false);
media_list.push(media); media_list.push(media);
} }
} }
@ -83,7 +83,7 @@ impl FolderSource {
config: config.clone(), config: config.clone(),
filter_chain, filter_chain,
nodes: current_list, nodes: current_list,
current_node: Media::new(0, String::new(), false), current_node: Media::new(0, "", false),
index: global_index, index: global_index,
} }
} }

View File

@ -65,7 +65,7 @@ pub fn generate_playlist(
} }
} }
}; };
let current_list = Arc::new(Mutex::new(vec![Media::new(0, "".to_string(), false)])); let current_list = Arc::new(Mutex::new(vec![Media::new(0, "", false)]));
let index = Arc::new(AtomicUsize::new(0)); let index = Arc::new(AtomicUsize::new(0));
let playlist_root = Path::new(&config.playlist.path); let playlist_root = Path::new(&config.playlist.path);
let mut playlists = vec![]; let mut playlists = vec![];
@ -119,7 +119,7 @@ pub fn generate_playlist(
playlist_file.display() playlist_file.display()
); );
let mut filler = Media::new(0, config.storage.filler_clip.clone(), true); let mut filler = Media::new(0, &config.storage.filler_clip, true);
let filler_length = filler.duration; let filler_length = filler.duration;
let mut length = 0.0; let mut length = 0.0;
let mut round = 0; let mut round = 0;

78
lib/src/utils/import.rs Normal file
View File

@ -0,0 +1,78 @@
/// Import text/m3u file and create a playlist out of it
use std::{
//error::Error,
fs::{create_dir_all, File},
io::{BufRead, BufReader, Error, ErrorKind},
path::Path,
};
use crate::utils::{json_reader, json_serializer::JsonPlaylist, json_writer, Media, PlayoutConfig};
pub fn import_file(
config: &PlayoutConfig,
date: &str,
channel_name: Option<String>,
path: &str,
) -> Result<String, Error> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut playlist = JsonPlaylist {
channel: channel_name.unwrap_or_else(|| "Channel 1".to_string()),
date: date.to_string(),
current_file: None,
start_sec: None,
modified: None,
program: vec![],
};
let playlist_root = Path::new(&config.playlist.path);
if !playlist_root.is_dir() {
return Err(Error::new(
ErrorKind::Other,
format!(
"Playlist folder <b><magenta>{}</></b> not exists!",
&config.playlist.path,
),
));
}
let d: Vec<&str> = date.split('-').collect();
let year = d[0];
let month = d[1];
let playlist_path = playlist_root.join(year).join(month);
let playlist_file = &playlist_path.join(format!("{date}.json"));
create_dir_all(playlist_path)?;
for line in reader.lines() {
let line = line?;
if !line.starts_with('#') {
let item = Media::new(0, &line, true);
playlist.program.push(item);
}
}
let mut file_exists = false;
if playlist_file.is_file() {
file_exists = true;
let existing_data = json_reader(playlist_file)?;
if playlist == existing_data {
return Ok(format!("Playlist from {date}, already exists!"));
}
};
let mut msg = format!("Write playlist from {date} success!");
if file_exists {
msg = format!("Update playlist from {date} success!");
}
match json_writer(playlist_file, playlist) {
Ok(_) => Ok(msg),
Err(e) => Err(Error::new(ErrorKind::Other, e)),
}
}

View File

@ -34,7 +34,7 @@ pub struct JsonPlaylist {
impl JsonPlaylist { impl JsonPlaylist {
fn new(date: String, start: f64) -> Self { fn new(date: String, start: f64) -> Self {
let mut media = Media::new(0, String::new(), false); let mut media = Media::new(0, "", false);
media.begin = Some(start); media.begin = Some(start);
media.duration = DUMMY_LEN; media.duration = DUMMY_LEN;
media.out = DUMMY_LEN; media.out = DUMMY_LEN;

View File

@ -11,7 +11,7 @@ use simplelog::*;
use crate::utils::{ use crate::utils::{
format_log_line, loop_image, sec_to_time, seek_and_length, valid_source, vec_strings, format_log_line, loop_image, sec_to_time, seek_and_length, valid_source, vec_strings,
JsonPlaylist, Media, PlayoutConfig, IMAGE_FORMAT, JsonPlaylist, Media, PlayoutConfig, FFMPEG_IGNORE_ERRORS, IMAGE_FORMAT,
}; };
/// check if ffmpeg can read the file and apply filter to it. /// check if ffmpeg can read the file and apply filter to it.
@ -58,9 +58,7 @@ fn check_media(
let mut filter = node.filter.unwrap_or_default(); let mut filter = node.filter.unwrap_or_default();
if filter.len() > 1 { if filter.len() > 1 {
filter[1] = filter[1] filter[1] = filter[1].replace("realtime=speed=1", "null")
.replace("realtime=speed=1", "null")
.replace("arealtime=speed=1", "snull")
} }
enc_cmd.append(&mut node.cmd.unwrap_or_default()); enc_cmd.append(&mut node.cmd.unwrap_or_default());
@ -81,24 +79,26 @@ fn check_media(
for line in enc_err.lines() { for line in enc_err.lines() {
let line = line?; let line = line?;
if line.contains("[error]") { if !FFMPEG_IGNORE_ERRORS.iter().any(|i| line.contains(*i)) {
let log_line = format_log_line(line, "error"); if line.contains("[error]") {
let log_line = format_log_line(line, "error");
if !error_list.contains(&log_line) { if !error_list.contains(&log_line) {
error_list.push(log_line); error_list.push(log_line);
} }
} else if line.contains("[fatal]") { } else if line.contains("[fatal]") {
let log_line = format_log_line(line, "fatal"); let log_line = format_log_line(line, "fatal");
if !error_list.contains(&log_line) { if !error_list.contains(&log_line) {
error_list.push(log_line); error_list.push(log_line);
}
} }
} }
} }
if !error_list.is_empty() { if !error_list.is_empty() {
error!( error!(
"<bright black>[Validator]</> Compressing error on position <yellow>{pos}</> {}: <b><magenta>{}</></b>:\n{}", "<bright black>[Validator]</> ffmpeg error on position <yellow>{pos}</> - {}: <b><magenta>{}</></b>:\n{}",
sec_to_time(begin), sec_to_time(begin),
node.source, node.source,
error_list.join("\n") error_list.join("\n")
@ -107,6 +107,10 @@ fn check_media(
error_list.clear(); error_list.clear();
if let Err(e) = enc_proc.wait() {
error!("Validation process: {e:?}");
}
Ok(()) Ok(())
} }

View File

@ -1,6 +1,6 @@
use std::{ use std::{
ffi::OsStr, ffi::OsStr,
fs::{self, metadata}, fs::{self, metadata, File},
io::{BufRead, BufReader, Error}, io::{BufRead, BufReader, Error},
net::TcpListener, net::TcpListener,
path::{Path, PathBuf}, path::{Path, PathBuf},
@ -26,6 +26,7 @@ pub mod config;
pub mod controller; pub mod controller;
pub mod folder; pub mod folder;
mod generator; mod generator;
pub mod import;
pub mod json_serializer; pub mod json_serializer;
mod json_validate; mod json_validate;
mod logging; mod logging;
@ -33,7 +34,13 @@ mod logging;
#[cfg(windows)] #[cfg(windows)]
mod windows; mod windows;
pub use config::{self as playout_config, PlayoutConfig, DUMMY_LEN, IMAGE_FORMAT}; pub use config::{
self as playout_config,
OutputMode::{self, *},
PlayoutConfig,
ProcessMode::{self, *},
DUMMY_LEN, FFMPEG_IGNORE_ERRORS, IMAGE_FORMAT,
};
pub use controller::{PlayerControl, PlayoutStatus, ProcessControl, ProcessUnit::*}; pub use controller::{PlayerControl, PlayoutStatus, ProcessControl, ProcessUnit::*};
pub use generator::generate_playlist; pub use generator::generate_playlist;
pub use json_serializer::{read_json, JsonPlaylist}; pub use json_serializer::{read_json, JsonPlaylist};
@ -97,12 +104,12 @@ pub struct Media {
} }
impl Media { impl Media {
pub fn new(index: usize, src: String, do_probe: bool) -> Self { pub fn new(index: usize, src: &str, do_probe: bool) -> Self {
let mut duration = 0.0; let mut duration = 0.0;
let mut probe = None; let mut probe = None;
if do_probe && Path::new(&src).is_file() { if do_probe && Path::new(src).is_file() {
probe = Some(MediaProbe::new(&src)); probe = Some(MediaProbe::new(src));
if let Some(dur) = probe if let Some(dur) = probe
.as_ref() .as_ref()
@ -120,9 +127,9 @@ impl Media {
out: duration, out: duration,
duration, duration,
category: String::new(), category: String::new(),
source: src.clone(), source: src.to_string(),
audio: String::new(), audio: String::new(),
cmd: Some(vec!["-i".to_string(), src]), cmd: Some(vec_strings!["-i", src]),
filter: Some(vec![]), filter: Some(vec![]),
custom_filter: String::new(), custom_filter: String::new(),
probe, probe,
@ -247,6 +254,24 @@ pub fn fps_calc(r_frame_rate: &str, default: f64) -> f64 {
fps fps
} }
pub fn json_reader(path: &PathBuf) -> Result<JsonPlaylist, Error> {
let f = File::options().read(true).write(false).open(&path)?;
let p = serde_json::from_reader(f)?;
Ok(p)
}
pub fn json_writer(path: &PathBuf, data: JsonPlaylist) -> Result<(), Error> {
let f = File::options()
.write(true)
.truncate(true)
.create(true)
.open(&path)?;
serde_json::to_writer_pretty(f, &data)?;
Ok(())
}
/// Covert JSON string to ffmpeg filter command. /// Covert JSON string to ffmpeg filter command.
pub fn get_filter_from_json(raw_text: String) -> String { pub fn get_filter_from_json(raw_text: String) -> String {
let re1 = Regex::new(r#""|}|\{"#).unwrap(); let re1 = Regex::new(r#""|}|\{"#).unwrap();
@ -280,10 +305,10 @@ pub fn write_status(config: &PlayoutConfig, date: &str, shift: f64) {
}; };
} }
// pub fn get_timestamp() -> i64 { // pub fn get_timestamp() -> i32 {
// let local: DateTime<Local> = time_now(); // let local: DateTime<Local> = time_now();
// local.timestamp_millis() as i64 // local.timestamp_millis() as i32
// } // }
/// Get current time in seconds. /// Get current time in seconds.
@ -474,24 +499,16 @@ pub fn seek_and_length(node: &Media) -> Vec<String> {
let mut source_cmd = vec![]; let mut source_cmd = vec![];
let mut cut_audio = false; let mut cut_audio = false;
if node.seek > 0.0 { if node.seek > 0.5 {
source_cmd.append(&mut vec_strings!["-ss", node.seek]) source_cmd.append(&mut vec_strings!["-ss", node.seek])
} }
if file_extension(Path::new(&node.source))
.unwrap_or_default()
.to_lowercase()
== "mp4"
{
source_cmd.append(&mut vec_strings!["-ignore_chapters", "1"]);
}
source_cmd.append(&mut vec_strings!["-i", node.source.clone()]); source_cmd.append(&mut vec_strings!["-i", node.source.clone()]);
if Path::new(&node.audio).is_file() { if Path::new(&node.audio).is_file() {
let audio_probe = MediaProbe::new(&node.audio); let audio_probe = MediaProbe::new(&node.audio);
if node.seek > 0.0 { if node.seek > 0.5 {
source_cmd.append(&mut vec_strings!["-ss", node.seek]) source_cmd.append(&mut vec_strings!["-ss", node.seek])
} }
@ -522,98 +539,40 @@ pub fn gen_dummy(config: &PlayoutConfig, duration: f64) -> (String, Vec<String>)
"color=c={color}:s={}x{}:d={duration}", "color=c={color}:s={}x{}:d={duration}",
config.processing.width, config.processing.height config.processing.width, config.processing.height
); );
let cmd: Vec<String> = vec![ let cmd: Vec<String> = vec_strings![
"-f".to_string(), "-f",
"lavfi".to_string(), "lavfi",
"-i".to_string(), "-i",
format!( format!(
"{source}:r={},format=pix_fmts=yuv420p", "{source}:r={},format=pix_fmts=yuv420p",
config.processing.fps config.processing.fps
), ),
"-f".to_string(), "-f",
"lavfi".to_string(), "lavfi",
"-i".to_string(), "-i",
format!("anoisesrc=d={duration}:c=pink:r=48000:a=0.3"), format!("anoisesrc=d={duration}:c=pink:r=48000:a=0.3")
]; ];
(source, cmd) (source, cmd)
} }
/// Prepare output parameters // fn get_output_count(cmd: &[String]) -> i32 {
/// // let mut count = 0;
/// seek for multiple outputs and add mapping for it
pub fn prepare_output_cmd(
prefix: Vec<String>,
mut filter: Vec<String>,
params: Vec<String>,
mode: &str,
) -> Vec<String> {
let params_len = params.len();
let mut output_params = params.clone();
let mut output_a_map = "[a_out1]".to_string();
let mut output_v_map = "[v_out1]".to_string();
let mut output_count = 1;
let mut cmd = prefix;
if !filter.is_empty() { // if let Some(index) = cmd.iter().position(|c| c == "-var_stream_map") {
output_params.clear(); // if let Some(mapping) = cmd.get(index + 1) {
// return mapping.split(' ').count() as i32;
// };
// };
for (i, p) in params.iter().enumerate() { // for (i, param) in cmd.iter().enumerate() {
let mut param = p.clone(); // if i > 0 && !param.starts_with('-') && !cmd[i - 1].starts_with('-') {
// count += 1;
// }
// }
param = param.replace("[0:v]", "[vout1]"); // count
param = param.replace("[0:a]", "[aout1]"); // }
if param != "-filter_complex" {
output_params.push(param.clone());
}
if i > 0
&& !param.starts_with('-')
&& !params[i - 1].starts_with('-')
&& i < params_len - 1
{
output_count += 1;
let mut a_map = "0:a".to_string();
let v_map = format!("[v_out{output_count}]");
output_v_map.push_str(v_map.as_str());
if mode == "hls" {
a_map = format!("[a_out{output_count}]");
}
output_a_map.push_str(a_map.as_str());
let mut map = vec!["-map".to_string(), v_map, "-map".to_string(), a_map];
output_params.append(&mut map);
}
}
if output_count > 1 && mode == "hls" {
filter[1].push_str(format!(";[vout1]split={output_count}{output_v_map}").as_str());
filter[1].push_str(format!(";[aout1]asplit={output_count}{output_a_map}").as_str());
filter.drain(2..);
cmd.append(&mut filter);
cmd.append(&mut vec_strings!["-map", "[v_out1]", "-map", "[a_out1]"]);
} else if output_count == 1 && mode == "hls" && output_params[0].contains("split") {
let out_filter = output_params.remove(0);
filter[1].push_str(format!(";{out_filter}").as_str());
filter.drain(2..);
cmd.append(&mut filter);
} else if output_count > 1 && mode == "stream" {
filter[1].push_str(format!(",split={output_count}{output_v_map}").as_str());
cmd.append(&mut filter);
cmd.append(&mut vec_strings!["-map", "[v_out1]", "-map", "0:a"]);
} else {
cmd.append(&mut filter);
}
}
cmd.append(&mut output_params);
cmd
}
pub fn is_remote(path: &str) -> bool { pub fn is_remote(path: &str) -> bool {
Regex::new(r"^https?://.*").unwrap().is_match(path) Regex::new(r"^https?://.*").unwrap().is_match(path)
@ -642,7 +601,7 @@ pub fn include_file(config: PlayoutConfig, file_path: &Path) -> bool {
} }
} }
if config.out.mode.to_lowercase() == "hls" { if config.out.mode == HLS {
if let Some(ts_path) = config if let Some(ts_path) = config
.out .out
.output_cmd .output_cmd
@ -700,7 +659,9 @@ pub fn stderr_reader(
"<bright black>[{suffix}]</> {}", "<bright black>[{suffix}]</> {}",
format_log_line(line, "warning") format_log_line(line, "warning")
) )
} else if line.contains("[error]") || line.contains("[fatal]") { } else if (line.contains("[error]") || line.contains("[fatal]"))
&& !FFMPEG_IGNORE_ERRORS.iter().any(|i| line.contains(*i))
{
error!( error!(
"<bright black>[{suffix}]</> {}", "<bright black>[{suffix}]</> {}",
line.replace("[error]", "").replace("[fatal]", "") line.replace("[error]", "").replace("[fatal]", "")
@ -780,7 +741,7 @@ pub fn validate_ffmpeg(config: &PlayoutConfig) -> Result<(), String> {
is_in_system("ffmpeg")?; is_in_system("ffmpeg")?;
is_in_system("ffprobe")?; is_in_system("ffprobe")?;
if config.out.mode == "desktop" { if config.out.mode == Desktop {
is_in_system("ffplay")?; is_in_system("ffplay")?;
} }

View File

@ -1,6 +1,7 @@
#!/usr/bin/bash #!/usr/bin/bash
source $(dirname "$0")/man_create.sh source $(dirname "$0")/man_create.sh
target=$1
echo "build frontend" echo "build frontend"
echo echo
@ -14,7 +15,11 @@ mv dist ../public
cd .. cd ..
targets=("x86_64-unknown-linux-musl" "aarch64-unknown-linux-gnu" "x86_64-pc-windows-gnu" "x86_64-apple-darwin" "aarch64-apple-darwin") if [[ -n $target ]]; then
targets=($target)
else
targets=("x86_64-unknown-linux-musl" "aarch64-unknown-linux-gnu" "x86_64-pc-windows-gnu" "x86_64-apple-darwin" "aarch64-apple-darwin")
fi
IFS="= " IFS="= "
while read -r name value; do while read -r name value; do
@ -68,10 +73,14 @@ for target in "${targets[@]}"; do
echo "" echo ""
done done
cargo deb --target=x86_64-unknown-linux-musl -p ffplayout --manifest-path=ffplayout-engine/Cargo.toml -o ffplayout_${version}_amd64.deb if [[ -z $target ]] || [[ $target == "x86_64-unknown-linux-musl" ]]; then
cargo deb --target=aarch64-unknown-linux-gnu --variant=arm64 -p ffplayout --manifest-path=ffplayout-engine/Cargo.toml -o ffplayout_${version}_arm64.deb cargo deb --target=x86_64-unknown-linux-musl -p ffplayout --manifest-path=ffplayout-engine/Cargo.toml -o ffplayout_${version}_amd64.deb
cd ffplayout-engine
cargo generate-rpm --target=x86_64-unknown-linux-musl -o ../ffplayout-${version}-1.x86_64.rpm
cd ffplayout-engine cd ..
cargo generate-rpm --target=x86_64-unknown-linux-musl -o ../ffplayout-${version}-1.x86_64.rpm fi
cd .. if [[ -z $target ]] || [[ $target == "aarch64-unknown-linux-gnu" ]]; then
cargo deb --target=aarch64-unknown-linux-gnu --variant=arm64 -p ffplayout --manifest-path=ffplayout-engine/Cargo.toml -o ffplayout_${version}_arm64.deb
fi

42
tests/Cargo.toml Normal file
View File

@ -0,0 +1,42 @@
[package]
name = "tests"
version = "0.1.0"
edition = "2021"
publish = false
[dev-dependencies]
ffplayout = { path = "../ffplayout-engine" }
# ffplayout-api = { path = "../ffplayout-api" }
ffplayout-lib = { path = "../lib" }
chrono = "0.4"
crossbeam-channel = "0.5"
ffprobe = "0.3"
file-rotate = "0.7.0"
jsonrpc-http-server = "18.0"
lettre = "0.10"
log = "0.4"
notify = "4.0"
rand = "0.8"
regex = "1"
reqwest = { version = "0.11", features = ["blocking", "json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.8"
shlex = "1.1"
simplelog = { version = "^0.12", features = ["paris"] }
time = { version = "0.3", features = ["formatting", "macros"] }
walkdir = "2"
[[test]]
name = "lib_utils"
path = "src/lib_utils.rs"
[[test]]
name = "engine_playlist"
path = "src/engine_playlist.rs"
[[test]]
name = "engine_cmd"
path = "src/engine_cmd.rs"

BIN
tests/assets/ad.mp4 Normal file

Binary file not shown.

BIN
tests/assets/audio.mp3 Normal file

Binary file not shown.

BIN
tests/assets/av_sync.mp4 Normal file

Binary file not shown.

BIN
tests/assets/dual_audio.mp4 Normal file

Binary file not shown.

BIN
tests/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
tests/assets/no_audio.mp4 Normal file

Binary file not shown.

22526
tests/assets/playlist_full.json Executable file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,56 @@
{
"channel": "Channel 1",
"date": "2022-11-01",
"program": [
{
"in": 0.0,
"out": 30.0,
"duration": 30.0,
"source": "tests/assets/av_sync.mp4"
},
{
"in": 0.0,
"out": 30.0,
"duration": 30.0,
"source": "tests/assets/dual_audio.mp4"
},
{
"in": 0.0,
"out": 10.0,
"duration": 10.0,
"source": "tests/assets/short_video.mp4"
},
{
"in": 0.0,
"out": 10.0,
"duration": 10.0,
"source": "tests/assets/still.jpg"
},
{
"in": 0.0,
"out": 10.0,
"duration": 10.0,
"source": "tests/assets/short_audio.mp4"
},
{
"in": 0.0,
"out": 30.0,
"duration": 30.0,
"source": "tests/assets/no_audio.mp4"
},
{
"in": 0.0,
"out": 10.0,
"duration": 10.0,
"source": "tests/assets/still.jpg",
"audio": "tests/assets/audio.mp3"
},
{
"in": 0.0,
"out": 25.0,
"duration": 25.0,
"source": "tests/assets/ad.mp4",
"category": "advertisement"
}
]
}

Binary file not shown.

Binary file not shown.

BIN
tests/assets/still.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

BIN
tests/assets/with_audio.mp4 Normal file

Binary file not shown.

1080
tests/src/engine_cmd.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,13 +3,11 @@ use std::{
time::Duration, time::Duration,
}; };
#[cfg(test)]
use crate::output::player;
#[cfg(test)]
use ffplayout_lib::utils::*;
#[cfg(test)]
use simplelog::*; use simplelog::*;
use ffplayout::output::player;
use ffplayout_lib::utils::*;
fn timed_kill(sec: u64, mut proc_ctl: ProcessControl) { fn timed_kill(sec: u64, mut proc_ctl: ProcessControl) {
sleep(Duration::from_secs(sec)); sleep(Duration::from_secs(sec));
@ -21,7 +19,7 @@ fn timed_kill(sec: u64, mut proc_ctl: ProcessControl) {
fn playlist_change_at_midnight() { fn playlist_change_at_midnight() {
let mut config = PlayoutConfig::new(None); let mut config = PlayoutConfig::new(None);
config.mail.recipient = "".into(); config.mail.recipient = "".into();
config.processing.mode = "playlist".into(); config.processing.mode = Playlist;
config.playlist.day_start = "00:00:00".into(); config.playlist.day_start = "00:00:00".into();
config.playlist.length = "24:00:00".into(); config.playlist.length = "24:00:00".into();
config.logging.log_to_file = false; config.logging.log_to_file = false;
@ -46,7 +44,7 @@ fn playlist_change_at_midnight() {
fn playlist_change_at_six() { fn playlist_change_at_six() {
let mut config = PlayoutConfig::new(None); let mut config = PlayoutConfig::new(None);
config.mail.recipient = "".into(); config.mail.recipient = "".into();
config.processing.mode = "playlist".into(); config.processing.mode = Playlist;
config.playlist.day_start = "06:00:00".into(); config.playlist.day_start = "06:00:00".into();
config.playlist.length = "24:00:00".into(); config.playlist.length = "24:00:00".into();
config.logging.log_to_file = false; config.logging.log_to_file = false;

52
tests/src/lib_utils.rs Normal file
View File

@ -0,0 +1,52 @@
#[cfg(test)]
use chrono::prelude::*;
#[cfg(test)]
use ffplayout_lib::utils::*;
#[test]
fn mock_date_time() {
let time_str = "2022-05-20T06:00:00";
let date_obj = NaiveDateTime::parse_from_str(time_str, "%Y-%m-%dT%H:%M:%S");
let time = Local.from_local_datetime(&date_obj.unwrap()).unwrap();
mock_time::set_mock_time(time_str);
assert_eq!(
time.format("%Y-%m-%dT%H:%M:%S.2f").to_string(),
time_now().format("%Y-%m-%dT%H:%M:%S.2f").to_string()
);
}
#[test]
fn get_date_yesterday() {
mock_time::set_mock_time("2022-05-20T05:59:24");
let date = get_date(true, 21600.0, 86400.0);
assert_eq!("2022-05-19".to_string(), date);
}
#[test]
fn get_date_tomorrow() {
mock_time::set_mock_time("2022-05-20T23:59:30");
let date = get_date(false, 0.0, 86400.01);
assert_eq!("2022-05-21".to_string(), date);
}
#[test]
fn test_delta() {
let mut config = PlayoutConfig::new(Some("../assets/ffplayout.yml".to_string()));
config.mail.recipient = "".into();
config.processing.mode = Playlist;
config.playlist.day_start = "00:00:00".into();
config.playlist.length = "24:00:00".into();
config.logging.log_to_file = false;
mock_time::set_mock_time("2022-05-09T23:59:59");
let (delta, _) = get_delta(&config, &86401.0);
assert!(delta < 2.0);
}