Merge pull request #46 from ffplayout/dev

Dev
This commit is contained in:
jb-alvarado 2020-02-11 20:53:58 +01:00 committed by GitHub
commit 50b305e736
18 changed files with 2072 additions and 1693 deletions

3
.gitignore vendored
View File

@ -7,3 +7,6 @@ __pycache__/
*-orig.* *-orig.*
*.json *.json
tests/ tests/
.pytest_cache/
venv/
log/

55
Makefile Normal file
View File

@ -0,0 +1,55 @@
SHELL := /bin/bash
CURRENT_DIR = $(shell pwd)
init:
virtualenv -p python3 venv
source ./venv/bin/activate && pip install -r requirements.txt
@echo ""
@echo "-------------------------------------------------------------------"
@echo "external packages for ffplayout installed in \"$(CURRENT_DIR)/venv\""
@echo ""
@echo "run:"
@echo "\"$(CURRENT_DIR)/venv/bin/python\" \"$(CURRENT_DIR)/ffplayout.py\""
@echo ""
@echo "or:"
@echo "source ./venv/bin/activate"
@echo "./ffplayout.py"
@echo ""
@echo "-------------------------------------------------------------------"
@echo "run \"sudo make install USER=www-data\" if you would like to run ffplayout on server like environments"
@echo "instead of www-data you can use any user which need write access to the config file"
@echo "this user will also be placed in systemd service"
@echo "systemd is required!"
install:
if [ ! "$(CURRENT_DIR)" == "/opt/ffplayout-engine" ]; then \
install -d -o $(USER) -g $(USER) /opt/ffplayout-engine/; \
cp -r docs ffplayout venv "/opt/ffplayout-engine/"; \
chown $(USER):$(USER) -R "/opt/ffplayout-engine/"; \
install -m 755 -o $(USER) -g $(USER) ffplayout.py "/opt/ffplayout-engine/"; \
fi
install -d /etc/ffplayout/
install -d -o $(USER) -g $(USER) /var/log/ffplayout/
if [ ! -f "/etc/ffplayout/ffplayout.yml" ]; then \
install -m 644 -o $(USER) -g $(USER) ffplayout.yml /etc/ffplayout/; \
fi
if [ -d "/etc/systemd/system" ] && [ ! -f "/etc/systemd/system/ffplayout.service" ]; then \
install -m 644 docs/ffplayout.service /etc/systemd/system/; \
sed -i "s/root/$(USER)/g" "/etc/systemd/system/ffplayout.service"; \
fi
@echo ""
@echo "-------------------------------------------------------------------"
@echo "installation done..."
@echo ""
@echo "if you want ffplayout to autostart, run: \"systemctl enable ffplayout\""
clean:
rm -rf venv
uninstall:
rm -rf "/etc/ffplayout"
rm -rf "/var/log/ffplayout"
rm -rf "/etc/systemd/system/ffplayout.service"
if [ ! "$(CURRENT_DIR)" == "/opt/ffplayout-engine" ]; then \
rm -rf "/opt/ffplayout-engine"; \
fi

View File

@ -41,7 +41,7 @@ Features
Requirements Requirements
----- -----
- python version 3.6+ - python version 3.6+
- python module **watchdog** (only when `playlist_mode = False`) - python module **watchdog** (only when `playlist_mode: False`)
- python module **colorama** if you are on windows - python module **colorama** if you are on windows
- **ffmpeg v4.2+** and **ffprobe** (**ffplay** if you want to play on desktop) - **ffmpeg v4.2+** and **ffprobe** (**ffplay** if you want to play on desktop)
- if you want to overlay text, ffmpeg needs to have **libzmq** - if you want to overlay text, ffmpeg needs to have **libzmq**
@ -103,16 +103,7 @@ More informations in [Wiki](https://github.com/ffplayout/ffplayout-engine/wiki/R
Installation Installation
----- -----
- install ffmpeg, ffprobe (and ffplay if you need the preview mode) Check [INSTALL.md](docs/INSTALL.md)
- copy ffplayout.py to **/usr/local/bin/**
- copy ffplayout.conf to **/etc/ffplayout/**
- create folder with correct permissions for logging (check config)
- copy ffplayout.service to **/etc/systemd/system/**
- change user in **/etc/systemd/system/ffplayout.service**
- create playlists folder, in that format: **/playlists/year/month**
- set variables in config file to your needs
- use **gen_playlist_from_subfolders.sh /path/to/mp4s/** as a starting point for your playlists (path in script needs to change)
- activate service and start it: **sudo systemctl enable ffplayout && sudo systemctl start ffplayout**
Start with Arguments Start with Arguments
----- -----
@ -129,9 +120,9 @@ ffplayout also allows the passing of parameters:
You can run the command like: You can run the command like:
``` ```
python3 ffplayout.py -l ~/ -p ~/playlist.json -d -s now -t none ./ffplayout.py -l none -p ~/playlist.json -d -s now -t none
``` ```
Play on Desktop Play on Desktop
----- -----
For playing on desktop use `-d` argument or set `preview = True` in config under `[OUT]`. For playing on desktop use `-d` argument or set `preview: True` in config under `out:`.

151
docs/CONFIG.md Normal file
View File

@ -0,0 +1,151 @@
The configuration file **ffplayout.yml** have this sections:
---
```YAML
general:
stop_on_error: True
stop_threshold: 11
```
sometimes it can happen, that a file is corrupt but still playable,
this can produce an streaming error over all following files.
The only way in this case is, to stop ffplayout and start it again
here we only say it can stop, the starting process is in your hand
best way is a **systemd serivce** on linux.
`stop_threshold:` stop ffplayout, if it is async in time above this value.
---
```YAML
mail:
subject: "Playout Error"
smpt_server: "mail.example.org"
smpt_port: 587
sender_addr: "ffplayout@example.org"
sender_pass: "12345"
recipient:
mail_level: "ERROR"
```
Send error messages to email address, like:
- missing playlist
- unvalid json format
- missing clip path
leave recipient blank, if you don't need this.
`mail_level` can be: **WARNING, ERROR**
---
```YAML
logging:
log_to_file: True
log_path: "/var/log/ffplayout/"
log_level: "DEBUG"
ffmpeg_level: "ERROR"
```
Logging to file, if `log_to_file = False` > log to console.
Path to **/var/log/** only if you run this program as *deamon*.
`log_level` can be: **DEBUG, INFO, WARNING, ERROR**
`ffmpeg_level` can be: **INFO, WARNING, ERROR**
---
```YAML
pre_compress:
width: 1024
height: 576
aspect: 1.778
fps: 25
add_logo: True
logo: "docs/logo.png"
logo_opacity: 0.7
logo_filter: "overlay=W-w-12:12"
add_loudnorm: False
loud_I: -18
loud_TP: -1.5
loud_LRA: 11
```
ffmpeg pre-compression settings, all clips get prepared in that way,
so the input for the final compression is unique.
- `aspect` mus be a float number.
- with `logo_opacity` logo can make transparent
- 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
**INFO:** output is progressive!
---
```YAML
playlist:
playlist_mode: True
path: "/playlists"
day_start: "5:59:25"
length: "24:00:00"
```
Playlist settings -
set `playlist_mode` to **False** if you want to play clips from the `storage:` section
put only the root path here, for example: **"/playlists"**.
Subfolders is read by the script and needs this structur:
- **"/playlists/2018/01"** (/playlists/year/month)
`day_start` means at which time the playlist should start. Leave `day_start` blank when playlist should always start at the begin.
`length` represent the target length from playlist, when is blank real length will not consider.
---
```YAML
storage:
path: "/mediaStorage"
filler_path: "/mediaStorage/filler/filler-clips"
filler_clip: "/mediaStorage/filler/filler.mp4"
extensions:
- "*.mp4"
- "*.mkv"
shuffle: True
```
Play ordered or ramdomly files from path, `filler_path` are for the GUI only at the moment.
`filler_clip` is for fill the end to reach 24 hours, it will loop when is necessary.
`extensions:` search only files with this extension, add as many as you want.
Set `shuffle` to **True** to pick files randomly.
---
```YAML
text:
add_text: True
bind_address: "tcp://127.0.0.1:5555"
fontfile: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
```
Overlay text in combination with [messenger](https://github.com/ffplayout/messenger).
On windows `fontfile` path need to be like this: **C\:/WINDOWS/fonts/DejaVuSans.ttf**.
In a standard environment the filter drawtext node is: **Parsed_drawtext_2**.
---
```YAML
out:
preview: False
service_name: "Live Stream"
service_provider: "example.org"
post_ffmpeg_param:
c:v: "libx264"
crf: "23"
x264-params: "keyint=50:min-keyint=25:scenecut=-1"
maxrate: "1300k"
bufsize: "2600k"
preset: "medium"
profile:v: "Main"
level: "3.1"
c:a: "aac"
ar: "44100"
b:a: "128k"
flags: +global_header
f: "flv"
out_addr: "rtmp://localhost/live/stream"
```
The final ffmpeg post compression, Set the settings to your needs!
`preview` works only on a desktop system with ffplay!! Set it to **True**, if you need it.

42
docs/INSTALL.md Normal file
View File

@ -0,0 +1,42 @@
**ffplayout-engine Installation**
================
Here are a description on how to install *ffplayout engine* on a standard Linux server.
Requirements
-----
- python version 3.6+
- **ffmpeg v4.2+** and **ffprobe**
- systemd (if ffplayout should run as a daemon)
Installation
-----
- install ffmpeg, ffprobe (and ffplay if you need the preview mode)
- clone repo: `git clone https://github.com/ffplayout/ffplayout-engine.git`
- `cd ffplayout-engine`
- run `make` (virtualenv is required)
- run `sudo make install USER=www-data`, use any other user which need write access
- create playlists folder, in that format: **/playlists/year/month**
- set variables in config file to your needs
- use `docs/gen_playlist_from_subfolders.sh /path/to/mp4s/` as a starting point for your playlists (path in script needs to change)
- activate service and start it: `sudo systemctl enable ffplayout && sudo systemctl start ffplayout`
Cleanup
-----
- run `make clean` to remove the virtual environment
Deinstallation
-----
- run `sudo make uninstall` it will remove all created folders (also the **ffplayout.yml** configuration file!)
Manual Installation
-----
The routine with `make` build a virtual environment with all dependencies, and install ffplayout to **/opt/ffplayout-engine**. If you do not want to install to this path, or you want to install the dependencies globally, you can do everything by hand.
Just copy the project where you want to have it, run inside `pip3 install -r requirements.txt`. For logging you have to create the folder **ffplayout** under **/var/log/**, or adjust the settings in config. **ffplayout.yml** have to go to **/etc/ffplayout/**, or should stay in same folder.
If you want to use the systemd service, edit the service file in **docs/ffplayout.service**, copy it to **/etc/systemd/system/** and activate it with: `sudo systemctl enable ffplayout`.
Using it Without Installation
-----
Of course you can just run it too. Install only the dependencies from **requirements.txt** and run it with **python ffplayout.py [parameters]**.

View File

@ -3,12 +3,12 @@ Description=python and ffmpeg based playout
After=network.target After=network.target
[Service] [Service]
ExecStart=/usr/local/bin/ffplayout.py ExecStart=/opt/ffplayout-engine/venv/bin/python /opt/ffplayout-engine/ffplayout.py
ExecReload=/bin/kill -1 $MAINPID ExecReload=/bin/kill -1 $MAINPID
Restart=always Restart=always
RestartSec=1 RestartSec=1
User=user User=root
Group=user Group=root
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -1,132 +0,0 @@
; This file is part of ffplayout.
;
; ffplayout is free software: you can redistribute it and/or modify
; it under the terms of the GNU General Public License as published by
; the Free Software Foundation, either version 3 of the License, or
; (at your option) any later version.
;
; ffplayout is distributed in the hope that it will be useful,
; but WITHOUT ANY WARRANTY; without even the implied warranty of
; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
; GNU General Public License for more details.
;
; You should have received a copy of the GNU General Public License
; along with ffplayout. If not, see <http://www.gnu.org/licenses/>.
; ------------------------------------------------------------------------------
; sometimes it can happen, that a file is corrupt but still playable,
; this can produce an streaming error over all following files
; the only way in this case is, to stop ffplayout and start it again
; here we only say it can stop, the starting process is in your hand
; best way is a systemd serivce on linux
; stop_threshold: stop ffplayout, if it is async in time above this value
[GENERAL]
stop_on_error = True
stop_threshold = 11
; send error messages to email address, like:
; missing playlist
; unvalid json format
; missing clip path
; leave recipient blank, if you don't need this
; mail_level can be: WARNING, ERROR
[MAIL]
subject = "Playout Error"
smpt_server = mail.example.org
smpt_port = 587
sender_addr = ffplayout@example.org
sender_pass = 12345
recipient =
mail_level = ERROR
; Logging to file
; if log_to_file = False > log to console
; path to /var/log/ only if you run this program as deamon
; log_level can be: DEBUG, INFO, WARNING, ERROR
; ffmpeg_level can be: INFO, WARNING, ERROR
[LOGGING]
log_to_file = True
log_path = /var/log/ffplayout/
log_level = INFO
ffmpeg_level = ERROR
; output settings for the pre-compression
; all clips get prepared in that way,
; so the input for the final compression is unique
; aspect mus be a float number
; logo is only used if the path exist
; with logo_opacity logo can make transparent
; 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
; INFO: output is progressive!
[PRE_COMPRESS]
width = 1024
height = 576
aspect = 1.778
fps = 25
add_logo = True
logo = logo.png
logo_opacity = 0.7
logo_filter = overlay=W-w-12:12
add_loudnorm = False
loud_I = -18
loud_TP = -1.5
loud_LRA = 11
; playlist settings
; set playlist_mode to False if you want to play clips from the [STORAGE] section
; put only the root path here, for example: "/playlists"
; subfolders are readed by the script
; subfolders needs this structur:
; "/playlists/2018/01" (/playlists/year/month)
; day_start means at which time the playlist should start
; leave day_start blank when playlist should always start at the begin
; length represent the target length from playlist, when is blank real length will not consider
[PLAYLIST]
playlist_mode = True
path = /playlists
day_start = 05:59:25
length = 24:00:00
; play ordered or ramdomly files from path
; extensions: search only files with this extension, can be a list
; set shuffle to True to pick files randomly
; filler_path are for the GUI only at the moment
; filler_clip is for fill the end to reach 24 hours, it will loop when is necessary
; best for this is a ~4 hours clip with black color and soft noise sound
[STORAGE]
path = /media
filler_path = /media/filler/filler-clips
filler_clip = /media/filler/filler.mp4
extensions = ["*.mp4"]
shuffle = False
; overlay text in combination with messenger: https://github.com/ffplayout/messenger
; on windows fontfile path need to be like this: C\:/WINDOWS/fonts/DejaVuSans.ttf
; in a standard environment the filter drawtext node is: Parsed_drawtext_2
[TEXT]
add_text = True
bind_address = tcp://127.0.0.1:5555
fontfile = /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf
; the final playout post compression
; set the settings to your needs
; preview works only on a desktop system with ffplay!! Set it to True, if you need it
[OUT]
preview = False
service_name = Live Stream
service_provider = example.org
post_comp_video = ["-c:v", "libx264", "-crf", "23", "-x264-params", "keyint=50:min-keyint=25:scenecut=-1", "-maxrate", "1300k", "-bufsize", "2600k", "-preset", "medium", "-profile:v", "Main", "-level", "3.1"]
post_comp_audio = ["-c:a", "aac", "-ar", "44100", "-b:a", "128k"]
post_comp_extra = ["-flags", "+global_header", "-f", "flv"]
out_addr = rtmp://127.0.0.1/live/stream

File diff suppressed because it is too large Load Diff

144
ffplayout.yml Normal file
View File

@ -0,0 +1,144 @@
# This file is part of ffplayout.
#
# ffplayout is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# ffplayout is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with ffplayout. If not, see <http://www.gnu.org/licenses/>.
# ------------------------------------------------------------------------------
# sometimes it can happen, that a file is corrupt but still playable,
# this can produce an streaming error over all following files
# the only way in this case is, to stop ffplayout and start it again
# here we only say it can stop, the starting process is in your hand
# best way is a systemd serivce on linux
# stop_threshold: stop ffplayout, if it is async in time above this value
general:
stop_on_error: True
stop_threshold: 11
# send error messages to email address, like:
# missing playlist
# unvalid json format
# missing clip path
# leave recipient blank, if you don't need this
# mail_level can be: WARNING, ERROR
mail:
subject: "Playout Error"
smpt_server: "mail.example.org"
smpt_port: 587
sender_addr: "ffplayout@example.org"
sender_pass: "12345"
recipient:
mail_level: "ERROR"
# Logging to file
# if log_to_file: False > log to console
# path to /var/log/ only if you run this program as deamon
# log_level can be: DEBUG, INFO, WARNING, ERROR
# ffmpeg_level can be: INFO, WARNING, ERROR
logging:
log_to_file: True
log_path: "/var/log/ffplayout/"
log_level: "DEBUG"
ffmpeg_level: "ERROR"
# output settings for the pre-compression
# all clips get prepared in that way,
# so the input for the final compression is unique
# aspect mus be a float number
# logo is only used if the path exist
# with logo_opacity logo can make transparent
# 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
# INFO: output is progressive!
pre_compress:
width: 1024
height: 576
aspect: 1.778
fps: 25
add_logo: True
logo: "docs/logo.png"
logo_opacity: 0.7
logo_filter: "overlay=W-w-12:12"
add_loudnorm: False
loud_I: -18
loud_TP: -1.5
loud_LRA: 11
# playlist settings
# set playlist_mode to False if you want to play clips from the [STORAGE] section
# put only the root path here, for example: "/playlists"
# subfolders are readed by the script
# subfolders needs this structur:
# "/playlists/2018/01" (/playlists/year/month)
# day_start means at which time the playlist should start
# leave day_start blank when playlist should always start at the begin
# length represent the target length from playlist, when is blank real length will not consider
playlist:
playlist_mode: True
path: "/playlists"
day_start: "5:59:25"
length: "24:00:00"
# play ordered or ramdomly files from path
# filler_path are for the GUI only at the moment
# filler_clip is for fill the end to reach 24 hours, it will loop when is necessary
# extensions: search only files with this extension, can be a list
# set shuffle to True to pick files randomly
storage:
path: "/mediaStorage"
filler_path: "/mediaStorage/filler/filler-clips"
filler_clip: "/mediaStorage/filler/filler.mp4"
extensions:
- "*.mp4"
- "*.mkv"
shuffle: True
# overlay text in combination with messenger: https://github.com/ffplayout/messenger
# on windows fontfile path need to be like this: C\:/WINDOWS/fonts/DejaVuSans.ttf
# in a standard environment the filter drawtext node is: Parsed_drawtext_2
text:
add_text: True
bind_address: "tcp://127.0.0.1:5555"
fontfile: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
# the final playout post compression
# set the settings to your needs
# preview works only on a desktop system with ffplay!! Set it to True, if you need it
out:
preview: False
service_name: "Live Stream"
service_provider: "example.org"
post_ffmpeg_param:
c:v: "libx264"
crf: "23"
x264-params: "keyint=50:min-keyint=25:scenecut=-1"
maxrate: "1300k"
bufsize: "2600k"
preset: "medium"
profile:v: "Main"
level: "3.1"
c:a: "aac"
ar: "44100"
b:a: "128k"
flags: +global_header
f: "flv"
out_addr: "rtmp://localhost/live/stream"

0
ffplayout/__init__.py Normal file
View File

243
ffplayout/filters.py Normal file
View File

@ -0,0 +1,243 @@
# -*- coding: utf-8 -*-
# This file is part of ffplayout.
#
# ffplayout is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# ffplayout is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with ffplayout. If not, see <http://www.gnu.org/licenses/>.
# ------------------------------------------------------------------------------
import math
import os
from .utils import _pre_comp
# ------------------------------------------------------------------------------
# building filters,
# when is needed add individuell filters to match output format
# ------------------------------------------------------------------------------
def deinterlace_filter(probe):
"""
when material is interlaced,
set deinterlacing filter
"""
filter_chain = []
if 'field_order' in probe.video[0] and \
probe.video[0]['field_order'] != 'progressive':
filter_chain.append('yadif=0:-1:0')
return filter_chain
def pad_filter(probe):
"""
if source and target aspect is different,
fix it with pillarbox or letterbox
"""
filter_chain = []
if not math.isclose(probe.video[0]['aspect'],
_pre_comp.aspect, abs_tol=0.03):
if probe.video[0]['aspect'] < _pre_comp.aspect:
filter_chain.append(
'pad=ih*{}/{}/sar:ih:(ow-iw)/2:(oh-ih)/2'.format(_pre_comp.w,
_pre_comp.h))
elif probe.video[0]['aspect'] > _pre_comp.aspect:
filter_chain.append(
'pad=iw:iw*{}/{}/sar:(ow-iw)/2:(oh-ih)/2'.format(_pre_comp.h,
_pre_comp.w))
return filter_chain
def fps_filter(probe):
"""
changing frame rate
"""
filter_chain = []
if probe.video[0]['fps'] != _pre_comp.fps:
filter_chain.append('fps={}'.format(_pre_comp.fps))
return filter_chain
def scale_filter(probe):
"""
if target resolution is different to source add scale filter,
apply also an aspect filter, when is different
"""
filter_chain = []
if int(probe.video[0]['width']) != _pre_comp.w or \
int(probe.video[0]['height']) != _pre_comp.h:
filter_chain.append('scale={}:{}'.format(_pre_comp.w, _pre_comp.h))
if not math.isclose(probe.video[0]['aspect'],
_pre_comp.aspect, abs_tol=0.03):
filter_chain.append('setdar=dar={}'.format(_pre_comp.aspect))
return filter_chain
def fade_filter(duration, seek, out, track=''):
"""
fade in/out video, when is cutted at the begin or end
"""
filter_chain = []
if seek > 0.0:
filter_chain.append('{}fade=in:st=0:d=0.5'.format(track))
if out != duration:
filter_chain.append('{}fade=out:st={}:d=1.0'.format(track,
out - seek - 1.0))
return filter_chain
def overlay_filter(duration, ad, ad_last, ad_next):
"""
overlay logo: when is an ad don't overlay,
when ad is comming next fade logo out,
when clip before was an ad fade logo in
"""
logo_filter = '[v]null[logo]'
if _pre_comp.add_logo and os.path.isfile(_pre_comp.logo) and not ad:
logo_chain = []
opacity = 'format=rgba,colorchannelmixer=aa={}'.format(
_pre_comp.opacity)
loop = 'loop=loop=-1:size=1:start=0'
logo_chain.append(
'movie={},{},{}'.format(_pre_comp.logo, loop, opacity))
if ad_last:
logo_chain.append('fade=in:st=0:d=1.0:alpha=1')
if ad_next:
logo_chain.append('fade=out:st={}:d=1.0:alpha=1'.format(
duration - 1))
logo_filter = '{}[l];[v][l]{}:shortest=1[logo]'.format(
','.join(logo_chain), _pre_comp.logo_filter)
return logo_filter
def add_audio(probe, duration, msg):
"""
when clip has no audio we generate an audio line
"""
line = []
if not probe.audio:
msg.warning('Clip "{}" has no audio!'.format(probe.src))
line = [
'aevalsrc=0:channel_layout=2:duration={}:sample_rate={}'.format(
duration, 48000)]
return line
def add_loudnorm(probe):
"""
add single pass loudnorm filter to audio line
"""
loud_filter = []
if probe.audio and _pre_comp.add_loudnorm:
loud_filter = [('loudnorm=I={}:TP={}:LRA={}').format(
_pre_comp.loud_i, _pre_comp.loud_tp, _pre_comp.loud_lra)]
return loud_filter
def extend_audio(probe, duration):
"""
check audio duration, is it shorter then clip duration - pad it
"""
pad_filter = []
if probe.audio and 'duration' in probe.audio[0] and \
duration > float(probe.audio[0]['duration']) + 0.3:
pad_filter.append('apad=whole_dur={}'.format(duration))
return pad_filter
def extend_video(probe, duration, target_duration):
"""
check video duration, is it shorter then clip duration - pad it
"""
pad_filter = []
if 'duration' in probe.video[0] and \
target_duration < duration > float(
probe.video[0]['duration']) + 0.3:
pad_filter.append('tpad=stop_mode=add:stop_duration={}'.format(
duration - float(probe.video[0]['duration'])))
return pad_filter
def build_filtergraph(duration, seek, out, ad, ad_last, ad_next, probe, msg):
"""
build final filter graph, with video and audio chain
"""
video_chain = []
audio_chain = []
video_map = ['-map', '[logo]']
if out > duration:
seek = 0
if probe.video[0]:
video_chain += deinterlace_filter(probe)
video_chain += pad_filter(probe)
video_chain += fps_filter(probe)
video_chain += scale_filter(probe)
video_chain += extend_video(probe, duration, out - seek)
video_chain += fade_filter(duration, seek, out)
audio_chain += add_audio(probe, out - seek, msg)
if not audio_chain:
audio_chain.append('[0:a]anull')
audio_chain += add_loudnorm(probe)
audio_chain += extend_audio(probe, out - seek)
audio_chain += fade_filter(duration, seek, out, 'a')
if video_chain:
video_filter = '{}[v]'.format(','.join(video_chain))
else:
video_filter = 'null[v]'
logo_filter = overlay_filter(out - seek, ad, ad_last, ad_next)
video_filter = [
'-filter_complex', '[0:v]{};{}'.format(
video_filter, logo_filter)]
if audio_chain:
audio_filter = [
'-filter_complex', '{}[a]'.format(','.join(audio_chain))]
audio_map = ['-map', '[a]']
else:
audio_filter = []
audio_map = ['-map', '0:a']
if probe.video[0]:
return video_filter + audio_filter + video_map + audio_map
else:
return video_filter + video_map + ['-map', '1:a']

155
ffplayout/folder.py Normal file
View File

@ -0,0 +1,155 @@
# -*- coding: utf-8 -*-
# This file is part of ffplayout.
#
# ffplayout is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# ffplayout is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with ffplayout. If not, see <http://www.gnu.org/licenses/>.
# ------------------------------------------------------------------------------
import glob
import os
import random
import time
from watchdog.events import PatternMatchingEventHandler
from watchdog.observers import Observer
from .filters import build_filtergraph
from .utils import MediaProbe, _storage, messenger, stdin_args
# ------------------------------------------------------------------------------
# folder watcher
# ------------------------------------------------------------------------------
class MediaStore:
"""
fill media list for playing
MediaWatch will interact with add and remove
"""
def __init__(self):
self.store = []
if stdin_args.folder:
self.folder = stdin_args.folder
else:
self.folder = _storage.path
self.fill()
def fill(self):
for ext in _storage.extensions:
self.store.extend(
glob.glob(os.path.join(self.folder, '**', ext),
recursive=True))
if _storage.shuffle:
self.rand()
else:
self.sort()
def add(self, file):
self.store.append(file)
self.sort()
def remove(self, file):
self.store.remove(file)
self.sort()
def sort(self):
# sort list for sorted playing
self.store = sorted(self.store)
def rand(self):
# random sort list for playing
random.shuffle(self.store)
class MediaWatcher:
"""
watch given folder for file changes and update media list
"""
def __init__(self, media):
self._media = media
self.event_handler = PatternMatchingEventHandler(
patterns=_storage.extensions)
self.event_handler.on_created = self.on_created
self.event_handler.on_moved = self.on_moved
self.event_handler.on_deleted = self.on_deleted
self.observer = Observer()
self.observer.schedule(self.event_handler, self._media.folder,
recursive=True)
self.observer.start()
def on_created(self, event):
# add file to media list only if it is completely copied
file_size = -1
while file_size != os.path.getsize(event.src_path):
file_size = os.path.getsize(event.src_path)
time.sleep(1)
self._media.add(event.src_path)
messenger.info('Add file to media list: "{}"'.format(event.src_path))
def on_moved(self, event):
self._media.remove(event.src_path)
self._media.add(event.dest_path)
messenger.info('Move file from "{}" to "{}"'.format(event.src_path,
event.dest_path))
def on_deleted(self, event):
self._media.remove(event.src_path)
messenger.info(
'Remove file from media list: "{}"'.format(event.src_path))
def stop(self):
self.observer.stop()
self.observer.join()
class GetSourceFromFolder:
"""
give next clip, depending on shuffle mode
"""
def __init__(self, media):
self._media = media
self.last_played = []
self.index = 0
self.probe = MediaProbe()
def next(self):
while True:
while self.index < len(self._media.store):
self.probe.load(self._media.store[self.index])
filtergraph = build_filtergraph(
float(self.probe.format['duration']), 0.0,
float(self.probe.format['duration']), False, False,
False, self.probe, messenger)
yield [
'-i', self._media.store[self.index]
] + filtergraph
self.index += 1
else:
self.index = 0

266
ffplayout/playlist.py Normal file
View File

@ -0,0 +1,266 @@
# -*- coding: utf-8 -*-
# This file is part of ffplayout.
#
# ffplayout is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# ffplayout is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with ffplayout. If not, see <http://www.gnu.org/licenses/>.
# ------------------------------------------------------------------------------
import os
import socket
import ssl
import time
from urllib import request
from ffplayout.filters import build_filtergraph
from ffplayout.utils import (MediaProbe, _playlist, gen_filler, get_date,
get_delta, get_time, is_float, messenger,
stdin_args, timed_source, valid_json,
validate_thread)
class GetSourceFromPlaylist:
"""
read values from json playlist,
get current clip in time,
set ffmpeg source command
"""
def __init__(self):
self.init_time = _playlist.start
self.last_time = get_time('full_sec')
if _playlist.length:
self.total_playtime = _playlist.length
else:
self.total_playtime = 86400.0
if self.last_time < _playlist.start:
self.last_time += self.total_playtime
self.last_mod_time = 0.0
self.json_file = None
self.clip_nodes = None
self.src_cmd = None
self.probe = MediaProbe()
self.filtergraph = []
self.first = True
self.last = False
self.list_date = get_date(True)
self.src = None
self.begin = 0
self.seek = 0
self.out = 20
self.duration = 20
self.ad = False
self.ad_last = False
self.ad_next = False
def get_playlist(self):
if stdin_args.playlist:
self.json_file = stdin_args.playlist
else:
year, month, day = self.list_date.split('-')
self.json_file = os.path.join(
_playlist.path, year, month, self.list_date + '.json')
if '://' in self.json_file:
self.json_file = self.json_file.replace('\\', '/')
try:
req = request.urlopen(self.json_file,
timeout=1,
context=ssl._create_unverified_context())
b_time = req.headers['last-modified']
temp_time = time.strptime(b_time, "%a, %d %b %Y %H:%M:%S %Z")
mod_time = time.mktime(temp_time)
if mod_time > self.last_mod_time:
self.clip_nodes = valid_json(req)
self.last_mod_time = mod_time
messenger.info('Open: ' + self.json_file)
validate_thread(self.clip_nodes)
except (request.URLError, socket.timeout):
self.eof_handling('Get playlist from url failed!', False)
elif os.path.isfile(self.json_file):
# check last modification from playlist
mod_time = os.path.getmtime(self.json_file)
if mod_time > self.last_mod_time:
with open(self.json_file, 'r', encoding='utf-8') as f:
self.clip_nodes = valid_json(f)
self.last_mod_time = mod_time
messenger.info('Open: ' + self.json_file)
validate_thread(self.clip_nodes)
else:
# when we have no playlist for the current day,
# then we generate a black clip
# and calculate the seek in time, for when the playlist comes back
self.eof_handling('Playlist not exist:', False)
def get_clip_in_out(self, node):
if is_float(node["in"]):
self.seek = node["in"]
else:
self.seek = 0
if is_float(node["duration"]):
self.duration = node["duration"]
else:
self.duration = 20
if is_float(node["out"]):
self.out = node["out"]
else:
self.out = self.duration
def get_input(self):
self.src_cmd, self.seek, self.out, self.next_playlist = timed_source(
self.probe, self.src, self.begin, self.duration,
self.seek, self.out, self.first, self.last
)
def get_category(self, index, node):
if 'category' in node:
if index - 1 >= 0:
last_category = self.clip_nodes[
"program"][index - 1]["category"]
else:
last_category = 'noad'
if index + 2 <= len(self.clip_nodes["program"]):
next_category = self.clip_nodes[
"program"][index + 1]["category"]
else:
next_category = 'noad'
if node["category"] == 'advertisement':
self.ad = True
else:
self.ad = False
if last_category == 'advertisement':
self.ad_last = True
else:
self.ad_last = False
if next_category == 'advertisement':
self.ad_next = True
else:
self.ad_next = False
def set_filtergraph(self):
self.filtergraph = build_filtergraph(
self.duration, self.seek, self.out, self.ad, self.ad_last,
self.ad_next, self.probe, messenger)
def check_for_next_playlist(self):
if not self.next_playlist:
# normal behavior, when no new playlist is needed
self.last_time = self.begin
elif self.next_playlist and _playlist.length != 86400.0:
# get sure that no new clip will be loaded
self.last_time = 86400.0 * 2
else:
# when there is no time left and we are in time,
# set right values for new playlist
self.list_date = get_date(False)
self.last_mod_time = 0.0
self.last_time = _playlist.start - 1
def eof_handling(self, message, fill):
self.seek = 0.0
self.ad = False
current_delta, total_delta = get_delta(self.begin)
self.out = abs(total_delta)
self.duration = abs(total_delta) + 1
self.list_date = get_date(False)
self.last_mod_time = 0.0
self.first = False
self.last_time = 0.0
if self.duration > 2 and fill:
self.probe, self.src_cmd = gen_filler(self.duration)
self.set_filtergraph()
else:
self.src_cmd = None
self.next_playlist = True
self.last = False
def peperation_task(self, index, node):
# call functions in order to prepare source and filter
self.src = node["source"]
self.probe.load(self.src)
self.get_input()
self.get_category(index, node)
self.set_filtergraph()
self.check_for_next_playlist()
def next(self):
while True:
self.get_playlist()
if self.clip_nodes is None:
self.eof_handling('Playlist is empty!', True)
yield self.src_cmd + self.filtergraph
continue
self.begin = self.init_time
# loop through all clips in playlist and get correct clip in time
for index, node in enumerate(self.clip_nodes["program"]):
self.get_clip_in_out(node)
# first time we end up here
if self.first and \
self.last_time < self.begin + self.out - self.seek:
self.peperation_task(index, node)
self.first = False
break
elif self.last_time < self.begin:
if index + 1 == len(self.clip_nodes["program"]):
self.last = True
else:
self.last = False
self.peperation_task(index, node)
break
self.begin += self.out - self.seek
else:
if stdin_args.loop:
self.check_for_next_playlist()
self.init_time = self.last_time + 1
self.src_cmd = None
elif not _playlist.length and not stdin_args.loop:
# when we reach playlist end, stop script
messenger.info('Playlist reached end!')
return None
elif self.begin == self.init_time:
# no clip was played, generate dummy
self.eof_handling('Playlist is empty!', False)
else:
# playlist is not long enough, play filler
self.eof_handling('Playlist is not long enough!', True)
if self.src_cmd is not None:
yield self.src_cmd + self.filtergraph

987
ffplayout/utils.py Normal file
View File

@ -0,0 +1,987 @@
# -*- coding: utf-8 -*-
# This file is part of ffplayout.
#
# ffplayout is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# ffplayout is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with ffplayout. If not, see <http://www.gnu.org/licenses/>.
# ------------------------------------------------------------------------------
import json
import logging
import math
import os
import re
import signal
import smtplib
import socket
import sys
import tempfile
import yaml
from argparse import ArgumentParser
from datetime import date, datetime, timedelta
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate
from logging.handlers import TimedRotatingFileHandler
from subprocess import CalledProcessError, check_output
from threading import Thread
from types import SimpleNamespace
# ------------------------------------------------------------------------------
# argument parsing
# ------------------------------------------------------------------------------
stdin_parser = ArgumentParser(
description='python and ffmpeg based playout',
epilog="don't use parameters if you want to use this settings from config")
stdin_parser.add_argument(
'-c', '--config', help='file path to ffplayout.conf'
)
stdin_parser.add_argument(
'-d', '--desktop', help='preview on desktop', action='store_true'
)
stdin_parser.add_argument(
'-f', '--folder', help='play folder content'
)
stdin_parser.add_argument(
'-l', '--log', help='file path for logfile'
)
stdin_parser.add_argument(
'-i', '--loop', help='loop playlist infinitely', action='store_true'
)
stdin_parser.add_argument(
'-p', '--playlist', help='path from playlist'
)
stdin_parser.add_argument(
'-s', '--start',
help='start time in "hh:mm:ss", "now" for start with first'
)
stdin_parser.add_argument(
'-t', '--length',
help='set length in "hh:mm:ss", "none" for no length check'
)
stdin_args = stdin_parser.parse_args()
# ------------------------------------------------------------------------------
# clock
# ------------------------------------------------------------------------------
def get_time(time_format):
"""
get different time formats:
- full_sec > current time in seconds
- stamp > current date time in seconds
- else > current time in HH:MM:SS
"""
t = datetime.today()
if time_format == 'full_sec':
return t.hour * 3600 + t.minute * 60 + t.second \
+ t.microsecond / 1000000
elif time_format == 'stamp':
return float(datetime.now().timestamp())
else:
return t.strftime('%H:%M:%S')
# ------------------------------------------------------------------------------
# default variables and values
# ------------------------------------------------------------------------------
_general = SimpleNamespace()
_mail = SimpleNamespace()
_log = SimpleNamespace()
_pre_comp = SimpleNamespace()
_playlist = SimpleNamespace()
_storage = SimpleNamespace()
_text = SimpleNamespace()
_playout = SimpleNamespace()
_init = SimpleNamespace(load=True)
_ff = SimpleNamespace(decoder=None, encoder=None)
def str_to_sec(s):
if s in ['now', '', None, 'none']:
return None
else:
s = s.split(':')
try:
return float(s[0]) * 3600 + float(s[1]) * 60 + float(s[2])
except ValueError:
print('Wrong time format!')
sys.exit(1)
def read_config(path):
with open(path, 'r') as config_file:
return yaml.safe_load(config_file)
def dict_to_list(d):
li = []
for key, value in d.items():
if value:
li += ['-{}'.format(key), str(value)]
else:
li += ['-{}'.format(key)]
return li
def load_config():
"""
this function can reload most settings from configuration file,
the change does not take effect immediately, but with the after next file,
some settings cannot be changed - like resolution, aspect, or output
"""
if stdin_args.config:
cfg = read_config(stdin_args.config)
elif os.path.isfile('/etc/ffplayout/ffplayout.yml'):
cfg = read_config('/etc/ffplayout/ffplayout.yml')
else:
cfg = read_config('ffplayout.yml')
if stdin_args.start:
p_start = str_to_sec(stdin_args.start)
else:
p_start = str_to_sec(cfg['playlist']['day_start'])
if not p_start:
p_start = get_time('full_sec')
if stdin_args.length:
p_length = str_to_sec(stdin_args.length)
else:
p_length = str_to_sec(cfg['playlist']['length'])
_general.stop = cfg['general']['stop_on_error']
_general.threshold = cfg['general']['stop_threshold']
_mail.subject = cfg['mail']['subject']
_mail.server = cfg['mail']['smpt_server']
_mail.port = cfg['mail']['smpt_port']
_mail.s_addr = cfg['mail']['sender_addr']
_mail.s_pass = cfg['mail']['sender_pass']
_mail.recip = cfg['mail']['recipient']
_mail.level = cfg['mail']['mail_level']
_pre_comp.add_logo = cfg['pre_compress']['add_logo']
_pre_comp.logo = cfg['pre_compress']['logo']
_pre_comp.opacity = cfg['pre_compress']['logo_opacity']
_pre_comp.logo_filter = cfg['pre_compress']['logo_filter']
_pre_comp.add_loudnorm = cfg['pre_compress']['add_loudnorm']
_pre_comp.loud_i = cfg['pre_compress']['loud_I']
_pre_comp.loud_tp = cfg['pre_compress']['loud_TP']
_pre_comp.loud_lra = cfg['pre_compress']['loud_LRA']
_playlist.mode = cfg['playlist']['playlist_mode']
_playlist.path = cfg['playlist']['path']
_playlist.start = p_start
_playlist.length = p_length
_storage.path = cfg['storage']['path']
_storage.filler = cfg['storage']['filler_clip']
_storage.extensions = cfg['storage']['extensions']
_storage.shuffle = cfg['storage']['shuffle']
_text.add_text = cfg['text']['add_text']
_text.address = cfg['text']['bind_address']
_text.fontfile = cfg['text']['fontfile']
if _init.load:
_log.to_file = cfg['logging']['log_to_file']
_log.path = cfg['logging']['log_path']
_log.level = cfg['logging']['log_level']
_log.ff_level = cfg['logging']['ffmpeg_level']
_pre_comp.w = cfg['pre_compress']['width']
_pre_comp.h = cfg['pre_compress']['height']
_pre_comp.aspect = cfg['pre_compress']['aspect']
_pre_comp.fps = cfg['pre_compress']['fps']
_pre_comp.v_bitrate = cfg['pre_compress']['width'] * 50
_pre_comp.v_bufsize = cfg['pre_compress']['width'] * 50 / 2
_playout.preview = cfg['out']['preview']
_playout.name = cfg['out']['service_name']
_playout.provider = cfg['out']['service_provider']
_playout.post_comp_param = dict_to_list(
cfg['out']['post_ffmpeg_param'])
_playout.out_addr = cfg['out']['out_addr']
_init.load = False
load_config()
# ------------------------------------------------------------------------------
# logging
# ------------------------------------------------------------------------------
class CustomFormatter(logging.Formatter):
"""
Logging Formatter to add colors and count warning / errors
"""
grey = '\x1b[38;1m'
darkgrey = '\x1b[30;1m'
yellow = '\x1b[33;1m'
red = '\x1b[31;1m'
magenta = '\x1b[35;1m'
green = '\x1b[32;1m'
blue = '\x1b[34;1m'
cyan = '\x1b[36;1m'
reset = '\x1b[0m'
timestamp = darkgrey + '[%(asctime)s]' + reset
level = '[%(levelname)s]' + reset
message = grey + ' %(message)s' + reset
FORMATS = {
logging.DEBUG: timestamp + blue + level + ' ' + message + reset,
logging.INFO: timestamp + green + level + ' ' + message + reset,
logging.WARNING: timestamp + yellow + level + message + reset,
logging.ERROR: timestamp + red + level + ' ' + message + reset
}
def format_message(self, msg):
if '"' in msg and '[' in msg:
msg = re.sub('(".*?")', self.cyan + r'\1' + self.reset, msg)
elif '[decoder]' in msg:
msg = re.sub(r'(\[decoder\])', self.reset + r'\1', msg)
elif '[encoder]' in msg:
msg = re.sub(r'(\[encoder\])', self.reset + r'\1', msg)
elif '/' in msg or '\\' in msg:
msg = re.sub(
r'(["\w.:/]+/|["\w.:]+\\.*?)', self.magenta + r'\1', msg)
elif re.search(r'\d', msg):
msg = re.sub(
'([0-9.:-]+)', self.yellow + r'\1' + self.reset, msg)
return msg
def format(self, record):
record.msg = self.format_message(record.getMessage())
log_fmt = self.FORMATS.get(record.levelno)
formatter = logging.Formatter(log_fmt)
return formatter.format(record)
# If the log file is specified on the command line then override the default
if stdin_args.log:
_log.path = stdin_args.log
playout_logger = logging.getLogger('playout')
playout_logger.setLevel(_log.level)
decoder_logger = logging.getLogger('decoder')
decoder_logger.setLevel(_log.ff_level)
encoder_logger = logging.getLogger('encoder')
encoder_logger.setLevel(_log.ff_level)
if _log.to_file and _log.path != 'none':
if _log.path and os.path.isdir(_log.path):
playout_log = os.path.join(_log.path, 'ffplayout.log')
decoder_log = os.path.join(_log.path, 'decoder.log')
encoder_log = os.path.join(_log.path, 'encoder.log')
else:
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
log_dir = os.path.join(base_dir, 'log')
os.makedirs(log_dir, exist_ok=True)
playout_log = os.path.join(log_dir, 'ffplayout.log')
decoder_log = os.path.join(log_dir, 'ffdecoder.log')
encoder_log = os.path.join(log_dir, 'ffencoder.log')
p_format = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s')
f_format = logging.Formatter('[%(asctime)s] %(message)s')
p_file_handler = TimedRotatingFileHandler(playout_log, when='midnight',
backupCount=5)
d_file_handler = TimedRotatingFileHandler(decoder_log, when='midnight',
backupCount=5)
e_file_handler = TimedRotatingFileHandler(encoder_log, when='midnight',
backupCount=5)
p_file_handler.setFormatter(p_format)
d_file_handler.setFormatter(f_format)
e_file_handler.setFormatter(f_format)
playout_logger.addHandler(p_file_handler)
decoder_logger.addHandler(d_file_handler)
encoder_logger.addHandler(e_file_handler)
DEC_PREFIX = ''
ENC_PREFIX = ''
else:
console_handler = logging.StreamHandler()
console_handler.setFormatter(CustomFormatter())
playout_logger.addHandler(console_handler)
decoder_logger.addHandler(console_handler)
encoder_logger.addHandler(console_handler)
DEC_PREFIX = '[decoder] '
ENC_PREFIX = '[encoder] '
# ------------------------------------------------------------------------------
# mail sender
# ------------------------------------------------------------------------------
class Mailer:
"""
mailer class for sending log messages, with level selector
"""
def __init__(self):
self.level = _mail.level
self.time = None
self.timestamp = get_time('stamp')
self.rate_limit = 600
self.temp_msg = os.path.join(tempfile.gettempdir(), 'ffplayout.txt')
def current_time(self):
self.time = get_time(None)
def send_mail(self, msg):
if _mail.recip:
# write message to temp file for rate limit
with open(self.temp_msg, 'w+') as f:
f.write(msg)
self.current_time()
message = MIMEMultipart()
message['From'] = _mail.s_addr
message['To'] = _mail.recip
message['Subject'] = _mail.subject
message['Date'] = formatdate(localtime=True)
message.attach(MIMEText('{} {}'.format(self.time, msg), 'plain'))
text = message.as_string()
try:
server = smtplib.SMTP(_mail.server, _mail.port)
except socket.error as err:
playout_logger.error(err)
server = None
if server is not None:
server.starttls()
try:
login = server.login(_mail.s_addr, _mail.s_pass)
except smtplib.SMTPAuthenticationError as serr:
playout_logger.error(serr)
login = None
if login is not None:
server.sendmail(_mail.s_addr, _mail.recip, text)
server.quit()
def check_if_new(self, msg):
# send messege only when is new or the rate_limit is pass
if os.path.isfile(self.temp_msg):
mod_time = os.path.getmtime(self.temp_msg)
with open(self.temp_msg, 'r', encoding='utf-8') as f:
last_msg = f.read()
if msg != last_msg \
or get_time('stamp') - mod_time > self.rate_limit:
self.send_mail(msg)
else:
self.send_mail(msg)
def info(self, msg):
if self.level in ['INFO']:
self.check_if_new(msg)
def warning(self, msg):
if self.level in ['INFO', 'WARNING']:
self.check_if_new(msg)
def error(self, msg):
if self.level in ['INFO', 'WARNING', 'ERROR']:
self.check_if_new(msg)
class Messenger:
"""
all logging and mail messages end up here,
from here they go to logger and mailer
"""
def __init__(self):
self._mailer = Mailer()
def debug(self, msg):
playout_logger.debug(msg.replace('\n', ' '))
def info(self, msg):
playout_logger.info(msg.replace('\n', ' '))
self._mailer.info(msg)
def warning(self, msg):
playout_logger.warning(msg.replace('\n', ' '))
self._mailer.warning(msg)
def error(self, msg):
playout_logger.error(msg.replace('\n', ' '))
self._mailer.error(msg)
messenger = Messenger()
# ------------------------------------------------------------------------------
# check ffmpeg libs
# ------------------------------------------------------------------------------
def ffmpeg_libs():
"""
check which external libs are compiled in ffmpeg,
for using them later
"""
cmd = ['ffmpeg', '-version']
libs = []
try:
info = check_output(cmd).decode('UTF-8')
except CalledProcessError as err:
messenger.error('ffmpeg - libs could not be readed!\n'
'Processing is not possible. Error:\n{}'.format(err))
sys.exit(1)
for line in info.split('\n'):
if 'configuration:' in line:
configs = line.split()
for cfg in configs:
if '--enable-lib' in cfg:
libs.append(cfg.replace('--enable-', ''))
break
return libs
FF_LIBS = ffmpeg_libs()
# ------------------------------------------------------------------------------
# probe media infos
# ------------------------------------------------------------------------------
class MediaProbe:
"""
get infos about media file, similare to mediainfo
"""
def load(self, file):
self.remote_source = ['http', 'https', 'ftp', 'smb', 'sftp']
self.src = file
self.format = None
self.audio = []
self.video = []
if self.src and self.src.split('://')[0] in self.remote_source:
self.is_remote = True
else:
self.is_remote = False
if not self.src or not os.path.isfile(self.src):
self.audio.append(None)
self.video.append(None)
return
cmd = ['ffprobe', '-v', 'quiet', '-print_format',
'json', '-show_format', '-show_streams', self.src]
try:
info = json.loads(check_output(cmd).decode('UTF-8'))
except CalledProcessError as err:
messenger.error('MediaProbe error in: "{}"\n {}'.format(self.src,
err))
self.audio.append(None)
self.video.append(None)
return
self.format = info['format']
for stream in info['streams']:
if stream['codec_type'] == 'audio':
self.audio.append(stream)
if stream['codec_type'] == 'video':
if 'display_aspect_ratio' not in stream:
stream['aspect'] = float(
stream['width']) / float(stream['height'])
else:
w, h = stream['display_aspect_ratio'].split(':')
stream['aspect'] = float(w) / float(h)
a, b = stream['r_frame_rate'].split('/')
stream['fps'] = float(a) / float(b)
self.video.append(stream)
# ------------------------------------------------------------------------------
# global helper functions
# ------------------------------------------------------------------------------
def handle_sigterm(sig, frame):
"""
handler for ctrl+c signal
"""
raise(SystemExit)
def handle_sighub(sig, frame):
"""
handling SIGHUB signal for reload configuration
Linux/macOS only
"""
messenger.info('Reload config file')
load_config()
signal.signal(signal.SIGTERM, handle_sigterm)
if os.name == 'posix':
signal.signal(signal.SIGHUP, handle_sighub)
def terminate_processes(watcher=None):
"""
kill orphaned processes
"""
if _ff.decoder and _ff.decoder.poll() is None:
_ff.decoder.terminate()
if _ff.encoder and _ff.encoder.poll() is None:
_ff.encoder.terminate()
if watcher:
watcher.stop()
def ffmpeg_stderr_reader(std_errors, decoder):
if decoder:
logger = decoder_logger
prefix = DEC_PREFIX
else:
logger = encoder_logger
prefix = ENC_PREFIX
try:
for line in std_errors:
if _log.ff_level == 'INFO':
logger.info('{}{}'.format(
prefix, line.decode("utf-8").rstrip()))
elif _log.ff_level == 'WARNING':
logger.warning('{}{}'.format(
prefix, line.decode("utf-8").rstrip()))
else:
logger.error('{}{}'.format(
prefix, line.decode("utf-8").rstrip()))
except ValueError:
pass
def get_date(seek_day):
"""
get date for correct playlist,
when seek_day is set:
check if playlist date must be from yesterday
"""
d = date.today()
if seek_day and get_time('full_sec') < _playlist.start:
yesterday = d - timedelta(1)
return yesterday.strftime('%Y-%m-%d')
else:
return d.strftime('%Y-%m-%d')
def is_float(value):
"""
test if value is float
"""
try:
float(value)
return True
except (ValueError, TypeError):
return False
def is_int(value):
"""
test if value is int
"""
try:
int(value)
return True
except ValueError:
return False
def valid_json(file):
"""
simple json validation
"""
try:
json_object = json.load(file)
return json_object
except ValueError:
messenger.error("Playlist {} is not JSON conform".format(file))
return None
def check_sync(delta):
"""
check that we are in tolerance time
"""
if _general.stop and abs(delta) > _general.threshold:
messenger.error(
'Sync tolerance value exceeded with {0:.2f} seconds,\n'
'program terminated!'.format(delta))
terminate_processes()
sys.exit(1)
def check_length(total_play_time):
"""
check if playlist is long enough
"""
if _playlist.length and total_play_time < _playlist.length - 5 \
and not stdin_args.loop:
messenger.error(
'Playlist ({}) is not long enough!\n'
'Total play time is: {}, target length is: {}'.format(
get_date(True),
timedelta(seconds=total_play_time),
timedelta(seconds=_playlist.length))
)
def validate_thread(clip_nodes):
"""
validate json values in new thread
and test if source paths exist
"""
def check_json(json_nodes):
error = ''
counter = 0
probe = MediaProbe()
# check if all values are valid
for node in json_nodes["program"]:
source = node["source"]
probe.load(source)
missing = []
if probe.is_remote:
if not probe.video[0]:
missing.append('Stream not exist: "{}"'.format(source))
elif not os.path.isfile(source):
missing.append('File not exist: "{}"'.format(source))
if is_float(node["in"]) and is_float(node["out"]):
counter += node["out"] - node["in"]
else:
missing.append('Missing Value in: "{}"'.format(node))
if not is_float(node["duration"]):
missing.append('No duration Value!')
line = '\n'.join(missing)
if line:
error += line + '\nIn line: {}\n\n'.format(node)
if error:
messenger.error(
'Validation error, check JSON playlist, '
'values are missing:\n{}'.format(error)
)
check_length(counter)
validate = Thread(name='check_json', target=check_json, args=(clip_nodes,))
validate.daemon = True
validate.start()
def seek_in(seek):
"""
seek in clip
"""
if seek > 0.0:
return ['-ss', str(seek)]
else:
return []
def set_length(duration, seek, out):
"""
set new clip length
"""
if out < duration:
return ['-t', str(out - seek)]
else:
return []
def loop_input(source, src_duration, target_duration):
# loop filles n times
loop_count = math.ceil(target_duration / src_duration)
messenger.info(
'Loop "{0}" {1} times, total duration: {2:.2f}'.format(
source, loop_count, target_duration))
return ['-stream_loop', str(loop_count),
'-i', source, '-t', str(target_duration)]
def gen_dummy(duration):
"""
generate a dummy clip, with black color and empty audiotrack
"""
color = '#121212'
# IDEA: add noise could be an config option
# noise = 'noise=alls=50:allf=t+u,hue=s=0'
return [
'-f', 'lavfi', '-i',
'color=c={}:s={}x{}:d={}:r={},format=pix_fmts=yuv420p'.format(
color, _pre_comp.w, _pre_comp.h, duration, _pre_comp.fps
),
'-f', 'lavfi', '-i', 'anoisesrc=d={}:c=pink:r=48000:a=0.05'.format(
duration)
]
def gen_filler(duration):
"""
when playlist is not 24 hours long, we generate a loop from filler clip
"""
probe = MediaProbe()
probe.load(_storage.filler)
if probe.format:
if 'duration' in probe.format:
filler_duration = float(probe.format['duration'])
if filler_duration > duration:
# cut filler
messenger.info(
'Generate filler with {0:.2f} seconds'.format(duration))
return probe, ['-i', _storage.filler] + set_length(
filler_duration, 0, duration)
else:
# loop file n times
return probe, loop_input(_storage.filler,
filler_duration, duration)
else:
messenger.error("Can't get filler length, generate dummy!")
return probe, gen_dummy(duration)
else:
# when no filler is set, generate a dummy
messenger.warning('No filler is set!')
return probe, gen_dummy(duration)
def src_or_dummy(probe, src, dur, seek, out):
"""
when source path exist, generate input with seek and out time
when path not exist, generate dummy clip
"""
# check if input is a remote source
if probe.is_remote and probe.video[0]:
if seek > 0.0:
messenger.warning(
'Seek in live source "{}" not supported!'.format(src))
return ['-i', src] + set_length(86400.0, seek, out)
elif src and os.path.isfile(src):
if out > dur:
if seek > 0.0:
messenger.warning(
'Seek in looped source "{}" not supported!'.format(src))
return ['-i', src] + set_length(dur, seek, out - seek)
else:
# FIXME: when list starts with looped clip,
# the logo length will be wrong
return loop_input(src, dur, out)
else:
return seek_in(seek) + ['-i', src] + set_length(dur, seek, out)
else:
messenger.error('Clip/URL not exist:\n{}'.format(src))
return gen_dummy(out - seek)
def get_delta(begin):
"""
get difference between current time and begin from clip in playlist
"""
current_time = get_time('full_sec')
if _playlist.length:
target_playtime = _playlist.length
else:
target_playtime = 86400.0
if _playlist.start >= current_time and not begin == _playlist.start:
current_time += target_playtime
current_delta = begin - current_time
if math.isclose(current_delta, 86400.0, abs_tol=6):
current_delta -= 86400.0
ref_time = target_playtime + _playlist.start
total_delta = ref_time - begin + current_delta
return current_delta, total_delta
def handle_list_init(current_delta, total_delta, seek, out):
"""
# handle init clip, but this clip can be the last one in playlist,
# this we have to figure out and calculate the right length
"""
new_seek = abs(current_delta) + seek
new_out = out
if 1 > new_seek:
new_seek = 0
if out - new_seek > total_delta:
new_out = total_delta + new_seek
if total_delta > new_out - new_seek > 1:
return new_seek, new_out, False
elif new_out - new_seek > 1:
return new_seek, new_out, True
else:
return 0, 0, True
def handle_list_end(probe, new_length, src, begin, dur, seek, out):
"""
when we come to last clip in playlist,
or when we reached total playtime,
we end up here
"""
new_out = out
new_playlist = True
if seek > 0:
new_out = seek + new_length
else:
new_out = new_length
# prevent looping
if new_out > dur:
new_out = dur
else:
messenger.info(
'We are over time, new length is: {0:.2f}'.format(new_length))
missing_secs = abs(new_length - (dur - seek))
if dur > new_length > 1.5 and dur - seek >= new_length:
src_cmd = src_or_dummy(probe, src, dur, seek, new_out)
elif dur > new_length > 0.0:
messenger.info(
'Last clip less then 1.5 second long, skip:\n{}'.format(src))
src_cmd = None
if missing_secs > 2:
new_playlist = False
messenger.error(
'Reach playlist end,\n{0:.2f} seconds needed.'.format(
missing_secs))
else:
new_out = out
new_playlist = False
src_cmd = src_or_dummy(probe, src, dur, seek, out)
messenger.error(
'Playlist is not long enough:'
'\n{0:.2f} seconds needed.'.format(missing_secs))
return src_cmd, seek, new_out, new_playlist
def timed_source(probe, src, begin, dur, seek, out, first, last):
"""
prepare input clip
check begin and length from clip
return clip only if we are in 24 hours time range
"""
current_delta, total_delta = get_delta(begin)
if first:
_seek, _out, new_list = handle_list_init(current_delta, total_delta,
seek, out)
if _out > 1.0:
return src_or_dummy(probe, src, dur, _seek, _out), \
_seek, _out, new_list
else:
messenger.warning('Clip less then a second, skip:\n{}'.format(src))
return None, 0, 0, True
else:
if not stdin_args.loop and _playlist.length:
check_sync(current_delta)
messenger.debug('current_delta: {:f}'.format(current_delta))
messenger.debug('total_delta: {:f}'.format(total_delta))
if (total_delta > out - seek and not last) \
or stdin_args.loop or not _playlist.length:
# when we are in the 24 houre range, get the clip
return src_or_dummy(probe, src, dur, seek, out), seek, out, False
elif total_delta <= 0:
messenger.info(
'Start time is over playtime, skip clip:\n{}'.format(src))
return None, 0, 0, True
elif total_delta < out - seek or last:
return handle_list_end(probe, total_delta, src,
begin, dur, seek, out)
else:
return None, 0, 0, True
def pre_audio_codec():
"""
when add_loudnorm is False we use a different audio encoder,
s302m has higher quality, but is experimental
and works not well together with the loudnorm filter
"""
if _pre_comp.add_loudnorm:
acodec = 'libtwolame' if 'libtwolame' in FF_LIBS else 'mp2'
audio = ['-c:a', acodec, '-b:a', '384k', '-ar', '48000', '-ac', '2']
else:
audio = ['-c:a', 's302m', '-strict', '-2', '-ar', '48000', '-ac', '2']
return audio

3
requirements-base.txt Normal file
View File

@ -0,0 +1,3 @@
watchdog
colorama
pyyaml

View File

@ -1,2 +1,4 @@
watchdog==0.9.0 colorama==0.4.3
colorama==0.4.1 pathtools==0.1.2
PyYAML==5.3
watchdog==0.10.1