diff --git a/app/main.py b/app/main.py index 31459e95..1c374cae 100644 --- a/app/main.py +++ b/app/main.py @@ -417,7 +417,7 @@ class Main: raise web.HTTPBadRequest(reason='file is required.') return web.Response( - text=M3u8(url=f"{self.config.url_host}{self.config.url_prefix}").make_stream( + text=await M3u8(url=f"{self.config.url_host}{self.config.url_prefix}").make_stream( download_path=self.config.download_path, file=file ), diff --git a/app/player/M3u8.py b/app/player/M3u8.py index adcde458..8980e4d4 100644 --- a/app/player/M3u8.py +++ b/app/player/M3u8.py @@ -16,19 +16,20 @@ class M3u8: self.url = url self.segment_duration = float(segment_duration) - def make_stream(self, download_path: str, file: str): + async def make_stream(self, download_path: str, file: str): realFile: str = calcDownloadPath(basePath=download_path, folder=file, createPath=False) if not os.path.exists(realFile): - raise Exception(f"File {realFile} does not exist.") + raise Exception(f"File '{realFile}' does not exist.") try: ffprobe = FFProbe(realFile) + await ffprobe.run() except UnicodeDecodeError as e: pass if not 'duration' in ffprobe.metadata: - raise Exception(f"Unable to get {realFile} duration.") + raise Exception(f"Unable to get '{realFile}' duration.") duration: float = float(ffprobe.metadata.get('duration')) diff --git a/app/player/Segments.py b/app/player/Segments.py index 01e65d57..b862717c 100644 --- a/app/player/Segments.py +++ b/app/player/Segments.py @@ -1,3 +1,4 @@ +import asyncio import hashlib import logging import os @@ -37,69 +38,80 @@ class Segments: else: startTime: float = '{:.6f}'.format((self.segment_duration * self.segment_index)) - ffmpegCmd = [] - ffmpegCmd.append('ffmpeg') - ffmpegCmd.append('-xerror') - ffmpegCmd.append('-hide_banner') - ffmpegCmd.append('-loglevel') - ffmpegCmd.append('error') + fargs = [] + fargs.append('-xerror') + fargs.append('-hide_banner') + fargs.append('-loglevel') + fargs.append('error') - ffmpegCmd.append('-ss') - ffmpegCmd.append(str(startTime if startTime else '0.00000')) - ffmpegCmd.append('-t') - ffmpegCmd.append(str('{:.6f}'.format(self.segment_duration))) + fargs.append('-ss') + fargs.append(str(startTime if startTime else '0.00000')) + fargs.append('-t') + fargs.append(str('{:.6f}'.format(self.segment_duration))) - ffmpegCmd.append('-copyts') + fargs.append('-copyts') - ffmpegCmd.append('-i') - ffmpegCmd.append(f'file:{tmpFile}') - ffmpegCmd.append('-map_metadata') - ffmpegCmd.append('-1') + fargs.append('-i') + fargs.append(f'file:{tmpFile}') + fargs.append('-map_metadata') + fargs.append('-1') - ffmpegCmd.append('-pix_fmt') - ffmpegCmd.append('yuv420p') - ffmpegCmd.append('-g') - ffmpegCmd.append('52') + fargs.append('-pix_fmt') + fargs.append('yuv420p') + fargs.append('-g') + fargs.append('52') - ffmpegCmd.append('-map') - ffmpegCmd.append('0:v:0') - ffmpegCmd.append('-strict') - ffmpegCmd.append('-2') + fargs.append('-map') + fargs.append('0:v:0') + fargs.append('-strict') + fargs.append('-2') - ffmpegCmd.append('-codec:v') - ffmpegCmd.append('libx264' if self.vconvert else 'copy') + fargs.append('-codec:v') + fargs.append('libx264' if self.vconvert else 'copy') if self.vconvert: - ffmpegCmd.append('-crf') - ffmpegCmd.append('23') - ffmpegCmd.append('-preset:v') - ffmpegCmd.append('fast') - ffmpegCmd.append('-level') - ffmpegCmd.append('4.1') - ffmpegCmd.append('-profile:v') - ffmpegCmd.append('baseline') + fargs.append('-crf') + fargs.append('23') + fargs.append('-preset:v') + fargs.append('fast') + fargs.append('-level') + fargs.append('4.1') + fargs.append('-profile:v') + fargs.append('baseline') # audio section. - ffmpegCmd.append('-map') - ffmpegCmd.append('0:a:0') - ffmpegCmd.append('-codec:a') - ffmpegCmd.append('aac' if self.aconvert else 'copy') + fargs.append('-map') + fargs.append('0:a:0') + fargs.append('-codec:a') + fargs.append('aac' if self.aconvert else 'copy') if self.aconvert: - ffmpegCmd.append('-b:a') - ffmpegCmd.append('192k') - ffmpegCmd.append('-ar') - ffmpegCmd.append('22050') - ffmpegCmd.append('-ac') - ffmpegCmd.append('2') + fargs.append('-b:a') + fargs.append('192k') + fargs.append('-ar') + fargs.append('22050') + fargs.append('-ac') + fargs.append('2') - ffmpegCmd.append('-sn') + fargs.append('-sn') - ffmpegCmd.append('-muxdelay') - ffmpegCmd.append('0') - ffmpegCmd.append('-f') - ffmpegCmd.append('mpegts') - ffmpegCmd.append('pipe:1') + fargs.append('-muxdelay') + fargs.append('0') + fargs.append('-f') + fargs.append('mpegts') + fargs.append('pipe:1') - LOG.debug(f'Streaming {realFile} segment {self.segment_index}.' + ' '.join(ffmpegCmd)) - proc = subprocess.run(ffmpegCmd, stdout=subprocess.PIPE) + LOG.debug(f"Streaming '{realFile}' segment '{self.segment_index}'. " + " ".join(fargs)) - return proc.stdout + proc = await asyncio.subprocess.create_subprocess_exec( + 'ffmpeg', *fargs, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + data, err = await proc.communicate() + + if 0 != proc.returncode: + LOG.error(f'Failed to stream {realFile} segment {self.segment_index}. {err.decode("utf-8")}') + raise Exception(f'Failed to stream {realFile} segment {self.segment_index}.') + + return data diff --git a/app/player/ffprobe.py b/app/player/ffprobe.py index ac52afb8..e40c7796 100644 --- a/app/player/ffprobe.py +++ b/app/player/ffprobe.py @@ -1,8 +1,10 @@ """ Python wrapper for ffprobe command line tool. ffprobe must exist in the path. """ +import asyncio import functools import json +import logging import operator import os import pipes @@ -178,55 +180,60 @@ class FFProbe: def __init__(self, path_to_video): self.path_to_video = path_to_video + async def run(self): try: with open(os.devnull, 'w') as tempf: - subprocess.check_call(["ffprobe", "-h"], stdout=tempf, stderr=tempf) + await asyncio.create_subprocess_exec( + "ffprobe", "-h", stdout=tempf, stderr=tempf + ) except FileNotFoundError: raise IOError('ffprobe not found.') - if os.path.isfile(self.path_to_video): - cmd: list = [ - "ffprobe -v quiet -of json -show_format -show_streams " + - pipes.quote(self.path_to_video) - ] + if not os.path.isfile(self.path_to_video): + raise IOError(f"No such media file '{self.path_to_video}'.") - p = subprocess.Popen( - args=cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - shell=True - ) - p.wait() - data, err = p.communicate() - if p.returncode == 0: - parsed: dict = json.loads(data.decode('utf-8')) - else: - raise FFProbeError(f"FFProbe error: {err}") + args = [ + '-v', 'quiet', + '-of', 'json', + '-show_streams', + '-show_format', + self.path_to_video, + ] + p = await asyncio.create_subprocess_exec( + 'ffprobe', *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) - stream = False - self.streams = [] - self.video = [] - self.audio = [] - self.subtitle = [] - self.attachment = [] + exitCode = await p.wait() - for stream in parsed['streams'] if 'streams' in parsed else []: - self.streams.append(FFStream(stream)) - - self.metadata = parsed['format'] if 'format' in parsed else {} - - for stream in self.streams: - if stream.is_audio(): - self.audio.append(stream) - elif stream.is_video(): - self.video.append(stream) - elif stream.is_subtitle(): - self.subtitle.append(stream) - elif stream.is_attachment(): - self.attachment.append(stream) + data, err = await p.communicate() + if 0 == exitCode: + parsed: dict = json.loads(data.decode('utf-8')) else: - raise IOError('No such media file ' + self.path_to_video) + raise FFProbeError(f"FFProbe return with non-0 exit code. '{err.decode('utf-8')}'") + + stream = False + self.streams = [] + self.video = [] + self.audio = [] + self.subtitle = [] + self.attachment = [] + + for stream in parsed.get('streams', []): + self.streams.append(FFStream(stream)) + + self.metadata = parsed.get('format', {}) + + for stream in self.streams: + if stream.is_audio(): + self.audio.append(stream) + elif stream.is_video(): + self.video.append(stream) + elif stream.is_subtitle(): + self.subtitle.append(stream) + elif stream.is_attachment(): + self.attachment.append(stream) def __repr__(self): return "".format(**vars(self)) - diff --git a/frontend/src/components/Video-Player.vue b/frontend/src/components/Video-Player.vue index 30194ff1..0acfc8ad 100644 --- a/frontend/src/components/Video-Player.vue +++ b/frontend/src/components/Video-Player.vue @@ -72,6 +72,7 @@ const prepareVideoPlayer = () => { enableWorker: true, lowLatencyMode: true, backBufferLength: 90, + fragLoadingTimeOut: 200000, }); hls.value.loadSource(props.link)