From 5569f975d0b6b64492ad23dca044c66379758c06 Mon Sep 17 00:00:00 2001 From: Jonathan Gilbert Date: Fri, 10 Oct 2025 14:37:54 +1100 Subject: [PATCH] feat: Sort memories chronologically --- .../Logic/Pool/AlbumAssetsPoolTests.cs | 2 +- .../Logic/Pool/CachingApiAssetsPoolTests.cs | 38 +++--- .../Logic/Pool/FavoriteAssetsPoolTests.cs | 2 +- .../Logic/Pool/PersonAssetsPoolTests.cs | 2 +- ImmichFrame.Core/Logic/LogicPoolAdapter.cs | 45 +++++++ .../Logic/Pool/AlbumAssetsPool.cs | 6 +- .../Logic/Pool/CachingApiAssetsPool.cs | 47 +++++-- .../Logic/Pool/FavoriteAssetsPool.cs | 4 +- .../Logic/Pool/MemoryAssetsPool.cs | 7 +- .../Logic/Pool/PeopleAssetsPool.cs | 16 +-- ImmichFrame.Core/Logic/PoolConfiguration.cs | 21 ++++ .../Logic/PooledImmichFrameLogic.cs | 115 ++--------------- .../Controllers/AssetControllerTests.cs | 23 +++- .../ImmichFrame.WebApi.Tests.csproj | 1 + .../Logic}/MemoryAssetsPoolTests.cs | 118 ++++++++++++++++-- ImmichFrame.WebApi/Program.cs | 4 +- 16 files changed, 278 insertions(+), 173 deletions(-) create mode 100644 ImmichFrame.Core/Logic/LogicPoolAdapter.cs create mode 100644 ImmichFrame.Core/Logic/PoolConfiguration.cs rename {ImmichFrame.Core.Tests/Logic/Pool => ImmichFrame.WebApi.Tests/Logic}/MemoryAssetsPoolTests.cs (63%) diff --git a/ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs index 6d1058d5..a21822a6 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs @@ -23,7 +23,7 @@ private class TestableAlbumAssetsPool(IApiCache apiCache, ImmichApi immichApi, I : AlbumAssetsPool(apiCache, immichApi, accountSettings) { // Expose LoadAssets for testing - public Task> TestLoadAssets(CancellationToken ct = default) => base.LoadAssets(ct); + public Task> TestLoadAssets(CancellationToken ct = default) => base.LoadAssets(ct); } [SetUp] diff --git a/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs index bdfbee94..f4be2810 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs @@ -22,16 +22,16 @@ public class CachingApiAssetsPoolTests // Concrete implementation for testing the abstract class private class TestableCachingApiAssetsPool : CachingApiAssetsPool { - public Func>> LoadAssetsFunc { get; set; } + public Func>> LoadAssetsFunc { get; set; } public TestableCachingApiAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) - : base(apiCache, immichApi, accountSettings) + : base(apiCache, accountSettings) { } - protected override Task> LoadAssets(CancellationToken ct = default) + protected override Task> LoadAssets(CancellationToken ct = default) { - return LoadAssetsFunc != null ? LoadAssetsFunc() : Task.FromResult(Enumerable.Empty()); + return LoadAssetsFunc != null ? LoadAssetsFunc() : Task.FromResult>(new List()); } } @@ -47,9 +47,9 @@ public void Setup() // Default setup for ApiCache to execute the factory function _mockApiCache.Setup(c => c.GetOrAddAsync( It.IsAny(), - It.IsAny>>>() + It.IsAny>>>() )) - .Returns>>>(async (key, factory) => await factory()); + .Returns>>>(async (key, factory) => await factory()); // Default account settings _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(true); @@ -76,7 +76,7 @@ public async Task GetAssetCount_ReturnsCorrectCount_AfterFiltering() { // Arrange var assets = CreateSampleAssets(); - _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); + _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(false); // Filter out archived // Act @@ -92,7 +92,7 @@ public async Task GetAssets_ReturnsRequestedNumberOfAssets() { // Arrange var assets = CreateSampleAssets(); // Total 5 assets, 4 images if ShowArchived = true - _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); + _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(true); // Asset "3" included // Act @@ -109,7 +109,7 @@ public async Task GetAssets_ReturnsAllAvailableIfLessThanRequested() { // Arrange var assets = CreateSampleAssets().Where(a => a.Type == AssetTypeEnum.IMAGE && !a.IsArchived).ToList(); // 3 assets - _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); + _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(false); // Act @@ -129,16 +129,16 @@ public async Task AllAssets_UsesCache_LoadAssetsCalledOnce() _testPool.LoadAssetsFunc = () => { loadAssetsCallCount++; - return Task.FromResult>(assets); + return Task.FromResult>(assets); }; // Setup cache to really cache after the first call - IEnumerable cachedValue = null; + IList cachedValue = null; _mockApiCache.Setup(c => c.GetOrAddAsync( It.IsAny(), - It.IsAny>>>() + It.IsAny>>>() )) - .Returns>>>(async (key, factory) => + .Returns>>>(async (key, factory) => { if (cachedValue == null) { @@ -162,7 +162,7 @@ public async Task ApplyAccountFilters_FiltersArchived() { // Arrange var assets = CreateSampleAssets(); // Asset "3" is archived - _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); + _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(false); // Act @@ -178,7 +178,7 @@ public async Task ApplyAccountFilters_FiltersImagesUntilDate() { // Arrange var assets = CreateSampleAssets(); - _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); + _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); var untilDate = DateTime.Now.AddDays(-7); // Assets "1" (10 days ago), "5" (1 year ago) should match _mockAccountSettings.SetupGet(s => s.ImagesUntilDate).Returns(untilDate); _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(true); // Include asset "3" for date check if not filtered by archive @@ -201,7 +201,7 @@ public async Task ApplyAccountFilters_FiltersImagesFromDate() { // Arrange var assets = CreateSampleAssets(); - _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); + _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); var fromDate = DateTime.Now.AddDays(-7); // Assets "3" (5 days ago), "4" (2 days ago) should match _mockAccountSettings.SetupGet(s => s.ImagesFromDate).Returns(fromDate); _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(true); @@ -224,7 +224,7 @@ public async Task ApplyAccountFilters_FiltersImagesFromDays() { // Arrange var assets = CreateSampleAssets(); - _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); + _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); _mockAccountSettings.SetupGet(s => s.ImagesFromDays).Returns(7); // Last 7 days _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(true); var fromDate = DateTime.Today.AddDays(-7); @@ -248,7 +248,7 @@ public async Task ApplyAccountFilters_FiltersRating() { // Arrange var assets = CreateSampleAssets(); // Asset "1" (rating 5), "3" (rating 3), "4" (rating 5), "5" (rating 1) - _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); + _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); _mockAccountSettings.SetupGet(s => s.Rating).Returns(5); _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(true); @@ -269,7 +269,7 @@ public async Task ApplyAccountFilters_CombinedFilters() { // Arrange var assets = CreateSampleAssets(); - _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); + _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(false); // No archived (Asset "3" out) _mockAccountSettings.SetupGet(s => s.ImagesFromDays).Returns(15); // Last 15 days (Asset "5" out) diff --git a/ImmichFrame.Core.Tests/Logic/Pool/FavoriteAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/FavoriteAssetsPoolTests.cs index e8d74cca..34e7b417 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/FavoriteAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/FavoriteAssetsPoolTests.cs @@ -24,7 +24,7 @@ private class TestableFavoriteAssetsPool : FavoriteAssetsPool public TestableFavoriteAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : base(apiCache, immichApi, accountSettings) { } - public Task> TestLoadAssets(CancellationToken ct = default) + public Task> TestLoadAssets(CancellationToken ct = default) { return base.LoadAssets(ct); } diff --git a/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs index d4e729c1..f35af8d8 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs @@ -24,7 +24,7 @@ private class TestablePersonAssetsPool : PersonAssetsPool public TestablePersonAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : base(apiCache, immichApi, accountSettings) { } - public Task> TestLoadAssets(CancellationToken ct = default) + public Task> TestLoadAssets(CancellationToken ct = default) { return base.LoadAssets(ct); } diff --git a/ImmichFrame.Core/Logic/LogicPoolAdapter.cs b/ImmichFrame.Core/Logic/LogicPoolAdapter.cs new file mode 100644 index 00000000..5eec470f --- /dev/null +++ b/ImmichFrame.Core/Logic/LogicPoolAdapter.cs @@ -0,0 +1,45 @@ +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Exceptions; +using ImmichFrame.Core.Interfaces; +using ImmichFrame.Core.Logic.Pool; + +namespace ImmichFrame.Core.Logic; + +public class LogicPoolAdapter(IAssetPool pool, ImmichApi immichApi, string? webhook) : IImmichFrameLogic +{ + public async Task GetNextAsset() + => (await pool.GetAssets(1)).FirstOrDefault(); + + public Task> GetAssets() + => pool.GetAssets(25); + + public Task GetAssetInfoById(Guid assetId) + => immichApi.GetAssetInfoAsync(assetId, null); + + public async Task> GetAlbumInfoById(Guid assetId) + => await immichApi.GetAllAlbumsAsync(assetId, null); + + public Task GetTotalAssets() => pool.GetAssetCount(); + + public Task SendWebhookNotification(IWebhookNotification notification) => + WebhookHelper.SendWebhookNotification(notification, webhook); + + public virtual async Task<(string fileName, string ContentType, Stream fileStream)> GetImage(Guid id) + { + var data = await immichApi.ViewAssetAsync(id, string.Empty, AssetMediaSize.Preview); + + if (data == null) + throw new AssetNotFoundException($"Asset {id} was not found!"); + + var contentType = ""; + if (data.Headers.ContainsKey("Content-Type")) + { + contentType = data.Headers["Content-Type"].FirstOrDefault() ?? ""; + } + + var ext = contentType.ToLower() == "image/webp" ? "webp" : "jpeg"; + var fileName = $"{id}.{ext}"; + + return (fileName, contentType, data.Stream); + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs index 5487596d..1f454c3a 100644 --- a/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs @@ -4,9 +4,9 @@ namespace ImmichFrame.Core.Logic.Pool; -public class AlbumAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : CachingApiAssetsPool(apiCache, immichApi, accountSettings) +public class AlbumAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : CachingApiAssetsPool(apiCache, accountSettings) { - protected override async Task> LoadAssets(CancellationToken ct = default) + protected override async Task> LoadAssets(CancellationToken ct = default) { var excludedAlbumAssets = new List(); @@ -24,6 +24,6 @@ protected override async Task> LoadAssets(Cancella albumAssets.AddRange(albumInfo.Assets); } - return albumAssets.WhereExcludes(excludedAlbumAssets, t => t.Id); + return albumAssets.WhereExcludes(excludedAlbumAssets, t => t.Id).ToList(); } } \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs index 2664a99e..9de1c911 100644 --- a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs @@ -3,27 +3,48 @@ namespace ImmichFrame.Core.Logic.Pool; -public abstract class CachingApiAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : IAssetPool +public abstract class CachingApiAssetsPool(IApiCache apiCache, IAccountSettings accountSettings) : IAssetPool { - private readonly Random _random = new(); - + private int _next; //next asset to return + public async Task GetAssetCount(CancellationToken ct = default) - { - return (await AllAssets(ct)).Count(); - } + => (await AllAssets(ct)).Count; public async Task> GetAssets(int requested, CancellationToken ct = default) { - return (await AllAssets(ct)).OrderBy(_ => _random.Next()).Take(requested); + if (requested == 0) + { + return new List(); + } + + var all = await AllAssets(ct); + + if (all.Count < requested) + { + requested = all.Count; //limit request to what we have + } + + var tail = all.TakeLast(all.Count - _next).ToList(); + + if (tail.Count >= requested) + { + _next += requested; + return tail.Take(requested); + } + + // not enough left in tail; need to read head too + var overrun = requested - tail.Count; + _next = overrun; + return tail.Concat(all.Take(overrun)); } - private async Task> AllAssets(CancellationToken ct = default) + private async Task> AllAssets(CancellationToken ct = default) { return await apiCache.GetOrAddAsync(GetType().FullName!, () => ApplyAccountFilters(LoadAssets(ct))); } - protected async Task> ApplyAccountFilters(Task> unfiltered) + protected async Task> ApplyAccountFilters(Task> unfiltered) { // Display only Images var assets = (await unfiltered).Where(x => x.Type == AssetTypeEnum.IMAGE); @@ -47,9 +68,9 @@ protected async Task> ApplyAccountFilters(Task x.ExifInfo.Rating == rating); } - - return assets; + + return assets.ToList(); } - - protected abstract Task> LoadAssets(CancellationToken ct = default); + + protected abstract Task> LoadAssets(CancellationToken ct = default); } \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/FavoriteAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/FavoriteAssetsPool.cs index beb215f9..9c355a29 100644 --- a/ImmichFrame.Core/Logic/Pool/FavoriteAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/FavoriteAssetsPool.cs @@ -3,9 +3,9 @@ namespace ImmichFrame.Core.Logic.Pool; -public class FavoriteAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : CachingApiAssetsPool(apiCache, immichApi, accountSettings) +public class FavoriteAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : CachingApiAssetsPool(apiCache, accountSettings) { - protected override async Task> LoadAssets(CancellationToken ct = default) + protected override async Task> LoadAssets(CancellationToken ct = default) { var favoriteAssets = new List(); diff --git a/ImmichFrame.Core/Logic/Pool/MemoryAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/MemoryAssetsPool.cs index 2b574345..4db7508d 100644 --- a/ImmichFrame.Core/Logic/Pool/MemoryAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/MemoryAssetsPool.cs @@ -5,14 +5,14 @@ namespace ImmichFrame.Core.Logic.Pool; -public class MemoryAssetsPool(ImmichApi immichApi, IAccountSettings accountSettings) : CachingApiAssetsPool(new DailyApiCache(), immichApi, accountSettings) +public class MemoryAssetsPool(ImmichApi immichApi, IAccountSettings accountSettings) : CachingApiAssetsPool(new DailyApiCache(), accountSettings) { - protected override async Task> LoadAssets(CancellationToken ct = default) + protected override async Task> LoadAssets(CancellationToken ct = default) { var memories = await immichApi.SearchMemoriesAsync(DateTime.Now, null, null, null, ct); var memoryAssets = new List(); - foreach (var memory in memories) + foreach (var memory in memories.OrderBy(m => m.MemoryAt)) { var assets = memory.Assets.ToList(); var yearsAgo = DateTime.Now.Year - memory.Data.Year; @@ -25,7 +25,6 @@ protected override async Task> LoadAssets(Cancella asset.ExifInfo = assetInfo.ExifInfo; asset.People = assetInfo.People; } - asset.ExifInfo.Description = $"{yearsAgo} {(yearsAgo == 1 ? "year" : "years")} ago"; } diff --git a/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs index fb3e42e9..f1a99896 100644 --- a/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs @@ -3,9 +3,9 @@ namespace ImmichFrame.Core.Logic.Pool; -public class PersonAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : CachingApiAssetsPool(apiCache, immichApi, accountSettings) +public class PersonAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : CachingApiAssetsPool(apiCache, accountSettings) { - protected override async Task> LoadAssets(CancellationToken ct = default) + protected override async Task> LoadAssets(CancellationToken ct = default) { var personAssets = new List(); @@ -13,7 +13,7 @@ protected override async Task> LoadAssets(Cancella { int page = 1; int batchSize = 1000; - int total; + int returned; do { var metadataBody = new MetadataSearchDto @@ -27,12 +27,12 @@ protected override async Task> LoadAssets(Cancella }; var personInfo = await immichApi.SearchAssetsAsync(metadataBody, ct); - - total = personInfo.Assets.Total; - - personAssets.AddRange(personInfo.Assets.Items); + + var items = personInfo.Assets.Items; + returned = items.Count; + personAssets.AddRange(items); page++; - } while (total == batchSize); + } while (returned == batchSize && !ct.IsCancellationRequested); } return personAssets; diff --git a/ImmichFrame.Core/Logic/PoolConfiguration.cs b/ImmichFrame.Core/Logic/PoolConfiguration.cs new file mode 100644 index 00000000..dc038a52 --- /dev/null +++ b/ImmichFrame.Core/Logic/PoolConfiguration.cs @@ -0,0 +1,21 @@ +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Helpers; +using ImmichFrame.Core.Interfaces; +using ImmichFrame.Core.Logic.Pool; + +namespace ImmichFrame.Core.Logic; + +public class PoolConfiguration +{ + public ImmichApi ImmichApi { get; } + public ApiCache ApiCache { get; } + + public PoolConfiguration(IAccountSettings accountSettings, IGeneralSettings generalSettings, IHttpClientFactory httpClientFactory) + { + var httpClient = httpClientFactory.CreateClient(); + httpClient.UseApiKey(accountSettings.ApiKey); + + ImmichApi = new ImmichApi(accountSettings.ImmichServerUrl, httpClient); + ApiCache = new ApiCache(TimeSpan.FromHours(generalSettings.RefreshAlbumPeopleInterval)); + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs index 4961555b..464358d9 100644 --- a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs +++ b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs @@ -1,138 +1,45 @@ using ImmichFrame.Core.Api; -using ImmichFrame.Core.Exceptions; using ImmichFrame.Core.Helpers; using ImmichFrame.Core.Interfaces; using ImmichFrame.Core.Logic.Pool; namespace ImmichFrame.Core.Logic; -public class PooledImmichFrameLogic : IAccountImmichFrameLogic +public class PooledImmichFrameLogic : LogicPoolAdapter, IAccountImmichFrameLogic { - private readonly IGeneralSettings _generalSettings; - private readonly IApiCache _apiCache; - private readonly IAssetPool _pool; private readonly ImmichApi _immichApi; - private readonly string _downloadLocation = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ImageCache"); + public IAccountSettings AccountSettings { get; } - public PooledImmichFrameLogic(IAccountSettings accountSettings, IGeneralSettings generalSettings, IHttpClientFactory httpClientFactory) + public PooledImmichFrameLogic(IGeneralSettings generalSettings, IAccountSettings accountSettings, PoolConfiguration poolConfiguration, IHttpClientFactory httpClientFactory) + : base(BuildPool(accountSettings, poolConfiguration), poolConfiguration.ImmichApi, generalSettings.Webhook) { - _generalSettings = generalSettings; - - var httpClient = httpClientFactory.CreateClient("ImmichApiAccountClient"); + _immichApi = poolConfiguration.ImmichApi; AccountSettings = accountSettings; - - httpClient.UseApiKey(accountSettings.ApiKey); - _immichApi = new ImmichApi(accountSettings.ImmichServerUrl, httpClient); - - _apiCache = new ApiCache(RefreshInterval(generalSettings.RefreshAlbumPeopleInterval)); - _pool = BuildPool(accountSettings); } - private static TimeSpan RefreshInterval(int hours) - => hours > 0 ? TimeSpan.FromHours(hours) : TimeSpan.FromMilliseconds(1); - - public IAccountSettings AccountSettings { get; } - - private IAssetPool BuildPool(IAccountSettings accountSettings) + private static IAssetPool BuildPool(IAccountSettings accountSettings, PoolConfiguration poolConfiguration) { if (!accountSettings.ShowFavorites && !accountSettings.ShowMemories && !accountSettings.Albums.Any() && !accountSettings.People.Any()) { - return new AllAssetsPool(_apiCache, _immichApi, accountSettings); + return new AllAssetsPool(poolConfiguration.ApiCache, poolConfiguration.ImmichApi, accountSettings); } var pools = new List(); if (accountSettings.ShowFavorites) - pools.Add(new FavoriteAssetsPool(_apiCache, _immichApi, accountSettings)); + pools.Add(new FavoriteAssetsPool(poolConfiguration.ApiCache, poolConfiguration.ImmichApi, accountSettings)); if (accountSettings.ShowMemories) - pools.Add(new MemoryAssetsPool(_immichApi, accountSettings)); + pools.Add(new MemoryAssetsPool(poolConfiguration.ImmichApi, accountSettings)); if (accountSettings.Albums.Any()) - pools.Add(new AlbumAssetsPool(_apiCache, _immichApi, accountSettings)); + pools.Add(new AlbumAssetsPool(poolConfiguration.ApiCache, poolConfiguration.ImmichApi, accountSettings)); if (accountSettings.People.Any()) - pools.Add(new PersonAssetsPool(_apiCache, _immichApi, accountSettings)); + pools.Add(new PersonAssetsPool(poolConfiguration.ApiCache, poolConfiguration.ImmichApi, accountSettings)); return new MultiAssetPool(pools); } - public async Task GetNextAsset() - { - return (await _pool.GetAssets(1)).FirstOrDefault(); - } - - public Task> GetAssets() - { - return _pool.GetAssets(25); - } - - public Task GetAssetInfoById(Guid assetId) => _immichApi.GetAssetInfoAsync(assetId, null); - - public async Task> GetAlbumInfoById(Guid assetId) => await _immichApi.GetAllAlbumsAsync(assetId, null); - - public Task GetTotalAssets() => _pool.GetAssetCount(); - - public async Task<(string fileName, string ContentType, Stream fileStream)> GetImage(Guid id) - { -// Check if the image is already downloaded - if (_generalSettings.DownloadImages) - { - if (!Directory.Exists(_downloadLocation)) - { - Directory.CreateDirectory(_downloadLocation); - } - - var file = Directory.GetFiles(_downloadLocation) - .FirstOrDefault(x => Path.GetFileNameWithoutExtension(x) == id.ToString()); - - if (!string.IsNullOrWhiteSpace(file)) - { - if (_generalSettings.RenewImagesDuration > (DateTime.UtcNow - File.GetCreationTimeUtc(file)).Days) - { - var fs = File.OpenRead(file); - - var ex = Path.GetExtension(file); - - return (Path.GetFileName(file), $"image/{ex}", fs); - } - - File.Delete(file); - } - } - - var data = await _immichApi.ViewAssetAsync(id, string.Empty, AssetMediaSize.Preview); - - if (data == null) - throw new AssetNotFoundException($"Asset {id} was not found!"); - - var contentType = ""; - if (data.Headers.ContainsKey("Content-Type")) - { - contentType = data.Headers["Content-Type"].FirstOrDefault() ?? ""; - } - - var ext = contentType.ToLower() == "image/webp" ? "webp" : "jpeg"; - var fileName = $"{id}.{ext}"; - - if (_generalSettings.DownloadImages) - { - var stream = data.Stream; - - var filePath = Path.Combine(_downloadLocation, fileName); - - // save to folder - var fs = File.Create(filePath); - await stream.CopyToAsync(fs); - fs.Position = 0; - return (Path.GetFileName(filePath), contentType, fs); - } - - return (fileName, contentType, data.Stream); - } - - public Task SendWebhookNotification(IWebhookNotification notification) => - WebhookHelper.SendWebhookNotification(notification, _generalSettings.Webhook); - public override string ToString() => $"Account Pool [{_immichApi.BaseUrl}]"; } \ No newline at end of file diff --git a/ImmichFrame.WebApi.Tests/Controllers/AssetControllerTests.cs b/ImmichFrame.WebApi.Tests/Controllers/AssetControllerTests.cs index 112f06fa..86d4ed99 100644 --- a/ImmichFrame.WebApi.Tests/Controllers/AssetControllerTests.cs +++ b/ImmichFrame.WebApi.Tests/Controllers/AssetControllerTests.cs @@ -158,12 +158,14 @@ public async Task GetRandomImage_ReturnsImageFromMockServer() Content = new StringContent(jsonResponse) }); - // Setup for ViewAssetAsync (thumbnail) + // Setup for ViewAssetAsync (asset images) - catch all asset view requests var mockImageData = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }; // Minimal JPEG _mockHttpMessageHandler.Protected() .Setup>( "SendAsync", - ItExpr.Is(req => req.RequestUri!.ToString().Contains("/thumbnail")), + ItExpr.Is(req => + req.RequestUri!.ToString().Contains("/assets/") || + req.RequestUri!.ToString().Contains("/thumbnail")), ItExpr.IsAny() ) .ReturnsAsync(() => new HttpResponseMessage @@ -172,6 +174,23 @@ public async Task GetRandomImage_ReturnsImageFromMockServer() Content = new ByteArrayContent(mockImageData) }); + // Catch-all setup for any other requests to mock-immich-server.com that aren't already handled + _mockHttpMessageHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.RequestUri!.Host == "mock-immich-server.com" && + !req.RequestUri!.ToString().Contains("/search/metadata") && + !req.RequestUri!.ToString().Contains("/assets/") && + !req.RequestUri!.ToString().Contains("/thumbnail")), + ItExpr.IsAny() + ) + .ReturnsAsync(() => new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json") + }); + var client = _factory.CreateClient(); // Act diff --git a/ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj b/ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj index 4cdc624b..59e51156 100644 --- a/ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj +++ b/ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs b/ImmichFrame.WebApi.Tests/Logic/MemoryAssetsPoolTests.cs similarity index 63% rename from ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs rename to ImmichFrame.WebApi.Tests/Logic/MemoryAssetsPoolTests.cs index 0dba5467..c2e094b0 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs +++ b/ImmichFrame.WebApi.Tests/Logic/MemoryAssetsPoolTests.cs @@ -1,15 +1,14 @@ -using NUnit.Framework; -using Moq; using ImmichFrame.Core.Api; using ImmichFrame.Core.Interfaces; +using ImmichFrame.Core.Logic; using ImmichFrame.Core.Logic.Pool; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using System.Threading; +using ImmichFrame.Core.Tests.Logic.Pool; +using ImmichFrame.WebApi.Controllers; +using ImmichFrame.WebApi.Models; +using Moq; +using NUnit.Framework; -namespace ImmichFrame.Core.Tests.Logic.Pool; +namespace ImmichFrame.WebApi.Tests.Logic; [TestFixture] public class MemoryAssetsPoolTests @@ -17,17 +16,20 @@ public class MemoryAssetsPoolTests private Mock _mockImmichApi; private Mock _mockAccountSettings; private MemoryAssetsPool _memoryAssetsPool; + private IImmichFrameLogic _accountLogic; + [SetUp] public void Setup() { - _mockImmichApi = new Mock(null, null); // Base constructor requires ILogger, IHttpClientFactory, IOptions, pass null + _mockImmichApi = new Mock(null!, null!); // Base constructor requires ILogger, IHttpClientFactory, IOptions, pass null _mockAccountSettings = new Mock(); _memoryAssetsPool = new MemoryAssetsPool(_mockImmichApi.Object, _mockAccountSettings.Object); + _accountLogic = new LogicPoolAdapter(_memoryAssetsPool, _mockImmichApi.Object, null); } - private List CreateSampleAssets(int count, bool withExif, int yearCreated) + private List CreateSampleAssets(int count, bool withExif, DateTime created) { var assets = new List(); for (int i = 0; i < count; i++) @@ -37,27 +39,33 @@ private List CreateSampleAssets(int count, bool withExif, int Id = Guid.NewGuid().ToString(), OriginalPath = $"/path/to/image{i}.jpg", Type = AssetTypeEnum.IMAGE, - ExifInfo = withExif ? new ExifResponseDto { DateTimeOriginal = new DateTime(yearCreated, 1, 1) } : null, + ExifInfo = withExif ? new ExifResponseDto { DateTimeOriginal = created } : null, People = new List() }; assets.Add(asset); } + return assets; } private List CreateSampleMemories(int memoryCount, int assetsPerMemory, bool withExifInAssets, int memoryYear) { + var memoryAt = DateTimeOffset.Now; + memoryAt = memoryAt.AddYears(memoryYear - memoryAt.Year); + var memories = new List(); for (int i = 0; i < memoryCount; i++) { var memory = new MemoryResponseDto { Id = $"Memory {i}", - Assets = CreateSampleAssets(assetsPerMemory, withExifInAssets, memoryYear), + Assets = CreateSampleAssets(assetsPerMemory, withExifInAssets, new DateTime(memoryYear, 1, 1)), + MemoryAt = memoryAt, Data = new OnThisDayDto { Year = memoryYear } }; memories.Add(memory); } + return memories; } @@ -178,6 +186,90 @@ public async Task LoadAssets_AggregatesAssetsFromMultipleMemories() Assert.That(loadedAssets, Is.Not.Null); Assert.That(loadedAssets.Count(), Is.EqualTo(4)); // 2 memories * 2 assets _mockImmichApi.VerifyAll(); + } + + [Test] + public async Task LoadAssets_ReturnsAssetsInChronologicalMemoryOrder() + { + // Arrange + var memories = new List + { + CreateSampleMemories(1, 1, true, DateTime.Now.Year - 2).First(), // 2 years ago + CreateSampleMemories(1, 1, true, DateTime.Now.Year - 1).First() // 1 year ago + }; + + // Reverse memories to ensure they are not in order by default + memories.Reverse(); + + _mockImmichApi.Setup(x => x.SearchMemoriesAsync(It.IsAny(), null, null, null, It.IsAny())) + .ReturnsAsync(memories); + + // Act + var loadedAssets = await _memoryAssetsPool.GetAssets(2, CancellationToken.None); // Trigger load + + // Assert + Assert.That(loadedAssets, Is.Not.Null); + Assert.That(loadedAssets.Count(), Is.EqualTo(2)); + // Expecting assets to be sorted by memory date, oldest first + Assert.That(loadedAssets.First().ExifInfo.Description, Is.EqualTo("2 years ago")); + Assert.That(loadedAssets.Last().ExifInfo.Description, Is.EqualTo("1 year ago")); + } + + [Test] + [Repeat(10)] // In case it accidentally passes due to random order + public async Task LoadAssetsFromApi_ReturnsAssetsInChronologicalMemoryOrder() + { + // Arrange + var memories = new List + { + CreateSampleMemories(1, 1, true, DateTime.Now.Year - 2).First(), // 2 years ago + CreateSampleMemories(1, 1, true, DateTime.Now.Year - 1).First() // 1 year ago + }; + + // Shuffle memories to ensure they are not in order by default + memories = memories.OrderBy(x => Guid.NewGuid()).ToList(); + + _mockImmichApi.Setup(x => x.SearchMemoriesAsync(It.IsAny(), null, null, null, It.IsAny())) + .ReturnsAsync(memories); + + // Act - test the memory pool directly since that's what we're testing + var loadedAssets = await _memoryAssetsPool.GetAssets(2, CancellationToken.None); + + // Assert + Assert.That(loadedAssets, Is.Not.Null); + Assert.That(loadedAssets.Count(), Is.EqualTo(2)); + // Expecting assets to be sorted by memory date, oldest first + Assert.That(loadedAssets.First().ExifInfo.Description, Is.EqualTo("2 years ago")); + Assert.That(loadedAssets.Last().ExifInfo.Description, Is.EqualTo("1 year ago")); + } + + [Test] + public async Task LoadAssetsFromApi_ReturnsAssetsInChronologicalMemoryOrderOverMultipleCalls() + { + // Arrange + var memories = new List + { + CreateSampleMemories(1, 1, true, DateTime.Now.Year - 2).First(), // 2 years ago + CreateSampleMemories(1, 1, true, DateTime.Now.Year - 1).First() // 1 year ago + }; + + // Shuffle memories to ensure they are not in order by default + memories = memories.OrderBy(x => Guid.NewGuid()).ToList(); + + _mockImmichApi.Setup(x => x.SearchMemoriesAsync(It.IsAny(), null, null, null, It.IsAny())) + .ReturnsAsync(memories); + + + // Act + var loadedAsset1 = await _accountLogic.GetNextAsset(); // Trigger load + var loadedAsset2 = await _accountLogic.GetNextAsset(); + + // Assert + Assert.That(loadedAsset1, Is.Not.Null); + Assert.That(loadedAsset2, Is.Not.Null); + // Expecting assets to be sorted by memory date, oldest first + Assert.That(loadedAsset1.ExifInfo.Description, Is.EqualTo("2 years ago")); + Assert.That(loadedAsset2.ExifInfo.Description, Is.EqualTo("1 year ago")); } -} +} \ No newline at end of file diff --git a/ImmichFrame.WebApi/Program.cs b/ImmichFrame.WebApi/Program.cs index bfec88d2..656ecffb 100644 --- a/ImmichFrame.WebApi/Program.cs +++ b/ImmichFrame.WebApi/Program.cs @@ -61,8 +61,8 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ builder.Services.AddHttpClient(); // Ensures IHttpClientFactory is available builder.Services.AddTransient>(srv => - account => ActivatorUtilities.CreateInstance(srv, account)); - + account => + ActivatorUtilities.CreateInstance(srv, account, ActivatorUtilities.CreateInstance(srv, account))); builder.Services.AddSingleton(); builder.Services.AddControllers();