Skip to content

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

Open
3rob3 wants to merge 4 commits intomainfrom
dev.3rob3.SeekableStream
Open

Seekable streams (Safari/iOS fix for video)#580
3rob3 wants to merge 4 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

  • Bug Fixes

    • Video assets now reliably default to content-type video/mp4 when missing.
    • Missing-video behavior preserved (proper not-found responses).
  • Performance

    • Optional disk-backed video caching with automatic renewal of expired files.
  • Reliability

    • Safer download flow: temporary files used then atomically moved to cache on success; temp streams cleaned up on failure.
    • Non-cached streams use temporary, non-persistent paths to avoid leaving files behind.

@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

📝 Walkthrough

Walkthrough

GetVideoAsset adds optional disk-backed caching with renewal checks and a streaming fallback; it writes via a temp-to-rename flow when caching, uses a DeleteOnClose temp stream when not, defaults missing Content-Type to video/mp4, and preserves AssetNotFoundException behavior.

Changes

Cohort / File(s) Summary
Video asset retrieval & caching
ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs
Reworked GetVideoAsset to optionally cache video files on disk ({id}.mp4) with directory creation, freshness/renewal checks, and temp-to-final rename on successful download. When DownloadImages is false, uses a system-temp file with DeleteOnClose and returns a stream. Defaults absent Content-Type to video/mp4, maintains AssetNotFoundException, and adds robust temp-file cleanup/error handling.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant Logic as PooledImmichFrameLogic
  participant API as Immich API
  participant Disk as Filesystem

  Caller->>Logic: GetVideoAsset(id)
  Logic->>Logic: inspect config.DownloadImages
  alt DownloadImages = true
    Logic->>Disk: ensure cache dir exists
    Logic->>Disk: check `id.mp4` age (RenewImagesDuration)
    alt cached & fresh
      Disk-->>Logic: open cached file stream
      Logic-->>Caller: return disk-backed stream + Content-Type
    else missing or expired
      Logic->>API: PlayAssetVideoAsync(id)
      API-->>Logic: response (body, Content-Type)
      Logic->>Disk: write to temp file -> rename to `id.mp4`
      Disk-->>Logic: open cached file stream
      Logic-->>Caller: return disk-backed stream + Content-Type
    end
  else DownloadImages = false
    Logic->>API: PlayAssetVideoAsync(id)
    API-->>Logic: response (body, Content-Type)
    Logic->>Logic: default Content-Type to "video/mp4" if absent
    Logic->>Disk: create temp file (DeleteOnClose), write bytes
    Disk-->>Logic: open temp-file stream
    Logic-->>Caller: return temp-file-backed stream + Content-Type
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I cached a clip beneath my paw,
I write with care, then rename in awe,
If disk is off I stream and prance,
Fresh bytes hop by in happy dance,
A rabbit's patchwork of video chance 🎬🐇

🚥 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)' directly corresponds to the main change: implementing disk-based caching and buffering to provide seekable streams for Safari/iOS video playback compatibility.

✏️ 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

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs (1)

215-235: DeleteOnClose temp file is a good approach for seekable non-cached streams, but consider guarding against large videos exhausting disk in the temp directory.

The FileOptions.DeleteOnClose ensures automatic cleanup when the caller disposes the stream, and Guid.NewGuid() avoids collisions. The FileOptions.Asynchronous flag is appropriate for the async I/O. The catch block correctly disposes (and thus deletes) the temp file on copy failure.

One consideration: for very large videos, writing to Path.GetTempPath() could fill the OS temp partition (often a smaller disk/volume, especially in containerized environments). If ImmichFrame is expected to serve large video files, you may want to allow configuring the temp directory or reusing _downloadLocation as the temp base even in the non-download-caching path.


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

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