Merge pull request #82 from arabcoders/dev

Made the video player async to not block some requests.
This commit is contained in:
Abdulmohsen 2024-03-25 23:48:09 +03:00 committed by GitHub
commit 97a9cd0751
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 208 additions and 167 deletions

View file

@ -28,13 +28,7 @@ class DataStore:
def load(self) -> None: def load(self) -> None:
for id, item in self.saved_items(): for id, item in self.saved_items():
self.dict.update({id: Download( self.dict.update({id: Download(info=item)})
info=item,
download_dir=calcDownloadPath(basePath=self.config.download_path, folder=item.folder),
temp_dir=self.config.temp_path,
output_template_chapter=self.config.output_template_chapter,
default_ytdl_opts=self.config.ytdl_options)
})
def exists(self, key: str = None, url: str = None) -> bool: def exists(self, key: str = None, url: str = None) -> bool:
if not key and not url: if not key and not url:

View file

@ -6,12 +6,12 @@ import os
import re import re
import shutil import shutil
import yt_dlp import yt_dlp
import hashlib
from Utils import Notifier, get_format, get_opts, jsonCookie, mergeConfig from Utils import Notifier, get_format, get_opts, jsonCookie, mergeConfig
from ItemDTO import ItemDTO from ItemDTO import ItemDTO
from Config import Config from Config import Config
import hashlib
LOG = logging.getLogger('download') LOG = logging.getLogger('download')
@ -60,25 +60,18 @@ class Download:
tempKeep: bool = False tempKeep: bool = False
"Keep temp directory after download." "Keep temp directory after download."
def __init__( def __init__(self, info: ItemDTO, info_dict: dict = None, debug: bool = False):
self, config = Config.get_instance()
info: ItemDTO,
download_dir: str, self.download_dir = info.download_dir
temp_dir: str, self.temp_dir = info.temp_dir
output_template_chapter: str, self.output_template_chapter = info.output_template_chapter
default_ytdl_opts: dict,
info_dict: dict = None,
debug: bool = False
):
self.download_dir = download_dir
self.temp_dir = temp_dir
self.output_template_chapter = output_template_chapter
self.output_template = info.output_template self.output_template = info.output_template
self.format = get_format(info.format, info.quality) self.format = get_format(info.format, info.quality)
self.ytdl_opts = get_opts(info.format, info.quality, info.ytdlp_config if info.ytdlp_config else {}) self.ytdl_opts = get_opts(info.format, info.quality, info.ytdlp_config if info.ytdlp_config else {})
self.info = info self.info = info
self.id = info._id self.id = info._id
self.default_ytdl_opts = default_ytdl_opts self.default_ytdl_opts = config.ytdl_options
self.debug = debug self.debug = debug
self.canceled = False self.canceled = False
@ -87,8 +80,8 @@ class Download:
self.proc = None self.proc = None
self.loop = None self.loop = None
self.notifier = None self.notifier = None
self.max_workers = int(Config.get_instance().max_workers) self.max_workers = int(config.max_workers)
self.tempKeep = bool(Config.get_instance().temp_keep) self.tempKeep = bool(config.temp_keep)
self.is_live = bool(info.is_live) or info.live_in is not None self.is_live = bool(info.is_live) or info.live_in is not None
self.is_manifestless = 'is_manifestless' in self.info.options and self.info.options['is_manifestless'] is True self.is_manifestless = 'is_manifestless' in self.info.options and self.info.options['is_manifestless'] is True
self.info_dict = info_dict self.info_dict = info_dict
@ -190,9 +183,7 @@ class Download:
LOG.info(f'Finished {os.getpid()=} id="{self.info.id}" title="{self.info.title}".') LOG.info(f'Finished {os.getpid()=} id="{self.info.id}" title="{self.info.title}".')
async def start(self, notifier: Notifier): async def start(self, notifier: Notifier):
self.manager = multiprocessing.Manager() if self.manager is None else self.manager self.status_queue = multiprocessing.Manager().Queue()
self.status_queue = self.manager.Queue()
self.loop = asyncio.get_running_loop() self.loop = asyncio.get_running_loop()
self.notifier = notifier self.notifier = notifier
@ -205,33 +196,59 @@ class Download:
self.proc = multiprocessing.Process(target=self._download) self.proc = multiprocessing.Process(target=self._download)
self.proc.start() self.proc.start()
self.info.status = 'preparing' self.info.status = 'preparing'
await self.notifier.updated(self.info)
asyncio.create_task(self.notifier.updated(self.info))
asyncio.create_task(self.progress_update()) asyncio.create_task(self.progress_update())
return await self.loop.run_in_executor(None, self.proc.join) return await self.loop.run_in_executor(None, self.proc.join)
def started(self) -> bool: def started(self) -> bool:
return self.proc is not None return self.proc is not None
def cancel(self): def cancel(self) -> bool:
self.kill() if not self.started():
self.canceled = True return False
def close(self): if self.kill():
if self.started(): self.canceled = True
return True
def close(self) -> bool:
if not self.started():
return False
try:
LOG.info(f"Closing download process: '{self.proc.ident}'.")
self.proc.close() self.proc.close()
self.delete_temp()
return True
except Exception as e:
LOG.error(f"Failed to close process: '{self.proc.ident}'. {e}")
self.delete_temp() return False
def running(self) -> bool: def running(self) -> bool:
return self.started() and self.proc.is_alive() try:
return self.proc is not None and self.proc.is_alive()
except ValueError:
return False
def is_canceled(self) -> bool: def is_canceled(self) -> bool:
return self.canceled return self.canceled
def kill(self): def kill(self) -> bool:
if self.running(): if not self.started():
LOG.info(f'Killing download process: {self.proc.ident}') return False
try:
LOG.info(f"Killing download process: '{self.proc.ident}'.")
self.proc.kill() self.proc.kill()
return True
except Exception as e:
LOG.error(f"Failed to kill process: '{self.proc.ident}'. {e}")
return False
def delete_temp(self): def delete_temp(self):
if self.tempKeep is True or not self.tempPath: if self.tempKeep is True or not self.tempPath:
@ -266,7 +283,7 @@ class Download:
LOG.debug(f'Status Update: {self.info._id=} {status=}') LOG.debug(f'Status Update: {self.info._id=} {status=}')
if isinstance(status, str): if isinstance(status, str):
await self.notifier.updated(self.info) asyncio.create_task(self.notifier.updated(self.info))
return return
self.tmpfilename = status.get('tmpfilename') self.tmpfilename = status.get('tmpfilename')
@ -286,7 +303,7 @@ class Download:
if self.info.status == 'error' and 'error' in status: if self.info.status == 'error' and 'error' in status:
self.info.error = status.get('error') self.info.error = status.get('error')
await self.notifier.error(self.info, self.info.error) asyncio.create_task(self.notifier.error(self.info, self.info.error))
if 'downloaded_bytes' in status: if 'downloaded_bytes' in status:
total = status.get('total_bytes') or status.get('total_bytes_estimate') total = status.get('total_bytes') or status.get('total_bytes_estimate')
@ -305,4 +322,4 @@ class Download:
self.info.file_size = None self.info.file_size = None
pass pass
await self.notifier.updated(self.info) asyncio.create_task(self.notifier.updated(self.info))

View file

@ -126,6 +126,12 @@ class DownloadQueue:
live_status: list = ['is_live', 'is_upcoming'] live_status: list = ['is_live', 'is_upcoming']
is_live = entry.get('is_live', None) or live_in or entry.get('live_status', None) in live_status is_live = entry.get('is_live', None) or live_in or entry.get('live_status', None) in live_status
try:
download_dir = calcDownloadPath(basePath=self.config.download_path, folder=folder)
except Exception as e:
LOG.exception(e)
return {'status': 'error', 'msg': str(e)}
dl = ItemDTO( dl = ItemDTO(
id=entry.get('id'), id=entry.get('id'),
title=entry.get('title'), title=entry.get('title'),
@ -133,37 +139,24 @@ class DownloadQueue:
quality=quality, quality=quality,
format=format, format=format,
folder=folder, folder=folder,
download_dir=download_dir,
temp_dir=self.config.temp_path,
ytdlp_cookies=ytdlp_cookies, ytdlp_cookies=ytdlp_cookies,
ytdlp_config=ytdlp_config, ytdlp_config=ytdlp_config,
output_template=output_template if output_template else self.config.output_template, output_template=output_template if output_template else self.config.output_template,
output_template_chapter=self.config.output_template_chapter,
datetime=formatdate(time.time()), datetime=formatdate(time.time()),
error=error, error=error,
is_live=is_live, is_live=is_live,
live_in=live_in, live_in=live_in,
options=options options=options,
) )
try:
download_dir = calcDownloadPath(basePath=self.config.download_path, folder=folder)
except Exception as e:
LOG.exception(e)
return {'status': 'error', 'msg': str(e)}
output_chapter = self.config.output_template_chapter
for property, value in entry.items(): for property, value in entry.items():
if property.startswith("playlist"): if property.startswith("playlist"):
dl.output_template = dl.output_template.replace(f"%({property})s", str(value)) dl.output_template = dl.output_template.replace(f"%({property})s", str(value))
dlInfo: Download = Download( dlInfo: Download = Download(info=dl, info_dict=entry, debug=bool(self.config.ytdl_debug))
info=dl,
download_dir=download_dir,
temp_dir=self.config.temp_path,
output_template_chapter=output_chapter,
default_ytdl_opts=self.config.ytdl_options,
info_dict=entry,
debug=bool(self.config.ytdl_debug)
)
if dlInfo.info.live_in or 'is_upcoming' == entry.get('live_status', None): if dlInfo.info.live_in or 'is_upcoming' == entry.get('live_status', None):
dlInfo.info.status = 'not_live' dlInfo.info.status = 'not_live'
@ -179,7 +172,7 @@ class DownloadQueue:
itemDownload = self.queue.put(dlInfo) itemDownload = self.queue.put(dlInfo)
self.event.set() self.event.set()
await self.notifier.emit(NotifyEvent, itemDownload.info) asyncio.create_task(self.notifier.emit(NotifyEvent, itemDownload.info))
return { return {
'status': 'ok' 'status': 'ok'
@ -270,11 +263,15 @@ class DownloadQueue:
already=already, already=already,
) )
async def cancel(self, ids): async def cancel(self, ids: list[str]) -> dict[str, str]:
status: dict[str, str] = {"status": "ok"}
for id in ids: for id in ids:
try: try:
item = self.queue.get(key=id) item = self.queue.get(key=id)
except KeyError as e: except KeyError as e:
status[id] = str(e)
status['status'] = 'error'
LOG.warning(f'Requested cancel for non-existent download {id=}. {str(e)}') LOG.warning(f'Requested cancel for non-existent download {id=}. {str(e)}')
continue continue
@ -284,33 +281,39 @@ class DownloadQueue:
LOG.debug(f'Canceling {itemMessage}') LOG.debug(f'Canceling {itemMessage}')
item.cancel() item.cancel()
LOG.info(f'Cancelled {itemMessage}') LOG.info(f'Cancelled {itemMessage}')
else: else:
item.close() item.close()
LOG.debug(f'Deleting from queue {itemMessage}') LOG.debug(f'Deleting from queue {itemMessage}')
self.queue.delete(id) self.queue.delete(id)
await self.notifier.canceled(id) asyncio.create_task(self.notifier.canceled(id))
self.done.put(item) self.done.put(item)
await self.notifier.completed(item) asyncio.create_task(self.notifier.completed(item))
LOG.info(f'Deleted from queue {itemMessage}') LOG.info(f'Deleted from queue {itemMessage}')
return {'status': 'ok'} status[id] = 'ok'
return status
async def clear(self, ids: list[str]) -> dict[str, str]:
status: dict[str, str] = {"status": "ok"}
async def clear(self, ids):
for id in ids: for id in ids:
try: try:
item = self.done.get(key=id) item = self.done.get(key=id)
except KeyError as e: except KeyError as e:
status[id] = str(e)
status['status'] = 'error'
LOG.warning(f'Requested delete for non-existent download {id=}. {str(e)}') LOG.warning(f'Requested delete for non-existent download {id=}. {str(e)}')
continue continue
itemMessage = f"{id=} {item.info.id=} {item.info.title=}" itemMessage = f"{id=} {item.info.id=} {item.info.title=}"
LOG.debug(f'Deleting completed download {itemMessage}') LOG.debug(f'Deleting completed download {itemMessage}')
self.done.delete(id) self.done.delete(id)
await self.notifier.cleared(id) asyncio.create_task(self.notifier.cleared(id))
LOG.info(f'Deleted completed download {itemMessage}') LOG.info(f'Deleted completed download {itemMessage}')
status[id] = 'ok'
return {'status': 'ok'} return status
def get(self) -> dict[str, list[dict[str, ItemDTO]]]: def get(self) -> dict[str, list[dict[str, ItemDTO]]]:
items = {'queue': {}, 'done': {}} items = {'queue': {}, 'done': {}}
@ -391,6 +394,7 @@ class DownloadQueue:
if entry.tmpfilename and os.path.isfile(entry.tmpfilename): if entry.tmpfilename and os.path.isfile(entry.tmpfilename):
try: try:
os.remove(entry.tmpfilename) os.remove(entry.tmpfilename)
entry.tmpfilename = None
except: except:
pass pass
@ -403,12 +407,12 @@ class DownloadQueue:
self.queue.delete(key=id) self.queue.delete(key=id)
if entry.is_canceled() is True: if entry.is_canceled() is True:
await self.notifier.canceled(id) asyncio.create_task(self.notifier.canceled(id))
entry.info.status = 'canceled' entry.info.status = 'canceled'
entry.info.error = 'Canceled by user.' entry.info.error = 'Canceled by user.'
self.done.put(value=entry) self.done.put(value=entry)
await self.notifier.completed(entry.info) asyncio.create_task(self.notifier.completed(entry.info))
self.event.set() self.event.set()

View file

@ -16,10 +16,13 @@ class ItemDTO:
quality: str quality: str
format: str format: str
folder: str folder: str
download_dir: str = None
temp_dir: str = None
status: str = None status: str = None
ytdlp_cookies: str = None ytdlp_cookies: str = None
ytdlp_config: dict = field(default_factory=dict) ytdlp_config: dict = field(default_factory=dict)
output_template: str = None output_template: str = None
output_template_chapter: str = None
timestamp: float = time.time_ns() timestamp: float = time.time_ns()
is_live: bool = None is_live: bool = None
datetime: str = field(default_factory=lambda: str(formatdate(time.time()))) datetime: str = field(default_factory=lambda: str(formatdate(time.time())))

View file

@ -291,6 +291,8 @@ class Main:
if not ids or where not in ['queue', 'done']: if not ids or where not in ['queue', 'done']:
raise web.HTTPBadRequest() raise web.HTTPBadRequest()
status: dict[str, str] = {}
status = await (self.queue.cancel(ids) if where == 'queue' else self.queue.clear(ids)) status = await (self.queue.cancel(ids) if where == 'queue' else self.queue.clear(ids))
return web.Response(text=self.serializer.encode(status)) return web.Response(text=self.serializer.encode(status))
@ -415,7 +417,7 @@ class Main:
raise web.HTTPBadRequest(reason='file is required.') raise web.HTTPBadRequest(reason='file is required.')
return web.Response( 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, download_path=self.config.download_path,
file=file file=file
), ),

View file

@ -16,19 +16,20 @@ class M3u8:
self.url = url self.url = url
self.segment_duration = float(segment_duration) 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) realFile: str = calcDownloadPath(basePath=download_path, folder=file, createPath=False)
if not os.path.exists(realFile): if not os.path.exists(realFile):
raise Exception(f"File {realFile} does not exist.") raise Exception(f"File '{realFile}' does not exist.")
try: try:
ffprobe = FFProbe(realFile) ffprobe = FFProbe(realFile)
await ffprobe.run()
except UnicodeDecodeError as e: except UnicodeDecodeError as e:
pass pass
if not 'duration' in ffprobe.metadata: 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')) duration: float = float(ffprobe.metadata.get('duration'))

View file

@ -1,3 +1,4 @@
import asyncio
import hashlib import hashlib
import logging import logging
import os import os
@ -37,69 +38,80 @@ class Segments:
else: else:
startTime: float = '{:.6f}'.format((self.segment_duration * self.segment_index)) startTime: float = '{:.6f}'.format((self.segment_duration * self.segment_index))
ffmpegCmd = [] fargs = []
ffmpegCmd.append('ffmpeg') fargs.append('-xerror')
ffmpegCmd.append('-xerror') fargs.append('-hide_banner')
ffmpegCmd.append('-hide_banner') fargs.append('-loglevel')
ffmpegCmd.append('-loglevel') fargs.append('error')
ffmpegCmd.append('error')
ffmpegCmd.append('-ss') fargs.append('-ss')
ffmpegCmd.append(str(startTime if startTime else '0.00000')) fargs.append(str(startTime if startTime else '0.00000'))
ffmpegCmd.append('-t') fargs.append('-t')
ffmpegCmd.append(str('{:.6f}'.format(self.segment_duration))) fargs.append(str('{:.6f}'.format(self.segment_duration)))
ffmpegCmd.append('-copyts') fargs.append('-copyts')
ffmpegCmd.append('-i') fargs.append('-i')
ffmpegCmd.append(f'file:{tmpFile}') fargs.append(f'file:{tmpFile}')
ffmpegCmd.append('-map_metadata') fargs.append('-map_metadata')
ffmpegCmd.append('-1') fargs.append('-1')
ffmpegCmd.append('-pix_fmt') fargs.append('-pix_fmt')
ffmpegCmd.append('yuv420p') fargs.append('yuv420p')
ffmpegCmd.append('-g') fargs.append('-g')
ffmpegCmd.append('52') fargs.append('52')
ffmpegCmd.append('-map') fargs.append('-map')
ffmpegCmd.append('0:v:0') fargs.append('0:v:0')
ffmpegCmd.append('-strict') fargs.append('-strict')
ffmpegCmd.append('-2') fargs.append('-2')
ffmpegCmd.append('-codec:v') fargs.append('-codec:v')
ffmpegCmd.append('libx264' if self.vconvert else 'copy') fargs.append('libx264' if self.vconvert else 'copy')
if self.vconvert: if self.vconvert:
ffmpegCmd.append('-crf') fargs.append('-crf')
ffmpegCmd.append('23') fargs.append('23')
ffmpegCmd.append('-preset:v') fargs.append('-preset:v')
ffmpegCmd.append('fast') fargs.append('fast')
ffmpegCmd.append('-level') fargs.append('-level')
ffmpegCmd.append('4.1') fargs.append('4.1')
ffmpegCmd.append('-profile:v') fargs.append('-profile:v')
ffmpegCmd.append('baseline') fargs.append('baseline')
# audio section. # audio section.
ffmpegCmd.append('-map') fargs.append('-map')
ffmpegCmd.append('0:a:0') fargs.append('0:a:0')
ffmpegCmd.append('-codec:a') fargs.append('-codec:a')
ffmpegCmd.append('aac' if self.aconvert else 'copy') fargs.append('aac' if self.aconvert else 'copy')
if self.aconvert: if self.aconvert:
ffmpegCmd.append('-b:a') fargs.append('-b:a')
ffmpegCmd.append('192k') fargs.append('192k')
ffmpegCmd.append('-ar') fargs.append('-ar')
ffmpegCmd.append('22050') fargs.append('22050')
ffmpegCmd.append('-ac') fargs.append('-ac')
ffmpegCmd.append('2') fargs.append('2')
ffmpegCmd.append('-sn') fargs.append('-sn')
ffmpegCmd.append('-muxdelay') fargs.append('-muxdelay')
ffmpegCmd.append('0') fargs.append('0')
ffmpegCmd.append('-f') fargs.append('-f')
ffmpegCmd.append('mpegts') fargs.append('mpegts')
ffmpegCmd.append('pipe:1') fargs.append('pipe:1')
LOG.debug(f'Streaming {realFile} segment {self.segment_index}.' + ' '.join(ffmpegCmd)) LOG.debug(f"Streaming '{realFile}' segment '{self.segment_index}'. " + " ".join(fargs))
proc = subprocess.run(ffmpegCmd, stdout=subprocess.PIPE)
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

View file

@ -1,8 +1,10 @@
""" """
Python wrapper for ffprobe command line tool. ffprobe must exist in the path. Python wrapper for ffprobe command line tool. ffprobe must exist in the path.
""" """
import asyncio
import functools import functools
import json import json
import logging
import operator import operator
import os import os
import pipes import pipes
@ -178,55 +180,60 @@ class FFProbe:
def __init__(self, path_to_video): def __init__(self, path_to_video):
self.path_to_video = path_to_video self.path_to_video = path_to_video
async def run(self):
try: try:
with open(os.devnull, 'w') as tempf: 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: except FileNotFoundError:
raise IOError('ffprobe not found.') raise IOError('ffprobe not found.')
if os.path.isfile(self.path_to_video): if not os.path.isfile(self.path_to_video):
cmd: list = [ raise IOError(f"No such media file '{self.path_to_video}'.")
"ffprobe -v quiet -of json -show_format -show_streams " +
pipes.quote(self.path_to_video)
]
p = subprocess.Popen( args = [
args=cmd, '-v', 'quiet',
stdout=subprocess.PIPE, '-of', 'json',
stderr=subprocess.PIPE, '-show_streams',
shell=True '-show_format',
) self.path_to_video,
p.wait() ]
data, err = p.communicate() p = await asyncio.create_subprocess_exec(
if p.returncode == 0: 'ffprobe', *args,
parsed: dict = json.loads(data.decode('utf-8')) stdout=asyncio.subprocess.PIPE,
else: stderr=asyncio.subprocess.PIPE,
raise FFProbeError(f"FFProbe error: {err}") )
stream = False exitCode = await p.wait()
self.streams = []
self.video = []
self.audio = []
self.subtitle = []
self.attachment = []
for stream in parsed['streams'] if 'streams' in parsed else []: data, err = await p.communicate()
self.streams.append(FFStream(stream)) if 0 == exitCode:
parsed: dict = json.loads(data.decode('utf-8'))
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)
else: 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): def __repr__(self):
return "<FFprobe: {metadata}, {video}, {audio}, {subtitle}, {attachment}>".format(**vars(self)) return "<FFprobe: {metadata}, {video}, {audio}, {subtitle}, {attachment}>".format(**vars(self))

View file

@ -72,6 +72,7 @@ const prepareVideoPlayer = () => {
enableWorker: true, enableWorker: true,
lowLatencyMode: true, lowLatencyMode: true,
backBufferLength: 90, backBufferLength: 90,
fragLoadingTimeOut: 200000,
}); });
hls.value.loadSource(props.link) hls.value.loadSource(props.link)