ffplayout/ffplayout.py

560 lines
18 KiB
Python
Raw Normal View History

2018-01-07 07:58:45 -05:00
#!/usr/bin/python3
# This file is part of ffplayout.
#
# ffplayout is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# ffplayout is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with ffplayout. If not, see <http://www.gnu.org/licenses/>.
# ------------------------------------------------------------------------------
import configparser
2018-01-10 09:41:56 -05:00
import logging
2018-01-07 07:58:45 -05:00
import re
import smtplib
2018-01-10 09:41:56 -05:00
import sys
from argparse import ArgumentParser
2018-01-07 07:58:45 -05:00
from ast import literal_eval
from datetime import datetime, date, timedelta
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
2018-01-10 09:41:56 -05:00
from logging.handlers import TimedRotatingFileHandler
2018-01-16 03:31:59 -05:00
from os import path
2018-01-07 07:58:45 -05:00
from shutil import copyfileobj
from subprocess import Popen, PIPE
from threading import Thread
from time import sleep
from types import SimpleNamespace
import xml.etree.ElementTree as ET
2018-01-07 07:58:45 -05:00
2018-01-07 16:31:06 -05:00
# ------------------------------------------------------------------------------
# read variables from config file
# ------------------------------------------------------------------------------
2018-01-07 11:56:54 -05:00
# read config
cfg = configparser.ConfigParser()
cfg.read("/etc/ffplayout/ffplayout.conf")
_mail = SimpleNamespace(
server=cfg.get('MAIL', 'smpt_server'),
port=cfg.getint('MAIL', 'smpt_port'),
s_addr=cfg.get('MAIL', 'sender_addr'),
s_pass=cfg.get('MAIL', 'sender_pass'),
recip=cfg.get('MAIL', 'recipient')
)
2018-01-10 09:41:56 -05:00
_log = SimpleNamespace(
path=cfg.get('LOGGING', 'log_file'),
level=cfg.get('LOGGING', 'log_level')
)
_pre_comp = SimpleNamespace(
w=cfg.getint('PRE_COMPRESS', 'width'),
h=cfg.getint('PRE_COMPRESS', 'height'),
aspect=cfg.getfloat('PRE_COMPRESS', 'width') /
cfg.getfloat('PRE_COMPRESS', 'height'),
fps=cfg.getint('PRE_COMPRESS', 'fps'),
v_bitrate=cfg.getint('PRE_COMPRESS', 'v_bitrate'),
2018-01-22 04:18:15 -05:00
v_bufsize=cfg.getint('PRE_COMPRESS', 'v_bitrate'),
a_bitrate=cfg.getint('PRE_COMPRESS', 'a_bitrate'),
a_sample=cfg.getint('PRE_COMPRESS', 'a_sample'),
)
_playlist = SimpleNamespace(
path=cfg.get('PLAYLIST', 'playlist_path'),
start=cfg.getint('PLAYLIST', 'day_start')
)
_buffer = SimpleNamespace(
length=cfg.getint('BUFFER', 'buffer_length'),
cli=cfg.get('BUFFER', 'buffer_cli'),
cmd=literal_eval(cfg.get('BUFFER', 'buffer_cmd'))
)
_playout = SimpleNamespace(
name=cfg.get('OUT', 'service_name'),
provider=cfg.get('OUT', 'service_provider'),
out_addr=cfg.get('OUT', 'out_addr'),
post_comp_video=literal_eval(cfg.get('OUT', 'post_comp_video')),
post_comp_audio=literal_eval(cfg.get('OUT', 'post_comp_audio')),
post_comp_extra=literal_eval(cfg.get('OUT', 'post_comp_extra'))
)
# set logo filtergraph
2018-01-16 03:31:59 -05:00
if path.exists(cfg.get('OUT', 'logo')):
_playout.logo = ['-thread_queue_size', '512', '-i', cfg.get('OUT', 'logo')]
_playout.filter = [
'-filter_complex', '[0:v][1:v]' + cfg.get('OUT', 'logo_o') + '[o]',
'-map', '[o]', '-map', '0:a'
]
else:
_playout.logo = []
_playout.filter = []
2018-01-07 07:58:45 -05:00
2018-01-10 09:41:56 -05:00
# ------------------------------------------------------------------------------
# logging
# ------------------------------------------------------------------------------
stdin_parser = ArgumentParser(description="python and ffmpeg based playout")
stdin_parser.add_argument(
"-l", "--log", help="file to write log to (default '" + _log.path + "')"
)
# If the log file is specified on the command line then override the default
stdin_args = stdin_parser.parse_args()
if stdin_args.log:
_log.path = stdin_args.log
logger = logging.getLogger(__name__)
logger.setLevel(_log.level)
handler = TimedRotatingFileHandler(_log.path, when="midnight", backupCount=5)
2018-01-16 03:31:59 -05:00
formatter = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s')
2018-01-10 09:41:56 -05:00
handler.setFormatter(formatter)
logger.addHandler(handler)
# capture stdout and sterr in the log
class ffplayout_logger(object):
def __init__(self, logger, level):
self.logger = logger
self.level = level
def write(self, message):
# Only log if there is a message (not just a new line)
if message.rstrip() != "":
self.logger.log(self.level, message.rstrip())
def flush(self):
pass
# Replace stdout with logging to file at INFO level
sys.stdout = ffplayout_logger(logger, logging.INFO)
# Replace stderr with logging to file at ERROR level
sys.stderr = ffplayout_logger(logger, logging.ERROR)
2018-01-07 07:58:45 -05:00
# ------------------------------------------------------------------------------
# global helper functions
2018-01-07 07:58:45 -05:00
# ------------------------------------------------------------------------------
# get time
def get_time(time_format):
t = datetime.today()
if time_format == 'hour':
return t.hour
elif time_format == 'full_sec':
return t.hour * 3600 + t.minute * 60 + t.second
else:
return t.strftime("%H:%M:%S")
2018-01-07 16:31:06 -05:00
# get date
def get_date(seek_day):
if get_time('hour') < _playlist.start and seek_day:
yesterday = date.today() - timedelta(1)
return yesterday.strftime('%Y-%m-%d')
else:
return datetime.now().strftime('%Y-%m-%d')
2018-01-07 16:31:06 -05:00
2018-01-07 07:58:45 -05:00
# send error messages to email addresses
2018-02-13 08:23:34 -05:00
def mail_or_log(message, time, path):
2018-01-07 07:58:45 -05:00
if _mail.recip:
msg = MIMEMultipart()
msg['From'] = _mail.s_addr
msg['To'] = _mail.recip
msg['Subject'] = "Playout Error"
2018-01-23 07:14:50 -05:00
msg.attach(MIMEText('{} {}\n{}'.format(time, message, path), 'plain'))
2018-01-07 07:58:45 -05:00
text = msg.as_string()
server = smtplib.SMTP(_mail.server, int(_mail.port))
server.starttls()
server.login(_mail.s_addr, _mail.s_pass)
server.sendmail(_mail.s_addr, _mail.recip, text)
server.quit()
else:
2018-01-23 07:14:50 -05:00
logger.error('{} {}'.format(message, path))
2018-01-07 07:58:45 -05:00
2018-02-13 08:23:34 -05:00
# calculating the size for the buffer in KB
2018-01-07 07:58:45 -05:00
def calc_buffer_size():
2018-02-13 08:23:34 -05:00
return (_pre_comp.v_bitrate + _pre_comp.a_bitrate) * 0.125 * _buffer.length
2018-01-07 07:58:45 -05:00
# check if processes a well
def check_process(watch_proc, terminate_proc):
while True:
sleep(4)
if watch_proc.poll() is not None:
terminate_proc.terminate()
break
# check if path exist,
# when not send email and generate blackclip
def check_file_exist(in_file):
2018-01-16 03:31:59 -05:00
if path.exists(in_file):
return True
2018-01-07 07:58:45 -05:00
else:
return False
2018-02-03 17:01:26 -05:00
# seek in clip and cut the end
def seek_in_cut_end(in_file, duration, seek, out):
2018-02-13 08:23:34 -05:00
if seek > 0.0:
2018-02-03 17:01:26 -05:00
inpoint = ['-ss', str(seek)]
fade_in_vid = 'fade=in:st=0:d=0.5'
fade_in_aud = 'afade=in:st=0:d=0.5'
else:
inpoint = []
fade_in_vid = 'null'
fade_in_aud = 'anull'
if out < duration:
2018-02-04 14:29:17 -05:00
fade_out_time = out - seek - 1.0
cut_end = ['-t', str(out - seek)]
2018-02-03 17:01:26 -05:00
fade_out_vid = 'fade=out:st=' + str(fade_out_time) + ':d=1.0'
fade_out_aud = 'afade=out:st=' + str(fade_out_time) + ':d=1.0'
else:
cut_end = []
fade_out_vid = 'null'
fade_out_aud = 'anull'
return inpoint + ['-i', in_file] + cut_end + [
'-vf', fade_in_vid + ',' + fade_out_vid,
'-af', fade_in_aud + ',' + fade_out_aud
]
2018-01-22 14:43:52 -05:00
# generate a dummy clip, with black color and empty audiotrack
def gen_dummy(duration):
return [
'-f', 'lavfi', '-i',
'color=s={}x{}:d={}'.format(
_pre_comp.w, _pre_comp.h, duration
),
2018-01-23 07:14:50 -05:00
'-f', 'lavfi', '-i', 'anullsrc=r=' + str(_pre_comp.a_sample),
'-shortest'
]
2018-02-03 17:01:26 -05:00
# prepare input clip
def prepare_input(src, duration, seek, out):
if check_file_exist(src):
2018-02-13 08:23:34 -05:00
if seek > 0.0 or out < duration:
2018-02-03 17:01:26 -05:00
src_cmd = seek_in_cut_end(src, duration, seek, out)
else:
src_cmd = ['-i', src]
else:
2018-02-13 08:23:34 -05:00
if seek > 0.0:
2018-02-03 17:01:26 -05:00
duration = duration - seek
if out < duration:
duration = duration - out
src_cmd = gen_dummy(duration)
return src_cmd
# last clip can be a filler
# so we get the IN point and calculate the new duration
# if the new duration is smaller then 6 sec put a blank clip
2018-02-13 08:23:34 -05:00
# we have to validate here to, that a last clip exists in the playlist
def prepare_last_clip(clip_nodes):
if clip_nodes:
last_node = clip_nodes[-1]
src = last_node.get('src')
begin = is_float(last_node.get('begin'), get_time('full_sec'), True)
duration = is_float(last_node.get('dur'), 300, True)
seek = is_float(last_node.get('in'), 0, True)
out = is_float(last_node.get('out'), 300, True)
first = False
last = True
else:
src = None
begin = get_time('full_sec')
duration = 300.0
seek = 0.0
out = duration
first = True
last = False
mail_or_log(
'Playlist has no valid entries!',
get_time(None), get_date(True)
)
tmp_dur = out - seek
if tmp_dur > 6.0:
2018-02-03 17:01:26 -05:00
if check_file_exist(src):
src_cmd = seek_in_cut_end(src, duration, seek, out)
2018-01-07 07:58:45 -05:00
else:
src_cmd = gen_dummy(tmp_dur)
2018-02-13 08:23:34 -05:00
elif tmp_dur > 1.0:
src_cmd = gen_dummy(tmp_dur)
else:
2018-01-16 03:31:59 -05:00
src_cmd = None
2018-01-07 07:58:45 -05:00
2018-02-13 08:23:34 -05:00
return src_cmd, begin, first, last
# test if value is float
def is_float(value, text, convert):
try:
float(value)
if convert:
return float(value)
else:
return ''
except ValueError:
return text
# check all variables in xml playlist
# and test if file path exist
def validate_xml(xml_nodes):
error = ''
for xml_node in xml_nodes:
if check_file_exist(xml_node.get('src')):
a = ''
else:
a = 'File not exist! '
b = is_float(xml_node.get('begin'), 'No Start Time! ', False)
c = is_float(xml_node.get('dur'), 'No Duration! ', False)
d = is_float(xml_node.get('in'), 'No In Value! ', False)
e = is_float(xml_node.get('out'), 'No Out Value! ', False)
line = a + b + c + d + e
if line:
error += line + 'In line: ' + str(xml_node.attrib) + '\n'
if error:
mail_or_log(
'Validation error, check xml playlist, values are missing:\n',
get_time(None), error
)
2018-01-07 07:58:45 -05:00
# ------------------------------------------------------------------------------
# main functions
# ------------------------------------------------------------------------------
# read values from xml playlist
2018-01-16 03:31:59 -05:00
def iter_src_commands():
last_time = get_time('full_sec')
if 0 <= last_time < _playlist.start * 3600:
last_time += 86400
2018-02-13 08:23:34 -05:00
last_mod_time = 0.0
time_diff = 0.0
first = True
last = False
time_difference = 0.0
2018-01-16 03:31:59 -05:00
while True:
2018-02-06 03:43:14 -05:00
# switch playlist after last clip from day befor
2018-02-13 08:23:34 -05:00
if last:
if time_difference > float(_buffer.length):
2018-02-06 03:43:14 -05:00
# wait to sync time
2018-02-13 08:23:34 -05:00
wait = time_difference - float(_buffer.length)
2018-02-08 05:30:40 -05:00
logger.info('Wait for: ' + str(wait) + ' seconds.')
2018-02-06 03:43:14 -05:00
sleep(wait)
list_date = get_date(False)
2018-02-13 08:23:34 -05:00
last = False
2018-02-06 03:43:14 -05:00
else:
list_date = get_date(True)
2018-01-16 03:31:59 -05:00
year, month, _day = re.split('-', list_date)
xml_path = path.join(_playlist.path, year, month, list_date + '.xml')
if check_file_exist(xml_path):
# check last modification from playlist
2018-01-22 04:18:15 -05:00
mod_time = path.getmtime(xml_path)
if mod_time > last_mod_time:
2018-01-16 03:31:59 -05:00
xml_root = ET.parse(open(xml_path, "r")).getroot()
clip_nodes = xml_root.findall('body/video')
2018-01-22 04:18:15 -05:00
last_mod_time = mod_time
logger.info('open: ' + xml_path)
2018-01-16 03:31:59 -05:00
2018-02-13 08:23:34 -05:00
# validate xml values in new Thread
validate_thread = Thread(
name='validate_xml', target=validate_xml, args=(
clip_nodes,
)
)
validate_thread.daemon = True
validate_thread.start()
2018-01-16 03:31:59 -05:00
# all clips in playlist except last one
for clip_node in clip_nodes[:-1]:
2018-02-03 17:01:26 -05:00
src = clip_node.get('src')
2018-02-13 08:23:34 -05:00
begin = is_float(clip_node.get('begin'), last_time, True)
duration = is_float(clip_node.get('dur'), 300, True)
seek = is_float(clip_node.get('in'), 0, True)
out = is_float(clip_node.get('out'), 300, True)
2018-01-16 03:31:59 -05:00
2018-02-13 08:23:34 -05:00
if first:
2018-01-16 03:31:59 -05:00
# first time we end up here
2018-02-03 17:01:26 -05:00
if last_time < begin + duration:
2018-01-16 03:31:59 -05:00
# calculate seek time
2018-02-03 17:01:26 -05:00
init_seek = last_time - begin + seek + time_diff
src_cmd = prepare_input(src, duration, init_seek, out)
2018-02-02 01:49:43 -05:00
2018-02-13 08:23:34 -05:00
time_diff = 0.0
first = False
2018-01-16 03:31:59 -05:00
2018-02-03 17:01:26 -05:00
last_time = begin
2018-01-16 03:31:59 -05:00
break
else:
2018-02-03 17:01:26 -05:00
if last_time < begin:
src_cmd = prepare_input(src, duration, seek, out)
last_time = begin
2018-01-16 03:31:59 -05:00
break
2018-01-07 07:58:45 -05:00
else:
# last clip in playlist
2018-02-13 08:23:34 -05:00
src_cmd, begin, first, last = prepare_last_clip(clip_nodes)
2018-02-06 03:43:14 -05:00
if last_time > 86400:
2018-02-13 08:23:34 -05:00
begin -= 86400.0
2018-02-06 03:43:14 -05:00
# calculate real time in buffer
2018-02-13 08:23:34 -05:00
time_difference = begin - get_time('full_sec')
last_time = float(_playlist.start * 3600 - 5)
2018-01-16 03:31:59 -05:00
list_date = get_date(True)
2018-02-13 08:23:34 -05:00
last_mod_time = 0.0
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
2018-01-16 03:31:59 -05:00
src_cmd = gen_dummy(300)
last_time += 300
2018-02-13 08:23:34 -05:00
last_mod_time = 0.0
mail_or_log('Playlist not exist:', get_time(None), xml_path)
2018-02-02 01:49:43 -05:00
# there is still material in the buffer,
# so we have to calculate the right seek time for the new playlist
# time_diff: is the real time what we have in buffer
2018-02-02 01:49:43 -05:00
if last_time > 86400:
time_val = last_time - 86400
else:
time_val = last_time
time_diff = time_val - get_time('full_sec')
2018-02-13 08:23:34 -05:00
first = True
2018-01-07 07:58:45 -05:00
2018-01-16 03:31:59 -05:00
if src_cmd is not None:
yield src_cmd, last_time
2018-01-07 07:58:45 -05:00
# independent thread for clip preparation
2018-01-16 03:31:59 -05:00
def play_clips(out_file, iter_src_commands):
2018-01-07 07:58:45 -05:00
# infinit loop
# send current file from xml playlist to stdin from buffer
2018-01-16 03:31:59 -05:00
for src_cmd, last_time in iter_src_commands:
if last_time > 86400:
tm_str = str(timedelta(seconds=int(last_time - 86400)))
else:
tm_str = str(timedelta(seconds=int(last_time)))
2018-01-16 03:31:59 -05:00
logger.info('play at "{}": {}'.format(tm_str, src_cmd))
2018-01-07 07:58:45 -05:00
2018-01-16 03:31:59 -05:00
try:
2018-01-07 07:58:45 -05:00
filePiper = Popen(
[
2018-02-02 01:49:43 -05:00
'ffmpeg', '-v', 'error', '-hide_banner', '-nostats'
2018-01-07 07:58:45 -05:00
] + src_cmd +
[
'-s', '{}x{}'.format(_pre_comp.w, _pre_comp.h),
'-aspect', str(_pre_comp.aspect),
'-pix_fmt', 'yuv420p', '-r', str(_pre_comp.fps),
2018-01-22 04:18:15 -05:00
'-af', 'apad', '-shortest',
2018-01-07 07:58:45 -05:00
'-c:v', 'mpeg2video', '-g', '12', '-bf', '2',
'-b:v', '{}k'.format(_pre_comp.v_bitrate),
'-minrate', '{}k'.format(_pre_comp.v_bitrate),
'-maxrate', '{}k'.format(_pre_comp.v_bitrate),
'-bufsize', '{}k'.format(_pre_comp.v_bufsize),
'-c:a', 'mp2', '-b:a', '{}k'.format(_pre_comp.a_bitrate),
2018-01-22 04:18:15 -05:00
'-ar', str(_pre_comp.a_sample), '-ac', '2',
'-threads', '2', '-f', 'mpegts', '-'
2018-01-07 07:58:45 -05:00
],
2018-02-13 08:23:34 -05:00
stdout=PIPE,
bufsize=0
2018-01-07 07:58:45 -05:00
)
copyfileobj(filePiper.stdout, out_file)
finally:
filePiper.wait()
def main():
2018-01-16 03:31:59 -05:00
year, month, _day = re.split('-', get_date(False))
2018-01-07 07:58:45 -05:00
try:
# open a buffer for the streaming pipeline
# stdin get the files loop
# stdout pipes to ffmpeg rtmp streaming
mbuffer = Popen(
[_buffer.cli] + list(_buffer.cmd) +
[str(calc_buffer_size()) + 'k'],
stdin=PIPE,
2018-02-13 08:23:34 -05:00
stdout=PIPE,
bufsize=0
2018-01-07 07:58:45 -05:00
)
try:
# playout to rtmp
playout = Popen(
[
2018-01-22 04:18:15 -05:00
'ffmpeg', '-v', 'info', '-hide_banner', '-nostats', '-re',
'-fflags', '+igndts', '-thread_queue_size', '512',
'-i', 'pipe:0', '-fflags', '+genpts'
2018-01-07 07:58:45 -05:00
] +
list(_playout.logo) +
list(_playout.filter) +
2018-01-07 07:58:45 -05:00
list(_playout.post_comp_video) +
list(_playout.post_comp_audio) +
[
'-metadata', 'service_name=' + _playout.name,
'-metadata', 'service_provider=' + _playout.provider,
2018-01-16 03:31:59 -05:00
'-metadata', 'year=' + year
2018-01-07 07:58:45 -05:00
] +
list(_playout.post_comp_extra) +
[
_playout.out_addr
],
2018-02-13 08:23:34 -05:00
stdin=mbuffer.stdout,
bufsize=0
2018-01-07 07:58:45 -05:00
)
play_thread = Thread(
2018-01-16 03:31:59 -05:00
name='play_clips', target=play_clips, args=(
mbuffer.stdin,
iter_src_commands(),
)
2018-01-07 07:58:45 -05:00
)
play_thread.daemon = True
play_thread.start()
check_process(playout, mbuffer)
finally:
playout.wait()
finally:
mbuffer.wait()
if __name__ == '__main__':
main()