diff --git a/ffplayout.py b/ffplayout.py index ef63ddac..1e5f5f9d 100755 --- a/ffplayout.py +++ b/ffplayout.py @@ -21,7 +21,7 @@ import os from pydoc import locate -from ffplayout.utils import PLAYOUT, STDIN_ARGS, validate_ffmpeg_libs +from ffplayout.utils import playout, stdin_args, validate_ffmpeg_libs try: if os.name != 'posix': @@ -40,8 +40,8 @@ def main(): play out depending on output mode """ - if STDIN_ARGS.mode: - output = locate(f'ffplayout.output.{STDIN_ARGS.mode}.output') + if stdin_args.mode: + output = locate(f'ffplayout.output.{stdin_args.mode}.output') output() else: @@ -53,7 +53,7 @@ def main(): and output != '__init__.py': mode = os.path.splitext(output)[0] - if mode == PLAYOUT.mode: + if mode == playout.mode: output = locate(f'ffplayout.output.{mode}.output') output() diff --git a/ffplayout/filters/a_volume.py b/ffplayout/filters/a_volume.py index a2b6f23f..82b31e94 100644 --- a/ffplayout/filters/a_volume.py +++ b/ffplayout/filters/a_volume.py @@ -1,4 +1,4 @@ -from ffplayout.utils import STDIN_ARGS, get_float +from ffplayout.utils import get_float, stdin_args def filter_link(node): @@ -6,5 +6,5 @@ def filter_link(node): set audio volume """ - if STDIN_ARGS.volume and get_float(STDIN_ARGS.volume, False): - return f'volume={STDIN_ARGS.volume}' + if stdin_args.volume and get_float(stdin_args.volume, False): + return f'volume={stdin_args.volume}' diff --git a/ffplayout/filters/default.py b/ffplayout/filters/default.py index 11942eec..c8550092 100644 --- a/ffplayout/filters/default.py +++ b/ffplayout/filters/default.py @@ -23,7 +23,8 @@ import re from glob import glob from pydoc import locate -from ffplayout.utils import GENERAL, PRE, TEXT, is_advertisement, messenger +from ffplayout.utils import (is_advertisement, lower_third, messenger, pre, + sync_op) # ------------------------------------------------------------------------------ # building filters, @@ -35,12 +36,12 @@ def text_filter(): filter_chain = [] font = '' - if TEXT.add_text and TEXT.over_pre: - if TEXT.fontfile and os.path.isfile(TEXT.fontfile): - font = f":fontfile='{TEXT.fontfile}'" + if lower_third.add_text and lower_third.over_pre: + if lower_third.fontfile and os.path.isfile(lower_third.fontfile): + font = f":fontfile='{lower_third.fontfile}'" filter_chain = [ "null,zmq=b=tcp\\\\://'{}',drawtext=text=''{}".format( - TEXT.address.replace(':', '\\:'), font)] + lower_third.address.replace(':', '\\:'), font)] return filter_chain @@ -67,13 +68,13 @@ def pad_filter(probe): filter_chain = [] if not math.isclose(probe.video[0]['aspect'], - PRE.aspect, abs_tol=0.03): - if probe.video[0]['aspect'] < PRE.aspect: + pre.aspect, abs_tol=0.03): + if probe.video[0]['aspect'] < pre.aspect: filter_chain.append( - f'pad=ih*{PRE.w}/{PRE.h}/sar:ih:(ow-iw)/2:(oh-ih)/2') - elif probe.video[0]['aspect'] > PRE.aspect: + f'pad=ih*{pre.w}/{pre.h}/sar:ih:(ow-iw)/2:(oh-ih)/2') + elif probe.video[0]['aspect'] > pre.aspect: filter_chain.append( - f'pad=iw:iw*{PRE.h}/{PRE.w}/sar:(ow-iw)/2:(oh-ih)/2') + f'pad=iw:iw*{pre.h}/{pre.w}/sar:(ow-iw)/2:(oh-ih)/2') return filter_chain @@ -84,8 +85,8 @@ def fps_filter(probe): """ filter_chain = [] - if probe.video[0]['fps'] != PRE.fps: - filter_chain.append(f'fps={PRE.fps}') + if probe.video[0]['fps'] != pre.fps: + filter_chain.append(f'fps={pre.fps}') return filter_chain @@ -97,13 +98,13 @@ def scale_filter(probe): """ filter_chain = [] - if int(probe.video[0]['width']) != PRE.w or \ - int(probe.video[0]['height']) != PRE.h: - filter_chain.append(f'scale={PRE.w}:{PRE.h}') + if int(probe.video[0]['width']) != pre.w or \ + int(probe.video[0]['height']) != pre.h: + filter_chain.append(f'scale={pre.w}:{pre.h}') if not math.isclose(probe.video[0]['aspect'], - PRE.aspect, abs_tol=0.03): - filter_chain.append(f'setdar=dar={PRE.aspect}') + pre.aspect, abs_tol=0.03): + filter_chain.append(f'setdar=dar={pre.aspect}') return filter_chain @@ -132,22 +133,22 @@ def overlay_filter(duration, ad, ad_last, ad_next): logo_filter = '[v]null' scale_filter = '' - 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 ad: logo_chain = [] - if PRE.logo_scale and \ - re.match(r'\d+:-?\d+', PRE.logo_scale): - scale_filter = f'scale={PRE.logo_scale},' + 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}' - f'colorchannelmixer=aa={PRE.logo_opacity}') + f'colorchannelmixer=aa={pre.logo_opacity}') loop = 'loop=loop=-1:size=1:start=0' - logo_chain.append(f'movie={PRE.logo},{loop},{logo_extras}') + logo_chain.append(f'movie={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(f'fade=out:st={duration - 1}:d=1.0:alpha=1') logo_filter = (f'{",".join(logo_chain)}[l];[v][l]' - f'{PRE.logo_filter}:shortest=1') + f'{pre.logo_filter}:shortest=1') return logo_filter @@ -172,9 +173,9 @@ def add_loudnorm(probe): """ loud_filter = [] - if probe.audio and PRE.add_loudnorm: + if probe.audio and pre.add_loudnorm: loud_filter = [ - f'loudnorm=I={PRE.loud_i}:TP={PRE.loud_tp}:LRA={PRE.loud_lra}'] + f'loudnorm=I={pre.loud_i}:TP={pre.loud_tp}:LRA={pre.loud_lra}'] return loud_filter @@ -209,11 +210,11 @@ def extend_video(probe, duration, target_duration): def realtime_filter(duration, track=''): speed_filter = '' - if PRE.realtime: + if pre.realtime: speed_filter = f',{track}realtime=speed=1' - if GENERAL.time_delta < 0: - speed = duration / (duration + GENERAL.time_delta) + if sync_op.time_delta < 0: + speed = duration / (duration + sync_op.time_delta) if speed < 1.1: speed_filter = f',{track}realtime=speed={speed}' @@ -229,11 +230,11 @@ def split_filter(filter_type): if filter_type == 'a': prefix = 'a' - if PRE.output_count > 1: - for num in range(PRE.output_count): + if pre.output_count > 1: + for num in range(pre.output_count): map_node.append(f'[{filter_type}out{num + 1}]') - _filter = f',{prefix}split={PRE.output_count}{"".join(map_node)}' + _filter = f',{prefix}split={pre.output_count}{"".join(map_node)}' else: _filter = f'[{filter_type}out1]' @@ -241,11 +242,11 @@ def split_filter(filter_type): return _filter -def custom_filter(type, node): +def custom_filter(filter_type, node): filter_dir = os.path.dirname(os.path.abspath(__file__)) filters = [] - for filter_file in glob(os.path.join(filter_dir, f'{type}_*')): + for filter_file in glob(os.path.join(filter_dir, f'{filter_type}_*')): filter_ = os.path.splitext(os.path.basename(filter_file))[0] filter_function = locate(f'ffplayout.filters.{filter_}.filter_link') link = filter_function(node) diff --git a/ffplayout/filters/v_drawtext.py b/ffplayout/filters/v_drawtext.py index e53e0063..28e27ea2 100644 --- a/ffplayout/filters/v_drawtext.py +++ b/ffplayout/filters/v_drawtext.py @@ -1,7 +1,7 @@ import os import re -from ffplayout.utils import TEXT +from ffplayout.utils import lower_third def filter_link(node): @@ -10,12 +10,12 @@ def filter_link(node): """ font = '' source = os.path.basename(node.get('source')) - match = re.match(TEXT.regex, source) + match = re.match(lower_third.regex, source) title = match[1] if match else source - if TEXT.fontfile and os.path.isfile(TEXT.fontfile): - font = f":fontfile='{TEXT.fontfile}'" + if lower_third.fontfile and os.path.isfile(lower_third.fontfile): + font = f":fontfile='{lower_third.fontfile}'" - if TEXT.text_from_filename: + if lower_third.text_from_filename: escape = title.replace("'", "'\\\\\\''").replace("%", "\\\\\\%") - return f"drawtext=text='{escape}':{TEXT.style}{font}" + return f"drawtext=text='{escape}':{lower_third.style}{font}" diff --git a/ffplayout/folder.py b/ffplayout/folder.py index ce7a938d..bb300bc8 100644 --- a/ffplayout/folder.py +++ b/ffplayout/folder.py @@ -27,7 +27,7 @@ from watchdog.events import PatternMatchingEventHandler from watchdog.observers import Observer from .filters.default import build_filtergraph -from .utils import FF, STDIN_ARGS, STORAGE, MediaProbe, messenger +from .utils import MediaProbe, ff_proc, messenger, stdin_args, storage # ------------------------------------------------------------------------------ # folder watcher @@ -43,21 +43,21 @@ class MediaStore: def __init__(self): self.store = [] - if STDIN_ARGS.folder: - self.folder = STDIN_ARGS.folder + if stdin_args.folder: + self.folder = stdin_args.folder else: - self.folder = STORAGE.path + self.folder = storage.path self.fill() def fill(self): - for ext in STORAGE.extensions: + for ext in storage.extensions: self.store.extend( glob.glob(os.path.join(self.folder, '**', f'*{ext}'), recursive=True)) def sort_or_radomize(self): - if STORAGE.shuffle: + if storage.shuffle: self.rand() else: self.sort() @@ -86,7 +86,7 @@ class MediaWatcher: def __init__(self, media): self._media = media - self.extensions = [f'*{ext}' for ext in STORAGE.extensions] + self.extensions = [f'*{ext}' for ext in storage.extensions] self.current_clip = None self.event_handler = PatternMatchingEventHandler( @@ -120,7 +120,7 @@ class MediaWatcher: f'Move file from "{event.src_path}" to "{event.dest_path}"') if self.current_clip == event.src_path: - FF.decoder.terminate() + ff_proc.decoder.terminate() def on_deleted(self, event): self._media.remove(event.src_path) @@ -128,7 +128,7 @@ class MediaWatcher: messenger.info(f'Remove file from media list: "{event.src_path}"') if self.current_clip == event.src_path: - FF.decoder.terminate() + ff_proc.decoder.terminate() def stop(self): self.observer.stop() diff --git a/ffplayout/output/desktop.py b/ffplayout/output/desktop.py index 707e9417..9659fffc 100644 --- a/ffplayout/output/desktop.py +++ b/ffplayout/output/desktop.py @@ -21,9 +21,9 @@ from threading import Thread from ffplayout.folder import GetSourceFromFolder, MediaStore, MediaWatcher from ffplayout.playlist import GetSourceFromPlaylist -from ffplayout.utils import (FF, LOG, PLAYLIST, PRE, STDIN_ARGS, TEXT, - ffmpeg_stderr_reader, messenger, pre_audio_codec, - terminate_processes) +from ffplayout.utils import (ff_proc, ffmpeg_stderr_reader, log, lower_third, + messenger, playlist, pre, pre_audio_codec, + stdin_args, terminate_processes) _WINDOWS = os.name == 'nt' COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 65424 @@ -36,21 +36,22 @@ def output(): overlay = [] ff_pre_settings = [ - '-pix_fmt', 'yuv420p', '-r', str(PRE.fps), + '-pix_fmt', 'yuv420p', '-r', str(pre.fps), '-c:v', 'mpeg2video', '-intra', - '-b:v', f'{PRE.v_bitrate}k', - '-minrate', f'{PRE.v_bitrate}k', - '-maxrate', f'{PRE.v_bitrate}k', - '-bufsize', f'{PRE.v_bufsize}k' + '-b:v', f'{pre.v_bitrate}k', + '-minrate', f'{pre.v_bitrate}k', + '-maxrate', f'{pre.v_bitrate}k', + '-bufsize', f'{pre.v_bufsize}k' ] + pre_audio_codec() + ['-f', 'mpegts', '-'] - if TEXT.add_text and not TEXT.over_pre: + if lower_third.add_text and not lower_third.over_pre: messenger.info( - f'Using drawtext node, listening on address: {TEXT.address}') + f'Using drawtext node, listening on address: {lower_third.address}' + ) overlay = [ '-vf', "null,zmq=b=tcp\\\\://'{}',drawtext=text='':fontfile='{}'".format( - TEXT.address.replace(':', '\\:'), TEXT.fontfile) + lower_third.address.replace(':', '\\:'), lower_third.fontfile) ] try: @@ -60,14 +61,14 @@ def output(): messenger.debug(f'Encoder CMD: "{" ".join(enc_cmd)}"') - FF.encoder = Popen(enc_cmd, stderr=PIPE, stdin=PIPE, stdout=None) + ff_proc.encoder = Popen(enc_cmd, stderr=PIPE, stdin=PIPE, stdout=None) enc_err_thread = Thread(target=ffmpeg_stderr_reader, - args=(FF.encoder.stderr, False)) + args=(ff_proc.encoder.stderr, False)) 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 get_source = GetSourceFromPlaylist() else: @@ -86,23 +87,25 @@ def output(): f'seconds: {node.get("source")}') dec_cmd = [ - 'ffmpeg', '-v', LOG.ff_level.lower(), + 'ffmpeg', '-v', log.ff_level.lower(), '-hide_banner', '-nostats' ] + node['src_cmd'] + node['filter'] + ff_pre_settings messenger.debug(f'Decoder CMD: "{" ".join(dec_cmd)}"') - with Popen(dec_cmd, stdout=PIPE, stderr=PIPE) as FF.decoder: + with Popen( + dec_cmd, stdout=PIPE, stderr=PIPE) as ff_proc.decoder: dec_err_thread = Thread(target=ffmpeg_stderr_reader, - args=(FF.decoder.stderr, True)) + args=(ff_proc.decoder.stderr, + True)) dec_err_thread.daemon = True dec_err_thread.start() while True: - buf = FF.decoder.stdout.read(COPY_BUFSIZE) + buf = ff_proc.decoder.stdout.read(COPY_BUFSIZE) if not buf: break - FF.encoder.stdin.write(buf) + ff_proc.encoder.stdin.write(buf) except BrokenPipeError: messenger.error('Broken Pipe!') @@ -117,10 +120,10 @@ def output(): terminate_processes(watcher) # close encoder when nothing is to do anymore - if FF.encoder.poll() is None: - FF.encoder.terminate() + if ff_proc.encoder.poll() is None: + ff_proc.encoder.terminate() finally: - if FF.encoder.poll() is None: - FF.encoder.terminate() - FF.encoder.wait() + if ff_proc.encoder.poll() is None: + ff_proc.encoder.terminate() + ff_proc.encoder.wait() diff --git a/ffplayout/output/hls.py b/ffplayout/output/hls.py index 775936a9..2de35793 100644 --- a/ffplayout/output/hls.py +++ b/ffplayout/output/hls.py @@ -23,8 +23,8 @@ from threading import Thread from ffplayout.folder import GetSourceFromFolder, MediaStore, MediaWatcher from ffplayout.playlist import GetSourceFromPlaylist -from ffplayout.utils import (FF, LOG, PLAYLIST, PLAYOUT, STDIN_ARGS, - ffmpeg_stderr_reader, get_date, messenger, +from ffplayout.utils import (ff_proc, ffmpeg_stderr_reader, get_date, log, + messenger, playlist, playout, stdin_args, terminate_processes) @@ -35,15 +35,15 @@ def clean_ts(): 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] + m3u8_files = [p for p in playout.hls_output if 'm3u8' in p] - for playlist in playlists: - messenger.debug(f'cleanup *.ts files from: "{playlist}"') + for m3u8_file in m3u8_files: + messenger.debug(f'cleanup *.ts files from: "{m3u8_file}"') test_num = 0 - hls_path = os.path.dirname(playlist) + hls_path = os.path.dirname(m3u8_file) - if os.path.isfile(playlist): - with open(playlist, 'r') as m3u8: + if os.path.isfile(m3u8_file): + with open(m3u8_file, 'r') as m3u8: for line in m3u8: if '.ts' in line: test_num = int(re.findall(r'(\d+).ts', line)[0]) @@ -66,7 +66,7 @@ def output(): year = get_date(False).split('-')[0] try: - if PLAYLIST.mode and not STDIN_ARGS.folder: + if playlist.mode and not stdin_args.folder: watcher = None get_source = GetSourceFromPlaylist() else: @@ -83,20 +83,21 @@ def output(): messenger.info(f'Play: {node.get("source")}') cmd = [ - 'ffmpeg', '-v', LOG.ff_level.lower(), '-hide_banner', + 'ffmpeg', '-v', log.ff_level.lower(), '-hide_banner', '-nostats' ] + node['src_cmd'] + node['filter'] + [ - '-metadata', 'service_name=' + PLAYOUT.name, - '-metadata', 'service_provider=' + PLAYOUT.provider, + '-metadata', 'service_name=' + playout.name, + '-metadata', 'service_provider=' + playout.provider, '-metadata', 'year={}'.format(year) - ] + PLAYOUT.ffmpeg_param + PLAYOUT.hls_output + ] + playout.ffmpeg_param + playout.hls_output messenger.debug(f'Encoder CMD: "{" ".join(cmd)}"') - FF.encoder = Popen(cmd, stdin=PIPE, stderr=PIPE) + ff_proc.encoder = Popen(cmd, stdin=PIPE, stderr=PIPE) stderr_reader_thread = Thread(target=ffmpeg_stderr_reader, - args=(FF.encoder.stderr, False)) + args=(ff_proc.encoder.stderr, + False)) stderr_reader_thread.daemon = True stderr_reader_thread.start() stderr_reader_thread.join() @@ -118,10 +119,10 @@ def output(): terminate_processes(watcher) # close encoder when nothing is to do anymore - if FF.encoder.poll() is None: - FF.encoder.terminate() + if ff_proc.encoder.poll() is None: + ff_proc.encoder.terminate() finally: - if FF.encoder.poll() is None: - FF.encoder.terminate() - FF.encoder.wait() + if ff_proc.encoder.poll() is None: + ff_proc.encoder.terminate() + ff_proc.encoder.wait() diff --git a/ffplayout/output/stream.py b/ffplayout/output/stream.py index f33408ad..d20fd12d 100644 --- a/ffplayout/output/stream.py +++ b/ffplayout/output/stream.py @@ -21,9 +21,9 @@ 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, STDIN_ARGS, TEXT, - ffmpeg_stderr_reader, get_date, messenger, - pre_audio_codec, terminate_processes) +from ffplayout.utils import (ff_proc, ffmpeg_stderr_reader, get_date, log, + lower_third, messenger, playlist, playout, pre, + pre_audio_codec, stdin_args, terminate_processes) _WINDOWS = os.name == 'nt' COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 65424 @@ -38,43 +38,44 @@ def output(): overlay = [] ff_pre_settings = [ - '-pix_fmt', 'yuv420p', '-r', str(PRE.fps), + '-pix_fmt', 'yuv420p', '-r', str(pre.fps), '-c:v', 'mpeg2video', '-intra', - '-b:v', f'{PRE.v_bitrate}k', - '-minrate', f'{PRE.v_bitrate}k', - '-maxrate', f'{PRE.v_bitrate}k', - '-bufsize', f'{PRE.v_bufsize}k' + '-b:v', f'{pre.v_bitrate}k', + '-minrate', f'{pre.v_bitrate}k', + '-maxrate', f'{pre.v_bitrate}k', + '-bufsize', f'{pre.v_bufsize}k' ] + pre_audio_codec() + ['-f', 'mpegts', '-'] - if TEXT.add_text and not TEXT.over_pre: + if lower_third.add_text and not lower_third.over_pre: messenger.info( - f'Using drawtext node, listening on address: {TEXT.address}') + f'Using drawtext node, listening on address: {lower_third.address}' + ) overlay = [ '-vf', "null,zmq=b=tcp\\\\://'{}',drawtext=text='':fontfile='{}'".format( - TEXT.address.replace(':', '\\:'), TEXT.fontfile) + lower_third.address.replace(':', '\\:'), lower_third.fontfile) ] try: enc_cmd = [ - 'ffmpeg', '-v', LOG.ff_level.lower(), '-hide_banner', + 'ffmpeg', '-v', log.ff_level.lower(), '-hide_banner', '-nostats', '-re', '-thread_queue_size', '160', '-i', 'pipe:0' ] + overlay + [ - '-metadata', 'service_name=' + PLAYOUT.name, - '-metadata', 'service_provider=' + PLAYOUT.provider, + '-metadata', 'service_name=' + playout.name, + '-metadata', 'service_provider=' + playout.provider, '-metadata', f'year={year}' - ] + PLAYOUT.ffmpeg_param + PLAYOUT.stream_output + ] + playout.ffmpeg_param + playout.stream_output messenger.debug(f'Encoder CMD: "{" ".join(enc_cmd)}"') - FF.encoder = Popen(enc_cmd, stdin=PIPE, stderr=PIPE) + ff_proc.encoder = Popen(enc_cmd, stdin=PIPE, stderr=PIPE) enc_err_thread = Thread(target=ffmpeg_stderr_reader, - args=(FF.encoder.stderr, False)) + args=(ff_proc.encoder.stderr, False)) 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 get_source = GetSourceFromPlaylist() else: @@ -91,23 +92,25 @@ def output(): messenger.info(f'Play: {node.get("source")}') dec_cmd = [ - 'ffmpeg', '-v', LOG.ff_level.lower(), + 'ffmpeg', '-v', log.ff_level.lower(), '-hide_banner', '-nostats' ] + node['src_cmd'] + node['filter'] + ff_pre_settings messenger.debug(f'Decoder CMD: "{" ".join(dec_cmd)}"') - with Popen(dec_cmd, stdout=PIPE, stderr=PIPE) as FF.decoder: + with Popen( + dec_cmd, stdout=PIPE, stderr=PIPE) as ff_proc.decoder: dec_err_thread = Thread(target=ffmpeg_stderr_reader, - args=(FF.decoder.stderr, True)) + args=(ff_proc.decoder.stderr, + True)) dec_err_thread.daemon = True dec_err_thread.start() while True: - buf = FF.decoder.stdout.read(COPY_BUFSIZE) + buf = ff_proc.decoder.stdout.read(COPY_BUFSIZE) if not buf: break - FF.encoder.stdin.write(buf) + ff_proc.encoder.stdin.write(buf) except BrokenPipeError: messenger.error('Broken Pipe!') @@ -122,10 +125,10 @@ def output(): terminate_processes(watcher) # close encoder when nothing is to do anymore - if FF.encoder.poll() is None: - FF.encoder.terminate() + if ff_proc.encoder.poll() is None: + ff_proc.encoder.terminate() finally: - if FF.encoder.poll() is None: - FF.encoder.terminate() - FF.encoder.wait() + if ff_proc.encoder.poll() is None: + ff_proc.encoder.terminate() + ff_proc.encoder.wait() diff --git a/ffplayout/playlist.py b/ffplayout/playlist.py index d72af75f..84ec6d81 100644 --- a/ffplayout/playlist.py +++ b/ffplayout/playlist.py @@ -28,9 +28,9 @@ from threading import Thread import requests from .filters.default import build_filtergraph -from .utils import (GENERAL, PLAYLIST, STDIN_ARGS, MediaProbe, check_sync, - get_date, get_delta, get_float, get_time, messenger, - src_or_dummy, valid_json) +from .utils import (MediaProbe, check_sync, get_date, get_delta, get_float, + get_time, messenger, playlist, src_or_dummy, stdin_args, + sync_op, valid_json) def handle_list_init(node): @@ -103,13 +103,13 @@ def timed_source(node, last): delta, total_delta = get_delta(node['begin']) node_ = None - if not STDIN_ARGS.loop and PLAYLIST.length: + if not stdin_args.loop and playlist.length: messenger.debug(f'delta: {delta:f}') messenger.debug(f'total_delta: {total_delta:f}') check_sync(delta) if (total_delta > node['out'] - node['seek'] and not last) \ - or STDIN_ARGS.loop or not PLAYLIST.length: + or stdin_args.loop or not playlist.length: # when we are in the 24 houre range, get the clip node_ = src_or_dummy(node) @@ -126,12 +126,12 @@ def check_length(total_play_time, list_date): """ check if playlist is long enough """ - if PLAYLIST.length and total_play_time < PLAYLIST.length - 5 \ - and not STDIN_ARGS.loop: + if playlist.length and total_play_time < playlist.length - 5 \ + and not stdin_args.loop: messenger.error( f'Playlist from {list_date} is not long enough!\n' f'Total play time is: {timedelta(seconds=total_play_time)}, ' - f'target length is: {timedelta(seconds=PLAYLIST.length)}' + f'target length is: {timedelta(seconds=playlist.length)}' ) @@ -203,11 +203,11 @@ class PlaylistReader: self.nodes = {'program': []} self.error = False - if STDIN_ARGS.playlist: - json_file = STDIN_ARGS.playlist + if stdin_args.playlist: + json_file = stdin_args.playlist else: year, month, day = self.list_date.split('-') - json_file = os.path.join(PLAYLIST.path, year, month, + json_file = os.path.join(playlist.path, year, month, f'{self.list_date}.json') if '://' in json_file: @@ -253,7 +253,7 @@ class GetSourceFromPlaylist: def __init__(self): self.prev_date = get_date(True) - self.list_start = PLAYLIST.start + self.list_start = playlist.start self.first = True self.last = False self.clip_nodes = [] @@ -261,7 +261,7 @@ class GetSourceFromPlaylist: self.node = None self.prev_node = None self.next_node = None - self.playlist = PlaylistReader(get_date(True), 0.0) + self.playlist_reader = PlaylistReader(get_date(True), 0.0) self.last_error = False def get_playlist(self): @@ -269,26 +269,26 @@ class GetSourceFromPlaylist: read playlist from given date and fill clip_nodes when playlist is not available, reset relevant values """ - self.playlist.read() + self.playlist_reader.read() - if self.last_error and not self.playlist.error and \ - self.playlist.list_date == self.prev_date: + if self.last_error and not self.playlist_reader.error and \ + self.playlist_reader.list_date == self.prev_date: # when last playlist where not exists but now is there and # is still the same playlist date, # set self.first to true to seek in clip # only in this situation seek in is correct!! self.first = True - self.last_error = self.playlist.error + self.last_error = self.playlist_reader.error - if self.playlist.nodes.get('program'): - self.clip_nodes = self.playlist.nodes.get('program') + if self.playlist_reader.nodes.get('program'): + self.clip_nodes = self.playlist_reader.nodes.get('program') self.node_count = len(self.clip_nodes) - if self.playlist.error: + if self.playlist_reader.error: self.clip_nodes = [] self.node_count = 0 - self.playlist.last_mod_time = 0.0 - self.last_error = self.playlist.error + self.playlist_reader.last_mod_time = 0.0 + self.last_error = self.playlist_reader.error def init_time(self): """ @@ -296,12 +296,12 @@ class GetSourceFromPlaylist: """ self.last_time = get_time('full_sec') - if PLAYLIST.length: - total_playtime = PLAYLIST.length + if playlist.length: + total_playtime = playlist.length else: total_playtime = 86400.0 - if self.last_time < PLAYLIST.start: + if self.last_time < playlist.start: self.last_time += total_playtime def check_for_next_playlist(self, begin): @@ -321,17 +321,17 @@ class GetSourceFromPlaylist: delta, total_delta = get_delta(begin) delta += seek + 1 - next_start = begin - PLAYLIST.start + out + delta + next_start = begin - playlist.start + out + delta else: delta, total_delta = get_delta(begin) - next_start = begin - PLAYLIST.start + GENERAL.threshold + delta + next_start = begin - playlist.start + sync_op.threshold + delta - if PLAYLIST.length and next_start >= PLAYLIST.length: + if playlist.length and next_start >= playlist.length: self.prev_date = get_date(False, next_start) - self.playlist.list_date = self.prev_date - self.playlist.last_mod_time = 0.0 - self.last_time = PLAYLIST.start - 1 + self.playlist_reader.list_date = self.prev_date + self.playlist_reader.last_mod_time = 0.0 + self.last_time = playlist.start - 1 self.clip_nodes = [] def previous_and_next_node(self, index): @@ -361,9 +361,9 @@ class GetSourceFromPlaylist: """ current_time = get_time('full_sec') - 86400 # balance small difference to start time - if PLAYLIST.start is not None and isclose(PLAYLIST.start, + if playlist.start is not None and isclose(playlist.start, current_time, abs_tol=2): - begin = PLAYLIST.start + begin = playlist.start else: self.init_time() begin = self.last_time @@ -385,14 +385,14 @@ class GetSourceFromPlaylist: """ handle except playlist end """ - if STDIN_ARGS.loop and self.node: + if stdin_args.loop and self.node: # when loop paramter is set and playlist node exists, # jump to playlist start and play again self.list_start = self.last_time + 1 self.node = None messenger.info('Loop playlist') - elif begin == PLAYLIST.start or not self.clip_nodes: + elif begin == playlist.start or not self.clip_nodes: # playlist not exist or is corrupt/empty messenger.error('Clip nodes are empty!') self.first = False @@ -454,7 +454,7 @@ class GetSourceFromPlaylist: begin += self.node['out'] - self.node['seek'] else: - if not PLAYLIST.length and not STDIN_ARGS.loop: + if not playlist.length and not stdin_args.loop: # when we reach playlist end, stop script messenger.info('Playlist reached end!') return None diff --git a/ffplayout/utils.py b/ffplayout/utils.py index 612920e6..c6e816e9 100644 --- a/ffplayout/utils.py +++ b/ffplayout/utils.py @@ -98,7 +98,7 @@ for arg_file in glob(os.path.join(CONFIG_PATH, 'argparse_*')): **config ) -STDIN_ARGS = stdin_parser.parse_args() +stdin_args = stdin_parser.parse_args() # ------------------------------------------------------------------------------ @@ -127,17 +127,17 @@ def get_time(time_format): # default variables and values # ------------------------------------------------------------------------------ -GENERAL = SimpleNamespace(time_delta=0) -MAIL = SimpleNamespace() -LOG = SimpleNamespace() -PRE = SimpleNamespace() -PLAYLIST = SimpleNamespace() -STORAGE = SimpleNamespace() -TEXT = SimpleNamespace() -PLAYOUT = SimpleNamespace() +sync_op = SimpleNamespace(time_delta=0) +mail = SimpleNamespace() +log = SimpleNamespace() +pre = SimpleNamespace() +playlist = SimpleNamespace() +storage = SimpleNamespace() +lower_third = SimpleNamespace() +playout = SimpleNamespace() -INITIAL = SimpleNamespace(load=True) -FF = SimpleNamespace(decoder=None, encoder=None) +initial = SimpleNamespace(load=True) +ff_proc = SimpleNamespace(decoder=None, encoder=None) def str_to_sec(s): @@ -164,90 +164,90 @@ def load_config(): some settings cannot be changed - like resolution, aspect, or output """ - if STDIN_ARGS.config: - cfg = read_config(STDIN_ARGS.config) + if stdin_args.config: + cfg = read_config(stdin_args.config) elif os.path.isfile('/etc/ffplayout/ffplayout.yml'): cfg = read_config('/etc/ffplayout/ffplayout.yml') else: cfg = read_config('ffplayout.yml') - if STDIN_ARGS.start: - p_start = str_to_sec(STDIN_ARGS.start) + if stdin_args.start: + p_start = str_to_sec(stdin_args.start) else: p_start = str_to_sec(cfg['playlist']['day_start']) if p_start is None: p_start = get_time('full_sec') - if STDIN_ARGS.length: - p_length = str_to_sec(STDIN_ARGS.length) + if stdin_args.length: + p_length = str_to_sec(stdin_args.length) else: p_length = str_to_sec(cfg['playlist']['length']) - GENERAL.stop = cfg['general']['stop_on_error'] - GENERAL.threshold = cfg['general']['stop_threshold'] + sync_op.stop = cfg['general']['stop_on_error'] + sync_op.threshold = cfg['general']['stop_threshold'] - MAIL.subject = cfg['mail']['subject'] - MAIL.server = cfg['mail']['smpt_server'] - MAIL.port = cfg['mail']['smpt_port'] - MAIL.s_addr = cfg['mail']['sender_addr'] - MAIL.s_pass = cfg['mail']['sender_pass'] - MAIL.recip = cfg['mail']['recipient'] - MAIL.level = cfg['mail']['mail_level'] + mail.subject = cfg['mail']['subject'] + mail.server = cfg['mail']['smpt_server'] + mail.port = cfg['mail']['smpt_port'] + mail.s_addr = cfg['mail']['sender_addr'] + mail.s_pass = cfg['mail']['sender_pass'] + mail.recip = cfg['mail']['recipient'] + mail.level = cfg['mail']['mail_level'] - 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'] + 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'] - PLAYLIST.start = p_start - PLAYLIST.length = p_length + playlist.mode = cfg['playlist']['playlist_mode'] + playlist.path = cfg['playlist']['path'] + playlist.start = p_start + playlist.length = p_length - STORAGE.path = cfg['storage']['path'] - STORAGE.filler = cfg['storage']['filler_clip'] - STORAGE.extensions = cfg['storage']['extensions'] - STORAGE.shuffle = cfg['storage']['shuffle'] + storage.path = cfg['storage']['path'] + storage.filler = cfg['storage']['filler_clip'] + storage.extensions = cfg['storage']['extensions'] + 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'] - TEXT.text_from_filename = cfg['text']['text_from_filename'] - TEXT.style = cfg['text']['style'] - TEXT.regex = cfg['text']['regex'] + lower_third.add_text = cfg['text']['add_text'] + lower_third.over_pre = cfg['text']['over_pre'] + lower_third.address = cfg['text']['bind_address'] + lower_third.fontfile = cfg['text']['fontfile'] + lower_third.text_from_filename = cfg['text']['text_from_filename'] + lower_third.style = cfg['text']['style'] + lower_third.regex = cfg['text']['regex'] - if INITIAL.load: - LOG.to_file = cfg['logging']['log_to_file'] - LOG.backup_count = cfg['logging']['backup_count'] - LOG.path = cfg['logging']['log_path'] - LOG.level = cfg['logging']['log_level'] - LOG.ff_level = cfg['logging']['ffmpeg_level'] + if initial.load: + log.to_file = cfg['logging']['log_to_file'] + log.backup_count = cfg['logging']['backup_count'] + log.path = cfg['logging']['log_path'] + log.level = cfg['logging']['log_level'] + log.ff_level = cfg['logging']['ffmpeg_level'] - 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'] * \ + 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'] * \ cfg['processing']['height'] / 10 - PRE.v_bufsize = PRE.v_bitrate / 2 - PRE.realtime = cfg['processing']['use_realtime'] + pre.v_bufsize = pre.v_bitrate / 2 + pre.realtime = cfg['processing']['use_realtime'] - PLAYOUT.mode = cfg['out']['mode'] - PLAYOUT.name = cfg['out']['service_name'] - PLAYOUT.provider = cfg['out']['service_provider'] - PLAYOUT.ffmpeg_param = cfg['out']['ffmpeg_param'].split(' ') - PLAYOUT.stream_output = cfg['out']['stream_output'].split(' ') - PLAYOUT.hls_output = cfg['out']['hls_output'].split(' ') + playout.mode = cfg['out']['mode'] + playout.name = cfg['out']['service_name'] + playout.provider = cfg['out']['service_provider'] + playout.ffmpeg_param = cfg['out']['ffmpeg_param'].split(' ') + playout.stream_output = cfg['out']['stream_output'].split(' ') + playout.hls_output = cfg['out']['hls_output'].split(' ') - INITIAL.load = False + initial.load = False load_config() @@ -308,21 +308,21 @@ class CustomFormatter(logging.Formatter): # If the log file is specified on the command line then override the default -if STDIN_ARGS.log: - LOG.path = STDIN_ARGS.log +if stdin_args.log: + log.path = stdin_args.log playout_logger = logging.getLogger('playout') -playout_logger.setLevel(LOG.level) +playout_logger.setLevel(log.level) decoder_logger = logging.getLogger('decoder') -decoder_logger.setLevel(LOG.ff_level) +decoder_logger.setLevel(log.ff_level) encoder_logger = logging.getLogger('encoder') -encoder_logger.setLevel(LOG.ff_level) +encoder_logger.setLevel(log.ff_level) -if LOG.to_file and LOG.path != 'none': - if LOG.path and os.path.isdir(LOG.path): - playout_log = os.path.join(LOG.path, 'ffplayout.log') - decoder_log = os.path.join(LOG.path, 'decoder.log') - encoder_log = os.path.join(LOG.path, 'encoder.log') +if log.to_file and log.path != 'none': + if log.path and os.path.isdir(log.path): + playout_log = os.path.join(log.path, 'ffplayout.log') + decoder_log = os.path.join(log.path, 'decoder.log') + encoder_log = os.path.join(log.path, 'encoder.log') else: base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) log_dir = os.path.join(base_dir, 'log') @@ -334,11 +334,11 @@ if LOG.to_file and LOG.path != 'none': p_format = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s') f_format = logging.Formatter('[%(asctime)s] %(message)s') p_file_handler = TimedRotatingFileHandler(playout_log, when='midnight', - backupCount=LOG.backup_count) + backupCount=log.backup_count) d_file_handler = TimedRotatingFileHandler(decoder_log, when='midnight', - backupCount=LOG.backup_count) + backupCount=log.backup_count) e_file_handler = TimedRotatingFileHandler(encoder_log, when='midnight', - backupCount=LOG.backup_count) + backupCount=log.backup_count) p_file_handler.setFormatter(p_format) d_file_handler.setFormatter(f_format) @@ -370,7 +370,7 @@ class Mailer: """ def __init__(self): - self.level = MAIL.level + self.level = mail.level self.time = None self.timestamp = get_time('stamp') self.rate_limit = 600 @@ -380,7 +380,7 @@ class Mailer: self.time = get_time(None) def send_mail(self, msg): - if MAIL.recip: + if mail.recip: # write message to temp file for rate limit with open(self.temp_msg, 'w+') as f: f.write(msg) @@ -388,15 +388,15 @@ class Mailer: self.current_time() message = MIMEMultipart() - message['From'] = MAIL.s_addr - message['To'] = MAIL.recip - message['Subject'] = MAIL.subject + message['From'] = mail.s_addr + message['To'] = mail.recip + message['Subject'] = mail.subject message['Date'] = formatdate(localtime=True) message.attach(MIMEText(f'{self.time} {msg}', 'plain')) text = message.as_string() try: - server = smtplib.SMTP(MAIL.server, MAIL.port) + server = smtplib.SMTP(mail.server, mail.port) except socket.error as err: playout_logger.error(err) server = None @@ -404,14 +404,14 @@ class Mailer: if server is not None: server.starttls() try: - login = server.login(MAIL.s_addr, MAIL.s_pass) + login = server.login(mail.s_addr, mail.s_pass) except smtplib.SMTPAuthenticationError as serr: playout_logger.error(serr) login = None if login is not None: - server.sendmail(MAIL.s_addr, - re.split(', |; |,|;', MAIL.recip), text) + server.sendmail(mail.s_addr, + re.split(', |; |,|;', mail.recip), text) server.quit() def check_if_new(self, msg): @@ -623,11 +623,11 @@ def terminate_processes(watcher=None): """ kill orphaned processes """ - if FF.decoder and FF.decoder.poll() is None: - FF.decoder.terminate() + if ff_proc.decoder and ff_proc.decoder.poll() is None: + ff_proc.decoder.terminate() - if FF.encoder and FF.encoder.poll() is None: - FF.encoder.terminate() + if ff_proc.encoder and ff_proc.encoder.poll() is None: + ff_proc.encoder.terminate() if watcher: watcher.stop() @@ -647,9 +647,9 @@ def ffmpeg_stderr_reader(std_errors, decoder): try: for line in std_errors: - if LOG.ff_level == 'INFO': + if log.ff_level == 'INFO': logger.info(f'{prefix}{line.decode("utf-8").rstrip()}') - elif LOG.ff_level == 'WARNING': + elif log.ff_level == 'WARNING': logger.warning(f'{prefix}{line.decode("utf-8").rstrip()}') else: logger.error(f'{prefix}{line.decode("utf-8").rstrip()}') @@ -663,17 +663,17 @@ def get_delta(begin): """ current_time = get_time('full_sec') - if STDIN_ARGS.length and str_to_sec(STDIN_ARGS.length): - target_playtime = str_to_sec(STDIN_ARGS.length) - elif PLAYLIST.length: - target_playtime = PLAYLIST.length + if stdin_args.length and str_to_sec(stdin_args.length): + target_playtime = str_to_sec(stdin_args.length) + elif playlist.length: + target_playtime = playlist.length else: target_playtime = 86400.0 - if begin == PLAYLIST.start == 0 and 86400.0 - current_time < 4: + if begin == playlist.start == 0 and 86400.0 - current_time < 4: current_time -= target_playtime - elif PLAYLIST.start >= current_time and not begin == PLAYLIST.start: + elif playlist.start >= current_time and not begin == playlist.start: current_time += target_playtime current_delta = begin - current_time @@ -681,7 +681,7 @@ def get_delta(begin): if math.isclose(current_delta, 86400.0, abs_tol=6): current_delta -= 86400.0 - ref_time = target_playtime + PLAYLIST.start + ref_time = target_playtime + playlist.start total_delta = ref_time - begin + current_delta return current_delta, total_delta @@ -695,9 +695,9 @@ def get_date(seek_day, next_start=0): """ d = date.today() - if seek_day and PLAYLIST.start > get_time('full_sec'): + 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: + elif playlist.start == 0 and next_start >= 86400: return (d + timedelta(1)).strftime('%Y-%m-%d') else: return d.strftime('%Y-%m-%d') @@ -738,12 +738,12 @@ def check_sync(delta): check that we are in tolerance time """ - if PLAYLIST.mode and PLAYLIST.start and PLAYLIST.length: + if playlist.mode and playlist.start and playlist.length: # save time delta to global variable for syncing # this is needed for real time filter - GENERAL.time_delta = delta + sync_op.time_delta = delta - if GENERAL.stop and abs(delta) > GENERAL.threshold: + if sync_op.stop and abs(delta) > sync_op.threshold: messenger.error( f'Sync tolerance value exceeded with {delta:.2f} seconds,\n' 'program terminated!') @@ -785,7 +785,7 @@ def gen_dummy(duration): # noise = 'noise=alls=50:allf=t+u,hue=s=0' return [ '-f', 'lavfi', '-i', - f'color=c={color}:s={PRE.w}x{PRE.h}:d={duration}:r={PRE.fps},' + f'color=c={color}:s={pre.w}x{pre.h}:d={duration}:r={pre.fps},' 'format=pix_fmts=yuv420p', '-f', 'lavfi', '-i', f'anoisesrc=d={duration}:c=pink:r=48000:a=0.05' ] @@ -796,7 +796,7 @@ def gen_filler(node): generate filler clip to fill empty space in playlist """ probe = MediaProbe() - probe.load(STORAGE.filler) + probe.load(storage.filler) duration = node['out'] - node['seek'] node['probe'] = probe @@ -808,13 +808,13 @@ def gen_filler(node): # cut filler messenger.info( f'Generate filler with {duration:.2f} seconds') - node['source'] = STORAGE.filler - node['src_cmd'] = ['-i', STORAGE.filler] + set_length( + node['source'] = storage.filler + node['src_cmd'] = ['-i', storage.filler] + set_length( filler_duration, 0, duration) return node else: # loop file n times - node['src_cmd'] = loop_input(STORAGE.filler, filler_duration, + node['src_cmd'] = loop_input(storage.filler, filler_duration, duration) return node else: @@ -881,7 +881,7 @@ def pre_audio_codec(): s302m has higher quality, but is experimental and works not well together with the loudnorm filter """ - if PRE.add_loudnorm: + 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'] diff --git a/tests/run_multiple_tests.py b/tests/run_multiple_tests.py index 183a315d..5bf1a0b8 100755 --- a/tests/run_multiple_tests.py +++ b/tests/run_multiple_tests.py @@ -98,37 +98,37 @@ def run_with_no_elements(time_tuple): if __name__ == '__main__': from ffplayout.output import desktop - from ffplayout.utils import PLAYLIST, terminate_processes + from ffplayout.utils import playlist, terminate_processes print('\ntest playlists, which are empty') - PLAYLIST.start = 0 + playlist.start = 0 run_time(140) run_with_no_elements((2021, 2, 15, 23, 59, 53)) print_separater() print('\ntest playlists, which are to short') - PLAYLIST.start = 0 + playlist.start = 0 run_time(140) run_with_less_elements((2021, 2, 15, 23, 58, 3)) print_separater() print('\ntest playlists, which are to long') - PLAYLIST.start = 0 + playlist.start = 0 run_time(140) run_with_more_elements((2021, 2, 15, 23, 59, 33)) print_separater() print('\ntest transition from playlists, with day_start at: 05:59:25') - PLAYLIST.start = 21575 + playlist.start = 21575 run_time(140) run_at((2021, 2, 17, 5, 58, 3)) print_separater() print('\ntest transition from playlists, with day_start at: 20:00:00') - PLAYLIST.start = 72000 + playlist.start = 72000 run_time(140) run_at((2021, 2, 17, 19, 58, 23))