Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ public interface IImmichFrameLogic
public Task<IEnumerable<AssetResponseDto>> GetAssets();
public Task<AssetResponseDto> GetAssetInfoById(Guid assetId);
public Task<IEnumerable<AlbumResponseDto>> 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<long> GetTotalAssets();
public Task SendWebhookNotification(IWebhookNotification notification);
}
Expand Down
4 changes: 2 additions & 2 deletions ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ public Task<IEnumerable<AlbumResponseDto>> 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<long> GetTotalAssets()
{
Expand Down
64 changes: 46 additions & 18 deletions ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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)
Expand All @@ -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));
Expand Down Expand Up @@ -80,7 +83,7 @@ public Task<IEnumerable<AssetResponseDto>> GetAssets()

public Task<long> 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)
{
Expand All @@ -92,17 +95,17 @@ public Task<IEnumerable<AssetResponseDto>> 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)
Expand Down Expand Up @@ -160,29 +163,54 @@ public Task<IEnumerable<AssetResponseDto>> GetAssets()
return (fileName, contentType, data.Stream);
}

private async Task<(string fileName, string ContentType, Stream fileStream)> GetVideoAsset(Guid id)
private async Task<FileResponse> 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);

Expand Down
42 changes: 36 additions & 6 deletions ImmichFrame.WebApi/Controllers/AssetController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,24 +63,54 @@ public async Task<List<AlbumResponseDto>> GetAlbumInfo(Guid id, string clientIde
[HttpGet("{id}/Image", Name = "GetImage")]
[Produces("image/jpeg", "image/webp")]
[ProducesResponseType(typeof(FileStreamResult), StatusCodes.Status200OK)]
public async Task<FileResult> GetImage(Guid id, string clientIdentifier = "")
public async Task<IActionResult> GetImage(Guid id, string clientIdentifier = "")
{
return await GetAsset(id, clientIdentifier, AssetTypeEnum.IMAGE);
}

[HttpGet("{id}/Asset", Name = "GetAsset")]
[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)]
public async Task<IActionResult> 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")]
Expand Down
Loading