diff --git a/.gitignore b/.gitignore
index 3d8d6901..50679a3a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -184,3 +184,7 @@ event.json
test_websocket.py
test_raw.py
rawdata.json
+
+# IDE settings
+.vscode/
+.idea/
diff --git a/.idea/uiprotect.iml b/.idea/uiprotect.iml
deleted file mode 100644
index a46d9bbd..00000000
--- a/.idea/uiprotect.iml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/.idea/watcherTasks.xml b/.idea/watcherTasks.xml
deleted file mode 100644
index 22b6eba7..00000000
--- a/.idea/watcherTasks.xml
+++ /dev/null
@@ -1,65 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
deleted file mode 100644
index f2e6d220..00000000
--- a/.idea/workspace.xml
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/uiprotect/api.py b/src/uiprotect/api.py
index 8aa77fb3..b8492b5c 100644
--- a/src/uiprotect/api.py
+++ b/src/uiprotect/api.py
@@ -1451,7 +1451,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,
@@ -1463,13 +1590,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.
@@ -1478,8 +1605,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
@@ -1490,16 +1617,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
@@ -1511,7 +1639,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,
@@ -1538,6 +1666,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,