diff --git a/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs b/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs index 4ddfd5b3..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)> GetAsset(Guid id, AssetTypeEnum? assetType = 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 3e0422cb..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)> 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, 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 a13eead4..4b5ac1aa 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, IDisposable? dispose, string? contentLength)> GetAsset(Guid id, AssetTypeEnum? assetType = null, string? rangeHeader = null) { if (!assetType.HasValue) { @@ -92,17 +95,17 @@ public Task> GetAssets() if (assetType == AssetTypeEnum.IMAGE) { - return await GetImageAsset(id); + var (fileName, contentType, fileStream) = await GetImageAsset(id); + return (fileName, contentType, fileStream, null, false, null, null); } 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})."); } - private async Task<(string fileName, string ContentType, Stream fileStream)> GetImageAsset(Guid id) { if (_generalSettings.DownloadImages) @@ -160,29 +163,54 @@ 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 videoResponse = await _immichApi.PlayAssetVideoAsync(id, string.Empty); + var url = $"{_immichApi.BaseUrl.TrimEnd('/')}/assets/{id}/video/playback"; - if (videoResponse == null) - throw new AssetNotFoundException($"Video asset {id} was not found!"); + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/octet-stream")); + request.Headers.TryAddWithoutValidation("Range", rangeHeader); - var contentType = ""; - if (videoResponse.Headers.ContainsKey("Content-Type")) - { - contentType = videoResponse.Headers["Content-Type"].FirstOrDefault() ?? ""; - } + 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; - if (string.IsNullOrWhiteSpace(contentType)) + var status = (int)response.StatusCode; + if (status == 200 || status == 206) { - contentType = "video/mp4"; + var stream = response.Content == null ? Stream.Null : await response.Content.ReadAsStreamAsync(cancellationToken); + return new FileResponse(status, headers, stream, null, response); } - var fileName = $"{id}.mp4"; - - return (fileName, contentType, videoResponse.Stream); + var error = response.Content == null ? null : await response.Content.ReadAsStringAsync(cancellationToken); + response.Dispose(); + throw new ApiException($"Unexpected status code ({status}).", status, error, headers, 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) + : await PlayVideoWithRange(id, rangeHeader); + + if (videoResponse == null) + throw new AssetNotFoundException($"Video asset {id} was not found!"); + + var contentType = videoResponse.Headers.TryGetValue("Content-Type", out var ct) + ? ct.FirstOrDefault() ?? "video/mp4" + : "video/mp4"; + + var contentRange = videoResponse.Headers.TryGetValue("Content-Range", out var cr) + ? cr.FirstOrDefault() + : null; + + 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 989b1efc..5d7bc4a5 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,16 +71,46 @@ 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 notification = new AssetRequestedNotification(id, sanitizedClientIdentifier); - _ = _logic.SendWebhookNotification(notification); + var rangeHeader = Request.Headers["Range"].FirstOrDefault(); + var asset = await _logic.GetAsset(id, assetType, rangeHeader); + + if (string.IsNullOrEmpty(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; - return File(asset.fileStream, asset.ContentType, asset.fileName, enableRangeProcessing: true); + 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(); + } + + using (asset.dispose) + { + return File(asset.fileStream, asset.ContentType, asset.fileName, enableRangeProcessing: true); + } } [HttpGet("RandomImageAndInfo", Name = "GetRandomImageAndInfo")]