add docstrings and clean code with pylint

This commit is contained in:
jb-alvarado 2021-03-26 14:43:21 +01:00
parent 22f971ddc8
commit 67be3afb3d
11 changed files with 226 additions and 76 deletions

0
__init__.py Normal file
View File

View File

@ -17,6 +17,10 @@
# ------------------------------------------------------------------------------
"""
This module is the starting program for running ffplayout engine.
"""
import os
from pydoc import locate

View File

@ -1,6 +1,11 @@
"""
cunstom audio filter, which get loaded automatically
"""
from ffplayout.utils import get_float, stdin_args
# pylint: disable=unused-argument
def filter_link(node):
"""
set audio volume
@ -8,3 +13,5 @@ def filter_link(node):
if stdin_args.volume and get_float(stdin_args.volume, False):
return f'volume={stdin_args.volume}'
return None

View File

@ -15,6 +15,11 @@
# ------------------------------------------------------------------------------
"""
This module prepare all ffmpeg filters.
This is mainly for unify clips to have a unique output.
"""
import math
import os
import re
@ -31,6 +36,9 @@ from ffplayout.utils import (is_advertisement, lower_third, messenger, pre,
def text_filter():
"""
add drawtext filter for lower thirds messages
"""
filter_chain = []
font = ''
@ -122,21 +130,21 @@ def fade_filter(duration, seek, out, track=''):
return filter_chain
def overlay_filter(duration, ad, ad_last, ad_next):
def overlay_filter(duration, advertisement, 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'
scale_filter = ''
scale = ''
if pre.add_logo and os.path.isfile(pre.logo) and not ad:
if pre.add_logo and os.path.isfile(pre.logo) and not advertisement:
logo_chain = []
if pre.logo_scale and \
re.match(r'\d+:-?\d+', pre.logo_scale):
scale_filter = f'scale={pre.logo_scale},'
logo_extras = (f'format=rgba,{scale_filter}'
scale = f'scale={pre.logo_scale},'
logo_extras = (f'format=rgba,{scale}'
f'colorchannelmixer=aa={pre.logo_opacity}')
loop = 'loop=loop=-1:size=1:start=0'
logo_chain.append(f'movie={pre.logo},{loop},{logo_extras}')
@ -182,30 +190,33 @@ def extend_audio(probe, duration):
"""
check audio duration, is it shorter then clip duration - pad it
"""
pad_filter = []
pad = []
if probe.audio and 'duration' in probe.audio[0] and \
duration > float(probe.audio[0]['duration']) + 0.1:
pad_filter.append(f'apad=whole_dur={duration}')
pad.append(f'apad=whole_dur={duration}')
return pad_filter
return pad
def extend_video(probe, duration, target_duration):
"""
check video duration, is it shorter then clip duration - pad it
"""
pad_filter = []
pad = []
vid_dur = probe.video[0].get('duration')
if vid_dur and target_duration < duration > float(vid_dur) + 0.1:
pad_filter.append(
pad.append(
f'tpad=stop_mode=add:stop_duration={duration - float(vid_dur)}')
return pad_filter
return pad
def realtime_filter(duration, track=''):
"""
this realtime filter is important for HLS output to stay in sync
"""
speed_filter = ''
if pre.realtime:
@ -221,6 +232,10 @@ def realtime_filter(duration, track=''):
def split_filter(filter_type):
"""
this filter splits the media input in multiple outputs,
to be able to have differnt streaming/HLS outputs
"""
map_node = []
prefix = ''
_filter = ''
@ -241,6 +256,9 @@ def split_filter(filter_type):
def custom_filter(filter_type, node):
"""
read custom filters from filters folder
"""
filter_dir = os.path.dirname(os.path.abspath(__file__))
filters = []
@ -260,7 +278,7 @@ def build_filtergraph(node, node_last, node_next):
build final filter graph, with video and audio chain
"""
ad = is_advertisement(node)
advertisement = is_advertisement(node)
ad_last = is_advertisement(node_last)
ad_next = is_advertisement(node_next)
@ -277,12 +295,12 @@ def build_filtergraph(node, node_last, node_next):
if probe and probe.video[0]:
custom_v_filter = custom_filter('v', node)
video_chain += text_filter()
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 += text_filter() \
+ deinterlace_filter(probe) \
+ pad_filter(probe) \
+ fps_filter(probe) \
+ scale_filter(probe) \
+ extend_video(probe, duration, out - seek)
if custom_v_filter:
video_chain += custom_v_filter
video_chain += fade_filter(duration, seek, out)
@ -292,9 +310,9 @@ def build_filtergraph(node, node_last, node_next):
if not audio_chain:
custom_a_filter = custom_filter('a', node)
audio_chain.append('[0:a]anull')
audio_chain += add_loudnorm(probe)
audio_chain += extend_audio(probe, out - seek)
audio_chain += ['[0:a]anull'] \
+ add_loudnorm(probe) \
+ extend_audio(probe, out - seek)
if custom_a_filter:
audio_chain += custom_a_filter
audio_chain += fade_filter(duration, seek, out, 'a')
@ -304,7 +322,7 @@ def build_filtergraph(node, node_last, node_next):
else:
video_filter = 'null[v]'
logo_filter = overlay_filter(out - seek, ad, ad_last, ad_next)
logo_filter = overlay_filter(out - seek, advertisement, ad_last, ad_next)
v_speed = realtime_filter(out - seek)
v_split = split_filter('v')
video_map = ['-map', '[vout1]']
@ -320,5 +338,5 @@ def build_filtergraph(node, node_last, node_next):
if probe and probe.video[0]:
return video_filter + audio_filter + video_map + audio_map
else:
return video_filter + video_map + ['-map', '1:a']
return video_filter + video_map + ['-map', '1:a']

View File

@ -1,3 +1,7 @@
"""
cunstom video filter, which get loaded automatically
"""
import os
import re
@ -19,3 +23,5 @@ def filter_link(node):
if lower_third.text_from_filename:
escape = title.replace("'", "'\\\\\\''").replace("%", "\\\\\\%")
return f"drawtext=text='{escape}':{lower_third.style}{font}"
return None

View File

@ -15,6 +15,10 @@
# ------------------------------------------------------------------------------
"""
This module handles folder reading. It monitor file adding, deleting or moving
"""
import glob
import os
import random
@ -49,31 +53,47 @@ class MediaStore:
self.fill()
def fill(self):
"""
fill media list
"""
for ext in storage.extensions:
self.store.extend(
glob.glob(os.path.join(self.folder, '**', f'*{ext}'),
recursive=True))
def sort_or_radomize(self):
"""
sort or randomize file list
"""
if storage.shuffle:
self.rand()
else:
self.sort()
def add(self, file):
"""
add new file to media list
"""
self.store.append(file)
self.sort_or_radomize()
def remove(self, file):
"""
remove file from media list
"""
self.store.remove(file)
self.sort_or_radomize()
def sort(self):
# sort list for sorted playing
"""
sort list for sorted playing
"""
self.store = sorted(self.store)
def rand(self):
# randomize list for playing
"""
randomize list for playing
"""
random.shuffle(self.store)
@ -100,7 +120,9 @@ class MediaWatcher:
self.observer.start()
def on_created(self, event):
# add file to media list only if it is completely copied
"""
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)
@ -111,6 +133,9 @@ class MediaWatcher:
messenger.info(f'Add file to media list: "{event.src_path}"')
def on_moved(self, event):
"""
operation when file on storage are moved
"""
self._media.remove(event.src_path)
self._media.add(event.dest_path)
@ -121,6 +146,9 @@ class MediaWatcher:
ff_proc.decoder.terminate()
def on_deleted(self, event):
"""
operation when file on storage are deleted
"""
self._media.remove(event.src_path)
messenger.info(f'Remove file from media list: "{event.src_path}"')
@ -129,6 +157,9 @@ class MediaWatcher:
ff_proc.decoder.terminate()
def stop(self):
"""
stop monitoring storage
"""
self.observer.stop()
self.observer.join()
@ -150,6 +181,9 @@ class GetSourceFromFolder:
self.node_next = None
def next(self):
"""
generator for getting always a new file
"""
while True:
while self.index < len(self._media.store):
if self.node_next:
@ -188,5 +222,5 @@ class GetSourceFromFolder:
yield self.node
self.index += 1
self.node_last = deepcopy(self.node)
else:
self.index = 0
self.index = 0

View File

@ -15,6 +15,10 @@
# ------------------------------------------------------------------------------
"""
This module plays the compressed output directly on the desktop.
"""
import os
from subprocess import PIPE, Popen
from threading import Thread

View File

@ -15,6 +15,10 @@
# ------------------------------------------------------------------------------
"""
This module write the files compression directly to a hls (m3u8) playlist.
"""
import os
import re
from glob import iglob

View File

@ -15,6 +15,10 @@
# ------------------------------------------------------------------------------
"""
This module streams the files out to a remote target.
"""
import os
from subprocess import PIPE, Popen
from threading import Thread

View File

@ -15,6 +15,12 @@
# ------------------------------------------------------------------------------
"""
This module handles playlists, it can be aware of time syncing.
Empty, missing or any other playlist related failure should be compensate.
Missing clips will be replaced by a dummy clip.
"""
import os
import socket
import time
@ -50,11 +56,10 @@ def handle_list_init(node):
node['out'] = out
node['seek'] = seek
return src_or_dummy(node)
else:
messenger.warning(
f'Clip less then a second, skip:\n{node["source"]}')
return None
messenger.warning(f'Clip less then a second, skip:\n{node["source"]}')
return None
def handle_list_end(duration, node):
@ -191,6 +196,11 @@ def validate_thread(clip_nodes, list_date):
class PlaylistReader:
"""
Class which read playlists, it checks if playlist got modified,
when yes it reads the file new, when not it used the cached one
"""
def __init__(self, list_date, last_mod_time):
self.list_date = list_date
self.last_mod_time = last_mod_time
@ -198,13 +208,16 @@ class PlaylistReader:
self.error = False
def read(self):
"""
read and process playlist
"""
self.nodes = {'program': []}
self.error = False
if stdin_args.playlist:
json_file = stdin_args.playlist
else:
year, month, day = self.list_date.split('-')
year, month, _ = self.list_date.split('-')
json_file = os.path.join(playlist.path, year, month,
f'{self.list_date}.json')
@ -231,8 +244,8 @@ class PlaylistReader:
# check last modification time from playlist
mod_time = os.path.getmtime(json_file)
if mod_time > self.last_mod_time:
with open(json_file, 'r', encoding='utf-8') as f:
self.nodes = valid_json(f)
with open(json_file, 'r', encoding='utf-8') as playlist_file:
self.nodes = valid_json(playlist_file)
self.last_mod_time = mod_time
messenger.info('Open: ' + json_file)
@ -252,6 +265,7 @@ class GetSourceFromPlaylist:
def __init__(self):
self.prev_date = get_date(True)
self.list_start = playlist.start
self.last_time = 0
self.first = True
self.last = False
self.clip_nodes = []
@ -316,13 +330,13 @@ class GetSourceFromPlaylist:
if self.last:
seek = self.node['seek'] if self.node['seek'] > 0 else 0
delta, total_delta = get_delta(begin)
delta, _ = get_delta(begin)
delta += seek + 1
next_start = begin - playlist.start + out + delta
else:
delta, total_delta = get_delta(begin)
delta, _ = get_delta(begin)
next_start = begin - playlist.start + sync_op.threshold + delta
if playlist.length and next_start >= playlist.length:
@ -456,8 +470,8 @@ class GetSourceFromPlaylist:
# when we reach playlist end, stop script
messenger.info('Playlist reached end!')
return None
else:
self.eof_handling(begin)
self.eof_handling(begin)
if self.node:
yield self.node

View File

@ -15,6 +15,10 @@
# ------------------------------------------------------------------------------
"""
This module contains default variables and helper functions
"""
import json
import logging
import math
@ -285,6 +289,9 @@ class CustomFormatter(logging.Formatter):
}
def format_message(self, msg):
"""
match strings with regex and add different color tags to it
"""
if '"' in msg:
msg = re.sub('(".*?")', self.cyan + r'\1' + self.reset, msg)
elif '[decoder]' in msg:
@ -302,6 +309,9 @@ class CustomFormatter(logging.Formatter):
return msg
def format(self, record):
"""
override logging format
"""
record.msg = self.format_message(record.getMessage())
log_fmt = self.FORMATS.get(record.levelno)
formatter = logging.Formatter(log_fmt)
@ -378,13 +388,19 @@ class Mailer:
self.temp_msg = os.path.join(tempfile.gettempdir(), 'ffplayout.txt')
def current_time(self):
"""
set sending time
"""
self.time = get_time(None)
def send_mail(self, msg):
"""
send emails to specified recipients
"""
if mail.recip:
# write message to temp file for rate limit
with open(self.temp_msg, 'w+') as f:
f.write(msg)
with open(self.temp_msg, 'w+') as msg_file:
msg_file.write(msg)
self.current_time()
@ -416,12 +432,14 @@ class Mailer:
server.quit()
def check_if_new(self, msg):
# send messege only when is new or the rate_limit is pass
"""
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()
with open(self.temp_msg, 'r', encoding='utf-8') as msg_file:
last_msg = msg_file.read()
if msg != last_msg \
or get_time('stamp') - mod_time > self.rate_limit:
@ -430,14 +448,23 @@ class Mailer:
self.send_mail(msg)
def info(self, msg):
"""
send emails with level INFO, WARNING and ERROR
"""
if self.level in ['INFO']:
self.check_if_new(msg)
def warning(self, msg):
"""
send emails with level WARNING and ERROR
"""
if self.level in ['INFO', 'WARNING']:
self.check_if_new(msg)
def error(self, msg):
"""
send emails with level ERROR
"""
if self.level in ['INFO', 'WARNING', 'ERROR']:
self.check_if_new(msg)
@ -451,18 +478,31 @@ class Messenger:
def __init__(self):
self._mailer = Mailer()
# pylint: disable=no-self-use
def debug(self, msg):
"""
log debugging messages
"""
playout_logger.debug(msg.replace('\n', ' '))
def info(self, msg):
"""
log and mail info messages
"""
playout_logger.info(msg.replace('\n', ' '))
self._mailer.info(msg)
def warning(self, msg):
"""
log and mail warning messages
"""
playout_logger.warning(msg.replace('\n', ' '))
self._mailer.warning(msg)
def error(self, msg):
"""
log and mail error messages
"""
playout_logger.error(msg.replace('\n', ' '))
self._mailer.error(msg)
@ -521,6 +561,9 @@ FF_LIBS = ffmpeg_libs()
def validate_ffmpeg_libs():
"""
check if ffmpeg contains some basic libs
"""
if 'libx264' not in FF_LIBS['libs']:
playout_logger.error('ffmpeg contains no libx264!')
if 'libfdk-aac' not in FF_LIBS['libs']:
@ -542,8 +585,18 @@ class MediaProbe:
get infos about media file, similare to mediainfo
"""
def load(self, file):
def __init__(self):
self.remote_source = ['http', 'https', 'ftp', 'smb', 'sftp']
self.src = None
self.format = None
self.audio = []
self.video = []
self.is_remote = False
def load(self, file):
"""
load media file with ffprobe and get infos out of it
"""
self.src = file
self.format = None
self.audio = []
@ -582,14 +635,14 @@ class MediaProbe:
if stream['codec_type'] == 'video':
if stream.get('display_aspect_ratio'):
w, h = stream['display_aspect_ratio'].split(':')
stream['aspect'] = float(w) / float(h)
width, heigth = stream['display_aspect_ratio'].split(':')
stream['aspect'] = float(width) / float(heigth)
else:
stream['aspect'] = float(
stream['width']) / float(stream['height'])
a, b = stream['r_frame_rate'].split('/')
stream['fps'] = float(a) / float(b)
rate, factor = stream['r_frame_rate'].split('/')
stream['fps'] = float(rate) / float(factor)
self.video.append(stream)
@ -602,9 +655,10 @@ def handle_sigterm(sig, frame):
"""
handler for ctrl+c signal
"""
raise(SystemExit)
raise SystemExit
# pylint: disable=unused-argument
def handle_sighub(sig, frame):
"""
handling SIGHUB signal for reload configuration
@ -694,14 +748,15 @@ def get_date(seek_day, next_start=0):
when seek_day is set:
check if playlist date must be from yesterday
"""
d = date.today()
date_ = date.today()
if seek_day and playlist.start > get_time('full_sec'):
return (d - timedelta(1)).strftime('%Y-%m-%d')
elif playlist.start == 0 and next_start >= 86400:
return (d + timedelta(1)).strftime('%Y-%m-%d')
else:
return d.strftime('%Y-%m-%d')
return (date_ - timedelta(1)).strftime('%Y-%m-%d')
if playlist.start == 0 and next_start >= 86400:
return (date_ + timedelta(1)).strftime('%Y-%m-%d')
return date_.strftime('%Y-%m-%d')
def get_float(value, default=False):
@ -721,6 +776,8 @@ def is_advertisement(node):
if node and node.get('category') == 'advertisement':
return True
return False
def valid_json(file):
"""
@ -804,35 +861,33 @@ def gen_filler(node):
if probe.format:
if probe.format.get('duration'):
filler_duration = float(probe.format['duration'])
if filler_duration > duration:
filler_dur = float(probe.format['duration'])
if filler_dur > duration:
# cut filler
messenger.info(
f'Generate filler with {duration:.2f} seconds')
node['source'] = storage.filler
node['src_cmd'] = ['-i', storage.filler] + set_length(
filler_duration, 0, duration)
filler_dur, 0, duration)
return node
else:
# loop file n times
node['src_cmd'] = loop_input(storage.filler, filler_duration,
duration)
return node
else:
messenger.error("Can't get filler length, generate dummy!")
dummy = gen_dummy(duration)
node['source'] = dummy[3]
node['src_cmd'] = dummy
# loop file n times
node['src_cmd'] = loop_input(storage.filler, filler_dur, duration)
return node
else:
# when no filler is set, generate a dummy
messenger.warning('No filler is set!')
messenger.error("Can't get filler length, generate dummy!")
dummy = gen_dummy(duration)
node['source'] = dummy[3]
node['src_cmd'] = dummy
return node
# when no filler is set, generate a dummy
messenger.warning('No filler is set!')
dummy = gen_dummy(duration)
node['source'] = dummy[3]
node['src_cmd'] = dummy
return node
def src_or_dummy(node):
"""
@ -862,7 +917,7 @@ def src_or_dummy(node):
] + set_length(node['duration'], node['seek'],
node['out'] - node['seek'])
else:
# FIXME: when list starts with looped clip,
# when list starts with looped clip,
# the logo length will be wrong
node['src_cmd'] = loop_input(node['source'], node['duration'],
node['out'])
@ -884,5 +939,5 @@ def pre_audio_codec():
"""
if pre.add_loudnorm:
return ['-c:a', 'mp2', '-b:a', '384k', '-ar', '48000', '-ac', '2']
else:
return ['-c:a', 's302m', '-strict', '-2', '-ar', '48000', '-ac', '2']
return ['-c:a', 's302m', '-strict', '-2', '-ar', '48000', '-ac', '2']