ffplayout/ffplayout/playlist.py
2021-03-24 17:22:26 +01:00

466 lines
15 KiB
Python

# -*- coding: utf-8 -*-
# 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 os
import socket
import time
from copy import deepcopy
from datetime import timedelta
from math import isclose
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)
def handle_list_init(node):
"""
handle init clip, but this clip can be the last one in playlist,
this we have to figure out and calculate the right length
"""
messenger.debug('List init')
delta, total_delta = get_delta(node['begin'])
seek = abs(delta) + node['seek'] if abs(delta) + node['seek'] >= 1 else 0
if node['out'] - seek > total_delta:
out = total_delta + seek
else:
out = node['out']
if out - seek > 1:
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
def handle_list_end(duration, node):
"""
when we come to last clip in playlist,
or when we reached total playtime,
we end up here
"""
messenger.debug('List end')
out = node['seek'] + duration if node['seek'] > 0 else duration
# prevent looping
if out > node['duration']:
out = node['duration']
else:
messenger.warning(
f'Clip length is not in time, new duration is: {duration:.2f}')
if node['duration'] > duration > 1 and \
node['duration'] - node['seek'] >= duration:
node['out'] = out
node = src_or_dummy(node)
elif node['duration'] > duration > 0.0:
messenger.warning(
f'Last clip less then 1 second long, skip:\n{node["source"]}')
node = None
else:
missing_secs = abs(duration - (node['duration'] - node['seek']))
messenger.error(
f'Playlist is not long enough:\n{missing_secs:.2f} seconds needed')
out = node['out']
node = src_or_dummy(node)
return node
def timed_source(node, last):
"""
prepare input clip
check begin and length from clip
return clip only if we are in 24 hours time range
"""
delta, total_delta = get_delta(node['begin'])
node_ = None
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:
# when we are in the 24 houre range, get the clip
node_ = src_or_dummy(node)
elif total_delta <= 0:
messenger.info(f'Begin is over play time, skip:\n{node["source"]}')
elif total_delta < node['duration'] - node['seek'] or last:
node_ = handle_list_end(total_delta, node)
return node_
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:
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)}'
)
def validate_thread(clip_nodes, list_date):
"""
validate json values in new thread
and test if source paths exist
"""
def check_json(clip_nodes, list_date):
error = ''
counter = 0
probe = MediaProbe()
# check if all values are valid
for node in clip_nodes['program']:
source = node.get('source')
probe.load(source)
missing = []
_in = get_float(node.get('in'), 0)
_out = get_float(node.get('out'), 0)
duration = get_float(node.get('duration'), 0)
if probe.is_remote:
if not probe.video[0]:
missing.append(f'Remote file not exist: "{source}"')
elif source is None or not os.path.isfile(source):
missing.append(f'File not exist: "{source}"')
if not type(node.get('in')) in [int, float]:
missing.append(f'No in Value in: "{node}"')
if _out == 0:
missing.append(f'No out Value in: "{node}"')
if duration == 0:
missing.append(f'No duration Value in: "{node}"')
counter += _out - _in
line = '\n'.join(missing)
if line:
error += line + f'\nIn line: {node}\n\n'
if error:
messenger.error(
'Validation error, check JSON playlist, '
f'values are missing:\n{error}'
)
check_length(counter, list_date)
if clip_nodes.get('program') and len(clip_nodes.get('program')) > 0:
validate = Thread(name='check_json', target=check_json,
args=(clip_nodes, list_date))
validate.daemon = True
validate.start()
else:
messenger.error('Validation error: playlist are empty')
class PlaylistReader:
def __init__(self, list_date, last_mod_time):
self.list_date = list_date
self.last_mod_time = last_mod_time
self.nodes = None
self.error = False
def read(self):
self.nodes = {'program': []}
self.error = False
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,
f'{self.list_date}.json')
if '://' in json_file:
json_file = json_file.replace('\\', '/')
try:
result = requests.get(json_file, timeout=1, verify=False)
b_time = result.headers['last-modified']
temp_time = time.strptime(b_time, "%a, %d %b %Y %H:%M:%S %Z")
mod_time = time.mktime(temp_time)
if mod_time > self.last_mod_time:
if isinstance(result.json(), dict):
self.nodes = result.json()
self.last_mod_time = mod_time
messenger.info('Open: ' + json_file)
validate_thread(deepcopy(self.nodes), self.list_date)
except (requests.exceptions.ConnectionError, socket.timeout):
messenger.error(f'No valid playlist from url: {json_file}')
self.error = True
elif os.path.isfile(json_file):
# 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)
self.last_mod_time = mod_time
messenger.info('Open: ' + json_file)
validate_thread(deepcopy(self.nodes), self.list_date)
else:
messenger.error(f'Playlist not exists: {json_file}')
self.error = True
class GetSourceFromPlaylist:
"""
read values from json playlist,
get current clip in time,
set ffmpeg source command
"""
def __init__(self):
self.prev_date = get_date(True)
self.list_start = PLAYLIST.start
self.first = True
self.last = False
self.clip_nodes = []
self.node_count = 0
self.node = None
self.prev_node = None
self.next_node = None
self.playlist = PlaylistReader(get_date(True), 0.0)
self.last_error = False
def get_playlist(self):
"""
read playlist from given date and fill clip_nodes
when playlist is not available, reset relevant values
"""
self.playlist.read()
if self.last_error and not self.playlist.error and \
self.playlist.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
if self.playlist.nodes.get('program'):
self.clip_nodes = self.playlist.nodes.get('program')
self.node_count = len(self.clip_nodes)
if self.playlist.error:
self.clip_nodes = []
self.node_count = 0
self.playlist.last_mod_time = 0.0
self.last_error = self.playlist.error
def init_time(self):
"""
get current time in second and shift it when is necessary
"""
self.last_time = get_time('full_sec')
if PLAYLIST.length:
total_playtime = PLAYLIST.length
else:
total_playtime = 86400.0
if self.last_time < PLAYLIST.start:
self.last_time += total_playtime
def check_for_next_playlist(self, begin):
"""
check if playlist length is 24 hours and matches current length,
to get the date for a new playlist
"""
if self.node is not None:
out = self.node['out']
delta = 0
if self.node['duration'] > self.node['out']:
out = self.node['duration']
if self.last:
seek = self.node['seek'] if self.node['seek'] > 0 else 0
delta, total_delta = get_delta(begin)
delta += seek + 1
next_start = begin - PLAYLIST.start + out + delta
else:
delta, total_delta = get_delta(begin)
next_start = begin - PLAYLIST.start + GENERAL.threshold + delta
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.clip_nodes = []
def previous_and_next_node(self, index):
"""
set previous and next clip node
"""
self.prev_node = self.clip_nodes[index - 1] if index > 0 else None
if index < self.node_count - 1:
self.next_node = self.clip_nodes[index + 1]
else:
self.next_node = None
def generate_cmd(self):
"""
extend clip node with ffmpeg source cmd and filters
"""
self.node = timed_source(self.node, self.last)
if self.node:
self.node['filter'] = build_filtergraph(self.node, self.prev_node,
self.next_node)
def generate_placeholder(self, duration):
"""
when playlist not exists, or is not long enough,
generate a placeholder node
"""
current_time = get_time('full_sec') - 86400
# balance small difference to start time
if PLAYLIST.start is not None and isclose(PLAYLIST.start,
current_time, abs_tol=2):
begin = PLAYLIST.start
else:
self.init_time()
begin = self.last_time
self.node = {
'begin': begin,
'number': 0,
'in': 0,
'seek': 0,
'out': duration,
'duration': duration + 1,
'source': None
}
self.generate_cmd()
self.check_for_next_playlist(begin)
def eof_handling(self, begin):
"""
handle except playlist end
"""
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:
# playlist not exist or is corrupt/empty
messenger.error('Clip nodes are empty!')
self.first = False
self.generate_placeholder(30)
else:
messenger.error('Playlist not long enough!')
self.generate_placeholder(60)
def next(self):
"""
endless loop for reading playlists
and getting the right clip node
"""
while True:
self.get_playlist()
begin = self.list_start
for index, self.node in enumerate(self.clip_nodes):
self.node['seek'] = get_float(self.node.get('in'), 0)
self.node['duration'] = get_float(self.node.get('duration'),
30)
self.node['out'] = get_float(self.node.get('out'),
self.node['duration'])
self.node['begin'] = begin
self.node['number'] = index + 1
# first time we end up here
if self.first:
self.init_time()
out = self.node['out']
if self.node['duration'] > self.node['out']:
out = self.node['duration']
if self.last_time < begin + out - self.node['seek']:
self.previous_and_next_node(index)
self.node = handle_list_init(self.node)
if self.node:
self.node['filter'] = build_filtergraph(
self.node, self.prev_node, self.next_node)
self.first = False
self.last_time = begin
self.check_for_next_playlist(begin)
break
elif self.last_time < begin:
if index == self.node_count - 1:
self.last = True
else:
self.last = False
self.previous_and_next_node(index)
self.generate_cmd()
self.last_time = begin
self.check_for_next_playlist(begin)
break
begin += self.node['out'] - self.node['seek']
else:
if not PLAYLIST.length and not STDIN_ARGS.loop:
# when we reach playlist end, stop script
messenger.info('Playlist reached end!')
return None
else:
self.eof_handling(begin)
if self.node:
yield self.node