Skip to content

Conversation

@timonrieger
Copy link
Owner

@timonrieger timonrieger commented Jan 7, 2026

@coderabbitai review

Summary by CodeRabbit

  • New Features

    • Added support for downloading asset videos to file.
    • Introduced a unified download helper with optional progress display.
  • Improvements

    • Resumable downloads for asset/file downloads (partial .temp files and HTTP Range resume).
    • Standardized progress parameter naming across download methods and consistent filename resolution.
  • Documentation

    • README updated with video-download method and resumable-download behavior notes.
  • Tests

    • Added comprehensive tests for download and resume behaviors.
  • Chores

    • Updated pre-commit security hook filter criteria.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 7, 2026

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

Introduces a reusable async download_file utility with resumable and progress-aware streaming; refactors asset, download, and user API wrappers to use it (adds show_progress and play_asset_video_to_file); updates README and pre-commit bandit filter; adds tests for download behavior.

Changes

Cohort / File(s) Summary
Configuration
/.pre-commit-config.yaml
Bandit hook filter changed from targeting a single file to excluding directories (`exclude: "^(immich/client/
Documentation
/README.md
Adds assets.play_asset_video_to_file entry and documents resumable download behavior and ZIP archive limitation.
Tooling
/bin/generate.py
Added # nosec directives/comments around subprocess usage (B404, B603); no behavior change.
Utilities
/immich/utils.py
Added case-insensitive header helper h(), updated filename_from_headers() signature, and introduced async download_file() with resume, .temp handling, progress bar, and cleanup logic.
Assets wrapper
/immich/client_wrapper/assets_api_wrapped.py
Refactored downloads to use download_file(), added show_progress parameter, centralized filename resolution, and added play_asset_video_to_file().
Download wrapper
/immich/client_wrapper/download_api_wrapped.py
Refactored archive download to use download_file(), renamed show_progress_barsshow_progress, simplified streaming and filename handling.
Users wrapper
/immich/client_wrapper/users_api_wrapped.py
Refactored profile-image download to use download_file(), added show_progress parameter and consistent filename resolution.
Tests
/tests/test_utils.py
Added tests for download_file() covering resume behavior, partial/complete files, temp-file cleanup on errors, missing Content-Length, and progress interactions.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Wrapper as API Wrapper
    participant Utils as download_file
    participant HTTP as HTTP Client
    participant Disk as File System

    Client->>Wrapper: download_asset_to_file(id, out_dir, ...)
    Wrapper->>Wrapper: build make_request(headers?)
    Wrapper->>Utils: download_file(make_request, out_dir, resolve_filename, show_progress)

    rect rgb(230, 240, 255)
    Note over Utils,HTTP: Initial request (no Range)
    Utils->>HTTP: make_request()
    HTTP-->>Utils: Response (headers, Content-Length)
    end

    Utils->>Utils: resolve_filename(resp.headers) → filename
    Utils->>Disk: check for existing .temp (partial)

    alt Partial .temp exists
        rect rgb(240, 230, 240)
        Note over Utils,HTTP: Resume attempt with Range
        Utils->>HTTP: make_request(Range header)
        HTTP-->>Utils: 206 Partial / 200 Full
        Utils->>Utils: set offset accordingly
        end
    end

    rect rgb(230, 255, 230)
    Note over Utils,Disk: Stream and write
    loop while chunks
        Utils->>HTTP: read chunk
        Utils->>Disk: append to .temp
        Utils->>Utils: update progress bar (if enabled)
    end
    end

    Utils->>Disk: rename .temp → final filename
    Utils-->>Wrapper: return Path
    Wrapper-->>Client: Path to file
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hop with bytes and a jittery cheer,
Resuming each download, no data to fear.
Temp files tidy, progress bars glow,
Videos and avatars safely flow.
Hooray — I’ve fetched them all, nice and near! ✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is insufficient. It contains only '@coderabbitai review' when the repository template requires meaningful description content. Add a proper PR description explaining the changes, objectives, and any breaking changes or migration notes needed, following repository conventions.
Docstring Coverage ⚠️ Warning Docstring coverage is 30.99% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main feature addition of a download_file utility with resume and progress support, which is the core objective of this PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

📜 Recent review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3e610c9 and bd9d4ed.

📒 Files selected for processing (1)
  • immich/client_wrapper/assets_api_wrapped.py

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link

github-actions bot commented Jan 7, 2026

Coverage

@timonrieger
Copy link
Owner Author

@CodeRabbit update the README.md to mention the resumable downloads

@coderabbitai coderabbitai bot changed the title @coderabbitai feat: add download_file utility with resume and progress support Jan 7, 2026
@coderabbitai

This comment was marked as spam.

@timonrieger
Copy link
Owner Author

@coderabbitai analyze the download_file specifically for unhandled exceptions and resource leaks

Copy link

@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: 1

🤖 Fix all issues with AI agents
In @immich/utils.py:
- Around line 134-140: Initialize resp and temp_path to None before the try
block and change the out_dir validation to only error if out_dir.exists() and
not out_dir.is_dir(); otherwise create it with out_dir.mkdir(...). Update
exception handlers and the finally block to check for resp is not None and
temp_path is not None (and temp_path.exists() where appropriate) before using
them, and avoid referencing temp_path or resp directly when they may not have
been assigned. Ensure references to make_request, resp, temp_path, out_dir, and
the try/except/finally blocks are updated accordingly.
🧹 Nitpick comments (3)
bin/generate.py (1)

111-111: Consider validating the ref parameter before use.

While the nosec: B603 directive is correctly placed, the underlying security concern flagged by Ruff (S603) about untrusted input remains valid. The args.ref parameter is user-controllable (via CLI or environment variable) and is incorporated into the URL without validation. Although the security risk is low for a build script, consider adding basic validation to ensure the ref contains only expected characters (e.g., alphanumerics, hyphens, underscores, dots, slashes).

🔒 Proposed validation for the ref parameter

Add validation after parsing arguments:

import re

def main() -> int:
    parser = argparse.ArgumentParser(
        description="Generate Immich OpenAPI client and rewrite imports."
    )
    parser.add_argument(
        "--ref",
        default=os.environ.get("IMMICH_OPENAPI_REF", "main"),
        help="Immich git ref for OpenAPI spec (default: IMMICH_OPENAPI_REF or 'main')",
    )
    args = parser.parse_args()
    
    # Validate ref to prevent injection attempts
    if not re.match(r'^[a-zA-Z0-9._/-]+$', args.ref):
        print(f"Invalid ref format: {args.ref}")
        return 1

    root = project_root()
    # ... rest of the code
immich/client_wrapper/download_api_wrapped.py (1)

69-99: Late-binding closure captures loop variables by reference.

Static analysis (Ruff B023) correctly flags that make_request and the resolve_filename lambda capture asset_ids_dto and filename by reference. While this currently works because the closure is consumed before the next iteration, it's fragile and will break if download_file ever defers the callback.

Additionally, the headers parameter in the resolve_filename lambda is unused (Ruff ARG005) since the filename is pre-determined.

♻️ Proposed fix: bind variables explicitly
             for asset_ids_dto, expected_size in archive_requests:
                 filename = f"archive-{uuid.uuid4()}.zip"

-                def make_request(extra_headers: Optional[dict[str, str]]):
+                def make_request(
+                    extra_headers: Optional[dict[str, str]],
+                    *,
+                    _asset_ids_dto: AssetIdsDto = asset_ids_dto,
+                ):
                     return self.download_archive_without_preload_content(
-                        asset_ids_dto=asset_ids_dto,
+                        asset_ids_dto=_asset_ids_dto,
                         key=key,
                         slug=slug,
                         _headers=kwargs.get("_headers", {}) | (extra_headers or {}),
                         **kwargs,
                     )

                 pbar = tqdm.tqdm(
                     total=expected_size or None,
                     unit="B",
                     unit_scale=True,
                     desc=str(filename),
                     position=1,
                     leave=False,
                     dynamic_ncols=True,
                     disable=not show_progress,
                 )
+                # Bind filename at definition time; headers unused for archives
+                bound_filename = filename
                 await download_file(
                     make_request=make_request,
                     out_dir=out_dir,
-                    resolve_filename=lambda headers: filename,
+                    resolve_filename=lambda _headers, fn=bound_filename: fn,
                     show_progress=show_progress,
                     pbar=pbar,
                     resumeable=False,  # zip files are not resumable
                 )
immich/utils.py (1)

206-213: Consider preserving caller-provided progress bars.

When the caller passes their own pbar, the finally block closes it unconditionally. This may be intentional, but if the caller wants to reuse or manage the progress bar's lifecycle, this could be unexpected. The fix proposed above includes tracking user_pbar to optionally skip closing caller-provided bars.

Also, temp_path.replace(out_path) on line 212 is outside the try block, so if this fails (e.g., cross-filesystem rename), no cleanup occurs. Consider wrapping this in error handling or using shutil.move for robustness.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bdfe25e and d62789e.

📒 Files selected for processing (7)
  • .pre-commit-config.yaml
  • README.md
  • bin/generate.py
  • immich/client_wrapper/assets_api_wrapped.py
  • immich/client_wrapper/download_api_wrapped.py
  • immich/client_wrapper/users_api_wrapped.py
  • immich/utils.py
🧰 Additional context used
🧬 Code graph analysis (3)
immich/utils.py (4)
immich/client/rest.py (1)
  • headers (43-45)
immich/client_wrapper/assets_api_wrapped.py (3)
  • make_request (41-48)
  • make_request (87-94)
  • make_request (135-143)
immich/client_wrapper/download_api_wrapped.py (1)
  • make_request (72-79)
immich/client_wrapper/users_api_wrapped.py (1)
  • make_request (35-40)
immich/client_wrapper/users_api_wrapped.py (4)
immich/utils.py (2)
  • download_file (114-213)
  • resolve_output_filename (62-111)
immich/client_wrapper/assets_api_wrapped.py (3)
  • make_request (41-48)
  • make_request (87-94)
  • make_request (135-143)
immich/client_wrapper/download_api_wrapped.py (1)
  • make_request (72-79)
immich/client/api/users_api.py (1)
  • get_profile_image_without_preload_content (1544-1601)
immich/client_wrapper/download_api_wrapped.py (3)
immich/utils.py (1)
  • download_file (114-213)
immich/client_wrapper/assets_api_wrapped.py (3)
  • make_request (41-48)
  • make_request (87-94)
  • make_request (135-143)
immich/client_wrapper/users_api_wrapped.py (1)
  • make_request (35-40)
🪛 LanguageTool
README.md

[style] ~57-~57: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym.
Context: ... asset thumbnail directly to disk . - assets.play_asset_video_to_file: Download an...

(ENGLISH_WORD_REPEAT_BEGINNING_RULE)

🪛 Ruff (0.14.10)
bin/generate.py

111-111: subprocess call: check for execution of untrusted input

(S603)

immich/utils.py

139-139: Abstract raise to an inner function

(TRY301)


139-139: Avoid specifying long messages outside the exception class

(TRY003)

immich/client_wrapper/download_api_wrapped.py

74-74: Function definition does not bind loop variable asset_ids_dto

(B023)


94-94: Unused lambda argument: headers

(ARG005)


94-94: Function definition does not bind loop variable filename

(B023)

🔇 Additional comments (9)
bin/generate.py (1)

14-14: LGTM: Appropriate security linter suppression.

The nosec: B404 directive correctly suppresses the Bandit warning for importing subprocess. The module is used legitimately in this build script to invoke openapi-generator-cli.

.pre-commit-config.yaml (1)

18-18: LGTM!

Excluding the auto-generated client code (immich/client/) from Bandit scans is appropriate. Excluding tests/ is also a common practice, though ensure any custom test fixtures handling sensitive data are still reviewed manually.

README.md (1)

57-57: LGTM!

Documentation correctly reflects the new play_asset_video_to_file method. The repetitive sentence structure flagged by static analysis is acceptable here—consistent formatting across API method descriptions aids readability.

immich/client_wrapper/users_api_wrapped.py (1)

12-51: LGTM!

Clean refactoring to leverage the shared download_file utility. The implementation correctly:

  • Merges headers via the make_request helper
  • Uses resolve_output_filename with an appropriate default base
  • Propagates show_progress to control the progress bar

Minor note: out_dir.mkdir(parents=True, exist_ok=True) on line 33 is also called inside download_file (line 140 of utils.py), making the call here redundant but harmless.

immich/client_wrapper/assets_api_wrapped.py (3)

61-105: LGTM!

The new play_asset_video_to_file method follows the established pattern used by download_asset_to_file and view_asset_to_file. The implementation correctly:

  • Uses video-{id} as the default filename base
  • Delegates to play_asset_video_without_preload_content via make_request
  • Propagates all relevant parameters including show_progress

15-59: Clean refactoring to the shared download utility.

The download_asset_to_file method now consistently uses the download_file abstraction with proper header merging and filename resolution.


107-154: Consistent implementation.

view_asset_to_file follows the same pattern, correctly using thumb-{id} as the default base and including the size parameter in the request.

immich/utils.py (2)

16-20: LGTM!

Simple and correct case-insensitive header lookup. Linear search is appropriate given typical header counts.


154-181: Well-implemented resume logic.

The resumable download handling is solid:

  • Properly attempts Range request for partial files
  • Falls back to restart if server doesn't return 206
  • Cleans up temp files larger than expected size

@coderabbitai

This comment was marked as resolved.

@timonrieger
Copy link
Owner Author

@coderabbitai review the latest commit

@coderabbitai

This comment was marked as outdated.

Copy link

@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: 1

🤖 Fix all issues with AI agents
In @immich/utils.py:
- Around line 216-217: The temp_path.replace(out_path) call is outside the
try/except that deletes temp_path on failure, so if replace raises
(cross-device, perms) the temp file is left behind; move the replace call into
the try block (or add a nested try/except around temp_path.replace in the same
function) so that any exception during rename is caught and the except/finally
cleanup still runs to remove temp_path; reference the temp_path and out_path
variables and ensure the cleanup code that removes temp_path executes on any
error from replace.
🧹 Nitpick comments (3)
immich/client_wrapper/download_api_wrapped.py (1)

69-99: Closures inside loop don't bind loop variables — fragile if refactored.

The make_request function and the resolve_filename lambda reference asset_ids_dto and filename without binding them as default arguments. While the current code awaits immediately within the same iteration (so it works), this is fragile and will break if these closures are ever stored or deferred.

♻️ Proposed fix to bind loop variables
 for asset_ids_dto, expected_size in archive_requests:
     filename = f"archive-{uuid.uuid4()}.zip"

-    def make_request(extra_headers: Optional[dict[str, str]]):
+    def make_request(extra_headers: Optional[dict[str, str]], _dto=asset_ids_dto):
         return self.download_archive_without_preload_content(
-            asset_ids_dto=asset_ids_dto,
+            asset_ids_dto=_dto,
             key=key,
             slug=slug,
             _headers=kwargs.get("_headers", {}) | (extra_headers or {}),
             **kwargs,
         )

     pbar = tqdm.tqdm(
         # ...
     )
     await download_file(
         make_request=make_request,
         out_dir=out_dir,
-        resolve_filename=lambda headers: filename,
+        resolve_filename=lambda headers, _fn=filename: _fn,
         show_progress=show_progress,
         pbar=pbar,
         resumeable=False,
     )
tests/test_utils.py (2)

394-416: Consider adding test for valid non-existent out_dir.

The test test_download_file_raises_error_if_out_dir_is_not_directory correctly tests that a file raises ValueError. However, there's no test for the case where out_dir is a valid path that simply doesn't exist yet. Once the validation bug in utils.py is fixed, this scenario should be tested.

💡 Example test to add
@pytest.mark.asyncio
async def test_download_file_creates_nonexistent_out_dir(tmp_path: Path) -> None:
    """Test that non-existent out_dir is created."""
    out_dir = tmp_path / "new" / "nested" / "dir"
    assert not out_dir.exists()

    content_data = b"test"
    headers = {"Content-Length": str(len(content_data))}

    async def make_request(headers_arg):
        return MockResponse(headers, content_data=content_data)

    def resolve_filename(h):
        return "test.txt"

    result = await utils.download_file(
        make_request, out_dir, resolve_filename, show_progress=False
    )

    assert out_dir.exists()
    assert result.read_bytes() == content_data

554-567: Consider verifying caller-provided pbar is not closed.

The docstring states caller-provided progress bars "must be closed by the caller." The test verifies update was called but doesn't assert that close was NOT called. A quick addition would strengthen this test.

     # Should update with partial data size, then with remaining chunks
     assert mock_pbar.update.called
     assert result.read_bytes() == full_content
+    # Caller-provided pbar should not be closed by download_file
+    mock_pbar.close.assert_not_called()
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d62789e and 3e610c9.

📒 Files selected for processing (4)
  • README.md
  • immich/client_wrapper/download_api_wrapped.py
  • immich/utils.py
  • tests/test_utils.py
🧰 Additional context used
🧬 Code graph analysis (2)
tests/test_utils.py (1)
immich/utils.py (2)
  • h (16-20)
  • download_file (114-229)
immich/utils.py (4)
immich/client/rest.py (1)
  • headers (43-45)
immich/client_wrapper/download_api_wrapped.py (1)
  • make_request (72-79)
tests/test_utils.py (27)
  • make_request (174-175)
  • make_request (204-208)
  • make_request (238-242)
  • make_request (273-281)
  • make_request (311-318)
  • make_request (349-350)
  • make_request (377-380)
  • make_request (404-405)
  • make_request (461-462)
  • make_request (496-497)
  • make_request (520-521)
  • make_request (546-549)
  • resolve_filename (177-178)
  • resolve_filename (210-211)
  • resolve_filename (244-245)
  • resolve_filename (283-284)
  • resolve_filename (320-321)
  • resolve_filename (352-353)
  • resolve_filename (382-383)
  • resolve_filename (407-408)
  • resolve_filename (464-465)
  • resolve_filename (499-500)
  • resolve_filename (523-524)
  • resolve_filename (551-552)
  • content (147-162)
  • content (448-459)
  • content (485-494)
immich/client_wrapper/assets_api_wrapped.py (3)
  • make_request (41-48)
  • make_request (87-94)
  • make_request (135-143)
🪛 LanguageTool
README.md

[style] ~57-~57: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym.
Context: ... asset thumbnail directly to disk . - assets.play_asset_video_to_file: Download an...

(ENGLISH_WORD_REPEAT_BEGINNING_RULE)

🪛 Ruff (0.14.10)
immich/client_wrapper/download_api_wrapped.py

74-74: Function definition does not bind loop variable asset_ids_dto

(B023)


94-94: Unused lambda argument: headers

(ARG005)


94-94: Function definition does not bind loop variable filename

(B023)

tests/test_utils.py

174-174: Unused function argument: headers_arg

(ARG001)


177-177: Unused function argument: h

(ARG001)


204-204: Unused function argument: headers_arg

(ARG001)


210-210: Unused function argument: h

(ARG001)


238-238: Unused function argument: headers_arg

(ARG001)


244-244: Unused function argument: h

(ARG001)


283-283: Unused function argument: h

(ARG001)


320-320: Unused function argument: h

(ARG001)


349-349: Unused function argument: headers_arg

(ARG001)


352-352: Unused function argument: h

(ARG001)


382-382: Unused function argument: h

(ARG001)


404-404: Unused function argument: headers_arg

(ARG001)


407-407: Unused function argument: h

(ARG001)


450-450: Unused method argument: size

(ARG002)


461-461: Unused function argument: headers_arg

(ARG001)


464-464: Unused function argument: h

(ARG001)


487-487: Unused method argument: size

(ARG002)


490-490: Avoid specifying long messages outside the exception class

(TRY003)


496-496: Unused function argument: headers_arg

(ARG001)


499-499: Unused function argument: h

(ARG001)


520-520: Unused function argument: headers_arg

(ARG001)


523-523: Unused function argument: h

(ARG001)


551-551: Unused function argument: h

(ARG001)

immich/utils.py

143-143: Abstract raise to an inner function

(TRY301)


143-143: Avoid specifying long messages outside the exception class

(TRY003)


217-217: Consider moving this statement to an else block

(TRY300)

🔇 Additional comments (6)
immich/utils.py (4)

152-166: Early return paths correctly close response — looks good.

Both early-return branches (size match and size mismatch) properly close resp before returning, preventing resource leaks. The security-conscious approach of not re-downloading mismatched files is documented appropriately.


168-194: Resume logic handles Range request fallback correctly.

The code properly handles servers that don't support Range requests by checking for status 206 and falling back to a full restart. Response objects are closed before making new requests, preventing leaks.


219-229: Exception and cleanup handling properly guards against NameError.

The code correctly initializes resp, temp_path, and pbar_created before the try block, preventing the NameError issues flagged in past reviews. Caller-provided progress bars are respected via the pbar_created flag.


16-20: Clean helper for case-insensitive header lookup.

Simple and correct implementation. This standardizes header access across the download flow.

tests/test_utils.py (1)

117-162: Well-structured mock response for async testing.

The MockResponse class properly implements the async context manager protocol and simulates chunked content iteration. This provides a solid foundation for testing the download_file behavior.

README.md (1)

53-66: Documentation clearly explains resumable downloads and limitations.

The new documentation accurately describes the added play_asset_video_to_file method, the automatic resume behavior via HTTP Range requests, and the limitation for ZIP archives. The .temp file naming convention is well documented.

@timonrieger timonrieger merged commit 6d3391f into main Jan 7, 2026
5 of 6 checks passed
@timonrieger timonrieger deleted the add-progress-bar-to-downloads branch January 7, 2026 13:50
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.

2 participants