only remote sources are supported, logging for playout; decoder; encoder

This commit is contained in:
Jonathan Baecker 2019-11-12 12:35:16 +01:00
parent 7c7719e224
commit 72d7fdb391
3 changed files with 140 additions and 59 deletions

View File

@ -27,7 +27,7 @@ Features
- no GPU power is needed - no GPU power is needed
- stream to server or play on desktop - stream to server or play on desktop
- on posix systems ffplayout can reload config with *SIGHUP* - on posix systems ffplayout can reload config with *SIGHUP*
- logging to file, or colored output to console - logging to files, or colored output to console
- add filters to input, if is necessary to match output stream: - add filters to input, if is necessary to match output stream:
- **yadif** (deinterlacing) - **yadif** (deinterlacing)
- **pad** (letterbox or pillarbox to fit aspect) - **pad** (letterbox or pillarbox to fit aspect)
@ -83,9 +83,9 @@ JSON Playlist Example
#### Warning: #### Warning:
(Endless) streaming over multiple days will only work when config have **day_start** value and the **length** value is **24 hours**. If you need only some hours for every day, use a *cron* job, or something similar. (Endless) streaming over multiple days will only work when config have **day_start** value and the **length** value is **24 hours**. If you need only some hours for every day, use a *cron* job, or something similar.
Source from URL / Live Stream Remote source from URL
----- -----
You can use sources from url or live stream in that way: You can use sources from remote URL in that way:
```json ```json
... ...
@ -94,19 +94,11 @@ You can use sources from url or live stream in that way:
"out": 149, "out": 149,
"duration": 149, "duration": 149,
"source": "https://example.org/big_buck_bunny.webm" "source": "https://example.org/big_buck_bunny.webm"
},
...
{
"in": 0,
"out": 2531.36,
"duration": 0,
"source": "rtmp://example.org/live/stream"
} }
...
``` ```
But be careful with it, better test it multiple times! But be careful with it, better test it multiple times!
More informations in [Wiki](https://github.com/ffplayout/ffplayout-engine/wiki/URL---Live-Source) More informations in [Wiki](https://github.com/ffplayout/ffplayout-engine/wiki/Remote-URL-Source)
Installation Installation
----- -----
@ -127,7 +119,7 @@ ffplayout also allows the passing of parameters:
- `-c, --config` use given config file - `-c, --config` use given config file
- `-d, --desktop` preview on desktop - `-d, --desktop` preview on desktop
- `-f, --folder` use folder for playing - `-f, --folder` use folder for playing
- `-l, --log` for user-defined log file, *none* for console output - `-l, --log` for user-defined log path, *none* for console output
- `-i, --loop` loop playlist infinitely - `-i, --loop` loop playlist infinitely
- `-p, --playlist` for playlist file - `-p, --playlist` for playlist file
- `-s, --start` set start time in *hh:mm:ss*, *now* for start with first' - `-s, --start` set start time in *hh:mm:ss*, *now* for start with first'
@ -136,7 +128,7 @@ ffplayout also allows the passing of parameters:
You can run the command like: You can run the command like:
``` ```
python3 ffplayout.py -l ~/ffplayout.log -p ~/playlist.json -d -s now -t none python3 ffplayout.py -l ~/ -p ~/playlist.json -d -s now -t none
``` ```
Play on Desktop Play on Desktop

View File

@ -44,13 +44,15 @@ mail_level = ERROR
; Logging to file ; Logging to file
; if log_to_file = False > log to stderr (console) ; if log_to_file = False > log to console
; path to /var/log/ only if you run this program as deamon ; path to /var/log/ only if you run this program as deamon
; log_level can be: DEBUG, INFO, WARNING, ERROR ; log_level can be: DEBUG, INFO, WARNING, ERROR
; ffmpeg_level can be: INFO, WARNING, ERROR
[LOGGING] [LOGGING]
log_to_file = True log_to_file = True
log_file = /var/log/ffplayout/ffplayout.log log_path = /var/log/ffplayout/
log_level = INFO log_level = INFO
ffmpeg_level = ERROR
; output settings for the pre-compression ; output settings for the pre-compression

View File

@ -122,7 +122,7 @@ def get_time(time_format):
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# default variables and values from config file # default variables and values
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
_general = SimpleNamespace() _general = SimpleNamespace()
@ -141,6 +141,36 @@ _WINDOWS = os.name == 'nt'
COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 64 * 1024 COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 64 * 1024
def ffmpeg_libs():
"""
check which external libs are compiled in ffmpeg,
for using them later
"""
cmd = ['ffprobe', '-version']
libs = []
try:
info = check_output(cmd).decode('UTF-8')
except CalledProcessError as err:
messenger.error('ffprobe - 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()
def load_config(): def load_config():
""" """
this function can reload most settings from configuration file, this function can reload most settings from configuration file,
@ -223,8 +253,9 @@ def load_config():
if _init.load: if _init.load:
_log.to_file = cfg.getboolean('LOGGING', 'log_to_file') _log.to_file = cfg.getboolean('LOGGING', 'log_to_file')
_log.path = cfg.get('LOGGING', 'log_file') _log.path = cfg.get('LOGGING', 'log_path')
_log.level = cfg.get('LOGGING', 'log_level') _log.level = cfg.get('LOGGING', 'log_level')
_log.ff_level = cfg.get('LOGGING', 'ffmpeg_level')
_pre_comp.w = cfg.getint('PRE_COMPRESS', 'width') _pre_comp.w = cfg.getint('PRE_COMPRESS', 'width')
_pre_comp.h = cfg.getint('PRE_COMPRESS', 'height') _pre_comp.h = cfg.getint('PRE_COMPRESS', 'height')
@ -284,7 +315,9 @@ class CustomFormatter(logging.Formatter):
if '"' in msg and '[' in msg: if '"' in msg and '[' in msg:
msg = re.sub('(".*?")', self.cyan + r'\1' + self.reset, msg) msg = re.sub('(".*?")', self.cyan + r'\1' + self.reset, msg)
elif '[decoder]' in msg: elif '[decoder]' in msg:
msg = re.sub(r'(\[decoder\])', self.red + r'\1' + self.reset, 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: elif '/' in msg or '\\' in msg:
msg = re.sub( msg = re.sub(
r'(["\w.:/]+/|["\w.:]+\\.*?)', self.magenta + r'\1', msg) r'(["\w.:/]+/|["\w.:]+\\.*?)', self.magenta + r'\1', msg)
@ -305,20 +338,49 @@ class CustomFormatter(logging.Formatter):
if stdin_args.log: if stdin_args.log:
_log.path = stdin_args.log _log.path = stdin_args.log
logger = logging.getLogger(__name__) playout_logger = logging.getLogger('playout')
logger.setLevel(_log.level) playout_logger.setLevel(_log.level)
console_handler = logging.StreamHandler() decoder_logger = logging.getLogger('decoder')
formatter = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s') 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.to_file and _log.path != 'none':
file_handler = TimedRotatingFileHandler(_log.path, when='midnight', if _log.path and os.path.isdir(_log.path):
backupCount=5) playout_log = os.path.join(_log.path, 'ffplayout.log')
file_handler.setFormatter(formatter) decoder_log = os.path.join(_log.path, 'decoder.log')
logger.addHandler(file_handler) 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')
formatter = logging.Formatter('[%(asctime)s] [%(levelname)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(formatter)
d_file_handler.setFormatter(formatter)
e_file_handler.setFormatter(formatter)
playout_logger.addHandler(p_file_handler)
decoder_logger.addHandler(d_file_handler)
encoder_logger.addHandler(e_file_handler)
DEC_PREFIX = ''
ENC_PREFIX = ''
else: else:
console_handler = logging.StreamHandler()
console_handler.setFormatter(CustomFormatter()) console_handler.setFormatter(CustomFormatter())
logger.addHandler(console_handler) playout_logger.addHandler(console_handler)
decoder_logger.addHandler(console_handler)
encoder_logger.addHandler(console_handler)
DEC_PREFIX = '[decoder] '
ENC_PREFIX = '[encoder] '
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@ -352,7 +414,7 @@ class Mailer:
try: try:
server = smtplib.SMTP(_mail.server, _mail.port) server = smtplib.SMTP(_mail.server, _mail.port)
except socket.error as err: except socket.error as err:
logger.error(err) playout_logger.error(err)
server = None server = None
if server is not None: if server is not None:
@ -360,7 +422,7 @@ class Mailer:
try: try:
login = server.login(_mail.s_addr, _mail.s_pass) login = server.login(_mail.s_addr, _mail.s_pass)
except smtplib.SMTPAuthenticationError as serr: except smtplib.SMTPAuthenticationError as serr:
logger.error(serr) playout_logger.error(serr)
login = None login = None
if login is not None: if login is not None:
@ -390,18 +452,18 @@ class Messenger:
self._mailer = Mailer() self._mailer = Mailer()
def debug(self, msg): def debug(self, msg):
logger.debug(msg.replace('\n', ' ')) playout_logger.debug(msg.replace('\n', ' '))
def info(self, msg): def info(self, msg):
logger.info(msg.replace('\n', ' ')) playout_logger.info(msg.replace('\n', ' '))
self._mailer.info(msg) self._mailer.info(msg)
def warning(self, msg): def warning(self, msg):
logger.warning(msg.replace('\n', ' ')) playout_logger.warning(msg.replace('\n', ' '))
self._mailer.warning(msg) self._mailer.warning(msg)
def error(self, msg): def error(self, msg):
logger.error(msg.replace('\n', ' ')) playout_logger.error(msg.replace('\n', ' '))
self._mailer.error(msg) self._mailer.error(msg)
@ -412,15 +474,13 @@ messenger = Messenger()
# probe media infos # probe media infos
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
class MediaProbe: class MediaProbe:
""" """
get infos about media file, similare to mediainfo get infos about media file, similare to mediainfo
""" """
def load(self, file): def load(self, file):
self.remote_source = ['http', 'https', 'ftp', 'rtmp', 'rtmpe', self.remote_source = ['http', 'https', 'ftp', 'smb', 'sftp']
'rtmps', 'rtp', 'rtsp', 'srt', 'tcp', 'udp']
self.src = file self.src = file
self.format = None self.format = None
self.audio = [] self.audio = []
@ -510,10 +570,18 @@ def terminate_processes(watcher=None):
watcher.stop() watcher.stop()
def decoder_error_reader(std_errors): def ffmpeg_stderr_reader(std_errors, logger, prefix):
try: try:
for line in std_errors: for line in std_errors:
messenger.error('[decoder] {}'.format(line.decode("utf-8"))) 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: except ValueError:
pass pass
@ -874,6 +942,21 @@ def timed_source(probe, src, begin, dur, seek, out, first, last):
return None, 0, 0, True 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, # building filters,
# when is needed add individuell filters to match output format # when is needed add individuell filters to match output format
@ -1008,14 +1091,10 @@ def add_loudnorm(probe):
add single pass loudnorm filter to audio line add single pass loudnorm filter to audio line
""" """
loud_filter = [] loud_filter = []
a_samples = int(192000 / _pre_comp.fps)
if probe.audio and _pre_comp.add_loudnorm: if probe.audio and _pre_comp.add_loudnorm:
loud_filter = [('loudnorm=I={}:TP={}:LRA={},' loud_filter = [('loudnorm=I={}:TP={}:LRA={}').format(
'asetnsamples=n={}').format(_pre_comp.loud_i, _pre_comp.loud_i, _pre_comp.loud_tp, _pre_comp.loud_lra)]
_pre_comp.loud_tp,
_pre_comp.loud_lra,
a_samples)]
return loud_filter return loud_filter
@ -1496,9 +1575,8 @@ def main():
'-b:v', '{}k'.format(_pre_comp.v_bitrate), '-b:v', '{}k'.format(_pre_comp.v_bitrate),
'-minrate', '{}k'.format(_pre_comp.v_bitrate), '-minrate', '{}k'.format(_pre_comp.v_bitrate),
'-maxrate', '{}k'.format(_pre_comp.v_bitrate), '-maxrate', '{}k'.format(_pre_comp.v_bitrate),
'-bufsize', '{}k'.format(_pre_comp.v_bufsize), '-bufsize', '{}k'.format(_pre_comp.v_bufsize)
'-c:a', 's302m', '-strict', '-2', '-ar', '48000', '-ac', '2', ] + pre_audio_codec() + ['-f', 'mpegts', '-']
'-f', 'mpegts', '-']
if _text.add_text and os.path.isfile(_text.textfile): if _text.add_text and os.path.isfile(_text.textfile):
messenger.info('Overlay text file: "{}"'.format(_text.textfile)) messenger.info('Overlay text file: "{}"'.format(_text.textfile))
@ -1516,17 +1594,24 @@ def main():
# preview playout to player # preview playout to player
_ff.encoder = Popen([ _ff.encoder = Popen([
'ffplay', '-hide_banner', '-nostats', '-i', 'pipe:0' 'ffplay', '-hide_banner', '-nostats', '-i', 'pipe:0'
] + overlay, stderr=None, stdin=PIPE, stdout=None) ] + overlay, stderr=PIPE, stdin=PIPE, stdout=None)
else: else:
_ff.encoder = Popen([ _ff.encoder = Popen([
'ffmpeg', '-v', 'info', '-hide_banner', '-nostats', 'ffmpeg', '-v', _log.ff_level.lower(), '-hide_banner',
'-re', '-thread_queue_size', '256', '-nostats', '-re', '-thread_queue_size', '256',
'-i', 'pipe:0'] + overlay + _playout.post_comp_video '-i', 'pipe:0'] + overlay + _playout.post_comp_video
+ _playout.post_comp_audio + [ + _playout.post_comp_audio + [
'-metadata', 'service_name=' + _playout.name, '-metadata', 'service_name=' + _playout.name,
'-metadata', 'service_provider=' + _playout.provider, '-metadata', 'service_provider=' + _playout.provider,
'-metadata', 'year={}'.format(year) '-metadata', 'year={}'.format(year)
] + _playout.post_comp_extra + [_playout.out_addr], stdin=PIPE) ] + _playout.post_comp_extra + [_playout.out_addr],
stdin=PIPE, stderr=PIPE)
enc_err_thread = Thread(target=ffmpeg_stderr_reader,
args=(_ff.encoder.stderr, encoder_logger,
ENC_PREFIX))
enc_err_thread.daemon = True
enc_err_thread.start()
if _playlist.mode and not stdin_args.folder: if _playlist.mode and not stdin_args.folder:
watcher = None watcher = None
@ -1548,14 +1633,16 @@ def main():
messenger.info('Play: "{}"'.format(current_file)) messenger.info('Play: "{}"'.format(current_file))
with Popen([ with Popen([
'ffmpeg', '-v', 'error', '-hide_banner', '-nostats' 'ffmpeg', '-v', _log.ff_level.lower(), '-hide_banner',
] + src_cmd + ff_pre_settings, '-nostats'] + src_cmd + ff_pre_settings,
stdout=PIPE, stderr=PIPE) as _ff.decoder: stdout=PIPE, stderr=PIPE) as _ff.decoder:
err_thread = Thread(target=decoder_error_reader, dec_err_thread = Thread(target=ffmpeg_stderr_reader,
args=(_ff.decoder.stderr,)) args=(_ff.decoder.stderr,
err_thread.daemon = True decoder_logger,
err_thread.start() DEC_PREFIX))
dec_err_thread.daemon = True
dec_err_thread.start()
while True: while True:
buf = _ff.decoder.stdout.read(COPY_BUFSIZE) buf = _ff.decoder.stdout.read(COPY_BUFSIZE)