create project

This commit is contained in:
jb_alvarado 2018-01-07 13:58:45 +01:00
parent df21182e3d
commit 601ca69f19
6 changed files with 585 additions and 2 deletions

16
2018-01-01.xml Normal file
View File

@ -0,0 +1,16 @@
<smil>
<head>
<meta name="author" content="Author"/>
<meta name="title" content="Title"/>
<meta name="copyright" content="(c)2018 company"/>
</head>
<body>
<video src="/path/clip_01.mkv" clipBegin="npt=21600s" dur="18.000000s" in="0.00" out="18.000000s"/>
<video src="/path/clip_02.mkv" clipBegin="npt=21618s" dur="18.111000s" in="0.00" out="18.111000s"/>
<video src="/path/clip_03.mkv" clipBegin="npt=21636.1s" dur="247.896000s" in="0.00" out="247.896000s"/>
<video src="/path/clip_04.mkv" clipBegin="npt=21884s" dur="483.114000s" in="0.00" out="483.114000s"/>
<video src="/path/clip_05.mkv" clipBegin="npt=22367.1s" dur="20.108000s" in="0.00" out="20.108000s"/>
<video src="/path/clip &amp; specials.mkv" clipBegin="npt=22387.2s" dur="203.290000s" in="0.00" out="203.290000s"/>
<video src="/path/clip_06.mkv" clipBegin="npt=22590.5s" dur="335.087000s" in="300.00" out="335.087000s"/>
</body>
</smil>

View File

@ -1,2 +1,50 @@
# ffplayout
python and ffmpeg based playout
**ffplayout**
================
This is a 24/7 streaming solution based on python and ffmpeg.
The goal is to play for every day an xml playlist, while the current playlist is still editable.
Features
-----
- have all values in a separate config file
- try to be as simple as possible
- dynamic playlist
- replace missing playlist or clip with a blank clip
- send emails with error message
- overlay a logo
- trim and fade the last clip, to get full 24 hours, if the duration is less then 6 seconds add a blank clip
- set custom day start, so you can have playlist for example: from 6am to 6am, instate of 0am to 12pm
- minimal system requirements and no special tools
- we only need **ffmpeg**, **ffprobe** and a buffer tool like **mbuffer** or **pv**
- no GPU power is needed
- ram and cpu depends on video resolution, I recommend minimum 4 cores and 3.5GB ram for 576p
- python version 3.5 and up
XML Playlist Example
-----
```xml
<smil>
<head>
<meta name="author" content="Author"/>
<meta name="title" content="Title"/>
<meta name="copyright" content="(c)2018 company"/>
</head>
<body>
<video src="/path/clip_01.mkv" clipBegin="npt=21600s" dur="18.000000s" in="0.00" out="18.000000s"/>
<video src="/path/clip_02.mkv" clipBegin="npt=21618s" dur="18.111000s" in="0.00" out="18.111000s"/>
<video src="/path/clip_03.mkv" clipBegin="npt=21636.1s" dur="247.896000s" in="0.00" out="247.896000s"/>
<video src="/path/clip_04.mkv" clipBegin="npt=21884s" dur="483.114000s" in="0.00" out="483.114000s"/>
<video src="/path/clip_05.mkv" clipBegin="npt=22367.1s" dur="20.108000s" in="0.00" out="20.108000s"/>
<video src="/path/clip &amp; specials.mkv" clipBegin="npt=22387.2s" dur="203.290000s" in="0.00" out="203.290000s"/>
<video src="/path/clip_06.mkv" clipBegin="npt=22590.5s" dur="335.087000s" in="300.00" out="335.087000s"/>
</body>
</smil>
```
This project is still in progress!
-----

92
ffplayout.conf Normal file
View File

@ -0,0 +1,92 @@
# 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/>.
# ------------------------------------------------------------------------------
# send error messages to email address, like:
# missing playlist
# unvalid xml format
# missing clip path
# leave recipient blank, if you don't need this
[MAIL]
smpt_server = mail.example.org
smpt_port = 587
sender_addr = log@example.org
sender_pass = 12345
recipient =
# output settings for the pre-compression
# all clips get prepared in that way,
# so the input for the final compression is unique
# it produce a mpeg2 ts stream
# bitrate is in kbit/s
[PRE_COMPRESS]
width = 1024
height = 576
fps = 25
v_bitrate = 15000
a_bitrate = 256
a_sample = 44100
# playlist settings
# 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)
# strings in playlist must have ampersan (&) as: &amp;
# playlist format is smil xml
# day_start means at witch hour starts the day, as integer
[PLAYLIST]
playlist_path = /playlists
day_start = 6
# buffer settings
# this is a system processs witch run between pre-compression
# and final compression, this makes the magic to playout multiple files
# without interrupt the stream
# buffer_length: length in seconds of the buffer
# this is the time what the playout have, to change from one clip to the next
# be liberal with this value but dont exaggerate
# buffer size gets calculate with: (v_bitrate + a_bitrate) * buffer_length
# buffer_cli: the prefert buffer tool, needs to be installed on your system
# buffer_cmd: need to end with the buffer size command, full command would look:
# /usr/bin/pv -q -B 72600k
[BUFFER]
buffer_length = 7
buffer_cli = /usr/bin/mbuffer
buffer_cmd = ["-q", "-c", "-m"]
# the final playout post compression
# set the settings to your needs
# logo is only used if the path exist
# with logo_o = overlay=W-w-2:0 you can modify the logo position
[OUT]
service_name = AD TV Live Stream
service_provider = amazing discoveries e. V.
logo = /usr/local/share/logo.png
logo_o = overlay=W-w-2:0
post_comp_video = ["-c:v", "libx264", "-crf", "23", "-g", "25", "-maxrate", "1300k", "-bufsize", "1300k", "-preset", "fast", "-profile:v", "Main", "-level", "3.1", "-refs", "3"]
post_comp_audio = ["-c:a", "libfdk_aac", "-b:a", "128k"]
post_comp_extra = ["-threads", "2", "-flags", "+global_header", "-f", "flv"]
out_addr = rtmp://192.168.3.31/live/stream

364
ffplayout.py Normal file
View File

@ -0,0 +1,364 @@
#!/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
import re
import smtplib
from ast import literal_eval
from datetime import datetime, date, timedelta
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from pathlib import Path
from shutil import copyfileobj
from subprocess import Popen, PIPE
from threading import Thread
from time import sleep
from xml.dom import minidom
from xml.parsers.expat import ExpatError
# get different time informations
def cur_ts(time_value, day):
start_clock = datetime.now().strftime('%H:%M:%S')
start_h, start_m, start_s = re.split(':', start_clock)
time_in_sec = int(start_h) * 3600 + int(start_m) * 60 + int(start_s)
if time_value == 't_hour':
return start_h
elif time_value == 't_full':
return time_in_sec
elif time_value == 't_date':
t_from_cfg = int(cfg.get('PLAYLIST', 'day_start'))
if int(start_h) < t_from_cfg and day != 'today':
yesterday = date.today() - timedelta(1)
list_date = yesterday.strftime('%Y-%m-%d')
else:
list_date = datetime.now().strftime('%Y-%m-%d')
return list_date
# ------------------------------------------------------------------------------
# read values from config file
# ------------------------------------------------------------------------------
# read config
cfg = configparser.ConfigParser()
cfg.read("/etc/ffplayout/ffplayout.conf")
class _mail:
server = cfg.get('MAIL', 'smpt_server')
port = cfg.get('MAIL', 'smpt_port')
s_addr = cfg.get('MAIL', 'sender_addr')
s_pass = cfg.get('MAIL', 'sender_pass')
recip = cfg.get('MAIL', 'recipient')
class _pre_comp:
w = cfg.get('PRE_COMPRESS', 'width')
h = cfg.get('PRE_COMPRESS', 'height')
aspect = float(w) / float(h)
fps = cfg.get('PRE_COMPRESS', 'fps')
v_bitrate = cfg.get('PRE_COMPRESS', 'v_bitrate')
v_bufsize = int(v_bitrate) / 2
a_bitrate = cfg.get('PRE_COMPRESS', 'a_bitrate')
a_sample = cfg.get('PRE_COMPRESS', 'a_sample')
class _playlist:
path = cfg.get('PLAYLIST', 'playlist_path')
start = int(cfg.get('PLAYLIST', 'day_start'))
if cur_ts('t_full', '0') > 0 and cur_ts('t_full', '0') < start:
start += 86400
class _buffer:
length = cfg.get('BUFFER', 'buffer_length')
cli = cfg.get('BUFFER', 'buffer_cli')
cmd = literal_eval(cfg.get('BUFFER', 'buffer_cmd'))
class _playout:
name = cfg.get('OUT', 'service_name')
provider = cfg.get('OUT', 'service_provider')
out_addr = cfg.get('OUT', 'out_addr')
# set logo filtergraph
if Path(cfg.get('OUT', 'logo')).is_file():
logo_path = ['-thread_queue_size', '512', '-i', cfg.get('OUT', 'logo')]
logo_graph = [
'-filter_complex', '[0:v][1:v]' + cfg.get('OUT', 'logo_o') + '[o]',
'-map', '[o]', '-map', '0:a'
]
else:
logo_path = []
logo_graph = []
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'))
# ------------------------------------------------------------------------------
# global functions
# ------------------------------------------------------------------------------
# send error messages to email addresses
def send_mail(message, path):
if _mail.recip:
msg = MIMEMultipart()
msg['From'] = _mail.s_addr
msg['To'] = _mail.recip
msg['Subject'] = "Playout Error"
msg.attach(MIMEText('{}\n{}\n'.format(message, path), 'plain'))
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:
print('{}\n{}\n'.format(message, path))
# calculating the size for the buffer in bytes
def calc_buffer_size():
total_size = (int(_pre_comp.v_bitrate) + int(_pre_comp.a_bitrate)) * \
int(_buffer.length)
return int(total_size)
# 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_path(f_o_l, in_file, duration, seek_t):
in_path = Path(in_file)
if f_o_l == 'list':
error_message = 'Plylist does not exist:'
elif f_o_l == 'file':
error_message = 'File does not exist:'
elif f_o_l == 'dummy_l':
error_message = 'XML Playlist is not valid!'
if not in_path.is_file() or f_o_l == 'dummy_l' or f_o_l == 'dummy_p':
if f_o_l != 'dummy_p':
send_mail(error_message, in_path)
out_path = [
'-f', 'lavfi', '-i',
'color=s={}x{}:d={}'.format(
_pre_comp.w, _pre_comp.h, duration
),
'-f', 'lavfi', '-i', 'anullsrc=r=' + _pre_comp.a_sample,
'-shortest'
]
else:
if float(seek_t) > 0.00:
out_path = [
'-ss', str(seek_t), '-i', in_file,
'-vf', 'fade=in:st=0:d=0.5',
'-af', 'afade=in:st=0:d=0.5'
]
else:
out_path = ['-i', in_file]
return out_path
# ------------------------------------------------------------------------------
# main functions
# ------------------------------------------------------------------------------
# read values from xml playlist
def get_from_playlist(last_time, list_date, seek_in_clip):
# path to current playlist
l_y, l_m, l_d = re.split('-', list_date)
c_p = '{}/{}/{}/{}.xml'.format(_playlist.path, l_y, l_m, list_date)
src_cmd = check_path('list', c_p, 300, 0.00)
if '-shortest' in src_cmd:
clip_start = last_time
else:
try:
xmldoc = minidom.parse(c_p)
except ExpatError:
src_cmd = check_path('dummy_l', c_p, 300, 0.00)
return src_cmd, last_time, list_date, seek_in_clip
clip_ls = xmldoc.getElementsByTagName('video')
for i in range(len(clip_ls)):
clip_start = re.sub(
'[a-z=]', '', clip_ls[i].attributes['clipBegin'].value
)
clip_dur = re.sub('s', '', clip_ls[i].attributes['dur'].value)
clip_path = clip_ls[i].attributes['src'].value
# last clip in playlist
if i == len(clip_ls) - 1:
# 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
clip_in = re.sub('s', '', clip_ls[i].attributes['in'].value)
tmp_dur = float(clip_dur) - float(clip_in)
if tmp_dur > 6.00:
src_cmd = check_path('file', clip_path, clip_dur, clip_in)
elif tmp_dur > 1.00:
src_cmd = check_path('dummy_c', clip_path, tmp_dur, 0.00)
else:
src_cmd = check_path('dummy_c', clip_path, 1, 0.00)
clip_start = _playlist.start * 3600 - 5
list_date = cur_ts('t_date', 'today')
get_time = cur_ts('t_full', '0')
# check if we are in time
if int(get_time) > int(clip_start) + 10:
send_mail('we are out of time...:', get_time)
# all other clips in playlist
elif seek_in_clip is True:
# first time we end up here
if float(last_time) < float(clip_start) + float(clip_dur):
# calculate seek time
seek_t = float(last_time) - float(clip_start)
clip_len = float(clip_dur) - seek_t
src_cmd = check_path('file', clip_path, clip_len, seek_t)
seek_in_clip = False
break
else:
if float(last_time) < float(clip_start):
src_cmd = check_path('file', clip_path, clip_dur, 0.00)
break
return src_cmd, clip_start, list_date, seek_in_clip
# independent thread for clip preparation
def play_clips(out_file):
last_time = cur_ts('t_full', '0')
list_date = cur_ts('t_date', '0')
seek_in_clip = True
# infinit loop
# send current file from xml playlist to stdin from buffer
while True:
try:
src_cmd, last_time, list_date, seek_in_clip = get_from_playlist(
last_time, list_date, seek_in_clip
)
# tm_str = str(timedelta(seconds=int(float(last_time))))
# print('[{}] current play command:\n{}\n'.format(tm_str, src_cmd))
filePiper = Popen(
[
'ffmpeg', '-v', 'error', '-hide_banner', '-nostats'
] + src_cmd +
[
'-s', '{}x{}'.format(_pre_comp.w, _pre_comp.h),
'-aspect', str(_pre_comp.aspect),
'-pix_fmt', 'yuv420p', '-r', str(_pre_comp.fps),
'-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),
'-ar', str(_pre_comp.a_sample), '-ac', '2', '-f', 'mpegts',
'-threads', '2', '-'
],
stdout=PIPE,
stderr=PIPE
)
copyfileobj(filePiper.stdout, out_file)
finally:
filePiper.wait()
def main():
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,
stdout=PIPE
)
try:
# playout to rtmp
playout = Popen(
[
'ffmpeg', '-v', 'error', '-hide_banner', '-re',
'-fflags', '+igndts', '-i', 'pipe:0', '-fflags', '+genpts'
] +
list(_playout.logo_path) +
list(_playout.logo_graph) +
list(_playout.post_comp_video) +
list(_playout.post_comp_audio) +
[
'-metadata', 'service_name=' + _playout.name,
'-metadata', 'service_provider=' + _playout.provider,
'-metadata', 'year=' + cur_ts('t_date', 'today')
] +
list(_playout.post_comp_extra) +
[
_playout.out_addr
],
stdin=mbuffer.stdout,
stdout=PIPE
)
play_thread = Thread(
name='play_clips', target=play_clips, args=(mbuffer.stdin,)
)
play_thread.daemon = True
play_thread.start()
check_process(playout, mbuffer)
finally:
playout.wait()
finally:
mbuffer.wait()
if __name__ == '__main__':
main()

14
ffplayout.service Normal file
View File

@ -0,0 +1,14 @@
[Unit]
Description=python and ffmpeg based playout
After=network.target
[Service]
PIDFile=/tmp/ffplayout.pid
ExecStart=/usr/local/bin/ffplayout.py
ExecStop=/bin/kill -s QUIT $MAINPID
Restart=always
User=user
Group=user
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,49 @@
#!/bin/bash
# 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/>.
# ------------------------------------------------------------------------------
src=$1
listDate=$(date +%Y-%m-%d)
trunk="/playlists/$(date +%Y)/$(date +%m)/"
playlist="$listDate.xml"
# start time in seconds
listStart="21600"
# build Head for playlist
printf '<smil>\n\t<head>
<meta name="author" content="Author"/>
<meta name="title" content="Titel"/>
<meta name="copyright" content="(c)%s company"/>
</head>\n\t<body>\n' "$(date +%Y)" >> "$trunk/$playlist"
# read playlist
while read -r line; do
clipPath=$(echo "$line" | sed 's/&/&amp;/g')
clipDuration=$( ffprobe -v error -show_format "$line" | awk -F= '/duration/{ print $2 }' )
printf '\t\t<video src="%s" clipBegin="npt=%ss" dur="%ss" in="%ss" out="%ss"/>\n' "$clipPath" "$listStart" "$clipDuration" "0.0" "$clipDuration" >> "$trunk/$playlist"
# add start time
listStart="$( awk -v lS="$listStart" -v cD="$clipDuration" 'BEGIN{ print lS + cD }' )"
done < <( find "$src" -name "*.mp4" )
printf "\t</body>\n</smil>\n" >> "$trunk/$playlist"