commit
0d5d47e8b9
26
.github/workflows/rust.yml
vendored
26
.github/workflows/rust.yml
vendored
@ -8,10 +8,22 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- run: rustup update stable
|
||||
- run: rustup component add rustfmt
|
||||
- run: rustup component add clippy
|
||||
- run: cargo test --all-features
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy --all-features --all-targets -- --deny warnings
|
||||
- run: cargo build --all-features
|
||||
- name: On all Systems
|
||||
run: |
|
||||
rustup update stable
|
||||
rustup component add rustfmt
|
||||
rustup component add clippy
|
||||
|
||||
- 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
469
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
members = ["ffplayout-api", "ffplayout-engine", "lib"]
|
||||
default-members = ["ffplayout-api", "ffplayout-engine"]
|
||||
members = ["ffplayout-api", "ffplayout-engine", "lib", "tests"]
|
||||
default-members = ["ffplayout-api", "ffplayout-engine", "tests"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
@ -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
|
||||
- [live ingest](/docs/live_ingest.md)
|
||||
- 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
|
||||
- import playlist from text or m3u file, with CLI or frontend
|
||||
|
||||
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)**
|
||||
|
||||
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.
|
||||
|
@ -47,7 +47,8 @@ processing:
|
||||
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
|
||||
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'
|
||||
you can activate single pass EBU R128 loudness normalization. 'loud_*' can
|
||||
adjust the loudnorm filter. With 'custom_filter' it is possible, to apply further
|
||||
@ -63,6 +64,7 @@ processing:
|
||||
logo_scale:
|
||||
logo_opacity: 0.7
|
||||
logo_filter: overlay=W-w-12:12
|
||||
audio_tracks: 1
|
||||
add_loudnorm: false
|
||||
loudnorm_ingest: false
|
||||
loud_i: -18
|
||||
@ -120,7 +122,7 @@ out:
|
||||
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.
|
||||
In production don't serve hls playlist with ffpapi, use nginx or another web server!
|
||||
mode: desktop
|
||||
mode: hls
|
||||
output_param: >-
|
||||
-c:v libx264
|
||||
-crf 23
|
||||
|
@ -21,6 +21,10 @@ Using live ingest to inject a live stream.
|
||||
|
||||
The different output modes.
|
||||
|
||||
### **[Multi Audio Tracks](/docs/multi_audio.md)**
|
||||
|
||||
Output multiple audio tracks.
|
||||
|
||||
### **[Custom Filter](/docs/custom_filters.md)**
|
||||
|
||||
Apply self defined audio/video filters.
|
||||
|
11
docs/api.md
11
docs/api.md
@ -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>'
|
||||
-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
73
docs/multi_audio.md
Normal 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
|
||||
```
|
@ -126,7 +126,7 @@ HLS output is currently the default, mostly because it works out of the box and
|
||||
-f hls
|
||||
-hls_time 6
|
||||
-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
|
||||
-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"
|
||||
|
@ -4,7 +4,7 @@ description = "Rest API for ffplayout"
|
||||
license = "GPL-3.0"
|
||||
authors = ["Jonathan Baecker jonbae77@gmail.com"]
|
||||
readme = "README.md"
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
@ -11,14 +11,14 @@ const JWT_EXPIRATION_DAYS: i64 = 7;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
|
||||
pub struct Claims {
|
||||
pub id: i64,
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
exp: i64,
|
||||
}
|
||||
|
||||
impl Claims {
|
||||
pub fn new(id: i64, username: String, role: String) -> Self {
|
||||
pub fn new(id: i32, username: String, role: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
username,
|
2
ffplayout-api/src/api/mod.rs
Normal file
2
ffplayout-api/src/api/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod auth;
|
||||
pub mod routes;
|
@ -8,7 +8,7 @@
|
||||
///
|
||||
/// For all endpoints an (Bearer) authentication is required.\
|
||||
/// `{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_web::{delete, get, http::StatusCode, patch, post, put, web, HttpResponse, Responder};
|
||||
@ -20,8 +20,12 @@ use argon2::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use simplelog::*;
|
||||
|
||||
use crate::auth::{create_jwt, Claims};
|
||||
use crate::db::{
|
||||
handles,
|
||||
models::{Channel, LoginUser, TextPreset, User},
|
||||
};
|
||||
use crate::utils::{
|
||||
auth::{create_jwt, Claims},
|
||||
channels::{create_channel, delete_channel},
|
||||
control::{control_service, control_state, media_info, send_message, Process},
|
||||
errors::ServiceError,
|
||||
@ -29,16 +33,10 @@ use crate::utils::{
|
||||
browser, create_directory, remove_file_or_folder, rename_file, upload, MoveObject,
|
||||
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},
|
||||
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)]
|
||||
struct ResponseObj<T> {
|
||||
@ -60,11 +58,19 @@ pub struct DateObj {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct FileObj {
|
||||
struct FileObj {
|
||||
#[serde(default)]
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ImportObj {
|
||||
#[serde(default)]
|
||||
file: String,
|
||||
#[serde(default)]
|
||||
date: String,
|
||||
}
|
||||
|
||||
/// #### User Handling
|
||||
///
|
||||
/// **Login**
|
||||
@ -85,7 +91,7 @@ pub struct FileObj {
|
||||
/// ```
|
||||
#[post("/auth/login/")]
|
||||
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) => {
|
||||
let pass = user.password.clone();
|
||||
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)
|
||||
.is_ok()
|
||||
{
|
||||
let role = db_role(&user.role_id.unwrap_or_default())
|
||||
let role = handles::select_role(&user.role_id.unwrap_or_default())
|
||||
.await
|
||||
.unwrap_or_else(|_| "guest".to_string());
|
||||
let claims = Claims::new(user.id, user.username.clone(), role.clone());
|
||||
@ -147,7 +153,7 @@ pub async fn login(credentials: web::Json<User>) -> impl Responder {
|
||||
#[get("/user")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
async fn get_user(user: web::ReqData<LoginUser>) -> Result<impl Responder, ServiceError> {
|
||||
match db_get_user(&user.username).await {
|
||||
match handles::select_user(&user.username).await {
|
||||
Ok(user) => Ok(web::Json(user)),
|
||||
Err(e) => {
|
||||
error!("{e}");
|
||||
@ -165,7 +171,7 @@ async fn get_user(user: web::ReqData<LoginUser>) -> Result<impl Responder, Servi
|
||||
#[put("/user/{id}")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
async fn update_user(
|
||||
id: web::Path<i64>,
|
||||
id: web::Path<i32>,
|
||||
user: web::ReqData<LoginUser>,
|
||||
data: web::Json<User>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
@ -189,7 +195,7 @@ async fn update_user(
|
||||
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");
|
||||
};
|
||||
|
||||
@ -209,7 +215,7 @@ async fn update_user(
|
||||
#[post("/user/")]
|
||||
#[has_any_role("Role::Admin", type = "Role")]
|
||||
async fn add_user(data: web::Json<User>) -> Result<impl Responder, ServiceError> {
|
||||
match db_add_user(data.into_inner()).await {
|
||||
match handles::insert_user(data.into_inner()).await {
|
||||
Ok(_) => Ok("Add User Success"),
|
||||
Err(e) => {
|
||||
error!("{e}");
|
||||
@ -241,8 +247,8 @@ async fn add_user(data: web::Json<User>) -> Result<impl Responder, ServiceError>
|
||||
/// ```
|
||||
#[get("/channel/{id}")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
async fn get_channel(id: web::Path<i64>) -> Result<impl Responder, ServiceError> {
|
||||
if let Ok(channel) = db_get_channel(&id).await {
|
||||
async fn get_channel(id: web::Path<i32>) -> Result<impl Responder, ServiceError> {
|
||||
if let Ok(channel) = handles::select_channel(&id).await {
|
||||
return Ok(web::Json(channel));
|
||||
}
|
||||
|
||||
@ -257,7 +263,7 @@ async fn get_channel(id: web::Path<i64>) -> Result<impl Responder, ServiceError>
|
||||
#[get("/channels")]
|
||||
#[has_any_role("Role::Admin", type = "Role")]
|
||||
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));
|
||||
}
|
||||
|
||||
@ -275,10 +281,13 @@ async fn get_all_channels() -> Result<impl Responder, ServiceError> {
|
||||
#[patch("/channel/{id}")]
|
||||
#[has_any_role("Role::Admin", type = "Role")]
|
||||
async fn patch_channel(
|
||||
id: web::Path<i64>,
|
||||
id: web::Path<i32>,
|
||||
data: web::Json<Channel>,
|
||||
) -> 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");
|
||||
};
|
||||
|
||||
@ -310,7 +319,7 @@ async fn add_channel(data: web::Json<Channel>) -> Result<impl Responder, Service
|
||||
/// ```
|
||||
#[delete("/channel/{id}")]
|
||||
#[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() {
|
||||
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}")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
async fn get_playout_config(
|
||||
id: web::Path<i64>,
|
||||
id: web::Path<i32>,
|
||||
_details: AuthDetails<Role>,
|
||||
) -> 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) {
|
||||
return Ok(web::Json(config));
|
||||
}
|
||||
@ -351,10 +360,10 @@ async fn get_playout_config(
|
||||
#[put("/playout/config/{id}")]
|
||||
#[has_any_role("Role::Admin", type = "Role")]
|
||||
async fn update_playout_config(
|
||||
id: web::Path<i64>,
|
||||
id: web::Path<i32>,
|
||||
data: web::Json<PlayoutConfig>,
|
||||
) -> 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()
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
@ -383,8 +392,8 @@ async fn update_playout_config(
|
||||
/// ```
|
||||
#[get("/presets/{id}")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
async fn get_presets(id: web::Path<i64>) -> Result<impl Responder, ServiceError> {
|
||||
if let Ok(presets) = db_get_presets(*id).await {
|
||||
async fn get_presets(id: web::Path<i32>) -> Result<impl Responder, ServiceError> {
|
||||
if let Ok(presets) = handles::select_presets(*id).await {
|
||||
return Ok(web::Json(presets));
|
||||
}
|
||||
|
||||
@ -402,10 +411,10 @@ async fn get_presets(id: web::Path<i64>) -> Result<impl Responder, ServiceError>
|
||||
#[put("/presets/{id}")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
async fn update_preset(
|
||||
id: web::Path<i64>,
|
||||
id: web::Path<i32>,
|
||||
data: web::Json<TextPreset>,
|
||||
) -> 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");
|
||||
}
|
||||
|
||||
@ -423,7 +432,7 @@ async fn update_preset(
|
||||
#[post("/presets/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
async fn add_preset(data: web::Json<TextPreset>) -> Result<impl Responder, ServiceError> {
|
||||
if db_add_preset(data.into_inner()).await.is_ok() {
|
||||
if handles::insert_preset(data.into_inner()).await.is_ok() {
|
||||
return Ok("Add preset Success");
|
||||
}
|
||||
|
||||
@ -438,8 +447,8 @@ async fn add_preset(data: web::Json<TextPreset>) -> Result<impl Responder, Servi
|
||||
/// ```
|
||||
#[delete("/presets/{id}")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
async fn delete_preset(id: web::Path<i64>) -> Result<impl Responder, ServiceError> {
|
||||
if db_delete_preset(&id).await.is_ok() {
|
||||
async fn delete_preset(id: web::Path<i32>) -> Result<impl Responder, ServiceError> {
|
||||
if handles::delete_preset(&id).await.is_ok() {
|
||||
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/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn send_text_message(
|
||||
id: web::Path<i64>,
|
||||
id: web::Path<i32>,
|
||||
data: web::Json<HashMap<String, String>>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
match send_message(*id, data.into_inner()).await {
|
||||
@ -488,7 +497,7 @@ pub async fn send_text_message(
|
||||
#[post("/control/{id}/playout/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn control_playout(
|
||||
id: web::Path<i64>,
|
||||
id: web::Path<i32>,
|
||||
control: web::Json<Process>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
match control_state(*id, control.command.clone()).await {
|
||||
@ -529,7 +538,7 @@ pub async fn control_playout(
|
||||
/// ```
|
||||
#[get("/control/{id}/media/current")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn media_current(id: web::Path<i64>) -> Result<impl Responder, ServiceError> {
|
||||
pub async fn media_current(id: web::Path<i32>) -> Result<impl Responder, ServiceError> {
|
||||
match media_info(*id, "current".into()).await {
|
||||
Ok(res) => Ok(res.text().await.unwrap_or_else(|_| "Success".into())),
|
||||
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")]
|
||||
#[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 {
|
||||
Ok(res) => Ok(res.text().await.unwrap_or_else(|_| "Success".into())),
|
||||
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")]
|
||||
#[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 {
|
||||
Ok(res) => Ok(res.text().await.unwrap_or_else(|_| "Success".into())),
|
||||
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/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn process_control(
|
||||
id: web::Path<i64>,
|
||||
id: web::Path<i32>,
|
||||
proc: web::Json<Process>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
control_service(*id, &proc.command).await
|
||||
@ -598,7 +607,7 @@ pub async fn process_control(
|
||||
#[get("/playlist/{id}")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn get_playlist(
|
||||
id: web::Path<i64>,
|
||||
id: web::Path<i32>,
|
||||
obj: web::Query<DateObj>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
match read_playlist(*id, obj.date.clone()).await {
|
||||
@ -617,7 +626,7 @@ pub async fn get_playlist(
|
||||
#[post("/playlist/{id}/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn save_playlist(
|
||||
id: web::Path<i64>,
|
||||
id: web::Path<i32>,
|
||||
data: web::Json<JsonPlaylist>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
match write_playlist(*id, data.into_inner()).await {
|
||||
@ -637,7 +646,7 @@ pub async fn save_playlist(
|
||||
#[get("/playlist/{id}/generate/{date}")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn gen_playlist(
|
||||
params: web::Path<(i64, String)>,
|
||||
params: web::Path<(i32, String)>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
match generate_playlist(params.0, params.1.clone()).await {
|
||||
Ok(playlist) => Ok(web::Json(playlist)),
|
||||
@ -654,7 +663,7 @@ pub async fn gen_playlist(
|
||||
#[delete("/playlist/{id}/{date}")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn del_playlist(
|
||||
params: web::Path<(i64, String)>,
|
||||
params: web::Path<(i32, String)>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
match delete_playlist(params.0, ¶ms.1).await {
|
||||
Ok(_) => Ok(format!("Delete playlist from {} success!", params.1)),
|
||||
@ -673,7 +682,7 @@ pub async fn del_playlist(
|
||||
#[get("/log/{id}")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn get_log(
|
||||
id: web::Path<i64>,
|
||||
id: web::Path<i32>,
|
||||
log: web::Query<DateObj>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
read_log_file(&id, &log.date).await
|
||||
@ -690,7 +699,7 @@ pub async fn get_log(
|
||||
#[post("/file/{id}/browse/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn file_browser(
|
||||
id: web::Path<i64>,
|
||||
id: web::Path<i32>,
|
||||
data: web::Json<PathObject>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
match browser(*id, &data.into_inner()).await {
|
||||
@ -708,7 +717,7 @@ pub async fn file_browser(
|
||||
#[post("/file/{id}/create-folder/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn add_dir(
|
||||
id: web::Path<i64>,
|
||||
id: web::Path<i32>,
|
||||
data: web::Json<PathObject>,
|
||||
) -> Result<HttpResponse, ServiceError> {
|
||||
create_directory(*id, &data.into_inner()).await
|
||||
@ -723,7 +732,7 @@ pub async fn add_dir(
|
||||
#[post("/file/{id}/rename/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn move_rename(
|
||||
id: web::Path<i64>,
|
||||
id: web::Path<i32>,
|
||||
data: web::Json<MoveObject>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
match rename_file(*id, &data.into_inner()).await {
|
||||
@ -741,7 +750,7 @@ pub async fn move_rename(
|
||||
#[post("/file/{id}/remove/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
pub async fn remove(
|
||||
id: web::Path<i64>,
|
||||
id: web::Path<i32>,
|
||||
data: web::Json<PathObject>,
|
||||
) -> Result<impl Responder, ServiceError> {
|
||||
match remove_file_or_folder(*id, &data.into_inner().source).await {
|
||||
@ -759,9 +768,38 @@ pub async fn remove(
|
||||
#[put("/file/{id}/upload/")]
|
||||
#[has_any_role("Role::Admin", "Role::User", type = "Role")]
|
||||
async fn save_file(
|
||||
id: web::Path<i64>,
|
||||
id: web::Path<i32>,
|
||||
payload: Multipart,
|
||||
obj: web::Query<FileObj>,
|
||||
) -> 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())
|
||||
}
|
@ -7,11 +7,8 @@ use rand::{distributions::Alphanumeric, Rng};
|
||||
use simplelog::*;
|
||||
use sqlx::{migrate::MigrateDatabase, sqlite::SqliteQueryResult, Pool, Sqlite, SqlitePool};
|
||||
|
||||
use crate::utils::{
|
||||
db_path, local_utc_offset,
|
||||
models::{Channel, TextPreset, User},
|
||||
GlobalSettings,
|
||||
};
|
||||
use crate::db::models::{Channel, TextPreset, User};
|
||||
use crate::utils::{db_path, local_utc_offset, GlobalSettings};
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct Role {
|
||||
@ -19,7 +16,7 @@ struct Role {
|
||||
}
|
||||
|
||||
async fn create_schema() -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
let conn = connection().await?;
|
||||
let query = "PRAGMA foreign_keys = ON;
|
||||
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)
|
||||
.collect();
|
||||
|
||||
let instances = db_connection().await?;
|
||||
let instances = connection().await?;
|
||||
|
||||
let url = match domain {
|
||||
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!")
|
||||
}
|
||||
|
||||
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 conn = SqlitePool::connect(&db_path).await?;
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
pub async fn db_global() -> Result<GlobalSettings, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
pub async fn select_global() -> Result<GlobalSettings, sqlx::Error> {
|
||||
let conn = connection().await?;
|
||||
let query = "SELECT secret FROM global WHERE id = 1";
|
||||
let result: GlobalSettings = sqlx::query_as(query).fetch_one(&conn).await?;
|
||||
conn.close().await;
|
||||
@ -146,8 +143,8 @@ pub async fn db_global() -> Result<GlobalSettings, sqlx::Error> {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn db_get_channel(id: &i64) -> Result<Channel, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
pub async fn select_channel(id: &i32) -> Result<Channel, sqlx::Error> {
|
||||
let conn = connection().await?;
|
||||
let query = "SELECT * FROM channels WHERE id = $1";
|
||||
let mut result: Channel = sqlx::query_as(query).bind(id).fetch_one(&conn).await?;
|
||||
conn.close().await;
|
||||
@ -157,8 +154,8 @@ pub async fn db_get_channel(id: &i64) -> Result<Channel, sqlx::Error> {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn db_get_all_channels() -> Result<Vec<Channel>, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
pub async fn select_all_channels() -> Result<Vec<Channel>, sqlx::Error> {
|
||||
let conn = connection().await?;
|
||||
let query = "SELECT * FROM channels";
|
||||
let mut results: Vec<Channel> = sqlx::query_as(query).fetch_all(&conn).await?;
|
||||
conn.close().await;
|
||||
@ -170,11 +167,8 @@ pub async fn db_get_all_channels() -> Result<Vec<Channel>, sqlx::Error> {
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub async fn db_update_channel(
|
||||
id: i64,
|
||||
channel: Channel,
|
||||
) -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
pub async fn update_channel(id: i32, channel: Channel) -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = connection().await?;
|
||||
|
||||
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)
|
||||
@ -190,8 +184,8 @@ pub async fn db_update_channel(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn db_add_channel(channel: Channel) -> Result<Channel, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
pub async fn insert_channel(channel: Channel) -> Result<Channel, sqlx::Error> {
|
||||
let conn = connection().await?;
|
||||
|
||||
let query = "INSERT INTO channels (name, preview_url, config_path, extra_extensions, service) VALUES($1, $2, $3, $4, $5)";
|
||||
let result = sqlx::query(query)
|
||||
@ -211,8 +205,8 @@ pub async fn db_add_channel(channel: Channel) -> Result<Channel, sqlx::Error> {
|
||||
Ok(new_channel)
|
||||
}
|
||||
|
||||
pub async fn db_delete_channel(id: &i64) -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
pub async fn delete_channel(id: &i32) -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = connection().await?;
|
||||
|
||||
let query = "DELETE FROM channels WHERE id = $1";
|
||||
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)
|
||||
}
|
||||
|
||||
pub async fn db_role(id: &i64) -> Result<String, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
pub async fn select_role(id: &i32) -> Result<String, sqlx::Error> {
|
||||
let conn = connection().await?;
|
||||
let query = "SELECT name FROM roles WHERE id = $1";
|
||||
let result: Role = sqlx::query_as(query).bind(id).fetch_one(&conn).await?;
|
||||
conn.close().await;
|
||||
@ -230,8 +224,8 @@ pub async fn db_role(id: &i64) -> Result<String, sqlx::Error> {
|
||||
Ok(result.name)
|
||||
}
|
||||
|
||||
pub async fn db_login(user: &str) -> Result<User, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
pub async fn select_login(user: &str) -> Result<User, sqlx::Error> {
|
||||
let conn = connection().await?;
|
||||
let query = "SELECT id, mail, username, password, salt, role_id FROM user WHERE username = $1";
|
||||
let result: User = sqlx::query_as(query).bind(user).fetch_one(&conn).await?;
|
||||
conn.close().await;
|
||||
@ -239,8 +233,8 @@ pub async fn db_login(user: &str) -> Result<User, sqlx::Error> {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn db_get_user(user: &str) -> Result<User, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
pub async fn select_user(user: &str) -> Result<User, sqlx::Error> {
|
||||
let conn = connection().await?;
|
||||
let query = "SELECT id, mail, username, role_id FROM user WHERE username = $1";
|
||||
let result: User = sqlx::query_as(query).bind(user).fetch_one(&conn).await?;
|
||||
conn.close().await;
|
||||
@ -248,8 +242,8 @@ pub async fn db_get_user(user: &str) -> Result<User, sqlx::Error> {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn db_add_user(user: User) -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
pub async fn insert_user(user: User) -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = connection().await?;
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let password_hash = Argon2::default()
|
||||
.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)
|
||||
}
|
||||
|
||||
pub async fn db_update_user(id: i64, fields: String) -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
pub async fn update_user(id: i32, fields: String) -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = connection().await?;
|
||||
let query = format!("UPDATE user SET {fields} WHERE id = $1");
|
||||
let result: SqliteQueryResult = sqlx::query(&query).bind(id).execute(&conn).await?;
|
||||
conn.close().await;
|
||||
@ -279,8 +273,8 @@ pub async fn db_update_user(id: i64, fields: String) -> Result<SqliteQueryResult
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn db_get_presets(id: i64) -> Result<Vec<TextPreset>, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
pub async fn select_presets(id: i32) -> Result<Vec<TextPreset>, sqlx::Error> {
|
||||
let conn = connection().await?;
|
||||
let query = "SELECT * FROM presets WHERE channel_id = $1";
|
||||
let result: Vec<TextPreset> = sqlx::query_as(query).bind(id).fetch_all(&conn).await?;
|
||||
conn.close().await;
|
||||
@ -288,11 +282,8 @@ pub async fn db_get_presets(id: i64) -> Result<Vec<TextPreset>, sqlx::Error> {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn db_update_preset(
|
||||
id: &i64,
|
||||
preset: TextPreset,
|
||||
) -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
pub async fn update_preset(id: &i32, preset: TextPreset) -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = connection().await?;
|
||||
let query =
|
||||
"UPDATE presets SET name = $1, text = $2, x = $3, y = $4, fontsize = $5, line_spacing = $6,
|
||||
fontcolor = $7, alpha = $8, box = $9, boxcolor = $10, boxborderw = 11 WHERE id = $12";
|
||||
@ -316,8 +307,8 @@ pub async fn db_update_preset(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn db_add_preset(preset: TextPreset) -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
pub async fn insert_preset(preset: TextPreset) -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = connection().await?;
|
||||
let query =
|
||||
"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)";
|
||||
@ -341,8 +332,8 @@ pub async fn db_add_preset(preset: TextPreset) -> Result<SqliteQueryResult, sqlx
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn db_delete_preset(id: &i64) -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = db_connection().await?;
|
||||
pub async fn delete_preset(id: &i32) -> Result<SqliteQueryResult, sqlx::Error> {
|
||||
let conn = connection().await?;
|
||||
let query = "DELETE FROM presets WHERE id = $1;";
|
||||
let result: SqliteQueryResult = sqlx::query(query).bind(id).execute(&conn).await?;
|
||||
conn.close().await;
|
2
ffplayout-api/src/db/mod.rs
Normal file
2
ffplayout-api/src/db/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod handles;
|
||||
pub mod models;
|
@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
pub struct User {
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_deserializing)]
|
||||
pub id: i64,
|
||||
pub id: i32,
|
||||
#[sqlx(default)]
|
||||
pub mail: Option<String>,
|
||||
pub username: String,
|
||||
@ -16,10 +16,10 @@ pub struct User {
|
||||
pub salt: Option<String>,
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing)]
|
||||
pub role_id: Option<i64>,
|
||||
pub role_id: Option<i32>,
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing)]
|
||||
pub channel_id: Option<i64>,
|
||||
pub channel_id: Option<i32>,
|
||||
#[sqlx(default)]
|
||||
pub token: Option<String>,
|
||||
}
|
||||
@ -30,12 +30,12 @@ fn empty_string() -> String {
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct LoginUser {
|
||||
pub id: i64,
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
impl LoginUser {
|
||||
pub fn new(id: i64, username: String) -> Self {
|
||||
pub fn new(id: i32, username: String) -> Self {
|
||||
Self { id, username }
|
||||
}
|
||||
}
|
||||
@ -43,8 +43,8 @@ impl LoginUser {
|
||||
pub struct TextPreset {
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_deserializing)]
|
||||
pub id: i64,
|
||||
pub channel_id: i64,
|
||||
pub id: i32,
|
||||
pub channel_id: i32,
|
||||
pub name: String,
|
||||
pub text: String,
|
||||
pub x: String,
|
||||
@ -61,7 +61,7 @@ pub struct TextPreset {
|
||||
#[derive(Debug, Deserialize, Serialize, sqlx::FromRow)]
|
||||
pub struct Channel {
|
||||
#[serde(skip_deserializing)]
|
||||
pub id: i64,
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub preview_url: String,
|
||||
pub config_path: String,
|
@ -9,21 +9,23 @@ use actix_web_httpauth::middleware::HttpAuthentication;
|
||||
use clap::Parser;
|
||||
use simplelog::*;
|
||||
|
||||
pub mod api;
|
||||
pub mod db;
|
||||
pub mod utils;
|
||||
|
||||
use utils::{
|
||||
args_parse::Args,
|
||||
auth, db_path, init_config,
|
||||
models::LoginUser,
|
||||
use api::{
|
||||
auth,
|
||||
routes::{
|
||||
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,
|
||||
get_playout_config, get_presets, get_user, login, media_current, media_last, media_next,
|
||||
move_rename, patch_channel, process_control, remove, remove_channel, save_file,
|
||||
save_playlist, send_text_message, update_playout_config, update_preset, update_user,
|
||||
get_playout_config, get_presets, get_user, import_playlist, login, media_current,
|
||||
media_last, media_next, move_rename, patch_channel, process_control, remove,
|
||||
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};
|
||||
|
||||
@ -118,7 +120,8 @@ async fn main() -> std::io::Result<()> {
|
||||
.service(add_dir)
|
||||
.service(move_rename)
|
||||
.service(remove)
|
||||
.service(save_file),
|
||||
.service(save_file)
|
||||
.service(import_playlist),
|
||||
)
|
||||
.service(Files::new("/", public_path()).index_file("index.html"))
|
||||
})
|
||||
|
@ -2,12 +2,9 @@ use std::fs;
|
||||
|
||||
use simplelog::*;
|
||||
|
||||
use crate::utils::{
|
||||
control::control_service,
|
||||
errors::ServiceError,
|
||||
handles::{db_add_channel, db_delete_channel, db_get_channel},
|
||||
models::Channel,
|
||||
};
|
||||
use crate::utils::{control::control_service, errors::ServiceError};
|
||||
|
||||
use crate::db::{handles, models::Channel};
|
||||
|
||||
pub async fn create_channel(target_channel: Channel) -> Result<Channel, ServiceError> {
|
||||
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,
|
||||
)?;
|
||||
|
||||
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?;
|
||||
|
||||
Ok(new_channel)
|
||||
}
|
||||
|
||||
pub async fn delete_channel(id: i64) -> Result<(), ServiceError> {
|
||||
let channel = db_get_channel(&id).await?;
|
||||
pub async fn delete_channel(id: i32) -> Result<(), ServiceError> {
|
||||
let channel = handles::select_channel(&id).await?;
|
||||
control_service(channel.id, "stop").await?;
|
||||
control_service(channel.id, "disable").await?;
|
||||
|
||||
@ -38,7 +35,7 @@ pub async fn delete_channel(id: i64) -> Result<(), ServiceError> {
|
||||
error!("{e}");
|
||||
};
|
||||
|
||||
db_delete_channel(&id).await?;
|
||||
handles::delete_channel(&id).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -6,13 +6,14 @@ use reqwest::{
|
||||
};
|
||||
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;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct RpcObj<T> {
|
||||
jsonrpc: String,
|
||||
id: i64,
|
||||
id: i32,
|
||||
method: String,
|
||||
params: T,
|
||||
}
|
||||
@ -34,7 +35,7 @@ struct MediaParams {
|
||||
}
|
||||
|
||||
impl<T> RpcObj<T> {
|
||||
fn new(id: i64, method: String, params: T) -> Self {
|
||||
fn new(id: i32, method: String, params: T) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".into(),
|
||||
id,
|
||||
@ -55,8 +56,8 @@ struct SystemD {
|
||||
}
|
||||
|
||||
impl SystemD {
|
||||
async fn new(id: i64) -> Result<Self, ServiceError> {
|
||||
let channel = db_get_channel(&id).await?;
|
||||
async fn new(id: i32) -> Result<Self, ServiceError> {
|
||||
let channel = select_channel(&id).await?;
|
||||
|
||||
Ok(Self {
|
||||
service: channel.service,
|
||||
@ -129,7 +130,7 @@ fn create_header(auth: &str) -> HeaderMap {
|
||||
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
|
||||
T: Serialize,
|
||||
{
|
||||
@ -150,7 +151,7 @@ where
|
||||
}
|
||||
|
||||
pub async fn send_message(
|
||||
id: i64,
|
||||
id: i32,
|
||||
message: HashMap<String, String>,
|
||||
) -> Result<Response, ServiceError> {
|
||||
let json_obj = RpcObj::new(
|
||||
@ -165,19 +166,19 @@ pub async fn send_message(
|
||||
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 });
|
||||
|
||||
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 });
|
||||
|
||||
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?;
|
||||
|
||||
match command {
|
||||
|
@ -47,9 +47,9 @@ pub struct VideoFile {
|
||||
///
|
||||
/// This function takes care, that it is not possible to break out from root_path.
|
||||
/// It also gives alway a relative path back.
|
||||
fn norm_abs_path(root_path: &String, input_path: &String) -> (PathBuf, String, String) {
|
||||
let mut path = PathBuf::from(root_path.clone());
|
||||
let path_relative = RelativePath::new(&root_path)
|
||||
fn norm_abs_path(root_path: &str, input_path: &str) -> (PathBuf, String, String) {
|
||||
let mut path = PathBuf::from(root_path);
|
||||
let path_relative = RelativePath::new(root_path)
|
||||
.normalize()
|
||||
.to_string()
|
||||
.replace("../", "");
|
||||
@ -57,7 +57,11 @@ fn norm_abs_path(root_path: &String, input_path: &String) -> (PathBuf, String, S
|
||||
.normalize()
|
||||
.to_string()
|
||||
.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) {
|
||||
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.
|
||||
/// Input should be a relative path segment, but when it is a absolut path, the norm_abs_path function
|
||||
/// will take care, that user can not break out from given storage path in config.
|
||||
pub async fn browser(id: i64, path_obj: &PathObject) -> Result<PathObject, ServiceError> {
|
||||
pub async fn browser(id: i32, path_obj: &PathObject) -> Result<PathObject, ServiceError> {
|
||||
let (config, _) = playout_config(&id).await?;
|
||||
let extensions = config.storage.extensions;
|
||||
let (path, parent, path_component) = norm_abs_path(&config.storage.path, &path_obj.source);
|
||||
@ -139,7 +143,7 @@ pub async fn browser(id: i64, path_obj: &PathObject) -> Result<PathObject, Servi
|
||||
}
|
||||
|
||||
pub async fn create_directory(
|
||||
id: i64,
|
||||
id: i32,
|
||||
path_obj: &PathObject,
|
||||
) -> Result<HttpResponse, ServiceError> {
|
||||
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 (source_path, _, _) = norm_abs_path(&config.storage.path, &move_object.source);
|
||||
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)
|
||||
}
|
||||
|
||||
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 (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)
|
||||
}
|
||||
|
||||
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 (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(
|
||||
id: i64,
|
||||
id: i32,
|
||||
mut payload: Multipart,
|
||||
path: &String,
|
||||
path: &str,
|
||||
abs_path: bool,
|
||||
) -> Result<HttpResponse, ServiceError> {
|
||||
while let Some(mut field) = payload.try_next().await? {
|
||||
let content_disposition = field.content_disposition();
|
||||
@ -286,8 +291,14 @@ pub async fn upload(
|
||||
.get_filename()
|
||||
.map_or_else(|| rand_string.to_string(), sanitize_filename::sanitize);
|
||||
|
||||
let filepath;
|
||||
|
||||
if abs_path {
|
||||
filepath = PathBuf::from(path);
|
||||
} else {
|
||||
let target_path = valid_path(id, path).await?;
|
||||
let filepath = target_path.join(filename);
|
||||
filepath = target_path.join(filename);
|
||||
}
|
||||
|
||||
if filepath.is_file() {
|
||||
return Err(ServiceError::BadRequest("Target already exists!".into()));
|
||||
|
@ -12,22 +12,17 @@ use rpassword::read_password;
|
||||
use simplelog::*;
|
||||
|
||||
pub mod args_parse;
|
||||
pub mod auth;
|
||||
pub mod channels;
|
||||
pub mod control;
|
||||
pub mod errors;
|
||||
pub mod files;
|
||||
pub mod handles;
|
||||
pub mod models;
|
||||
pub mod playlist;
|
||||
pub mod routes;
|
||||
|
||||
use crate::utils::{
|
||||
args_parse::Args,
|
||||
errors::ServiceError,
|
||||
handles::{db_add_user, db_get_channel, db_global, db_init},
|
||||
use crate::db::{
|
||||
handles::{db_init, insert_user, select_channel, select_global},
|
||||
models::{Channel, User},
|
||||
};
|
||||
use crate::utils::{args_parse::Args, errors::ServiceError};
|
||||
use ffplayout_lib::utils::PlayoutConfig;
|
||||
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
@ -54,7 +49,7 @@ pub struct GlobalSettings {
|
||||
|
||||
impl GlobalSettings {
|
||||
async fn new() -> Self {
|
||||
let global_settings = db_global();
|
||||
let global_settings = select_global();
|
||||
|
||||
match global_settings.await {
|
||||
Ok(g) => g,
|
||||
@ -165,7 +160,7 @@ pub async fn run_args(mut args: Args) -> Result<(), i32> {
|
||||
token: None,
|
||||
};
|
||||
|
||||
if let Err(e) = db_add_user(user).await {
|
||||
if let Err(e) = insert_user(user).await {
|
||||
error!("{e}");
|
||||
return Err(1);
|
||||
};
|
||||
@ -185,8 +180,8 @@ pub fn read_playout_config(path: &str) -> Result<PlayoutConfig, Box<dyn Error>>
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub async fn playout_config(channel_id: &i64) -> Result<(PlayoutConfig, Channel), ServiceError> {
|
||||
if let Ok(channel) = db_get_channel(channel_id).await {
|
||||
pub async fn playout_config(channel_id: &i32) -> Result<(PlayoutConfig, Channel), ServiceError> {
|
||||
if let Ok(channel) = select_channel(channel_id).await {
|
||||
if let Ok(config) = read_playout_config(&channel.config_path.clone()) {
|
||||
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> {
|
||||
if let Ok(channel) = db_get_channel(channel_id).await {
|
||||
pub async fn read_log_file(channel_id: &i32, date: &str) -> Result<String, ServiceError> {
|
||||
if let Ok(channel) = select_channel(channel_id).await {
|
||||
let mut date_str = "".to_string();
|
||||
|
||||
if !date.is_empty() {
|
||||
|
@ -1,33 +1,13 @@
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
io::Error,
|
||||
path::PathBuf,
|
||||
};
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
use simplelog::*;
|
||||
|
||||
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> {
|
||||
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> {
|
||||
pub async fn read_playlist(id: i32, date: String) -> Result<JsonPlaylist, ServiceError> {
|
||||
let (config, _) = playout_config(&id).await?;
|
||||
let mut playlist_path = PathBuf::from(&config.playlist.path);
|
||||
let d: Vec<&str> = date.split('-').collect();
|
||||
@ -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 date = json_data.date.clone();
|
||||
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)
|
||||
}
|
||||
|
||||
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?;
|
||||
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 mut playlist_path = PathBuf::from(&config.playlist.path);
|
||||
let d: Vec<&str> = date.split('-').collect();
|
||||
|
@ -4,8 +4,9 @@ description = "24/7 playout based on rust and ffmpeg"
|
||||
license = "GPL-3.0"
|
||||
authors = ["Jonathan Baecker jonbae77@gmail.com"]
|
||||
readme = "README.md"
|
||||
version = "0.15.2"
|
||||
version = "0.16.0"
|
||||
edition = "2021"
|
||||
default-run = "ffplayout"
|
||||
|
||||
[dependencies]
|
||||
ffplayout-lib = { path = "../lib" }
|
||||
@ -15,6 +16,7 @@ crossbeam-channel = "0.5"
|
||||
futures = "0.3"
|
||||
jsonrpc-http-server = "18.0"
|
||||
notify = "4.0"
|
||||
regex = "1"
|
||||
reqwest = { version = "0.11", features = ["blocking", "json"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
@ -8,11 +8,14 @@ ffplayout also allows the passing of parameters:
|
||||
|
||||
```
|
||||
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
|
||||
-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
|
||||
-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
|
||||
-m, --play-mode <PLAY_MODE> Playing mode: folder, playlist
|
||||
-o, --output <OUTPUT> Set output mode: desktop, hls, stream
|
||||
|
@ -42,7 +42,7 @@ pub fn watchman(
|
||||
match res {
|
||||
Create(new_path) => {
|
||||
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) {
|
||||
sources.lock().unwrap().push(media);
|
||||
@ -66,7 +66,7 @@ pub fn watchman(
|
||||
.position(|x| *x.source == old_path.display().to_string())
|
||||
.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;
|
||||
|
||||
info!("Rename file: <b><magenta>{old_path:?}</></b> to <b><magenta>{new_path:?}</></b>");
|
||||
|
@ -84,7 +84,7 @@ pub fn ingest_server(
|
||||
let mut buffer: [u8; 65088] = [0; 65088];
|
||||
let mut server_cmd = vec_strings!["-hide_banner", "-nostats", "-v", "level+info"];
|
||||
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);
|
||||
let mut filters = filter_chains(&config, &mut dummy_media, &Arc::new(Mutex::new(vec![])));
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
use std::{
|
||||
process,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicUsize},
|
||||
Arc, Mutex,
|
||||
@ -9,7 +8,7 @@ use std::{
|
||||
|
||||
use simplelog::*;
|
||||
|
||||
use ffplayout_lib::utils::{Media, PlayoutConfig, PlayoutStatus};
|
||||
use ffplayout_lib::utils::{Media, PlayoutConfig, PlayoutStatus, ProcessMode::*};
|
||||
|
||||
pub mod folder;
|
||||
pub mod ingest;
|
||||
@ -29,8 +28,8 @@ pub fn source_generator(
|
||||
playout_stat: PlayoutStatus,
|
||||
is_terminated: Arc<AtomicBool>,
|
||||
) -> Box<dyn Iterator<Item = Media>> {
|
||||
let get_source = match config.processing.mode.as_str() {
|
||||
"folder" => {
|
||||
match config.processing.mode {
|
||||
Folder => {
|
||||
info!("Playout in folder mode");
|
||||
debug!(
|
||||
"Monitor folder: <b><magenta>{}</></b>",
|
||||
@ -46,18 +45,12 @@ pub fn source_generator(
|
||||
|
||||
Box::new(folder_source) as Box<dyn Iterator<Item = Media>>
|
||||
}
|
||||
"playlist" => {
|
||||
Playlist => {
|
||||
info!("Playout in playlist mode");
|
||||
let program =
|
||||
CurrentProgram::new(&config, playout_stat, is_terminated, current_list, index);
|
||||
|
||||
Box::new(program) as Box<dyn Iterator<Item = Media>>
|
||||
}
|
||||
_ => {
|
||||
error!("Process Mode not exists!");
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
get_source
|
||||
}
|
||||
|
@ -69,7 +69,7 @@ impl CurrentProgram {
|
||||
json_path: json.current_file,
|
||||
json_date: json.date,
|
||||
nodes: current_list,
|
||||
current_node: Media::new(0, String::new(), false),
|
||||
current_node: Media::new(0, "", false),
|
||||
index: global_index,
|
||||
is_terminated,
|
||||
playout_stat,
|
||||
@ -118,7 +118,7 @@ impl CurrentProgram {
|
||||
"Playlist <b><magenta>{}</></b> not exists!",
|
||||
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.duration = DUMMY_LEN;
|
||||
media.out = DUMMY_LEN;
|
||||
@ -304,7 +304,7 @@ impl Iterator for CurrentProgram {
|
||||
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.duration = duration;
|
||||
media.out = duration;
|
||||
@ -357,7 +357,7 @@ impl Iterator for CurrentProgram {
|
||||
// Test if playlist is to early finish,
|
||||
// and if we have to fill it with a placeholder.
|
||||
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());
|
||||
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.
|
||||
fn gen_source(
|
||||
pub fn gen_source(
|
||||
config: &PlayoutConfig,
|
||||
mut node: Media,
|
||||
filter_chain: &Arc<Mutex<Vec<String>>>,
|
||||
|
4
ffplayout-engine/src/lib.rs
Normal file
4
ffplayout-engine/src/lib.rs
Normal file
@ -0,0 +1,4 @@
|
||||
pub mod input;
|
||||
pub mod output;
|
||||
pub mod rpc;
|
||||
pub mod utils;
|
@ -12,27 +12,19 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use simplelog::*;
|
||||
|
||||
pub mod input;
|
||||
pub mod output;
|
||||
pub mod rpc;
|
||||
// #[cfg(test)]
|
||||
// mod tests;
|
||||
pub mod utils;
|
||||
|
||||
use utils::{arg_parse::get_args, get_config};
|
||||
|
||||
use crate::{
|
||||
use ffplayout::{
|
||||
output::{player, write_hls},
|
||||
rpc::json_rpc_server,
|
||||
utils::{arg_parse::get_args, get_config},
|
||||
};
|
||||
|
||||
use ffplayout_lib::utils::{
|
||||
generate_playlist, init_logging, send_mail, validate_ffmpeg, PlayerControl, PlayoutStatus,
|
||||
ProcessControl,
|
||||
generate_playlist, import::import_file, init_logging, send_mail, validate_ffmpeg,
|
||||
OutputMode::*, PlayerControl, PlayoutStatus, ProcessControl,
|
||||
};
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
use utils::Args;
|
||||
use ffplayout::utils::Args;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
use ffplayout_lib::utils::{mock_time, time_now};
|
||||
@ -90,10 +82,11 @@ fn fake_time(args: &Args) {
|
||||
fn main() {
|
||||
let args = get_args();
|
||||
|
||||
// use fake time function only in debugging mode
|
||||
#[cfg(debug_assertions)]
|
||||
fake_time(&args);
|
||||
|
||||
let config = get_config(args);
|
||||
let config = get_config(args.clone());
|
||||
let config_clone = config.clone();
|
||||
let play_control = PlayerControl::new();
|
||||
let playout_stat = PlayoutStatus::new();
|
||||
@ -122,6 +115,26 @@ fn main() {
|
||||
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 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));
|
||||
@ -129,9 +142,9 @@ fn main() {
|
||||
|
||||
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
|
||||
"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
|
||||
_ => player(&config, play_control, playout_stat, proc_control),
|
||||
}
|
||||
|
@ -28,10 +28,11 @@ use std::{
|
||||
use simplelog::*;
|
||||
|
||||
use crate::input::{ingest::log_line, source_generator};
|
||||
use crate::utils::prepare_output_cmd;
|
||||
use ffplayout_lib::filter::filter_chains;
|
||||
use ffplayout_lib::utils::{
|
||||
prepare_output_cmd, sec_to_time, stderr_reader, test_tcp_port, Decoder, Ingest, Media,
|
||||
PlayerControl, PlayoutConfig, PlayoutStatus, ProcessControl,
|
||||
sec_to_time, stderr_reader, test_tcp_port, Encoder, Ingest, Media, PlayerControl,
|
||||
PlayoutConfig, PlayoutStatus, ProcessControl,
|
||||
};
|
||||
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 stream_input = config.ingest.input_cmd.clone().unwrap();
|
||||
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);
|
||||
|
||||
let mut is_running;
|
||||
@ -79,12 +80,7 @@ fn ingest_to_hls_server(
|
||||
}
|
||||
}
|
||||
|
||||
let server_cmd = prepare_output_cmd(
|
||||
server_prefix.clone(),
|
||||
filters,
|
||||
config.out.clone().output_cmd.unwrap(),
|
||||
"hls",
|
||||
);
|
||||
let server_cmd = prepare_output_cmd(server_prefix.clone(), filters, &config);
|
||||
|
||||
debug!(
|
||||
"Server CMD: <bright-blue>\"ffmpeg {}\"</>",
|
||||
@ -124,7 +120,7 @@ fn ingest_to_hls_server(
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@ -200,12 +196,7 @@ pub fn write_hls(
|
||||
let mut enc_prefix = vec_strings!["-hide_banner", "-nostats", "-v", &ff_log_format];
|
||||
enc_prefix.append(&mut cmd);
|
||||
let enc_filter = node.filter.unwrap();
|
||||
let enc_cmd = prepare_output_cmd(
|
||||
enc_prefix,
|
||||
enc_filter,
|
||||
config.out.clone().output_cmd.unwrap(),
|
||||
&config.out.mode,
|
||||
);
|
||||
let enc_cmd = prepare_output_cmd(enc_prefix, enc_filter, config);
|
||||
|
||||
debug!(
|
||||
"HLS writer CMD: <bright-blue>\"ffmpeg {}\"</>",
|
||||
@ -218,20 +209,20 @@ pub fn write_hls(
|
||||
.spawn()
|
||||
{
|
||||
Err(e) => {
|
||||
error!("couldn't spawn decoder process: {e}");
|
||||
panic!("couldn't spawn decoder process: {e}")
|
||||
error!("couldn't spawn encoder process: {e}");
|
||||
panic!("couldn't spawn encoder process: {e}")
|
||||
}
|
||||
Ok(proc) => proc,
|
||||
};
|
||||
|
||||
let dec_err = BufReader::new(enc_proc.stderr.take().unwrap());
|
||||
*proc_control.decoder_term.lock().unwrap() = Some(enc_proc);
|
||||
let enc_err = BufReader::new(enc_proc.stderr.take().unwrap());
|
||||
*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:?}")
|
||||
};
|
||||
|
||||
if let Err(e) = proc_control.wait(Decoder) {
|
||||
if let Err(e) = proc_control.wait(Encoder) {
|
||||
error!("{e}");
|
||||
}
|
||||
|
||||
|
@ -18,8 +18,8 @@ pub use hls::write_hls;
|
||||
|
||||
use crate::input::{ingest_server, source_generator};
|
||||
use ffplayout_lib::utils::{
|
||||
sec_to_time, stderr_reader, Decoder, PlayerControl, PlayoutConfig, PlayoutStatus,
|
||||
ProcessControl,
|
||||
sec_to_time, stderr_reader, Decoder, OutputMode::*, PlayerControl, PlayoutConfig,
|
||||
PlayoutStatus, ProcessControl,
|
||||
};
|
||||
use ffplayout_lib::vec_strings;
|
||||
|
||||
@ -54,10 +54,10 @@ pub fn player(
|
||||
);
|
||||
|
||||
// get ffmpeg output instance
|
||||
let mut enc_proc = match config.out.mode.as_str() {
|
||||
"desktop" => desktop::output(config, &ff_log_format),
|
||||
"null" => null::output(config, &ff_log_format),
|
||||
"stream" => stream::output(config, &ff_log_format),
|
||||
let mut enc_proc = match config.out.mode {
|
||||
Desktop => desktop::output(config, &ff_log_format),
|
||||
Null => null::output(config, &ff_log_format),
|
||||
Stream => stream::output(config, &ff_log_format),
|
||||
_ => panic!("Output mode doesn't exists!"),
|
||||
};
|
||||
|
||||
|
@ -5,8 +5,9 @@ use std::{
|
||||
|
||||
use simplelog::*;
|
||||
|
||||
use crate::utils::prepare_output_cmd;
|
||||
use ffplayout_lib::filter::v_drawtext;
|
||||
use ffplayout_lib::utils::{prepare_output_cmd, PlayoutConfig};
|
||||
use ffplayout_lib::utils::PlayoutConfig;
|
||||
use ffplayout_lib::vec_strings;
|
||||
|
||||
/// Streaming Output
|
||||
@ -46,7 +47,7 @@ pub fn output(config: &PlayoutConfig, log_format: &str) -> process::Child {
|
||||
|
||||
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!(
|
||||
"Encoder CMD: <bright-blue>\"ffmpeg {}\"</>",
|
||||
|
@ -13,7 +13,7 @@ use simplelog::*;
|
||||
|
||||
use ffplayout_lib::utils::{
|
||||
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;
|
||||
@ -85,12 +85,12 @@ pub fn json_rpc_server(
|
||||
{
|
||||
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() {
|
||||
let mut clips_filter = playout_stat.chain.lock().unwrap();
|
||||
*clips_filter = vec![filter.clone()];
|
||||
|
||||
if config.out.mode == "hls" {
|
||||
if config.out.mode == HLS {
|
||||
if proc.server_is_running.load(Ordering::SeqCst) {
|
||||
let filter_server = format!(
|
||||
"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!(
|
||||
"Parsed_drawtext_{} reinit {filter}",
|
||||
playout_stat.drawtext_stream_index.load(Ordering::SeqCst)
|
||||
|
@ -1,5 +1,7 @@
|
||||
use clap::Parser;
|
||||
|
||||
use ffplayout_lib::utils::{OutputMode, ProcessMode};
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[clap(version,
|
||||
about = "ffplayout, Rust based 24/7 playout solution.",
|
||||
@ -26,11 +28,24 @@ pub struct Args {
|
||||
pub generate: Option<Vec<String>>,
|
||||
|
||||
#[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")]
|
||||
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")]
|
||||
pub playlist: Option<String>,
|
||||
|
||||
@ -51,8 +66,8 @@ pub struct Args {
|
||||
#[clap(short, long, help = "Loop playlist infinitely")]
|
||||
pub infinit: bool,
|
||||
|
||||
#[clap(short, long, help = "Set output mode: desktop, hls, stream")]
|
||||
pub output: Option<String>,
|
||||
#[clap(short, long, help = "Set output mode: desktop, hls, null, stream")]
|
||||
pub output: Option<OutputMode>,
|
||||
|
||||
#[clap(short, long, help = "Set audio volume")]
|
||||
pub volume: Option<f64>,
|
||||
|
@ -3,11 +3,17 @@ use std::{
|
||||
process::exit,
|
||||
};
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
pub mod arg_parse;
|
||||
|
||||
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 {
|
||||
let cfg_path = match args.channel {
|
||||
Some(c) => {
|
||||
@ -48,7 +54,7 @@ pub fn get_config(args: Args) -> PlayoutConfig {
|
||||
|
||||
if let Some(folder) = args.folder {
|
||||
config.storage.path = folder;
|
||||
config.processing.mode = "folder".into();
|
||||
config.processing.mode = Folder;
|
||||
}
|
||||
|
||||
if let Some(start) = args.start {
|
||||
@ -80,4 +86,130 @@ pub fn get_config(args: Args) -> PlayoutConfig {
|
||||
|
||||
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(¶m, "[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
|
@ -4,7 +4,7 @@ description = "Library for ffplayout"
|
||||
license = "GPL-3.0"
|
||||
authors = ["Jonathan Baecker jonbae77@gmail.com"]
|
||||
readme = "README.md"
|
||||
version = "0.15.2"
|
||||
version = "0.16.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
@ -1,85 +1,140 @@
|
||||
use std::{
|
||||
fmt,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use simplelog::*;
|
||||
|
||||
pub mod a_loudnorm;
|
||||
pub mod custom_filter;
|
||||
mod a_loudnorm;
|
||||
mod custom_filter;
|
||||
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 {
|
||||
Audio,
|
||||
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)]
|
||||
struct Filters {
|
||||
audio_chain: Option<String>,
|
||||
video_chain: Option<String>,
|
||||
audio_map: String,
|
||||
video_map: String,
|
||||
audio_chain: String,
|
||||
video_chain: String,
|
||||
final_chain: 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 {
|
||||
fn new() -> Self {
|
||||
Filters {
|
||||
audio_chain: None,
|
||||
video_chain: None,
|
||||
audio_map: "0:a".to_string(),
|
||||
video_map: "0:v".to_string(),
|
||||
fn new(position: i32) -> Self {
|
||||
Self {
|
||||
audio_chain: String::new(),
|
||||
video_chain: String::new(),
|
||||
final_chain: String::new(),
|
||||
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) {
|
||||
match codec_type {
|
||||
Audio => match &self.audio_chain {
|
||||
Some(ac) => {
|
||||
if filter.starts_with(';') || filter.starts_with('[') {
|
||||
self.audio_chain = Some(format!("{ac}{filter}"))
|
||||
fn add_filter(&mut self, filter: &str, track_nr: i32, filter_type: FilterType) {
|
||||
let (map, chain, position, last) = match filter_type {
|
||||
Audio => (
|
||||
&mut self.audio_map,
|
||||
&mut self.audio_chain,
|
||||
self.audio_position,
|
||||
&mut self.audio_last,
|
||||
),
|
||||
Video => (
|
||||
&mut self.video_map,
|
||||
&mut self.video_chain,
|
||||
self.video_position,
|
||||
&mut self.video_last,
|
||||
),
|
||||
};
|
||||
|
||||
if *last != track_nr {
|
||||
// start new filter chain
|
||||
let mut selector = String::new();
|
||||
let mut sep = String::new();
|
||||
if !chain.is_empty() {
|
||||
selector = format!("[{}out{}]", filter_type, last);
|
||||
sep = ";".to_string()
|
||||
}
|
||||
|
||||
chain.push_str(&selector);
|
||||
|
||||
if filter.starts_with("aevalsrc") || filter.starts_with("movie") {
|
||||
chain.push_str(&format!("{sep}{filter}"));
|
||||
} else {
|
||||
self.audio_chain = Some(format!("{ac},{filter}"))
|
||||
chain.push_str(&format!(
|
||||
"{sep}[{}:{}:{track_nr}]{filter}",
|
||||
position, filter_type
|
||||
));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if filter.contains("aevalsrc") || filter.contains("anoisesrc") {
|
||||
self.audio_chain = Some(filter.to_string());
|
||||
|
||||
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 {
|
||||
self.audio_chain = Some(format!("[{}]{filter}", self.audio_map.clone()));
|
||||
}
|
||||
self.audio_map = "[aout1]".to_string();
|
||||
}
|
||||
},
|
||||
Video => match &self.video_chain {
|
||||
Some(vc) => {
|
||||
if filter.starts_with(';') || filter.starts_with('[') {
|
||||
self.video_chain = Some(format!("{vc}{filter}"))
|
||||
} else {
|
||||
self.video_chain = Some(format!("{vc},{filter}"))
|
||||
chain.push_str(&format!(",{filter}"))
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.video_chain = Some(format!("[0:v]{filter}"));
|
||||
self.video_map = "[vout1]".to_string();
|
||||
}
|
||||
},
|
||||
|
||||
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) {
|
||||
if let Some(order) = field_order {
|
||||
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",
|
||||
config.processing.width, config.processing.height
|
||||
),
|
||||
0,
|
||||
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) {
|
||||
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={}:{}",
|
||||
config.processing.width, config.processing.height
|
||||
),
|
||||
0,
|
||||
Video,
|
||||
);
|
||||
} else {
|
||||
chain.add_filter("null", Video);
|
||||
chain.add_filter("null", 0, Video);
|
||||
}
|
||||
|
||||
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 {
|
||||
chain.add_filter(
|
||||
@ -141,27 +202,33 @@ fn scale(
|
||||
"scale={}:{}",
|
||||
config.processing.width, config.processing.height
|
||||
),
|
||||
0,
|
||||
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 = "";
|
||||
|
||||
if codec_type == Audio {
|
||||
if filter_type == Audio {
|
||||
t = "a"
|
||||
}
|
||||
|
||||
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 {
|
||||
chain.add_filter(
|
||||
&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()
|
||||
&& &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) {
|
||||
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
|
||||
.push_str(format!("[l];[v][l]{}:shortest=1", config.processing.logo_filter).as_str());
|
||||
|
||||
chain.add_filter(&logo_chain, Video);
|
||||
chain.add_filter(&logo_chain, 0, Video);
|
||||
}
|
||||
}
|
||||
|
||||
@ -204,6 +271,7 @@ fn extend_video(node: &mut Media, chain: &mut Filters) {
|
||||
"tpad=stop_mode=add:stop_duration={}",
|
||||
(node.out - node.seek) - (video_duration - node.seek)
|
||||
),
|
||||
0,
|
||||
Video,
|
||||
)
|
||||
}
|
||||
@ -217,33 +285,22 @@ fn add_text(
|
||||
config: &PlayoutConfig,
|
||||
filter_chain: &Arc<Mutex<Vec<String>>>,
|
||||
) {
|
||||
if config.text.add_text
|
||||
&& (config.text.text_from_filename || config.out.mode.to_lowercase() == "hls")
|
||||
{
|
||||
if config.text.add_text && (config.text.text_from_filename || config.out.mode == HLS) {
|
||||
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) {
|
||||
if node
|
||||
.probe
|
||||
.as_ref()
|
||||
.and_then(|p| p.audio_streams.get(0))
|
||||
.is_none()
|
||||
&& !Path::new(&node.audio).is_file()
|
||||
{
|
||||
warn!("Clip <b><magenta>{}</></b> has no audio!", node.source);
|
||||
fn add_audio(node: &Media, chain: &mut Filters, nr: i32) {
|
||||
let audio = format!(
|
||||
"aevalsrc=0:channel_layout=stereo:duration={}:sample_rate=48000",
|
||||
node.out - node.seek
|
||||
);
|
||||
chain.add_filter(&audio, Audio);
|
||||
}
|
||||
chain.add_filter(&audio, nr, 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() {
|
||||
Some(MediaProbe::new(&node.audio))
|
||||
} else {
|
||||
@ -257,22 +314,26 @@ fn extend_audio(node: &mut Media, chain: &mut Filters) {
|
||||
.and_then(|a| a.parse::<f64>().ok())
|
||||
{
|
||||
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.
|
||||
fn add_loudnorm(chain: &mut Filters, config: &PlayoutConfig) {
|
||||
fn add_loudnorm(chain: &mut Filters, config: &PlayoutConfig, nr: i32) {
|
||||
if config.processing.add_loudnorm {
|
||||
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 {
|
||||
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.
|
||||
fn realtime_filter(
|
||||
node: &mut Media,
|
||||
chain: &mut Filters,
|
||||
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");
|
||||
fn realtime(node: &mut Media, chain: &mut Filters, config: &PlayoutConfig) {
|
||||
if config.general.generate.is_none() && config.out.mode == HLS {
|
||||
let mut speed_filter = "realtime=speed=1".to_string();
|
||||
|
||||
if let Some(begin) = &node.begin {
|
||||
let (delta, _) = get_delta(config, begin);
|
||||
@ -313,24 +363,18 @@ fn realtime_filter(
|
||||
let speed = duration / (duration + delta);
|
||||
|
||||
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) {
|
||||
let (video_filter, audio_filter) = custom_filter(filter);
|
||||
|
||||
if !video_filter.is_empty() {
|
||||
chain.add_filter(&video_filter, Video);
|
||||
}
|
||||
|
||||
if !audio_filter.is_empty() {
|
||||
chain.add_filter(&audio_filter, Audio);
|
||||
fn custom(filter: &str, chain: &mut Filters, nr: i32, filter_type: FilterType) {
|
||||
if !filter.is_empty() {
|
||||
chain.add_filter(filter, nr, filter_type);
|
||||
}
|
||||
}
|
||||
|
||||
@ -339,11 +383,11 @@ pub fn filter_chains(
|
||||
node: &mut Media,
|
||||
filter_chain: &Arc<Mutex<Vec<String>>>,
|
||||
) -> Vec<String> {
|
||||
let mut filters = Filters::new();
|
||||
let mut filters = Filters::new(0);
|
||||
|
||||
if let Some(probe) = node.probe.as_ref() {
|
||||
if probe.audio_streams.get(0).is_none() || Path::new(&node.audio).is_file() {
|
||||
filters.audio_map = "1:a".to_string();
|
||||
if Path::new(&node.audio).is_file() {
|
||||
filters.audio_position = 1;
|
||||
}
|
||||
|
||||
if let Some(v_stream) = &probe.video_streams.get(0) {
|
||||
@ -363,56 +407,51 @@ pub fn filter_chains(
|
||||
}
|
||||
|
||||
extend_video(node, &mut filters);
|
||||
|
||||
add_audio(node, &mut filters);
|
||||
extend_audio(node, &mut filters);
|
||||
} else {
|
||||
fps(0.0, &mut filters, config);
|
||||
scale(None, None, 1.0, &mut filters, config);
|
||||
}
|
||||
|
||||
add_text(node, &mut filters, config, filter_chain);
|
||||
fade(node, &mut filters, Video);
|
||||
fade(node, &mut filters, 0, Video);
|
||||
overlay(node, &mut filters, config);
|
||||
realtime_filter(node, &mut filters, config, Video);
|
||||
realtime(node, &mut filters, config);
|
||||
|
||||
add_loudnorm(&mut filters, config);
|
||||
fade(node, &mut filters, Audio);
|
||||
audio_volume(&mut filters, config);
|
||||
realtime_filter(node, &mut filters, config, Audio);
|
||||
let (proc_vf, proc_af) = custom_filter(&config.processing.custom_filter);
|
||||
let (list_vf, list_af) = custom_filter(&node.custom_filter);
|
||||
|
||||
custom(&config.processing.custom_filter, &mut filters);
|
||||
custom(&node.custom_filter, &mut filters);
|
||||
custom(&proc_vf, &mut filters, 0, Video);
|
||||
custom(&list_vf, &mut filters, 0, Video);
|
||||
|
||||
let mut filter_cmd = vec![];
|
||||
let mut filter_str: String = String::new();
|
||||
let mut filter_map: Vec<String> = vec![];
|
||||
for i in 0..config.processing.audio_tracks {
|
||||
if node
|
||||
.probe
|
||||
.as_ref()
|
||||
.and_then(|p| p.audio_streams.get(i as usize))
|
||||
.is_some()
|
||||
{
|
||||
extend_audio(node, &mut filters, i);
|
||||
} else if !node.is_live.unwrap_or(false) {
|
||||
warn!(
|
||||
"Missing audio track (id {i}) from <b><magenta>{}</></b>",
|
||||
node.source
|
||||
);
|
||||
add_audio(node, &mut filters, i);
|
||||
}
|
||||
// add at least anull filter, for correct filter construction,
|
||||
// is important for split filter in HLS mode
|
||||
filters.add_filter("anull", i, Audio);
|
||||
|
||||
if let Some(v_filters) = filters.video_chain {
|
||||
filter_str.push_str(v_filters.as_str());
|
||||
filter_str.push_str(filters.video_map.clone().as_str());
|
||||
filter_map.append(&mut vec!["-map".to_string(), filters.video_map]);
|
||||
} else {
|
||||
filter_map.append(&mut vec!["-map".to_string(), "0:v".to_string()]);
|
||||
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 let Some(a_filters) = filters.audio_chain {
|
||||
if filter_str.len() > 10 {
|
||||
filter_str.push(';')
|
||||
}
|
||||
filter_str.push_str(a_filters.as_str());
|
||||
filter_str.push_str(filters.audio_map.clone().as_str());
|
||||
filter_map.append(&mut vec!["-map".to_string(), filters.audio_map]);
|
||||
} else {
|
||||
filter_map.append(&mut vec!["-map".to_string(), filters.audio_map]);
|
||||
}
|
||||
filters.close_chains();
|
||||
filters.build_final_chain();
|
||||
|
||||
if filter_str.len() > 10 {
|
||||
filter_cmd.push("-filter_complex".to_string());
|
||||
filter_cmd.push(filter_str);
|
||||
}
|
||||
|
||||
filter_cmd.append(&mut filter_map);
|
||||
|
||||
filter_cmd
|
||||
filters.cmd
|
||||
}
|
||||
|
@ -26,12 +26,9 @@ pub fn filter_node(
|
||||
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() {
|
||||
let source = node
|
||||
.unwrap_or(&Media::new(0, String::new(), false))
|
||||
.source
|
||||
.clone();
|
||||
let source = node.unwrap_or(&Media::new(0, "", false)).source.clone();
|
||||
let regex: Regex = Regex::new(&config.text.regex).unwrap();
|
||||
|
||||
let text: String = match regex.captures(&source) {
|
||||
|
@ -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
|
||||
}
|
@ -4,6 +4,3 @@ extern crate simplelog;
|
||||
pub mod filter;
|
||||
pub mod macros;
|
||||
pub mod utils;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
@ -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);
|
||||
}
|
@ -1,14 +1,15 @@
|
||||
use std::{
|
||||
env,
|
||||
env, fmt,
|
||||
fs::File,
|
||||
path::{Path, PathBuf},
|
||||
process,
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
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;
|
||||
|
||||
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",
|
||||
];
|
||||
|
||||
// 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
|
||||
///
|
||||
/// 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)]
|
||||
pub struct Processing {
|
||||
pub help_text: String,
|
||||
pub mode: String,
|
||||
pub mode: ProcessMode,
|
||||
pub width: i64,
|
||||
pub height: i64,
|
||||
pub aspect: f64,
|
||||
pub fps: f64,
|
||||
pub add_logo: bool,
|
||||
pub logo: String,
|
||||
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
pub logo_fps: Option<f64>,
|
||||
|
||||
pub logo_scale: String,
|
||||
pub logo_opacity: f32,
|
||||
pub logo_filter: String,
|
||||
#[serde(default)]
|
||||
pub audio_tracks: i32,
|
||||
pub add_loudnorm: bool,
|
||||
pub loudnorm_ingest: bool,
|
||||
pub loud_i: f32,
|
||||
@ -168,7 +225,7 @@ pub struct Text {
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Out {
|
||||
pub help_text: String,
|
||||
pub mode: String,
|
||||
pub mode: OutputMode,
|
||||
pub output_param: String,
|
||||
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
@ -227,17 +284,12 @@ impl PlayoutConfig {
|
||||
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 let Some(v_stream) = MediaProbe::new(&config.processing.logo)
|
||||
.video_streams
|
||||
.get(0)
|
||||
{
|
||||
let fps = fps_calc(&v_stream.r_frame_rate, config.processing.fps);
|
||||
|
||||
config.processing.logo_fps = Some(fps);
|
||||
};
|
||||
if config.processing.audio_tracks < 1 {
|
||||
config.processing.audio_tracks = 1
|
||||
}
|
||||
|
||||
// We set the decoder settings here, so we only define them ones.
|
||||
|
@ -92,9 +92,7 @@ impl ProcessControl {
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = self.wait(unit) {
|
||||
return Err(e);
|
||||
};
|
||||
self.wait(unit)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -169,7 +167,7 @@ impl PlayerControl {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ impl FolderSource {
|
||||
.filter(|f| f.path().is_file())
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -83,7 +83,7 @@ impl FolderSource {
|
||||
config: config.clone(),
|
||||
filter_chain,
|
||||
nodes: current_list,
|
||||
current_node: Media::new(0, String::new(), false),
|
||||
current_node: Media::new(0, "", false),
|
||||
index: global_index,
|
||||
}
|
||||
}
|
||||
|
@ -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 playlist_root = Path::new(&config.playlist.path);
|
||||
let mut playlists = vec![];
|
||||
@ -119,7 +119,7 @@ pub fn generate_playlist(
|
||||
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 mut length = 0.0;
|
||||
let mut round = 0;
|
||||
|
78
lib/src/utils/import.rs
Normal file
78
lib/src/utils/import.rs
Normal 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)),
|
||||
}
|
||||
}
|
@ -34,7 +34,7 @@ pub struct JsonPlaylist {
|
||||
|
||||
impl JsonPlaylist {
|
||||
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.duration = DUMMY_LEN;
|
||||
media.out = DUMMY_LEN;
|
||||
|
@ -11,7 +11,7 @@ use simplelog::*;
|
||||
|
||||
use crate::utils::{
|
||||
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.
|
||||
@ -58,9 +58,7 @@ fn check_media(
|
||||
let mut filter = node.filter.unwrap_or_default();
|
||||
|
||||
if filter.len() > 1 {
|
||||
filter[1] = filter[1]
|
||||
.replace("realtime=speed=1", "null")
|
||||
.replace("arealtime=speed=1", "snull")
|
||||
filter[1] = filter[1].replace("realtime=speed=1", "null")
|
||||
}
|
||||
|
||||
enc_cmd.append(&mut node.cmd.unwrap_or_default());
|
||||
@ -81,6 +79,7 @@ fn check_media(
|
||||
for line in enc_err.lines() {
|
||||
let line = line?;
|
||||
|
||||
if !FFMPEG_IGNORE_ERRORS.iter().any(|i| line.contains(*i)) {
|
||||
if line.contains("[error]") {
|
||||
let log_line = format_log_line(line, "error");
|
||||
|
||||
@ -95,10 +94,11 @@ fn check_media(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !error_list.is_empty() {
|
||||
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),
|
||||
node.source,
|
||||
error_list.join("\n")
|
||||
@ -107,6 +107,10 @@ fn check_media(
|
||||
|
||||
error_list.clear();
|
||||
|
||||
if let Err(e) = enc_proc.wait() {
|
||||
error!("Validation process: {e:?}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
use std::{
|
||||
ffi::OsStr,
|
||||
fs::{self, metadata},
|
||||
fs::{self, metadata, File},
|
||||
io::{BufRead, BufReader, Error},
|
||||
net::TcpListener,
|
||||
path::{Path, PathBuf},
|
||||
@ -26,6 +26,7 @@ pub mod config;
|
||||
pub mod controller;
|
||||
pub mod folder;
|
||||
mod generator;
|
||||
pub mod import;
|
||||
pub mod json_serializer;
|
||||
mod json_validate;
|
||||
mod logging;
|
||||
@ -33,7 +34,13 @@ mod logging;
|
||||
#[cfg(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 generator::generate_playlist;
|
||||
pub use json_serializer::{read_json, JsonPlaylist};
|
||||
@ -97,12 +104,12 @@ pub struct 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 probe = None;
|
||||
|
||||
if do_probe && Path::new(&src).is_file() {
|
||||
probe = Some(MediaProbe::new(&src));
|
||||
if do_probe && Path::new(src).is_file() {
|
||||
probe = Some(MediaProbe::new(src));
|
||||
|
||||
if let Some(dur) = probe
|
||||
.as_ref()
|
||||
@ -120,9 +127,9 @@ impl Media {
|
||||
out: duration,
|
||||
duration,
|
||||
category: String::new(),
|
||||
source: src.clone(),
|
||||
source: src.to_string(),
|
||||
audio: String::new(),
|
||||
cmd: Some(vec!["-i".to_string(), src]),
|
||||
cmd: Some(vec_strings!["-i", src]),
|
||||
filter: Some(vec![]),
|
||||
custom_filter: String::new(),
|
||||
probe,
|
||||
@ -247,6 +254,24 @@ pub fn fps_calc(r_frame_rate: &str, default: f64) -> f64 {
|
||||
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.
|
||||
pub fn get_filter_from_json(raw_text: String) -> String {
|
||||
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();
|
||||
|
||||
// local.timestamp_millis() as i64
|
||||
// local.timestamp_millis() as i32
|
||||
// }
|
||||
|
||||
/// 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 cut_audio = false;
|
||||
|
||||
if node.seek > 0.0 {
|
||||
if node.seek > 0.5 {
|
||||
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()]);
|
||||
|
||||
if Path::new(&node.audio).is_file() {
|
||||
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])
|
||||
}
|
||||
|
||||
@ -522,98 +539,40 @@ pub fn gen_dummy(config: &PlayoutConfig, duration: f64) -> (String, Vec<String>)
|
||||
"color=c={color}:s={}x{}:d={duration}",
|
||||
config.processing.width, config.processing.height
|
||||
);
|
||||
let cmd: Vec<String> = vec![
|
||||
"-f".to_string(),
|
||||
"lavfi".to_string(),
|
||||
"-i".to_string(),
|
||||
let cmd: Vec<String> = vec_strings![
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
format!(
|
||||
"{source}:r={},format=pix_fmts=yuv420p",
|
||||
config.processing.fps
|
||||
),
|
||||
"-f".to_string(),
|
||||
"lavfi".to_string(),
|
||||
"-i".to_string(),
|
||||
format!("anoisesrc=d={duration}:c=pink:r=48000:a=0.3"),
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
format!("anoisesrc=d={duration}:c=pink:r=48000:a=0.3")
|
||||
];
|
||||
|
||||
(source, cmd)
|
||||
}
|
||||
|
||||
/// Prepare output parameters
|
||||
///
|
||||
/// 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;
|
||||
// fn get_output_count(cmd: &[String]) -> i32 {
|
||||
// let mut count = 0;
|
||||
|
||||
if !filter.is_empty() {
|
||||
output_params.clear();
|
||||
// if let Some(index) = cmd.iter().position(|c| c == "-var_stream_map") {
|
||||
// if let Some(mapping) = cmd.get(index + 1) {
|
||||
// return mapping.split(' ').count() as i32;
|
||||
// };
|
||||
// };
|
||||
|
||||
for (i, p) in params.iter().enumerate() {
|
||||
let mut param = p.clone();
|
||||
// for (i, param) in cmd.iter().enumerate() {
|
||||
// if i > 0 && !param.starts_with('-') && !cmd[i - 1].starts_with('-') {
|
||||
// count += 1;
|
||||
// }
|
||||
// }
|
||||
|
||||
param = param.replace("[0:v]", "[vout1]");
|
||||
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
|
||||
}
|
||||
// count
|
||||
// }
|
||||
|
||||
pub fn is_remote(path: &str) -> bool {
|
||||
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
|
||||
.out
|
||||
.output_cmd
|
||||
@ -700,7 +659,9 @@ pub fn stderr_reader(
|
||||
"<bright black>[{suffix}]</> {}",
|
||||
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!(
|
||||
"<bright black>[{suffix}]</> {}",
|
||||
line.replace("[error]", "").replace("[fatal]", "")
|
||||
@ -780,7 +741,7 @@ pub fn validate_ffmpeg(config: &PlayoutConfig) -> Result<(), String> {
|
||||
is_in_system("ffmpeg")?;
|
||||
is_in_system("ffprobe")?;
|
||||
|
||||
if config.out.mode == "desktop" {
|
||||
if config.out.mode == Desktop {
|
||||
is_in_system("ffplay")?;
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
source $(dirname "$0")/man_create.sh
|
||||
target=$1
|
||||
|
||||
echo "build frontend"
|
||||
echo
|
||||
@ -14,7 +15,11 @@ mv dist ../public
|
||||
|
||||
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="= "
|
||||
while read -r name value; do
|
||||
@ -68,10 +73,14 @@ for target in "${targets[@]}"; do
|
||||
echo ""
|
||||
done
|
||||
|
||||
cargo deb --target=x86_64-unknown-linux-musl -p ffplayout --manifest-path=ffplayout-engine/Cargo.toml -o ffplayout_${version}_amd64.deb
|
||||
cargo deb --target=aarch64-unknown-linux-gnu --variant=arm64 -p ffplayout --manifest-path=ffplayout-engine/Cargo.toml -o ffplayout_${version}_arm64.deb
|
||||
if [[ -z $target ]] || [[ $target == "x86_64-unknown-linux-musl" ]]; then
|
||||
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
|
||||
cargo generate-rpm --target=x86_64-unknown-linux-musl -o ../ffplayout-${version}-1.x86_64.rpm
|
||||
cd ..
|
||||
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
42
tests/Cargo.toml
Normal 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
BIN
tests/assets/ad.mp4
Normal file
Binary file not shown.
BIN
tests/assets/audio.mp3
Normal file
BIN
tests/assets/audio.mp3
Normal file
Binary file not shown.
BIN
tests/assets/av_sync.mp4
Normal file
BIN
tests/assets/av_sync.mp4
Normal file
Binary file not shown.
BIN
tests/assets/dual_audio.mp4
Normal file
BIN
tests/assets/dual_audio.mp4
Normal file
Binary file not shown.
BIN
tests/assets/logo.png
Normal file
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
BIN
tests/assets/no_audio.mp4
Normal file
Binary file not shown.
22526
tests/assets/playlist_full.json
Executable file
22526
tests/assets/playlist_full.json
Executable file
File diff suppressed because it is too large
Load Diff
56
tests/assets/playlist_short.json
Normal file
56
tests/assets/playlist_short.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
BIN
tests/assets/short_audio.mp4
Normal file
BIN
tests/assets/short_audio.mp4
Normal file
Binary file not shown.
BIN
tests/assets/short_video.mp4
Normal file
BIN
tests/assets/short_video.mp4
Normal file
Binary file not shown.
BIN
tests/assets/still.jpg
Normal file
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
BIN
tests/assets/with_audio.mp4
Normal file
Binary file not shown.
1080
tests/src/engine_cmd.rs
Normal file
1080
tests/src/engine_cmd.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -3,13 +3,11 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::output::player;
|
||||
#[cfg(test)]
|
||||
use ffplayout_lib::utils::*;
|
||||
#[cfg(test)]
|
||||
use simplelog::*;
|
||||
|
||||
use ffplayout::output::player;
|
||||
use ffplayout_lib::utils::*;
|
||||
|
||||
fn timed_kill(sec: u64, mut proc_ctl: ProcessControl) {
|
||||
sleep(Duration::from_secs(sec));
|
||||
|
||||
@ -21,7 +19,7 @@ fn timed_kill(sec: u64, mut proc_ctl: ProcessControl) {
|
||||
fn playlist_change_at_midnight() {
|
||||
let mut config = PlayoutConfig::new(None);
|
||||
config.mail.recipient = "".into();
|
||||
config.processing.mode = "playlist".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;
|
||||
@ -46,7 +44,7 @@ fn playlist_change_at_midnight() {
|
||||
fn playlist_change_at_six() {
|
||||
let mut config = PlayoutConfig::new(None);
|
||||
config.mail.recipient = "".into();
|
||||
config.processing.mode = "playlist".into();
|
||||
config.processing.mode = Playlist;
|
||||
config.playlist.day_start = "06:00:00".into();
|
||||
config.playlist.length = "24:00:00".into();
|
||||
config.logging.log_to_file = false;
|
52
tests/src/lib_utils.rs
Normal file
52
tests/src/lib_utils.rs
Normal 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);
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user