From aa02e0143d4fb2b8693dfee333716944be0b4145 Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Mon, 2 Feb 2026 14:08:09 -0500 Subject: [PATCH 1/9] Fix video playback on Safari/iOS by ensuring seekable streams. Implement caching (same as images) --- .../Logic/PooledImmichFrameLogic.cs | 47 ++++++++++++++----- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs index a13eead4..2178c4c2 100644 --- a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs +++ b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs @@ -163,26 +163,49 @@ public Task> GetAssets() private async Task<(string fileName, string ContentType, Stream fileStream)> GetVideoAsset(Guid id) { var videoResponse = await _immichApi.PlayAssetVideoAsync(id, string.Empty); - if (videoResponse == null) throw new AssetNotFoundException($"Video asset {id} was not found!"); - var contentType = ""; - if (videoResponse.Headers.ContainsKey("Content-Type")) - { - contentType = videoResponse.Headers["Content-Type"].FirstOrDefault() ?? ""; - } + var contentType = videoResponse.Headers.ContainsKey("Content-Type") + ? videoResponse.Headers["Content-Type"].FirstOrDefault() ?? "video/mp4" + : "video/mp4"; - if (string.IsNullOrWhiteSpace(contentType)) + var fileName = $"{id}.mp4"; + + if (_generalSettings.DownloadImages) { - contentType = "video/mp4"; - } + if (!Directory.Exists(_downloadLocation)) + { + Directory.CreateDirectory(_downloadLocation); + } - var fileName = $"{id}.mp4"; + var filePath = Path.Combine(_downloadLocation, fileName); - return (fileName, contentType, videoResponse.Stream); - } + if (File.Exists(filePath)) + { + if (_generalSettings.RenewImagesDuration > (DateTime.UtcNow - File.GetCreationTimeUtc(filePath)).Days) + { + return (fileName, contentType, File.OpenRead(filePath)); + } + File.Delete(filePath); + } + using (var fileStream = File.Create(filePath)) + { + await videoResponse.Stream.CopyToAsync(fileStream); + } + + return (fileName, contentType, File.OpenRead(filePath)); + } + else + { + var memoryStream = new MemoryStream(); + await videoResponse.Stream.CopyToAsync(memoryStream); + memoryStream.Position = 0; + + return (fileName, contentType, memoryStream); + } + } public Task SendWebhookNotification(IWebhookNotification notification) => WebhookHelper.SendWebhookNotification(notification, _generalSettings.Webhook); From 4ed4fb33d68b9d8262d70be638b1314ac1d76d44 Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Mon, 2 Feb 2026 14:43:23 -0500 Subject: [PATCH 2/9] coderabbit suggestion --- .../Logic/PooledImmichFrameLogic.cs | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs index 2178c4c2..d3dbb705 100644 --- a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs +++ b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs @@ -162,14 +162,6 @@ public Task> GetAssets() private async Task<(string fileName, string ContentType, Stream fileStream)> GetVideoAsset(Guid id) { - 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 fileName = $"{id}.mp4"; if (_generalSettings.DownloadImages) @@ -185,11 +177,20 @@ public Task> GetAssets() { if (_generalSettings.RenewImagesDuration > (DateTime.UtcNow - File.GetCreationTimeUtc(filePath)).Days) { - return (fileName, contentType, File.OpenRead(filePath)); + return (fileName, "video/mp4", File.OpenRead(filePath)); } File.Delete(filePath); } + 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"; + using (var fileStream = File.Create(filePath)) { await videoResponse.Stream.CopyToAsync(fileStream); @@ -199,6 +200,15 @@ public Task> GetAssets() } else { + 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 memoryStream = new MemoryStream(); await videoResponse.Stream.CopyToAsync(memoryStream); memoryStream.Position = 0; From 1b7e7c6d6a485bf5379e0657aa384ec413ecce56 Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Mon, 9 Feb 2026 16:10:50 -0500 Subject: [PATCH 3/9] Change from memorystream to temp file --- .../Logic/PooledImmichFrameLogic.cs | 58 ++++++++++--------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs index d3dbb705..ef6bf66c 100644 --- a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs +++ b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs @@ -163,6 +163,7 @@ public Task> GetAssets() private async Task<(string fileName, string ContentType, Stream fileStream)> GetVideoAsset(Guid id) { var fileName = $"{id}.mp4"; + string? filePath = null; if (_generalSettings.DownloadImages) { @@ -171,8 +172,7 @@ public Task> GetAssets() Directory.CreateDirectory(_downloadLocation); } - var filePath = Path.Combine(_downloadLocation, fileName); - + filePath = Path.Combine(_downloadLocation, fileName); if (File.Exists(filePath)) { if (_generalSettings.RenewImagesDuration > (DateTime.UtcNow - File.GetCreationTimeUtc(filePath)).Days) @@ -181,39 +181,45 @@ public Task> GetAssets() } File.Delete(filePath); } + } - using var videoResponse = await _immichApi.PlayAssetVideoAsync(id, string.Empty); - - if (videoResponse == null) - throw new AssetNotFoundException($"Video asset {id} was not found!"); + 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 = videoResponse.Headers.ContainsKey("Content-Type") + ? videoResponse.Headers["Content-Type"].FirstOrDefault() ?? "video/mp4" + : "video/mp4"; - using (var fileStream = File.Create(filePath)) + if (_generalSettings.DownloadImages) + { + using (var fileStream = File.Create(filePath!)) { await videoResponse.Stream.CopyToAsync(fileStream); } + return (fileName, contentType, File.OpenRead(filePath!)); + } - return (fileName, contentType, File.OpenRead(filePath)); + var tempPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.mp4"); + var tempFileStream = new FileStream( + tempPath, + FileMode.Create, + FileAccess.ReadWrite, + FileShare.None, + 4096, + FileOptions.DeleteOnClose | FileOptions.Asynchronous + ); + + try + { + await videoResponse.Stream.CopyToAsync(tempFileStream); + tempFileStream.Position = 0; + return (fileName, contentType, tempFileStream); } - else + catch { - 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 memoryStream = new MemoryStream(); - await videoResponse.Stream.CopyToAsync(memoryStream); - memoryStream.Position = 0; - - return (fileName, contentType, memoryStream); + tempFileStream.Dispose(); + throw; } } public Task SendWebhookNotification(IWebhookNotification notification) => From a804690c2a5ac84b08b466f3a215edea3a406149 Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Mon, 9 Feb 2026 16:25:37 -0500 Subject: [PATCH 4/9] temp-then-rename pattern --- .../Logic/PooledImmichFrameLogic.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs index ef6bf66c..21a7e2f7 100644 --- a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs +++ b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs @@ -193,11 +193,23 @@ public Task> GetAssets() if (_generalSettings.DownloadImages) { - using (var fileStream = File.Create(filePath!)) + var tempFilePath = filePath + ".tmp"; + try { - await videoResponse.Stream.CopyToAsync(fileStream); + using (var fileStream = File.Create(tempFilePath)) + { + await videoResponse.Stream.CopyToAsync(fileStream); + } + + File.Move(tempFilePath, filePath!, overwrite: true); + return (fileName, contentType, File.OpenRead(filePath!)); + } + catch + { + if (File.Exists(tempFilePath)) + File.Delete(tempFilePath); + throw; } - return (fileName, contentType, File.OpenRead(filePath!)); } var tempPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.mp4"); From 6a24e12e0c0f3b60843c097eb539aee22f2e4683 Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Thu, 19 Feb 2026 11:03:16 -0500 Subject: [PATCH 5/9] Proper range header handling --- .../Interfaces/IImmichFrameLogic.cs | 2 +- .../Logic/MultiImmichFrameLogicDelegate.cs | 4 +- .../Logic/PooledImmichFrameLogic.cs | 105 +++++++----------- .../Controllers/AssetController.cs | 20 +++- 4 files changed, 60 insertions(+), 71 deletions(-) diff --git a/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs b/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs index 4ddfd5b3..ab26d604 100644 --- a/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs +++ b/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs @@ -9,7 +9,7 @@ public interface IImmichFrameLogic public Task> GetAssets(); public Task GetAssetInfoById(Guid assetId); public Task> GetAlbumInfoById(Guid assetId); - public Task<(string fileName, string ContentType, Stream fileStream)> GetAsset(Guid id, AssetTypeEnum? assetType = null); + public Task<(string fileName, string ContentType, Stream fileStream, string? contentRange, bool isPartial)> GetAsset(Guid id, AssetTypeEnum? assetType = null, string? rangeHeader = null); public Task GetTotalAssets(); public Task SendWebhookNotification(IWebhookNotification notification); } diff --git a/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs b/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs index 3e0422cb..0b0c6c63 100644 --- a/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs +++ b/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs @@ -42,8 +42,8 @@ public Task> GetAlbumInfoById(Guid assetId) => _accountSelectionStrategy.ForAsset(assetId, logic => logic.GetAlbumInfoById(assetId)); - public Task<(string fileName, string ContentType, Stream fileStream)> GetAsset(Guid assetId, AssetTypeEnum? assetType = null) - => _accountSelectionStrategy.ForAsset(assetId, logic => logic.GetAsset(assetId, assetType)); + public Task<(string fileName, string ContentType, Stream fileStream, string? contentRange, bool isPartial)> GetAsset(Guid assetId, AssetTypeEnum? assetType = null, string? rangeHeader = null) + => _accountSelectionStrategy.ForAsset(assetId, logic => logic.GetAsset(assetId, assetType, rangeHeader)); public async Task GetTotalAssets() { diff --git a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs index 21a7e2f7..21709852 100644 --- a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs +++ b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs @@ -3,6 +3,7 @@ using ImmichFrame.Core.Helpers; using ImmichFrame.Core.Interfaces; using ImmichFrame.Core.Logic.Pool; +using System.Net.Http.Headers; namespace ImmichFrame.Core.Logic; @@ -12,6 +13,7 @@ public class PooledImmichFrameLogic : IAccountImmichFrameLogic private readonly IApiCache _apiCache; private readonly IAssetPool _pool; private readonly ImmichApi _immichApi; + private readonly HttpClient _httpClient; private readonly string _downloadLocation = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ImageCache"); public PooledImmichFrameLogic(IAccountSettings accountSettings, IGeneralSettings generalSettings, IHttpClientFactory httpClientFactory) @@ -22,6 +24,7 @@ public PooledImmichFrameLogic(IAccountSettings accountSettings, IGeneralSettings AccountSettings = accountSettings; httpClient.UseApiKey(accountSettings.ApiKey); + _httpClient = httpClient; _immichApi = new ImmichApi(accountSettings.ImmichServerUrl, httpClient); _apiCache = new ApiCache(RefreshInterval(generalSettings.RefreshAlbumPeopleInterval)); @@ -80,7 +83,7 @@ public Task> GetAssets() public Task GetTotalAssets() => _pool.GetAssetCount(); - public async Task<(string fileName, string ContentType, Stream fileStream)> GetAsset(Guid id, AssetTypeEnum? assetType = null) + public async Task<(string fileName, string ContentType, Stream fileStream, string? contentRange, bool isPartial)> GetAsset(Guid id, AssetTypeEnum? assetType = null, string? rangeHeader = null) { if (!assetType.HasValue) { @@ -92,12 +95,13 @@ public Task> GetAssets() if (assetType == AssetTypeEnum.IMAGE) { - return await GetImageAsset(id); + var (fileName, contentType, fileStream) = await GetImageAsset(id); + return (fileName, contentType, fileStream, null, false); } if (assetType == AssetTypeEnum.VIDEO) { - return await GetVideoAsset(id); + return await GetVideoAsset(id, rangeHeader); } throw new AssetNotFoundException($"Asset {id} is not a supported media type ({assetType})."); @@ -160,79 +164,50 @@ public Task> GetAssets() return (fileName, contentType, data.Stream); } - private async Task<(string fileName, string ContentType, Stream fileStream)> GetVideoAsset(Guid id) + private async Task PlayVideoWithRange(Guid id, string rangeHeader, CancellationToken cancellationToken = default) { - var fileName = $"{id}.mp4"; - string? filePath = null; + var url = $"{_immichApi.BaseUrl.TrimEnd('/')}/assets/{id}/video/playback"; - if (_generalSettings.DownloadImages) - { - if (!Directory.Exists(_downloadLocation)) - { - Directory.CreateDirectory(_downloadLocation); - } + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/octet-stream")); + request.Headers.TryAddWithoutValidation("Range", rangeHeader); - filePath = Path.Combine(_downloadLocation, fileName); - if (File.Exists(filePath)) - { - if (_generalSettings.RenewImagesDuration > (DateTime.UtcNow - File.GetCreationTimeUtc(filePath)).Days) - { - return (fileName, "video/mp4", File.OpenRead(filePath)); - } - File.Delete(filePath); - } + var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + + var headers = response.Headers.ToDictionary(h => h.Key, h => h.Value); + if (response.Content?.Headers != null) + foreach (var item in response.Content.Headers) + headers[item.Key] = item.Value; + + var status = (int)response.StatusCode; + if (status == 200 || status == 206) + { + var stream = response.Content == null ? Stream.Null : await response.Content.ReadAsStreamAsync(cancellationToken); + return new FileResponse(status, headers, stream, null, response); } - using var videoResponse = await _immichApi.PlayAssetVideoAsync(id, string.Empty); + var error = response.Content == null ? null : await response.Content.ReadAsStringAsync(cancellationToken); + throw new ApiException($"Unexpected status code ({status}).", status, error, headers, null); + } + + private async Task<(string fileName, string ContentType, Stream fileStream, string? contentRange, bool isPartial)> GetVideoAsset(Guid id, string? rangeHeader = null) + { + var videoResponse = string.IsNullOrEmpty(rangeHeader) + ? await _immichApi.PlayAssetVideoAsync(id, string.Empty) + : await PlayVideoWithRange(id, rangeHeader); + 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" + var contentType = videoResponse.Headers.TryGetValue("Content-Type", out var ct) + ? ct.FirstOrDefault() ?? "video/mp4" : "video/mp4"; - if (_generalSettings.DownloadImages) - { - var tempFilePath = filePath + ".tmp"; - try - { - using (var fileStream = File.Create(tempFilePath)) - { - await videoResponse.Stream.CopyToAsync(fileStream); - } + var contentRange = videoResponse.Headers.TryGetValue("Content-Range", out var cr) + ? cr.FirstOrDefault() + : null; - File.Move(tempFilePath, filePath!, overwrite: true); - return (fileName, contentType, File.OpenRead(filePath!)); - } - catch - { - if (File.Exists(tempFilePath)) - File.Delete(tempFilePath); - throw; - } - } - - var tempPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.mp4"); - var tempFileStream = new FileStream( - tempPath, - FileMode.Create, - FileAccess.ReadWrite, - FileShare.None, - 4096, - FileOptions.DeleteOnClose | FileOptions.Asynchronous - ); - - try - { - await videoResponse.Stream.CopyToAsync(tempFileStream); - tempFileStream.Position = 0; - return (fileName, contentType, tempFileStream); - } - catch - { - tempFileStream.Dispose(); - throw; - } + return ($"{id}.mp4", contentType, videoResponse.Stream, contentRange, videoResponse.StatusCode == 206); } public Task SendWebhookNotification(IWebhookNotification notification) => WebhookHelper.SendWebhookNotification(notification, _generalSettings.Webhook); diff --git a/ImmichFrame.WebApi/Controllers/AssetController.cs b/ImmichFrame.WebApi/Controllers/AssetController.cs index 989b1efc..485a4384 100644 --- a/ImmichFrame.WebApi/Controllers/AssetController.cs +++ b/ImmichFrame.WebApi/Controllers/AssetController.cs @@ -63,7 +63,7 @@ public async Task> GetAlbumInfo(Guid id, string clientIde [HttpGet("{id}/Image", Name = "GetImage")] [Produces("image/jpeg", "image/webp")] [ProducesResponseType(typeof(FileStreamResult), StatusCodes.Status200OK)] - public async Task GetImage(Guid id, string clientIdentifier = "") + public async Task GetImage(Guid id, string clientIdentifier = "") { return await GetAsset(id, clientIdentifier, AssetTypeEnum.IMAGE); } @@ -71,15 +71,29 @@ public async Task GetImage(Guid id, string clientIdentifier = "") [HttpGet("{id}/Asset", Name = "GetAsset")] [Produces("image/jpeg", "image/webp", "video/mp4", "video/quicktime")] [ProducesResponseType(typeof(FileStreamResult), StatusCodes.Status200OK)] - public async Task GetAsset(Guid id, string clientIdentifier = "", AssetTypeEnum? assetType = null) + [ProducesResponseType(typeof(FileStreamResult), StatusCodes.Status206PartialContent)] + public async Task GetAsset(Guid id, string clientIdentifier = "", AssetTypeEnum? assetType = null) { var sanitizedClientIdentifier = clientIdentifier.SanitizeString(); _logger.LogDebug("Asset '{id}' requested by '{sanitizedClientIdentifier}' (type hint: {assetType})", id, sanitizedClientIdentifier, assetType); - var asset = await _logic.GetAsset(id, assetType); + + var rangeHeader = Request.Headers["Range"].FirstOrDefault(); + var asset = await _logic.GetAsset(id, assetType, rangeHeader); var notification = new AssetRequestedNotification(id, sanitizedClientIdentifier); _ = _logic.SendWebhookNotification(notification); + Response.Headers["Accept-Ranges"] = "bytes"; + + if (asset.isPartial && !string.IsNullOrEmpty(asset.contentRange)) + { + Response.Headers["Content-Range"] = asset.contentRange; + Response.StatusCode = 206; + Response.ContentType = asset.ContentType; + await asset.fileStream.CopyToAsync(Response.Body); + return new EmptyResult(); + } + return File(asset.fileStream, asset.ContentType, asset.fileName, enableRangeProcessing: true); } From 8624060366bffd0f4a078f1d6ade6e924e54d35e Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Thu, 19 Feb 2026 11:27:48 -0500 Subject: [PATCH 6/9] disposal --- .../Interfaces/IImmichFrameLogic.cs | 2 +- .../Logic/MultiImmichFrameLogicDelegate.cs | 4 ++-- .../Logic/PooledImmichFrameLogic.cs | 11 ++++++----- .../Controllers/AssetController.cs | 17 +++++++++++++++-- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs b/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs index ab26d604..6d92f8e9 100644 --- a/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs +++ b/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs @@ -9,7 +9,7 @@ public interface IImmichFrameLogic public Task> GetAssets(); public Task GetAssetInfoById(Guid assetId); public Task> GetAlbumInfoById(Guid assetId); - public Task<(string fileName, string ContentType, Stream fileStream, string? contentRange, bool isPartial)> GetAsset(Guid id, AssetTypeEnum? assetType = null, string? rangeHeader = null); + public Task<(string fileName, string ContentType, Stream fileStream, string? contentRange, bool isPartial, IDisposable? dispose, string? contentLength)> GetAsset(Guid id, AssetTypeEnum? assetType = null, string? rangeHeader = null); public Task GetTotalAssets(); public Task SendWebhookNotification(IWebhookNotification notification); } diff --git a/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs b/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs index 0b0c6c63..4dd564d9 100644 --- a/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs +++ b/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs @@ -42,8 +42,8 @@ public Task> GetAlbumInfoById(Guid assetId) => _accountSelectionStrategy.ForAsset(assetId, logic => logic.GetAlbumInfoById(assetId)); - public Task<(string fileName, string ContentType, Stream fileStream, string? contentRange, bool isPartial)> GetAsset(Guid assetId, AssetTypeEnum? assetType = null, string? rangeHeader = null) - => _accountSelectionStrategy.ForAsset(assetId, logic => logic.GetAsset(assetId, assetType, rangeHeader)); +public Task<(string fileName, string ContentType, Stream fileStream, string? contentRange, bool isPartial, IDisposable? dispose, string? contentLength)> GetAsset(Guid assetId, AssetTypeEnum? assetType = null, string? rangeHeader = null) + => _accountSelectionStrategy.ForAsset(assetId, logic => logic.GetAsset(assetId, assetType, rangeHeader)); public async Task GetTotalAssets() { diff --git a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs index 21709852..f28d08a1 100644 --- a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs +++ b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs @@ -83,7 +83,7 @@ public Task> GetAssets() public Task GetTotalAssets() => _pool.GetAssetCount(); - public async Task<(string fileName, string ContentType, Stream fileStream, string? contentRange, bool isPartial)> GetAsset(Guid id, AssetTypeEnum? assetType = null, string? rangeHeader = null) + public async Task<(string fileName, string ContentType, Stream fileStream, string? contentRange, bool isPartial, IDisposable? dispose, string? contentLength)> GetAsset(Guid id, AssetTypeEnum? assetType = null, string? rangeHeader = null) { if (!assetType.HasValue) { @@ -96,7 +96,7 @@ public Task> GetAssets() if (assetType == AssetTypeEnum.IMAGE) { var (fileName, contentType, fileStream) = await GetImageAsset(id); - return (fileName, contentType, fileStream, null, false); + return (fileName, contentType, fileStream, null, false, null, null); } if (assetType == AssetTypeEnum.VIDEO) @@ -106,7 +106,6 @@ public Task> GetAssets() throw new AssetNotFoundException($"Asset {id} is not a supported media type ({assetType})."); } - private async Task<(string fileName, string ContentType, Stream fileStream)> GetImageAsset(Guid id) { if (_generalSettings.DownloadImages) @@ -190,7 +189,7 @@ private async Task PlayVideoWithRange(Guid id, string rangeHeader, throw new ApiException($"Unexpected status code ({status}).", status, error, headers, null); } - private async Task<(string fileName, string ContentType, Stream fileStream, string? contentRange, bool isPartial)> GetVideoAsset(Guid id, string? rangeHeader = null) + private async Task<(string fileName, string ContentType, Stream fileStream, string? contentRange, bool isPartial, IDisposable? dispose, string? contentLength)> GetVideoAsset(Guid id, string? rangeHeader = null) { var videoResponse = string.IsNullOrEmpty(rangeHeader) ? await _immichApi.PlayAssetVideoAsync(id, string.Empty) @@ -207,7 +206,9 @@ private async Task PlayVideoWithRange(Guid id, string rangeHeader, ? cr.FirstOrDefault() : null; - return ($"{id}.mp4", contentType, videoResponse.Stream, contentRange, videoResponse.StatusCode == 206); + var contentLength = videoResponse.Headers.TryGetValue("Content-Length", out var cl) ? cl.FirstOrDefault() : null; + + return ($"{id}.mp4", contentType, videoResponse.Stream, contentRange, videoResponse.StatusCode == 206, videoResponse, contentLength); } public Task SendWebhookNotification(IWebhookNotification notification) => WebhookHelper.SendWebhookNotification(notification, _generalSettings.Webhook); diff --git a/ImmichFrame.WebApi/Controllers/AssetController.cs b/ImmichFrame.WebApi/Controllers/AssetController.cs index 485a4384..59bbb9f1 100644 --- a/ImmichFrame.WebApi/Controllers/AssetController.cs +++ b/ImmichFrame.WebApi/Controllers/AssetController.cs @@ -90,11 +90,24 @@ public async Task GetAsset(Guid id, string clientIdentifier = "", Response.Headers["Content-Range"] = asset.contentRange; Response.StatusCode = 206; Response.ContentType = asset.ContentType; - await asset.fileStream.CopyToAsync(Response.Body); + + if (asset.fileStream is { CanSeek: true } && asset.fileStream.Length > 0) + Response.ContentLength = asset.fileStream.Length; + else if (!string.IsNullOrEmpty(asset.contentLength) && long.TryParse(asset.contentLength, out var length)) + Response.ContentLength = length; + + await using (asset.fileStream) + { + await asset.fileStream.CopyToAsync(Response.Body); + } + asset.dispose?.Dispose(); return new EmptyResult(); } - return File(asset.fileStream, asset.ContentType, asset.fileName, enableRangeProcessing: true); + using (asset.dispose) + { + return File(asset.fileStream, asset.ContentType, asset.fileName, enableRangeProcessing: true); + } } [HttpGet("RandomImageAndInfo", Name = "GetRandomImageAndInfo")] From 99a17c7bb3935cefd7657b9444055c3065248e28 Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Thu, 19 Feb 2026 11:39:22 -0500 Subject: [PATCH 7/9] fix webhook spamming --- ImmichFrame.WebApi/Controllers/AssetController.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ImmichFrame.WebApi/Controllers/AssetController.cs b/ImmichFrame.WebApi/Controllers/AssetController.cs index 59bbb9f1..5d7bc4a5 100644 --- a/ImmichFrame.WebApi/Controllers/AssetController.cs +++ b/ImmichFrame.WebApi/Controllers/AssetController.cs @@ -80,8 +80,11 @@ public async Task GetAsset(Guid id, string clientIdentifier = "", var rangeHeader = Request.Headers["Range"].FirstOrDefault(); var asset = await _logic.GetAsset(id, assetType, rangeHeader); - var notification = new AssetRequestedNotification(id, sanitizedClientIdentifier); - _ = _logic.SendWebhookNotification(notification); + if (string.IsNullOrEmpty(rangeHeader)) + { + var notification = new AssetRequestedNotification(id, sanitizedClientIdentifier); + _ = _logic.SendWebhookNotification(notification); + } Response.Headers["Accept-Ranges"] = "bytes"; From 81986577ca7256ce3e069287e2ba4a1b4c756e9b Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Thu, 19 Feb 2026 13:50:26 -0500 Subject: [PATCH 8/9] indent --- ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs b/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs index 4dd564d9..7abc8daa 100644 --- a/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs +++ b/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs @@ -42,8 +42,8 @@ public Task> GetAlbumInfoById(Guid assetId) => _accountSelectionStrategy.ForAsset(assetId, logic => logic.GetAlbumInfoById(assetId)); -public Task<(string fileName, string ContentType, Stream fileStream, string? contentRange, bool isPartial, IDisposable? dispose, string? contentLength)> GetAsset(Guid assetId, AssetTypeEnum? assetType = null, string? rangeHeader = null) - => _accountSelectionStrategy.ForAsset(assetId, logic => logic.GetAsset(assetId, assetType, rangeHeader)); + public Task<(string fileName, string ContentType, Stream fileStream, string? contentRange, bool isPartial, IDisposable? dispose, string? contentLength)> GetAsset(Guid assetId, AssetTypeEnum? assetType = null, string? rangeHeader = null) + => _accountSelectionStrategy.ForAsset(assetId, logic => logic.GetAsset(assetId, assetType, rangeHeader)); public async Task GetTotalAssets() { From 8a9ad8ce9de5f4705dac918fa5562f27e76c0030 Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Thu, 19 Feb 2026 14:16:46 -0500 Subject: [PATCH 9/9] dispose response --- ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs index f28d08a1..4b5ac1aa 100644 --- a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs +++ b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs @@ -186,6 +186,7 @@ private async Task PlayVideoWithRange(Guid id, string rangeHeader, } var error = response.Content == null ? null : await response.Content.ReadAsStringAsync(cancellationToken); + response.Dispose(); throw new ApiException($"Unexpected status code ({status}).", status, error, headers, null); }