From 18aadd6b82748ecc3d06ecb876d645c7a737e042 Mon Sep 17 00:00:00 2001 From: Mike Lang Date: Sat, 29 Dec 2018 21:32:18 -0800 Subject: [PATCH] restreamer: Also have an endpoint for generating cut videos on demand This is mainly just for testing until we get the database and proper cutter up, but it might prove useful to have in the long run too. This code will probably end up being totally rewritten, as it uses the most naive form of cutting and reencoding, and it has a whole bunch of http-serving specifics intertwined with the cutting logic. --- restreamer/restreamer/main.py | 96 ++++++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/restreamer/restreamer/main.py b/restreamer/restreamer/main.py index 671ddc4..1e01d08 100644 --- a/restreamer/restreamer/main.py +++ b/restreamer/restreamer/main.py @@ -5,11 +5,13 @@ import functools import json import logging import os +import shutil import signal import dateutil.parser import gevent -from flask import Flask, url_for, request, abort +from flask import Flask, url_for, request, abort, Response +from gevent import subprocess from gevent.pywsgi import WSGIServer from common import get_best_segments @@ -155,6 +157,8 @@ def generate_media_playlist(stream, variant): Must be in ISO 8601 format (ie. yyyy-mm-ddTHH:MM:SS). If not given, effectively means "infinity", ie. no start means any time ago, no end means any time in the future. + Note that because it returns segments _covering_ that range, the playlist + may start slightly before and end slightly after the given times. """ hours_path = os.path.join(app.static_folder, stream, variant) @@ -183,6 +187,96 @@ def generate_media_playlist(stream, variant): return generate_hls.generate_media(segments, os.path.join(app.static_url_path, stream, variant)) +@app.route('/cut//.ts') +@has_path_args +def cut(stream, variant): + """Return a MPEGTS video file covering the exact timestamp range. + Params: + start, end: Required. The start and end times, down to the millisecond. + Must be in ISO 8601 format (ie. yyyy-mm-ddTHH:MM:SS). + allow_holes: Optional, default false. If false, errors out with a 406 Not Acceptable + if any holes are detected, rather than producing a video with missing parts. + Set to true by passing "true" (case insensitive). + Even if holes are allowed, a 406 may result if the resulting video would be empty. + """ + start = dateutil.parser.parse(request.args['start']) + end = dateutil.parser.parse(request.args['end']) + if end <= start: + return "End must be after start", 400 + + allow_holes = request.args.get('allow_holes', 'false').lower() + if allow_holes not in ["true", "false"]: + return "allow_holes must be one of: true, false", 400 + allow_holes = (allow_holes == "true") + + hours_path = os.path.join(app.static_folder, stream, variant) + if not os.path.isdir(hours_path): + abort(404) + + segments = get_best_segments(hours_path, start, end) + if not allow_holes and None in segments: + return "Requested time range contains holes or is incomplete.", 406 + + segments = [segment for segment in segments if segment is not None] + + if not segments: + return "We have no content available within the requested time range.", 406 + + # how far into the first segment to begin + cut_start = max(0, (segments[0].start - start).total_seconds()) + # calculate full uncut duration of content, ie. without holes. + full_duration = sum(segment.duration.total_seconds() for segment in segments) + # calculate how much of final segment should be cut off + cut_end = max(0, (end - segments[-1].end).total_seconds()) + # finally, calculate actual output duration, which is what ffmpeg will use + duration = full_duration - cut_start - cut_end + + def feed_input(pipe): + # pass each segment into ffmpeg's stdin in order, while outputing everything on stdout. + for segment in segments: + with open(segment.path) as f: + shutil.copyfileobj(f, pipe) + pipe.close() + + def _cut(): + ffmpeg = None + input_feeder = None + try: + ffmpeg = subprocess.Popen([ + "ffmpeg", + "-i", "-", # read from stdin + "-ss", str(cut_start), # seconds to cut from start + "-t", str(duration), # total duration, which says when to cut at end + "-f", "mpegts", # output as MPEG-TS format + "-", # output to stdout + ], stdin=subprocess.PIPE, stdout=subprocess.PIPE) + input_feeder = gevent.spawn(feed_input, ffmpeg.stdin) + # stream the output until it is closed + while True: + chunk = ffmpeg.stdout.read(16*1024) + if not chunk: + break + yield chunk + # check if any errors occurred in input writing, or if ffmpeg exited non-success. + # raising an error mid-streaming-response will get flask to abort the response + # uncleanly, which tells the client that something went wrong. + if ffmpeg.wait() != 0: + raise Exception("Error while streaming cut: ffmpeg exited {}".format(ffmpeg.returncode)) + input_feeder.get() # re-raise any errors from feed_input() + finally: + # if something goes wrong, try to clean up ignoring errors + if input_feeder is not None: + input_feeder.kill() + if ffmpeg is not None and ffmpeg.poll() is None: + for action in (ffmpeg.kill, ffmpeg.stdin.close, ffmpeg.stdout.close): + try: + action() + except (OSError, IOError): + pass + + return Response(_cut(), mimetype='video/MP2T') + + def main(host='0.0.0.0', port=8000, base_dir='.'): app.static_folder = base_dir server = WSGIServer((host, port), cors(app))