Merge branch 'dev'

This commit is contained in:
jb-alvarado 2020-07-11 23:39:29 +02:00
commit 6283a8729d
12 changed files with 519 additions and 204 deletions

1
.gitignore vendored
View File

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

View File

@ -1,13 +1,14 @@
**ffplayout-engine**
================
[![made-with-python](https://img.shields.io/badge/Made%20with-Python-1f425f.svg)](https://www.python.org/)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
The purpose with ffplayout is to provide a 24/7 broadcasting solution that plays a *json* playlist for every day, while keeping the current playlist editable.
#### Check [ffplayout-gui](https://github.com/ffplayout/ffplayout-gui): web-based GUI for ffplayout.
**Check [ffplayout-gui](https://github.com/ffplayout/ffplayout-gui): web-based GUI for ffplayout**
Features
**Features**
-----
- have all values in a separate config file
@ -29,17 +30,22 @@ Features
- on posix systems ffplayout can reload config with *SIGHUP*
- logging to files, or colored output to console
- add filters to input, if is necessary to match output stream:
- **yadif** (deinterlacing)
- **pad** (letterbox or pillarbox to fit aspect)
- **fps** (change fps)
- **scale** (fit target resolution)
- **aevalsrc** (if video have no audio)
- **apad** (add silence if audio duration is to short)
- **tpad** (add black frames if video duration is to short)
- **yadif** (deinterlacing)
- **pad** (letterbox or pillarbox to fit aspect)
- **fps** (change fps)
- **scale** (fit target resolution)
- **aevalsrc** (if video have no audio)
- **apad** (add silence if audio duration is to short)
- **tpad** (add black frames if video duration is to short)
- different types of [output](https://github.com/ffplayout/ffplayout-engine/wiki/Outputs):
- **stream**
- **desktop**
- **HLS**
- **custom**
Requirements
-----
- python version 3.6+
- python module **watchdog** (only when `playlist_mode: False`)
- python module **colorama** if you are on windows
@ -81,11 +87,14 @@ 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.
Remote source from URL
-----
You can use sources from remote URL in that way:
```json
@ -97,17 +106,21 @@ You can use sources from remote URL in that way:
"source": "https://example.org/big_buck_bunny.webm"
}
```
But be careful with it, better test it multiple times!
More informations in [Wiki](https://github.com/ffplayout/ffplayout-engine/wiki/Remote-URL-Source)
Installation
-----
Check [INSTALL.md](docs/INSTALL.md)
Start with Arguments
-----
ffplayout also allows the passing of parameters:
- `-c, --config` use given config file
- `-d, --desktop` preview on desktop
- `-f, --folder` use folder for playing
@ -119,10 +132,11 @@ ffplayout also allows the passing of parameters:
You can run the command like:
```
```SHELL
./ffplayout.py -l none -p ~/playlist.json -d -s now -t none
```
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 `mode: 'desktop'` in config under `out:`.

View File

@ -19,15 +19,9 @@
# ------------------------------------------------------------------------------
import os
from subprocess import PIPE, Popen
from threading import Thread
from pydoc import locate
from ffplayout.folder import GetSourceFromFolder, MediaStore, MediaWatcher
from ffplayout.playlist import GetSourceFromPlaylist
from ffplayout.utils import (_ff, _log, _playlist, _playout, _pre_comp, _text,
ffmpeg_stderr_reader, get_date, messenger,
pre_audio_codec, stdin_args, terminate_processes,
validate_ffmpeg_libs)
from ffplayout.utils import _playout, validate_ffmpeg_libs
try:
if os.name != 'posix':
@ -36,9 +30,6 @@ try:
except ImportError:
print('colorama import failed, no colored console output on windows...')
_WINDOWS = os.name == 'nt'
COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 65424
# ------------------------------------------------------------------------------
# main functions
@ -49,105 +40,15 @@ def main():
pipe ffmpeg pre-process to final ffmpeg post-process,
or play with ffplay
"""
year = get_date(False).split('-')[0]
overlay = []
ff_pre_settings = [
'-pix_fmt', 'yuv420p', '-r', str(_pre_comp.fps),
'-c:v', 'mpeg2video', '-intra',
'-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)
] + pre_audio_codec() + ['-f', 'mpegts', '-']
for output in os.listdir('ffplayout/output'):
if os.path.isfile(os.path.join('ffplayout/output', output)) \
and output != '__init__.py':
mode = os.path.splitext(output)[0]
if mode == _playout.mode:
output = locate('ffplayout.output.{}.output'.format(mode))
if _text.add_text:
messenger.info('Using drawtext node, listening on address: {}'.format(
_text.address
))
overlay = [
'-vf',
"null,zmq=b=tcp\\\\://'{}',drawtext=text='':fontfile='{}'".format(
_text.address.replace(':', '\\:'), _text.fontfile)
]
try:
if _playout.preview or stdin_args.desktop:
# preview playout to player
_ff.encoder = Popen([
'ffplay', '-hide_banner', '-nostats', '-i', 'pipe:0'
] + overlay, stderr=PIPE, stdin=PIPE, stdout=None)
else:
_ff.encoder = Popen([
'ffmpeg', '-v', _log.ff_level.lower(), '-hide_banner',
'-nostats', '-re', '-thread_queue_size', '256', '-i', 'pipe:0'
] + overlay + [
'-metadata', 'service_name=' + _playout.name,
'-metadata', 'service_provider=' + _playout.provider,
'-metadata', 'year={}'.format(year)
] + _playout.post_comp_param + [_playout.out_addr],
stdin=PIPE, stderr=PIPE)
enc_err_thread = Thread(target=ffmpeg_stderr_reader,
args=(_ff.encoder.stderr, False))
enc_err_thread.daemon = True
enc_err_thread.start()
if _playlist.mode and not stdin_args.folder:
watcher = None
get_source = GetSourceFromPlaylist()
else:
messenger.info('Start folder mode')
media = MediaStore()
watcher = MediaWatcher(media)
get_source = GetSourceFromFolder(media)
try:
for src_cmd in get_source.next():
messenger.debug('src_cmd: "{}"'.format(src_cmd))
if src_cmd[0] == '-i':
current_file = src_cmd[1]
else:
current_file = src_cmd[3]
messenger.info('Play: "{}"'.format(current_file))
with Popen([
'ffmpeg', '-v', _log.ff_level.lower(), '-hide_banner',
'-nostats'] + src_cmd + ff_pre_settings,
stdout=PIPE, stderr=PIPE) as _ff.decoder:
dec_err_thread = Thread(target=ffmpeg_stderr_reader,
args=(_ff.decoder.stderr, True))
dec_err_thread.daemon = True
dec_err_thread.start()
while True:
buf = _ff.decoder.stdout.read(COPY_BUFSIZE)
if not buf:
break
_ff.encoder.stdin.write(buf)
except BrokenPipeError:
messenger.error('Broken Pipe!')
terminate_processes(watcher)
except SystemExit:
messenger.info('Got close command')
terminate_processes(watcher)
except KeyboardInterrupt:
messenger.warning('Program terminated')
terminate_processes(watcher)
# close encoder when nothing is to do anymore
if _ff.encoder.poll() is None:
_ff.encoder.terminate()
finally:
if _ff.encoder.poll() is None:
_ff.encoder.terminate()
_ff.encoder.wait()
output()
if __name__ == '__main__':

View File

@ -31,16 +31,19 @@ logging:
log_level: "DEBUG"
ffmpeg_level: "ERROR"
pre_compress:
helptext: Settings for the pre-compression. All clips get prepared in that way,
so the input for the final compression is unique. 'aspect' must be a float
number. 'logo' is only used if the path exist. 'logo_scale' scale the logo to
target size, leave it blank when no scaling is needed, format is 'number:number',
for example '100:-1' for proportional scaling. With 'logo_opacity' logo can
become 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. [Output is
always progressive!]
processing:
helptext: Default processing, for all clips that they get prepared in that way,
so the output is unique. 'aspect' must be a float number. 'logo' is only used
if the path exist. 'logo_scale' scale the logo to target size, leave it blank
when no scaling is needed, format is 'number:number', for example '100:-1'
for proportional scaling. With 'logo_opacity' logo can become 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. 'output_count' sets the outputs for
the filtering, > 1 gives the option to use the same filters for multiple outputs.
This outputs can be taken in 'ffmpeg_param', names will be vout2, vout3;
aout2, aout2 etc.'use_realtime' is realtime filter, it works not in all scenarios,
but for example is necessary for hls output.
width: 1024
height: 576
aspect: 1.778
@ -54,9 +57,11 @@ pre_compress:
loud_I: -18
loud_TP: -1.5
loud_LRA: 11
output_count: 1
use_realtime: false
playlist:
helptext: Set 'playlist_mode' to 'False' if you want to play clips from the [STORAGE]
helptext: 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
@ -70,9 +75,8 @@ playlist:
storage:
helptext: Play ordered or ramdomly files from path. '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.
the end to reach 24 hours, it will loop when is necessary. 'extensions' search
only files with this extension. Set 'shuffle' to 'True' to pick files randomly.
path: "/mediaStorage"
filler_clip: "/mediaStorage/filler/filler.mp4"
extensions:
@ -84,18 +88,24 @@ text:
helptext: Overlay text in combination with libzmq for remote text manipulation.
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.
'over_pre' if True text will be overlay in pre processing. Continue same text
over multiple files is in that mode not possible.
add_text: False
over_pre: False
bind_address: "127.0.0.1:5555"
fontfile: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
out:
helptext: 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.
preview: False
helptext: The final playout compression. Set the settings to your needs.
'mode' has the standard options 'desktop', 'hls', 'stream'. Self made outputs
can be define, by adding script in output folder with an 'output' function inside.
'stream_output' is for streaming output, two ffmpeg instances are fired up, for
pre- and post-processing. 'hls_output' is for direct output to hls playlist,
without pre- and post-processing, mode must be 'hls'.
mode: 'stream'
service_name: "Live Stream"
service_provider: "example.org"
post_ffmpeg_param: >-
ffmpeg_param: >-
-c:v libx264
-crf 23
-x264-params keyint=50:min-keyint=25:scenecut=-1
@ -107,6 +117,14 @@ out:
-c:a aac
-ar 44100
-b:a 128k
stream_output: >-
-flags +global_header
-f flv
out_addr: "rtmp://localhost/live/stream"
-f flv "rtmp://localhost/live/stream"
hls_output: >-
-flags +cgop
-f hls
-hls_time 6
-hls_list_size 600
-hls_delete_threshold 30
-hls_flags append_list+delete_segments+omit_endlist+program_date_time
/var/www/srs/live/stream.m3u8

View File

@ -21,13 +21,25 @@ import math
import os
import re
from .utils import _pre_comp
from .utils import _global, _pre, _text
# ------------------------------------------------------------------------------
# building filters,
# when is needed add individuell filters to match output format
# ------------------------------------------------------------------------------
def text_filter():
filter_chain = []
if _text.add_text and _text.over_pre:
filter_chain = [
"null,zmq=b=tcp\\\\://'{}',drawtext=text='':fontfile='{}'".format(
_text.address.replace(':', '\\:'), _text.fontfile)]
return filter_chain
def deinterlace_filter(probe):
"""
when material is interlaced,
@ -50,15 +62,15 @@ def pad_filter(probe):
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:
_pre.aspect, abs_tol=0.03):
if probe.video[0]['aspect'] < _pre.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:
'pad=ih*{}/{}/sar:ih:(ow-iw)/2:(oh-ih)/2'.format(_pre.w,
_pre.h))
elif probe.video[0]['aspect'] > _pre.aspect:
filter_chain.append(
'pad=iw:iw*{}/{}/sar:(ow-iw)/2:(oh-ih)/2'.format(_pre_comp.h,
_pre_comp.w))
'pad=iw:iw*{}/{}/sar:(ow-iw)/2:(oh-ih)/2'.format(_pre.h,
_pre.w))
return filter_chain
@ -69,8 +81,8 @@ def fps_filter(probe):
"""
filter_chain = []
if probe.video[0]['fps'] != _pre_comp.fps:
filter_chain.append('fps={}'.format(_pre_comp.fps))
if probe.video[0]['fps'] != _pre.fps:
filter_chain.append('fps={}'.format(_pre.fps))
return filter_chain
@ -82,13 +94,13 @@ def scale_filter(probe):
"""
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 int(probe.video[0]['width']) != _pre.w or \
int(probe.video[0]['height']) != _pre.h:
filter_chain.append('scale={}:{}'.format(_pre.w, _pre.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))
_pre.aspect, abs_tol=0.03):
filter_chain.append('setdar=dar={}'.format(_pre.aspect))
return filter_chain
@ -115,27 +127,27 @@ def overlay_filter(duration, ad, ad_last, ad_next):
when ad is comming next fade logo out,
when clip before was an ad fade logo in
"""
logo_filter = '[v]null[logo]'
logo_filter = '[v]null'
scale_filter = ''
if _pre_comp.add_logo and os.path.isfile(_pre_comp.logo) and not ad:
if _pre.add_logo and os.path.isfile(_pre.logo) and not ad:
logo_chain = []
if _pre_comp.logo_scale and \
re.match(r'\d+:-?\d+', _pre_comp.logo_scale):
scale_filter = 'scale={},'.format(_pre_comp.logo_scale)
if _pre.logo_scale and \
re.match(r'\d+:-?\d+', _pre.logo_scale):
scale_filter = 'scale={},'.format(_pre.logo_scale)
logo_extras = 'format=rgba,{}colorchannelmixer=aa={}'.format(
scale_filter, _pre_comp.logo_opacity)
scale_filter, _pre.logo_opacity)
loop = 'loop=loop=-1:size=1:start=0'
logo_chain.append(
'movie={},{},{}'.format(_pre_comp.logo, loop, logo_extras))
'movie={},{},{}'.format(_pre.logo, loop, logo_extras))
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)
logo_filter = '{}[l];[v][l]{}:shortest=1'.format(
','.join(logo_chain), _pre.logo_filter)
return logo_filter
@ -161,9 +173,9 @@ def add_loudnorm(probe):
"""
loud_filter = []
if probe.audio and _pre_comp.add_loudnorm:
if probe.audio and _pre.add_loudnorm:
loud_filter = [('loudnorm=I={}:TP={}:LRA={}').format(
_pre_comp.loud_i, _pre_comp.loud_tp, _pre_comp.loud_lra)]
_pre.loud_i, _pre.loud_tp, _pre.loud_lra)]
return loud_filter
@ -175,7 +187,7 @@ def extend_audio(probe, duration):
pad_filter = []
if probe.audio and 'duration' in probe.audio[0] and \
duration > float(probe.audio[0]['duration']) + 0.3:
duration > float(probe.audio[0]['duration']) + 0.1:
pad_filter.append('apad=whole_dur={}'.format(duration))
return pad_filter
@ -189,25 +201,61 @@ def extend_video(probe, duration, target_duration):
if 'duration' in probe.video[0] and \
target_duration < duration > float(
probe.video[0]['duration']) + 0.3:
probe.video[0]['duration']) + 0.1:
pad_filter.append('tpad=stop_mode=add:stop_duration={}'.format(
duration - float(probe.video[0]['duration'])))
return pad_filter
def realtime_filter(duration, track=''):
speed_filter = ''
if _pre.realtime:
speed_filter = ',{}realtime=speed=1'.format(track)
if _global.time_delta < 0:
speed = duration / (duration + _global.time_delta)
if speed < 1.1:
speed_filter = ',{}realtime=speed={}'.format(track, speed)
return speed_filter
def split_filter(filter_type):
map_node = []
filter_prefix = ''
_filter = ''
if filter_type == 'a':
filter_prefix = 'a'
if _pre.output_count > 1:
for num in range(_pre.output_count):
map_node.append('[{}out{}]'.format(filter_type, num + 1))
_filter = ',{}split={}{}'.format(filter_prefix, _pre.output_count,
''.join(map_node))
else:
_filter = '[{}out1]'.format(filter_type)
return _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 += text_filter()
video_chain += deinterlace_filter(probe)
video_chain += pad_filter(probe)
video_chain += fps_filter(probe)
@ -229,17 +277,19 @@ def build_filtergraph(duration, seek, out, ad, ad_last, ad_next, probe, msg):
video_filter = 'null[v]'
logo_filter = overlay_filter(out - seek, ad, ad_last, ad_next)
v_speed = realtime_filter(out - seek)
v_split = split_filter('v')
video_map = ['-map', '[vout1]']
video_filter = [
'-filter_complex', '[0:v]{};{}'.format(
video_filter, logo_filter)]
'-filter_complex', '[0:v]{};{}{}{}'.format(
video_filter, logo_filter, v_speed, v_split)]
if audio_chain:
audio_filter = [
'-filter_complex', '{}[a]'.format(','.join(audio_chain))]
audio_map = ['-map', '[a]']
else:
audio_filter = []
audio_map = ['-map', '0:a']
a_speed = realtime_filter(out - seek, 'a')
a_split = split_filter('a')
audio_map = ['-map', '[aout1]']
audio_filter = [
'-filter_complex', '{}{}{}'.format(','.join(audio_chain),
a_speed, a_split)]
if probe.video[0]:
return video_filter + audio_filter + video_map + audio_map

View File

104
ffplayout/output/desktop.py Normal file
View File

@ -0,0 +1,104 @@
import os
from subprocess import PIPE, Popen
from threading import Thread
from ffplayout.folder import GetSourceFromFolder, MediaStore, MediaWatcher
from ffplayout.playlist import GetSourceFromPlaylist
from ffplayout.utils import (_ff, _log, _playlist, _pre, _text,
ffmpeg_stderr_reader, messenger, pre_audio_codec,
stdin_args, terminate_processes)
_WINDOWS = os.name == 'nt'
COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 65424
def output():
"""
this output is for playing on desktop with ffplay
"""
overlay = []
ff_pre_settings = [
'-pix_fmt', 'yuv420p', '-r', str(_pre.fps),
'-c:v', 'mpeg2video', '-intra',
'-b:v', '{}k'.format(_pre.v_bitrate),
'-minrate', '{}k'.format(_pre.v_bitrate),
'-maxrate', '{}k'.format(_pre.v_bitrate),
'-bufsize', '{}k'.format(_pre.v_bufsize)
] + pre_audio_codec() + ['-f', 'mpegts', '-']
if _text.add_text and not _text.over_pre:
messenger.info('Using drawtext node, listening on address: {}'.format(
_text.address
))
overlay = [
'-vf',
"null,zmq=b=tcp\\\\://'{}',drawtext=text='':fontfile='{}'".format(
_text.address.replace(':', '\\:'), _text.fontfile)
]
try:
_ff.encoder = Popen([
'ffplay', '-hide_banner', '-nostats', '-i', 'pipe:0'
] + overlay, stderr=PIPE, stdin=PIPE, stdout=None)
enc_err_thread = Thread(target=ffmpeg_stderr_reader,
args=(_ff.encoder.stderr, False))
enc_err_thread.daemon = True
enc_err_thread.start()
if _playlist.mode and not stdin_args.folder:
watcher = None
get_source = GetSourceFromPlaylist()
else:
messenger.info('Start folder mode')
media = MediaStore()
watcher = MediaWatcher(media)
get_source = GetSourceFromFolder(media)
try:
for src_cmd in get_source.next():
messenger.debug('src_cmd: "{}"'.format(src_cmd))
if src_cmd[0] == '-i':
current_file = src_cmd[1]
else:
current_file = src_cmd[3]
messenger.info('Play: "{}"'.format(current_file))
with Popen([
'ffmpeg', '-v', _log.ff_level.lower(), '-hide_banner',
'-nostats'] + src_cmd + ff_pre_settings,
stdout=PIPE, stderr=PIPE) as _ff.decoder:
dec_err_thread = Thread(target=ffmpeg_stderr_reader,
args=(_ff.decoder.stderr, True))
dec_err_thread.daemon = True
dec_err_thread.start()
while True:
buf = _ff.decoder.stdout.read(COPY_BUFSIZE)
if not buf:
break
_ff.encoder.stdin.write(buf)
except BrokenPipeError:
messenger.error('Broken Pipe!')
terminate_processes(watcher)
except SystemExit:
messenger.info('Got close command')
terminate_processes(watcher)
except KeyboardInterrupt:
messenger.warning('Program terminated')
terminate_processes(watcher)
# close encoder when nothing is to do anymore
if _ff.encoder.poll() is None:
_ff.encoder.terminate()
finally:
if _ff.encoder.poll() is None:
_ff.encoder.terminate()
_ff.encoder.wait()

105
ffplayout/output/hls.py Normal file
View File

@ -0,0 +1,105 @@
import os
import re
from glob import iglob
from subprocess import PIPE, Popen
from threading import Thread
from ffplayout.folder import GetSourceFromFolder, MediaStore, MediaWatcher
from ffplayout.playlist import GetSourceFromPlaylist
from ffplayout.utils import (_ff, _log, _playlist, _playout,
ffmpeg_stderr_reader, get_date, messenger,
stdin_args, terminate_processes)
def clean_ts():
"""
this function get all *.m3u8 playlists from config,
read lines from them until it founds first *.ts file,
then it checks if files on harddrive are older then this first *.ts
and if so delete them
"""
playlists = [p for p in _playout.hls_output if 'm3u8' in p]
for playlist in playlists:
messenger.debug('cleanup *.ts files from: "{}"'.format(playlist))
test_num = 0
hls_path = os.path.dirname(playlist)
with open(playlist, 'r') as m3u8:
for line in m3u8:
if '.ts' in line:
test_num = int(re.findall(r'(\d+).ts', line)[0])
break
for ts_file in iglob(os.path.join(hls_path, '*.ts')):
ts_num = int(re.findall(r'(\d+).ts', ts_file)[0])
if test_num > ts_num:
os.remove(ts_file)
def output():
"""
this output is hls output, no preprocess is needed.
"""
year = get_date(False).split('-')[0]
try:
if _playlist.mode and not stdin_args.folder:
watcher = None
get_source = GetSourceFromPlaylist()
else:
messenger.info('Start folder mode')
media = MediaStore()
watcher = MediaWatcher(media)
get_source = GetSourceFromFolder(media)
try:
for src_cmd in get_source.next():
messenger.debug('src_cmd: "{}"'.format(src_cmd))
if src_cmd[0] == '-i':
current_file = src_cmd[1]
else:
current_file = src_cmd[3]
messenger.info('Play: "{}"'.format(current_file))
cmd = [
'ffmpeg', '-v', _log.ff_level.lower(), '-hide_banner',
'-nostats'
] + src_cmd + [
'-metadata', 'service_name=' + _playout.name,
'-metadata', 'service_provider=' + _playout.provider,
'-metadata', 'year={}'.format(year)
] + _playout.ffmpeg_param + _playout.hls_output
_ff.encoder = Popen(cmd, stdin=PIPE, stderr=PIPE)
stderr_reader_thread = Thread(target=ffmpeg_stderr_reader,
args=(_ff.encoder.stderr, False))
stderr_reader_thread.daemon = True
stderr_reader_thread.start()
stderr_reader_thread.join()
ts_cleaning_thread = Thread(target=clean_ts)
ts_cleaning_thread.daemon = True
ts_cleaning_thread.start()
except BrokenPipeError:
messenger.error('Broken Pipe!')
terminate_processes(watcher)
except SystemExit:
messenger.info('Got close command')
terminate_processes(watcher)
except KeyboardInterrupt:
messenger.warning('Program terminated')
terminate_processes(watcher)
# close encoder when nothing is to do anymore
if _ff.encoder.poll() is None:
_ff.encoder.terminate()
finally:
if _ff.encoder.poll() is None:
_ff.encoder.terminate()
_ff.encoder.wait()

112
ffplayout/output/stream.py Normal file
View File

@ -0,0 +1,112 @@
import os
from subprocess import PIPE, Popen
from threading import Thread
from ffplayout.folder import GetSourceFromFolder, MediaStore, MediaWatcher
from ffplayout.playlist import GetSourceFromPlaylist
from ffplayout.utils import (_ff, _log, _playlist, _playout, _pre, _text,
ffmpeg_stderr_reader, get_date, messenger,
pre_audio_codec, stdin_args, terminate_processes)
_WINDOWS = os.name == 'nt'
COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 65424
def output():
"""
this output is for streaming to a target address,
like rtmp, rtp, svt, etc.
"""
year = get_date(False).split('-')[0]
overlay = []
ff_pre_settings = [
'-pix_fmt', 'yuv420p', '-r', str(_pre.fps),
'-c:v', 'mpeg2video', '-intra',
'-b:v', '{}k'.format(_pre.v_bitrate),
'-minrate', '{}k'.format(_pre.v_bitrate),
'-maxrate', '{}k'.format(_pre.v_bitrate),
'-bufsize', '{}k'.format(_pre.v_bufsize)
] + pre_audio_codec() + ['-f', 'mpegts', '-']
if _text.add_text and not _text.over_pre:
messenger.info('Using drawtext node, listening on address: {}'.format(
_text.address
))
overlay = [
'-vf',
"null,zmq=b=tcp\\\\://'{}',drawtext=text='':fontfile='{}'".format(
_text.address.replace(':', '\\:'), _text.fontfile)
]
try:
_ff.encoder = Popen([
'ffmpeg', '-v', _log.ff_level.lower(), '-hide_banner',
'-nostats', '-re', '-thread_queue_size', '256', '-i', 'pipe:0'
] + overlay + [
'-metadata', 'service_name=' + _playout.name,
'-metadata', 'service_provider=' + _playout.provider,
'-metadata', 'year={}'.format(year)
] + _playout.ffmpeg_param + _playout.stream_output,
stdin=PIPE, stderr=PIPE)
enc_err_thread = Thread(target=ffmpeg_stderr_reader,
args=(_ff.encoder.stderr, False))
enc_err_thread.daemon = True
enc_err_thread.start()
if _playlist.mode and not stdin_args.folder:
watcher = None
get_source = GetSourceFromPlaylist()
else:
messenger.info('Start folder mode')
media = MediaStore()
watcher = MediaWatcher(media)
get_source = GetSourceFromFolder(media)
try:
for src_cmd in get_source.next():
messenger.debug('src_cmd: "{}"'.format(src_cmd))
if src_cmd[0] == '-i':
current_file = src_cmd[1]
else:
current_file = src_cmd[3]
messenger.info('Play: "{}"'.format(current_file))
with Popen([
'ffmpeg', '-v', _log.ff_level.lower(), '-hide_banner',
'-nostats'] + src_cmd + ff_pre_settings,
stdout=PIPE, stderr=PIPE) as _ff.decoder:
dec_err_thread = Thread(target=ffmpeg_stderr_reader,
args=(_ff.decoder.stderr, True))
dec_err_thread.daemon = True
dec_err_thread.start()
while True:
buf = _ff.decoder.stdout.read(COPY_BUFSIZE)
if not buf:
break
_ff.encoder.stdin.write(buf)
except BrokenPipeError:
messenger.error('Broken Pipe!')
terminate_processes(watcher)
except SystemExit:
messenger.info('Got close command')
terminate_processes(watcher)
except KeyboardInterrupt:
messenger.warning('Program terminated')
terminate_processes(watcher)
# close encoder when nothing is to do anymore
if _ff.encoder.poll() is None:
_ff.encoder.terminate()
finally:
if _ff.encoder.poll() is None:
_ff.encoder.terminate()
_ff.encoder.wait()

View File

@ -224,7 +224,7 @@ class GetSourceFromPlaylist:
if self.clip_nodes is None:
self.eof_handling(
'No valid playlist:\n{}'.format(self.json_file), True, 300)
'No valid playlist:\n{}'.format(self.json_file), True, 30)
yield self.src_cmd + self.filtergraph
continue

View File

@ -113,7 +113,7 @@ def get_time(time_format):
_general = SimpleNamespace()
_mail = SimpleNamespace()
_log = SimpleNamespace()
_pre_comp = SimpleNamespace()
_pre = SimpleNamespace()
_playlist = SimpleNamespace()
_storage = SimpleNamespace()
_text = SimpleNamespace()
@ -121,6 +121,7 @@ _playout = SimpleNamespace()
_init = SimpleNamespace(load=True)
_ff = SimpleNamespace(decoder=None, encoder=None)
_global = SimpleNamespace(time_delta=0)
def str_to_sec(s):
@ -178,15 +179,16 @@ def load_config():
_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.logo_scale = cfg['pre_compress']['logo_scale']
_pre_comp.logo_filter = cfg['pre_compress']['logo_filter']
_pre_comp.logo_opacity = cfg['pre_compress']['logo_opacity']
_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']
_pre.add_logo = cfg['processing']['add_logo']
_pre.logo = cfg['processing']['logo']
_pre.logo_scale = cfg['processing']['logo_scale']
_pre.logo_filter = cfg['processing']['logo_filter']
_pre.logo_opacity = cfg['processing']['logo_opacity']
_pre.add_loudnorm = cfg['processing']['add_loudnorm']
_pre.loud_i = cfg['processing']['loud_I']
_pre.loud_tp = cfg['processing']['loud_TP']
_pre.loud_lra = cfg['processing']['loud_LRA']
_pre.output_count = cfg['processing']['output_count']
_playlist.mode = cfg['playlist']['playlist_mode']
_playlist.path = cfg['playlist']['path']
@ -199,6 +201,7 @@ def load_config():
_storage.shuffle = cfg['storage']['shuffle']
_text.add_text = cfg['text']['add_text']
_text.over_pre = cfg['text']['over_pre']
_text.address = cfg['text']['bind_address']
_text.fontfile = cfg['text']['fontfile']
@ -209,18 +212,20 @@ def load_config():
_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
_pre.w = cfg['processing']['width']
_pre.h = cfg['processing']['height']
_pre.aspect = cfg['processing']['aspect']
_pre.fps = cfg['processing']['fps']
_pre.v_bitrate = cfg['processing']['width'] * 50
_pre.v_bufsize = cfg['processing']['width'] * 50 / 2
_pre.realtime = cfg['processing']['use_realtime']
_playout.preview = cfg['out']['preview']
_playout.mode = cfg['out']['mode']
_playout.name = cfg['out']['service_name']
_playout.provider = cfg['out']['service_provider']
_playout.post_comp_param = cfg['out']['post_ffmpeg_param'].split(' ')
_playout.out_addr = cfg['out']['out_addr']
_playout.ffmpeg_param = cfg['out']['ffmpeg_param'].split(' ')
_playout.stream_output = cfg['out']['stream_output'].split(' ')
_playout.hls_output = cfg['out']['hls_output'].split(' ')
_init.load = False
@ -672,6 +677,11 @@ def check_sync(delta):
"""
check that we are in tolerance time
"""
if _playlist.mode and _playlist.start and _playlist.length:
# save time delta to global variable for syncing
_global.time_delta = delta
if _general.stop and abs(delta) > _general.threshold:
messenger.error(
'Sync tolerance value exceeded with {0:.2f} seconds,\n'
@ -782,7 +792,7 @@ def gen_dummy(duration):
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
color, _pre.w, _pre.h, duration, _pre.fps
),
'-f', 'lavfi', '-i', 'anoisesrc=d={}:c=pink:r=48000:a=0.05'.format(
duration)
@ -989,7 +999,7 @@ def pre_audio_codec():
s302m has higher quality, but is experimental
and works not well together with the loudnorm filter
"""
if _pre_comp.add_loudnorm:
if _pre.add_loudnorm:
acodec = 'libtwolame' if 'libtwolame' in FF_LIBS['libs'] else 'mp2'
return ['-c:a', acodec, '-b:a', '384k', '-ar', '48000', '-ac', '2']
else:

View File

@ -1,4 +1,4 @@
colorama==0.4.3
pathtools==0.1.2
PyYAML==5.3
watchdog==0.10.1
PyYAML==5.3.1
watchdog==0.10.3