diff --git a/src/uiprotect/api.py b/src/uiprotect/api.py index 89fbf74e..99b0ad27 100644 --- a/src/uiprotect/api.py +++ b/src/uiprotect/api.py @@ -1480,7 +1480,134 @@ async def _stream_response( if progress_callback is not None: await progress_callback(step, current, total) - async def get_camera_video( + async def download_camera_video( + self, + camera_id: str, + filename: str, + output_file: Path | None = None, + iterator_callback: IteratorCallback | None = None, + progress_callback: ProgressCallback | None = None, + chunk_size: int = 65536, + ) -> bytes: + """ + Downloads a prepared MP4 video from a given camera. + + This is the newer implementation of retrieving video from Unifi Protect. + You need to supply the filename returned from prepare_camera_video(). + + It is recommended to provide an output file or progress callback for larger + video clips, otherwise the full video must be downloaded to memory before + being written. + """ + path = "video/download" + + params = { + "camera": camera_id, + "filename": filename, + } + + if ( + iterator_callback is None + and progress_callback is None + and output_file is None + ): + return await self.api_request_raw( + path, + params=params, + raise_exception=False, + ) + + r = await self._os.request( + "get", + urljoin(self.api_path, path), + auto_close=False, + timeout=0, + params=params, + ) + if output_file is not None: + async with aiofiles.open(output_file, "wb") as output: + + async def callback(total: int, chunk: bytes | None) -> None: + if iterator_callback is not None: + await iterator_callback(total, chunk) + if chunk is not None: + await output.write(chunk) + + await self._stream_response(r, chunk_size, callback, progress_callback) + else: + await self._stream_response( + r, + chunk_size, + iterator_callback, + progress_callback, + ) + r.close() + return None + + async def prepare_camera_video( + self, + camera_id: str, + start: datetime, + end: datetime, + channel_index: int = 0, + validate_channel_id: bool = True, + fps: int | None = None, + filename: str | None = None, + ) -> list[Any] | dict[str, Any] | None: + """ + Prepares MP4 video from a given camera at a specific time. + + This is the newer implementation of retrieving video from Unifi Protect. + When using this method, it should be followed up with download_camera_video(). + + Start/End of video export are approximate. It may be +/- a few seconds. + + Providing the `fps` parameter creates a "timelapse" export wtih the given FPS + value. Protect app gives the options for 60x (fps=4), 120x (fps=8), 300x + (fps=20), and 600x (fps=40). + + You will receive a filename and an expiry time in seconds. + """ + if validate_channel_id and self._bootstrap is not None: + try: + camera = self._bootstrap.cameras[camera_id] + camera.channels[channel_index] + except IndexError as e: + raise BadRequest from e + + params = { + "camera": camera_id, + "start": to_js_time(start), + "end": to_js_time(end), + } + + if channel_index == 3: + params.update({"lens": 2}) + else: + params.update({"channel": channel_index}) + + if fps is not None and fps > 0: + params["fps"] = fps + params["type"] = "timelapse" + else: + params["type"] = "rotating" + + if not filename: + start_str = start.strftime("%m-%d-%Y, %H.%M.%S %Z") + end_str = end.strftime("%m-%d-%Y, %H.%M.%S %Z") + filename = f"{camera_id} {start_str} - {end_str}.mp4" + + params["filename"] = filename + + path = "video/prepare" + + return await self.api_request( + path, + params=params, + raise_exception=True, + ) + + async def export_camera_video( self, camera_id: str, start: datetime, @@ -1492,13 +1619,13 @@ async def get_camera_video( progress_callback: ProgressCallback | None = None, chunk_size: int = 65536, fps: int | None = None, - ) -> bytes | None: + ) -> bytes: """ Exports MP4 video from a given camera at a specific time. Start/End of video export are approximate. It may be +/- a few seconds. - It is recommended to provide a output file or progress callback for larger + It is recommended to provide an output file or progress callback for larger video clips, otherwise the full video must be downloaded to memory before being written. @@ -1507,8 +1634,8 @@ async def get_camera_video( (fps=20), and 600x (fps=40). """ if validate_channel_id and self._bootstrap is not None: - camera = self._bootstrap.cameras[camera_id] try: + camera = self._bootstrap.cameras[camera_id] camera.channels[channel_index] except IndexError as e: raise BadRequest from e @@ -1519,16 +1646,17 @@ async def get_camera_video( "end": to_js_time(end), } - if fps is not None: - params["fps"] = fps - params["type"] = "timelapse" - if channel_index == 3: params.update({"lens": 2}) else: params.update({"channel": channel_index}) + if fps is not None and fps > 0: + params["fps"] = fps + params["type"] = "timelapse" + path = "video/export" + if ( iterator_callback is None and progress_callback is None @@ -1540,7 +1668,7 @@ async def get_camera_video( raise_exception=False, ) - r = await self.request( + r = await self._os.request( "get", urljoin(self.api_path, path), auto_close=False, @@ -1567,6 +1695,64 @@ async def callback(total: int, chunk: bytes | None) -> None: r.close() return None + async def get_camera_video( + self, + camera_id: str, + start: datetime, + end: datetime, + channel_index: int = 0, + validate_channel_id: bool = True, + output_file: Path | None = None, + iterator_callback: IteratorCallback | None = None, + progress_callback: ProgressCallback | None = None, + chunk_size: int = 65536, + fps: int | None = None, + filename: str | None = None, + ) -> bytes: + """ + Deprecated: maintained for backwards compatibility. + + If you are using Unifi Protect 4 or later, please use + prepare_camera_video() and download_camera_video() instead. + """ + try: + prepare_response = await self.prepare_camera_video( + camera_id=camera_id, + start=start, + end=end, + channel_index=channel_index, + validate_channel_id=validate_channel_id, + fps=fps, + filename=filename, + ) + + if isinstance(prepare_response, dict): + download_filename = prepare_response["fileName"] + else: + raise Exception + + return await self.download_camera_video( + camera_id=camera_id, + filename=download_filename, + output_file=output_file, + iterator_callback=iterator_callback, + progress_callback=progress_callback, + chunk_size=chunk_size, + ) + except Exception: + return await self.export_camera_video( + camera_id=camera_id, + start=start, + end=end, + channel_index=channel_index, + validate_channel_id=validate_channel_id, + output_file=output_file, + iterator_callback=iterator_callback, + progress_callback=progress_callback, + chunk_size=chunk_size, + fps=fps, + ) + async def _get_image_with_retry( self, path: str,