From e045f4d6e60e3a525ce06db0eccfe6fc3896228c Mon Sep 17 00:00:00 2001 From: gsd Date: Sat, 10 Aug 2024 22:52:36 +0300 Subject: [PATCH] transcode tools v2 --- .gitignore | 2 + backend/config_parser.py | 177 ++++++++++++++++++++++++++++++++++++++- backend/server.py | 50 +++++++++++ 3 files changed, 227 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 8c25b96..d8367d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ config.json __pycache__ +backend/MiskaRisa264 +backend/transcode # See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. diff --git a/backend/config_parser.py b/backend/config_parser.py index f764247..38892dd 100644 --- a/backend/config_parser.py +++ b/backend/config_parser.py @@ -1,9 +1,14 @@ +from genericpath import exists import os, sys from json import loads +from turtle import width +import uuid from asyncio_dvrip import DVRIPCam import asyncio from nvr_core import NVR from nvr_types import File +import platform +import aiofiles def app_dir(): return os.path.dirname(os.path.abspath(__file__)) @@ -38,8 +43,155 @@ class Recorder: else: return self.name +class TranscodeStatus: + def __init__(self, b64) -> None: + self.b64 = b64 + self.uuid = str(uuid.uuid4) + self.h264 = 0 + self.downloaded_h264_bytes = 0 + self.total_h264_bytes = 0 + self.avi = 0 + self.mp4 = 0 + self.outFile = None + self.done = False + self.outSize = 0 + + @property + def outName(self): + if self.outFile: + return os.path.split(self.outFile)[-1] + return "" + + async def generate_bytes(self): + async with aiofiles.open(self.outFile, "rb") as out: + yield await out.read(32 * 1024) + +class TranscodeTools: + statuses:dict[str, TranscodeStatus] = {} + WIN32PYTHON = "python-win32" + + def __init__(self, tools_directory, transcode_directory, hide_checks = True) -> None: + self.hide_checks = hide_checks + self.tools_directory = tools_directory + self.transcode_directory = transcode_directory + if not os.path.exists(tools_directory): + print("download git repo https://git.pblr-nyk.pro/gsd/MiskaRisa264 and place in backend folder to enable transcode tools") + self.enabled = False + else: + python_win32_exists = self.python_win32_exists + if not python_win32_exists: + print("download https://www.python.org/ftp/python/3.12.3/python-3.12.3-embed-win32.zip and unzip in backend/MiskaRisa/python-win32 all contains files") + + check_exists_needed_files = self.check_exists_needed_files + if not check_exists_needed_files: + print("MiskaRisa264 is not fully downloaded, watch in directory to find lost files") + + check_ffmpeg = self.check_ffmpeg() + if not check_ffmpeg: + print("ffmpeg in not installed on system or on windows in not in PATH env") + + check_converter = self.check_converter() + if not check_converter: + print("failed run h264_converter.py with python-win32") + + self.enabled = check_exists_needed_files and python_win32_exists and check_ffmpeg and check_converter + if not self.enabled: + print("Cannot enabled transcode tools, have a errors on init, run config_parser with --no-hide-check parameters to more info") + + print("Transcode tools", "enabled" if self.enabled else "disabled") + + @property + def check_exists_needed_files(self): + for file in ["H264Play.dll", "h264_converter.py", "StreamReader.dll"]: + if not os.path.exists(os.path.join(self.tools_directory, file)): + return False + return True + + @property + def python_win32_exists(self): + return os.path.exists(os.path.join(self.tools_directory, self.WIN32PYTHON)) + + @property + def python_win32(self): + return os.path.join(self.tools_directory, self.WIN32PYTHON, "python.exe") + + @property + def converter_script(self): + return os.path.join(self.tools_directory, "h264_converter.py") + + def check_ffmpeg(self): + from subprocess import call, DEVNULL + try: + return not call("ffmpeg -version".split(), stdin=DEVNULL if self.hide_checks else None, stdout=DEVNULL if self.hide_checks else None, stderr=DEVNULL if self.hide_checks else None) + except: + return False + + def check_converter(self): + from subprocess import call, DEVNULL + try: + return not call(f"{self.python_win32} {self.converter_script} --help".split(), stdin=DEVNULL if self.hide_checks else None, stdout=DEVNULL if self.hide_checks else None, stderr=DEVNULL if self.hide_checks else None) + except: + return False + + async def h264toavi(self, source_file, delete_source_file = False): + exec_string = "" + if platform.system() == "Windows": + exec_string += "" + elif platform.system() == "Linux": + exec_string += "wine " + else: + raise Exception("Unknown platform to transcode") + + exec_string += str(self.converter_script) + " " + exec_string += str(source_file) + proc = await asyncio.create_subprocess_exec(exec_string.split()) + await proc.wait() + + if delete_source_file: + os.remove(source_file) + if os.path.exists(source_file + ".avi"): + return source_file + ".avi" + else: + raise Exception("AVI not be created") + + async def avitomp4(self, source_file, delete_source_file = False): + exec_string = f"ffmpeg -y -i {source_file} {source_file}.mp4" + proc = await asyncio.create_subprocess_exec(exec_string.split()) + await proc.wait() + + if delete_source_file: + os.remove(source_file) + if os.path.exists(source_file + ".mp4"): + return source_file + ".mp4" + else: + raise Exception("MP4 not be created") + + def deleteFile(self, source_file): + os.remove(source_file) + + async def processing(self, status: TranscodeStatus, file:File, nvr: NVR): + raw_file = os.path.join(self.transcode_directory, status.uuid + ".h264") + async with aiofiles.open(raw_file, "wb") as raw: + self.statuses[status.uuid].total_h264_bytes = file.size + async for chunk in nvr.stream_file(file): + self.statuses[status.uuid].downloaded_h264_bytes += len(chunk) + await raw.write(chunk) + nvr.logout() + + self.statuses[status.uuid].avi = 0 + avi_file = await self.h264toavi(raw_file) + self.statuses[status.uuid].avi = 100 + + self.statuses[status.uuid].mp4 = 0 + mp4_file = await self.avitomp4(avi_file) + self.statuses[status.uuid].mp4 = 100 + + self.statuses[status.uuid].outFile = mp4_file + self.statuses[status.uuid].done = True + self.statuses[status.uuid].outSize = os.path.getsize(mp4_file) + class Config: - def __init__(self, config_name = "config.json") -> None: + def __init__(self, config_name = "config.json", args = None) -> None: raw = load_config(config_name) self.listen_address = raw.get("backend", {}).get("address", "0.0.0.0") self.listen_port = int(raw.get("backend", {}).get("port", "8080")) @@ -51,9 +203,30 @@ class Config: else: for recorder in self.recorders: print(recorder) + self.transcode_tools:TranscodeTools = self.check_transcode_tools(not args.no_hide_check if args != None else True) def getRecorder(self, index = 0) -> Recorder: return self.recorders[index] def getRecorders(self): - return [str(r) for r in self.recorders] \ No newline at end of file + return [str(r) for r in self.recorders] + + def check_transcode_directory(self): + t_dir = os.path.join(app_dir(), "transcode") + if not os.path.exists(t_dir): + os.mkdir(t_dir) + return t_dir + + def check_transcode_tools(self, hide_check): + tools_dir = os.path.join(app_dir(), "MiskaRisa264") + return TranscodeTools(tools_dir, self.check_transcode_directory, hide_check) + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("--no-hide-check", action="store_true") + args = parser.parse_args() + + if args.no_hide_check: + Config(args = args) + sys.exit(0) \ No newline at end of file diff --git a/backend/server.py b/backend/server.py index 7b85bc5..69a2b38 100644 --- a/backend/server.py +++ b/backend/server.py @@ -4,6 +4,7 @@ import uvicorn import traceback from config_parser import Config as ConfigParser +from config_parser import TranscodeStatus, TranscodeTools from nvr_core import NVR from nvr_types import File @@ -102,6 +103,55 @@ class Server: response.status_code = 400 return {"ok":False, "error":e} + @self.app.get("/api/transcode/status/{recorder_index}") + async def getTranscodeStatus(response: Response, recorder_index:int, b64:str, background_tasks: BackgroundTasks): + try: + if len(b64) == 0: + response.status_code = 404 + return "" + + if b64 in self.config.transcode_tools.statuses: + return self.config.transcode_tools.statuses[b64] + + nvr:NVR = self.config.getRecorder(recorder_index).nvr + await nvr.login() + nvr.client.debug() + file: File = File.from_b64(b64 + "==") + + self.config.transcode_tools.statuses[b64] = TranscodeStatus(b64) + background_tasks.add_task(self.config.transcode_tools.processing, status = self.config.transcode_tools.statuses[b64], file = file, nvr = nvr) + return {"ok":True, "data":self.config.transcode_tools.statuses[b64]} + except Exception as e: + traceback.print_exc() + response.status_code = 400 + return {"ok":False, "error":e} + + @self.app.get("/api/transcode/download") + async def getTranscodeDownload(response: Response, b64:str): + try: + if len(b64) == 0: + response.status_code = 404 + return "" + + if not b64 in self.config.transcode_tools.statuses: + response.status_code = 404 + return "" + + if self.config.transcode_tools.statuses[b64].done: + headers = {} + headers.update({"Content-Length":str(self.config.transcode_tools.statuses[b64].outSize)}) + headers.update({"Content-Disposition": f'attachment; filename="{self.config.transcode_tools.statuses[b64].outName}"'}) + return StreamingResponse(self.config.transcode_tools.statuses[b64].generate_bytes, media_type="application/octet-stream", headers=headers) + else: + response.status_code = 429 + return "" + + except Exception as e: + traceback.print_exc() + response.status_code = 400 + return {"ok":False, "error":e} + + def run(self): uvicorn.run( self.app,