From 929ecfa1c0d49d2bc7df37a089e3bc86bb37e6e8 Mon Sep 17 00:00:00 2001 From: Jonathan Baecker Date: Mon, 3 Feb 2020 12:04:31 +0100 Subject: [PATCH 01/22] add more ignores --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 984d95cf..4ebff419 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ __pycache__/ *-orig.* *.json tests/ +.pytest_cache/ +venv/ From 7f7d5103a59741ad9d7fe88211a034365b02e0d5 Mon Sep 17 00:00:00 2001 From: Jonathan Baecker Date: Mon, 3 Feb 2020 12:06:35 +0100 Subject: [PATCH 02/22] move files to docs --- README.md | 4 ++-- ffplayout.service => docs/ffplayout.service | 0 .../gen_playlist_from_subfolders.sh | 0 logo.png => docs/logo.png | Bin 4 files changed, 2 insertions(+), 2 deletions(-) rename ffplayout.service => docs/ffplayout.service (100%) rename gen_playlist_from_subfolders.sh => docs/gen_playlist_from_subfolders.sh (100%) rename logo.png => docs/logo.png (100%) diff --git a/README.md b/README.md index 40ff8746..f5549014 100644 --- a/README.md +++ b/README.md @@ -107,11 +107,11 @@ Installation - 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/** +- copy docs/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) +- 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** Start with Arguments diff --git a/ffplayout.service b/docs/ffplayout.service similarity index 100% rename from ffplayout.service rename to docs/ffplayout.service diff --git a/gen_playlist_from_subfolders.sh b/docs/gen_playlist_from_subfolders.sh similarity index 100% rename from gen_playlist_from_subfolders.sh rename to docs/gen_playlist_from_subfolders.sh diff --git a/logo.png b/docs/logo.png similarity index 100% rename from logo.png rename to docs/logo.png From 6c4bb61bf722cd9267488c2264e56f353d194588 Mon Sep 17 00:00:00 2001 From: Jonathan Baecker Date: Mon, 3 Feb 2020 12:07:06 +0100 Subject: [PATCH 03/22] create base for installation --- requirements-base.txt | 3 +++ requirements.txt | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 requirements-base.txt diff --git a/requirements-base.txt b/requirements-base.txt new file mode 100644 index 00000000..2bd46d34 --- /dev/null +++ b/requirements-base.txt @@ -0,0 +1,3 @@ +watchdog +colorama +pyyaml diff --git a/requirements.txt b/requirements.txt index be7e0b02..2448a3b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ -watchdog==0.9.0 -colorama==0.4.1 +colorama==0.4.3 +pathtools==0.1.2 +PyYAML==5.3 +watchdog==0.10.1 From 472b11965e5bbd4672f14d70ef24a7bf2eb960f4 Mon Sep 17 00:00:00 2001 From: Jonathan Baecker Date: Mon, 3 Feb 2020 12:07:21 +0100 Subject: [PATCH 04/22] migrate to yaml config file --- ffplayout.py | 147 +++++++++++++++++++++++++++----------------------- ffplayout.yml | 144 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 223 insertions(+), 68 deletions(-) create mode 100644 ffplayout.yml diff --git a/ffplayout.py b/ffplayout.py index 901b48c6..44e50460 100755 --- a/ffplayout.py +++ b/ffplayout.py @@ -18,7 +18,6 @@ # ------------------------------------------------------------------------------ -import configparser import glob import json import logging @@ -33,6 +32,7 @@ import ssl import sys import tempfile import time +import yaml from argparse import ArgumentParser from datetime import date, datetime, timedelta from email.mime.multipart import MIMEMultipart @@ -142,36 +142,52 @@ _WINDOWS = os.name == 'nt' COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 64 * 1024 +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 """ - cfg = configparser.ConfigParser() - - 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) if stdin_args.config: - cfg.read(stdin_args.config) - elif os.path.isfile('/etc/ffplayout/ffplayout.conf'): - cfg.read('/etc/ffplayout/ffplayout.conf') + cfg = read_config(stdin_args.config) + elif os.path.isfile('/etc/ffplayout/ffplayout.yml'): + cfg = read_config('/etc/ffplayout/ffplayout.yml') else: - cfg.read('ffplayout.conf') + cfg = read_config('ffplayout.yml') if stdin_args.start: p_start = str_to_sec(stdin_args.start) else: - p_start = str_to_sec(cfg.get('PLAYLIST', 'day_start')) + p_start = str_to_sec(cfg['playlist']['day_start']) if not p_start: p_start = get_time('full_sec') @@ -179,65 +195,61 @@ def load_config(): if stdin_args.length: p_length = str_to_sec(stdin_args.length) else: - p_length = str_to_sec(cfg.get('PLAYLIST', 'length')) + p_length = str_to_sec(cfg['playlist']['length']) - _general.stop = cfg.getboolean('GENERAL', 'stop_on_error') - _general.threshold = cfg.getfloat('GENERAL', 'stop_threshold') + _general.stop = cfg['general']['stop_on_error'] + _general.threshold = cfg['general']['stop_threshold'] - _mail.subject = cfg.get('MAIL', 'subject') - _mail.server = cfg.get('MAIL', 'smpt_server') - _mail.port = cfg.getint('MAIL', 'smpt_port') - _mail.s_addr = cfg.get('MAIL', 'sender_addr') - _mail.s_pass = cfg.get('MAIL', 'sender_pass') - _mail.recip = cfg.get('MAIL', 'recipient') - _mail.level = cfg.get('MAIL', 'mail_level') + _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.getboolean('PRE_COMPRESS', 'add_logo') - _pre_comp.logo = cfg.get('PRE_COMPRESS', 'logo') - _pre_comp.opacity = cfg.get('PRE_COMPRESS', 'logo_opacity') - _pre_comp.logo_filter = cfg.get('PRE_COMPRESS', 'logo_filter') - _pre_comp.add_loudnorm = cfg.getboolean('PRE_COMPRESS', 'add_loudnorm') - _pre_comp.loud_i = cfg.getfloat('PRE_COMPRESS', 'loud_I') - _pre_comp.loud_tp = cfg.getfloat('PRE_COMPRESS', 'loud_TP') - _pre_comp.loud_lra = cfg.getfloat('PRE_COMPRESS', 'loud_LRA') + _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.getboolean('PLAYLIST', 'playlist_mode') - _playlist.path = cfg.get('PLAYLIST', 'path') + _playlist.mode = cfg['playlist']['playlist_mode'] + _playlist.path = cfg['playlist']['path'] _playlist.start = p_start _playlist.length = p_length - _storage.path = cfg.get('STORAGE', 'path') - _storage.filler = cfg.get('STORAGE', 'filler_clip') - _storage.extensions = json.loads(cfg.get('STORAGE', 'extensions')) - _storage.shuffle = cfg.getboolean('STORAGE', 'shuffle') + _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.getboolean('TEXT', 'add_text') - _text.address = cfg.get('TEXT', 'bind_address') - _text.fontfile = cfg.get('TEXT', 'fontfile') + _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.getboolean('LOGGING', 'log_to_file') - _log.path = cfg.get('LOGGING', 'log_path') - _log.level = cfg.get('LOGGING', 'log_level') - _log.ff_level = cfg.get('LOGGING', 'ffmpeg_level') + _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.getint('PRE_COMPRESS', 'width') - _pre_comp.h = cfg.getint('PRE_COMPRESS', 'height') - _pre_comp.aspect = cfg.getfloat('PRE_COMPRESS', 'aspect') - _pre_comp.fps = cfg.getint('PRE_COMPRESS', 'fps') - _pre_comp.v_bitrate = cfg.getint('PRE_COMPRESS', 'width') * 50 - _pre_comp.v_bufsize = cfg.getint('PRE_COMPRESS', 'width') * 50 / 2 + _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.getboolean('OUT', 'preview') - _playout.name = cfg.get('OUT', 'service_name') - _playout.provider = cfg.get('OUT', 'service_provider') - _playout.out_addr = cfg.get('OUT', 'out_addr') - _playout.post_comp_video = json.loads( - cfg.get('OUT', 'post_comp_video')) - _playout.post_comp_audio = json.loads( - cfg.get('OUT', 'post_comp_audio')) - _playout.post_comp_extra = json.loads( - cfg.get('OUT', 'post_comp_extra')) + _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 @@ -1606,12 +1618,11 @@ def main(): _ff.encoder = Popen([ 'ffmpeg', '-v', _log.ff_level.lower(), '-hide_banner', '-nostats', '-re', '-thread_queue_size', '256', - '-i', 'pipe:0'] + overlay + _playout.post_comp_video - + _playout.post_comp_audio + [ + '-i', 'pipe:0'] + overlay + [ '-metadata', 'service_name=' + _playout.name, '-metadata', 'service_provider=' + _playout.provider, '-metadata', 'year={}'.format(year) - ] + _playout.post_comp_extra + [_playout.out_addr], + ] + _playout.post_comp_param + [_playout.out_addr], stdin=PIPE, stderr=PIPE) enc_err_thread = Thread(target=ffmpeg_stderr_reader, diff --git a/ffplayout.yml b/ffplayout.yml new file mode 100644 index 00000000..ef85da5b --- /dev/null +++ b/ffplayout.yml @@ -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 . + +# ------------------------------------------------------------------------------ + + +# 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" From 43f8e41fa25ca934751e930f4536b38dbb72a81f Mon Sep 17 00:00:00 2001 From: Jonathan Baecker Date: Mon, 3 Feb 2020 12:18:57 +0100 Subject: [PATCH 05/22] logging in subfolder --- .gitignore | 1 + ffplayout.py | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 4ebff419..f3857de9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ __pycache__/ tests/ .pytest_cache/ venv/ +log/ diff --git a/ffplayout.py b/ffplayout.py index 44e50460..1f037a9e 100755 --- a/ffplayout.py +++ b/ffplayout.py @@ -327,9 +327,12 @@ if _log.to_file and _log.path != 'none': decoder_log = os.path.join(_log.path, 'decoder.log') encoder_log = os.path.join(_log.path, 'encoder.log') else: - playout_log = os.path.join(os.getcwd(), 'ffplayout.log') - decoder_log = os.path.join(os.getcwd(), 'ffdecoder.log') - encoder_log = os.path.join(os.getcwd(), 'ffencoder.log') + base_dir = 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') From f108a13798cbe9afd1a73e109d50e9491d63bdde Mon Sep 17 00:00:00 2001 From: Jonathan Baecker Date: Mon, 3 Feb 2020 12:19:40 +0100 Subject: [PATCH 06/22] migrate to yaml --- ffplayout.conf | 132 ------------------------------------------------- 1 file changed, 132 deletions(-) delete mode 100644 ffplayout.conf diff --git a/ffplayout.conf b/ffplayout.conf deleted file mode 100644 index 38658c06..00000000 --- a/ffplayout.conf +++ /dev/null @@ -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 . - -; ------------------------------------------------------------------------------ - - -; 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 From 19ac9c8b6c8ff6df62b7c2880cf11a209fe89b01 Mon Sep 17 00:00:00 2001 From: Jonathan Baecker Date: Mon, 3 Feb 2020 13:22:46 +0100 Subject: [PATCH 07/22] add makefile --- Makefile | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..0e7bb97b --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +SHELL := /bin/bash +current_dir = $(shell pwd) + +init: + virtualenv -p python3 venv + source ./venv/bin/activate && pip install -r requirements-base.txt + + @echo "" + @echo "-------------------------------------------------------------------" + @echo "packages for ffplayout installed in \"$(current_dir)/venv\"" + @echo "" + @echo "run \"$(current_dir)/venv/bin/python\" \"$(current_dir)/ffplayout.py\"" From 30128bbe5d2cc187ea64b392e85995977e2c0d9a Mon Sep 17 00:00:00 2001 From: Jonathan Baecker Date: Mon, 3 Feb 2020 17:33:08 +0100 Subject: [PATCH 08/22] reorder code --- ffplayout.py | 1561 +---------------------------------------- ffplayout/__init__.py | 0 ffplayout/filters.py | 243 +++++++ ffplayout/folder.py | 155 ++++ ffplayout/playlist.py | 266 +++++++ ffplayout/utils.py | 984 ++++++++++++++++++++++++++ 6 files changed, 1657 insertions(+), 1552 deletions(-) create mode 100644 ffplayout/__init__.py create mode 100644 ffplayout/filters.py create mode 100644 ffplayout/folder.py create mode 100644 ffplayout/playlist.py create mode 100644 ffplayout/utils.py diff --git a/ffplayout.py b/ffplayout.py index 1f037a9e..36b114c3 100755 --- a/ffplayout.py +++ b/ffplayout.py @@ -18,1573 +18,30 @@ # ------------------------------------------------------------------------------ -import glob -import json -import logging -import math import os -import random -import re -import signal -import smtplib -import socket -import ssl -import sys -import tempfile -import time -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 PIPE, CalledProcessError, Popen, check_output +from subprocess import PIPE, Popen from threading import Thread -from types import SimpleNamespace -from urllib import request + +from ffplayout.folder import GetSourceFromFolder, MediaStore, MediaWatcher +from ffplayout.playlist import GetSourceFromPlaylist +from ffplayout.utils import (COPY_BUFSIZE, DEC_PREFIX, ENC_PREFIX, _ff, _log, + _playlist, _playout, _pre_comp, _text, + decoder_logger, encoder_logger, + ffmpeg_stderr_reader, get_date, messenger, + pre_audio_codec, stdin_args, terminate_processes) try: if os.name != 'posix': import colorama colorama.init() - - from watchdog.events import PatternMatchingEventHandler - from watchdog.observers import Observer except ImportError: print('Some modules are not installed, ffplayout may or may not work') -# ------------------------------------------------------------------------------ -# 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) - -_WINDOWS = os.name == 'nt' -COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 64 * 1024 - - -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.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, logger, 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 - - -# ------------------------------------------------------------------------------ -# 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): - """ - when clip has no audio we generate an audio line - """ - line = [] - - if not probe.audio: - messenger.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): - """ - 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) - - 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'] - - -# ------------------------------------------------------------------------------ -# 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) - - yield [ - '-i', self._media.store[self.index] - ] + filtergraph - self.index += 1 - else: - self.index = 0 - - # ------------------------------------------------------------------------------ # main functions # ------------------------------------------------------------------------------ -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) - - 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 - - def main(): """ pipe ffmpeg pre-process to final ffmpeg post-process, diff --git a/ffplayout/__init__.py b/ffplayout/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ffplayout/filters.py b/ffplayout/filters.py new file mode 100644 index 00000000..8b1365ac --- /dev/null +++ b/ffplayout/filters.py @@ -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 . + +# ------------------------------------------------------------------------------ + +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'] diff --git a/ffplayout/folder.py b/ffplayout/folder.py new file mode 100644 index 00000000..94952c6a --- /dev/null +++ b/ffplayout/folder.py @@ -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 . + +# ------------------------------------------------------------------------------ + +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 diff --git a/ffplayout/playlist.py b/ffplayout/playlist.py new file mode 100644 index 00000000..1e9c11d0 --- /dev/null +++ b/ffplayout/playlist.py @@ -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 . + +# ------------------------------------------------------------------------------ + +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 diff --git a/ffplayout/utils.py b/ffplayout/utils.py new file mode 100644 index 00000000..88bc7203 --- /dev/null +++ b/ffplayout/utils.py @@ -0,0 +1,984 @@ +# -*- 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 . + +# ------------------------------------------------------------------------------ + + +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) + +_WINDOWS = os.name == 'nt' +COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 64 * 1024 + + +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, logger, 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 From 5e0e99d1ca796d496ac59d472ced14b247b5a0e3 Mon Sep 17 00:00:00 2001 From: Jonathan Baecker Date: Mon, 3 Feb 2020 17:48:22 +0100 Subject: [PATCH 09/22] install actual version --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 0e7bb97b..5a3c0085 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ current_dir = $(shell pwd) init: virtualenv -p python3 venv - source ./venv/bin/activate && pip install -r requirements-base.txt + source ./venv/bin/activate && pip install -r requirements.txt @echo "" @echo "-------------------------------------------------------------------" From 3b528296dc3717ef7839a2923c83921e59d59e04 Mon Sep 17 00:00:00 2001 From: Jonathan Baecker Date: Mon, 3 Feb 2020 17:48:30 +0100 Subject: [PATCH 10/22] update path --- docs/ffplayout.service | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/ffplayout.service b/docs/ffplayout.service index 403b0fe2..f66a7f5e 100644 --- a/docs/ffplayout.service +++ b/docs/ffplayout.service @@ -3,7 +3,8 @@ Description=python and ffmpeg based playout After=network.target [Service] -ExecStart=/usr/local/bin/ffplayout.py +ExecStart=/opt/ffplayout-engine/venv/bin/python /opt/ffplayout-engine/ffplayout.py + ExecReload=/bin/kill -1 $MAINPID Restart=always RestartSec=1 From 011bff84a3dfb47a537ad2f6e1f3ee8fd98550c9 Mon Sep 17 00:00:00 2001 From: Jonathan Baecker Date: Mon, 3 Feb 2020 17:48:46 +0100 Subject: [PATCH 11/22] update discription --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f5549014..727d3b48 100644 --- a/README.md +++ b/README.md @@ -104,10 +104,13 @@ More informations in [Wiki](https://github.com/ffplayout/ffplayout-engine/wiki/R Installation ----- - install ffmpeg, ffprobe (and ffplay if you need the preview mode) -- copy ffplayout.py to **/usr/local/bin/** -- copy ffplayout.conf to **/etc/ffplayout/** +- `cd` to **/opt/** +- clone repo: `git clone https://github.com/ffplayout/ffplayout-engine.git` +- `cd ffplayout-engine` +- run **make** +- copy ffplayout.yml to **/etc/ffplayout/** - create folder with correct permissions for logging (check config) -- copy docs/ffplayout.service to **/etc/systemd/system/** +- copy **docs/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 @@ -129,7 +132,7 @@ ffplayout also allows the passing of parameters: 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 From 3516ac3858b3509d2226350b5deb70be8527eef0 Mon Sep 17 00:00:00 2001 From: jb-alvarado Date: Mon, 3 Feb 2020 20:40:13 +0100 Subject: [PATCH 12/22] update infos --- Makefile | 9 +++++++-- ffplayout.py | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 5a3c0085..ff9c8d1d 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,11 @@ init: @echo "" @echo "-------------------------------------------------------------------" - @echo "packages for ffplayout installed in \"$(current_dir)/venv\"" + @echo "external packages for ffplayout installed in \"$(current_dir)/venv\"" + @echo "" + @echo "run: \"$(current_dir)/venv/bin/python\" \"$(current_dir)/ffplayout.py\"" + @echo "" + @echo "or:" + @echo "source ./venv/bin/activate" + @echo "./ffplayout.py" @echo "" - @echo "run \"$(current_dir)/venv/bin/python\" \"$(current_dir)/ffplayout.py\"" diff --git a/ffplayout.py b/ffplayout.py index 36b114c3..b3e07138 100755 --- a/ffplayout.py +++ b/ffplayout.py @@ -35,7 +35,7 @@ try: import colorama colorama.init() except ImportError: - print('Some modules are not installed, ffplayout may or may not work') + print('colorama import failed, no colored console output on windows...') # ------------------------------------------------------------------------------ From 1a4b3a53028366cc3cae7e48ad57c712e4afdc02 Mon Sep 17 00:00:00 2001 From: jb-alvarado Date: Mon, 3 Feb 2020 20:49:55 +0100 Subject: [PATCH 13/22] less import --- ffplayout.py | 14 ++++++-------- ffplayout/utils.py | 12 ++++++++---- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/ffplayout.py b/ffplayout.py index b3e07138..8536a73c 100755 --- a/ffplayout.py +++ b/ffplayout.py @@ -24,9 +24,7 @@ from threading import Thread from ffplayout.folder import GetSourceFromFolder, MediaStore, MediaWatcher from ffplayout.playlist import GetSourceFromPlaylist -from ffplayout.utils import (COPY_BUFSIZE, DEC_PREFIX, ENC_PREFIX, _ff, _log, - _playlist, _playout, _pre_comp, _text, - decoder_logger, encoder_logger, +from ffplayout.utils import (_ff, _log, _playlist, _playout, _pre_comp, _text, ffmpeg_stderr_reader, get_date, messenger, pre_audio_codec, stdin_args, terminate_processes) @@ -37,6 +35,9 @@ try: except ImportError: print('colorama import failed, no colored console output on windows...') +_WINDOWS = os.name == 'nt' +COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 64 * 1024 + # ------------------------------------------------------------------------------ # main functions @@ -86,8 +87,7 @@ def main(): stdin=PIPE, stderr=PIPE) enc_err_thread = Thread(target=ffmpeg_stderr_reader, - args=(_ff.encoder.stderr, encoder_logger, - ENC_PREFIX)) + args=(_ff.encoder.stderr, False)) enc_err_thread.daemon = True enc_err_thread.start() @@ -116,9 +116,7 @@ def main(): stdout=PIPE, stderr=PIPE) as _ff.decoder: dec_err_thread = Thread(target=ffmpeg_stderr_reader, - args=(_ff.decoder.stderr, - decoder_logger, - DEC_PREFIX)) + args=(_ff.decoder.stderr, True)) dec_err_thread.daemon = True dec_err_thread.start() diff --git a/ffplayout/utils.py b/ffplayout/utils.py index 88bc7203..a52dc0c7 100644 --- a/ffplayout/utils.py +++ b/ffplayout/utils.py @@ -123,9 +123,6 @@ _playout = SimpleNamespace() _init = SimpleNamespace(load=True) _ff = SimpleNamespace(decoder=None, encoder=None) -_WINDOWS = os.name == 'nt' -COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 64 * 1024 - def str_to_sec(s): if s in ['now', '', None, 'none']: @@ -590,7 +587,14 @@ def terminate_processes(watcher=None): watcher.stop() -def ffmpeg_stderr_reader(std_errors, logger, prefix): +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': From afd564f6373401bbb357f8f25a1dcd37ad20e6d4 Mon Sep 17 00:00:00 2001 From: Jonathan Baecker Date: Tue, 4 Feb 2020 13:13:23 +0100 Subject: [PATCH 14/22] add install/clean/uninstall routine --- Makefile | 46 ++++++++++++++++++++++++++++++++++++++---- docs/ffplayout.service | 5 ++--- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index ff9c8d1d..3c461384 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,55 @@ SHELL := /bin/bash -current_dir = $(shell pwd) +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 "external packages for ffplayout installed in \"$(CURRENT_DIR)/venv\"" @echo "" - @echo "run: \"$(current_dir)/venv/bin/python\" \"$(current_dir)/ffplayout.py\"" + @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 644 -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 diff --git a/docs/ffplayout.service b/docs/ffplayout.service index f66a7f5e..1c0a55aa 100644 --- a/docs/ffplayout.service +++ b/docs/ffplayout.service @@ -4,12 +4,11 @@ After=network.target [Service] ExecStart=/opt/ffplayout-engine/venv/bin/python /opt/ffplayout-engine/ffplayout.py - ExecReload=/bin/kill -1 $MAINPID Restart=always RestartSec=1 -User=user -Group=user +User=root +Group=root [Install] WantedBy=multi-user.target From 9771ad59906efb97a252eba23691daa06aad41db Mon Sep 17 00:00:00 2001 From: Jonathan Baecker Date: Tue, 4 Feb 2020 14:38:05 +0100 Subject: [PATCH 15/22] add separate install instruction --- README.md | 14 +------------- docs/INSTALL.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 13 deletions(-) create mode 100644 docs/INSTALL.md diff --git a/README.md b/README.md index 727d3b48..d48506aa 100644 --- a/README.md +++ b/README.md @@ -103,19 +103,7 @@ More informations in [Wiki](https://github.com/ffplayout/ffplayout-engine/wiki/R Installation ----- -- install ffmpeg, ffprobe (and ffplay if you need the preview mode) -- `cd` to **/opt/** -- clone repo: `git clone https://github.com/ffplayout/ffplayout-engine.git` -- `cd ffplayout-engine` -- run **make** -- copy ffplayout.yml to **/etc/ffplayout/** -- create folder with correct permissions for logging (check config) -- copy **docs/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 **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** +Check [INSTALL.md](docs/INSTALL.md) Start with Arguments ----- diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 00000000..45265d2e --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,30 @@ +**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` +- run `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 virtual environment + +Deinstallation +----- +- run `make uninstall` it will remove all created folders (also the **ffplayout.yml** configuration file!) From e83243b2a5169d08b024409eaf2bd0565a3a5fb2 Mon Sep 17 00:00:00 2001 From: Jonathan Baecker Date: Tue, 4 Feb 2020 15:28:06 +0100 Subject: [PATCH 16/22] new config format --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d48506aa..f8e7a4a5 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Features Requirements ----- - 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 - **ffmpeg v4.2+** and **ffprobe** (**ffplay** if you want to play on desktop) - if you want to overlay text, ffmpeg needs to have **libzmq** @@ -125,4 +125,4 @@ You can run the command like: 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:`. From 591acf967d3643ccdae5847f2ed3a65c627afba2 Mon Sep 17 00:00:00 2001 From: Jonathan Baecker Date: Tue, 4 Feb 2020 15:28:18 +0100 Subject: [PATCH 17/22] add config description --- docs/CONFIG.md | 151 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/CONFIG.md diff --git a/docs/CONFIG.md b/docs/CONFIG.md new file mode 100644 index 00000000..36d7e00d --- /dev/null +++ b/docs/CONFIG.md @@ -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. From 370ad243196b963f384da7ea02236cd1e5ee7c82 Mon Sep 17 00:00:00 2001 From: Jonathan Baecker Date: Tue, 4 Feb 2020 15:33:53 +0100 Subject: [PATCH 18/22] spelling --- ffplayout.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ffplayout.yml b/ffplayout.yml index ef85da5b..3194bcd1 100644 --- a/ffplayout.yml +++ b/ffplayout.yml @@ -44,7 +44,7 @@ mail: # Logging to file -# if log_to_file = False > log to console +# 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 @@ -61,7 +61,7 @@ logging: # 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 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! From 3484e4794abaea4c8b09857fe0dae0051f966deb Mon Sep 17 00:00:00 2001 From: Jonathan Baecker Date: Tue, 4 Feb 2020 15:46:12 +0100 Subject: [PATCH 19/22] add sudo --- docs/INSTALL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 45265d2e..0d7963b0 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -15,7 +15,7 @@ Installation - clone repo: `git clone https://github.com/ffplayout/ffplayout-engine.git` - `cd ffplayout-engine` - run `make` -- run `make install USER=www-data`, use any other user which need write access +- 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) @@ -27,4 +27,4 @@ Cleanup Deinstallation ----- -- run `make uninstall` it will remove all created folders (also the **ffplayout.yml** configuration file!) +- run `sudo make uninstall` it will remove all created folders (also the **ffplayout.yml** configuration file!) From 753b1f3676b0b8ed3246fad7e2ac6aea49eee870 Mon Sep 17 00:00:00 2001 From: Jonathan Baecker Date: Tue, 4 Feb 2020 17:09:13 +0100 Subject: [PATCH 20/22] cleanup --- ffplayout/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ffplayout/utils.py b/ffplayout/utils.py index a52dc0c7..b10e245f 100644 --- a/ffplayout/utils.py +++ b/ffplayout/utils.py @@ -17,7 +17,6 @@ # ------------------------------------------------------------------------------ - import json import logging import math From 017d13e225518847dc11e8cda1c1ee563c4181ef Mon Sep 17 00:00:00 2001 From: Jonathan Baecker Date: Wed, 5 Feb 2020 09:25:56 +0100 Subject: [PATCH 21/22] install file executable --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 3c461384..27a5ecbd 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,7 @@ install: 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 644 -o $(USER) -g $(USER) ffplayout.py "/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/ From 56bc0d74ced253cdb1d2cd43db76a0579d773b38 Mon Sep 17 00:00:00 2001 From: Jonathan Baecker Date: Wed, 5 Feb 2020 09:26:09 +0100 Subject: [PATCH 22/22] add more descriptions --- docs/INSTALL.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 0d7963b0..72100437 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -1,7 +1,7 @@ -**ffplayout-engine installation** +**ffplayout-engine Installation** ================ -Here are a description on how to install *ffplayout engine* on a standard linux server. +Here are a description on how to install *ffplayout engine* on a standard Linux server. Requirements ----- @@ -14,7 +14,7 @@ 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` +- 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 @@ -23,8 +23,20 @@ Installation Cleanup ----- -- run `make clean` to remove virtual environment +- 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]**.