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

Conversation

cyberpower678
Copy link
Contributor

@cyberpower678 cyberpower678 commented Jun 11, 2024

Description of change

  • Adds export_camera_video() which is the exact implementation of what get_camera_video() used to be.
  • Adds prepare_camera_video() which is the new implementation used by Unifi Protect 4, and the recommended way to get videos.
  • Adds download_camera_video() which is the follow call that needs to be made after prepare_camera_video()
  • Rewrites get_camera_video() to automatically choose between export or prepare/download to maintain backwards compatability.

Pull-Request Checklist

  • [ x ] Code is up-to-date with the main branch
  • [ x ] This pull request follows the contributing guidelines.
  • This pull request links relevant issues as Fixes #0000 (N/A) (export fails frequently when used on new versions of Protect)
  • There are new or updated unit tests validating the change
  • Documentation has been updated to reflect this change
  • [ x ] The new commits follow conventions outlined in the conventional commit spec, such as "fix(api): prevent racing of requests".
  • If pre-commit.ci is failing, try pre-commit run -a for further information.
  • If CI / test is failing, try poetry run pytest for further information.

Summary by CodeRabbit

  • New Features

    • Introduced download_camera_video method to download prepared MP4 videos from cameras.
    • Added prepare_camera_video method to prepare MP4 videos from cameras at specific times.
  • Improvements

    • Updated export_camera_video method to include new parameters for better functionality.
  • Deprecations

    • Deprecated get_camera_video method. Users are encouraged to use prepare_camera_video and download_camera_video for compatibility with Unifi Protect 4 or later.
  • Chores

    • Updated .gitignore to include .vscode/ and .idea/ directories.

cyberpower678 and others added 2 commits June 11, 2024 18:32
Copy pull request changes I proposed to pyunifiprotect

Signed-off-by: cyberpower678 <[email protected]>
Copy link
Contributor

coderabbitai bot commented Jun 11, 2024

Walkthrough

The recent updates to src/uiprotect/api.py introduce significant enhancements in video management functionalities. The get_camera_video method has been renamed to download_camera_video, adding new parameters for improved customization. A new method, prepare_camera_video, facilitates video preparation, while the export_camera_video method has been updated for better functionality. The deprecated get_camera_video method ensures compatibility with older versions.

Changes

File Summary
src/uiprotect/api.py Renamed get_camera_video to download_camera_video, added prepare_camera_video, updated export_camera_video, and deprecated get_camera_video.
.gitignore Added .vscode/ and .idea/ directories to ignore IDE settings.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant API as uiprotect.api
    participant Camera as Camera

    User ->> API: prepare_camera_video(start, end, ...)
    API ->> Camera: Prepare video (start, end, ...)
    Camera -->> API: Prepared video info
    
    User ->> API: download_camera_video(prepared_video_info, ...)
    API ->> Camera: Download video chunks
    Camera -->> API: Video chunks
    API ->> User: MP4 Video
Loading

Poem

In the land where code does play,
A rabbit hopped in bright array,
Preparing videos with care so fine,
Downloading chunks, a task divine.
Exported frames, in bytes they soar,
Unifi Protect, now improved much more!


Thank you for using CodeRabbit. We offer it for free to the OSS community and would appreciate your support in helping us grow. If you find it useful, would you consider giving us a shout-out on your favorite social media?

Share
Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai generate interesting stats about this repository and render them as a table.
    • @coderabbitai show all the console.log statements in this repository.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (invoked as PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Additionally, you can add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

codecov bot commented Jun 11, 2024

Codecov Report

Attention: Patch coverage is 51.85185% with 26 lines in your changes missing coverage. Please review.

Files Patch % Lines
src/uiprotect/api.py 51.85% 26 Missing ⚠️
Files Coverage Δ
src/uiprotect/api.py 55.03% <51.85%> (-0.19%) ⬇️

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Outside diff range and nitpick comments (3)
src/uiprotect/api.py (3)

Line range hint 1454-1726: Review the usage of Optional and Union types.

The static analysis tool has identified multiple instances where Optional and Union types are used but not defined. This will cause runtime errors. To resolve this, you should import these types from the typing module at the beginning of your file.

+ from typing import Optional, Union
Tools
Ruff

1458-1458: Undefined name Optional (F821)


1459-1459: Undefined name Optional (F821)


1460-1460: Undefined name Optional (F821)


1462-1462: Undefined name Optional (F821)


1525-1525: Undefined name Optional (F821)


1526-1526: Undefined name Optional (F821)


1527-1527: Undefined name Optional (F821)


1527-1527: Undefined name Union (F821)


1588-1588: Undefined name Optional (F821)


1589-1589: Undefined name Optional (F821)


1590-1590: Undefined name Optional (F821)


1592-1592: Undefined name Optional (F821)


1593-1593: Undefined name Optional (F821)


1454-1462: Ensure proper error handling in download_camera_video.

The method download_camera_video lacks explicit error handling for network or IO operations. Consider adding try-except blocks around the network calls and file operations to handle potential exceptions and provide a more robust error handling mechanism.

Tools
Ruff

1458-1458: Undefined name Optional (F821)


1459-1459: Undefined name Optional (F821)


1460-1460: Undefined name Optional (F821)


1462-1462: Undefined name Optional (F821)


1669-1725: Deprecation of get_camera_video needs clear documentation.

The method get_camera_video is deprecated, but the documentation could be clearer about what users should do if they are using versions of Unifi Protect older than 4. Consider enhancing the documentation to guide users more clearly.

Tools
Ruff

1676-1676: Undefined name Optional (F821)


1677-1677: Undefined name Optional (F821)


1678-1678: Undefined name Optional (F821)


1680-1680: Undefined name Optional (F821)


1681-1681: Undefined name Optional (F821)


1682-1682: Undefined name Optional (F821)

Review details

Configuration used: CodeRabbit UI
Review profile: CHILL

Commits

Files that changed from the base of the PR and between eb87ddf and 6edcb19.

Files selected for processing (1)
  • src/uiprotect/api.py (4 hunks)
Additional context used
Ruff
src/uiprotect/api.py

1458-1458: Undefined name Optional (F821)


1459-1459: Undefined name Optional (F821)


1460-1460: Undefined name Optional (F821)


1462-1462: Undefined name Optional (F821)


1525-1525: Undefined name Optional (F821)


1526-1526: Undefined name Optional (F821)


1527-1527: Undefined name Optional (F821)


1527-1527: Undefined name Union (F821)


1588-1588: Undefined name Optional (F821)


1589-1589: Undefined name Optional (F821)


1590-1590: Undefined name Optional (F821)


1592-1592: Undefined name Optional (F821)


1593-1593: Undefined name Optional (F821)


1676-1676: Undefined name Optional (F821)


1677-1677: Undefined name Optional (F821)


1678-1678: Undefined name Optional (F821)


1680-1680: Undefined name Optional (F821)


1681-1681: Undefined name Optional (F821)


1682-1682: Undefined name Optional (F821)

src/uiprotect/api.py Outdated Show resolved Hide resolved
src/uiprotect/api.py Outdated Show resolved Hide resolved
@bdraco
Copy link
Member

bdraco commented Jun 11, 2024

Thanks for the PR, I'll add some comments to get the review started shortly

@cyberpower678
Copy link
Contributor Author

Let me conform it first to the way you have the typing handled. :-)

Copy link
Member

@bdraco bdraco left a comment

Choose a reason for hiding this comment

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

Thanks for the PR. Please see feedback above.

self,
camera_id: str,
filename: str,
output_file: Optional[Path] = None,
Copy link
Member

Choose a reason for hiding this comment

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

Please use pep 0563 style

pyupgrade can likely convert these for you

https://peps.python.org/pep-0563/

Copy link
Member

Choose a reason for hiding this comment

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

if isinstance(prepare_response, dict):
download_filename = prepare_response["fileName"]
else:
raise 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 using a broad exception raise in a try block to control flow

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

@@ -1451,25 +1451,152 @@ 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(
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

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Review details

Configuration used: CodeRabbit UI
Review profile: CHILL

Commits

Files that changed from the base of the PR and between 6edcb19 and dda6f72.

Files selected for processing (1)
  • src/uiprotect/api.py (6 hunks)
Additional context used
Ruff
src/uiprotect/api.py

1680-1680: SyntaxError: Parameter without a default cannot follow a parameter with a default (E999)

Comment on lines 1518 to 1580
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,
)

Copy link
Contributor

Choose a reason for hiding this comment

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

Ensure proper error handling in prepare_camera_video.

-        return await self.api_request(
+        try:
+            return await self.api_request(
-        )
+            )
+        except Exception as e:
+            _LOGGER.error(f"Failed to prepare video: {str(e)}")
+            raise

Adding exception handling to prepare_camera_video will help in identifying and logging issues that may occur during the API request, improving the method's reliability.

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,
)
try:
return await self.api_request(
path,
params=params,
raise_exception=True,
)
except Exception as e:
_LOGGER.error(f"Failed to prepare video: {str(e)}")
raise

iterator_callback: IteratorCallback | None = None,
progress_callback: ProgressCallback | None = None,
chunk_size: int = 65536,
fps: int | None,
Copy link
Contributor

Choose a reason for hiding this comment

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

Correct the syntax error in the method signature.

-        fps: int | None,
+        fps: int | None = None,

Parameters without default values should not follow parameters with default values. This change corrects the method signature to comply with Python's syntax rules.

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
fps: int | None,
fps: int | None = None,
Tools
Ruff

1680-1680: SyntaxError: Parameter without a default cannot follow a parameter with a default (E999)

@bdraco
Copy link
Member

bdraco commented Jun 11, 2024

When I get back home I'll adjust the settings so you don't have to wait for the ci to rerun

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Outside diff range and nitpick comments (1)
src/uiprotect/api.py (1)

Line range hint 1581-1630: Consider simplifying the handling of optional parameters.

The method export_camera_video handles optional parameters fps and channel_index by checking their values and updating the parameters dictionary conditionally. This could be simplified by setting default values in the parameters dictionary and updating them only if necessary.

-        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"
+        params.update({"channel": channel_index if channel_index != 3 else "lens": 2})
+        if fps > 0:
+            params.update({"fps": fps, "type": "timelapse"})

This refactoring uses a more concise approach to update the parameters dictionary, reducing the complexity of the code and improving readability.

Review details

Configuration used: CodeRabbit UI
Review profile: CHILL

Commits

Files that changed from the base of the PR and between dda6f72 and 98274fa.

Files selected for processing (1)
  • src/uiprotect/api.py (6 hunks)

@bdraco
Copy link
Member

bdraco commented Jun 11, 2024

When I get back home I'll adjust the settings so you don't have to wait for the ci to rerun

I was able to adjust the settings on mobile so you shouldn't have to wait for manual ci runs 👍

@cyberpower678
Copy link
Contributor Author

Sorry, but it's hard to follow our conversation with code rabbit producing a lot verbiage. I'm not quite understanding the Lint Commit Messages thing.

@bdraco
Copy link
Member

bdraco commented Jun 12, 2024

Sorry, but it's hard to follow our conversation with code rabbit producing a lot verbiage. I'm not quite understanding the Lint Commit Messages thing.

Don't worry about the commits as only the title matters since it will get squashed at the end anyways

@bdraco bdraco changed the title Add support for the new download mechanism used in Unifi OS 4 feat: add support for the new download mechanism used in Unifi OS 4 Jun 12, 2024
@cyberpower678
Copy link
Contributor Author

Alright, the pyunifiprotect library went completely poof, so I'll be focusing 100% of my attention here now. :p Not sure what prompted the spontaneous deletion of the Christopher's fork of the library, but I see it was forked from you, which was forked from briis. Just wondering what the story is if you know/willing to share. I think it's safe to say that this is the official (unofficial) library for integrating with Unifi Protect now.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

Review details

Configuration used: CodeRabbit UI
Review profile: CHILL

Commits

Files that changed from the base of the PR and between 98274fa and 11732fd.

Files selected for processing (1)
  • .gitignore (1 hunks)
Files skipped from review due to trivial changes (1)
  • .gitignore

@bdraco
Copy link
Member

bdraco commented Jun 12, 2024

Alright, the pyunifiprotect library went completely poof, so I'll be focusing 100% of my attention here now. :p Not sure what prompted the spontaneous deletion of the Christopher's fork of the library, but I see it was forked from you, which was forked from briis. Just wondering what the story is if you know/willing to share. I think it's safe to say that this is the official (unofficial) library for integrating with Unifi Protect now.

This comment best summarizes the situation

.gitignore Outdated Show resolved Hide resolved
.idea/uiprotect.iml Outdated Show resolved Hide resolved
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Review details

Configuration used: CodeRabbit UI
Review profile: CHILL

Commits

Files that changed from the base of the PR and between 11732fd and 5351838.

Files selected for processing (1)
  • src/uiprotect/api.py (6 hunks)
Additional context used
GitHub Check: codecov/patch
src/uiprotect/api.py

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


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


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


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


[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] 1503-1503: src/uiprotect/api.py#L1503
Added line #L1503 was not covered by tests


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


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


[warning] 1513-1514: src/uiprotect/api.py#L1513-L1514
Added lines #L1513 - L1514 were not covered by tests


[warning] 1544-1545: src/uiprotect/api.py#L1544-L1545
Added lines #L1544 - L1545 were not covered by tests


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


[warning] 1559-1560: src/uiprotect/api.py#L1559-L1560
Added lines #L1559 - L1560 were not covered by tests


[warning] 1624-1625: src/uiprotect/api.py#L1624-L1625
Added lines #L1624 - L1625 were not covered by tests


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


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


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

@bdraco
Copy link
Member

bdraco commented Jun 21, 2024

I've been keeping this up to date with main.

No rush, but if you want to get this change in before HA beta on Wednesday, it will need another turn before Monday.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Review details

Configuration used: CodeRabbit UI
Review profile: CHILL

Commits

Files that changed from the base of the PR and between 5351838 and 28faec8.

Files selected for processing (1)
  • src/uiprotect/api.py (6 hunks)

Comment on lines +1499 to +1529
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

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

@cyberpower678
Copy link
Contributor Author

I've been keeping this up to date with main.

No rush, but if you want to get this change in before HA beta on Wednesday, it will need another turn before Monday.

I'll see what I can do. My time has been very limited these past few weeks and will continue to remain limited until late July. What's throwing me are the tests, since I can't quite seem to figure out the flow of logic that is letting make the proper tests for it. I've been predominantly developing in PHP and have never dabbled with pytest before.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Outside diff range and nitpick comments (1)
src/uiprotect/api.py (1)

Line range hint 1607-1751: Refactor the deprecated get_camera_video method to improve error handling

Currently, 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.

-        except Exception:
+        except (BadRequest, NvrError) as e:
+            _LOGGER.error(f"Error during video retrieval: {str(e)}")
+            raise
Tools
GitHub Check: codecov/patch

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


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

Review details

Configuration used: CodeRabbit UI
Review profile: CHILL

Commits

Files that changed from the base of the PR and between 28faec8 and 02ab2ea.

Files selected for processing (1)
  • src/uiprotect/api.py (6 hunks)
Additional context used
GitHub Check: codecov/patch
src/uiprotect/api.py

[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


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


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


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


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


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


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


[warning] 1541-1542: src/uiprotect/api.py#L1541-L1542
Added lines #L1541 - L1542 were not covered by tests


[warning] 1572-1573: src/uiprotect/api.py#L1572-L1573
Added lines #L1572 - L1573 were not covered by tests


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


[warning] 1587-1588: src/uiprotect/api.py#L1587-L1588
Added lines #L1587 - L1588 were not covered by tests


[warning] 1652-1653: src/uiprotect/api.py#L1652-L1653
Added lines #L1652 - L1653 were not covered by tests


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


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


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

Comment on lines +1480 to +1515
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,
)
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

@bdraco
Copy link
Member

bdraco commented Jun 27, 2024

I've been keeping this up to date with main.
No rush, but if you want to get this change in before HA beta on Wednesday, it will need another turn before Monday.

I'll see what I can do. My time has been very limited these past few weeks and will continue to remain limited until late July. What's throwing me are the tests, since I can't quite seem to figure out the flow of logic that is letting make the proper tests for it. I've been predominantly developing in PHP and have never dabbled with pytest before.

No worries. Take your time.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Outside diff range and nitpick comments (1)
src/uiprotect/api.py (1)

Line range hint 1607-1751: Enhance error handling and optimize memory usage in export_camera_video.

The method lacks explicit error handling for network requests and file operations, which could lead to unhandled exceptions. Additionally, downloading large video files directly into memory can lead to high memory usage. Consider the following improvements:

  1. Add error handling to manage potential failures in network requests or file operations.
  2. Optimize memory usage by always writing to a temporary file if output_file is not provided.
async def export_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,
) -> bytes | None:
    if validate_channel_id and self._bootstrap is not None:
        try:
            camera = self._bootstrap.cameras[camera_id]
            camera.channels[channel_index]
        except (IndexError, KeyError, AttributeError) 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"

    path = "video/export"

    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 export video: {str(e)}")
        raise
    return None
Review details

Configuration used: CodeRabbit UI
Review profile: CHILL

Commits

Files that changed from the base of the PR and between 02ab2ea and 694064a.

Files selected for processing (1)
  • src/uiprotect/api.py (6 hunks)

Comment on lines +1695 to +1752
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,
)

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

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Outside diff range, codebase verification and nitpick comments (1)
src/uiprotect/api.py (1)

Line range hint 1605-1749: Enhance error handling and optimize handling of fps parameter in export_camera_video.

  1. Error Handling: Similar to prepare_camera_video, ensure that the camera object and its channels attribute are valid before accessing them. This can prevent AttributeError or KeyError.

  2. Optimizing fps Handling: The handling of the fps parameter can be optimized by checking if fps is not None before setting the params['type']. This avoids unnecessary dictionary operations.

async def export_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,
) -> bytes:
    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"

    path = "video/export"

    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
Review details

Configuration used: CodeRabbit UI
Review profile: CHILL

Commits

Files that changed from the base of the PR and between 694064a and 692a550.

Files selected for processing (1)
  • src/uiprotect/api.py (6 hunks)

Comment on lines +1478 to +1540
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
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

@ep1cman
Copy link
Collaborator

ep1cman commented Jul 16, 2024

Has there been any progress on this? Its blocking unifi-protect-backup from being swapped to this library. If there is no plan to progress this PR, I might have to drop support for this new download method in order to make the switch

@bdraco
Copy link
Member

bdraco commented Jul 16, 2024

@ep1cman If you want, I can give you access to this repo so you can finish this PR

@cyberpower678
Copy link
Contributor Author

Has there been any progress on this? Its blocking unifi-protect-backup from being swapped to this library. If there is no plan to progress this PR, I might have to drop support for this new download method in order to make the switch

So sorry, I just have not had any time. I anticipate my time freeing up towards the end of July where I can then focus on this once again. The only thing actually holding up this PR, is implementing some test cases, which I'm not too familiar with in Python. If you know how to set those up, that would get this PR over the finish line.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Outside diff range, codebase verification and nitpick comments (1)
src/uiprotect/api.py (1)

Line range hint 1610-1657: Optimize fps handling in export_camera_video.

The handling of the fps parameter can be optimized by checking if fps is not None before setting params['type']. This avoids unnecessary dictionary operations.

Here's a proposed improvement:

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

Additionally, the function lacks test coverage for certain lines, as indicated by static analysis.

Review details

Configuration used: CodeRabbit UI
Review profile: CHILL

Commits

Files that changed from the base of the PR and between 692a550 and a8a31e4.

Files selected for processing (1)
  • src/uiprotect/api.py (6 hunks)
Additional context used
GitHub Check: codecov/patch
src/uiprotect/api.py

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


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


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


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


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


[warning] 1527-1528: src/uiprotect/api.py#L1527-L1528
Added lines #L1527 - L1528 were not covered by tests


[warning] 1530-1534: src/uiprotect/api.py#L1530-L1534
Added lines #L1530 - L1534 were not covered by tests


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


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


[warning] 1544-1545: src/uiprotect/api.py#L1544-L1545
Added lines #L1544 - L1545 were not covered by tests


[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


[warning] 1655-1656: src/uiprotect/api.py#L1655-L1656
Added lines #L1655 - L1656 were not covered by tests


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


[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 +1483 to +1501
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.
"""
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

Comment on lines +1698 to +1754
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,
)
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 +1547 to +1608
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,
)
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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants