Skip to content

Comments

Seekable streams (Safari/iOS fix for video)#580

Closed
3rob3 wants to merge 9 commits intomainfrom
dev.3rob3.SeekableStream
Closed

Seekable streams (Safari/iOS fix for video)#580
3rob3 wants to merge 9 commits intomainfrom
dev.3rob3.SeekableStream

Conversation

@3rob3
Copy link
Collaborator

@3rob3 3rob3 commented Feb 2, 2026

Fix Safari/iOS video playback by buffering videos to seekable streams

Safari requires seekable streams for video range requests. Previously, non-seekable network streams from Immich caused playback failures(error code 4 - MEDIA_ERR_SRC_NOT_SUPPORTED). Now videos are either cached to disk (when DownloadImages=true) or buffered to memory to provide seekability.

Summary by CodeRabbit

  • New Features

    • Range-based streaming for video and image assets with partial-content (206) responses and Accept-Ranges header.
  • Bug Fixes

    • Better content-type and content-length handling for streamed assets; downloads still support range-enabled transfers.
  • Reliability

    • Safer partial-content delivery with improved header extraction, error handling, and resource disposal during streaming.
  • Behavioral Change

    • Webhook notifications suppressed for ranged (partial) requests.

@3rob3 3rob3 requested a review from JW-CH February 2, 2026 19:11
@3rob3 3rob3 added the enhancement New feature or request label Feb 2, 2026
@coderabbitai
Copy link

coderabbitai bot commented Feb 2, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

GetAsset now accepts an optional Range header and returns extended metadata (contentRange, isPartial, dispose, contentLength). Video streaming uses an injected HttpClient with range support and explicit status/header handling; the Web API controller reads Range, sets Accept-Ranges, and can return 206 Partial Content.

Changes

Cohort / File(s) Summary
Core logic: GetAsset & streaming
ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs
Expanded GetAsset signature to (fileName, ContentType, Stream fileStream, string? contentRange, bool isPartial, IDisposable? dispose, string? contentLength) and added string? rangeHeader. Introduced _httpClient from IHttpClientFactory, new ranged playback flow (PlayVideoWithRange), header extraction (Content-Range, Content-Length, Content-Type) and ApiException on unexpected statuses.
Interface update
ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs
Updated GetAsset signature to accept string? rangeHeader = null and return the expanded tuple including contentRange, isPartial, dispose, and contentLength.
Delegation propagation
ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs
Delegate method updated to accept and forward rangeHeader and to return the expanded tuple from underlying logic; account-selection callback adjusted to pass rangeHeader.
Web API: controller range handling
ImmichFrame.WebApi/Controllers/AssetController.cs
Controller actions now return Task<IActionResult>. They read the Range header and pass it to GetAsset, always set Accept-Ranges: bytes, handle isPartial by writing the stream with Content-Range and Content-Length and returning status 206, dispose resources on partial responses; non-partial responses use File(..., enableRangeProcessing:true). Added 206 ProducesResponseType.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Controller as AssetController
  participant Logic as PooledImmichFrameLogic
  participant External as ExternalImmichAPI

  Client->>Controller: GET /assets/{id} (Range: bytes=..)?
  Controller->>Logic: GetAsset(id, assetType, rangeHeader)
  Logic->>External: HTTP GET (via _httpClient) with Range header (if provided)
  External-->>Logic: 200 OK / 206 Partial + headers (Content-Type, Content-Range, Content-Length) + stream
  alt isPartial (206)
    Logic-->>Controller: (fileName, ContentType, stream, contentRange, isPartial=true, dispose, contentLength)
    Controller->>Client: 206 Partial Content (writes stream, sets Content-Range, Accept-Ranges, Content-Length)
  else full (200)
    Logic-->>Controller: (fileName, ContentType, stream, null, isPartial=false, dispose, contentLength)
    Controller->>Client: 200 OK (FileStreamResult / File(..., enableRangeProcessing:true))
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I nibble bytes and chase a range,
I hop through headers, small and strange,
Partial crumbs or whole delight,
Streams hum softly through the night,
A rabbit cheers — the bytes take flight 🎬🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Seekable streams (Safari/iOS fix for video)' clearly and directly summarizes the main objective of the pull request: implementing seekable streams to fix video playback issues on Safari/iOS.

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

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch dev.3rob3.SeekableStream

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

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 `@ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs`:
- Around line 165-206: In GetVideoAsset (PooledImmichFrameLogic.GetVideoAsset)
move the local cache check (using _downloadLocation, fileName and
_generalSettings.RenewImagesDuration) to run before calling
_immichApi.PlayAssetVideoAsync so we short‑circuit on cache hits; then call the
API only if cache miss or expired, wrap the returned videoResponse (and its
Stream) in a using block to ensure disposal, and on API failure fall back to
returning the cached file if present; mirror the flow used in GetImageAsset to
ensure no leaked streams and proper cache-first behavior.

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 `@ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs`:
- Around line 176-180: The cache-hit branch in PooledImmichFrameLogic is
returning a hardcoded content-type "video/mp4" for cached files (see filePath,
fileName and the caller that expects a tuple of (fileName, contentType,
Stream)), which can mismatch original responses; fix by persisting the original
content-type when first saving the file (e.g., write a companion metadata file
next to filePath like filePath + ".meta" or embed a suffix) and on cache hit
read that metadata and return the stored content-type instead of the hardcoded
"video/mp4"; also add a safe fallback to "video/mp4" if the metadata is missing
or unreadable and ensure any write/read logic is added where the file is created
and where the cached file is returned.
🧹 Nitpick comments (1)
ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs (1)

190-192: Consider extracting Content-Type parsing to reduce duplication.

The Content-Type extraction logic is duplicated across both branches. A small helper could improve maintainability.

♻️ Optional refactor
+    private static string GetContentType(FileResponse response, string defaultType)
+    {
+        return response.Headers.ContainsKey("Content-Type")
+            ? response.Headers["Content-Type"].FirstOrDefault() ?? defaultType
+            : defaultType;
+    }
+
     private async Task<(string fileName, string ContentType, Stream fileStream)> GetVideoAsset(Guid id)
     {
         // ... cache check ...

         using var videoResponse = await _immichApi.PlayAssetVideoAsync(id, string.Empty);

         if (videoResponse == null)
             throw new AssetNotFoundException($"Video asset {id} was not found!");

-        var contentType = videoResponse.Headers.ContainsKey("Content-Type")
-            ? videoResponse.Headers["Content-Type"].FirstOrDefault() ?? "video/mp4"
-            : "video/mp4";
+        var contentType = GetContentType(videoResponse, "video/mp4");

Also applies to: 208-210

@3rob3
Copy link
Collaborator Author

3rob3 commented Feb 2, 2026

Workflow is not running, I don't know why.

Copy link
Collaborator

@JW-CH JW-CH left a comment

Choose a reason for hiding this comment

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

I noticed that with this change videos are no longer streamed from Immich but fully loaded into memory (or disk when caching is enabled) before being served.

While I understand this fixes Safari/iOS playback, it means the server is no longer just a proxy. For larger videos this could cause high memory usage and delayed playback start.

Is there a way to keep the original streaming behavior for non-Safari clients, or maybe forward Range requests to Immich instead?

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

@3rob3
Copy link
Collaborator Author

3rob3 commented Feb 19, 2026

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Feb 19, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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: 2

🧹 Nitpick comments (2)
ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs (1)

12-12: Consider replacing the 5-element tuple with a named record/class.

A 5-element ValueTuple hurts readability at every call site and makes future extensions (e.g., adding Content-Length) another breaking change. A small DTO like AssetResult with named properties would be clearer and more extensible.

public record AssetResult(string FileName, string ContentType, Stream FileStream, string? ContentRange, bool IsPartial);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs` at line 12, Replace the
5-element tuple return from IImmichFrameLogic.GetAsset with a named DTO: create
a record (e.g., AssetResult with properties FileName, ContentType, FileStream,
ContentRange, IsPartial) and update the method signature from GetAsset(...)
returning Task<(string fileName, string ContentType, Stream fileStream, string?
contentRange, bool isPartial)> to Task<AssetResult>; update any implementing
class/methods (and callers) to construct and consume AssetResult instead of the
tuple to improve readability and future extensibility.
ImmichFrame.WebApi/Controllers/AssetController.cs (1)

79-81: Range header is passed unsanitized to the logic layer.

The raw Range header value from the client is forwarded directly to the upstream Immich server via PlayVideoWithRange (which sets it on an outgoing HttpRequestMessage). While HTTP headers are generally safe to forward, consider validating that it matches the expected bytes=START-END format before passing it through. A malformed or adversarial Range header could trigger unexpected behavior in the upstream server.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ImmichFrame.WebApi/Controllers/AssetController.cs` around lines 79 - 81, The
Range header (Request.Headers["Range"] → rangeHeader) is forwarded unsanitized
to _logic.GetAsset and ultimately to PlayVideoWithRange; validate and sanitize
it first by ensuring it matches the expected bytes=START-END pattern (e.g. regex
like ^bytes=(\d*)-(\d*)$) and reject or normalize anything else (either set
rangeHeader to null/empty or return a BadRequest) before calling _logic.GetAsset
so malformed or adversarial values are not passed through to PlayVideoWithRange.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs`:
- Around line 167-191: PlayVideoWithRange returns an IDisposable FileResponse
that holds the HttpResponseMessage (with
HttpCompletionOption.ResponseHeadersRead), but callers (GetVideoAsset) discard
it and never dispose it, leaking connections; to fix, change GetVideoAsset to
immediately buffer the FileResponse.Stream into a MemoryStream, reset its
Position to 0, dispose the FileResponse/HttpResponseMessage and then return the
buffered MemoryStream (or otherwise transfer the stream ownership to the
caller), or alternatively update the call chain so the caller that receives
FileResponse disposes it after fully consuming Stream; locate
PlayVideoWithRange, GetVideoAsset, FileResponse and
HttpCompletionOption.ResponseHeadersRead to implement buffering-and-dispose or
ensure deterministic disposal.

In `@ImmichFrame.WebApi/Controllers/AssetController.cs`:
- Around line 88-95: The partial-content branch currently copies
asset.fileStream to Response.Body without disposing it and doesn't set
Content-Length; update the partial path (the block using asset.isPartial,
asset.contentRange, Response.Headers, Response.StatusCode, Response.ContentType,
await asset.fileStream.CopyToAsync(...), return new EmptyResult()) to (1) set
Response.Headers["Content-Range"] and Response.Headers["Content-Length"] (use
asset.ContentLength supplied by GetVideoAsset/PlayVideoWithRange), (2) copy the
stream and then Dispose/Close asset.fileStream in a try/finally or using pattern
so the underlying connection is released, and (3) if asset.fileStream is
non-seekable ensure GetVideoAsset/PlayVideoWithRange populates
asset.ContentLength by reading the upstream response Content-Length header and
include that length in the asset tuple so the partial branch can use it instead
of stream.Length.

---

Nitpick comments:
In `@ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs`:
- Line 12: Replace the 5-element tuple return from IImmichFrameLogic.GetAsset
with a named DTO: create a record (e.g., AssetResult with properties FileName,
ContentType, FileStream, ContentRange, IsPartial) and update the method
signature from GetAsset(...) returning Task<(string fileName, string
ContentType, Stream fileStream, string? contentRange, bool isPartial)> to
Task<AssetResult>; update any implementing class/methods (and callers) to
construct and consume AssetResult instead of the tuple to improve readability
and future extensibility.

In `@ImmichFrame.WebApi/Controllers/AssetController.cs`:
- Around line 79-81: The Range header (Request.Headers["Range"] → rangeHeader)
is forwarded unsanitized to _logic.GetAsset and ultimately to
PlayVideoWithRange; validate and sanitize it first by ensuring it matches the
expected bytes=START-END pattern (e.g. regex like ^bytes=(\d*)-(\d*)$) and
reject or normalize anything else (either set rangeHeader to null/empty or
return a BadRequest) before calling _logic.GetAsset so malformed or adversarial
values are not passed through to PlayVideoWithRange.

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

🧹 Nitpick comments (2)
ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs (1)

169-169: URL construction duplicates knowledge of the Immich API route.

The URL $"{_immichApi.BaseUrl.TrimEnd('/')}/assets/{id}/video/playback" duplicates the route that PlayAssetVideoAsync uses internally. If the upstream API path changes, this will silently break while the NSwag-generated client gets updated.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs` at line 169, The code in
PooledImmichFrameLogic builds the playback URL manually, duplicating the API
route; instead stop hardcoding
$"{_immichApi.BaseUrl.TrimEnd('/')}/assets/{id}/video/playback" and use the
NSwag-generated client API (e.g., call or build the request via
_immichApi.PlayAssetVideoAsync or the client's request/URL builder) so the route
is derived from the generated client; update the code that currently constructs
the url variable to obtain the playback endpoint from _immichApi (or its request
factory) rather than string-concatenating the path.
ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs (1)

12-12: Consider replacing the 5-element tuple with a named record type.

The return tuple (string fileName, string ContentType, Stream fileStream, string? contentRange, bool isPartial) is becoming unwieldy. A dedicated AssetResult record would improve readability at call sites, make future extensions (e.g., adding Content-Length) non-breaking, and allow the type to implement IDisposable to centralize stream lifecycle management.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs` at line 12, The GetAsset
method in IImmichFrameLogic returns a 5-element tuple which is hard to read and
brittle; create a named record (e.g., AssetResult) containing properties
FileName, ContentType, FileStream (Stream), ContentRange (string?), IsPartial
(bool) and implement IDisposable on the record to manage the stream lifecycle,
then change the interface signature from Task<(string fileName, string
ContentType, Stream fileStream, string? contentRange, bool isPartial)>
GetAsset(...) to Task<AssetResult> GetAsset(...). Update all implementations of
IImmichFrameLogic (methods named GetAsset) and all callers to construct/consume
AssetResult instead of the tuple, and add any needed nullability or
using/dispose handling per the new IDisposable contract.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ImmichFrame.WebApi/Controllers/AssetController.cs`:
- Around line 83-84: The webhook is firing for every byte-range sub-request;
modify the AssetController where AssetRequestedNotification is created so you
only call _logic.SendWebhookNotification when this is the initial request (e.g.,
when rangeHeader is null/empty or indicates the first range like "bytes=0-");
locate the code that builds AssetRequestedNotification and wrap the call to
_logic.SendWebhookNotification(notification) in a conditional that checks
rangeHeader (or equivalent request header variable) and returns/omits the
webhook for subsequent range sub-requests.

---

Duplicate comments:
In `@ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs`:
- Around line 167-191: PlayVideoWithRange currently returns a FileResponse that
holds the HttpResponseMessage (and its live stream) which is never disposed,
risking connection-pool exhaustion; change the consumer logic (GetVideoAsset and
PlayAssetVideoAsync) to fully buffer the response content for range requests
into a MemoryStream, dispose the returned FileResponse/HttpResponseMessage
immediately after buffering, and then return/serve the buffered stream (not the
FileResponse) so the underlying HTTP connection is released; locate symbols
PlayVideoWithRange, GetVideoAsset, PlayAssetVideoAsync, FileResponse and
HttpResponseMessage and implement the buffering+dispose pattern for partial
(range) and full responses as described.

In `@ImmichFrame.WebApi/Controllers/AssetController.cs`:
- Around line 88-95: The partial-content branch writes asset.fileStream to
Response.Body but never disposes the stream (leaking the underlying
HttpResponseMessage from PlayVideoWithRange); after awaiting
asset.fileStream.CopyToAsync(Response.Body) in the 206 path, ensure the stream
is disposed (preferably via await asset.fileStream.DisposeAsync() or by wrapping
the stream usage in an await using/try/finally) before returning the
EmptyResult; update the code paths that handle asset.fileStream (the
partial-content branch in the action that calls PlayVideoWithRange) so ownership
is consistently released.

---

Nitpick comments:
In `@ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs`:
- Line 12: The GetAsset method in IImmichFrameLogic returns a 5-element tuple
which is hard to read and brittle; create a named record (e.g., AssetResult)
containing properties FileName, ContentType, FileStream (Stream), ContentRange
(string?), IsPartial (bool) and implement IDisposable on the record to manage
the stream lifecycle, then change the interface signature from Task<(string
fileName, string ContentType, Stream fileStream, string? contentRange, bool
isPartial)> GetAsset(...) to Task<AssetResult> GetAsset(...). Update all
implementations of IImmichFrameLogic (methods named GetAsset) and all callers to
construct/consume AssetResult instead of the tuple, and add any needed
nullability or using/dispose handling per the new IDisposable contract.

In `@ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs`:
- Line 169: The code in PooledImmichFrameLogic builds the playback URL manually,
duplicating the API route; instead stop hardcoding
$"{_immichApi.BaseUrl.TrimEnd('/')}/assets/{id}/video/playback" and use the
NSwag-generated client API (e.g., call or build the request via
_immichApi.PlayAssetVideoAsync or the client's request/URL builder) so the route
is derived from the generated client; update the code that currently constructs
the url variable to obtain the playback endpoint from _immichApi (or its request
factory) rather than string-concatenating the path.

@coderabbitai
Copy link

coderabbitai bot commented Feb 19, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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: 2

🧹 Nitpick comments (1)
ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs (1)

12-12: Consider replacing the 7-element tuple with a dedicated record type.

A 7-element value tuple is unwieldy for callers and makes the interface contract harder to understand. A named record (e.g., AssetResult) would improve readability, allow adding fields without breaking every call site, and clarify ownership semantics (especially for the IDisposable? dispose member, whose purpose is opaque from the signature alone).

Additionally, ContentType uses PascalCase while the remaining elements use camelCase — minor inconsistency.

Example record
public record AssetResult(
    string FileName,
    string ContentType,
    Stream FileStream,
    string? ContentRange = null,
    bool IsPartial = false,
    IDisposable? Disposable = null,
    string? ContentLength = null);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs` at line 12, Replace the
7-element tuple return from IImmichFrameLogic.GetAsset with a dedicated record
(e.g., AssetResult) to improve clarity and extendability; create a public record
AssetResult with clearly named properties (FileName, ContentType, FileStream,
ContentRange, IsPartial, Disposable, ContentLength) and change the method
signature in IImmichFrameLogic from Task<(string fileName, string ContentType,
Stream fileStream, string? contentRange, bool isPartial, IDisposable? dispose,
string? contentLength)> GetAsset(...) to Task<AssetResult> GetAsset(...); also
normalize casing (use PascalCase for property names) and ensure the
nullable/ownership semantics of the IDisposable member are captured in the
record name (Disposable) to make its purpose explicit.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs`:
- Around line 45-46: The method declaration for GetAsset in class
MultiImmichFrameLogicDelegate is misaligned (starts at column 0); indent the
GetAsset method so it matches the other class members (use the same 4-space
indentation level as the other methods/properties in
MultiImmichFrameLogicDelegate) while keeping the existing signature and call to
_accountSelectionStrategy.ForAsset(logic => logic.GetAsset(...)).

In `@ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs`:
- Around line 186-189: The HttpResponseMessage named response is never disposed
on the error path which can leak sockets when using
HttpCompletionOption.ResponseHeadersRead; update the method (where response is
awaited and an ApiException thrown) to ensure response is disposed before
throwing — e.g., wrap the success path to return a new FileResponse without
disposing response and wrap the error path in a using or try/finally that calls
response.Dispose() after reading the error body, or restructure to use try/catch
that disposes response on failure while allowing the successful FileResponse to
retain ownership; ensure the ApiException is still constructed with status,
error and headers after disposing the response.

---

Duplicate comments:
In `@ImmichFrame.WebApi/Controllers/AssetController.cs`:
- Around line 83-84: The webhook is being sent for every HTTP Range sub-request
which causes duplicate notifications; in AssetController.cs before
creating/sending the AssetRequestedNotification via
_logic.SendWebhookNotification, check the incoming Range header
(Request.Headers["Range"]) and only call SendWebhookNotification when
string.IsNullOrEmpty(rangeHeader) (i.e., when no Range header is present, or
when it indicates a full request), so Safari's multiple Range sub-requests won't
trigger duplicate notifications.
- Around line 107-110: The using (asset.dispose) block disposes the response
stream too early (in AssetController around the File(asset.fileStream,
asset.ContentType, asset.fileName, enableRangeProcessing: true) return), so
remove that using and instead defer disposal until after ASP.NET finishes
sending the response; replace the immediate disposal with registering the
disposer via HttpContext.Response.RegisterForDispose(asset.dispose) (or the
equivalent Response.RegisterForDispose helper) so the asset.fileStream remains
open for the File/FileStreamResult to read, or if RegisterForDispose isn't
available implement deferred disposal via middleware or a response-completed
callback tied to the same asset.dispose.

---

Nitpick comments:
In `@ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs`:
- Line 12: Replace the 7-element tuple return from IImmichFrameLogic.GetAsset
with a dedicated record (e.g., AssetResult) to improve clarity and
extendability; create a public record AssetResult with clearly named properties
(FileName, ContentType, FileStream, ContentRange, IsPartial, Disposable,
ContentLength) and change the method signature in IImmichFrameLogic from
Task<(string fileName, string ContentType, Stream fileStream, string?
contentRange, bool isPartial, IDisposable? dispose, string? contentLength)>
GetAsset(...) to Task<AssetResult> GetAsset(...); also normalize casing (use
PascalCase for property names) and ensure the nullable/ownership semantics of
the IDisposable member are captured in the record name (Disposable) to make its
purpose explicit.

@coderabbitai
Copy link

coderabbitai bot commented Feb 19, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ImmichFrame.WebApi/Controllers/AssetController.cs (1)

139-148: ⚠️ Potential issue | 🟠 Major

asset.fileStream and asset.dispose are never disposed in GetRandomImageAndInfo — resource leak.

GetAsset now returns a tuple that includes IDisposable? dispose. Line 139 calls GetAsset but neither asset.fileStream nor asset.dispose is ever disposed after the copy at line 146. This is a new leak introduced by this PR's expanded return type.

🐛 Proposed fix
             var asset = await _logic.GetAsset(new Guid(randomAsset.Id), AssetTypeEnum.IMAGE);
             var notification = new AssetRequestedNotification(new Guid(randomAsset.Id), sanitizedClientIdentifier);
             _ = _logic.SendWebhookNotification(notification);

             string randomImageBase64;
+            using (asset.dispose)
+            await using var _ = asset.fileStream;
             using (var memoryStream = new MemoryStream())
             {
                 await asset.fileStream.CopyToAsync(memoryStream);
                 randomImageBase64 = Convert.ToBase64String(memoryStream.ToArray());
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ImmichFrame.WebApi/Controllers/AssetController.cs` around lines 139 - 148,
GetAsset now returns a tuple containing fileStream and an IDisposable
(asset.dispose) but GetRandomImageAndInfo never disposes them, leaking
resources; update the code around the var asset = await _logic.GetAsset(...)
call to ensure asset.fileStream is disposed after copying (wrap the copy in a
using or explicitly call Dispose/DisposeAsync) and also dispose asset.dispose
(call Dispose or DisposeAsync based on its type) in a finally or using-like
construct; keep the SendWebhookNotification call as-is but ensure both
asset.fileStream and asset.dispose are cleaned up before method exit (reference
symbols: GetAsset, asset.fileStream, asset.dispose, AssetRequestedNotification,
SendWebhookNotification).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ImmichFrame.WebApi/Controllers/AssetController.cs`:
- Line 74: The ProducesResponseType attribute on the AssetController method is
incorrect for 206 responses: it currently uses typeof(FileStreamResult) but the
206 branch writes directly to Response.Body and returns EmptyResult; update the
attribute on the relevant action in AssetController (the method that writes to
Response.Body and returns EmptyResult) to use typeof(void) or remove the type
argument so Swagger/OpenAPI reflects no response body for the 206 branch instead
of FileStreamResult.
- Around line 102-113: The current code disposes asset.dispose incorrectly
causing leaks on exceptions and premature disposal before ASP.NET executes
FileStreamResult; fix by registering the HttpResponseMessage for deferred
disposal instead of immediate Dispose: before copying or returning, call
HttpContext.Response.RegisterForDispose(asset.dispose) when asset.dispose is
non-null so the framework disposes it after the response is complete; remove the
explicit asset.dispose?.Dispose() after the await using and remove the using
(asset.dispose) block around the File(...) return so FileStreamResult can read
the stream during ExecuteResultAsync; keep await using for asset.fileStream but
ensure asset.dispose is registered for disposal up front (reference symbols:
asset.dispose, asset.fileStream, CopyToAsync, File(...)/FileStreamResult,
HttpContext.Response.RegisterForDispose()).

---

Outside diff comments:
In `@ImmichFrame.WebApi/Controllers/AssetController.cs`:
- Around line 139-148: GetAsset now returns a tuple containing fileStream and an
IDisposable (asset.dispose) but GetRandomImageAndInfo never disposes them,
leaking resources; update the code around the var asset = await
_logic.GetAsset(...) call to ensure asset.fileStream is disposed after copying
(wrap the copy in a using or explicitly call Dispose/DisposeAsync) and also
dispose asset.dispose (call Dispose or DisposeAsync based on its type) in a
finally or using-like construct; keep the SendWebhookNotification call as-is but
ensure both asset.fileStream and asset.dispose are cleaned up before method exit
(reference symbols: GetAsset, asset.fileStream, asset.dispose,
AssetRequestedNotification, SendWebhookNotification).

---

Duplicate comments:
In `@ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs`:
- Around line 45-46: The GetAsset method declaration in
MultiImmichFrameLogicDelegate has inconsistent leading/continuation indentation
compared to other expression-bodied methods; open the
MultiImmichFrameLogicDelegate class and align the GetAsset signature and its
continuation (the => _accountSelectionStrategy.ForAsset(...) expression) to
match the same column/indentation used by other methods (e.g., use the same
8-space indent used for similar => expressions), ensuring the declaration for
GetAsset and its continuation line use consistent whitespace; verify the
signature for GetAsset, the _accountSelectionStrategy.ForAsset call, and the
closing punctuation are all aligned with the surrounding methods.

[Produces("image/jpeg", "image/webp", "video/mp4", "video/quicktime")]
[ProducesResponseType(typeof(FileStreamResult), StatusCodes.Status200OK)]
public async Task<FileResult> GetAsset(Guid id, string clientIdentifier = "", AssetTypeEnum? assetType = null)
[ProducesResponseType(typeof(FileStreamResult), StatusCodes.Status206PartialContent)]
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

ProducesResponseType for 206 incorrectly declares FileStreamResult.

The 206 branch returns EmptyResult (line 107) after writing directly to Response.Body. Annotating it with typeof(FileStreamResult) generates misleading Swagger/OpenAPI metadata. Use typeof(void) or omit the type argument.

♻️ Proposed fix
-        [ProducesResponseType(typeof(FileStreamResult), StatusCodes.Status206PartialContent)]
+        [ProducesResponseType(StatusCodes.Status206PartialContent)]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ImmichFrame.WebApi/Controllers/AssetController.cs` at line 74, The
ProducesResponseType attribute on the AssetController method is incorrect for
206 responses: it currently uses typeof(FileStreamResult) but the 206 branch
writes directly to Response.Body and returns EmptyResult; update the attribute
on the relevant action in AssetController (the method that writes to
Response.Body and returns EmptyResult) to use typeof(void) or remove the type
argument so Swagger/OpenAPI reflects no response body for the 206 branch instead
of FileStreamResult.

Comment on lines +102 to +113
await using (asset.fileStream)
{
await asset.fileStream.CopyToAsync(Response.Body);
}
asset.dispose?.Dispose();
return new EmptyResult();
}

using (asset.dispose)
{
return File(asset.fileStream, asset.ContentType, asset.fileName, enableRangeProcessing: true);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's find and examine PooledImmichFrameLogic.cs to understand what dispose and fileStream are
find . -name "PooledImmichFrameLogic.cs" -type f

Repository: immichFrame/ImmichFrame

Length of output: 117


🏁 Script executed:

# Search for GetImageAsset and GetVideoAsset method implementations
rg -n "GetImageAsset|GetVideoAsset" --type cs -A 15 | head -100

Repository: immichFrame/ImmichFrame

Length of output: 4247


🏁 Script executed:

# Check AssetController.cs to see the full context around lines 102-113
fd "AssetController.cs" --type f

Repository: immichFrame/ImmichFrame

Length of output: 116


🏁 Script executed:

# Read AssetController.cs to see the context around lines 102-113
cat -n ImmichFrame.WebApi/Controllers/AssetController.cs | head -150 | tail -60

Repository: immichFrame/ImmichFrame

Length of output: 3097


🏁 Script executed:

# Get the complete GetImageAsset implementation
rg -n "private async Task.*GetImageAsset" ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs -A 40

Repository: immichFrame/ImmichFrame

Length of output: 1659


🏁 Script executed:

# Get the complete GetVideoAsset implementation
rg -n "private async Task.*GetVideoAsset" ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs -A 80

Repository: immichFrame/ImmichFrame

Length of output: 1528


🏁 Script executed:

# Find the GetAsset method that's called from the controller
rg -n "public.*Task.*GetAsset" ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs -A 25

Repository: immichFrame/ImmichFrame

Length of output: 1777


🏁 Script executed:

# Check if there are other usages of asset in the controller that might affect resource lifecycle
rg -n "asset\." ImmichFrame.WebApi/Controllers/AssetController.cs -B 2 -A 2 | head -80

Repository: immichFrame/ImmichFrame

Length of output: 1515


🏁 Script executed:

# Verify what File() returns and search for documentation or usage patterns
rg -n "File\(" ImmichFrame.WebApi/Controllers/AssetController.cs -B 2 -A 2

Repository: immichFrame/ImmichFrame

Length of output: 269


Two resource-management bugs: asset.dispose leaks on exception (partial path) and is disposed prematurely before FileStreamResult executes (non-partial path).

Partial path (lines 102–107): asset.dispose?.Dispose() is called after the await using block but outside it. If CopyToAsync throws, the await using correctly cleans up asset.fileStream, but asset.dispose (the HttpResponseMessage for video range requests) is never released — leaking the HTTP connection.

Non-partial path (lines 110–113): For video assets, the using block disposes asset.dispose (the HttpResponseMessage) immediately after the return expression is evaluated, before the FileStreamResult is handed to ASP.NET Core. This closes the underlying stream before FileStreamResult.ExecuteResultAsync() executes, causing a read failure. (Image assets are unaffected because asset.dispose is null.) The safe pattern is to register asset.dispose for deferred disposal via HttpContext.Response.RegisterForDispose().

🐛 Proposed fix — partial path
-                await using (asset.fileStream)
-                {
-                    await asset.fileStream.CopyToAsync(Response.Body);
-                }
-                asset.dispose?.Dispose();
+                try
+                {
+                    await using (asset.fileStream)
+                    {
+                        await asset.fileStream.CopyToAsync(Response.Body);
+                    }
+                }
+                finally
+                {
+                    asset.dispose?.Dispose();
+                }
🐛 Proposed fix — non-partial path
-            using (asset.dispose)
-            {
-                return File(asset.fileStream, asset.ContentType, asset.fileName, enableRangeProcessing: true);
-            }
+            if (asset.dispose != null)
+                HttpContext.Response.RegisterForDispose(asset.dispose);
+            return File(asset.fileStream, asset.ContentType, asset.fileName, enableRangeProcessing: true);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ImmichFrame.WebApi/Controllers/AssetController.cs` around lines 102 - 113,
The current code disposes asset.dispose incorrectly causing leaks on exceptions
and premature disposal before ASP.NET executes FileStreamResult; fix by
registering the HttpResponseMessage for deferred disposal instead of immediate
Dispose: before copying or returning, call
HttpContext.Response.RegisterForDispose(asset.dispose) when asset.dispose is
non-null so the framework disposes it after the response is complete; remove the
explicit asset.dispose?.Dispose() after the await using and remove the using
(asset.dispose) block around the File(...) return so FileStreamResult can read
the stream during ExecuteResultAsync; keep await using for asset.fileStream but
ensure asset.dispose is registered for disposal up front (reference symbols:
asset.dispose, asset.fileStream, CopyToAsync, File(...)/FileStreamResult,
HttpContext.Response.RegisterForDispose()).

@3rob3
Copy link
Collaborator Author

3rob3 commented Feb 19, 2026

@CodeRabbit review

@coderabbitai
Copy link

coderabbitai bot commented Feb 19, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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.

🧹 Nitpick comments (3)
ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs (3)

86-108: Consider replacing the 7-element value tuple with a named type.

The return type (string, string, Stream, string?, bool, IDisposable?, string?) is difficult to reason about at call sites — positional access is fragile and the caller must know which element is which. A small record would make the contract self-documenting and reduce the risk of transposed members.

Example
public record AssetResult(
    string FileName,
    string ContentType,
    Stream FileStream,
    string? ContentRange,
    bool IsPartial,
    IDisposable? DisposeHandle,
    string? ContentLength);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs` around lines 86 - 108,
Replace the 7-tuple return from GetAsset with a self-describing record type
(e.g., AssetResult) so callers don't rely on positional tuple members; define a
record like AssetResult(string FileName, string ContentType, Stream FileStream,
string? ContentRange, bool IsPartial, IDisposable? DisposeHandle, string?
ContentLength), change the signature of GetAsset to return AssetResult, update
the return sites in GetAsset (where it currently constructs the tuple from
GetImageAsset and GetVideoAsset) to construct and return an AssetResult, and
update GetImageAsset/GetVideoAsset (or their callers) to produce
AssetResult-compatible values; keep AssetNotFoundException usage unchanged.

168-168: URL path is duplicated outside the NSwag client.

The path /assets/{id}/video/playback is hardcoded here and also lives inside the NSwag-generated client. If the Immich API changes this endpoint, this manual construction will silently break while the generated client gets updated. Consider extracting this path as a constant or adding a comment noting the coupling.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs` at line 168, The code in
PooledImmichFrameLogic manually constructs the endpoint with _immichApi.BaseUrl
+ "/assets/{id}/video/playback", duplicating a path that also exists in the
NSwag-generated client and risking mismatches; replace the hardcoded path by
extracting it to a single named constant (e.g., ASSET_VIDEO_PLAYBACK_PATH) in
this class or a shared constants file and reference that constant when building
the URL, or better yet, call the NSwag client helper/method that returns this
route if one exists; also add a short comment noting the coupling to the
NSwag-generated endpoint so future API changes are caught in one place.

193-213: Thread CancellationToken through the call chain to enable cancellation on client disconnect.

PlayVideoWithRange accepts a CancellationToken parameter but always receives the default value because GetAsset and GetVideoAsset do not accept or forward one. Additionally, the ASP.NET controller has access to HttpContext.RequestAborted (the CancellationToken that signals client disconnect), but it is not passed to the logic layer.

For long-running video streams, a client disconnect will continue holding the upstream HTTP connection until the stream naturally completes or is garbage-collected. Threading CancellationToken through the chain—starting from the controller's HttpContext.RequestAbortedGetAssetGetVideoAssetPlayVideoWithRange—allows the stream to be cancelled promptly when the client disconnects.

Also consider updating the CopyToAsync call at line 104 in the controller to use the overload that accepts a CancellationToken.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs` around lines 193 - 213,
GetVideoAsset and its caller GetAsset currently don't accept or forward a
CancellationToken so PlayVideoWithRange always runs with CancellationToken.None;
add a CancellationToken parameter to GetAsset and GetVideoAsset, pass the token
into PlayVideoWithRange, and ensure any upstream call sites (controller action)
pass HttpContext.RequestAborted into GetAsset; also update the controller's
stream CopyToAsync call to use the overload that accepts the same
CancellationToken so the upstream HTTP request/response streams are cancelled
promptly on client disconnect.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs`:
- Around line 86-108: Replace the 7-tuple return from GetAsset with a
self-describing record type (e.g., AssetResult) so callers don't rely on
positional tuple members; define a record like AssetResult(string FileName,
string ContentType, Stream FileStream, string? ContentRange, bool IsPartial,
IDisposable? DisposeHandle, string? ContentLength), change the signature of
GetAsset to return AssetResult, update the return sites in GetAsset (where it
currently constructs the tuple from GetImageAsset and GetVideoAsset) to
construct and return an AssetResult, and update GetImageAsset/GetVideoAsset (or
their callers) to produce AssetResult-compatible values; keep
AssetNotFoundException usage unchanged.
- Line 168: The code in PooledImmichFrameLogic manually constructs the endpoint
with _immichApi.BaseUrl + "/assets/{id}/video/playback", duplicating a path that
also exists in the NSwag-generated client and risking mismatches; replace the
hardcoded path by extracting it to a single named constant (e.g.,
ASSET_VIDEO_PLAYBACK_PATH) in this class or a shared constants file and
reference that constant when building the URL, or better yet, call the NSwag
client helper/method that returns this route if one exists; also add a short
comment noting the coupling to the NSwag-generated endpoint so future API
changes are caught in one place.
- Around line 193-213: GetVideoAsset and its caller GetAsset currently don't
accept or forward a CancellationToken so PlayVideoWithRange always runs with
CancellationToken.None; add a CancellationToken parameter to GetAsset and
GetVideoAsset, pass the token into PlayVideoWithRange, and ensure any upstream
call sites (controller action) pass HttpContext.RequestAborted into GetAsset;
also update the controller's stream CopyToAsync call to use the overload that
accepts the same CancellationToken so the upstream HTTP request/response streams
are cancelled promptly on client disconnect.

@3rob3 3rob3 closed this Feb 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants