Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for the new download mechanism used in Unifi OS 4 #38

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 195 additions & 9 deletions src/uiprotect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1480,7 +1480,134 @@
if progress_callback is not None:
await progress_callback(step, current, total)

async def get_camera_video(
async def download_camera_video(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add some tests for the new methods

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.
"""
Comment on lines +1483 to +1501
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enhance error handling and optimize memory usage in download_camera_video.

  1. Memory Usage: Consider always writing to a temporary file if output_file is not provided to avoid high memory consumption.
  2. Error Handling: Add error handling to manage potential failures in network requests or file operations.
  3. Test Coverage: The function lacks test coverage for several lines, as indicated by static analysis.

Here's a proposed improvement for handling memory usage:

import tempfile
from pathlib import Path

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:
    if output_file is None:
        temp_file = Path(tempfile.mkstemp()[1])
        await self._download_to_file(camera_id, filename, temp_file, iterator_callback, progress_callback, chunk_size)
        with open(temp_file, 'rb') as file:
            return file.read()
    else:
        await self._download_to_file(camera_id, filename, output_file, iterator_callback, progress_callback, chunk_size)
    return None

async def _download_to_file(
    self,
    camera_id: str,
    filename: str,
    output_file: Path,
    iterator_callback: IteratorCallback | None,
    progress_callback: ProgressCallback | None,
    chunk_size: int
) -> None:
    # Existing logic for downloading to a file

path = "video/download"

Check warning on line 1502 in src/uiprotect/api.py

View check run for this annotation

Codecov / codecov/patch

src/uiprotect/api.py#L1502

Added line #L1502 was not covered by tests

params = {

Check warning on line 1504 in src/uiprotect/api.py

View check run for this annotation

Codecov / codecov/patch

src/uiprotect/api.py#L1504

Added line #L1504 was not covered by tests
"camera": camera_id,
"filename": filename,
}

if (

Check warning on line 1509 in src/uiprotect/api.py

View check run for this annotation

Codecov / codecov/patch

src/uiprotect/api.py#L1509

Added line #L1509 was not covered by tests
iterator_callback is None
and progress_callback is None
and output_file is None
):
return await self.api_request_raw(

Check warning on line 1514 in src/uiprotect/api.py

View check run for this annotation

Codecov / codecov/patch

src/uiprotect/api.py#L1514

Added line #L1514 was not covered by tests
path,
params=params,
raise_exception=False,
)
Comment on lines +1483 to +1518
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential memory usage issue when downloading large video files

The method download_camera_video allows downloading large video files directly into memory if neither iterator_callback, progress_callback, nor output_file are provided. This can lead to high memory usage which might cause performance issues or crashes, especially for large video files.

- if (iterator_callback is None and progress_callback is None and output_file is None):
+ if output_file is None:
+     temp_file = Path(tempfile.mkstemp()[1])
+     result = await self.download_camera_video(
+         camera_id=camera_id,
+         filename=filename,
+         output_file=temp_file,
+         iterator_callback=iterator_callback,
+         progress_callback=progress_callback,
+         chunk_size=chunk_size,
+     )
+     with open(temp_file, 'rb') as file:
+         return file.read()
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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,
)
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 output_file is None:
temp_file = Path(tempfile.mkstemp()[1])
result = await self.download_camera_video(
camera_id=camera_id,
filename=filename,
output_file=temp_file,
iterator_callback=iterator_callback,
progress_callback=progress_callback,
chunk_size=chunk_size,
)
with open(temp_file, 'rb') as file:
return file.read()
Tools
GitHub Check: codecov/patch

[warning] 1499-1499: src/uiprotect/api.py#L1499
Added line #L1499 was not covered by tests


[warning] 1501-1501: src/uiprotect/api.py#L1501
Added line #L1501 was not covered by tests


[warning] 1511-1511: src/uiprotect/api.py#L1511
Added line #L1511 was not covered by tests


r = await self._os.request(

Check warning on line 1520 in src/uiprotect/api.py

View check run for this annotation

Codecov / codecov/patch

src/uiprotect/api.py#L1520

Added line #L1520 was not covered by tests
"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:

Check warning on line 1528 in src/uiprotect/api.py

View check run for this annotation

Codecov / codecov/patch

src/uiprotect/api.py#L1527-L1528

Added lines #L1527 - L1528 were not covered by tests

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)

Check warning on line 1534 in src/uiprotect/api.py

View check run for this annotation

Codecov / codecov/patch

src/uiprotect/api.py#L1530-L1534

Added lines #L1530 - L1534 were not covered by tests

await self._stream_response(r, chunk_size, callback, progress_callback)

Check warning on line 1536 in src/uiprotect/api.py

View check run for this annotation

Codecov / codecov/patch

src/uiprotect/api.py#L1536

Added line #L1536 was not covered by tests
else:
await self._stream_response(

Check warning on line 1538 in src/uiprotect/api.py

View check run for this annotation

Codecov / codecov/patch

src/uiprotect/api.py#L1538

Added line #L1538 was not covered by tests
r,
chunk_size,
iterator_callback,
progress_callback,
)
r.close()
return None

Check warning on line 1545 in src/uiprotect/api.py

View check run for this annotation

Codecov / codecov/patch

src/uiprotect/api.py#L1544-L1545

Added lines #L1544 - L1545 were not covered by tests
Comment on lines +1483 to +1545
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optimize memory usage and error handling in download_camera_video.

  1. Memory Usage: When neither iterator_callback, progress_callback, nor output_file are provided, the method downloads the entire video into memory. This could lead to high memory usage, especially for large video files. Consider always writing to a temporary file if output_file is not provided to avoid high memory consumption.

  2. Error Handling: The method lacks explicit error handling for network requests and file operations, which could lead to unhandled exceptions. Consider adding error handling to manage potential failures in network requests or file operations.

  3. Resource Management: Ensure that the response object r is always properly closed, even in the case of exceptions. This can be achieved by using a context manager or ensuring r.close() is called in a finally block.

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 | None:
    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,
        )

    try:
        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()
    except Exception as e:
        _LOGGER.error(f"Failed to download video: {str(e)}")
        raise
    return None
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 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 | None:
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,
)
try:
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()
except Exception as e:
_LOGGER.error(f"Failed to download video: {str(e)}")
raise
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

Check warning on line 1576 in src/uiprotect/api.py

View check run for this annotation

Codecov / codecov/patch

src/uiprotect/api.py#L1575-L1576

Added lines #L1575 - L1576 were not covered by tests

Comment on lines +1547 to +1577
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enhance error handling in prepare_camera_video method.

The method catches IndexError to handle invalid channel_index but does not account for other potential issues such as KeyError if camera_id is not valid. Improve the error handling to cover these cases.

-            except IndexError as e:
-                raise BadRequest from e
+            except (IndexError, KeyError) as e:
+                raise BadRequest(f"Invalid input: {str(e)}") from e
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
if validate_channel_id and self._bootstrap is not None:
try:
camera = self._bootstrap.cameras[camera_id]
camera.channels[channel_index]
except (IndexError, KeyError) as e:
raise BadRequest(f"Invalid input: {str(e)}") from e

params = {
"camera": camera_id,
"start": to_js_time(start),
"end": to_js_time(end),
}

if channel_index == 3:
params.update({"lens": 2})

Check warning on line 1585 in src/uiprotect/api.py

View check run for this annotation

Codecov / codecov/patch

src/uiprotect/api.py#L1585

Added line #L1585 was not covered by tests
else:
params.update({"channel": channel_index})

if fps is not None and fps > 0:
params["fps"] = fps
params["type"] = "timelapse"

Check warning on line 1591 in src/uiprotect/api.py

View check run for this annotation

Codecov / codecov/patch

src/uiprotect/api.py#L1590-L1591

Added lines #L1590 - L1591 were not covered by tests
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,
)
Comment on lines +1547 to +1608
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enhance error handling and optimize fps handling in prepare_camera_video.

  1. Error Handling: Add checks to ensure that camera and camera.channels are valid before accessing them to prevent AttributeError or KeyError.
  2. Optimizing fps Handling: Avoid unnecessary dictionary operations by checking if fps is not None before setting params['type'].
  3. Test Coverage: The function lacks test coverage for several lines, as indicated by static analysis.

Here's a proposed improvement for error handling:

if validate_channel_id and self._bootstrap is not None:
    try:
        camera = self._bootstrap.cameras[camera_id]
        if channel_index >= len(camera.channels):
            raise IndexError("Channel index out of range")
    except (KeyError, AttributeError, IndexError) as e:
        raise BadRequest(f"Invalid input: {str(e)}") from e
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 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]
if channel_index >= len(camera.channels):
raise IndexError("Channel index out of range")
except (KeyError, AttributeError, IndexError) as e:
raise BadRequest(f"Invalid input: {str(e)}") 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,
)
Tools
GitHub Check: codecov/patch

[warning] 1575-1576: src/uiprotect/api.py#L1575-L1576
Added lines #L1575 - L1576 were not covered by tests


[warning] 1585-1585: src/uiprotect/api.py#L1585
Added line #L1585 was not covered by tests


[warning] 1590-1591: src/uiprotect/api.py#L1590-L1591
Added lines #L1590 - L1591 were not covered by tests


async def export_camera_video(
self,
camera_id: str,
start: datetime,
Expand All @@ -1492,13 +1619,13 @@
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.

Expand All @@ -1507,8 +1634,8 @@
(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
Expand All @@ -1519,16 +1646,17 @@
"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"

Check warning on line 1656 in src/uiprotect/api.py

View check run for this annotation

Codecov / codecov/patch

src/uiprotect/api.py#L1655-L1656

Added lines #L1655 - L1656 were not covered by tests

path = "video/export"

if (
iterator_callback is None
and progress_callback is None
Expand All @@ -1540,7 +1668,7 @@
raise_exception=False,
)

r = await self.request(
r = await self._os.request(

Check warning on line 1671 in src/uiprotect/api.py

View check run for this annotation

Codecov / codecov/patch

src/uiprotect/api.py#L1671

Added line #L1671 was not covered by tests
"get",
urljoin(self.api_path, path),
auto_close=False,
Expand All @@ -1567,6 +1695,64 @@
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

Check warning on line 1732 in src/uiprotect/api.py

View check run for this annotation

Codecov / codecov/patch

src/uiprotect/api.py#L1732

Added line #L1732 was not covered by tests
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please avoid using a broad exception raise in a try block to control flow


return await self.download_camera_video(

Check warning on line 1734 in src/uiprotect/api.py

View check run for this annotation

Codecov / codecov/patch

src/uiprotect/api.py#L1734

Added line #L1734 was not covered by tests
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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please avoid a broad exception catch and only catch exceptions that would confirm the need to fallback to the other method as it can unexpectedly hide bugs

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was quite intentional, as it's supposed to fall back on the previous video/export on any kind of error, including using it on a pre-4 Protect installation whose responses I can't predict since I have no platform to test it on. The get_camera_video() needs to be deprecated in favor of either using the export_camera_video method, which is functionally identical, or the new prepare/download_camera_video methods used in Protect 4.

Copy link
Member

@bdraco bdraco Jun 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's better for it to fail than to have undefined behavior since if it falls back without warning the problem will never get fixed, and when the deprecated function is removed we will find out the hard way when it breaks which will be harder to fix.

Please narrow the exception catch to only exceptions that are expected that should trigger the fallback.

https://pylint.readthedocs.io/en/latest/user_guide/messages/warning/broad-exception-caught.html

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also needs a test to simulate a failure so we know under which conditions it should fallback to the old api

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should also log a message when it falls back so we know it's happening so we have a chance to fix whatever is causing the failure

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,
)
Comment on lines +1698 to +1754
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Narrow down exceptions and add logging in get_camera_video.

  1. Exception Handling: Narrow down the exceptions to those that are expected to occur, such as BadRequest or NvrError. This prevents masking other important exceptions.
  2. Logging: Add logging to capture fallback scenarios for better debugging.
  3. Test Coverage: The function lacks test coverage for certain lines, as indicated by static analysis.

Here's a proposed improvement:

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 BadRequest("Invalid response from prepare_camera_video")

    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 (BadRequest, NvrError) as e:
    _LOGGER.error(f"Error during video retrieval: {str(e)}")
    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,
    )
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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_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 BadRequest("Invalid response from prepare_camera_video")
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 (BadRequest, NvrError) as e:
_LOGGER.error(f"Error during video retrieval: {str(e)}")
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,
)
Tools
GitHub Check: codecov/patch

[warning] 1732-1732: src/uiprotect/api.py#L1732
Added line #L1732 was not covered by tests


[warning] 1734-1734: src/uiprotect/api.py#L1734
Added line #L1734 was not covered by tests


Comment on lines +1698 to +1755
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Narrow down exceptions and add logging in get_camera_video.

The method catches a broad Exception, which could mask other important exceptions that should be handled specifically. Narrow down the exceptions to those that are expected to occur. Additionally, add logging to capture fallback scenarios for better debugging.

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 | None:
    """
    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 BadRequest("Invalid response from prepare_camera_video")

        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 (BadRequest, NvrError) as e:
        _LOGGER.error(f"Error during video retrieval: {str(e)}")
        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,
        )
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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_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 | None:
"""
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 BadRequest("Invalid response from prepare_camera_video")
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 (BadRequest, NvrError) as e:
_LOGGER.error(f"Error during video retrieval: {str(e)}")
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,
Expand Down
Loading